diff --git a/FEATURES.md b/FEATURES.md index d7a1a22..375fd09 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -658,6 +658,17 @@ you to the one you were on. run. Where the server also implements `emailpush`, the payload carries the sender, subject and preview; without it the notification says only that mail arrived. Offered only on a device you said was yours. +- **The subscription is renewed on every app start**, because a JMAP push + subscription expires — seven days is the ceiling — and re-registering before + it lapses is the client's job. Renewal can only happen with a page open: + registering is a JMAP call and the service worker has no session to make one + with. So the guarantee is that background notifications keep working as long + as ihasmail is opened now and again, and the two-day renewal window means + once a week is enough. A browser that dropped or rotated its subscription on + its own is re-subscribed at the same moment, rather than left with a switch + that says push is on and a browser that is no longer listening. + Registration is per browser, not per account: a phone having push does not + make it on for the desktop, and each device tracks its own. - **Stale build reload** — when the server starts serving a build the open tab did not come from, the tab reloads itself rather than going on talking to a newer server with older JavaScript. It waits for a moment that is safe: an diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 5fda3a7..cbd7efd 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -27,6 +27,8 @@ const NO_FUTURE_RELEASE = process.env.MOCK_NO_FUTURE_RELEASE === "1"; /** What the session advertises, matching Stalwart's own 30 days. */ const MAX_DELAYED_SEND = 86400 * 30; const ACCOUNT = "a1"; +/** How long a push subscription lives before the server drops it. */ +const PUSH_TTL_MS = 7 * 24 * 60 * 60 * 1000; /** An account somebody has shared with the demo user. See the session below. */ const SHARED_ACCOUNT = "a2"; const SHARED_CAPS: Obj = { @@ -772,8 +774,18 @@ const handlers: Record = { 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 }; + /* + * A subscription expires, and this used to hand back `expires: null`. + * That is the one shape that makes the client's real problem invisible in + * development: JMAP puts a ceiling of seven days on a push subscription + * and expects the client to re-register before it lapses, so a client + * that never renews works perfectly against a mock that never expires + * anything and goes silent a week after being deployed. Seven days here, + * so "does this client renew?" is a question the mock can answer. + */ + const expires = new Date(Date.now() + PUSH_TTL_MS).toISOString(); + pushSubscriptions.push({ id, deviceClientId: deviceId, url: o.url, types: o.types ?? null, emailPush: o.emailPush ?? null, expires, keys, verified: false, code: `v${randomUUID().slice(0, 8)}` }); + created[cid] = { id, expires }; state.n++; } for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) { diff --git a/web/src/App.tsx b/web/src/App.tsx index 2a48d53..25d1c16 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -19,7 +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"; +import { listenForVerification, renewWebPush } 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 }))); @@ -91,6 +91,16 @@ function AuthedApp() { // A push subscription stays silent until its verification code is echoed // back, and the code may have arrived while no tab was open. listenForVerification(); + /* + * And a subscription expires -- seven days is the ceiling JMAP puts on one, + * and re-registering before that is the client's job. Nothing did it, so + * background notifications lapsed within a week of being switched on and + * only came back if somebody + * happened to toggle the switch. Opening the app is the only moment this + * can be done -- registering is a JMAP call, and the service worker has no + * session to make one with -- so it is done on every start. + */ + void renewWebPush(); 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 index 40b50f4..668e243 100644 --- a/web/src/lib/__tests__/webpush.test.ts +++ b/web/src/lib/__tests__/webpush.test.ts @@ -4,9 +4,13 @@ import { applicationServerKey, decodeApplicationServerKey, encodeKey, + findSubscription, + needsRenewal, + RENEW_WITHIN_MS, subscriptionPayload, supportsEmailPush, webPushAvailable, + type JmapPushSubscription, } from "@/lib/webpush"; import type { JmapSession } from "@/jmap/types"; @@ -175,3 +179,64 @@ describe("the emailPush filter", () => { expect(filter.notKeyword).toBe("$seen"); }); }); + +/** + * Keeping a subscription alive. + * + * The failure this guards against leaves no trace anywhere: the switch says + * background notifications are on, the browser still holds a subscription, and + * the server quietly stopped delivering days ago because the registration + * expired and nothing renewed it. Nobody reports that as a bug — they report + * that push "doesn't really work". + */ +const sub = (deviceClientId: string, expires: string | null): JmapPushSubscription => + ({ id: `i-${deviceClientId}`, deviceClientId, url: "https://push.example/x", expires }); + +const MINE = "ihasmail-this-browser"; +const NOW = Date.parse("2026-09-01T12:00:00Z"); +const inDays = (n: number) => new Date(NOW + n * 24 * 60 * 60 * 1000).toISOString(); + +describe("finding this browser's subscription", () => { + it("matches on the device id rather than taking the first one", () => { + const subs = [sub("ihasmail-desktop", null), sub(MINE, null), sub("ihasmail-tablet", null)]; + expect(findSubscription(subs, MINE)?.deviceClientId).toBe(MINE); + }); + + it("finds nothing when only other devices are registered", () => { + // The bug this replaces: any subscription at all counted as this one, so a + // phone that had never registered read as already on and stayed silent. + expect(findSubscription([sub("ihasmail-desktop", null)], MINE)).toBe(null); + }); +}); + +describe("needsRenewal", () => { + it("renews when this browser is not registered at all", () => { + expect(needsRenewal([], MINE, NOW)).toBe(true); + expect(needsRenewal([sub("ihasmail-desktop", inDays(6))], MINE, NOW)).toBe(true); + }); + + it("leaves a subscription alone while it has time on it", () => { + expect(needsRenewal([sub(MINE, inDays(6))], MINE, NOW)).toBe(false); + expect(needsRenewal([sub(MINE, inDays(3))], MINE, NOW)).toBe(false); + }); + + it("renews inside the window, so a weekend does not lose it", () => { + expect(needsRenewal([sub(MINE, inDays(2))], MINE, NOW)).toBe(true); + expect(needsRenewal([sub(MINE, inDays(1))], MINE, NOW)).toBe(true); + expect(RENEW_WITHIN_MS).toBeLessThan(7 * 24 * 60 * 60 * 1000); + }); + + it("renews one that has already lapsed", () => { + expect(needsRenewal([sub(MINE, inDays(-1))], MINE, NOW)).toBe(true); + }); + + it("leaves a subscription with no expiry alone", () => { + // A server that never expires one has nothing to renew, and rewriting the + // registration on every cold start would be a JMAP call for nothing. + expect(needsRenewal([sub(MINE, null)], MINE, NOW)).toBe(false); + }); + + it("renews rather than trusts an expiry it cannot read", () => { + expect(needsRenewal([sub(MINE, "whenever")], MINE, NOW)).toBe(true); + }); +}); diff --git a/web/src/lib/webpush.ts b/web/src/lib/webpush.ts index 2c8cd3f..5e37a26 100644 --- a/web/src/lib/webpush.ts +++ b/web/src/lib/webpush.ts @@ -147,6 +147,73 @@ export function subscriptionPayload(sub: PushSubscription, accountId: Id | null, return body; } +/** + * Whether push was switched on *in this browser*. + * + * Device-local on purpose. A subscription is a browser and an endpoint, not an + * account: turning it on for a phone says nothing about the desktop, and the + * account-wide settings file is the wrong place to record it. It is also not in + * `KEEP_ON_SIGN_OUT`, so signing out forgets it, which matches sign-out already + * destroying the subscription itself. + */ +const ENABLED_KEY = "ihasmail:pushEnabled"; + +export function pushEnabledHere(): boolean { + if (!isDeviceTrusted()) return false; + try { + return localStorage.getItem(ENABLED_KEY) === "1"; + } catch { + return false; + } +} + +export function setPushEnabledHere(on: boolean): void { + try { + if (on) localStorage.setItem(ENABLED_KEY, "1"); + else localStorage.removeItem(ENABLED_KEY); + } catch { + /* private mode: push will not survive the session there anyway */ + } +} + +/** + * How close to expiry a subscription is re-registered rather than left alone. + * + * Two days against a ceiling of seven, so an app opened even once over a + * weekend keeps its notifications. Renewing is a single idempotent call, so + * being early costs almost nothing and being late costs everything. + */ +export const RENEW_WITHIN_MS = 2 * 24 * 60 * 60 * 1000; + +/** This browser's registered subscription, out of everything the account has. */ +export function findSubscription(subs: JmapPushSubscription[], deviceId: string): JmapPushSubscription | null { + return subs.find((s) => s.deviceClientId === deviceId) ?? null; +} + +/** + * Whether this browser's subscription needs registering again. + * + * A JMAP push subscription expires -- seven days is the ceiling -- and it is + * the client's job to re-register before it does. Nothing did: `enableWebPush` + * was reachable only from the Settings switch, so the + * first version of this quietly stopped delivering within a week of being + * turned on, and stayed off until somebody thought to toggle it. On a phone, + * where the app is opened for a minute at a time and Settings almost never, + * that is indistinguishable from the feature not working. + * + * An expiry that will not parse counts as needing renewal. It should never + * happen; if it does, one extra write is the cheaper way to be wrong. + */ +export function needsRenewal(subs: JmapPushSubscription[], deviceId: string, now: number = Date.now()): boolean { + const mine = findSubscription(subs, deviceId); + if (!mine) return true; + // No expiry: the server is not going to take it away, so leave it alone. + if (!mine.expires) return false; + const at = Date.parse(mine.expires); + if (Number.isNaN(at)) return true; + return at - now <= RENEW_WITHIN_MS; +} + export async function listSubscriptions(): Promise { const res = await client.call>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]); return res.list; @@ -200,4 +267,5 @@ export async function unsubscribeThisDevice(): Promise { } catch { /* signing out must not fail over this */ } + setPushEnabledHere(false); } diff --git a/web/src/lib/webpushEnable.ts b/web/src/lib/webpushEnable.ts index c84f222..b7e48de 100644 --- a/web/src/lib/webpushEnable.ts +++ b/web/src/lib/webpushEnable.ts @@ -13,7 +13,12 @@ import { applicationServerKey, createSubscription, decodeApplicationServerKey, + deviceClientId, + findSubscription, listSubscriptions, + needsRenewal, + pushEnabledHere, + setPushEnabledHere, subscriptionPayload, unsubscribeThisDevice, verifySubscription, @@ -78,16 +83,8 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso 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().ownAccountFor(CAP.mail); - const inboxId = useMail.getState().roleId("inbox"); - await createSubscription(subscriptionPayload(sub, accountId, inboxId)); + await registerThisBrowser(key); + setPushEnabledHere(true); listenForVerification(); return { ok: true }; } catch (err) { @@ -95,17 +92,77 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso } } +/** + * Get this browser subscribed at the push service and registered at Stalwart. + * + * Shared by turning push on and by renewing it, because they are the same + * call: `deviceClientId` makes a repeat registration replace rather than + * accumulate, so there is no separate "update" path to get wrong. + * + * The local subscription is created when it is missing rather than only reused. + * A browser may drop or rotate one on its own -- a `pushsubscriptionchange` + * nobody was open to hear -- and the version that only reused an existing one + * gave up there, leaving push off for good with the switch still saying it was + * on. + */ +async function registerThisBrowser(key: string): Promise { + const reg = await navigator.serviceWorker.ready; + const sub = (await reg.pushManager.getSubscription()) ?? (await reg.pushManager.subscribe({ + // Web Push requires it, and Chrome refuses a subscription without it. + userVisibleOnly: true, + applicationServerKey: decodeApplicationServerKey(key), + })); + const accountId = useSession.getState().ownAccountFor(CAP.mail); + const inboxId = useMail.getState().roleId("inbox"); + await createSubscription(subscriptionPayload(sub, accountId, inboxId)); +} + +/** + * Keep a subscription alive, from app start. + * + * Renewal has to happen here rather than in the service worker: registering + * with Stalwart is a JMAP call, and a JMAP call needs the session cookie that + * only a page has. So the guarantee is "push keeps working as long as ihasmail + * is opened now and again", and the renewal window is wide enough that once a + * week is enough. + * + * Silent by design. Every reason to stop is a normal state -- push was never + * turned on here, the permission is gone, the device is not trusted any more -- + * and none of them is news to deliver on a cold start. + */ +export async function renewWebPush(): Promise { + if (!pushEnabledHere() || !webPushAvailable()) return; + if (typeof Notification === "undefined" || Notification.permission !== "granted") return; + const key = applicationServerKey(); + if (!key) return; + try { + if (!needsRenewal(await listSubscriptions(), deviceClientId())) return; + await registerThisBrowser(key); + listenForVerification(); + } catch { + /* offline, or the server said no: the next start tries again */ + } +} + /** 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. */ +/** + * Whether *this browser* has a subscription registered at the server. + * + * The device has to match. This used to answer "does the account have any + * subscription at all", which is true the moment one other device has one -- + * so a phone that had never successfully registered, or whose registration had + * since expired, showed the switch already on and delivered nothing. The + * account-wide question is not one this switch is asking. + */ export async function webPushActive(): Promise { try { const reg = await navigator.serviceWorker?.getRegistration(); if (!(await reg?.pushManager.getSubscription())) return false; - return (await listSubscriptions()).length > 0; + return Boolean(findSubscription(await listSubscriptions(), deviceClientId())); } catch { return false; }