// Turn an email address into a JMAP server URL so the sign-in flow doesn't
// have to open with a "Server URL" field nobody knows the answer to.
//
// The probe is deliberately cheap and short-lived: a GET of
// `/.well-known/jmap` against a handful of conventional hosts, all in
// parallel, with a hard deadline. Discovery is an optimisation — if it comes
// back empty the caller falls through to asking for the server by hand, so a
// slow or hostile network must never be worse than typing it in.
import { secureFetch } from './client-cert';
import { signalWithTimeout } from './sign-in-abort';
import { toAsciiDomain } from './idn';
export const DISCOVERY_TIMEOUT_MS = 2500;
const SCHEME_RE = /^([a-z][a-z0-9+.-]*):\/\//i;
/** Loopback, the Android emulator's host alias, RFC 1918 ranges and `.local`. */
export function isLocalHost(url: string): boolean {
const m = /^[a-z][a-z0-9+.-]*:\/\/([^/?#:]+)/i.exec(url);
const host = (m ? m[1] : '').toLowerCase();
if (!host) return false;
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '10.0.2.2') return true;
if (host.endsWith('.local') || host.endsWith('.localhost')) return true;
const ip = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(host);
if (!ip) return false;
const [a, b] = [Number(ip[1]), Number(ip[2])];
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
}
/** True when a normalised server URL uses plain http. */
export function isCleartextUrl(url: string): boolean {
return /^http:\/\//i.test(url);
}
const EMAIL_RE = /^[^\s@]+@([^\s@]+\.[^\s@]+)$/;
/**
* Accept what people actually type — `mail.example.com`, `example.com/`,
* `https://mail.example.com/.well-known/jmap`, `…/jmap/session` — and return
* the base URL the JMAP client expects, or null if it can't be one.
*/
export function normalizeServerUrl(input: string): string | null {
let value = input.trim();
if (!value) return null;
const scheme = SCHEME_RE.exec(value);
if (scheme) {
const protocol = scheme[1].toLowerCase();
if (protocol !== 'http' && protocol !== 'https') return null;
value = `${protocol}://${value.slice(scheme[0].length)}`;
} else {
value = `https://${value}`;
}
// Basic credentials travel in every request. Cleartext is only acceptable
// for a server on the developer's own machine / LAN; on a public host it
// would fail opaquely on Android anyway (no cleartext traffic allowed) and
// leak the password on iOS.
if (/^http:\/\//i.test(value) && !isLocalHost(value)) return null;
// Someone pasting the endpoint they found in the webmail's settings gets the
// same result as someone typing the address they use in the browser. Only
// the session or API suffix goes, so a base path prefix survives (#971).
value = value
.replace(/[?#].*$/, '')
.replace(/\/+$/, '')
.replace(/\/(?:jmap\/session|\.well-known\/jmap|jmap)$/i, '')
.replace(/\/+$/, '');
const host = value.slice(value.indexOf('://') + 3).split('/')[0];
if (!host || /[\s@]/.test(host)) return null;
let hostname = host.split(':')[0].toLowerCase();
if (!hostname) return null;
// An internationalized host goes on the wire in its ASCII form (#1100).
if (/[^\x00-\x7f]/.test(hostname)) {
const ascii = toAsciiDomain(hostname);
if (!ascii) return null;
const schemeEnd = value.indexOf('://') + 3;
value = value.slice(0, schemeEnd) + ascii + value.slice(schemeEnd + hostname.length);
hostname = ascii;
}
if (!hostname.includes('.') && hostname !== 'localhost') return null;
return value;
}
/**
* The domain of an address in its ASCII form (`jörg@bücher.de` ->
* `xn--bcher-kva.de`), which is what discovery probes and the server knows.
* Show it with toUnicodeDomain.
*/
export function emailDomain(email: string): string | null {
const match = EMAIL_RE.exec(email.trim());
if (!match) return null;
return toAsciiDomain(match[1]) ?? match[1].toLowerCase();
}
export function isEmailAddress(value: string): boolean {
return emailDomain(value) !== null;
}
function hostOf(url: string): string {
return url.slice(url.indexOf('://') + 3).split('/')[0].split(':')[0].toLowerCase();
}
/**
* Hosts worth probing for `domain`, most likely first. The order is also the
* tie-break order when several respond.
*/
export function serverCandidates(domain: string): string[] {
return [`https://${domain}`, `https://mail.${domain}`, `https://webmail.${domain}`];
}
function withTimeout(promise: Promise, ms: number, fallback: T, signal?: AbortSignal): Promise {
return new Promise((resolve) => {
if (signal?.aborted) {
resolve(fallback);
return;
}
const finish = (value: T) => {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
resolve(value);
};
const onAbort = () => finish(fallback);
const timer = setTimeout(() => finish(fallback), ms);
signal?.addEventListener('abort', onAbort, { once: true });
promise.then(finish).catch(() => finish(fallback));
});
}
function looksLikeSession(body: unknown): boolean {
if (!body || typeof body !== 'object') return false;
return 'capabilities' in body || 'apiUrl' in body || 'primaryAccounts' in body;
}
/**
* True when `baseUrl` looks like a JMAP server.
*
* A 401 counts as a hit — an unauthenticated probe of Stalwart's session
* endpoint is *supposed* to be rejected, and that rejection is proof the
* endpoint is there. A 200, on the other hand, has to actually contain a
* session document: a captive portal or a catch-all web server will happily
* answer 200 with HTML for any path we ask for.
*/
export async function probeJmapServer(
baseUrl: string,
timeoutMs: number = DISCOVERY_TIMEOUT_MS,
signal?: AbortSignal,
): Promise {
// Gives up at the deadline, or when the user cancels the sign-in.
const deadline = signalWithTimeout(signal, timeoutMs);
const request = (async () => {
try {
const response = await secureFetch(`${baseUrl}/.well-known/jmap`, {
headers: { Accept: 'application/json' },
signal: deadline.signal,
});
if (response.status === 401) return true;
if (response.status !== 200) return false;
return looksLikeSession(await response.json());
} catch {
return false;
}
})();
try {
// The abort signal doesn't reach the native client-certificate path, so
// race the whole thing rather than trusting the request to give up.
return await withTimeout(request, timeoutMs, false, signal);
} finally {
deadline.done();
}
}
export interface DiscoveryOptions {
/** Servers of already-registered accounts, trusted without a probe. */
knownServerUrls?: string[];
timeoutMs?: number;
/** The sign-in screen's Cancel: discovery stops and finds nothing. */
signal?: AbortSignal;
}
/**
* Best guess at the mail server for `email`, or null if nothing answered in
* time. Callers treat null as "ask the user".
*/
export async function discoverServerForEmail(
email: string,
options: DiscoveryOptions = {},
): Promise {
const domain = emailDomain(email);
if (!domain || options.signal?.aborted) return null;
// A server we're already signed in to for this domain needs no probe, and
// covers the common case of adding a second account on the same host.
const known = (options.knownServerUrls ?? [])
.map(normalizeServerUrl)
.filter((url): url is string => url !== null);
const alreadyKnown = known.find((url) => {
const host = hostOf(url);
return host === domain || host.endsWith(`.${domain}`);
});
if (alreadyKnown) return alreadyKnown;
const timeoutMs = options.timeoutMs ?? DISCOVERY_TIMEOUT_MS;
const candidates = serverCandidates(domain);
const results = await Promise.all(
candidates.map(async (candidate) => ((await probeJmapServer(candidate, timeoutMs, options.signal)) ? candidate : null)),
);
if (options.signal?.aborted) return null;
// `find` keeps candidate order, so a bare domain wins over `mail.` even when
// both answer.
return results.find((url): url is string => url !== null) ?? null;
}