mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +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', () => {
|
describe('selectNotifiableEmails', () => {
|
||||||
const email = (id: string, keywords: Record<string, boolean> = {}): Email =>
|
const email = (id: string, keywords: Record<string, boolean> = {}): Email =>
|
||||||
({ id, threadId: 't', keywords, mailboxIds: {}, size: 0, receivedAt: '', hasAttachment: false } as Email);
|
({ id, threadId: 't', keywords, mailboxIds: {}, size: 0, receivedAt: '', hasAttachment: false } as Email);
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ import {
|
|||||||
lastNotifiedKey,
|
lastNotifiedKey,
|
||||||
migrateLegacyPushKeys,
|
migrateLegacyPushKeys,
|
||||||
notifiedIdsKey,
|
notifiedIdsKey,
|
||||||
|
pushSubscriptionDueForExtension,
|
||||||
readPushAccountIds,
|
readPushAccountIds,
|
||||||
readPushJmapAccountIds,
|
readPushJmapAccountIds,
|
||||||
|
recordPushSubscriptionExpires,
|
||||||
|
wantedPushSubscriptionExpires,
|
||||||
} from './push-notifications';
|
} from './push-notifications';
|
||||||
|
|
||||||
// Mirrors STORAGE_KEY in `stores/settings-store.ts`. The headless task can't
|
// 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> {
|
async function processAccountForPush(accountId: string, payload: RelayPushData): Promise<void> {
|
||||||
const session = await openDetachedSession(accountId);
|
const session = await openDetachedSession(accountId);
|
||||||
if (!session) return;
|
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);
|
const alreadyNotified = await readNotifiedIds(accountId);
|
||||||
let candidates: Email[];
|
let candidates: Email[];
|
||||||
let emailAccountId = session.jmapAccountId;
|
let emailAccountId = session.jmapAccountId;
|
||||||
|
|||||||
@@ -1548,6 +1548,43 @@ export async function forgetPushSubscriptionExpiry(accountId: string): Promise<v
|
|||||||
await AsyncStorage.removeItem(subscriptionExpiresKey(accountId));
|
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
|
* Tear down push for a single account. Destroys every JMAP subscription the
|
||||||
* server holds for this device (assumes the jmapClient is currently
|
* server holds for this device (assumes the jmapClient is currently
|
||||||
|
|||||||
Reference in New Issue
Block a user