Files
ihasmail/web/src/lib/address.ts
T
jcoffey-dev 2c23ea980b Offer ihasmail as the browser's mailto: handler
Settings > General gains a "Default mail app" section that calls
registerProtocolHandler so mail links anywhere in the browser open ihasmail.
The browser owns the decision and there is no API to read it back, so the UI
says what it can: it records that we asked, offers "Ask again", shows a
Remove button where unregisterProtocolHandler exists, and points at the
browser's own settings. Unsupported browsers (Safari) and insecure contexts
get an explanation instead of a dead button.

The manifest now declares protocol_handlers for mailto, which is the route by
which an *installed* app can be offered by the operating system itself; the
UI says so and links the two ideas rather than promising a system-wide
default the page cannot grant.

Mailto parsing is now one function (parseMailto in lib/address.ts) instead of
three hand-rolled copies in AppShell and MessageView. It follows RFC 6068:
recipients from the path, the to= header or both, case-insensitive headers,
"+" as space, and tolerant of malformed escapes. That fixes Cc and Bcc being
silently dropped, and draftFromMailto escapes the body so a mailto: URL from
an untrusted page reaches the composer as text rather than markup.
2026-08-23 13:21:50 -07:00

159 lines
4.9 KiB
TypeScript

import type { EmailAddress } from "@/jmap/types";
const EMAIL_RE = /^[^\s@<>"',;]+@[^\s@<>"',;]+\.[^\s@<>"',;]+$/;
export function isValidEmail(s: string): boolean {
return EMAIL_RE.test(s.trim());
}
/**
* Parse a free-form recipient string ("Ann <[email protected]>, [email protected]; \"C, D\" <c@z>")
* into a list of EmailAddress. Lenient by design.
*/
export function parseAddressList(input: string): EmailAddress[] {
const out: EmailAddress[] = [];
let buf = "";
let inQuote = false;
let inAngle = false;
const flush = () => {
const a = parseOne(buf);
if (a) out.push(a);
buf = "";
};
for (const ch of input) {
if (ch === '"' && !inAngle) inQuote = !inQuote;
if (ch === "<" && !inQuote) inAngle = true;
if (ch === ">" && !inQuote) inAngle = false;
if ((ch === "," || ch === ";" || ch === "\n") && !inQuote && !inAngle) {
flush();
continue;
}
buf += ch;
}
flush();
return out;
}
export function parseOne(raw: string): EmailAddress | null {
const s = raw.trim();
if (!s) return null;
const m = /^(.*?)\s*<([^<>]+)>\s*$/.exec(s);
if (m) {
let name = m[1]!.trim();
if (name.startsWith('"') && name.endsWith('"')) name = name.slice(1, -1).replace(/\\(.)/g, "$1");
return { name: name || null, email: m[2]!.trim() };
}
return { name: null, email: s.replace(/^<|>$/g, "") };
}
export function formatAddress(a: EmailAddress | null | undefined): string {
if (!a) return "";
if (!a.name) return a.email;
const needsQuote = /[,;<>"()\\]/.test(a.name);
const name = needsQuote ? `"${a.name.replace(/(["\\])/g, "\\$1")}"` : a.name;
return `${name} <${a.email}>`;
}
export function formatAddressList(list: EmailAddress[] | null | undefined): string {
return (list ?? []).map(formatAddress).join(", ");
}
export function displayName(a: EmailAddress | null | undefined, fallback = "(unknown)"): string {
if (!a) return fallback;
if (a.name?.trim()) return a.name.trim();
return a.email || fallback;
}
export function shortName(a: EmailAddress | null | undefined): string {
const n = displayName(a, "");
if (!n) return "";
if (n.includes("@")) return n.split("@")[0]!;
return n.split(/\s+/)[0]!;
}
export function initials(a: EmailAddress | { name?: string | null; email?: string } | string | null | undefined): string {
const name = typeof a === "string" ? a : a?.name || a?.email || "";
const parts = name
.replace(/[<>"]/g, "")
.split(/[\s._@-]+/)
.filter(Boolean);
if (!parts.length) return "?";
if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
return (parts[0]![0]! + parts[1]![0]!).toUpperCase();
}
const PALETTE = [
"#0f766e", "#b45309", "#7c3aed", "#be185d", "#1d4ed8", "#047857",
"#c2410c", "#4338ca", "#a21caf", "#0e7490", "#b91c1c", "#15803d",
];
export function avatarColor(seed: string | null | undefined): string {
const s = (seed ?? "").toLowerCase();
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return PALETTE[h % PALETTE.length]!;
}
export function sameAddress(a: string | null | undefined, b: string | null | undefined): boolean {
return (a ?? "").trim().toLowerCase() === (b ?? "").trim().toLowerCase();
}
export function uniqueAddresses(list: EmailAddress[]): EmailAddress[] {
const seen = new Set<string>();
const out: EmailAddress[] = [];
for (const a of list) {
const k = a.email.trim().toLowerCase();
if (!k || seen.has(k)) continue;
seen.add(k);
out.push(a);
}
return out;
}
export function domainOf(email: string): string {
const i = email.lastIndexOf("@");
return i >= 0 ? email.slice(i + 1).toLowerCase() : "";
}
export interface MailtoFields {
to: EmailAddress[];
cc: EmailAddress[];
bcc: EmailAddress[];
subject: string;
body: string;
}
/**
* Parse a `mailto:` URL (RFC 6068) into composer fields.
*
* Recipients may sit in the path, in `to=`, or both; headers other than
* to/cc/bcc/subject/body are ignored. Percent-encoding is undone leniently —
* a malformed escape yields the raw text rather than throwing.
*/
export function parseMailto(url: string): MailtoFields {
const withoutScheme = url.replace(/^mailto:/i, "");
const q = withoutScheme.indexOf("?");
const path = q === -1 ? withoutScheme : withoutScheme.slice(0, q);
const params = new URLSearchParams(q === -1 ? "" : withoutScheme.slice(q + 1));
const header = (name: string) => {
for (const [k, v] of params) if (k.toLowerCase() === name) return v;
return "";
};
const addresses = (raw: string) => (raw.trim() ? parseAddressList(decode(raw)) : []);
return {
to: [...addresses(path), ...addresses(header("to"))],
cc: addresses(header("cc")),
bcc: addresses(header("bcc")),
subject: decode(header("subject")),
body: decode(header("body")),
};
}
function decode(s: string): string {
try {
return decodeURIComponent(s.replace(/\+/g, " "));
} catch {
return s;
}
}