Files

165 lines
5.9 KiB
TypeScript

/**
* Internationalized domain names (IDN) in email addresses.
*
* Stalwart keeps domains in their ASCII (punycode, `xn--`) form and maps a
* Unicode domain to it on lookup, so the webmail does the same: whatever the
* user types is converted to ASCII before it is sent anywhere, and the ASCII
* form is turned back into Unicode only for display.
*
* ToASCII goes through the WHATWG URL parser (UTS #46, the mapping browsers,
* Node and Stalwart's `idna` crate all use). There is no built-in ToUnicode,
* so decoding is RFC 3492 below, and a decoded domain is only shown when it
* converts back to exactly the ASCII form it came from.
*/
// Characters that would make the URL parser read the input as something other
// than a bare host (path, query, port, userinfo, percent-escapes, IPv6).
const NON_HOST_CHARS = /[\s/\\?#@:%[\]]/;
const ACE_LABEL = /(^|\.)xn--/i;
const NON_ASCII = /[^\p{ASCII}]/u;
/**
* The ASCII form of a domain (`bücher.de` -> `xn--bcher-kva.de`), lowercased
* and UTS #46 mapped. Null when the input is not a valid host name.
*/
export function toAsciiDomain(domain: string): string | null {
const trimmed = domain.trim();
if (!trimmed || NON_HOST_CHARS.test(trimmed)) return null;
try {
return new URL(`http://${trimmed}`).hostname || null;
} catch {
return null;
}
}
/**
* The address with its domain in ASCII form. Only a non-ASCII domain is
* touched, so ASCII addresses and bare login names come back unchanged; the
* local part is never changed (it is case-sensitive to the server).
*/
export function toAsciiEmail(address: string): string {
const at = address.lastIndexOf('@');
if (at <= 0) return address;
const domain = address.slice(at + 1);
if (!NON_ASCII.test(domain)) return address;
const ascii = toAsciiDomain(domain);
return ascii ? `${address.slice(0, at)}@${ascii}` : address;
}
/**
* The Unicode form of a domain for display (`xn--bcher-kva.de` -> `bücher.de`).
* Returns the input unchanged when it has no `xn--` label or does not decode
* to a valid, canonical domain.
*/
export function toUnicodeDomain(domain: string): string {
if (!ACE_LABEL.test(domain)) return domain;
// Punycode keeps the case of its ASCII letters, so decode the lowercased
// domain or `XN--BCHER-KVA.DE` would come out as `BüCHER.DE`.
const lower = domain.toLowerCase();
const decoded: string[] = [];
for (const label of lower.split('.')) {
if (!label.startsWith('xn--')) {
decoded.push(label);
continue;
}
const unicode = decodePunycode(label.slice(4));
if (unicode === null) return domain;
decoded.push(unicode);
}
const unicode = decoded.join('.');
return toAsciiDomain(unicode) === lower ? unicode : domain;
}
/** The address with its domain in Unicode form, for display. */
export function toUnicodeEmail(address: string): string {
const at = address.lastIndexOf('@');
if (at <= 0) return address;
const domain = address.slice(at + 1);
const unicode = toUnicodeDomain(domain);
return unicode === domain ? address : `${address.slice(0, at)}@${unicode}`;
}
/**
* `toUnicodeEmail` for an address in a message, applied only when it is on
* one of the user's own domains (taken from `ownAddresses`). Anyone else's IDN
* domain keeps its xn-- form: a Unicode domain can be drawn to look like a
* familiar one, and the ASCII form gives such a look-alike away.
*/
export function toUnicodeEmailOnOwnDomains(
address: string,
ownAddresses: readonly (string | null | undefined)[],
): string {
const domain = address.slice(address.lastIndexOf('@') + 1).toLowerCase();
if (!ACE_LABEL.test(domain)) return address;
const own = ownAddresses.some((own) => {
if (!own) return false;
const ascii = toAsciiEmail(own);
return ascii.slice(ascii.lastIndexOf('@') + 1).toLowerCase() === domain;
});
return own ? toUnicodeEmail(address) : address;
}
// RFC 3492 section 5 parameters.
const BASE = 36;
const T_MIN = 1;
const T_MAX = 26;
const SKEW = 38;
const DAMP = 700;
const INITIAL_BIAS = 72;
const INITIAL_N = 0x80;
const MAX_INT = 0x7fffffff;
function adapt(delta: number, numPoints: number, firstTime: boolean): number {
let d = firstTime ? Math.floor(delta / DAMP) : Math.floor(delta / 2);
d += Math.floor(d / numPoints);
let k = 0;
while (d > ((BASE - T_MIN) * T_MAX) / 2) {
d = Math.floor(d / (BASE - T_MIN));
k += BASE;
}
return k + Math.floor(((BASE - T_MIN + 1) * d) / (d + SKEW));
}
function digitValue(code: number): number {
if (code >= 0x30 && code <= 0x39) return code - 0x16; // 0-9 -> 26-35
if (code >= 0x41 && code <= 0x5a) return code - 0x41; // A-Z -> 0-25
if (code >= 0x61 && code <= 0x7a) return code - 0x61; // a-z -> 0-25
return BASE;
}
/** RFC 3492 section 6.2 decoding of one label (without `xn--`). */
function decodePunycode(input: string): string | null {
const output: number[] = [];
const basic = Math.max(input.lastIndexOf('-'), 0);
for (let j = 0; j < basic; j++) {
const code = input.charCodeAt(j);
if (code >= 0x80) return null;
output.push(code);
}
let n = INITIAL_N;
let bias = INITIAL_BIAS;
let i = 0;
for (let index = basic > 0 ? basic + 1 : 0; index < input.length;) {
const oldI = i;
for (let w = 1, k = BASE; ; k += BASE) {
if (index >= input.length) return null;
const digit = digitValue(input.charCodeAt(index++));
if (digit >= BASE || digit > Math.floor((MAX_INT - i) / w)) return null;
i += digit * w;
const t = k <= bias ? T_MIN : k >= bias + T_MAX ? T_MAX : k - bias;
if (digit < t) break;
if (w > Math.floor(MAX_INT / (BASE - t))) return null;
w *= BASE - t;
}
const length = output.length + 1;
bias = adapt(i - oldI, length, oldI === 0);
if (Math.floor(i / length) > MAX_INT - n) return null;
n += Math.floor(i / length);
i %= length;
if (n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff)) return null;
output.splice(i++, 0, n);
}
return String.fromCodePoint(...output);
}