Merge pull request #143 from Coffey-Labs/push-renewal

Renew the push subscription, so it does not lapse in a week
This commit is contained in:
Coffey Labs
2026-08-31 08:14:32 -07:00
committed by GitHub
6 changed files with 238 additions and 15 deletions
+11
View File
@@ -658,6 +658,17 @@ you to the one you were on.
run. Where the server also implements `emailpush`, the payload carries the run. Where the server also implements `emailpush`, the payload carries the
sender, subject and preview; without it the notification says only that mail sender, subject and preview; without it the notification says only that mail
arrived. Offered only on a device you said was yours. 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 - **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 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 newer server with older JavaScript. It waits for a moment that is safe: an
+14 -2
View File
@@ -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. */ /** What the session advertises, matching Stalwart's own 30 days. */
const MAX_DELAYED_SEND = 86400 * 30; const MAX_DELAYED_SEND = 86400 * 30;
const ACCOUNT = "a1"; 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. */ /** An account somebody has shared with the demo user. See the session below. */
const SHARED_ACCOUNT = "a2"; const SHARED_ACCOUNT = "a2";
const SHARED_CAPS: Obj = { const SHARED_CAPS: Obj = {
@@ -772,8 +774,18 @@ const handlers: Record<string, Handler> = {
const clash = pushSubscriptions.findIndex((s) => s.deviceClientId === deviceId); const clash = pushSubscriptions.findIndex((s) => s.deviceClientId === deviceId);
if (clash >= 0) pushSubscriptions.splice(clash, 1); if (clash >= 0) pushSubscriptions.splice(clash, 1);
const id = `ps${randomUUID().slice(0, 6)}`; 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++; state.n++;
} }
for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) { for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) {
+11 -1
View File
@@ -19,7 +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"; import { listenForVerification, renewWebPush } 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 })));
@@ -91,6 +91,16 @@ function AuthedApp() {
// A push subscription stays silent until its verification code is echoed // A push subscription stays silent until its verification code is echoed
// back, and the code may have arrived while no tab was open. // back, and the code may have arrived while no tab was open.
listenForVerification(); 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<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) => {
+65
View File
@@ -4,9 +4,13 @@ import {
applicationServerKey, applicationServerKey,
decodeApplicationServerKey, decodeApplicationServerKey,
encodeKey, encodeKey,
findSubscription,
needsRenewal,
RENEW_WITHIN_MS,
subscriptionPayload, subscriptionPayload,
supportsEmailPush, supportsEmailPush,
webPushAvailable, webPushAvailable,
type JmapPushSubscription,
} from "@/lib/webpush"; } from "@/lib/webpush";
import type { JmapSession } from "@/jmap/types"; import type { JmapSession } from "@/jmap/types";
@@ -175,3 +179,64 @@ describe("the emailPush filter", () => {
expect(filter.notKeyword).toBe("$seen"); 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);
});
});
+68
View File
@@ -147,6 +147,73 @@ export function subscriptionPayload(sub: PushSubscription, accountId: Id | null,
return body; 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<JmapPushSubscription[]> { export async function listSubscriptions(): Promise<JmapPushSubscription[]> {
const res = await client.call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]); const res = await client.call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]);
return res.list; return res.list;
@@ -200,4 +267,5 @@ export async function unsubscribeThisDevice(): Promise<void> {
} catch { } catch {
/* signing out must not fail over this */ /* signing out must not fail over this */
} }
setPushEnabledHere(false);
} }
+69 -12
View File
@@ -13,7 +13,12 @@ import {
applicationServerKey, applicationServerKey,
createSubscription, createSubscription,
decodeApplicationServerKey, decodeApplicationServerKey,
deviceClientId,
findSubscription,
listSubscriptions, listSubscriptions,
needsRenewal,
pushEnabledHere,
setPushEnabledHere,
subscriptionPayload, subscriptionPayload,
unsubscribeThisDevice, unsubscribeThisDevice,
verifySubscription, 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." }; if (!key) return { ok: false, reason: "This mail server does not publish a push key." };
try { try {
const reg = await navigator.serviceWorker.ready; await registerThisBrowser(key);
const existing = await reg.pushManager.getSubscription(); setPushEnabledHere(true);
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));
listenForVerification(); listenForVerification();
return { ok: true }; return { ok: true };
} catch (err) { } 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<void> {
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<void> {
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. */ /** Remove this browser's subscription, at the browser and at the server. */
export async function disableWebPush(): Promise<void> { export async function disableWebPush(): Promise<void> {
await unsubscribeThisDevice(); 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<boolean> { export async function webPushActive(): Promise<boolean> {
try { try {
const reg = await navigator.serviceWorker?.getRegistration(); const reg = await navigator.serviceWorker?.getRegistration();
if (!(await reg?.pushManager.getSubscription())) return false; if (!(await reg?.pushManager.getSubscription())) return false;
return (await listSubscriptions()).length > 0; return Boolean(findSubscription(await listSubscriptions(), deviceClientId()));
} catch { } catch {
return false; return false;
} }