mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
fix: send to addresses on internationalized domains (bulwarkmail/webmail#1100)
This commit is contained in:
@@ -11,6 +11,7 @@ import type { Identity } from '../api/types';
|
|||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
import { useSheetInsets } from '../lib/use-layout';
|
import { useSheetInsets } from '../lib/use-layout';
|
||||||
|
import { toUnicodeEmail } from '../lib/idn';
|
||||||
|
|
||||||
interface IdentitySheetProps {
|
interface IdentitySheetProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -97,7 +98,7 @@ export function IdentitySheet({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text style={[styles.identityEmail, !identity.name && styles.identityEmailOnly]} numberOfLines={1}>
|
<Text style={[styles.identityEmail, !identity.name && styles.identityEmailOnly]} numberOfLines={1}>
|
||||||
{identity.email}
|
{toUnicodeEmail(identity.email)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
|
|||||||
@@ -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\)\) \|\|/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ import { isSmimeEmail } from '../../lib/smime';
|
|||||||
import { resolveEmailTags } from '../../lib/thread-utils';
|
import { resolveEmailTags } from '../../lib/thread-utils';
|
||||||
import { findVerificationCode, verificationCodeBodyText } from '../../lib/verification-code';
|
import { findVerificationCode, verificationCodeBodyText } from '../../lib/verification-code';
|
||||||
import { VerificationCodeChip } from './VerificationCodeChip';
|
import { VerificationCodeChip } from './VerificationCodeChip';
|
||||||
|
import { useOwnDomainAddress } from '../../lib/use-own-domain-address';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
email: Email;
|
email: Email;
|
||||||
@@ -109,6 +110,9 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
|||||||
|
|
||||||
const info = React.useMemo(() => headerInfo ?? deriveHeaderInfo(email), [headerInfo, email]);
|
const info = React.useMemo(() => headerInfo ?? deriveHeaderInfo(email), [headerInfo, email]);
|
||||||
const from = email.from?.[0];
|
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 starred = !!email.keywords?.$flagged;
|
||||||
const displayDate = emailDisplayDate(email);
|
const displayDate = emailDisplayDate(email);
|
||||||
const spoofed = isAuthenticationSpoofed(info.auth);
|
const spoofed = isAuthenticationSpoofed(info.auth);
|
||||||
@@ -170,7 +174,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
|||||||
onPress={from ? () => onAddressPress(from) : undefined}
|
onPress={from ? () => onAddressPress(from) : undefined}
|
||||||
hitSlop={4}
|
hitSlop={4}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')}
|
accessibilityLabel={from?.name || fromEmail || t('email_viewer.unknown_sender', 'Unknown')}
|
||||||
>
|
>
|
||||||
<SenderAvatar
|
<SenderAvatar
|
||||||
name={from?.name}
|
name={from?.name}
|
||||||
@@ -182,10 +186,10 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
|||||||
<View style={styles.info}>
|
<View style={styles.info}>
|
||||||
<Pressable onPress={from ? () => onAddressPress(from) : undefined}>
|
<Pressable onPress={from ? () => onAddressPress(from) : undefined}>
|
||||||
<Text style={styles.name} numberOfLines={1}>
|
<Text style={styles.name} numberOfLines={1}>
|
||||||
{from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')}
|
{from?.name || fromEmail || t('email_viewer.unknown_sender', 'Unknown')}
|
||||||
</Text>
|
</Text>
|
||||||
{from?.name && from?.email ? (
|
{from?.name && fromEmail ? (
|
||||||
<Text style={styles.email} numberOfLines={1}>{from.email}</Text>
|
<Text style={styles.email} numberOfLines={1}>{fromEmail}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{verificationText && (
|
{verificationText && (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { emailDisplayDate, formatHeaderDate, formatHeaderTime } from '../../lib/
|
|||||||
import { singleLine } from '../../lib/single-line';
|
import { singleLine } from '../../lib/single-line';
|
||||||
import { cleanPreview } from '../../lib/preview-text';
|
import { cleanPreview } from '../../lib/preview-text';
|
||||||
import { useSenderWarning } from './use-sender-warning';
|
import { useSenderWarning } from './use-sender-warning';
|
||||||
|
import { useOwnDomainAddress } from '../../lib/use-own-domain-address';
|
||||||
|
|
||||||
interface Props extends MessageContentProps {
|
interface Props extends MessageContentProps {
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
@@ -46,6 +47,8 @@ export function ThreadMessageCard({
|
|||||||
const starred = !!email.keywords?.$flagged;
|
const starred = !!email.keywords?.$flagged;
|
||||||
const date = emailDisplayDate(email);
|
const date = emailDisplayDate(email);
|
||||||
const senderWarning = useSenderWarning(expanded ? undefined : 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) {
|
if (!expanded) {
|
||||||
return (
|
return (
|
||||||
@@ -54,7 +57,7 @@ export function ThreadMessageCard({
|
|||||||
<View style={styles.collapsedInfo}>
|
<View style={styles.collapsedInfo}>
|
||||||
<View style={styles.collapsedTop}>
|
<View style={styles.collapsedTop}>
|
||||||
<Text style={[styles.collapsedName, unread && styles.unread]} numberOfLines={1}>
|
<Text style={[styles.collapsedName, unread && styles.unread]} numberOfLines={1}>
|
||||||
{from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')}
|
{from?.name || (from?.email && formatAddress(from.email)) || t('email_viewer.unknown_sender', 'Unknown')}
|
||||||
</Text>
|
</Text>
|
||||||
{email.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
|
{email.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
|
||||||
<Text style={styles.collapsedDate}>{formatHeaderDate(date, locale)} {formatHeaderTime(date, timeFormat, locale)}</Text>
|
<Text style={styles.collapsedDate}>{formatHeaderDate(date, locale)} {formatHeaderTime(date, timeFormat, locale)}</Text>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
expandRecipients,
|
expandRecipients,
|
||||||
splitPastedRecipients,
|
splitPastedRecipients,
|
||||||
parseMailtoUrl,
|
parseMailtoUrl,
|
||||||
|
outgoingAddress,
|
||||||
} from '../recipients';
|
} from '../recipients';
|
||||||
|
|
||||||
describe('isValidEmail', () => {
|
describe('isValidEmail', () => {
|
||||||
@@ -24,6 +25,28 @@ describe('isValidEmail', () => {
|
|||||||
expect(isValidEmail('<a@b.c>')).toBe(false);
|
expect(isValidEmail('<a@b.c>')).toBe(false);
|
||||||
expect(isValidEmail('noat')).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', () => {
|
describe('splitRecipients', () => {
|
||||||
|
|||||||
+20
-2
@@ -4,6 +4,7 @@
|
|||||||
// splitPastedRecipients, expandRecipients). All DOM-free.
|
// splitPastedRecipients, expandRecipients). All DOM-free.
|
||||||
|
|
||||||
import { splitMailbox } from './rfc5322-mailbox';
|
import { splitMailbox } from './rfc5322-mailbox';
|
||||||
|
import { toAsciiEmail } from './idn';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A composer recipient. Display name is optional; email is required - except
|
* 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.
|
* 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 {
|
export function isValidEmail(email: string): boolean {
|
||||||
// Length check
|
if (!email) return false;
|
||||||
if (!email || email.length > 254) return false;
|
|
||||||
|
|
||||||
// Security: Block control characters and header injection
|
// Security: Block control characters and header injection
|
||||||
if (/[\r\n\0<>]/.test(email)) return false;
|
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)
|
// 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])?)*$/;
|
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;
|
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
|
* 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
|
* opens. `from` is the index of the character under test (a separator, or the
|
||||||
|
|||||||
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -60,8 +60,9 @@ import {
|
|||||||
import { htmlComposeBodyToPlainText, initialPlainTextMode, plainComposeBodyToHtml } from '../lib/compose-format';
|
import { htmlComposeBodyToPlainText, initialPlainTextMode, plainComposeBodyToHtml } from '../lib/compose-format';
|
||||||
import { buildQuoteHeader, formatQuoteDate, quoteHeaderLabels, type QuoteHeaderLabels } from '../lib/quote-header';
|
import { buildQuoteHeader, formatQuoteDate, quoteHeaderLabels, type QuoteHeaderLabels } from '../lib/quote-header';
|
||||||
import {
|
import {
|
||||||
isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, type Recipient as ParsedRecipient,
|
isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, outgoingAddress, type Recipient as ParsedRecipient,
|
||||||
} from '../lib/recipients';
|
} from '../lib/recipients';
|
||||||
|
import { toUnicodeEmail } from '../lib/idn';
|
||||||
import {
|
import {
|
||||||
findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity,
|
findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity,
|
||||||
} from '../lib/reply-identity';
|
} from '../lib/reply-identity';
|
||||||
@@ -188,8 +189,9 @@ function fromParsed(r: ParsedRecipient): Recipient {
|
|||||||
return { name: r.name ?? '', email: r.email, group: r.group };
|
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 {
|
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 {
|
function chipIsValid(r: Recipient): boolean {
|
||||||
@@ -2606,7 +2608,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
const fromDisplay = React.useMemo(() => {
|
const fromDisplay = React.useMemo(() => {
|
||||||
if (!primaryIdentity) return identityError ? t('email_composer.identity_unavailable', 'Identity unavailable') : t('common.loading', 'Loading...');
|
if (!primaryIdentity) return identityError ? t('email_composer.identity_unavailable', 'Identity unavailable') : t('common.loading', 'Loading...');
|
||||||
const { from } = senderAddress(primaryIdentity);
|
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]);
|
}, [primaryIdentity, identityError, senderAddress, t]);
|
||||||
|
|
||||||
const draftStatusText =
|
const draftStatusText =
|
||||||
@@ -3341,9 +3345,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
)}
|
)}
|
||||||
{!!primaryIdentity && (
|
{!!primaryIdentity && (
|
||||||
<Text style={styles.modalLabel} numberOfLines={1}>
|
<Text style={styles.modalLabel} numberOfLines={1}>
|
||||||
{t('identities.sub_address.preview_label', 'Preview:')} {tagDraft && !tagError
|
{t('identities.sub_address.preview_label', 'Preview:')} {toUnicodeEmail(tagDraft && !tagError
|
||||||
? generateSubAddress(primaryIdentity.email, tagDraft, subAddressDelimiter)
|
? generateSubAddress(primaryIdentity.email, tagDraft, subAddressDelimiter)
|
||||||
: primaryIdentity.email}
|
: primaryIdentity.email)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user