mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
196 lines
7.0 KiB
TypeScript
196 lines
7.0 KiB
TypeScript
// 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
|
|
// `<base>/.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';
|
|
|
|
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;
|
|
|
|
const hostname = host.split(':')[0].toLowerCase();
|
|
if (!hostname) return null;
|
|
if (!hostname.includes('.') && hostname !== 'localhost') return null;
|
|
|
|
return value;
|
|
}
|
|
|
|
export function emailDomain(email: string): string | null {
|
|
const match = EMAIL_RE.exec(email.trim());
|
|
return match ? match[1].toLowerCase() : null;
|
|
}
|
|
|
|
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<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
|
return new Promise<T>((resolve) => {
|
|
const timer = setTimeout(() => resolve(fallback), ms);
|
|
promise
|
|
.then((value) => {
|
|
clearTimeout(timer);
|
|
resolve(value);
|
|
})
|
|
.catch(() => {
|
|
clearTimeout(timer);
|
|
resolve(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,
|
|
): Promise<boolean> {
|
|
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
const abortTimer = setTimeout(() => controller?.abort(), timeoutMs);
|
|
|
|
const request = (async () => {
|
|
try {
|
|
const response = await secureFetch(`${baseUrl}/.well-known/jmap`, {
|
|
headers: { Accept: 'application/json' },
|
|
signal: controller?.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);
|
|
} finally {
|
|
clearTimeout(abortTimer);
|
|
}
|
|
}
|
|
|
|
export interface DiscoveryOptions {
|
|
/** Servers of already-registered accounts, trusted without a probe. */
|
|
knownServerUrls?: string[];
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
/**
|
|
* 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<string | null> {
|
|
const domain = emailDomain(email);
|
|
if (!domain) 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)) ? candidate : 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;
|
|
}
|