Merge pull request #6 from LINUXexpert-org/default-mail-handler

Offer ihasmail as the browser's mailto: handler
This commit is contained in:
LINUXexpert.org
2026-08-23 13:29:21 -07:00
committed by GitHub
10 changed files with 294 additions and 19 deletions
+35 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { formatAddress, initials, isValidEmail, parseAddressList } from "../address";
import { formatAddress, initials, isValidEmail, parseAddressList, parseMailto } from "../address";
describe("address parsing", () => {
it("parses mixed lists", () => {
@@ -21,3 +21,37 @@ describe("address parsing", () => {
expect(initials({ name: null, email: "[email protected]" })).toBe("LK");
});
});
describe("mailto URLs", () => {
it("takes recipients from the path, the to header, or both", () => {
expect(parseMailto("mailto:[email protected]")).toMatchObject({ to: [{ name: null, email: "[email protected]" }] });
expect(parseMailto("mailto:[email protected]").to).toEqual([{ name: null, email: "[email protected]" }]);
expect(parseMailto("mailto:[email protected][email protected]").to).toHaveLength(2);
expect(parseMailto("mailto:[email protected],[email protected]").to).toHaveLength(2);
});
it("reads cc, bcc, subject and body", () => {
const m = parseMailto("mailto:[email protected][email protected]&[email protected]&subject=Hello%20there&body=Line%20one");
expect(m.cc).toEqual([{ name: null, email: "[email protected]" }]);
expect(m.bcc).toEqual([{ name: null, email: "[email protected]" }]);
expect(m.subject).toBe("Hello there");
expect(m.body).toBe("Line one");
});
it("is case-insensitive about headers and decodes plus as space", () => {
const m = parseMailto("MAILTO:[email protected]?SUBJECT=Re:+lunch&Body=see+you");
expect(m.subject).toBe("Re: lunch");
expect(m.body).toBe("see you");
});
it("keeps display names and survives malformed escapes", () => {
expect(parseMailto('mailto:%22Smith%2C%20John%22%20%[email protected]%3E').to).toEqual([{ name: "Smith, John", email: "[email protected]" }]);
expect(parseMailto("mailto:[email protected]?subject=100%").subject).toBe("100%");
});
it("ignores headers it does not understand", () => {
const m = parseMailto("mailto:[email protected]?x-random=1&subject=Hi");
expect(m.subject).toBe("Hi");
expect(m.to).toHaveLength(1);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { draftFromMailto } from "@/store/compose";
/**
* mailto: URLs arrive from anywhere — a web page, a document, another app —
* so the body must reach the composer as text, never as markup.
*/
describe("draftFromMailto", () => {
it("fills recipients, subject and body", () => {
const d = draftFromMailto("mailto:[email protected][email protected]&[email protected]&subject=Q3%20plan&body=Hi%20Ann");
expect(d.to).toEqual([{ name: null, email: "[email protected]" }]);
expect(d.cc).toEqual([{ name: null, email: "[email protected]" }]);
expect(d.bcc).toEqual([{ name: null, email: "[email protected]" }]);
expect(d.showCc).toBe(true);
expect(d.showBcc).toBe(true);
expect(d.subject).toBe("Q3 plan");
expect(d.text).toBe("Hi Ann");
});
it("escapes markup in the body and keeps line breaks", () => {
const d = draftFromMailto("mailto:[email protected]?body=%3Cimg%20src%3Dx%20onerror%3Dboom%3E%20%26%20plain%0Asecond%20line");
expect(d.html).not.toContain("<img");
expect(d.html).toContain("&lt;img");
expect(d.html).toContain("&amp;");
expect(d.html).toContain("<br>");
expect(d.text).toContain("<img");
});
it("leaves the body alone when the URL has none", () => {
const d = draftFromMailto("mailto:[email protected]");
expect(d.html).toBeUndefined();
expect(d.text).toBeUndefined();
expect(d.showCc).toBe(false);
});
});
+42
View File
@@ -114,3 +114,45 @@ 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;
}
}
+61
View File
@@ -0,0 +1,61 @@
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;
}
}