fix: restart push verification when the mail server skipped it after a quick off and on

This commit is contained in:
Linus Rath
2026-11-04 03:13:55 +01:00
parent b0a36b7d1a
commit 3a805d1631
2 changed files with 58 additions and 6 deletions
+35
View File
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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));
+23 -6
View File
@@ -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<unknown>,
): Promise<string> {
// 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);