// 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; }