mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
165 lines
5.9 KiB
TypeScript
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);
|
|
}
|