diff --git a/src/components/settings/VacationSettings.tsx b/src/components/settings/VacationSettings.tsx index 4af40e77..42772a56 100644 --- a/src/components/settings/VacationSettings.tsx +++ b/src/components/settings/VacationSettings.tsx @@ -15,6 +15,8 @@ import { useLocaleStore } from '../../stores/locale-store'; import { useSettingsStore } from '../../stores/settings-store'; import { isValidEmail } from '../../lib/recipients'; import { ownDomains } from '../../lib/sieve/vacation-audience'; +import { vacationForwardAddress } from '../../lib/sieve/vacation-forward'; +import { toUnicodeEmail } from '../../lib/idn'; import type { VacationForward } from '../../lib/sieve/types'; import { htmlToPlainText } from '../../lib/compose-html'; import { stripDangerousTags, escapeHtml } from '../../lib/email-html'; @@ -65,7 +67,8 @@ export function VacationSettings() { // Seed the editor once per load; RichTextEditor only reads initialHtml on mount. const [editorSeed, setEditorSeed] = useState(store.htmlBody || ''); const [forwardEnabled, setForwardEnabled] = useState(store.forward?.enabled ?? false); - const [forwardTo, setForwardTo] = useState(store.forward?.to ?? ''); + // Shown with its domain in Unicode, as it was typed. + const [forwardTo, setForwardTo] = useState(toUnicodeEmail(store.forward?.to ?? '')); const [forwardKeep, setForwardKeep] = useState(store.forward?.keepCopy ?? false); const [audienceOnly, setAudienceOnly] = useState(store.audience?.only ?? 'all'); // "Internal" are the account's own domains, as its identities give them. @@ -92,7 +95,7 @@ export function VacationSettings() { setHtmlBody(store.htmlBody || ''); setEditorSeed(store.htmlBody || ''); setForwardEnabled(store.forward?.enabled ?? false); - setForwardTo(store.forward?.to ?? ''); + setForwardTo(toUnicodeEmail(store.forward?.to ?? '')); setForwardKeep(store.forward?.keepCopy ?? false); setAudienceOnly(store.audience?.only ?? 'all'); }, [ @@ -115,7 +118,8 @@ export function VacationSettings() { // Forwarding as a save stores it: an address that is off is kept for next // time if it is usable, and dropped otherwise. - const forwardAddress = forwardTo.trim(); + // Checked and stored with a Unicode domain in ASCII form (webmail #1100). + const forwardAddress = vacationForwardAddress(forwardTo); const forwardInvalid = store.forwardAvailable && forwardEnabled && !isValidEmail(forwardAddress); const forwardSettings: VacationForwardSettings | null | undefined = !store.forwardAvailable ? undefined diff --git a/src/lib/sieve/__tests__/vacation-forward-idn.test.ts b/src/lib/sieve/__tests__/vacation-forward-idn.test.ts new file mode 100644 index 00000000..68bd407a --- /dev/null +++ b/src/lib/sieve/__tests__/vacation-forward-idn.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { generateScript } from '../generator'; +import { isValidVacationForward, vacationForwardAddress } from '../vacation-forward'; + +// A forward address with a Unicode domain went into the Sieve redirect as +// typed; the server needs the ASCII (xn--) form (bulwarkmail/webmail#1100). + +describe('vacation forwarding to a Unicode domain', () => { + it('writes the redirect with the domain in ASCII form', () => { + const to = vacationForwardAddress(' Ich@bücher.de '); + expect(to).toBe('Ich@xn--bcher-kva.de'); + const forward = { enabled: true, to, keepCopy: false }; + expect(isValidVacationForward(forward)).toBe(true); + const script = generateScript([], undefined, { includeVacation: true, vacationForward: forward }); + expect(script).toContain('redirect "Ich@xn--bcher-kva.de";'); + expect(script).not.toContain('bücher'); + // ASCII addresses stay as they are. + expect(vacationForwardAddress('a@example.org')).toBe('a@example.org'); + }); + + // No RN renderer in the test env: check the settings page's wiring. + it('is what the out-of-office page checks and saves', () => { + const page = fs.readFileSync(path.resolve(__dirname, '../../../components/settings/VacationSettings.tsx'), 'utf8'); + expect(page).toMatch(/const forwardAddress = vacationForwardAddress\(forwardTo\);/); + expect(page).toMatch(/\{ enabled: forwardEnabled, to: forwardAddress, keepCopy: forwardKeep \}/); + }); +}); diff --git a/src/lib/sieve/vacation-forward.ts b/src/lib/sieve/vacation-forward.ts index 1c1d6e8c..bce8bcb6 100644 --- a/src/lib/sieve/vacation-forward.ts +++ b/src/lib/sieve/vacation-forward.ts @@ -1,6 +1,7 @@ import type { VacationForward } from './types'; import { isValidEmail } from '../recipients'; import { isPeriodBoundary } from './period'; +import { toAsciiEmail } from '../idn'; /** * Forwarding from the vacation card (see VacationForward). The generator @@ -15,6 +16,15 @@ export const VACATION_FORWARD_MARKER = '# Vacation forwarding'; */ export const VACATION_FORWARD_MARKER_RE = /^[ \t]*# Vacation forwarding[ \t]*$/m; +/** + * The forwarding address as typed, the way the Sieve redirect needs it: a + * Unicode domain in its ASCII (`xn--`) form, which the server can deliver + * to (webmail #1100). + */ +export function vacationForwardAddress(typed: string): string { + return toAsciiEmail(typed.trim()); +} + /** Whether `value` is forwarding Bulwark can write back as it reads. */ export function isValidVacationForward(value: unknown): value is VacationForward { if (!value || typeof value !== 'object') return false;