Notifications that arrive when ihasmail is closed

ihasmail's notifications came from EventSource, which lives exactly as
long as a tab does -- so "desktop notifications" has always quietly
meant "while you are looking". That switch is now labelled as much, and
a second one does the thing people assumed the first one did.

Stalwart 0.16 signs Web Push with VAPID (RFC 9749) and can put the
message itself in the payload (draft-ietf-jmap-emailpush). The server
pushes straight to the browser's own push service: ihasmail's server is
not in the delivery path, there is no relay to run, and nothing beyond
the browser vendor's endpoint that Web Push requires of everyone.

Checked against the live 0.16.19 before any of this was written, because
an advertised capability is not a configured one:

  - the session publishes a real applicationServerKey, so no key
    generation or server configuration is needed
  - PushSubscription/get answers an ordinary user rather than refusing
  - emailpush is advertised, and its draft defines a filter, an ordered
    properties list and an urgency -- so the payload can carry sender and
    subject, and the server drops properties from the end when it will
    not fit rather than failing the notification

Three things this gets right that are easy to get wrong:

  - The verification handshake. A JMAP subscription delivers nothing
    until the client echoes back a code the server pushed, and the
    service worker cannot answer it -- no credentials in that context.
    It forwards the code to a tab, or leaves it in the cache when no tab
    was open to forward it to.

  - Key encoding. The W3C Push API produces unpadded base64url and
    Stalwart 0.16 was fixed to accept exactly that, so nothing here pads
    on the way out. The VAPID key needs padding on the way *in* for
    atob; getting that backwards fails at subscribe() with an opaque
    error, so it lives in one named function with tests.

  - Sign-out. A subscription belongs to the account, not the session.
    Without tearing it down, a shared machine keeps notifying for a
    mailbox nobody is signed into -- which is somebody else's mail.

The mock models the JMAP half, including refusing padded keys and
non-https endpoints, and creating subscriptions *unverified*. Delivery
cannot be mocked -- it runs through the browser vendor's real push
service -- but a mock that marked a subscription verified on creation
would let a client ship without the handshake, and the symptom in
production is "registered, and silent".

