From 4932764a666d8aeed07a799eb51faebc5b2c395c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:14:55 +0100 Subject: [PATCH] fix: send to addresses on internationalized domains (bulwarkmail/webmail#1100) --- src/components/IdentitySheet.tsx | 3 +- .../__tests__/idn-recipients-wiring.test.ts | 34 +++++++++++++++++++ src/components/email/MessageHeader.tsx | 12 ++++--- src/components/email/ThreadMessageCard.tsx | 5 ++- src/lib/__tests__/recipients.test.ts | 23 +++++++++++++ src/lib/recipients.ts | 22 ++++++++++-- src/lib/use-own-domain-address.ts | 23 +++++++++++++ src/screens/ComposeScreen.tsx | 14 +++++--- 8 files changed, 123 insertions(+), 13 deletions(-) create mode 100644 src/components/__tests__/idn-recipients-wiring.test.ts create mode 100644 src/lib/use-own-domain-address.ts diff --git a/src/components/IdentitySheet.tsx b/src/components/IdentitySheet.tsx index a4712c83..a14f7bbf 100644 --- a/src/components/IdentitySheet.tsx +++ b/src/components/IdentitySheet.tsx @@ -11,6 +11,7 @@ import type { Identity } from '../api/types'; import { useLocaleStore } from '../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; import { useSheetInsets } from '../lib/use-layout'; +import { toUnicodeEmail } from '../lib/idn'; interface IdentitySheetProps { visible: boolean; @@ -97,7 +98,7 @@ export function IdentitySheet({ )} - {identity.email} + {toUnicodeEmail(identity.email)} {isSelected && ( diff --git a/src/components/__tests__/idn-recipients-wiring.test.ts b/src/components/__tests__/idn-recipients-wiring.test.ts new file mode 100644 index 00000000..b137e655 --- /dev/null +++ b/src/components/__tests__/idn-recipients-wiring.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Addresses on internationalized domains (webmail #1100): the composer sends +// a domain typed in Unicode in its ASCII form, shows the From identity as +// written, and a sender on one of the user's own IDN domains reads as +// written in the reader. No React Native renderer in the test env, so this +// checks the wiring; outgoingAddress and toUnicodeEmailOnOwnDomains have unit +// tests in recipients.test.ts and idn.test.ts. + +const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8'); + +describe('IDN addresses in the composer and reader', () => { + it('builds the outgoing To, Cc and Bcc through outgoingAddress', () => { + const src = read('screens/ComposeScreen.tsx'); + expect(src).toMatch(/function toAddress\(r: \{ name\?: string; email: string \}\): EmailAddress \{\s*return outgoingAddress\(r\);/); + expect(src).toMatch(/to: expandRecipients\(finalTo\)\.map\(toAddress\)/); + }); + + it('shows the From identity with its domain as written', () => { + expect(read('screens/ComposeScreen.tsx')).toMatch(/const email = toUnicodeEmail\(from\.email\);/); + expect(read('components/IdentitySheet.tsx')).toMatch(/\{toUnicodeEmail\(identity\.email\)\}/); + }); + + it('formats the sender address in the reader through useOwnDomainAddress', () => { + const header = read('components/email/MessageHeader.tsx'); + expect(header).toMatch(/const fromEmail = from\?\.email \? formatAddress\(from\.email\) : '';/); + expect(header).toMatch(/\{from\?\.name \|\| fromEmail \|\| t\('email_viewer\.unknown_sender'/); + expect(header).not.toMatch(/\{from\.email\}<\/Text>/); + const card = read('components/email/ThreadMessageCard.tsx'); + expect(card).toMatch(/\{from\?\.name \|\| \(from\?\.email && formatAddress\(from\.email\)\) \|\|/); + }); +}); diff --git a/src/components/email/MessageHeader.tsx b/src/components/email/MessageHeader.tsx index 123e0e67..5a07caf9 100644 --- a/src/components/email/MessageHeader.tsx +++ b/src/components/email/MessageHeader.tsx @@ -21,6 +21,7 @@ import { isSmimeEmail } from '../../lib/smime'; import { resolveEmailTags } from '../../lib/thread-utils'; import { findVerificationCode, verificationCodeBodyText } from '../../lib/verification-code'; import { VerificationCodeChip } from './VerificationCodeChip'; +import { useOwnDomainAddress } from '../../lib/use-own-domain-address'; interface Props { email: Email; @@ -109,6 +110,9 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA const info = React.useMemo(() => headerInfo ?? deriveHeaderInfo(email), [headerInfo, email]); const from = email.from?.[0]; + // An address on one of the user's own IDN domains reads as written (#1100). + const formatAddress = useOwnDomainAddress(); + const fromEmail = from?.email ? formatAddress(from.email) : ''; const starred = !!email.keywords?.$flagged; const displayDate = emailDisplayDate(email); const spoofed = isAuthenticationSpoofed(info.auth); @@ -170,7 +174,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA onPress={from ? () => onAddressPress(from) : undefined} hitSlop={4} accessibilityRole="button" - accessibilityLabel={from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')} + accessibilityLabel={from?.name || fromEmail || t('email_viewer.unknown_sender', 'Unknown')} > onAddressPress(from) : undefined}> - {from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')} + {from?.name || fromEmail || t('email_viewer.unknown_sender', 'Unknown')} - {from?.name && from?.email ? ( - {from.email} + {from?.name && fromEmail ? ( + {fromEmail} ) : null} {verificationText && ( diff --git a/src/components/email/ThreadMessageCard.tsx b/src/components/email/ThreadMessageCard.tsx index 51c822ac..95b5daf4 100644 --- a/src/components/email/ThreadMessageCard.tsx +++ b/src/components/email/ThreadMessageCard.tsx @@ -12,6 +12,7 @@ import { emailDisplayDate, formatHeaderDate, formatHeaderTime } from '../../lib/ import { singleLine } from '../../lib/single-line'; import { cleanPreview } from '../../lib/preview-text'; import { useSenderWarning } from './use-sender-warning'; +import { useOwnDomainAddress } from '../../lib/use-own-domain-address'; interface Props extends MessageContentProps { expanded: boolean; @@ -46,6 +47,8 @@ export function ThreadMessageCard({ const starred = !!email.keywords?.$flagged; const date = emailDisplayDate(email); const senderWarning = useSenderWarning(expanded ? undefined : email); + // An address on one of the user's own IDN domains reads as written (#1100). + const formatAddress = useOwnDomainAddress(); if (!expanded) { return ( @@ -54,7 +57,7 @@ export function ThreadMessageCard({ - {from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')} + {from?.name || (from?.email && formatAddress(from.email)) || t('email_viewer.unknown_sender', 'Unknown')} {email.hasAttachment && } {formatHeaderDate(date, locale)} {formatHeaderTime(date, timeFormat, locale)} diff --git a/src/lib/__tests__/recipients.test.ts b/src/lib/__tests__/recipients.test.ts index d6790a7c..0fa42772 100644 --- a/src/lib/__tests__/recipients.test.ts +++ b/src/lib/__tests__/recipients.test.ts @@ -8,6 +8,7 @@ import { expandRecipients, splitPastedRecipients, parseMailtoUrl, + outgoingAddress, } from '../recipients'; describe('isValidEmail', () => { @@ -24,6 +25,28 @@ describe('isValidEmail', () => { expect(isValidEmail('')).toBe(false); expect(isValidEmail('noat')).toBe(false); }); + + it('accepts a domain written in Unicode when its ASCII form is valid (webmail #1100)', () => { + expect(isValidEmail('anna@bücher.de')).toBe(true); + expect(isValidEmail('ivan@пример.рф')).toBe(true); + expect(isValidEmail('anna@xn--bcher-kva.de')).toBe(true); + // Still no spaces, no non-ASCII local part, no bogus xn-- labels. + expect(isValidEmail('anna@bü cher.de')).toBe(false); + expect(isValidEmail('änna@bücher.de')).toBe(false); + expect(isValidEmail('anna@xn--zzzz-.de')).toBe(false); + expect(isValidEmail('anna@bücher.de\r\nBcc: x@y.z')).toBe(false); + }); +}); + +describe('outgoingAddress', () => { + it('sends a Unicode domain in its ASCII form and keeps the name', () => { + expect(outgoingAddress({ name: 'Anna', email: 'anna@bücher.de' })).toEqual({ name: 'Anna', email: 'anna@xn--bcher-kva.de' }); + expect(outgoingAddress({ email: 'ivan@пример.рф' })).toEqual({ email: 'ivan@xn--e1afmkfd.xn--p1ai' }); + }); + + it('leaves ASCII addresses and the local part alone', () => { + expect(outgoingAddress({ name: '', email: 'Anna.B@Example.org' })).toEqual({ email: 'Anna.B@Example.org' }); + }); }); describe('splitRecipients', () => { diff --git a/src/lib/recipients.ts b/src/lib/recipients.ts index f95dd8e6..7f05eeeb 100644 --- a/src/lib/recipients.ts +++ b/src/lib/recipients.ts @@ -4,6 +4,7 @@ // splitPastedRecipients, expandRecipients). All DOM-free. import { splitMailbox } from './rfc5322-mailbox'; +import { toAsciiEmail } from './idn'; /** * A composer recipient. Display name is optional; email is required - except @@ -19,14 +20,22 @@ export interface Recipient { /** * RFC 5322 compliant email validation with security enhancements. + * + * A domain written in Unicode (`anna@bücher.de`) counts when its ASCII form + * (`anna@xn--bcher-kva.de`) is valid; {@link outgoingAddress} sends that form + * (webmail #1100). The local part stays ASCII-only. */ export function isValidEmail(email: string): boolean { - // Length check - if (!email || email.length > 254) return false; + if (!email) return false; // Security: Block control characters and header injection if (/[\r\n\0<>]/.test(email)) return false; + email = toAsciiEmail(email); + + // Length check + if (email.length > 254) return false; + // RFC 5322 compliant regex (simplified but secure) const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; @@ -46,6 +55,15 @@ export function isValidEmail(email: string): boolean { return true; } +/** + * A recipient as it goes into the message: the address with its domain in + * ASCII (xn--) form, so a domain typed in Unicode reaches every server. + */ +export function outgoingAddress(r: { name?: string; email: string }): { name?: string; email: string } { + const email = toAsciiEmail(r.email); + return r.name ? { name: r.name, email } : { email }; +} + /** * True when the angle run that is open at `from` closes before the next one * opens. `from` is the index of the character under test (a separator, or the diff --git a/src/lib/use-own-domain-address.ts b/src/lib/use-own-domain-address.ts new file mode 100644 index 00000000..a60f2eeb --- /dev/null +++ b/src/lib/use-own-domain-address.ts @@ -0,0 +1,23 @@ +import React from 'react'; +import { useAuthStore } from '../stores/auth-store'; +import { useSettingsStore } from '../stores/settings-store'; +import { toUnicodeEmailOnOwnDomains } from './idn'; + +/** + * Formats an address from a message for display: on one of the signed-in + * user's own IDN domains it reads as written (`bücher.de`), anywhere else it + * stays as the message has it (see toUnicodeEmailOnOwnDomains; webmail + * hooks/use-own-domain-address.ts, #1100). + */ +export function useOwnDomainAddress(): (address: string) => string { + const username = useAuthStore((s) => s.username); + const identities = useSettingsStore((s) => s.identities); + const ownAddresses = React.useMemo( + () => [username, ...identities.map((i) => i.email)], + [username, identities], + ); + return React.useCallback( + (address: string) => toUnicodeEmailOnOwnDomains(address, ownAddresses), + [ownAddresses], + ); +} diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 02fc6bd6..dc67853d 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -60,8 +60,9 @@ import { import { htmlComposeBodyToPlainText, initialPlainTextMode, plainComposeBodyToHtml } from '../lib/compose-format'; import { buildQuoteHeader, formatQuoteDate, quoteHeaderLabels, type QuoteHeaderLabels } from '../lib/quote-header'; import { - isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, type Recipient as ParsedRecipient, + isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, outgoingAddress, type Recipient as ParsedRecipient, } from '../lib/recipients'; +import { toUnicodeEmail } from '../lib/idn'; import { findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity, } from '../lib/reply-identity'; @@ -188,8 +189,9 @@ function fromParsed(r: ParsedRecipient): Recipient { return { name: r.name ?? '', email: r.email, group: r.group }; } +// A domain typed in Unicode goes out in its ASCII form (webmail #1100). function toAddress(r: { name?: string; email: string }): EmailAddress { - return r.name ? { name: r.name, email: r.email } : { email: r.email }; + return outgoingAddress(r); } function chipIsValid(r: Recipient): boolean { @@ -2606,7 +2608,9 @@ export default function ComposeScreen({ route, navigation }: Props) { const fromDisplay = React.useMemo(() => { if (!primaryIdentity) return identityError ? t('email_composer.identity_unavailable', 'Identity unavailable') : t('common.loading', 'Loading...'); const { from } = senderAddress(primaryIdentity); - return from.name ? `${from.name} <${from.email}>` : from.email; + // An IDN domain arrives in its xn-- form; show it as written (#1100). + const email = toUnicodeEmail(from.email); + return from.name ? `${from.name} <${email}>` : email; }, [primaryIdentity, identityError, senderAddress, t]); const draftStatusText = @@ -3341,9 +3345,9 @@ export default function ComposeScreen({ route, navigation }: Props) { )} {!!primaryIdentity && ( - {t('identities.sub_address.preview_label', 'Preview:')} {tagDraft && !tagError + {t('identities.sub_address.preview_label', 'Preview:')} {toUnicodeEmail(tagDraft && !tagError ? generateSubAddress(primaryIdentity.email, tagDraft, subAddressDelimiter) - : primaryIdentity.email} + : primaryIdentity.email)} )}