mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 16:07:32 +00:00
251 lines
8.6 KiB
TypeScript
251 lines
8.6 KiB
TypeScript
/**
|
|
* Internationalized domain names (IDN) in email addresses (webmail
|
|
* lib/idn.ts, #1100).
|
|
*
|
|
* Stalwart keeps domains in their ASCII (punycode, `xn--`) form and maps a
|
|
* Unicode domain to it on lookup, so the app 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.
|
|
*
|
|
* The webmail converts through the WHATWG URL parser, which React Native's
|
|
* URL doesn't implement for hosts, so ToASCII is done here: NFKC and
|
|
* lowercasing (the UTS #46 mapping for everything a mail domain contains in
|
|
* practice), then RFC 3492 for each non-ASCII label. A decoded domain is
|
|
* only shown when it converts back to exactly the ASCII form it came from.
|
|
*/
|
|
|
|
// Characters that make the input something other than a bare host (path,
|
|
// query, port, userinfo, percent-escapes, IPv6) and the URL parser's other
|
|
// forbidden host code points.
|
|
const NON_HOST_CHARS = /[\s/\\?#@:%[\]<>^|]/;
|
|
const ACE_LABEL = /(^|\.)xn--/i;
|
|
const NON_ASCII = /[^\x00-\x7f]/;
|
|
// UTS #46 label separators: full stop, ideographic, fullwidth, halfwidth.
|
|
const SEPARATORS = /[.。.。]/;
|
|
|
|
function mapLabel(label: string): string {
|
|
let mapped = label;
|
|
try {
|
|
mapped = mapped.normalize('NFKC');
|
|
} catch {
|
|
// An engine without normalize(): the label as typed.
|
|
}
|
|
return mapped.toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* The ASCII form of a domain (`bücher.de` -> `xn--bcher-kva.de`), lowercased
|
|
* and 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;
|
|
const labels = trimmed.split(SEPARATORS);
|
|
const out: string[] = [];
|
|
for (let i = 0; i < labels.length; i++) {
|
|
const label = mapLabel(labels[i]);
|
|
if (!label) {
|
|
// Only a trailing dot may leave an empty label.
|
|
if (i === labels.length - 1 && i > 0) {
|
|
out.push('');
|
|
continue;
|
|
}
|
|
return null;
|
|
}
|
|
if (NON_HOST_CHARS.test(label)) return null;
|
|
if (!NON_ASCII.test(label)) {
|
|
// An ACE label must be valid, canonical punycode of a non-ASCII label.
|
|
if (label.startsWith('xn--')) {
|
|
const decoded = decodePunycode(label.slice(4));
|
|
if (decoded === null || !NON_ASCII.test(decoded) || encodePunycode(decoded) !== label.slice(4)) return null;
|
|
}
|
|
out.push(label);
|
|
continue;
|
|
}
|
|
const encoded = encodePunycode(label);
|
|
if (encoded === null) return null;
|
|
out.push(`xn--${encoded}`);
|
|
}
|
|
return out.join('.');
|
|
}
|
|
|
|
/**
|
|
* 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((ownAddress) => {
|
|
if (!ownAddress) return false;
|
|
const ascii = toAsciiEmail(ownAddress);
|
|
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;
|
|
}
|
|
|
|
function digitChar(d: number): string {
|
|
// 0-25 -> a-z, 26-35 -> 0-9
|
|
return String.fromCharCode(d < 26 ? 0x61 + d : 0x16 + d);
|
|
}
|
|
|
|
/** RFC 3492 section 6.3 encoding of one label (without `xn--`). */
|
|
function encodePunycode(input: string): string | null {
|
|
const codePoints = Array.from(input, (ch) => ch.codePointAt(0)!);
|
|
let output = '';
|
|
for (const cp of codePoints) if (cp < 0x80) output += String.fromCharCode(cp);
|
|
const basic = output.length;
|
|
let handled = basic;
|
|
if (basic > 0) output += '-';
|
|
|
|
let n = INITIAL_N;
|
|
let delta = 0;
|
|
let bias = INITIAL_BIAS;
|
|
while (handled < codePoints.length) {
|
|
let m = MAX_INT;
|
|
for (const cp of codePoints) if (cp >= n && cp < m) m = cp;
|
|
if (m - n > Math.floor((MAX_INT - delta) / (handled + 1))) return null;
|
|
delta += (m - n) * (handled + 1);
|
|
n = m;
|
|
for (const cp of codePoints) {
|
|
if (cp < n) {
|
|
delta += 1;
|
|
if (delta > MAX_INT) return null;
|
|
}
|
|
if (cp === n) {
|
|
let q = delta;
|
|
for (let k = BASE; ; k += BASE) {
|
|
const t = k <= bias ? T_MIN : k >= bias + T_MAX ? T_MAX : k - bias;
|
|
if (q < t) break;
|
|
output += digitChar(t + ((q - t) % (BASE - t)));
|
|
q = Math.floor((q - t) / (BASE - t));
|
|
}
|
|
output += digitChar(q);
|
|
bias = adapt(delta, handled + 1, handled === basic);
|
|
delta = 0;
|
|
handled += 1;
|
|
}
|
|
}
|
|
delta += 1;
|
|
n += 1;
|
|
}
|
|
return output;
|
|
}
|
|
|
|
/** 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);
|
|
}
|
|
if (output.length === 0) return null;
|
|
return String.fromCodePoint(...output);
|
|
}
|