fix: forward out-of-office mail to a unicode domain in its ascii form (bulwarkmail/webmail#1100)

This commit is contained in:
Linus Rath
2026-10-31 05:38:43 +01:00
parent 96b163a117
commit ecf1f3ecd9
3 changed files with 46 additions and 3 deletions
+7 -3
View File
@@ -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<AudienceChoice>(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
@@ -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 \}/);
});
});
+10
View File
@@ -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;