From 3a805d1631ec3394b5149d205d99d7880dc32482 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:36:42 +0100 Subject: [PATCH] fix: restart push verification when the mail server skipped it after a quick off and on --- lib/__tests__/web-push.test.ts | 35 ++++++++++++++++++++++++++++++++++ lib/web-push.ts | 29 ++++++++++++++++++++++------ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index ac95771cf..b02630fa9 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -472,6 +472,41 @@ 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('enableWebPush when the verification does not come', () => { + // The relay's poll stays empty until `arrivesAfterTouch` calls come in, like a + // Stalwart that skipped the verification until the subscription is touched. + function installSilentRelay(codeOnceTouched: () => boolean) { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response; + if (url.endsWith('/api/push/vapid-public-key')) return json({ publicKey: 'QUJD' }); + if (url.includes('/api/push/verify/')) return json({ verificationCode: codeOnceTouched() ? 'late-code' : null }); + return json({}); + }); + vi.stubGlobal('fetch', fetchMock); + } + + afterEach(() => { vi.useRealTimers(); }); + + it('touches the subscription after the server\'s minute, which makes it send the code', async () => { + vi.useFakeTimers(); + const client = makeClient([]); + installSilentRelay(() => (client.updatePushSubscription as ReturnType).mock.calls.length > 0); + + const run = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + const settled = run.then(() => 'done', (error: Error) => error.message); + await vi.advanceTimersByTimeAsync(58_000); + expect(client.updatePushSubscription).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(10_000); + + expect(client.updatePushSubscription).toHaveBeenCalledTimes(1); + expect((client.updatePushSubscription as ReturnType).mock.calls[0][0]).toBe('push-new'); + await vi.advanceTimersByTimeAsync(5_000); + expect(await settled).toBe('done'); + expect(client.verifyPushSubscription).toHaveBeenCalledWith('push-new', 'late-code'); + }); +}); + describe('resyncWebPush when the relay changed (#903)', () => { afterEach(() => setNoThirdPartyTraffic(false)); diff --git a/lib/web-push.ts b/lib/web-push.ts index 4ab2e43de..568c20143 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -632,17 +632,32 @@ async function relayReportsDead( return (await relayStatusFor(relayBaseUrl, subscriptionId)) === 'inactive'; } +// Stalwart rate-limits PushVerification per account (60 s by default). +const VERIFICATION_RESEND_AFTER_MS = 62_000; +const VERIFICATION_TIMEOUT_MS = 80_000; + async function pollVerificationCode( relayBaseUrl: string, subscriptionId: string, + askAgain: () => Promise, ): Promise { - // Stalwart per-account rate-limits PushVerification posts (default 60s). - // If there are leftover unverified subscriptions on the account, our new - // one queues up behind them - so we wait long enough to clear one verify - // window even in the unlucky case. - const timeoutAt = Date.now() + 75_000; + // If there are leftover unverified subscriptions on the account, our new one + // queues up behind them, so we wait long enough to clear one verify window. + // + // Stalwart hands out the lowest free subscription id, so one destroyed and + // created again within the window (off and on again, or a retry) gets the same + // id. It takes that for a repeat and drops the verification for good, on 0.16.27 + // and 1.0 alike (push.rs: `verified_id == subscription.id`). Any change to the + // subscription after the window makes it send one. + const startedAt = Date.now(); + const timeoutAt = startedAt + VERIFICATION_TIMEOUT_MS; + let askedAgain = false; let delay = 400; while (Date.now() < timeoutAt) { + if (!askedAgain && Date.now() - startedAt >= VERIFICATION_RESEND_AFTER_MS) { + askedAgain = true; + await askAgain().catch(() => undefined); + } const res = await fetch( buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`), ); @@ -866,7 +881,9 @@ export async function enableWebPush( const serverAssignedId = created.result; rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush); - const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId); + const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId, () => + params.client.updatePushSubscription(serverAssignedId, { expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS) }), + ); await params.client.verifyPushSubscription(serverAssignedId, verificationCode); localStorage.setItem(subIdKey, serverAssignedId); localStorage.setItem(relayBaseKey(accountId), relayBaseUrl);