mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
@@ -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(() => {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user