Merge pull request #68 from LINUXexpert-org/web-push

Notifications that arrive when ihasmail is closed
This commit is contained in:
LINUXexpert.org
2026-08-26 13:54:18 -07:00
committed by GitHub
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
- 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
- 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)
+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
* 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<string, Handler> = {
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<Buffer> {
}
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,
+96 -2
View File
@@ -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);
})());
});
+4
View File
@@ -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<string, Set<string>>();
let timer: number | null = null;
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 { 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<SessionState>((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" });
@@ -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<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(() => {
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".
*/}
<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" />
<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>