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
+1
View File
@@ -55,6 +55,7 @@ ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters a
**Platform** **Platform**
- Installable PWA (manifest + service worker), mobile layout with bottom tab bar, drawer navigation, full-screen composer, FAB - Installable PWA (manifest + service worker), mobile layout with bottom tab bar, drawer navigation, full-screen composer, FAB
- **Default mail app**: register ihasmail as the browser's handler for `mailto:` links from Settings General (`registerProtocolHandler`; needs HTTPS and a browser that supports it — Safari does not). Installed as an app it also declares `protocol_handlers` in the manifest, which is what lets the operating system offer ihasmail wherever it asks for a mail client. Links arrive with recipients, Cc, Bcc, subject and body filled in
- Security: no credentials in the browser (server-side session with per-session encrypted upstream credentials), httpOnly SameSite cookies, CSRF header + Sec-Fetch-Site checks, strict CSP, sandboxed blob downloads, SSRF-safe image proxy, login rate limiting, security headers - Security: no credentials in the browser (server-side session with per-session encrypted upstream credentials), httpOnly SameSite cookies, CSRF header + Sec-Fetch-Site checks, strict CSP, sandboxed blob downloads, SSRF-safe image proxy, login rate limiting, security headers
## Architecture ## Architecture
+35 -6
View File
@@ -4,18 +4,47 @@
"description": "Fast, friendly JMAP webmail for Stalwart", "description": "Fast, friendly JMAP webmail for Stalwart",
"start_url": "/mail", "start_url": "/mail",
"scope": "/", "scope": "/",
"protocol_handlers": [
{
"protocol": "mailto",
"url": "/mail?mailto=%s"
}
],
"display": "standalone", "display": "standalone",
"orientation": "any", "orientation": "any",
"background_color": "#ffffff", "background_color": "#ffffff",
"theme_color": "#0f766e", "theme_color": "#0f766e",
"icons": [ "icons": [
{ "src": "/img/icon-192.png", "sizes": "192x192", "type": "image/png" }, {
{ "src": "/img/icon-512.png", "sizes": "512x512", "type": "image/png" }, "src": "/img/icon-192.png",
{ "src": "/img/icon-maskable.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" } "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": [ "shortcuts": [
{ "name": "Compose", "url": "/mail?compose=new", "description": "Write a new message" }, {
{ "name": "Calendar", "url": "/calendar" }, "name": "Compose",
{ "name": "Contacts", "url": "/contacts" } "url": "/mail?compose=new",
"description": "Write a new message"
},
{
"name": "Calendar",
"url": "/calendar"
},
{
"name": "Contacts",
"url": "/contacts"
}
] ]
} }
+35 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { formatAddress, initials, isValidEmail, parseAddressList } from "../address"; import { formatAddress, initials, isValidEmail, parseAddressList, parseMailto } from "../address";
describe("address parsing", () => { describe("address parsing", () => {
it("parses mixed lists", () => { it("parses mixed lists", () => {
@@ -21,3 +21,37 @@ describe("address parsing", () => {
expect(initials({ name: null, email: "[email protected]" })).toBe("LK"); 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("@"); const i = email.lastIndexOf("@");
return i >= 0 ? email.slice(i + 1).toLowerCase() : ""; 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;
}
}
+16 -1
View File
@@ -2,7 +2,7 @@ import { create } from "zustand";
import { client } from "@/jmap/client"; import { client } from "@/jmap/client";
import type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } from "@/jmap/types"; import type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } from "@/jmap/types";
import { formatFullDate, uid } from "@/lib/format"; 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 { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/lib/text";
import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/html"; import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/html";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
@@ -676,3 +676,18 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
} }
export { FULL_PROPS, BODY_PROPS }; 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 } : {}),
};
}
+2 -4
View File
@@ -4,7 +4,7 @@ import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIco
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { useSettings } from "@/store/settings"; import { useSettings } from "@/store/settings";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import { useCompose } from "@/store/compose"; import { draftFromMailto, useCompose } from "@/store/compose";
import { Avatar, useIsMobile } from "@/ui/misc"; import { Avatar, useIsMobile } from "@/ui/misc";
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover"; import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
import { SearchBar } from "./SearchBar"; import { SearchBar } from "./SearchBar";
@@ -42,9 +42,7 @@ export function AppShell({ children }: { children: ReactNode }) {
} }
const mailto = params.get("mailto"); const mailto = params.get("mailto");
if (mailto) { if (mailto) {
const [addr, qs] = mailto.replace(/^mailto:/, "").split("?"); openCompose(draftFromMailto(mailto));
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>` : "" });
navigate("/mail", { replace: true }); navigate("/mail", { replace: true });
} }
}, [openCompose, navigate]); }, [openCompose, navigate]);
+4 -7
View File
@@ -4,7 +4,7 @@ import { FilterFromMessageDialog } from "./FilterFromMessage";
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types"; import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import { useSettings } from "@/store/settings"; import { useSettings } from "@/store/settings";
import { useCompose } from "@/store/compose"; import { draftFromMailto, useCompose } from "@/store/compose";
import { useContacts } from "@/store/contacts"; import { useContacts } from "@/store/contacts";
import { client } from "@/jmap/client"; import { client } from "@/jmap/client";
import { formatFullDate, formatListDate, formatSize } from "@/lib/format"; 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 mailto = urls.find((u) => u.startsWith("mailto:"));
const http = urls.find((u) => /^https?:/i.test(u)); const http = urls.find((u) => /^https?:/i.test(u));
if (mailto) { if (mailto) {
const [addr, qs] = mailto.slice(7).split("?"); const fields = draftFromMailto(mailto);
const q = new URLSearchParams(qs ?? ""); useCompose.getState().open({ ...fields, subject: fields.subject || "unsubscribe", html: fields.html ?? "<div>unsubscribe</div>", text: fields.text ?? "unsubscribe" });
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" });
toast.show("Unsubscribe message prepared — just hit Send"); toast.show("Unsubscribe message prepared — just hit Send");
} else if (http) { } else if (http) {
window.open(http, "_blank", "noopener,noreferrer"); window.open(http, "_blank", "noopener,noreferrer");
@@ -272,9 +271,7 @@ function HtmlBody({ html, bodyStyle, onShowImages }: { html: string; bodyStyle:
const href = a.getAttribute("href") ?? ""; const href = a.getAttribute("href") ?? "";
if (href.startsWith("mailto:")) { if (href.startsWith("mailto:")) {
ev.preventDefault(); ev.preventDefault();
const [addr, qs] = href.slice(7).split("?"); openCompose(draftFromMailto(href));
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>` : "" });
return; return;
} }
if (/^(javascript|data|vbscript):/i.test(href)) { if (/^(javascript|data|vbscript):/i.test(href)) {
@@ -2,6 +2,15 @@ import { useSettings } from "@/store/settings";
import { Switch } from "@/ui/misc"; import { Switch } from "@/ui/misc";
import { browserTimeZone, listTimeZones } from "@/lib/dates"; import { browserTimeZone, listTimeZones } from "@/lib/dates";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import { useState } from "react";
import {
canUnregisterMailtoHandler,
isInstalledApp,
mailtoHandlerRequested,
mailtoHandlerSupport,
registerMailtoHandler,
unregisterMailtoHandler,
} from "@/lib/mailhandler";
import { import {
browserLocale, browserLocale,
formatClock, formatClock,
@@ -154,6 +163,9 @@ export function GeneralSettings() {
</div> </div>
<p className="hint">Preview: {formatFullDateTime(SAMPLE)}</p> <p className="hint">Preview: {formatFullDateTime(SAMPLE)}</p>
<h2>Default mail app</h2>
<MailHandlerSettings />
<h2>Backup</h2> <h2>Backup</h2>
<div className="row wrap"> <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> <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>
)}
</>
);
}