From 90e250d376faa1d87337703552c10efb79d9a8e5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:20:06 +0100 Subject: [PATCH 1/3] fix: go back to the previous push relay whenever a move to a new one fails --- src/lib/__tests__/push-notifications.test.ts | 30 +++++++++ src/lib/push-notifications.ts | 69 +++++++++++++------- 2 files changed, 74 insertions(+), 25 deletions(-) diff --git a/src/lib/__tests__/push-notifications.test.ts b/src/lib/__tests__/push-notifications.test.ts index 3d5e8b06..479cde68 100644 --- a/src/lib/__tests__/push-notifications.test.ts +++ b/src/lib/__tests__/push-notifications.test.ts @@ -1244,6 +1244,36 @@ describe('setupPushNotifications at the server\'s subscription limit', () => { expect(createMock).toHaveBeenCalledTimes(1); expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('existing'); expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing'); + // The working record still delivers through the old relay, so that one + // stays the relay in use, and this device is registered with it again. + expect(await AsyncStorage.getItem('push:relayBaseUrl:v1')).toBe(RELAY); + const relayCalls = (global.fetch as ReturnType).mock.calls + .filter((c) => String(c[0]).includes('/api/push/register')) + .map((c) => `${(c[1] as RequestInit | undefined)?.method ?? 'GET'} ${String(c[0])}`); + expect(relayCalls).toEqual([ + 'POST https://relay.home.lan/api/push/register', + `DELETE https://relay.home.lan/api/push/register/${OUR_DCID}`, + `POST ${RELAY}/api/push/register`, + ]); + }); + + it('keeps the old relay when the new one never verifies the replacement', async () => { + verifyMock.mockRejectedValueOnce(new Error('invalid verification code')); + + const err = await setupPushNotifications({ relayBaseUrl: 'https://relay.other.example', forceRecreate: true }).catch((e) => e); + + expect(err).toBeInstanceOf(PushSetupError); + expect(err.phase).toBe('verify'); + expect(await AsyncStorage.getItem('push:relayBaseUrl:v1')).toBe(RELAY); + expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing'); + expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('existing'); + const fetchMock = global.fetch as ReturnType; + expect(fetchMock.mock.calls.some((c) => String(c[0]) === `https://relay.other.example/api/push/register/${OUR_DCID}` + && (c[1] as RequestInit | undefined)?.method === 'DELETE')).toBe(true); + + // A move that works keeps the new relay. + expect((await setupPushNotifications({ relayBaseUrl: 'https://relay.other.example', forceRecreate: true })).verified).toBe(true); + expect(await AsyncStorage.getItem('push:relayBaseUrl:v1')).toBe('https://relay.other.example'); }); it('a forced re-register frees the old record\'s place and tries once more', async () => { diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index bf60fc50..d08e38c5 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -1187,9 +1187,15 @@ export function setupPushNotifications( const key = `${jmapClient.username ?? ''}@${jmapClient.serverUrl ?? ''}`; const existing = inFlightSetups.get(key); if (existing) return existing; - const run = setupPushNotificationsInner(params).finally(() => { - inFlightSetups.delete(key); - }); + const rollback: SetupRollback = {}; + const run = setupPushNotificationsInner(params, rollback) + .catch(async (err: unknown) => { + await rollback.relayMove?.().catch(() => undefined); + throw err; + }) + .finally(() => { + inFlightSetups.delete(key); + }); inFlightSetups.set(key, run); return run; } @@ -1198,8 +1204,14 @@ function logPhase(phase: string, detail?: string): void { console.log(`[push] ${phase}${detail ? `: ${detail}` : ''}`); } +// What a failed setup run has to undo, set by the run as it goes. +interface SetupRollback { + relayMove?: () => Promise; +} + async function setupPushNotificationsInner( params: PushSetupParams, + rollback: SetupRollback, ): Promise { const transport = await getEffectivePushTransport(); const native = getNative(); @@ -1249,8 +1261,8 @@ 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. + // The relay a working registration goes through, put back should a move + // to another relay fail. const previousRelayBaseUrl = await getStoredRelayBaseUrl(); await setStoredRelayBaseUrl(relayBaseUrl); @@ -1284,18 +1296,32 @@ async function setupPushNotificationsInner( }); } }; - await registerWith(relayBaseUrl, upEndpoint); - // Undo this run's relay steps once the mail server refuses the new relay's - // address. The refused URL may be another name of the relay in use, so the - // record dropped from it can be the working one; and a UnifiedPush - // distributor now holds the refused relay's VAPID key, which may make it - // turn away the previous relay's pushes. Registering with the previous relay - // again puts both back; the record itself only for an account that had a - // working subscription through it. + // Undo this run's relay steps once a move to another relay fails. The new + // URL may be another name of the relay in use, so the record dropped from + // it can be the working one; and a UnifiedPush distributor now holds the + // new relay's VAPID key, which may make it turn away the previous relay's + // pushes. Registering with the previous relay again puts both back; the + // record itself only for an account that had a working subscription + // through it. const restoreRelay = async (relay: string, hadSubscription: boolean) => { const endpoint = upEndpoint ? await getUnifiedPushEndpointOrThrow(relay) : null; if (hadSubscription) await registerWith(relay, endpoint); }; + // Until a subscription through the new relay is verified, whatever still + // works goes through the relay used before, whichever step failed (the + // server refused the address, the subscription limit, a verification that + // never came): keep that one stored, since token refreshes, the settings, + // the device list and the next launch all follow it, and drop the record + // this run left on the new relay. + let match: PushSubscription | undefined; + if (previousRelayBaseUrl !== relayBaseUrl) { + rollback.relayMove = async () => { + await setStoredRelayBaseUrl(previousRelayBaseUrl); + await deregisterFromRelay(relayBaseUrl, deviceClientId); + if (previousRelayBaseUrl) await restoreRelay(previousRelayBaseUrl, !!match).catch(() => undefined); + }; + } + await registerWith(relayBaseUrl, upEndpoint); // Reuse the previous JMAP subscription when the server still has it, but // push the expiry forward so it doesn't time out before the next app start. @@ -1339,7 +1365,7 @@ async function setupPushNotificationsInner( // Ours only if it was made for this device: Stalwart's ids are short and // sequential, so once the server is reinstalled at the same address the // recorded id can name another device's subscription. - const match = storedServerId + match = storedServerId ? existingSubs.find((s) => s.id === storedServerId && (!s.deviceClientId || s.deviceClientId === deviceClientId)) : undefined; if (storedServerId) { @@ -1447,15 +1473,6 @@ async function setupPushNotificationsInner( serverExpires = created.result.expires; await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush); } catch (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); - if (previousRelayBaseUrl) await restoreRelay(previousRelayBaseUrl, !!match).catch(() => undefined); - } throw subscriptionError(err, relayBaseUrl); } @@ -1470,6 +1487,7 @@ async function setupPushNotificationsInner( t('settings.notifications.push.err_verify_rejected', 'The mail server rejected the verification code: {detail}', { detail }), ); } + rollback.relayMove = undefined; await AsyncStorage.setItem(subKey, serverAssignedId); if (serverExpires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), serverExpires); @@ -1786,8 +1804,9 @@ export interface PushEnableOutcome { /** * The enable and re-register buttons of the notification settings: run the * setup and say what it left behind. When the mail server refuses a new - * relay, the setup goes back to the one in use; the relay field must show - * that one again rather than the refused address, since the device list asks + * relay, or the move to it fails otherwise, the setup goes back to the one in + * use; the relay field must show that one again rather than the new address, + * since the device list asks * the relay in the field about each device and a revoke goes to it. With no * relay in use the typed one stays, so the user can correct it. */ From b352e470c14da100e6457738b6f3af4b848051ed Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:25:05 +0100 Subject: [PATCH 2/3] fix: keep a push for another login's group account out of the login whose own id it carries --- plugins/ios-push/NotificationService.swift | 9 +++++++- src/lib/__tests__/ios-push-tap.test.ts | 13 ++++++++++++ .../__tests__/push-background-task.test.ts | 13 +++++++++++- src/lib/ios-push-shared.ts | 7 +++++++ src/lib/push-background-task.ts | 12 ++++++++--- src/lib/push-notifications.ts | 21 +++++++++++++------ 6 files changed, 64 insertions(+), 11 deletions(-) diff --git a/plugins/ios-push/NotificationService.swift b/plugins/ios-push/NotificationService.swift index beed8009..0557455f 100644 --- a/plugins/ios-push/NotificationService.swift +++ b/plugins/ios-push/NotificationService.swift @@ -11,7 +11,7 @@ let pushLog = Logger(subsystem: "org.bulwarkmail.mobile.NotificationService", ca /// passes through the relay or Apple. /// /// What the app shares through the keychain group (src/lib/ios-push-shared.ts): -/// bulwark_push_account__ {credentialsKey, apiUrl} +/// bulwark_push_account__ {credentialsKey, apiUrl, label} /// bulwark_push_settings {badge, activeAccountId, strings{...}} /// the account's stored credentials /// If anything is missing or the server can't be reached, the relay's @@ -66,6 +66,13 @@ final class NotificationService: UNNotificationServiceExtension { pushLog.error("no shared account entry for \(accountId, privacy: .public)") return content } + // Found by the account id alone, which a group account of another login + // can share: the relay's label (the username) has to be this entry's. + if let label = push["accountLabel"] as? String, !label.isEmpty, + let owner = account["label"] as? String, owner.lowercased() != label.lowercased() { + pushLog.error("push label does not fit the shared account for \(accountId, privacy: .public)") + return content + } let emailIds = push["emailIds"] as? [String] ?? [] let settings = SharedKeychain.readJSON("bulwark_push_settings") ?? [:] let strings = settings["strings"] as? [String: String] ?? [:] diff --git a/src/lib/__tests__/ios-push-tap.test.ts b/src/lib/__tests__/ios-push-tap.test.ts index 6de9cd69..a9c85d90 100644 --- a/src/lib/__tests__/ios-push-tap.test.ts +++ b/src/lib/__tests__/ios-push-tap.test.ts @@ -32,6 +32,12 @@ describe('shareAccountWithExtension', () => { expect(writeIosPushAccount).toHaveBeenCalledWith('c', { credentialsKey: expect.any(String), apiUrl: API }); }); + it('names the username the relay labels the account\'s pushes with', async () => { + await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({ 'ada@mail.example.org': 'c' })); + await shareAccountWithExtension('ada@mail.example.org', 'c', API, 'ada'); + expect(writeIosPushAccount).toHaveBeenCalledWith('c', { credentialsKey: expect.any(String), apiUrl: API, label: 'ada' }); + }); + it('shares neither of two logins with the same JMAP account id, so the extension shows its placeholder', async () => { await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({ 'ada@mail.example.org': 'c', @@ -88,6 +94,13 @@ describe('iosTapPayload', () => { expect(await iosTapPayload(push)).toBeNull(); }); + it("does not open another login's group push in the login whose own id it carries", async () => { + // Bea's group account "c" on her server; Ada's own account is "c" on hers. + const push = { kind: 'jmap-email-push', accountId: 'c', emailIds: ['e1'] }; + expect(await iosTapPayload({ ...push, accountLabel: 'bea' })).toBeNull(); + expect((await iosTapPayload({ ...push, accountLabel: 'ada' }))?.accountId).toBe('ada@mail.example.org'); + }); + it('does nothing for a push without a message id', async () => { expect(await iosTapPayload({ kind: 'jmap-state-change', accountId: 'c', emailIds: [] })).toBeNull(); }); diff --git a/src/lib/__tests__/push-background-task.test.ts b/src/lib/__tests__/push-background-task.test.ts index 24ae517e..65c7f27a 100644 --- a/src/lib/__tests__/push-background-task.test.ts +++ b/src/lib/__tests__/push-background-task.test.ts @@ -68,9 +68,20 @@ describe('matchAccountsForPush', () => { ]; it('matches on the recorded JMAP account id first', () => { - const payload = parseRelayPushData({ accountId: 'jb', accountLabel: 'alice' }); + const payload = parseRelayPushData({ accountId: 'jb', accountLabel: 'bob', emailIds: '["m1"]' }); expect(matchAccountsForPush(payload, accounts, { 'bob@mail.example.com': 'jb' }, registry)) .toEqual({ accountIds: ['bob@mail.example.com'], ambiguous: false }); + // A payload without a label (an older relay) goes by the id alone. + const unlabelled = parseRelayPushData({ accountId: 'jb', emailIds: '["m1"]' }); + expect(matchAccountsForPush(unlabelled, accounts, { 'bob@mail.example.com': 'jb' }, registry)) + .toEqual({ accountIds: ['bob@mail.example.com'], ambiguous: false }); + }); + + it("does not take another login's group push for the one whose own id it carries", () => { + // Bob's group account "c" on his server; Alice's own account is "c" on hers. + const payload = parseRelayPushData({ accountId: 'c', accountLabel: 'bob', emailIds: '["m1"]' }); + expect(matchAccountsForPush(payload, accounts, { 'alice@mail.example.com': 'c', 'bob@mail.example.com': 'b' }, registry)) + .toMatchObject({ ambiguous: true }); }); it('falls back to the relay accountLabel (username)', () => { diff --git a/src/lib/ios-push-shared.ts b/src/lib/ios-push-shared.ts index 7e476e4d..2f28e57e 100644 --- a/src/lib/ios-push-shared.ts +++ b/src/lib/ios-push-shared.ts @@ -62,6 +62,13 @@ export interface IosPushAccount { credentialsKey: string; /** The JMAP API URL as the app uses it (rewritten to the server's host). */ apiUrl: string; + /** + * The username the account registered with the relay, which the relay + * sends back as the push's accountLabel. The extension leaves a push whose + * label is another's as the placeholder: it may be for another login's + * group account that has this account's id. + */ + label?: string; } export async function writeIosPushAccount(jmapAccountId: string, account: IosPushAccount): Promise { diff --git a/src/lib/push-background-task.ts b/src/lib/push-background-task.ts index a9dbc814..f13dd680 100644 --- a/src/lib/push-background-task.ts +++ b/src/lib/push-background-task.ts @@ -224,7 +224,8 @@ export interface PushAccountMatch { * same few), and so do message ids. A push naming messages that still fits * more than one login is `ambiguous`: each login would fetch those ids from * its own server and could show another server's message, so the caller - * shows a plain notification instead. A push without ids makes each login + * shows a plain notification instead. So is one whose label fits none of the + * logins with its JMAP account id. A push without ids makes each login * look at its own newest mail, which is never another login's. */ export function matchAccountsForPush( @@ -237,17 +238,22 @@ export function matchAccountsForPush( ? ids.filter((id) => accountMatchesPushLabel(id, payload.accountLabel!, registry.find((a) => a.id === id)?.username)) : []); let accountIds = pushAccountIds; + let unsure = false; const byJmapId = payload.jmapAccountId ? pushAccountIds.filter((id) => jmapAccountIds[id] === payload.jmapAccountId) : []; if (byJmapId.length > 0) { - const narrowed = byJmapId.length > 1 ? byLabel(byJmapId) : []; + // Also a single match has to fit the label: group accounts are subscribed + // too, and their ids come from the same short pool, so a push for another + // login's group can carry this login's own id. + const narrowed = byLabel(byJmapId); accountIds = narrowed.length > 0 ? narrowed : byJmapId; + unsure = !!payload.accountLabel && narrowed.length === 0; } else { const labelled = byLabel(pushAccountIds); if (labelled.length > 0) accountIds = labelled; } - return { accountIds, ambiguous: accountIds.length > 1 && payload.emailIds.length > 0 }; + return { accountIds, ambiguous: (unsure || accountIds.length > 1) && payload.emailIds.length > 0 }; } async function readNotifiedIds(accountId: string): Promise { diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index d08e38c5..a4d4c733 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -1010,12 +1010,15 @@ async function registerWithRelayApns(params: { * another login with push has the same id, neither gets an entry: the * placeholder says there is new mail, where one login's credentials would * fetch and show a message of the other's server. Run again for the login - * that is left once the other is gone (the next resync does). + * that is left once the other is gone (the next resync does). The entry also + * names the username, which the extension compares with the push's label: a + * group account of another login can have this login's id. */ export async function shareAccountWithExtension( accountId: string, jmapAccountId: string | null, apiUrl: string | undefined = jmapClient.currentSession?.apiUrl, + username: string | null | undefined = jmapClient.username, ): Promise { if (Platform.OS !== 'ios' || !jmapAccountId) return; const byLocal = await readPushJmapAccountIds(); @@ -1024,7 +1027,11 @@ export async function shareAccountWithExtension( return; } if (!apiUrl) return; - await writeIosPushAccount(jmapAccountId, { credentialsKey: credentialsKey(accountId), apiUrl }).catch(() => undefined); + await writeIosPushAccount(jmapAccountId, { + credentialsKey: credentialsKey(accountId), + apiUrl, + ...(username ? { label: username } : {}), + }).catch(() => undefined); } /** @@ -1943,7 +1950,7 @@ async function renewPushAccount(accountId: string, relayBaseUrl: string | null): } catch { jmapAccountId = null; } - await shareAccountWithExtension(accountId, jmapAccountId, client.currentSession?.apiUrl); + await shareAccountWithExtension(accountId, jmapAccountId, client.currentSession?.apiUrl, client.username); } /** @@ -2216,11 +2223,13 @@ export async function iosTapPayload(data: unknown): Promise byLocal[local] === jmapAccountId); - if (candidates.length > 1) { - const label = typeof d.accountLabel === 'string' ? d.accountLabel : ''; + const label = typeof d.accountLabel === 'string' ? d.accountLabel : ''; + if (candidates.length > 1 || (label && candidates.length === 1)) { candidates = label ? candidates.filter((local) => accountMatchesPushLabel(local, label)) : []; if (candidates.length !== 1) return null; } From 152f30896646bce907275d22b9cb7ef4dd26ce6b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:27:40 +0100 Subject: [PATCH 3/3] fix: tear down a removed account's push through its own client, not the open account's --- src/lib/__tests__/push-notifications.test.ts | 66 ++++++++++++++++++++ src/lib/push-notifications.ts | 48 ++++++++++++-- 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/lib/__tests__/push-notifications.test.ts b/src/lib/__tests__/push-notifications.test.ts index 479cde68..2b2e4edd 100644 --- a/src/lib/__tests__/push-notifications.test.ts +++ b/src/lib/__tests__/push-notifications.test.ts @@ -66,6 +66,10 @@ const { DETACHED, SYNC_SESSION } = vi.hoisted(() => { // The subscription's expiry, and what an update of it settles on. expires: null as string | null, settles: null as string | null, + // Every subscription of the account, for a listing with ids: null, and + // whether that listing fails. + all: [] as Array<{ id: string; deviceClientId?: string }>, + listFails: false, log: [] as unknown[], session: SYNC_SESSION, }, @@ -96,6 +100,11 @@ vi.mock('../../api/jmap-client', () => ({ async request(calls: Array<[string, Record, string]>) { const methodResponses = calls.map(([name, args, id]) => { DETACHED.log.push([name, args]); + if (name === 'PushSubscription/get' && args.ids === null) { + if (DETACHED.listFails) throw new Error('network down'); + return [name, { list: DETACHED.all }, id]; + } + if (args.destroy) return [name, { destroyed: args.destroy }, id]; if (name === 'PushSubscription/get') { const listed = { id: args.ids[0], @@ -1854,6 +1863,63 @@ describe('teardownPushNotificationsForAccount', () => { expect(destroyMock.mock.calls.map((c) => c[0])).toEqual(['recorded']); expect(await AsyncStorage.getItem(SUB_KEY)).toBeNull(); }); + + describe('for a signed-in account the client does not serve', () => { + const OTHER = 'bob@other.example.net'; + const OTHER_DCID = 'bobsphonebobsphonebobsphonebobsp'; + const destroyedThroughOwnClient = () => DETACHED.log + .filter((entry) => (entry as unknown[])[0] === 'PushSubscription/set') + .map((entry) => ((entry as [string, { destroy: string[] }])[1]).destroy); + + beforeEach(async () => { + await AsyncStorage.setItem('push:relayBaseUrl:v1', RELAY); + await AsyncStorage.setItem(`push:subscriptionId:v2:${OTHER}`, 'b'); + await AsyncStorage.setItem(deviceClientIdKey(OTHER), OTHER_DCID); + // The open account's own push, with the same short id. + listMock.mockResolvedValue([{ id: 'b', types: ['EmailDelivery'] }]); + Object.assign(DETACHED, { + loaded: true, + all: [{ id: 'b', deviceClientId: OTHER_DCID }, { id: 'c', deviceClientId: 'ffffffffffffffffffffffffffffffff' }], + listFails: false, + log: [], + }); + }); + + afterEach(() => { + listMock.mockImplementation(async () => []); + Object.assign(DETACHED, { loaded: true, all: [], listFails: false }); + }); + + it('destroys its subscriptions through a client of its own', async () => { + await teardownPushNotificationsForAccount(OTHER); + + expect(DETACHED.log[0]).toEqual(['load', OTHER]); + expect(destroyedThroughOwnClient()).toEqual([['b']]); + expect(listMock).not.toHaveBeenCalled(); + expect(destroyMock).not.toHaveBeenCalled(); + expect(await AsyncStorage.getItem(`push:subscriptionId:v2:${OTHER}`)).toBeNull(); + }); + + it("never destroys the recorded id on the open account when its own server can't list", async () => { + listMock.mockRejectedValue(new Error('network down')); + DETACHED.listFails = true; + + await teardownPushNotificationsForAccount(OTHER); + + expect(destroyMock).not.toHaveBeenCalled(); + expect(destroyedThroughOwnClient()).toEqual([['b']]); + + // An account that can't be loaded leaves every server alone. + DETACHED.log = []; + DETACHED.loaded = false; + await AsyncStorage.setItem(`push:subscriptionId:v2:${OTHER}`, 'b'); + await AsyncStorage.setItem(deviceClientIdKey(OTHER), OTHER_DCID); + await teardownPushNotificationsForAccount(OTHER); + expect(destroyMock).not.toHaveBeenCalled(); + expect(destroyedThroughOwnClient()).toEqual([]); + expect(await AsyncStorage.getItem(`push:subscriptionId:v2:${OTHER}`)).toBeNull(); + }); + }); }); describe('notificationTapJmapAccountId', () => { diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index a4d4c733..16e9148a 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -1682,10 +1682,13 @@ export async function recordPushSubscriptionExpires(accountId: string, expires: /** * Tear down push for a single account. Destroys every JMAP subscription the - * server holds for this device (assumes the jmapClient is currently - * authenticated to that account; the active-account logout flow guarantees - * this) and tells the relay to drop its mapping. Other accounts' push setups - * are untouched. + * server holds for this device and tells the relay to drop its mapping. + * Other accounts' push setups are untouched. The account the jmapClient + * serves goes through it; another signed-in account (removed from the + * account list while a different one is open) through a client of its own, + * never the singleton: subscription ids repeat across accounts ("b" in every + * Stalwart account), so a destroy by id there could end the open account's + * own push. * * The FCM token is deliberately left alive: the relay mapping is gone so * nothing gets forwarded, and deleting the token makes the next getToken() @@ -1706,8 +1709,9 @@ export async function teardownPushNotificationsForAccount( // failed enable can leave a registration this client no longer tracks (#841). // The recorded id alone is trusted only when the server can't be asked: on // a server reinstalled at the same address it can name another device's. + const server = await pushSubscriptionsOf(accountId); const idsToDestroy = new Set(); - const existing = storedDcid ? await listPushSubscriptions().catch(() => null) : null; + const existing = server && storedDcid ? await server.list().catch(() => null) : null; if (existing) { for (const s of existing) { const recordedWithoutDevice = s.id === storedSubId && !s.deviceClientId; @@ -1717,7 +1721,7 @@ export async function teardownPushNotificationsForAccount( idsToDestroy.add(storedSubId); } for (const id of idsToDestroy) { - await destroyPushSubscription(id).catch(() => undefined); + await server?.destroy(id).catch(() => undefined); } if (relayBaseUrl && storedDcid) { await deregisterFromRelay(relayBaseUrl, storedDcid); @@ -1729,6 +1733,38 @@ export async function teardownPushNotificationsForAccount( await writePushAccountIds(remaining); } +/** + * Listing and destroying the account's push subscriptions, through the + * jmapClient when it serves the account, else through a client of its own. + * Null when the account can't be loaded: its server is left alone then. + */ +async function pushSubscriptionsOf(accountId: string): Promise<{ + list: () => Promise; + destroy: (id: string) => Promise; +} | null> { + const username = jmapClient.username; + const serverUrl = jmapClient.serverUrl; + if (username && serverUrl && generateAccountId(username, serverUrl) === accountId) { + return { list: listPushSubscriptions, destroy: destroyPushSubscription }; + } + const client = new JMAPClient(); + if (!(await client.loadAccount(accountId).catch(() => false))) return null; + const using = [CAPABILITIES.CORE]; + return { + list: async () => { + const res = await client.request( + [['PushSubscription/get', { ids: null, properties: ['id', 'deviceClientId'] }, '0']], + using, + ); + return (requireMethodResult(res, '0', 'PushSubscription/get').list as ListedSubscription[] | undefined) ?? []; + }, + destroy: async (id) => { + const res = await client.request([['PushSubscription/set', { destroy: [id] }, '0']], using); + requireMethodResult(res, '0', 'PushSubscription/set'); + }, + }; +} + /** * Turn push off for one account because the user asked to: the settings * toggle, or revoking this device (here, or from another device - see