fix: keep push working when the app isn't opened for a week

This commit is contained in:
Linus Rath
2026-10-29 02:08:10 +01:00
parent 4ec2ccac11
commit 9804b49979
3 changed files with 195 additions and 0 deletions
@@ -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<string, unknown>).BulwarkFcm = { showNotification };
(jmapClient.getStoredCredentials as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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, any>, 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<string, unknown>).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<string, boolean> = {}): Email =>
({ id, threadId: 't', keywords, mailboxIds: {}, size: 0, receivedAt: '', hasAttachment: false } as Email);
+40
View File
@@ -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<void> {
const due = await pushSubscriptionDueForExtension(accountId);
if (!due) return;
const subscriptionOf = (responses: Array<[string, Record<string, any>, 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<void> {
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<void> {
const alreadyNotified = await readNotifiedIds(accountId);
let candidates: Email[];
let emailAccountId = session.jmapAccountId;
+37
View File
@@ -1548,6 +1548,43 @@ export async function forgetPushSubscriptionExpiry(accountId: string): Promise<v
await AsyncStorage.removeItem(subscriptionExpiresKey(accountId));
}
// Stalwart (0.16 and 1.0) keeps a subscription for seven days from its last
// refresh, and only the app's start refreshes it. Someone who reads new mail
// from the notifications without opening the app would lose push after a
// week, so a push handled in the background extends the subscription too,
// at most about once a day: once less than this much of it is left.
const BACKGROUND_EXTEND_BELOW_MS = 6 * 24 * 60 * 60 * 1000;
/**
* The account's subscription when a background push should extend it: it is
* recorded with a known expiry that has not passed yet (a lapsed one is the
* launch-time resync's to re-create) and is less than six days away. Carries
* this device's client id, so the caller can make sure a server that reused
* the id hands back our own subscription.
*/
export async function pushSubscriptionDueForExtension(
accountId: string,
): Promise<{ id: string; deviceClientId: string | null } | null> {
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<void> {
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