Files
ihasmail/web/src/lib/mailhandler.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

62 lines
2.2 KiB
TypeScript

import { loadRaw, saveJson } from "./storage";
/**
* Registering ihasmail as the browser's `mailto:` handler.
*
* `registerProtocolHandler` is the only web API for this. It needs a secure
* context, a same-origin URL containing `%s`, and a user gesture; the browser
* then asks the user. There is no way to read back whether a handler is
* registered, so we remember that we asked and keep the wording honest about
* it. Installed PWAs get a second route via the manifest's `protocol_handlers`,
* which is what lets the operating system itself offer ihasmail.
*/
const KEY = "mailtoHandler";
const SCHEME = "mailto";
export type HandlerSupport = "ok" | "insecure" | "unsupported";
export function handlerUrl(): string {
return `${window.location.origin}/mail?mailto=%s`;
}
export function mailtoHandlerSupport(): HandlerSupport {
if (typeof navigator === "undefined" || typeof navigator.registerProtocolHandler !== "function") return "unsupported";
if (!window.isSecureContext) return "insecure";
return "ok";
}
export function canUnregisterMailtoHandler(): boolean {
return typeof navigator !== "undefined" && typeof (navigator as Navigator & { unregisterProtocolHandler?: unknown }).unregisterProtocolHandler === "function";
}
/** Whether we have asked this browser — not whether the user accepted. */
export function mailtoHandlerRequested(): boolean {
return loadRaw<boolean>(KEY, false) === true;
}
export function setMailtoHandlerRequested(v: boolean): void {
saveJson(KEY, v);
}
/** Must be called from a user gesture. Throws if the browser refuses. */
export function registerMailtoHandler(): void {
navigator.registerProtocolHandler(SCHEME, handlerUrl());
setMailtoHandlerRequested(true);
}
export function unregisterMailtoHandler(): void {
const nav = navigator as Navigator & { unregisterProtocolHandler?: (scheme: string, url: string) => void };
nav.unregisterProtocolHandler?.(SCHEME, handlerUrl());
setMailtoHandlerRequested(false);
}
/** True when the app is running as an installed PWA. */
export function isInstalledApp(): boolean {
try {
return window.matchMedia("(display-mode: standalone)").matches || (navigator as Navigator & { standalone?: boolean }).standalone === true;
} catch {
return false;
}
}