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:
+11
@@ -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
|
||||
|
||||
@@ -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<string, Handler> = {
|
||||
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) ?? {})) {
|
||||
|
||||
+11
-1
@@ -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<string, Set<string>>();
|
||||
let timer: number | null = null;
|
||||
const unsub = push.subscribe((acct, type) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<JmapPushSubscription[]> {
|
||||
const res = await client.call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]);
|
||||
return res.list;
|
||||
@@ -200,4 +267,5 @@ export async function unsubscribeThisDevice(): Promise<void> {
|
||||
} catch {
|
||||
/* signing out must not fail over this */
|
||||
}
|
||||
setPushEnabledHere(false);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
applicationServerKey,
|
||||
createSubscription,
|
||||
decodeApplicationServerKey,
|
||||
deviceClientId,
|
||||
findSubscription,
|
||||
listSubscriptions,
|
||||
needsRenewal,
|
||||
pushEnabledHere,
|
||||
setPushEnabledHere,
|
||||
subscriptionPayload,
|
||||
unsubscribeThisDevice,
|
||||
verifySubscription,
|
||||
@@ -78,9 +83,31 @@ 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 {
|
||||
await registerThisBrowser(key);
|
||||
setPushEnabledHere(true);
|
||||
listenForVerification();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: (err as Error).message || "Could not subscribe to notifications." };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 existing = await reg.pushManager.getSubscription();
|
||||
const sub = existing ?? (await reg.pushManager.subscribe({
|
||||
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),
|
||||
@@ -88,10 +115,32 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
|
||||
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();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: (err as Error).message || "Could not subscribe to notifications." };
|
||||
} catch {
|
||||
/* offline, or the server said no: the next start tries again */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,12 +149,20 @@ export async function disableWebPush(): Promise<void> {
|
||||
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> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user