From 20be11053751b7d2095c19cf7250faf0f37c0a73 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:00:50 +0100 Subject: [PATCH 1/4] fix: keep the device sync push types when a subscription write fails for another reason --- src/lib/__tests__/push-notifications.test.ts | 29 +++++++++++++++ src/lib/push-notifications.ts | 37 +++++++++++++++----- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/lib/__tests__/push-notifications.test.ts b/src/lib/__tests__/push-notifications.test.ts index 3b9125d9..1869c02d 100644 --- a/src/lib/__tests__/push-notifications.test.ts +++ b/src/lib/__tests__/push-notifications.test.ts @@ -514,6 +514,35 @@ describe('push types for device sync (#34)', () => { expect(destroyMock).not.toHaveBeenCalled(); }); + it("retries with the mail types alone when Stalwart refuses the types property", async () => { + await syncing({ [CONTACTS]: true }); + // Stalwart's own answer to a type it doesn't know. + createMock.mockRejectedValueOnce(new JMAPSetError('invalidProperties', 'Invalid data type.', ['types'])); + await setupPushNotifications({ relayBaseUrl: RELAY }); + expect(createMock.mock.calls.map((c) => c[0].types)) + .toEqual([['EmailDelivery', 'ContactCard', 'AddressBook'], ['EmailDelivery']]); + }); + + it('does not drop the device sync types over a failure that is not about them', async () => { + await syncing({ [CONTACTS]: true }); + for (const failure of [ + new Error('Network request failed'), + new JMAPSetError('invalidProperties', 'Host is not a public domain name', ['url']), + new JMAPSetError('overQuota', 'There are too many subscriptions'), + ]) { + createMock.mockClear(); + createMock.mockRejectedValueOnce(failure); + const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e); + expect(err).toBeInstanceOf(PushSetupError); + expect(createMock.mock.calls.map((c) => c[0].types)).toEqual([['EmailDelivery', 'ContactCard', 'AddressBook']]); + } + + // The next attempt (the next launch) subscribes with them. + createMock.mockClear(); + await setupPushNotifications({ relayBaseUrl: RELAY }); + expect(createMock.mock.calls.map((c) => c[0].types)).toEqual([['EmailDelivery', 'ContactCard', 'AddressBook']]); + }); + it('subscribes with them', async () => { await syncing({ [CALENDAR]: true }); await setupPushNotifications({ relayBaseUrl: RELAY }); diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index 14e2d21a..de331774 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -275,7 +275,8 @@ const EMAIL_PUSH_PROPERTIES = ['id', 'threadId']; // user re-enables it from settings. const SUBSCRIPTION_EXPIRES_DAYS = 90; // When an existing subscription has less than this much lifetime left, push -// expires forward on the next app start. +// expires forward on the next app start. Stalwart (0.16 and 1.0) clamps every +// expiry to seven days, so there each start refreshes it. const SUBSCRIPTION_REFRESH_THRESHOLD_DAYS = 7; // A recorded subscription missing from the server was revoked only if it still // had at least this long to live; closer to its expiry (or with the clock a @@ -508,10 +509,25 @@ async function writeWithEmailPush( } } +/** + * Whether a refused subscription write may be about its types. A failed + * request says nothing about them, and a SetError naming other properties + * (the push URL, the emailPush map) or meaning the subscription limit or a + * missing record would fail the same way with fewer types; retrying those + * with the mail types alone would only drop device sync's on a write that + * later goes through. + */ +function mayConcernTypes(err: unknown): boolean { + if (!(err instanceof JMAPMethodError)) return false; + if (!isJMAPSetError(err)) return true; + if (err.type !== 'invalidProperties') return false; + return !err.properties || err.properties.includes('types'); +} + /** * Run a subscription write with the account's push types and, when the server - * refuses it while they include device sync's, once more with the mail types - * alone: mail push never depends on device sync (#34). + * refuses it over them while they include device sync's, once more with the + * mail types alone: mail push never depends on device sync (#34). */ async function writeWithPushTypes( types: readonly string[], @@ -520,7 +536,7 @@ async function writeWithPushTypes( try { return await write(types); } catch (err) { - if (sameTypes(types, PUSH_TYPES)) throw err; + if (sameTypes(types, PUSH_TYPES) || !mayConcernTypes(err)) throw err; logPhase('jmap', 'push types refused, retrying with the mail types alone'); return write(PUSH_TYPES); } @@ -1301,10 +1317,12 @@ async function setupPushNotificationsInner( } // Reap leftover Stalwart subscriptions that would otherwise starve the new - // one's verification. Stalwart emits only one PushVerification per account - // per ~60s and picks the oldest unverified subscription, so a single stale - // straggler blocks every fresh attempt - the symptom is the confusing - // "Timed out waiting for PushVerification" error. We can't read a + // one's verification. Stalwart (0.16 and 1.0) emits only one + // PushVerification per account per 60 s, to the newest unverified + // subscription, and sends the next one when that window has passed: every + // straggler created or touched just before ours delays it by a window, and + // a swarm of them ends in the confusing "Timed out waiting for + // PushVerification" error. We can't read a // subscription's verified state or URL over JMAP (Stalwart hides both), only // its deviceClientId, so we decide what's safe to remove like this: // - same deviceClientId as ours: a previous attempt from THIS device, @@ -1663,7 +1681,8 @@ export async function teardownPushNotifications(): Promise { if (storedSubId) { // Will only succeed if the jmapClient happens to be authenticated to // this account right now. We don't switch the client to attempt each - // one; the subscription will expire server-side instead (90-day TTL). + // one; the subscription expires server-side instead (Stalwart keeps + // one for at most seven days without a refresh). await destroyPushSubscription(storedSubId).catch(() => undefined); } if (relayBaseUrl && storedDcid) { From 4ec2ccac1163fe3d079460fe7cc94ca203e83c05 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:03:24 +0100 Subject: [PATCH 2/4] fix: say the mail server refuses push relays that are not on a public domain --- locales/rn/en.json | 1 + .../settings/NotificationSettings.tsx | 3 +- src/lib/__tests__/push-notifications.test.ts | 39 +++++++++++++++++++ src/lib/push-notifications.ts | 37 ++++++++++++++++-- 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/locales/rn/en.json b/locales/rn/en.json index f81aa537..7ac7c963 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -1058,6 +1058,7 @@ "err_relay_rejected": "The push relay rejected the registration: {detail}", "err_relay_unreachable": "Could not reach the push relay at {url} ({detail}).", "err_up_android_only": "UnifiedPush is only available on Android.", + "err_url_refused": "The mail server refuses to send notifications to the relay at {url}. It only accepts https addresses on a public domain name, not a private IP address or a local name such as localhost, .local or .lan. Use the hosted relay, or give your relay a public domain name.", "err_verify_rejected": "The mail server rejected the verification code: {detail}", "err_verify_relay": "The relay never received the verification code from the mail server (last relay response: {detail}).", "err_verify_timeout": "The mail server did not send a verification code to the relay within 75 s. Check that the server can reach the relay URL.", diff --git a/src/components/settings/NotificationSettings.tsx b/src/components/settings/NotificationSettings.tsx index 059d0fb9..2b3b1c85 100644 --- a/src/components/settings/NotificationSettings.tsx +++ b/src/components/settings/NotificationSettings.tsx @@ -197,7 +197,8 @@ export function NotificationSettings() { message: t('settings.notifications.push.status_busy', 'Working…'), }); try { - await setStoredRelayBaseUrl(trimmed); + // The setup stores the relay itself, and keeps the one in use when the + // mail server refuses the new one's address. await setupPushNotifications({ relayBaseUrl: trimmed, accountLabel: username ?? undefined, diff --git a/src/lib/__tests__/push-notifications.test.ts b/src/lib/__tests__/push-notifications.test.ts index 1869c02d..221ab701 100644 --- a/src/lib/__tests__/push-notifications.test.ts +++ b/src/lib/__tests__/push-notifications.test.ts @@ -371,6 +371,45 @@ describe('setupPushNotifications subscription shape', () => { expect(result.subscriptionId).toBe('new-server-id'); }); + it('says the mail server refuses a relay that is not on a public domain (Stalwart 1.0)', async () => { + const relay = 'https://relay.home.lan'; + createMock.mockRejectedValueOnce(new JMAPSetError( + 'invalidProperties', + 'Failed to create push subscription new: invalidProperties – properties=[url] – Host is not a public domain name', + ['url'], + )); + const err = await setupPushNotifications({ relayBaseUrl: relay }).catch((e) => e); + expect(err).toBeInstanceOf(PushSetupError); + expect(err.phase).toBe('jmap'); + expect(err.message).toContain(relay); + expect(err.message).toContain('public domain name'); + expect(err.message).not.toContain('properties=[url]'); + }); + + it('keeps the relay a working registration goes through when the server refuses the new one', async () => { + await AsyncStorage.setItem('push:relayBaseUrl:v1', RELAY); + await AsyncStorage.setItem(SUB_KEY, 'existing'); + listMock.mockResolvedValue([{ id: 'existing', deviceClientId: OUR_DCID, expires: new Date(Date.now() + 5 * 86400000).toISOString(), types: ['EmailDelivery'] }]); + createMock.mockRejectedValueOnce(new JMAPSetError('invalidProperties', 'Host is not a public domain name', ['url'])); + const fetchMock = global.fetch as ReturnType; + + const err = await setupPushNotifications({ relayBaseUrl: 'https://relay.home.lan', forceRecreate: true }).catch((e) => e); + + expect(err).toBeInstanceOf(PushSetupError); + expect(await AsyncStorage.getItem('push:relayBaseUrl:v1')).toBe(RELAY); + expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing'); + expect(destroyMock).not.toHaveBeenCalledWith('existing'); + const deleted = fetchMock.mock.calls.filter((c) => (c[1] as RequestInit | undefined)?.method === 'DELETE').map((c) => String(c[0])); + expect(deleted).toEqual([`https://relay.home.lan/api/push/register/${OUR_DCID}`]); + + // The same relay refused (a server that tightened its rules) stays as it is. + fetchMock.mockClear(); + createMock.mockRejectedValueOnce(new JMAPSetError('invalidProperties', 'Host is not a public domain name', ['url'])); + await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate: true }).catch(() => undefined); + expect(await AsyncStorage.getItem('push:relayBaseUrl:v1')).toBe(RELAY); + expect(fetchMock.mock.calls.some((c) => (c[1] as RequestInit | undefined)?.method === 'DELETE')).toBe(false); + }); + it('rejects plain-http relay URLs', async () => { await expect(setupPushNotifications({ relayBaseUrl: 'http://relay.example.com' })).rejects.toMatchObject({ phase: 'relay' }); expect(createMock).not.toHaveBeenCalled(); diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index de331774..4d3a3187 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -684,10 +684,30 @@ export class PushSetupError extends Error { } } +/** + * Whether the server refused the push address itself. Stalwart checks the + * URL when a subscription is created: 0.16.27 and 1.0 refuse plain http and + * private or reserved IP addresses, and 1.0 also refuses any host that is not + * a public domain name (localhost, .local, .lan, .internal, a single label). + */ +function isRefusedPushUrl(err: unknown): boolean { + return isJMAPSetError(err) && err.type === 'invalidProperties' && !!err.properties?.includes('url'); +} + // A subscription write the mail server refused or could not take. The // refusals a user can do something about read as plain text; anything else // keeps the error's own message. -function subscriptionError(err: unknown): PushSetupError { +function subscriptionError(err: unknown, relayBaseUrl: string): PushSetupError { + if (isRefusedPushUrl(err)) { + return new PushSetupError( + 'jmap', + t( + 'settings.notifications.push.err_url_refused', + 'The mail server refuses to send notifications to the relay at {url}. It only accepts https addresses on a public domain name, not a private IP address or a local name such as localhost, .local or .lan. Use the hosted relay, or give your relay a public domain name.', + { url: relayBaseUrl }, + ), + ); + } const type = err instanceof JMAPMethodError ? err.type : undefined; if (type === 'forbidden') { return new PushSetupError( @@ -1204,6 +1224,9 @@ async function setupPushNotificationsInner( await migrateLegacyPushKeys(); const deviceClientId = await getOrCreateDeviceClientId(accountId); + // The relay a working registration goes through, put back should the mail + // server refuse the new one's address. + const previousRelayBaseUrl = await getStoredRelayBaseUrl(); await setStoredRelayBaseUrl(relayBaseUrl); // Register this account's device-client-id with the relay. Multiple @@ -1291,7 +1314,7 @@ async function setupPushNotificationsInner( // Nothing the server said about the subscription (a failed request, // rate limiting, a refused method call): it keeps delivering, and // the next run tries again. - throw subscriptionError(err); + throw subscriptionError(err, relayBaseUrl); } if (refreshed) { await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush); @@ -1377,7 +1400,15 @@ async function setupPushNotificationsInner( serverExpires = created.result.expires; await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush); } catch (err) { - throw subscriptionError(err); + // The server will never take this relay's address. Whatever registration + // still works goes through the relay used before: keep that one stored + // (token refreshes and the next launch register with it) and drop the + // record this run left on the refused relay. + if (isRefusedPushUrl(err) && previousRelayBaseUrl !== relayBaseUrl) { + await setStoredRelayBaseUrl(previousRelayBaseUrl); + await deregisterFromRelay(relayBaseUrl, deviceClientId); + } + throw subscriptionError(err, relayBaseUrl); } logPhase('verify'); 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 3/4] 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 From e03a38001dcf519103451d17ebedfc82759538ba Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:11:09 +0100 Subject: [PATCH 4/4] test: check push subscriptions live on Stalwart 0.16 and 1.0 --- src/api/__tests__/live-stalwart-push.test.ts | 270 +++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 src/api/__tests__/live-stalwart-push.test.ts diff --git a/src/api/__tests__/live-stalwart-push.test.ts b/src/api/__tests__/live-stalwart-push.test.ts new file mode 100644 index 00000000..08033310 --- /dev/null +++ b/src/api/__tests__/live-stalwart-push.test.ts @@ -0,0 +1,270 @@ +/** + * Live push subscription checks against a real Stalwart server. + * + * Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP` and + * `BULWARK_LIVE_PASS` are set. No device is involved (there is no FCM or APNs + * token here): it drives the PushSubscription calls the app makes, with the + * URLs, types and emailPush maps the app builds, and destroys every + * subscription it creates. + * + * BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \ + * BULWARK_LIVE_USER=e2e-tablet-dark@example.org \ + * BULWARK_LIVE_PASS=… npx vitest run src/api/__tests__/live-stalwart-push.test.ts + * + * `BULWARK_LIVE_PUSH_RELAY=1` also runs the verification round trip through + * the hosted relay (https://notifications.relay.bulwarkmail.org): the server + * posts PushVerification there and the test reads it back the way the app's + * poll does. It registers a throwaway record with the relay under a random + * id and removes it again. Run it at least a minute after any other push + * test against the same account: Stalwart sends one PushVerification per + * account per minute. + * + * Verified against stalwartlabs/stalwart:v0.16.27 and v1.0.0. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { randomBytes } from 'node:crypto'; + +import { JMAPClient } from '../jmap-client'; +import { + createPushSubscription, + destroyPushSubscription, + listPushSubscriptions, + updatePushSubscription, + verifyPushSubscription, +} from '../push'; +import { isJMAPSetError, JMAPSetError } from '../jmap-result'; +import { getMailboxes } from '../email'; +import { + buildEmailPushConfig, + DEFAULT_RELAY_BASE_URL, + emailPushFallbacks, + EMAIL_PUSH_CAPABILITY, + sameEmailPush, + sameTypes, +} from '../../lib/push-notifications'; +import type { EmailPushConfig } from '../types'; + +const SERVER = process.env.BULWARK_LIVE_JMAP; +const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; +const PASS = process.env.BULWARK_LIVE_PASS ?? ''; +const live = SERVER && PASS ? describe : describe.skip; +const withRelay = process.env.BULWARK_LIVE_PUSH_RELAY === '1' ? it : it.skip; + +const DAY = 86_400_000; +// What pushTypesFor gives an account that syncs contacts and calendars. +const ALL_TYPES = ['EmailDelivery', 'ContactCard', 'AddressBook', 'CalendarEvent', 'Calendar']; + +let client: JMAPClient; + +async function connectSingleton(): Promise { + const mod = await import('../jmap-client'); + client = new JMAPClient(); + await client.connect(SERVER!, USER, PASS); + (mod.jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot()); +} + +/** Stalwart 1.0 lists principals:owner in the account capabilities, 0.16 doesn't. */ +function isStalwart1(): boolean { + const caps = client.currentSession?.accounts?.[client.accountId]?.accountCapabilities ?? {}; + return 'urn:ietf:params:jmap:principals:owner' in caps; +} + +live('live Stalwart push subscriptions', () => { + const run = randomBytes(4).toString('hex'); + const deviceClientId = (label: string) => `live-push-${run}-${label}`; + // The URL the app subscribes with (setupPushNotifications). + const relayUrl = (dcid: string) => `${DEFAULT_RELAY_BASE_URL}/api/push/jmap/${encodeURIComponent(dcid)}`; + const created: string[] = []; + let junk = ''; + let inbox = ''; + + async function create(label: string, params: Partial[0]> = {}) { + const dcid = deviceClientId(label); + const result = await createPushSubscription({ + deviceClientId: dcid, + url: relayUrl(dcid), + types: ['EmailDelivery'], + expires: new Date(Date.now() + 90 * DAY).toISOString(), + ...params, + }); + created.push(result.id); + return result; + } + + async function refusal(promise: Promise): Promise { + const err = await promise.then( + (value) => { + // A create that went through still has to be cleaned up. + const id = (value as { id?: string } | undefined)?.id; + if (id) created.push(id); + return null; + }, + (e: unknown) => e, + ); + expect(isJMAPSetError(err), String(err)).toBe(true); + return err as JMAPSetError; + } + + beforeAll(async () => { + await connectSingleton(); + const boxes = await getMailboxes(); + junk = boxes.find((b) => b.role === 'junk')?.id ?? ''; + inbox = boxes.find((b) => b.role === 'inbox')?.id ?? ''; + expect(inbox).toBeTruthy(); + expect(junk).toBeTruthy(); + }, 60_000); + + afterAll(async () => { + // Everything this run made, also what a failed assertion left behind. + const left = await listPushSubscriptions().catch(() => []); + const ids = new Set([...created, ...left.filter((s) => s.deviceClientId.startsWith('live-push-')).map((s) => s.id)]); + for (const id of ids) await destroyPushSubscription(id).catch(() => undefined); + }, 60_000); + + // First, so earlier creates of this run don't hold the verification slot. + withRelay('delivers PushVerification to the hosted relay and accepts the code', async () => { + const dcid = `liveprobe-${randomBytes(8).toString('hex')}`; + const relay = (path: string, init?: RequestInit) => fetch(`${DEFAULT_RELAY_BASE_URL}${path}`, init); + const registered = await relay('/api/push/register', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ subscriptionId: dcid, fcmToken: `liveprobe${'x'.repeat(100)}`, accountLabel: 'live test (delete me)' }), + }); + expect(registered.status).toBe(200); + try { + const { id } = await createPushSubscription({ deviceClientId: dcid, url: relayUrl(dcid), types: ['EmailDelivery'] }); + created.push(id); + // The app's poll: up to 75 s, enough for one verification window. + let code: string | null = null; + const until = Date.now() + 75_000; + while (!code && Date.now() < until) { + const res = await relay(`/api/push/verify/${dcid}`); + if (res.ok) code = ((await res.json()) as { verificationCode?: string | null }).verificationCode ?? null; + if (!code) await new Promise((r) => setTimeout(r, 1000)); + } + expect(code).toBeTruthy(); + await expect(verifyPushSubscription(id, 'not-the-code')).rejects.toMatchObject({ type: 'invalidProperties' }); + await verifyPushSubscription(id, code!); + } finally { + await relay(`/api/push/register/${dcid}`, { method: 'DELETE' }); + } + }, 120_000); + + it('advertises emailpush, on the own account too', () => { + expect(EMAIL_PUSH_CAPABILITY in (client.currentSession?.capabilities ?? {})).toBe(true); + expect(EMAIL_PUSH_CAPABILITY in (client.currentSession?.accounts?.[client.accountId]?.accountCapabilities ?? {})).toBe(true); + }); + + it('creates the subscription the app makes and clamps its expiry to seven days', async () => { + const emailPush = await buildEmailPushConfig(); + expect(Object.keys(emailPush)).toContain(client.accountId); + const { id, expires } = await create('full', { types: ALL_TYPES, emailPush }); + const left = Date.parse(expires ?? '') - Date.now(); + expect(left).toBeGreaterThan(7 * DAY - 5 * 60_000); + expect(left).toBeLessThanOrEqual(7 * DAY + 60_000); + + // Read back as the app compares it, so a launch doesn't re-patch it. + const listed = (await listPushSubscriptions()).find((s) => s.id === id); + expect(listed?.deviceClientId).toBe(deviceClientId('full')); + expect(sameTypes(listed?.types, ALL_TYPES)).toBe(true); + expect(sameEmailPush(listed?.emailPush, emailPush), JSON.stringify(listed?.emailPush)).toBe(true); + }, 30_000); + + it('takes the refresh the launch sends: expiry, types and filter in one update', async () => { + const { id } = await create('refresh', { types: ALL_TYPES }); + const emailPush = await buildEmailPushConfig(); + await updatePushSubscription(id, { expires: new Date(Date.now() + 90 * DAY).toISOString(), types: ['EmailDelivery'], emailPush }); + const listed = (await listPushSubscriptions()).find((s) => s.id === id); + expect(listed?.types).toEqual(['EmailDelivery']); + expect(sameEmailPush(listed?.emailPush, emailPush)).toBe(true); + expect(Date.parse(listed?.expires ?? '') - Date.now()).toBeLessThanOrEqual(7 * DAY + 60_000); + + // A record that is gone answers notFound, which the setup answers with a + // replacement; destroying it again counts as done. + await destroyPushSubscription(id); + expect((await refusal(updatePushSubscription(id, { expires: new Date(Date.now() + DAY).toISOString() }))).type).toBe('notFound'); + await destroyPushSubscription(id); + }, 30_000); + + it('refuses a map naming an account the user is not a member of as forbidden, and takes the narrowed one', async () => { + const own = await buildEmailPushConfig(); + // Account ids are short and sequential; one the user doesn't belong to. + const foreign = ['a', 'b', 'c', 'd', 'e'].find((id) => !(id in (client.currentSession?.accounts ?? {})))!; + const wanted: Record = { ...own, [foreign]: own[client.accountId] }; + + const refused = await refusal(create('foreign', { emailPush: wanted })); + expect(refused.type).toBe('forbidden'); + + // What the setup tries next: the primary account alone. + const attempts = emailPushFallbacks(wanted, client.accountId, client.currentSession?.accounts); + expect(Object.keys(attempts[attempts.length - 1])).toEqual([client.accountId]); + const { id } = await create('narrowed', { emailPush: attempts[attempts.length - 1] }); + + // An update is refused the same way and leaves the subscription as it was. + expect((await refusal(updatePushSubscription(id, { emailPush: wanted }))).type).toBe('forbidden'); + expect(sameEmailPush((await listPushSubscriptions()).find((s) => s.id === id)?.emailPush, own)).toBe(true); + }, 30_000); + + it('takes the filters mutes produce and echoes them so they compare equal (#78)', async () => { + const me = client.accountId; + const cases = { + folderMuted: { accounts: [], mailboxes: { [`${me}/${inbox}`]: true } }, + accountMutedInboxUnmuted: { accounts: [me], mailboxes: { [`${me}/${inbox}`]: false } }, + accountMuted: { accounts: [me], mailboxes: {} }, + }; + const built = Object.fromEntries(await Promise.all( + Object.entries(cases).map(async ([name, mutes]) => [name, await buildEmailPushConfig(mutes)] as const), + )); + expect(built.folderMuted[me].filter).toEqual({ + operator: 'AND', + conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: [inbox, junk].sort() }], + }); + expect(built.accountMutedInboxUnmuted[me].filter).toEqual({ + operator: 'AND', + conditions: [{ notKeyword: '$junk' }, { inMailbox: inbox }], + }); + expect(built.accountMuted[me].filter).toEqual({ + operator: 'AND', + conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }], + }); + for (const [name, emailPush] of Object.entries(built)) { + const { id } = await create(`mute-${name}`, { emailPush }); + const listed = (await listPushSubscriptions()).find((s) => s.id === id); + expect(sameEmailPush(listed?.emailPush, emailPush), `${name}: ${JSON.stringify(listed?.emailPush)}`).toBe(true); + } + }, 60_000); + + it('refuses push addresses that are not public: http and private IPs everywhere, local names on 1.0', async () => { + for (const url of ['http://notifications.relay.bulwarkmail.org/api/push/jmap/x', 'https://192.168.1.20/api/push/jmap/x', 'https://10.0.0.5:8443/api/push/jmap/x']) { + const err = await refusal(create('private-ip', { url })); + expect(err.type).toBe('invalidProperties'); + expect(err.properties).toEqual(['url']); + } + for (const url of ['https://relay.home.lan/api/push/jmap/x', 'https://relay.local/api/push/jmap/x', 'https://localhost/api/push/jmap/x']) { + if (isStalwart1()) { + const err = await refusal(create('local-name', { url })); + expect(err.type).toBe('invalidProperties'); + expect(err.properties).toEqual(['url']); + expect(err.message).toContain('public domain'); + } else { + // 0.16 takes them; it would only fail to deliver. + await create('local-name', { url }); + } + } + }, 30_000); + + it('refuses a type it does not know over the types property', async () => { + const err = await refusal(create('bogus-type', { types: ['EmailDelivery', 'NoSuchType'] })); + expect(err.type).toBe('invalidProperties'); + expect(err.properties).toEqual(['types']); + }); + + it('caps the subscriptions of an account and says so with overQuota', async () => { + let err: JMAPSetError | null = null; + for (let i = 0; i < 30 && !err; i++) { + err = await create(`cap-${i}`).then(() => null, (e: unknown) => (isJMAPSetError(e) ? e : Promise.reject(e))); + } + expect(err?.type).toBe('overQuota'); + expect((await listPushSubscriptions()).length).toBe(15); + }, 60_000); +});