mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: keep push working when the app isn't opened for a week
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user