diff --git a/README.md b/README.md index 5c3aef6..b6292c9 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters a **Platform** - 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 ## Architecture diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest index b3a506d..0f704ab 100644 --- a/web/public/manifest.webmanifest +++ b/web/public/manifest.webmanifest @@ -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" + } ] } diff --git a/web/src/lib/__tests__/address.test.ts b/web/src/lib/__tests__/address.test.ts index daa48e6..fda7e94 100644 --- a/web/src/lib/__tests__/address.test.ts +++ b/web/src/lib/__tests__/address.test.ts @@ -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: "linus@kernel.org" })).toBe("LK"); }); }); + +describe("mailto URLs", () => { + it("takes recipients from the path, the to header, or both", () => { + expect(parseMailto("mailto:ann@example.com")).toMatchObject({ to: [{ name: null, email: "ann@example.com" }] }); + expect(parseMailto("mailto:?to=bob@example.org").to).toEqual([{ name: null, email: "bob@example.org" }]); + expect(parseMailto("mailto:ann@example.com?to=bob@example.org").to).toHaveLength(2); + expect(parseMailto("mailto:ann@example.com,bob@example.org").to).toHaveLength(2); + }); + + it("reads cc, bcc, subject and body", () => { + const m = parseMailto("mailto:ann@example.com?cc=c@x.io&bcc=d@x.io&subject=Hello%20there&body=Line%20one"); + expect(m.cc).toEqual([{ name: null, email: "c@x.io" }]); + expect(m.bcc).toEqual([{ name: null, email: "d@x.io" }]); + 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:ann@example.com?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%3Cj@x.io%3E').to).toEqual([{ name: "Smith, John", email: "j@x.io" }]); + expect(parseMailto("mailto:ann@example.com?subject=100%").subject).toBe("100%"); + }); + + it("ignores headers it does not understand", () => { + const m = parseMailto("mailto:ann@example.com?x-random=1&subject=Hi"); + expect(m.subject).toBe("Hi"); + expect(m.to).toHaveLength(1); + }); +}); diff --git a/web/src/lib/__tests__/mailto.test.ts b/web/src/lib/__tests__/mailto.test.ts new file mode 100644 index 0000000..a4af302 --- /dev/null +++ b/web/src/lib/__tests__/mailto.test.ts @@ -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:ann@example.com?cc=c@x.io&bcc=d@x.io&subject=Q3%20plan&body=Hi%20Ann"); + expect(d.to).toEqual([{ name: null, email: "ann@example.com" }]); + expect(d.cc).toEqual([{ name: null, email: "c@x.io" }]); + expect(d.bcc).toEqual([{ name: null, email: "d@x.io" }]); + 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:x@y.io?body=%3Cimg%20src%3Dx%20onerror%3Dboom%3E%20%26%20plain%0Asecond%20line"); + expect(d.html).not.toContain(""); + expect(d.text).toContain(" { + const d = draftFromMailto("mailto:x@y.io"); + expect(d.html).toBeUndefined(); + expect(d.text).toBeUndefined(); + expect(d.showCc).toBe(false); + }); +}); diff --git a/web/src/lib/address.ts b/web/src/lib/address.ts index e7475f6..76cbdd7 100644 --- a/web/src/lib/address.ts +++ b/web/src/lib/address.ts @@ -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; + } +} diff --git a/web/src/lib/mailhandler.ts b/web/src/lib/mailhandler.ts new file mode 100644 index 0000000..b78aadd --- /dev/null +++ b/web/src/lib/mailhandler.ts @@ -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(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; + } +} diff --git a/web/src/store/compose.ts b/web/src/store/compose.ts index 6391984..3a1e23c 100644 --- a/web/src/store/compose.ts +++ b/web/src/store/compose.ts @@ -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 { } export { FULL_PROPS, BODY_PROPS }; + +/** Composer fields for a `mailto:` URL, including cc/bcc and a quoted body. */ +export function draftFromMailto(url: string): Partial { + const m = parseMailto(url); + const body = m.body ? `
${escapeHtml(m.body).replace(/\n/g, "
")}
` : ""; + 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 } : {}), + }; +} diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index 879a599..fe08c65 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -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") ? `
${q.get("body")}
` : "" }); + openCompose(draftFromMailto(mailto)); navigate("/mail", { replace: true }); } }, [openCompose, navigate]); diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index 78313a3..7d8b0de 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -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: `
${q.get("body") ?? "unsubscribe"}
`, text: q.get("body") ?? "unsubscribe" }); + const fields = draftFromMailto(mailto); + useCompose.getState().open({ ...fields, subject: fields.subject || "unsubscribe", html: fields.html ?? "
unsubscribe
", 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") ? `
${q.get("body")}
` : "" }); + openCompose(draftFromMailto(href)); return; } if (/^(javascript|data|vbscript):/i.test(href)) { diff --git a/web/src/views/settings/GeneralSettings.tsx b/web/src/views/settings/GeneralSettings.tsx index 2ea3af1..ddacaf2 100644 --- a/web/src/views/settings/GeneralSettings.tsx +++ b/web/src/views/settings/GeneralSettings.tsx @@ -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() {

Preview: {formatFullDateTime(SAMPLE)}

+

Default mail app

+ +

Backup

@@ -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

This browser cannot register apps for mailto: 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.

; + } + if (support === "insecure") { + return

Registering for mailto: links requires a secure (HTTPS) connection.

; + } + + return ( + <> +

+ Open mailto: 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). +

+
+ + {requested && canUnregisterMailtoHandler() && } +
+ {requested &&

Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.

} + {!isInstalledApp() && ( +

+ 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. +

+ )} + + ); +}