Not verified end to end: an actual notification arriving. That needs a
real browser, a real push service and real delivery, so it is live
testing or nothing.
This commit is contained in:
2026-08-26 13:51:09 -07:00
parent e2f17f6f49
commit 96bc7b53d7
9 changed files with 648 additions and 4 deletions
+1
View File
@@ -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 - 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 - 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 - 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
- AZ 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 - AZ 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) **Calendar** (JMAP Calendars / JSCalendar)
+73 -1
View File
@@ -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 * 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. * 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[] = [ const mailboxes: Obj[] = [
mb("inbox", "Inbox", "inbox"), mb("inbox", "Inbox", "inbox"),
mb("drafts", "Drafts", "drafts"), mb("drafts", "Drafts", "drafts"),
@@ -476,6 +479,73 @@ const handlers: Record<string, Handler> = {
state.n++; state.n++;
return setResp({ updated: { singleton: null } }); 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/get": (a) => genericGet(account.appPasswords)(a),
"x:AppPassword/set": (a) => { "x:AppPassword/set": (a) => {
const created: Obj = {}; const created: Obj = {};
@@ -667,7 +737,9 @@ function readBody(req: IncomingMessage): Promise<Buffer> {
} }
const session = () => ({ 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": {} }) } } }, 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 }) }, 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, username: USER,
+96 -2
View File
@@ -1,5 +1,7 @@
/* ihasmail service worker: app-shell caching for installability & fast loads. /* ihasmail service worker.
API requests are never cached. */ 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 VERSION = "ihasmail-v2";
const SHELL = ["/", "/manifest.webmanifest", "/img/logo.png", "/img/icon-192.png", "/favicon.ico"]; 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))); 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);
})());
});
+4
View File
@@ -19,6 +19,7 @@ import { ComposerDock } from "@/views/compose/ComposerDock";
import { setUnreadBadge } from "@/lib/notify"; import { setUnreadBadge } from "@/lib/notify";
import { useSettings, syncedPart } from "@/store/settings"; import { useSettings, syncedPart } from "@/store/settings";
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync"; 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 ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView }))); const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
@@ -87,6 +88,9 @@ function AuthedApp() {
void useFiles.getState().init(); void useFiles.getState().init();
void useSieve.getState().init(); void useSieve.getState().init();
push.start(); 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<string, Set<string>>(); const pending = new Map<string, Set<string>>();
let timer: number | null = null; let timer: number | null = null;
const unsub = push.subscribe((acct, type) => { const unsub = push.subscribe((acct, type) => {
+130
View File
@@ -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<string, unknown>): 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<string, any>;
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<string, unknown>).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);
});
});
+186
View File
@@ -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<string, unknown> {
const json = sub.toJSON();
const body: Record<string, unknown> = {
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<JmapPushSubscription[]> {
const res = await client.call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]);
return res.list;
}
export async function createSubscription(body: Record<string, unknown>): Promise<Id | null> {
const res = await client.call<SetResponse<JmapPushSubscription>>(
"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<void> {
const res = await client.call<SetResponse<JmapPushSubscription>>(
"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<void> {
await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: [id] }, [CAP.core, VAPID_CAP]);
}
/** Remove every subscription this browser registered. Used when signing out. */
export async function unsubscribeThisDevice(): Promise<void> {
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 */
}
}
+103
View File
@@ -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<void> {
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<void> {
await unsubscribeThisDevice();
}
/** Whether this browser currently has a verified subscription registered. */
export async function webPushActive(): Promise<boolean> {
try {
const reg = await navigator.serviceWorker?.getRegistration();
if (!(await reg?.pushManager.getSubscription())) return false;
return (await listSubscriptions()).length > 0;
} catch {
return false;
}
}
+9
View File
@@ -4,6 +4,7 @@ import type { Id, JmapSession } from "@/jmap/types";
import { push, type PushState } from "@/jmap/push"; import { push, type PushState } from "@/jmap/push";
import { setServerLocale } from "@/lib/datetime"; import { setServerLocale } from "@/lib/datetime";
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync"; import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
import { unsubscribeThisDevice } from "@/lib/webpush";
export type AuthStatus = "loading" | "anonymous" | "authenticated"; export type AuthStatus = "loading" | "anonymous" | "authenticated";
@@ -62,6 +63,14 @@ export const useSession = create<SessionState>((set, get) => ({
} catch { } catch {
/* ignore */ /* 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(); stopSettingsSync();
try { try {
await apiFetch("/api/auth/logout", { method: "POST" }); await apiFetch("/api/auth/logout", { method: "POST" });
@@ -3,12 +3,21 @@ import { useSettings } from "@/store/settings";
import { Switch } from "@/ui/misc"; import { Switch } from "@/ui/misc";
import { requestNotificationPermission, showNotification, playNewMailSound } from "@/lib/notify"; import { requestNotificationPermission, showNotification, playNewMailSound } from "@/lib/notify";
import { useSession } from "@/store/session"; 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() { export function NotificationsSettings() {
const s = useSettings((st) => st.settings); const s = useSettings((st) => st.settings);
const update = useSettings((st) => st.update); const update = useSettings((st) => st.update);
const pushConnected = useSession((st) => st.pushConnected); const pushConnected = useSession((st) => st.pushConnected);
const [perm, setPerm] = useState<NotificationPermission | "unsupported">("Notification" in window ? Notification.permission : "unsupported"); const [perm, setPerm] = useState<NotificationPermission | "unsupported">("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(() => { useEffect(() => {
if ("Notification" in window) setPerm(Notification.permission); if ("Notification" in window) setPerm(Notification.permission);
}, [s.desktopNotifications]); }, [s.desktopNotifications]);
@@ -26,10 +35,46 @@ export function NotificationsSettings() {
} }
update({ desktopNotifications: v }); 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."} 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"} 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".
*/}
<Switch
checked={background}
disabled={!canBackground || busy || perm === "denied"}
onChange={async (v) => {
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."
}
/>
<Switch checked={s.notificationSound} onChange={(v) => update({ notificationSound: v })} label="Play a sound for new mail" /> <Switch checked={s.notificationSound} onChange={(v) => update({ notificationSound: v })} label="Play a sound for new mail" />
<div className="row mt-16"> <div className="row mt-16">
<button className="btn" onClick={() => { showNotification("ihasmail test", { body: "This is what a new-mail notification looks like." }); playNewMailSound(); }}>Test notification</button> <button className="btn" onClick={() => { showNotification("ihasmail test", { body: "This is what a new-mail notification looks like." }); playNewMailSound(); }}>Test notification</button>