From 9804b4997972335825eaaf43cffa1d2e75c2780d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:08:10 +0100 Subject: [PATCH] fix: keep push working when the app isn't opened for a week --- .../__tests__/push-background-task.test.ts | 118 ++++++++++++++++++ src/lib/push-background-task.ts | 40 ++++++ src/lib/push-notifications.ts | 37 ++++++ 3 files changed, 195 insertions(+) diff --git a/src/lib/__tests__/push-background-task.test.ts b/src/lib/__tests__/push-background-task.test.ts index 676ff2e7..f2cc64a1 100644 --- a/src/lib/__tests__/push-background-task.test.ts +++ b/src/lib/__tests__/push-background-task.test.ts @@ -460,6 +460,124 @@ describe('pushes for device sync (#34)', () => { }); }); +// Stalwart keeps a subscription for seven days from its last refresh. +describe('pushBackgroundTask keeps the push subscription alive', () => { + const LOCAL = 'alice@mail.example.com'; + const DAY = 86_400_000; + const showNotification = vi.fn(async () => undefined); + const server = { deviceClientId: 'dcid-ours', expires: '', calls: [] as string[], failPush: false }; + + const push = () => pushBackgroundTask({ + kind: 'jmap-email-push', + accountLabel: 'alice', + accountId: 'jmap-primary', + emailIds: JSON.stringify(['m1']), + }); + const recorded = (msFromNow: number) => AsyncStorage.setItem(`push:subscriptionExpires:v1:${LOCAL}`, new Date(Date.now() + msFromNow).toISOString()); + + beforeEach(async () => { + vi.clearAllMocks(); + await AsyncStorage.clear(); + await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([LOCAL])); + await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({ [LOCAL]: 'jmap-primary' })); + await AsyncStorage.setItem(`push:subscriptionId:v2:${LOCAL}`, 'b'); + await AsyncStorage.setItem(`push:deviceClientId:v2:${LOCAL}`, 'dcid-ours'); + (NativeModules as Record).BulwarkFcm = { showNotification }; + (jmapClient.getStoredCredentials as ReturnType).mockResolvedValue({ + serverUrl: 'https://mail.example.com', + username: 'alice', + password: 'secret', + }); + Object.assign(server, { deviceClientId: 'dcid-ours', expires: new Date(Date.now() + 2 * DAY).toISOString(), calls: [], failPush: false }); + (secureFetch as ReturnType).mockImplementation(async (url: string, init?: { body?: string }) => { + if (url.endsWith('/.well-known/jmap')) { + return { + ok: true, + json: async () => ({ + apiUrl: 'https://mail.example.com/jmap/', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' }, + accounts: { 'jmap-primary': {} }, + }), + }; + } + const calls = JSON.parse(init?.body ?? '{}').methodCalls as Array<[string, Record, string]>; + if (server.failPush && calls[0][0].startsWith('PushSubscription/')) return { ok: false, status: 500 }; + const methodResponses = calls.map(([name, args, id]) => { + server.calls.push(name); + if (name === 'PushSubscription/set') { + // Stalwart clamps the expiry and answers the update with null. + server.expires = new Date(Date.now() + 7 * DAY).toISOString().replace(/\.\d+Z$/, 'Z'); + return [name, { updated: { [Object.keys(args.update)[0]]: null } }, id]; + } + if (name === 'PushSubscription/get') { + return [name, { list: [{ id: 'b', deviceClientId: server.deviceClientId, expires: server.expires }] }, id]; + } + return [name, { list: [{ id: 'm1', threadId: 't1', keywords: {}, subject: 'Hi', from: [{ email: 'bob@example.com' }] }] }, id]; + }); + return { ok: true, json: async () => ({ methodResponses }) }; + }); + }); + + afterEach(() => { + delete (NativeModules as Record).BulwarkFcm; + }); + + it('extends a subscription with less than six days left and records what the server settled on', async () => { + await recorded(2 * DAY); + await push(); + expect(showNotification).toHaveBeenCalled(); + expect(server.calls).toEqual(['Email/get', 'PushSubscription/get', 'PushSubscription/set', 'PushSubscription/get']); + expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${LOCAL}`)).toBe(server.expires); + + // Recorded now: the next pushes leave it alone for a day. + server.calls = []; + await AsyncStorage.multiRemove([`push:notifiedIds:v1:${LOCAL}`, `push:lastNotifiedEmailId:v2:${LOCAL}`]); + await push(); + expect(server.calls).toEqual(['Email/get']); + }); + + it('extends it also when the push brings nothing new to show', async () => { + await recorded(2 * DAY); + await AsyncStorage.setItem(`push:notifiedIds:v1:${LOCAL}`, JSON.stringify(['m1'])); + await push(); + expect(showNotification).not.toHaveBeenCalled(); + expect(server.calls).toContain('PushSubscription/set'); + }); + + it('leaves alone a subscription with time left, one that lapsed, one of unknown expiry and one push was turned off for', async () => { + for (const setup of [ + () => recorded(6.5 * DAY), + () => recorded(-DAY), + () => AsyncStorage.removeItem(`push:subscriptionExpires:v1:${LOCAL}`), + async () => { + await recorded(2 * DAY); + await AsyncStorage.setItem(`push:optedOut:v1:${LOCAL}`, '1'); + }, + ]) { + server.calls = []; + await AsyncStorage.multiRemove([`push:notifiedIds:v1:${LOCAL}`, `push:lastNotifiedEmailId:v2:${LOCAL}`]); + await AsyncStorage.removeItem(`push:optedOut:v1:${LOCAL}`); + await setup(); + await push(); + expect(server.calls).toEqual(['Email/get']); + } + }); + + it("does not extend another device's subscription that reuses the recorded id", async () => { + await recorded(2 * DAY); + server.deviceClientId = 'dcid-someone-else'; + await push(); + expect(server.calls).toEqual(['Email/get', 'PushSubscription/get']); + }); + + it('still notifies when the server cannot be asked about the subscription', async () => { + await recorded(2 * DAY); + server.failPush = true; + await push(); + expect(showNotification).toHaveBeenCalled(); + }); +}); + describe('selectNotifiableEmails', () => { const email = (id: string, keywords: Record = {}): Email => ({ id, threadId: 't', keywords, mailboxIds: {}, size: 0, receivedAt: '', hasAttachment: false } as Email); diff --git a/src/lib/push-background-task.ts b/src/lib/push-background-task.ts index 4afcad23..026b608d 100644 --- a/src/lib/push-background-task.ts +++ b/src/lib/push-background-task.ts @@ -17,8 +17,11 @@ import { lastNotifiedKey, migrateLegacyPushKeys, notifiedIdsKey, + pushSubscriptionDueForExtension, readPushAccountIds, readPushJmapAccountIds, + recordPushSubscriptionExpires, + wantedPushSubscriptionExpires, } from './push-notifications'; // Mirrors STORAGE_KEY in `stores/settings-store.ts`. The headless task can't @@ -457,10 +460,47 @@ export function selectNotifiableEmails(emails: Email[], alreadyNotified: readonl }); } +/** + * Push the account's subscription expiry forward from the background (see + * pushSubscriptionDueForExtension). Best effort: anything unexpected leaves + * it to the next launch. + */ +async function extendPushSubscription(accountId: string, session: DetachedSession): Promise { + const due = await pushSubscriptionDueForExtension(accountId); + if (!due) return; + const subscriptionOf = (responses: Array<[string, Record, string]>, callId: string) => { + const response = responses.find((r) => r[2] === callId); + if (response?.[0] !== 'PushSubscription/get') return undefined; + return (response[1].list as Array<{ id: string; deviceClientId?: string; expires?: string | null }> | undefined) + ?.find((s) => s.id === due.id); + }; + const properties = ['id', 'deviceClientId', 'expires']; + // Stalwart reuses the ids of destroyed subscriptions: extend only our own. + const current = subscriptionOf( + await jmapPost(session, [['PushSubscription/get', { ids: [due.id], properties }, '0']]), + '0', + ); + if (!current || (current.deviceClientId && due.deviceClientId && current.deviceClientId !== due.deviceClientId)) return; + const responses = await jmapPost(session, [ + ['PushSubscription/set', { update: { [due.id]: { expires: wantedPushSubscriptionExpires() } } }, '0'], + // The update's answer doesn't say what the server settled on. + ['PushSubscription/get', { ids: [due.id], properties }, '1'], + ]); + const extended = subscriptionOf(responses, '1'); + if (extended?.expires) await recordPushSubscriptionExpires(accountId, extended.expires); +} + async function processAccountForPush(accountId: string, payload: RelayPushData): Promise { const session = await openDetachedSession(accountId); if (!session) return; + try { + await notifyForPush(accountId, session, payload); + } finally { + await extendPushSubscription(accountId, session).catch(() => undefined); + } +} +async function notifyForPush(accountId: string, session: DetachedSession, payload: RelayPushData): Promise { const alreadyNotified = await readNotifiedIds(accountId); let candidates: Email[]; let emailAccountId = session.jmapAccountId; diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index 4d3a3187..254cdfb3 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -1548,6 +1548,43 @@ export async function forgetPushSubscriptionExpiry(accountId: string): Promise { + if (await AsyncStorage.getItem(optedOutKey(accountId))) return null; + const id = await AsyncStorage.getItem(subscriptionIdKey(accountId)); + if (!id) return null; + const expires = Date.parse((await AsyncStorage.getItem(subscriptionExpiresKey(accountId))) ?? ''); + if (!Number.isFinite(expires)) return null; + const left = expires - Date.now(); + if (left <= 0 || left >= BACKGROUND_EXTEND_BELOW_MS) return null; + return { id, deviceClientId: await AsyncStorage.getItem(deviceClientIdKey(accountId)) }; +} + +/** The expiry to ask for when extending a subscription; servers clamp it. */ +export function wantedPushSubscriptionExpires(): string { + return expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS); +} + +/** Record the expiry the server reported for the account's subscription. */ +export async function recordPushSubscriptionExpires(accountId: string, expires: string): Promise { + await AsyncStorage.setItem(subscriptionExpiresKey(accountId), expires); +} + /** * Tear down push for a single account. Destroys every JMAP subscription the * server holds for this device (assumes the jmapClient is currently