diff --git a/README.md b/README.md index 6e70a2f..5b5f6ec 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ seconds of downtime with nothing lost. - Search with Gmail operators (`from:`, `to:`, `subject:`, `has:attachment`, `is:unread`, `is:starred`, `in:`, `label:`, `before:`, `after:`, `larger:`, `smaller:` …) plus an advanced-search panel - Composer: multiple floating/minimised/maximised composers, rich-text editor (formatting, lists, links, colours, images pasted/dropped inline, emoji), plain-text mode, recipient chips with autocomplete from **contacts, the directory (GAL) and recent recipients**, multiple identities with HTML signatures, Cc/Bcc, priority, read-receipt request, templates/canned responses, attachment upload with progress, drag & drop, attachment reminder, **undo send**, **scheduled send** (quick picks or an exact date and time; the message waits in the server's queue, so it goes out whether or not ihasmail is open), autosaved drafts, reply/reply-all/forward with quoting and inline images preserved - Live updates via JMAP push (EventSource proxied server-side) with polling fallback; desktop notifications, sound, title/favicon unread badge +- **Notifications that arrive with ihasmail closed** — Web Push signed with VAPID ([RFC 9749](https://datatracker.ietf.org/doc/rfc9749/)), which Stalwart 0.16 supports and without which Chromium and Safari cannot receive push at all. Stalwart pushes straight to the browser's own push service: no relay, no extra service to run, and ihasmail's server is not in the delivery path. With [`emailpush`](https://datatracker.ietf.org/doc/draft-ietf-jmap-emailpush/) the payload carries the sender and subject, so the notification is useful without a round-trip — and the filter lives on the server, so spam never leaves it. The subscription is torn down on sign-out, because it belongs to the account rather than the session and would otherwise keep notifying a shared machine for a mailbox nobody is signed into - A–Z folder list with Inbox pinned on top (other special folders mixed in), subfolders nested and collapsed by default with chevrons in their own gutter so every icon lines up; unread folders are bold (a parent is bold when a subfolder has unread mail); right-click a folder to mark it read *including subfolders*, create/rename/hide/share/empty, quota bar, Outlook-style module bar (Mail · Calendar · Contacts · Files) at the bottom of the pane, multi-account switching for shared accounts **Calendar** (JMAP Calendars / JSCalendar) diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 386a6e1..8489058 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -53,6 +53,9 @@ const nextState = () => String(state.n++); * list no user has. The role is what the client branches on; the name is only * ever displayed, which is exactly why it has to look right. */ +/** Push subscriptions, as a fresh account has none. */ +const pushSubscriptions: Obj[] = []; + const mailboxes: Obj[] = [ mb("inbox", "Inbox", "inbox"), mb("drafts", "Drafts", "drafts"), @@ -476,6 +479,73 @@ const handlers: Record = { state.n++; return setResp({ updated: { singleton: null } }); }, + /* + * Push subscriptions. The JMAP half can be modelled; delivery cannot -- that + * runs through the browser vendor's real push service, so nothing local will + * ever make a notification appear. + * + * What is worth reproducing is the handshake, because it is the part that + * fails quietly: a subscription is created unverified and stays silent until + * the client echoes back a code the server pushed. A mock that marked one + * verified on creation would let a client ship without ever implementing + * that, and the symptom in production is "registered, and no notifications". + */ + "PushSubscription/get": (a) => { + const ids = (a.ids as string[] | null) ?? pushSubscriptions.map((s) => s.id as string); + const list = pushSubscriptions.filter((s) => ids.includes(s.id as string)); + // `keys` is write-only in JMAP: the server never hands it back. + return { accountId: ACCOUNT, state: String(state.n), list: list.map((s) => { const { keys: _drop, ...rest } = s; return rest; }), notFound: ids.filter((i) => !list.some((s) => s.id === i)) }; + }, + "PushSubscription/set": (a) => { + const created: Obj = {}; + const notCreated: Obj = {}; + const updated: Obj = {}; + const notUpdated: Obj = {}; + const destroyed: string[] = []; + for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) { + const o = obj as Obj; + const keys = (o.keys ?? {}) as Obj; + // Stalwart 0.16 was fixed to accept the unpadded base64url the W3C Push + // API produces; padding it would be the client inventing a shape. + for (const k of ["p256dh", "auth"]) { + const v = String(keys[k] ?? ""); + if (!v) { notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `Missing ${k}.` }; break; } + if (v.includes("=") || v.includes("+") || v.includes("/")) { + notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `${k} must be unpadded base64url.` }; + break; + } + } + if (notCreated[cid]) continue; + if (!String(o.url ?? "").startsWith("https://")) { + notCreated[cid] = { type: "invalidProperties", properties: ["url"], description: "Push endpoint must be https." }; + continue; + } + // One per device: re-subscribing replaces rather than accumulates. + const deviceId = String(o.deviceClientId ?? ""); + const clash = pushSubscriptions.findIndex((s) => s.deviceClientId === deviceId); + if (clash >= 0) pushSubscriptions.splice(clash, 1); + const id = `ps${randomUUID().slice(0, 6)}`; + pushSubscriptions.push({ id, deviceClientId: deviceId, url: o.url, types: o.types ?? null, emailPush: o.emailPush ?? null, expires: null, keys, verified: false, code: `v${randomUUID().slice(0, 8)}` }); + created[cid] = { id, expires: null }; + state.n++; + } + for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) { + const s = pushSubscriptions.find((x) => x.id === id); + if (!s) { notUpdated[id] = { type: "notFound" }; continue; } + const code = (patch as Obj).verificationCode; + if (code !== undefined) { + if (code !== s.code) { notUpdated[id] = { type: "invalidProperties", properties: ["verificationCode"], description: "Verification code does not match." }; continue; } + s.verified = true; + } + updated[id] = null; + state.n++; + } + for (const id of (a.destroy as string[]) ?? []) { + const i = pushSubscriptions.findIndex((x) => x.id === id); + if (i >= 0) { pushSubscriptions.splice(i, 1); destroyed.push(id); state.n++; } + } + return setResp({ created, notCreated, updated, notUpdated, destroyed }); + }, "x:AppPassword/get": (a) => genericGet(account.appPasswords)(a), "x:AppPassword/set": (a) => { const created: Obj = {}; @@ -667,7 +737,9 @@ function readBody(req: IncomingMessage): Promise { } const session = () => ({ - capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} }, + capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: "BBvig2GPmqohMJJHMzp6bTKviHibYiVCyAY8gdq2fPhS-9YfO9_0TnhMyZ0a0JxTsbCqd3zm1rEiXsXsL3jveJY" }, + "urn:ietf:params:jmap:emailpush": {}, + "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} }, accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } }, primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": ACCOUNT }) }, username: USER, diff --git a/web/public/sw.js b/web/public/sw.js index f19aeb6..a4994b8 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -1,5 +1,7 @@ -/* ihasmail service worker: app-shell caching for installability & fast loads. - API requests are never cached. */ +/* ihasmail service worker. + Two jobs: app-shell caching for installability and fast loads (API requests + are never cached), and Web Push, which is the only part of ihasmail that runs + when no tab is open. */ const VERSION = "ihasmail-v2"; const SHELL = ["/", "/manifest.webmanifest", "/img/logo.png", "/img/icon-192.png", "/favicon.ico"]; @@ -39,3 +41,95 @@ self.addEventListener("fetch", (event) => { } event.respondWith(fetch(req).catch(() => caches.match(req))); }); + + +/* ------------------------------------------------------------------ */ +/* Web Push */ +/* ------------------------------------------------------------------ */ + +/* + * Stalwart signs with VAPID and pushes straight to the browser's push service; + * nothing here talks to ihasmail's server. The payload is an EmailPush object + * (draft-ietf-jmap-emailpush) carrying enough of the message to show a useful + * notification without a round-trip — which matters, because when this fires + * there may be no session to make one with. + * + * A JMAP subscription also delivers a PushVerification first, and stays silent + * until the client echoes its code back. That cannot be done from here (no + * credentials), so it is stashed for a tab to collect and confirm. + */ + +const VERIFY_KEY = "ihasmail-push-verification"; + +function textOf(email) { + const from = email?.from?.[0]; + const who = from?.name || from?.email || "New message"; + const what = email?.subject || "(no subject)"; + return { title: who, body: what, preview: email?.preview || "" }; +} + +self.addEventListener("push", (event) => { + let data = null; + try { + data = event.data ? event.data.json() : null; + } catch { + /* not JSON: fall through to the generic notification below */ + } + + // The verification handshake. No credentials here, so hand it to a tab — + // an open one now, or the next one to start. + if (data && data["@type"] === "PushVerification") { + event.waitUntil((async () => { + const payload = { id: data.pushSubscriptionId, code: data.verificationCode }; + const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" }); + if (clients.length) { + for (const c of clients) c.postMessage({ type: "push-verification", ...payload }); + } else { + const cache = await caches.open(VERSION); + await cache.put(VERIFY_KEY, new Response(JSON.stringify(payload))); + } + })()); + return; + } + + const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : []; + event.waitUntil((async () => { + if (!emails.length) { + // A StateChange, or a payload too large to carry the message. Say + // something true rather than inventing a sender. + await self.registration.showNotification("New mail", { + icon: "/img/icon-192.png", badge: "/img/favicon-64.png", tag: "ihasmail-mail", data: { url: "/mail" }, + }); + return; + } + // One notification per message, collapsing repeats of the same message by + // tag so a re-push does not stack. + for (const email of emails.slice(0, 5)) { + const { title, body, preview } = textOf(email); + await self.registration.showNotification(title, { + body: preview ? `${body}\n${preview}` : body, + icon: "/img/icon-192.png", + badge: "/img/favicon-64.png", + tag: `ihasmail-${email.id || body}`, + data: { url: email.id ? `/mail/inbox/${email.id}` : "/mail" }, + }); + } + })()); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const url = event.notification.data?.url || "/mail"; + event.waitUntil((async () => { + const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" }); + // Reuse a tab if one is open rather than piling up windows. + for (const c of clients) { + if (new URL(c.url).origin === self.location.origin) { + await c.focus(); + if ("navigate" in c) await c.navigate(url).catch(() => {}); + return; + } + } + await self.clients.openWindow(url); + })()); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 1524e31..2a48d53 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -19,6 +19,7 @@ import { ComposerDock } from "@/views/compose/ComposerDock"; import { setUnreadBadge } from "@/lib/notify"; import { useSettings, syncedPart } from "@/store/settings"; import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync"; +import { listenForVerification } from "@/lib/webpushEnable"; const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView }))); const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView }))); @@ -87,6 +88,9 @@ function AuthedApp() { void useFiles.getState().init(); void useSieve.getState().init(); push.start(); + // A push subscription stays silent until its verification code is echoed + // back, and the code may have arrived while no tab was open. + listenForVerification(); const pending = new Map>(); let timer: number | null = null; const unsub = push.subscribe((acct, type) => { diff --git a/web/src/lib/__tests__/webpush.test.ts b/web/src/lib/__tests__/webpush.test.ts new file mode 100644 index 0000000..780977f --- /dev/null +++ b/web/src/lib/__tests__/webpush.test.ts @@ -0,0 +1,130 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { client } from "@/jmap/client"; +import { + applicationServerKey, + decodeApplicationServerKey, + encodeKey, + subscriptionPayload, + supportsEmailPush, + webPushAvailable, +} from "@/lib/webpush"; +import type { JmapSession } from "@/jmap/types"; + +/** + * The key encoding is where this breaks silently. `subscribe()` fails with an + * opaque error on a mis-decoded VAPID key, and Stalwart 0.16 had to be fixed to + * accept the *unpadded* base64url the W3C Push API produces — so re-padding on + * the way out would be sending a shape the server has not been tested against. + * + * The real key from the live 0.16.19 is used below rather than a made-up one: + * its length is what exercises the padding arithmetic. + */ +const LIVE_KEY = "BBvig2GPmqohMJJHMzp6bTKviHibYiVCyAY8gdq2fPhS-9YfO9_0TnhMyZ0a0JxTsbCqd3zm1rEiXsXsL3jveJY"; + +function session(caps: Record): JmapSession { + return { capabilities: caps, accounts: {}, primaryAccounts: {}, state: "s" } as unknown as JmapSession; +} + +afterEach(() => { + client.session = null; + vi.unstubAllGlobals(); +}); + +describe("the VAPID key", () => { + it("is read from the capability the server publishes", () => { + client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } }); + expect(applicationServerKey()).toBe(LIVE_KEY); + }); + + it("is null when the server does not do Web Push, rather than an empty string", () => { + client.session = session({ "urn:ietf:params:jmap:core": {} }); + expect(applicationServerKey()).toBeNull(); + }); + + it("decodes to the 65 bytes of an uncompressed P-256 point", () => { + const buf = decodeApplicationServerKey(LIVE_KEY); + expect(buf.byteLength).toBe(65); + // 0x04 marks an uncompressed EC point; the Push API rejects anything else. + expect(new Uint8Array(buf)[0]).toBe(0x04); + }); + + it("handles base64url without padding, which is how it arrives", () => { + expect(LIVE_KEY).not.toContain("="); + expect(LIVE_KEY).toMatch(/[-_]/); + expect(() => decodeApplicationServerKey(LIVE_KEY)).not.toThrow(); + }); + + it("returns an ArrayBuffer, which is what subscribe() accepts", () => { + expect(decodeApplicationServerKey(LIVE_KEY)).toBeInstanceOf(ArrayBuffer); + }); +}); + +describe("encoding keys for the server", () => { + it("produces unpadded base64url, the form Stalwart was fixed to accept", () => { + // 5 bytes: a length that would be padded with "===" in standard base64. + const buf = new Uint8Array([1, 2, 3, 4, 5]).buffer; + const out = encodeKey(buf); + expect(out).not.toContain("="); + expect(out).not.toContain("+"); + expect(out).not.toContain("/"); + }); + + it("round-trips through the decoder", () => { + const bytes = new Uint8Array([0, 255, 128, 64, 32, 16]); + expect(new Uint8Array(decodeApplicationServerKey(encodeKey(bytes.buffer)))).toEqual(bytes); + }); + + it("gives an empty string rather than throwing on a missing key", () => { + expect(encodeKey(null)).toBe(""); + }); +}); + +describe("what gets registered", () => { + const fakeSub = { + endpoint: "https://push.example/abc", + toJSON: () => ({ keys: { p256dh: "cGRoLWtleQ", auth: "YXV0aA" } }), + getKey: () => null, + } as unknown as PushSubscription; + + it("asks for the message itself when the server supports emailpush", () => { + client.session = session({ + "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY }, + "urn:ietf:params:jmap:emailpush": {}, + }); + const body = subscriptionPayload(fakeSub, "a1") as Record; + expect(body.url).toBe("https://push.example/abc"); + expect(body.keys).toEqual({ p256dh: "cGRoLWtleQ", auth: "YXV0aA" }); + expect(body.emailPush.a1.properties).toContain("subject"); + expect(body.emailPush.a1.properties).toContain("from"); + // Order is priority: the server drops from the end when the payload is + // too large, so the sender must outrank the preview. + const props: string[] = body.emailPush.a1.properties; + expect(props.indexOf("from")).toBeLessThan(props.indexOf("preview")); + }); + + it("omits emailPush entirely when the server does not support it", () => { + client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } }); + expect(supportsEmailPush()).toBe(false); + expect(subscriptionPayload(fakeSub, "a1")).not.toHaveProperty("emailPush"); + }); + + it("omits emailPush when there is no account to scope it to", () => { + client.session = session({ + "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY }, + "urn:ietf:params:jmap:emailpush": {}, + }); + expect(subscriptionPayload(fakeSub, null)).not.toHaveProperty("emailPush"); + }); + + it("subscribes to Email changes only, since EventSource covers an open tab", () => { + client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } }); + expect((subscriptionPayload(fakeSub, "a1") as Record).types).toEqual(["Email"]); + }); +}); + +describe("availability", () => { + it("is false without a push key, however capable the browser", () => { + client.session = session({ "urn:ietf:params:jmap:core": {} }); + expect(webPushAvailable()).toBe(false); + }); +}); diff --git a/web/src/lib/webpush.ts b/web/src/lib/webpush.ts new file mode 100644 index 0000000..11700e5 --- /dev/null +++ b/web/src/lib/webpush.ts @@ -0,0 +1,186 @@ +/** + * Web Push: notifications that arrive when ihasmail is not open. + * + * The existing EventSource channel only lives as long as a tab does, so + * "desktop notifications" have really meant "while you are looking". Stalwart + * 0.16 signs Web Push with VAPID (RFC 9749) and can carry the message itself in + * the payload (draft-ietf-jmap-emailpush), so the browser's own push service + * delivers a useful notification with ihasmail closed. + * + * Nothing in this path touches ihasmail's server. Stalwart talks to the push + * service directly; the only thing proxied is the JMAP call that registers the + * subscription. That is deliberate — it is why this needs no relay, no extra + * service to run, and no third party beyond the browser vendor's push endpoint + * that Web Push requires of everyone. + * + * Verified against the live 0.16.19 before this was written: the server + * publishes a real `applicationServerKey`, and `PushSubscription/get` answers a + * normal user rather than refusing them. + */ +import { CAP, client } from "@/jmap/client"; +import type { GetResponse, Id, SetResponse } from "@/jmap/types"; + +export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid"; +export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush"; + +/** Which Email properties to put in the payload, best first. */ +const PAYLOAD_PROPS = ["from", "subject", "preview", "receivedAt"]; + +export interface JmapPushSubscription { + id: Id; + deviceClientId: string; + url: string; + expires: string | null; + verificationCode?: string | null; +} + +/** The VAPID key this server signs with, or null if it does not do Web Push. */ +export function applicationServerKey(): string | null { + const cap = client.session?.capabilities?.[VAPID_CAP] as { applicationServerKey?: string } | undefined; + return typeof cap?.applicationServerKey === "string" ? cap.applicationServerKey : null; +} + +/** Whether the payload can carry the message, rather than only "something changed". */ +export function supportsEmailPush(): boolean { + return Boolean(client.session?.capabilities && EMAILPUSH_CAP in client.session.capabilities); +} + +/** Whether this browser and this server can do Web Push at all. */ +export function webPushAvailable(): boolean { + return ( + typeof navigator !== "undefined" && + "serviceWorker" in navigator && + typeof window !== "undefined" && + "PushManager" in window && + applicationServerKey() !== null + ); +} + +/** + * The VAPID key as the Push API wants it. + * + * It arrives base64url and unpadded; `atob` needs standard base64 with padding. + * Getting this wrong fails at subscribe() with an opaque error, which is the + * sort of thing worth doing in one place with a name. + */ +export function decodeApplicationServerKey(key: string): ArrayBuffer { + const padded = key.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (key.length % 4)) % 4); + const raw = atob(padded); + // An ArrayBuffer rather than a Uint8Array: TypeScript 5.7 types the latter + // over ArrayBufferLike, which no longer satisfies BufferSource, and + // subscribe() wants a BufferSource. + const buffer = new ArrayBuffer(raw.length); + const out = new Uint8Array(buffer); + for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); + return buffer; +} + +/** + * Base64url, unpadded — the form the W3C Push API produces for its keys. + * + * Stalwart 0.16 had to be fixed to accept unpadded keys, so this deliberately + * does not pad: sending what the browser gave us is the case the server now + * handles, and re-padding would be inventing a shape nobody tested. + */ +export function encodeKey(buffer: ArrayBuffer | null): string { + if (!buffer) return ""; + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** A stable id for this browser, so a re-subscribe replaces rather than piles up. */ +export function deviceClientId(): string { + const KEY = "ihasmail:pushDeviceId"; + try { + const existing = localStorage.getItem(KEY); + if (existing) return existing; + const made = `ihasmail-${crypto.randomUUID()}`; + localStorage.setItem(KEY, made); + return made; + } catch { + // Private mode: a per-session id still works, it just will not be reused. + return `ihasmail-${Math.random().toString(36).slice(2)}`; + } +} + +/** What to send Stalwart for a browser subscription. */ +export function subscriptionPayload(sub: PushSubscription, accountId: Id | null): Record { + const json = sub.toJSON(); + const body: Record = { + deviceClientId: deviceClientId(), + url: sub.endpoint, + keys: { p256dh: json.keys?.p256dh ?? encodeKey(sub.getKey("p256dh")), auth: json.keys?.auth ?? encodeKey(sub.getKey("auth")) }, + // StateChange notifications are not wanted: the app already has EventSource + // while it is open, and this channel exists for when it is not. + types: ["Email"], + }; + if (accountId && supportsEmailPush()) { + body.emailPush = { + [accountId]: { + // Only mail that actually lands in the inbox. Filtering here rather + // than in the service worker means spam never leaves the server. + filter: { inMailbox: null, notKeyword: "$seen" }, + properties: PAYLOAD_PROPS, + urgency: "normal", + }, + }; + } + return body; +} + +export async function listSubscriptions(): Promise { + const res = await client.call>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]); + return res.list; +} + +export async function createSubscription(body: Record): Promise { + const res = await client.call>( + "PushSubscription/set", + { create: { s: body } }, + [CAP.core, VAPID_CAP, EMAILPUSH_CAP], + ); + if (res.notCreated?.s) throw new Error(String(res.notCreated.s.description ?? res.notCreated.s.type)); + return (res.created?.s as { id?: Id } | undefined)?.id ?? null; +} + +/** + * Hand back the code the server pushed. + * + * A JMAP push subscription delivers nothing until this round-trip completes — + * the server sends a code over the channel to prove it reaches this client, and + * the client echoes it. A subscription left unverified looks registered and is + * silent, which is the confusing failure worth being explicit about. + */ +export async function verifySubscription(id: Id, verificationCode: string): Promise { + const res = await client.call>( + "PushSubscription/set", + { update: { [id]: { verificationCode } } }, + [CAP.core, VAPID_CAP], + ); + const err = res.notUpdated?.[id]; + if (err) throw new Error(String(err.description ?? err.type)); +} + +export async function destroySubscription(id: Id): Promise { + await client.call>("PushSubscription/set", { destroy: [id] }, [CAP.core, VAPID_CAP]); +} + +/** Remove every subscription this browser registered. Used when signing out. */ +export async function unsubscribeThisDevice(): Promise { + const mine = deviceClientId(); + try { + const reg = await navigator.serviceWorker?.getRegistration(); + const sub = await reg?.pushManager.getSubscription(); + await sub?.unsubscribe(); + } catch { + /* the browser end is gone or was never there; still clear the server end */ + } + try { + const subs = await listSubscriptions(); + for (const s of subs) if (s.deviceClientId === mine) await destroySubscription(s.id); + } catch { + /* signing out must not fail over this */ + } +} diff --git a/web/src/lib/webpushEnable.ts b/web/src/lib/webpushEnable.ts new file mode 100644 index 0000000..7285a68 --- /dev/null +++ b/web/src/lib/webpushEnable.ts @@ -0,0 +1,103 @@ +/** + * Turning Web Push on and off, and completing the handshake it needs. + * + * Kept apart from `webpush.ts` so that module stays pure JMAP and stays + * testable: everything here touches the browser's service worker and + * permission prompt, none of which exists under a test runner. + */ +import { CAP } from "@/jmap/client"; +import { useSession } from "@/store/session"; +import { + applicationServerKey, + createSubscription, + decodeApplicationServerKey, + listSubscriptions, + subscriptionPayload, + unsubscribeThisDevice, + verifySubscription, + webPushAvailable, +} from "@/lib/webpush"; + +let listening = false; + +/** + * Watch for the verification code the server pushes. + * + * The service worker cannot answer it — a JMAP call needs the session cookie + * and this is a background context — so it forwards the code here, or leaves it + * in the cache when no tab was open to forward it to. + */ +export function listenForVerification(): void { + if (listening || typeof navigator === "undefined" || !("serviceWorker" in navigator)) return; + listening = true; + navigator.serviceWorker.addEventListener("message", (e: MessageEvent) => { + const d = e.data as { type?: string; id?: string; code?: string } | undefined; + if (d?.type === "push-verification" && d.id && d.code) void verifySubscription(d.id, d.code).catch(() => {}); + }); + void collectStoredVerification(); +} + +/** Pick up a code that arrived while no tab was open. */ +async function collectStoredVerification(): Promise { + try { + const cache = await caches.open("ihasmail-v2"); + const hit = await cache.match("ihasmail-push-verification"); + if (!hit) return; + const { id, code } = (await hit.json()) as { id?: string; code?: string }; + await cache.delete("ihasmail-push-verification"); + if (id && code) await verifySubscription(id, code); + } catch { + /* nothing waiting, or no cache: not a failure */ + } +} + +/** + * Subscribe this browser. Safe to call again — the deviceClientId makes a + * repeat replace rather than accumulate. + * + * Returns why it could not, rather than throwing, because every reason is + * something to tell the user plainly: an old server, a browser without push, a + * permission they declined. + */ +export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reason: string }> { + if (!webPushAvailable()) { + return { ok: false, reason: "This browser or mail server does not support background notifications." }; + } + if (Notification.permission === "denied") { + return { ok: false, reason: "Notifications are blocked for this site in your browser's settings." }; + } + const key = applicationServerKey(); + if (!key) return { ok: false, reason: "This mail server does not publish a push key." }; + + try { + const reg = await navigator.serviceWorker.ready; + const existing = await reg.pushManager.getSubscription(); + const sub = existing ?? (await reg.pushManager.subscribe({ + // Web Push requires it, and Chrome refuses a subscription without it. + userVisibleOnly: true, + applicationServerKey: decodeApplicationServerKey(key), + })); + const accountId = useSession.getState().accountFor(CAP.mail); + await createSubscription(subscriptionPayload(sub, accountId)); + listenForVerification(); + return { ok: true }; + } catch (err) { + return { ok: false, reason: (err as Error).message || "Could not subscribe to notifications." }; + } +} + +/** Remove this browser's subscription, at the browser and at the server. */ +export async function disableWebPush(): Promise { + await unsubscribeThisDevice(); +} + +/** Whether this browser currently has a verified subscription registered. */ +export async function webPushActive(): Promise { + try { + const reg = await navigator.serviceWorker?.getRegistration(); + if (!(await reg?.pushManager.getSubscription())) return false; + return (await listSubscriptions()).length > 0; + } catch { + return false; + } +} diff --git a/web/src/store/session.ts b/web/src/store/session.ts index aafadbc..1eb9f5d 100644 --- a/web/src/store/session.ts +++ b/web/src/store/session.ts @@ -4,6 +4,7 @@ import type { Id, JmapSession } from "@/jmap/types"; import { push, type PushState } from "@/jmap/push"; import { setServerLocale } from "@/lib/datetime"; import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync"; +import { unsubscribeThisDevice } from "@/lib/webpush"; export type AuthStatus = "loading" | "anonymous" | "authenticated"; @@ -62,6 +63,14 @@ export const useSession = create((set, get) => ({ } catch { /* ignore */ } + // A push subscription lives on the account, not the session, so signing out + // without removing it leaves this browser notifying for a mailbox nobody is + // signed into. On a shared machine that is somebody else's mail. + try { + await unsubscribeThisDevice(); + } catch { + /* never block signing out over this */ + } stopSettingsSync(); try { await apiFetch("/api/auth/logout", { method: "POST" }); diff --git a/web/src/views/settings/NotificationsSettings.tsx b/web/src/views/settings/NotificationsSettings.tsx index 2cd4ce0..2fa5e58 100644 --- a/web/src/views/settings/NotificationsSettings.tsx +++ b/web/src/views/settings/NotificationsSettings.tsx @@ -3,12 +3,21 @@ import { useSettings } from "@/store/settings"; import { Switch } from "@/ui/misc"; import { requestNotificationPermission, showNotification, playNewMailSound } from "@/lib/notify"; import { useSession } from "@/store/session"; +import { disableWebPush, enableWebPush, webPushActive } from "@/lib/webpushEnable"; +import { supportsEmailPush, webPushAvailable } from "@/lib/webpush"; +import { toast } from "@/ui/toast"; export function NotificationsSettings() { const s = useSettings((st) => st.settings); const update = useSettings((st) => st.update); const pushConnected = useSession((st) => st.pushConnected); const [perm, setPerm] = useState("Notification" in window ? Notification.permission : "unsupported"); + const [background, setBackground] = useState(false); + const [busy, setBusy] = useState(false); + const canBackground = webPushAvailable(); + useEffect(() => { + void webPushActive().then(setBackground); + }, []); useEffect(() => { if ("Notification" in window) setPerm(Notification.permission); }, [s.desktopNotifications]); @@ -26,10 +35,46 @@ export function NotificationsSettings() { } update({ desktopNotifications: v }); }} - label="Desktop notifications for new mail" + label="Desktop notifications while ihasmail is open" hint={perm === "denied" ? "Notifications are blocked in your browser settings." : perm === "unsupported" ? "Not supported in this browser." : "Shows a system notification when new mail arrives in your Inbox while the tab is in the background."} disabled={perm === "denied" || perm === "unsupported"} /> + {/* + The distinction worth drawing for the user: the switch above needs a tab + open, this one does not. Everything before this shipped only the first + kind, while calling it "desktop notifications". + */} + { + setBusy(true); + try { + if (v) { + const p = await requestNotificationPermission(); + setPerm(p); + if (p !== "granted") return; + const res = await enableWebPush(); + if (!res.ok) { toast.error(res.reason); return; } + setBackground(true); + toast.success("Background notifications are on"); + } else { + await disableWebPush(); + setBackground(false); + } + } finally { + setBusy(false); + } + }} + label="Notify me even when ihasmail is closed" + hint={ + !canBackground + ? "Needs a browser with the Push API and a mail server that publishes a push key." + : supportsEmailPush() + ? "Your mail server delivers these directly to your browser, so they arrive with no tab open. The sender and subject travel in the notification." + : "Your mail server can wake this browser, but will not include the sender or subject." + } + /> update({ notificationSound: v })} label="Play a sound for new mail" />