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.
This commit is contained in:
@@ -4,18 +4,47 @@
|
||||
"description": "Fast, friendly JMAP webmail for Stalwart",
|
||||
"start_url": "/mail",
|
||||
"scope": "/",
|
||||
"protocol_handlers": [
|
||||
{
|
||||
"protocol": "mailto",
|
||||
"url": "/mail?mailto=%s"
|
||||
}
|
||||
],
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#0f766e",
|
||||
"icons": [
|
||||
{ "src": "/img/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/img/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/img/icon-maskable.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }
|
||||
{
|
||||
"src": "/img/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/img/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/img/icon-maskable.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{ "name": "Compose", "url": "/mail?compose=new", "description": "Write a new message" },
|
||||
{ "name": "Calendar", "url": "/calendar" },
|
||||
{ "name": "Contacts", "url": "/contacts" }
|
||||
{
|
||||
"name": "Compose",
|
||||
"url": "/mail?compose=new",
|
||||
"description": "Write a new message"
|
||||
},
|
||||
{
|
||||
"name": "Calendar",
|
||||
"url": "/calendar"
|
||||
},
|
||||
{
|
||||
"name": "Contacts",
|
||||
"url": "/contacts"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { create } from "zustand";
|
||||
import { client } from "@/jmap/client";
|
||||
import type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } from "@/jmap/types";
|
||||
import { formatFullDate, uid } from "@/lib/format";
|
||||
import { formatAddress, sameAddress, uniqueAddresses } from "@/lib/address";
|
||||
import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address";
|
||||
import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/lib/text";
|
||||
import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/html";
|
||||
import { toast } from "@/ui/toast";
|
||||
@@ -676,3 +676,18 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
|
||||
}
|
||||
|
||||
export { FULL_PROPS, BODY_PROPS };
|
||||
|
||||
/** Composer fields for a `mailto:` URL, including cc/bcc and a quoted body. */
|
||||
export function draftFromMailto(url: string): Partial<Draft> {
|
||||
const m = parseMailto(url);
|
||||
const body = m.body ? `<div>${escapeHtml(m.body).replace(/\n/g, "<br>")}</div>` : "";
|
||||
return {
|
||||
to: m.to,
|
||||
cc: m.cc,
|
||||
bcc: m.bcc,
|
||||
showCc: m.cc.length > 0,
|
||||
showBcc: m.bcc.length > 0,
|
||||
subject: m.subject,
|
||||
...(body ? { html: body, text: m.body } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIco
|
||||
import { useSession } from "@/store/session";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { draftFromMailto, useCompose } from "@/store/compose";
|
||||
import { Avatar, useIsMobile } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
@@ -42,9 +42,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
}
|
||||
const mailto = params.get("mailto");
|
||||
if (mailto) {
|
||||
const [addr, qs] = mailto.replace(/^mailto:/, "").split("?");
|
||||
const q = new URLSearchParams(qs ?? "");
|
||||
openCompose({ to: addr ? addr.split(",").map((e) => ({ name: null, email: e.trim() })) : [], subject: q.get("subject") ?? "", html: q.get("body") ? `<div>${q.get("body")}</div>` : "" });
|
||||
openCompose(draftFromMailto(mailto));
|
||||
navigate("/mail", { replace: true });
|
||||
}
|
||||
}, [openCompose, navigate]);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FilterFromMessageDialog } from "./FilterFromMessage";
|
||||
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { draftFromMailto, useCompose } from "@/store/compose";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { client } from "@/jmap/client";
|
||||
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
|
||||
@@ -103,9 +103,8 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
|
||||
const mailto = urls.find((u) => u.startsWith("mailto:"));
|
||||
const http = urls.find((u) => /^https?:/i.test(u));
|
||||
if (mailto) {
|
||||
const [addr, qs] = mailto.slice(7).split("?");
|
||||
const q = new URLSearchParams(qs ?? "");
|
||||
useCompose.getState().open({ to: [{ name: null, email: addr ?? "" }], subject: q.get("subject") ?? "unsubscribe", html: `<div>${q.get("body") ?? "unsubscribe"}</div>`, text: q.get("body") ?? "unsubscribe" });
|
||||
const fields = draftFromMailto(mailto);
|
||||
useCompose.getState().open({ ...fields, subject: fields.subject || "unsubscribe", html: fields.html ?? "<div>unsubscribe</div>", text: fields.text ?? "unsubscribe" });
|
||||
toast.show("Unsubscribe message prepared — just hit Send");
|
||||
} else if (http) {
|
||||
window.open(http, "_blank", "noopener,noreferrer");
|
||||
@@ -272,9 +271,7 @@ function HtmlBody({ html, bodyStyle, onShowImages }: { html: string; bodyStyle:
|
||||
const href = a.getAttribute("href") ?? "";
|
||||
if (href.startsWith("mailto:")) {
|
||||
ev.preventDefault();
|
||||
const [addr, qs] = href.slice(7).split("?");
|
||||
const q = new URLSearchParams(qs ?? "");
|
||||
openCompose({ to: addr ? addr.split(",").map((x) => ({ name: null, email: decodeURIComponent(x.trim()) })) : [], subject: q.get("subject") ?? "", html: q.get("body") ? `<div>${q.get("body")}</div>` : "" });
|
||||
openCompose(draftFromMailto(href));
|
||||
return;
|
||||
}
|
||||
if (/^(javascript|data|vbscript):/i.test(href)) {
|
||||
|
||||
@@ -2,6 +2,15 @@ import { useSettings } from "@/store/settings";
|
||||
import { Switch } from "@/ui/misc";
|
||||
import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
canUnregisterMailtoHandler,
|
||||
isInstalledApp,
|
||||
mailtoHandlerRequested,
|
||||
mailtoHandlerSupport,
|
||||
registerMailtoHandler,
|
||||
unregisterMailtoHandler,
|
||||
} from "@/lib/mailhandler";
|
||||
import {
|
||||
browserLocale,
|
||||
formatClock,
|
||||
@@ -154,6 +163,9 @@ export function GeneralSettings() {
|
||||
</div>
|
||||
<p className="hint">Preview: {formatFullDateTime(SAMPLE)}</p>
|
||||
|
||||
<h2>Default mail app</h2>
|
||||
<MailHandlerSettings />
|
||||
|
||||
<h2>Backup</h2>
|
||||
<div className="row wrap">
|
||||
<button className="btn" onClick={() => { const blob = new Blob([exportJson()], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "ihasmail-settings.json"; a.click(); }}>Export settings</button>
|
||||
@@ -167,3 +179,54 @@ export function GeneralSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer ihasmail as the browser's handler for `mailto:` links. The browser
|
||||
* owns the decision, and nothing can read the answer back, so this states what
|
||||
* it can and points at the browser's own settings for the rest.
|
||||
*/
|
||||
function MailHandlerSettings() {
|
||||
const support = mailtoHandlerSupport();
|
||||
const [requested, setRequested] = useState(mailtoHandlerRequested);
|
||||
|
||||
const ask = () => {
|
||||
try {
|
||||
registerMailtoHandler();
|
||||
setRequested(true);
|
||||
toast.success("Your browser will ask whether to open mail links in ihasmail");
|
||||
} catch (err) {
|
||||
toast.error(`Your browser refused the request: ${(err as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
unregisterMailtoHandler();
|
||||
setRequested(false);
|
||||
toast.show("Removed. Mail links will open in whatever your browser falls back to.");
|
||||
};
|
||||
|
||||
if (support === "unsupported") {
|
||||
return <p className="hint">This browser cannot register apps for <code>mailto:</code> links. Safari, in particular, has no such API — you can still make ihasmail the default from your operating system if you install it as an app.</p>;
|
||||
}
|
||||
if (support === "insecure") {
|
||||
return <p className="hint">Registering for <code>mailto:</code> links requires a secure (HTTPS) connection.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="hint">
|
||||
Open <code>mailto:</code> links — in web pages, documents and other apps — in ihasmail instead of a desktop mail client.
|
||||
Your browser will ask you to confirm, and you can change it later in its own settings (Chrome: Settings › Privacy and security › Site settings › Protocol handlers; Firefox: Settings › General › Applications).
|
||||
</p>
|
||||
<div className="row wrap">
|
||||
<button className="btn btn-primary" onClick={ask}>{requested ? "Ask again" : "Make ihasmail the default mail app"}</button>
|
||||
{requested && canUnregisterMailtoHandler() && <button className="btn btn-ghost" onClick={remove}>Remove</button>}
|
||||
</div>
|
||||
{requested && <p className="hint mt-8">Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.</p>}
|
||||
{!isInstalledApp() && (
|
||||
<p className="hint mt-8">
|
||||
For a system-wide default, install ihasmail as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer ihasmail directly wherever it asks which mail app to use.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user