diff --git a/components/__tests__/push-notification-prompt.test.tsx b/components/__tests__/push-notification-prompt.test.tsx index 701882002..d09e7d83b 100644 --- a/components/__tests__/push-notification-prompt.test.tsx +++ b/components/__tests__/push-notification-prompt.test.tsx @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ supported: true, enableWebPush: vi.fn(), isWebPushEnabled: vi.fn(), + resyncWebPush: vi.fn(), authState: { client: { getAccountId: () => "account-1" } as { getAccountId: () => string; @@ -53,6 +54,7 @@ vi.mock("@/lib/web-push", () => ({ enableWebPush: mocks.enableWebPush, isWebPushSupported: () => mocks.supported, isWebPushEnabled: mocks.isWebPushEnabled, + resyncWebPush: mocks.resyncWebPush, })); function setNotificationPermission(permission: NotificationPermission) { @@ -92,6 +94,7 @@ describe("PushNotificationPrompt", () => { mocks.policyState.policy.pushRelayUrlLocked = false; mocks.enableWebPush.mockReset().mockResolvedValue({ subscriptionId: "sub-1" }); mocks.isWebPushEnabled.mockReset().mockImplementation(async () => mocks.enabled); + mocks.resyncWebPush.mockReset().mockResolvedValue(false); setNotificationPermission("default"); }); @@ -170,6 +173,29 @@ describe("PushNotificationPrompt", () => { expect(screen.queryByText("title")).not.toBeInTheDocument(); }); + it("re-syncs an existing registration in the background on mount", async () => { + mocks.enabled = true; + render(); + await advancePromptDelay(); + + expect(mocks.resyncWebPush).toHaveBeenCalledTimes(1); + expect(mocks.resyncWebPush).toHaveBeenCalledWith( + expect.objectContaining({ + relayBaseUrl: "https://notifications.relay.bulwarkmail.org", + }), + ); + // Only the explicit Enable button may start the interactive flow. + expect(mocks.enableWebPush).not.toHaveBeenCalled(); + }); + + it("does not touch push in demo mode", async () => { + mocks.authState.isDemoMode = true; + render(); + await advancePromptDelay(); + + expect(mocks.resyncWebPush).not.toHaveBeenCalled(); + }); + it("does not render when browser notifications are blocked", async () => { setNotificationPermission("denied"); render(); diff --git a/components/push-notification-prompt.tsx b/components/push-notification-prompt.tsx index 8e5f2af6c..e2de6e1f7 100644 --- a/components/push-notification-prompt.tsx +++ b/components/push-notification-prompt.tsx @@ -11,6 +11,7 @@ import { enableWebPush, isWebPushEnabled, isWebPushSupported, + resyncWebPush, } from "@/lib/web-push"; import { resolveActiveRelayUrl } from "@/lib/push-relays"; import { @@ -109,6 +110,21 @@ export function PushNotificationPrompt() { }; }, []); + // Accounts that already have push on get their registration touched up in + // the background once per page load: expiry refreshed and the server-side + // delivery filter installed or repaired (older registrations predate it and + // would keep waking the device for spam). Deliberately independent of the + // prompt gating below - dismissing the onboarding prompt must not leave a + // live registration stale. + useEffect(() => { + if (!policyLoaded || !isAuthenticated || !client || !accountId || isDemoMode) return; + void resyncWebPush({ + client, + relayBaseUrl, + accountLabel: username ?? undefined, + }); + }, [accountId, client, isAuthenticated, isDemoMode, policyLoaded, relayBaseUrl, username]); + useEffect(() => { let cancelled = false; let timer: ReturnType | null = null; diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index adfa7c96d..369266b2a 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -5,6 +5,8 @@ import { disableWebPush, enableWebPush, listPushDevices, + resetWebPushResyncState, + resyncWebPush, revokePushDevice, } from '@/lib/web-push'; @@ -153,6 +155,7 @@ function installFetch(activeByDevice: Record) { beforeEach(() => { localStorage.clear(); + resetWebPushResyncState(); installPushBrowser(); }); @@ -201,6 +204,63 @@ describe('enableWebPush', () => { }); }); +// Registrations made before the client learned about the delivery filter are +// repaired in the background on app start - nobody should have to find the +// settings toggle to stop the spam pushes. +describe('resyncWebPush', () => { + it('re-syncs an enabled registration and installs the missing filter', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-old'); + const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true }); + installFetch({}); + + expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(true); + + expect(client.createPushSubscription).not.toHaveBeenCalled(); + expect(client.updatePushSubscription).toHaveBeenCalledWith( + 'push-old', + expect.objectContaining({ emailPush: EXPECTED_EMAIL_PUSH }), + ); + }); + + it('does nothing when push is not enabled for the account', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + const client = makeClient([], { emailPushCapability: true }); + installFetch({}); + + expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(false); + + expect(client.listPushSubscriptions).not.toHaveBeenCalled(); + expect(client.createPushSubscription).not.toHaveBeenCalled(); + }); + + it('runs at most once per account per page load', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-old'); + const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true }); + installFetch({}); + + expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(true); + expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(false); + + expect(client.listPushSubscriptions).toHaveBeenCalledTimes(1); + }); + + it('swallows failures instead of surfacing them to the app', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-old'); + const client = makeClient([sub('push-old', THIS_DEVICE)]); + client.listPushSubscriptions = vi.fn(async () => { + throw new Error('server down'); + }); + vi.stubGlobal('fetch', vi.fn(async () => { + throw new Error('relay down'); + })); + + await expect(resyncWebPush({ client, relayBaseUrl: RELAY })).resolves.toBe(false); + }); +}); + // Spam filed straight into Junk used to wake the device: `EmailDelivery` // fires for every ingested message. Servers that implement // draft-ietf-jmap-emailpush evaluate a per-account filter before pushing, so diff --git a/lib/web-push.ts b/lib/web-push.ts index 4c30c2745..48e48ebd5 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -646,3 +646,48 @@ export async function isWebPushEnabled(accountId: string): Promise { const sub = await registration.pushManager.getSubscription(); 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(); + +export interface ResyncWebPushParams { + client: IJMAPClient; + relayBaseUrl?: string; + accountLabel?: string; +} + +/** + * Bring an already-enabled push registration up to date without any user + * action: refresh its expiry and install/repair the delivery filter. Nothing + * here can prompt - it only runs when push is already on for the account - + * and every failure is swallowed because the app must not care whether the + * background touch-up worked. Returns true when a re-sync actually ran. + */ +export async function resyncWebPush(params: ResyncWebPushParams): Promise { + let accountId: string; + try { + accountId = params.client.getAccountId(); + } catch { + return false; + } + if (!accountId || resyncedAccountIds.has(accountId)) return false; + try { + if (!(await isWebPushEnabled(accountId))) return false; + resyncedAccountIds.add(accountId); + await enableWebPush({ + client: params.client, + relayBaseUrl: params.relayBaseUrl, + accountLabel: params.accountLabel, + }); + return true; + } catch { + return false; + } +} + +// Test hook: forget which accounts were re-synced during this page load. +export function resetWebPushResyncState(): void { + resyncedAccountIds.clear(); +}