Merge pull request #6 from LINUXexpert-org/default-mail-handler
Offer ihasmail as the browser's mailto: handler
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("<img");
|
||||
expect(d.html).toContain("&");
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user