fix: renew push subscriptions before Stalwart's 7-day expiry

Bulwark asks for a 90-day push subscription, but Stalwart clamps
`expires` to 7 days. The background renewal ran once per page load,
so a tab or installed app left open for more than a week let the
subscription lapse and notifications stopped without a hint.

The renewal now runs at most once a day instead of once per load, and
also when the tab comes back to the foreground.
This commit is contained in:
Linus Rath
2026-09-22 21:17:48 +02:00
parent ce1804e5a9
commit b217dba461
3 changed files with 33 additions and 14 deletions
+13 -5
View File
@@ -118,11 +118,19 @@ export function PushNotificationPrompt() {
// live registration stale.
useEffect(() => {
if (!policyLoaded || !isAuthenticated || !client || !accountId || isDemoMode) return;
void resyncWebPush({
client,
relayBaseUrl,
accountLabel: username ?? undefined,
});
const resync = () => {
void resyncWebPush({
client,
relayBaseUrl,
accountLabel: username ?? undefined,
});
};
resync();
// A long-open tab comes back to the foreground: renew the subscription
// before the server's 7-day expiry (resyncWebPush runs at most daily).
const onVisible = () => { if (document.visibilityState === 'visible') resync(); };
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
}, [accountId, client, isAuthenticated, isDemoMode, policyLoaded, relayBaseUrl, username]);
useEffect(() => {
+8 -2
View File
@@ -234,16 +234,22 @@ describe('resyncWebPush', () => {
expect(client.createPushSubscription).not.toHaveBeenCalled();
});
it('runs at most once per account per page load', async () => {
it('runs at most once a day per account', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true });
installFetch({});
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000);
expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(true);
expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(false);
expect(client.listPushSubscriptions).toHaveBeenCalledTimes(1);
// A tab left open past a day renews again, before Stalwart's 7-day expiry.
now.mockReturnValue(1_000_000 + 25 * 60 * 60 * 1000);
expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(true);
expect(client.listPushSubscriptions).toHaveBeenCalledTimes(2);
now.mockRestore();
});
it('swallows failures instead of surfacing them to the app', async () => {
+12 -7
View File
@@ -651,10 +651,13 @@ export async function isWebPushEnabled(accountId: string): Promise<boolean> {
return sub !== null && localStorage.getItem(subscriptionIdKey(accountId)) !== null;
}
// Accounts already re-synced during this page load. One pass per account is
// plenty: the subscription only drifts between sessions (expiry, a client
// update that changed what we subscribe to, a recreated Junk mailbox).
const resyncedAccountIds = new Set<string>();
// When each account was last re-synced during this page load. Once a day is
// enough for the drift between sessions (a client update that changed what
// we subscribe to, a recreated Junk mailbox), but not only once: Stalwart
// clamps `expires` to 7 days, so a tab or installed app left open for a
// week would otherwise let the subscription lapse and push stop silently.
const RESYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
const lastResyncAt = new Map<string, number>();
export interface ResyncWebPushParams {
client: IJMAPClient;
@@ -676,10 +679,12 @@ export async function resyncWebPush(params: ResyncWebPushParams): Promise<boolea
} catch {
return false;
}
if (!accountId || resyncedAccountIds.has(accountId)) return false;
if (!accountId) return false;
const last = lastResyncAt.get(accountId);
if (last !== undefined && Date.now() - last < RESYNC_INTERVAL_MS) return false;
try {
if (!(await isWebPushEnabled(accountId))) return false;
resyncedAccountIds.add(accountId);
lastResyncAt.set(accountId, Date.now());
await enableWebPush({
client: params.client,
relayBaseUrl: params.relayBaseUrl,
@@ -693,5 +698,5 @@ export async function resyncWebPush(params: ResyncWebPushParams): Promise<boolea
// Test hook: forget which accounts were re-synced during this page load.
export function resetWebPushResyncState(): void {
resyncedAccountIds.clear();
lastResyncAt.clear();
}