diff --git a/src/api/__tests__/push.test.ts b/src/api/__tests__/push.test.ts index 02747719..3ed508df 100644 --- a/src/api/__tests__/push.test.ts +++ b/src/api/__tests__/push.test.ts @@ -12,7 +12,7 @@ vi.mock('../jmap-client', () => ({ })); import { jmapClient } from '../jmap-client'; -import { startPolling } from '../push'; +import { createPushSubscription, startPolling, updatePushSubscription } from '../push'; const mockRequest = jmapClient.request as ReturnType; @@ -26,6 +26,37 @@ describe('push operations', () => { vi.useRealTimers(); }); + describe('PushSubscription/set refusals', () => { + const forbidden = { + type: 'forbidden', + description: 'No access to one of the accounts in the emailPush map.', + }; + + it('surfaces the SetError type when a create is refused', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['PushSubscription/set', { notCreated: { new: forbidden } }, '0']], + }); + await expect( + createPushSubscription({ deviceClientId: 'd', url: 'https://relay/x', types: ['EmailDelivery'] }), + ).rejects.toMatchObject({ name: 'JMAPMethodError', type: 'forbidden' }); + }); + + it('surfaces the SetError type when an update is refused', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['PushSubscription/set', { notUpdated: { s1: forbidden } }, '0']], + }); + await expect(updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' })) + .rejects.toMatchObject({ type: 'forbidden' }); + }); + + it('resolves when the update is applied', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['PushSubscription/set', { updated: { s1: null } }, '0']], + }); + await expect(updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' })).resolves.toBeUndefined(); + }); + }); + describe('startPolling', () => { it('should poll at the specified interval', async () => { mockRequest.mockResolvedValue({ diff --git a/src/api/push.ts b/src/api/push.ts index 4724dea7..400995a7 100644 --- a/src/api/push.ts +++ b/src/api/push.ts @@ -1,4 +1,5 @@ import { jmapClient } from './jmap-client'; +import { assertSetResult, JMAPMethodError } from './jmap-result'; import { CAPABILITIES } from './types'; import type { EmailPushConfig, PushSubscription, StateChange } from './types'; @@ -18,7 +19,9 @@ export async function listPushSubscriptions(): Promise { /** * Create a PushSubscription pointing the JMAP server at the given relay URL. * Returns the server-assigned id (which the client also registers with the - * relay so the relay can route incoming pushes to an Expo token). + * relay so the relay can route incoming pushes to an Expo token). A refusal + * throws a JMAPMethodError carrying the server's SetError type, so callers + * can tell a `forbidden` emailPush map from other failures. */ export async function createPushSubscription(params: { deviceClientId: string; @@ -50,25 +53,25 @@ export async function createPushSubscription(params: { [CAPABILITIES.CORE], ); const [, body] = res.methodResponses[0] ?? []; + assertSetResult(body, ['new'], 'push subscription'); const result = body?.created?.new as { id?: string } | undefined; if (!result?.id) { - const notCreated = body?.notCreated?.new; - throw new Error( - `PushSubscription/set create failed: ${JSON.stringify(notCreated ?? body)}`, - ); + throw new Error(`PushSubscription/set create failed: ${JSON.stringify(body)}`); } return result.id; } /** - * Push the subscription's expiry forward (RFC 8620 §7.2.1). Returns false if - * the server rejected the update (e.g. the subscription no longer exists), - * which the caller treats as a signal to recreate. + * Push the subscription's expiry forward and re-sync its types or delivery + * filter (RFC 8620 §7.2.1). Throws a JMAPMethodError carrying the server's + * SetError type when it refuses the update - `notFound` once the + * subscription is gone, `forbidden` for an emailPush map naming an account + * the user may not subscribe to. */ export async function updatePushSubscription( id: string, patch: { expires?: string; types?: string[]; emailPush?: Record }, -): Promise { +): Promise { const res = await jmapClient.request( [ [ @@ -80,8 +83,10 @@ export async function updatePushSubscription( [CAPABILITIES.CORE], ); const [, body] = res.methodResponses[0] ?? []; - if (body?.notUpdated?.[id]) return false; - return body?.updated?.[id] !== undefined; + assertSetResult(body, [id], 'push subscription'); + if (body?.updated?.[id] === undefined) { + throw new JMAPMethodError('notUpdated', `PushSubscription/set did not update ${id}`); + } } /** diff --git a/src/lib/__tests__/push-notifications.test.ts b/src/lib/__tests__/push-notifications.test.ts index 3d0e2249..4625dda6 100644 --- a/src/lib/__tests__/push-notifications.test.ts +++ b/src/lib/__tests__/push-notifications.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // Provide the Android native FCM surface setupPushNotifications needs. The // global test-setup mocks react-native with an empty NativeModules, so override @@ -58,7 +58,7 @@ vi.mock('../../api/push', () => ({ createPushSubscription: vi.fn(async () => 'new-server-id'), verifyPushSubscription: vi.fn(async () => undefined), destroyPushSubscription: vi.fn(async () => undefined), - updatePushSubscription: vi.fn(async () => true), + updatePushSubscription: vi.fn(async () => undefined), })); import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -75,7 +75,11 @@ import { createPushSubscription, destroyPushSubscription, updatePushSubscription, + verifyPushSubscription, } from '../../api/push'; +import { getSharedMailboxes } from '../../api/email'; +import { JMAPMethodError } from '../../api/jmap-result'; +import type { EmailPushConfig } from '../../api/types'; import { jmapClient } from '../../api/jmap-client'; import { NativeModules } from 'react-native'; import { generateAccountId } from '../account-utils'; @@ -286,6 +290,168 @@ describe('setupPushNotifications subscription shape', () => { }); }); +describe('setupPushNotifications with ACL-shared accounts (B18)', () => { + const REFUSED_KEY = 'push:emailPushRefused:v1:' + ACCOUNT_ID; + const sharedMock = getSharedMailboxes as ReturnType; + const verifyMock = verifyPushSubscription as ReturnType; + const calendars = (mayCreateCalendar: boolean) => ({ + 'urn:ietf:params:jmap:calendars': { mayCreateCalendar }, + }); + // What Stalwart puts in the session: a group the user belongs to and a + // mailbox another user shared by ACL look alike except for the create flags. + const SESSION = { + capabilities: { 'urn:ietf:params:jmap:emailpush': {} }, + accounts: { + 'jmap-primary': { name: 'user', isPersonal: true, isReadOnly: false, accountCapabilities: calendars(true) }, + team: { name: 'team', isPersonal: false, isReadOnly: false, accountCapabilities: calendars(true) }, + 'acl-b': { name: 'userb', isPersonal: false, isReadOnly: false, accountCapabilities: calendars(false) }, + }, + }; + const forbidden = () => + new JMAPMethodError('forbidden', 'No access to one of the accounts in the emailPush map.'); + // Stalwart refuses the whole map as soon as it names an account the user + // doesn't own; `allowed` is what it accepts. + const refuseUnless = (allowed: string[]) => (emailPush?: Record) => { + if (emailPush && Object.keys(emailPush).some((id) => !allowed.includes(id))) throw forbidden(); + }; + const healthy = () => ({ + id: 'existing', + deviceClientId: OUR_DCID, + expires: new Date(Date.now() + 80 * 86400000).toISOString(), + types: ['EmailDelivery'], + }); + const mapKeys = (emailPush: Record | undefined) => + Object.keys(emailPush ?? {}).sort(); + + beforeEach(async () => { + vi.clearAllMocks(); + await AsyncStorage.clear(); + await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID); + (jmapClient as { currentSession: unknown }).currentSession = SESSION; + sharedMock.mockResolvedValue([ + { id: 'team:inbox', originalId: 'inbox', role: 'inbox', accountId: 'team' }, + { id: 'acl-b:inbox', originalId: 'inbox', role: 'inbox', accountId: 'acl-b' }, + ]); + listMock.mockResolvedValue([]); + installFetch({}); + }); + + afterEach(() => { + (jmapClient as { currentSession: unknown }).currentSession = { + capabilities: { 'urn:ietf:params:jmap:core': {} }, + }; + sharedMock.mockResolvedValue([]); + createMock.mockImplementation(async () => 'new-server-id'); + updateMock.mockImplementation(async () => undefined); + }); + + it('narrows a refused map on refresh instead of destroying the working subscription', async () => { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + listMock.mockResolvedValue([healthy()]); + const accept = refuseUnless(['jmap-primary', 'team']); + updateMock.mockImplementation(async (_id: string, patch: { emailPush?: Record }) => + accept(patch.emailPush), + ); + + const result = await setupPushNotifications({ relayBaseUrl: RELAY }); + + expect(result.subscriptionId).toBe('existing'); + expect(updateMock).toHaveBeenCalledTimes(2); + expect(mapKeys(updateMock.mock.calls[0][1].emailPush)).toEqual(['acl-b', 'jmap-primary', 'team']); + // The group keeps its junk filter; only the ACL share is dropped. + expect(mapKeys(updateMock.mock.calls[1][1].emailPush)).toEqual(['jmap-primary', 'team']); + expect(destroyMock).not.toHaveBeenCalled(); + expect(createMock).not.toHaveBeenCalled(); + expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing'); + expect(JSON.parse((await AsyncStorage.getItem(REFUSED_KEY))!)).toEqual(['acl-b']); + }); + + it('leaves a remembered refusal out of the map on the next run', async () => { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + await AsyncStorage.setItem(REFUSED_KEY, JSON.stringify(['acl-b'])); + listMock.mockResolvedValue([healthy()]); + const accept = refuseUnless(['jmap-primary', 'team']); + updateMock.mockImplementation(async (_id: string, patch: { emailPush?: Record }) => + accept(patch.emailPush), + ); + + await setupPushNotifications({ relayBaseUrl: RELAY }); + + expect(updateMock).toHaveBeenCalledTimes(1); + expect(mapKeys(updateMock.mock.calls[0][1].emailPush)).toEqual(['jmap-primary', 'team']); + }); + + it('falls back to the primary account alone when the group is refused too', async () => { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + listMock.mockResolvedValue([healthy()]); + const accept = refuseUnless(['jmap-primary']); + updateMock.mockImplementation(async (_id: string, patch: { emailPush?: Record }) => + accept(patch.emailPush), + ); + + const result = await setupPushNotifications({ relayBaseUrl: RELAY }); + + expect(result.subscriptionId).toBe('existing'); + expect(updateMock).toHaveBeenCalledTimes(3); + expect(mapKeys(updateMock.mock.calls[2][1].emailPush)).toEqual(['jmap-primary']); + expect(JSON.parse((await AsyncStorage.getItem(REFUSED_KEY))!).sort()).toEqual(['acl-b', 'team']); + }); + + it('narrows a refused map when creating a subscription', async () => { + const accept = refuseUnless(['jmap-primary', 'team']); + createMock.mockImplementation(async (params: { emailPush?: Record }) => { + accept(params.emailPush); + return 'new-server-id'; + }); + + const result = await setupPushNotifications({ relayBaseUrl: RELAY }); + + expect(result).toEqual({ subscriptionId: 'new-server-id', verified: true }); + expect(createMock).toHaveBeenCalledTimes(2); + expect(mapKeys(createMock.mock.calls[1][0].emailPush)).toEqual(['jmap-primary', 'team']); + expect(await AsyncStorage.getItem(SUB_KEY)).toBe('new-server-id'); + }); + + it('does not retry other refusals with a narrower map', async () => { + createMock.mockImplementation(async () => { + throw new JMAPMethodError('overQuota', 'There are too many subscriptions.'); + }); + + const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e); + + expect(err).toBeInstanceOf(PushSetupError); + expect(err.phase).toBe('jmap'); + expect(createMock).toHaveBeenCalledTimes(1); + }); + + it('destroys the replaced subscription only after the new one is verified', async () => { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + listMock.mockResolvedValue([healthy()]); + + await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate: true }); + + const destroyOrder = destroyMock.mock.invocationCallOrder[ + destroyMock.mock.calls.findIndex((c) => c[0] === 'existing') + ]; + expect(destroyOrder).toBeGreaterThan(verifyMock.mock.invocationCallOrder[0]); + expect(await AsyncStorage.getItem(SUB_KEY)).toBe('new-server-id'); + }); + + it('keeps the working subscription when its replacement is refused', async () => { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + listMock.mockResolvedValue([healthy()]); + createMock.mockImplementation(async () => { + throw forbidden(); + }); + + const err = await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate: true }).catch((e) => e); + + expect(err.phase).toBe('jmap'); + expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('existing'); + expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing'); + }); +}); + describe('setupPushNotifications over UnifiedPush', () => { type UpNative = { getDistributors: ReturnType; @@ -344,6 +510,35 @@ describe('setupPushNotifications over UnifiedPush', () => { expect(createMock).toHaveBeenCalledTimes(1); }); + it('narrows a refused emailPush map over UnifiedPush as well', async () => { + installUpFetch(); + (jmapClient as { currentSession: unknown }).currentSession = { + capabilities: { 'urn:ietf:params:jmap:emailpush': {} }, + accounts: { 'jmap-primary': { name: 'user', isPersonal: true, isReadOnly: false } }, + }; + (getSharedMailboxes as ReturnType).mockResolvedValueOnce([ + { id: 'acl-b:inbox', originalId: 'inbox', role: 'inbox', accountId: 'acl-b' }, + ]); + createMock.mockImplementation(async (params: { emailPush?: Record }) => { + if (params.emailPush && 'acl-b' in params.emailPush) { + throw new JMAPMethodError('forbidden', 'No access to one of the accounts in the emailPush map.'); + } + return 'new-server-id'; + }); + + try { + const result = await setupPushNotifications({ relayBaseUrl: RELAY }); + expect(result.verified).toBe(true); + expect(createMock).toHaveBeenCalledTimes(2); + expect(Object.keys(createMock.mock.calls[1][0].emailPush)).toEqual(['jmap-primary']); + } finally { + (jmapClient as { currentSession: unknown }).currentSession = { + capabilities: { 'urn:ietf:params:jmap:core': {} }, + }; + createMock.mockImplementation(async () => 'new-server-id'); + } + }); + it('sends keys: null for a legacy distributor without Web Push keys', async () => { upNative().getEndpoint.mockResolvedValue({ url: 'https://legacy.example/up123456', diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index c7fbf3a2..c3b02680 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -9,7 +9,9 @@ import { } from '../api/push'; import { getMailboxes, getSharedMailboxes } from '../api/email'; import { jmapClient } from '../api/jmap-client'; -import type { EmailPushConfig, Mailbox } from '../api/types'; +import { JMAPMethodError } from '../api/jmap-result'; +import { CAPABILITIES } from '../api/types'; +import type { EmailPushConfig, JMAPAccountInfo, Mailbox } from '../api/types'; import { generateAccountId } from './account-utils'; import { getUnifiedPushDistributors, @@ -39,6 +41,10 @@ export const LAST_NOTIFIED_EMAIL_ID_PREFIX = 'push:lastNotifiedEmailId:v2:'; // lastNotified id, which could not tell "already shown" from "older mail"). const NOTIFIED_IDS_PREFIX = 'push:notifiedIds:v1:'; const PROMPT_DISMISSED_PREFIX = 'push:promptDismissed:v1:'; +// JMAP account ids the server refused in this account's emailPush map (see +// emailPushFallbacks). Left out of the map from then on, so a refusal isn't +// re-provoked on every launch. +const EMAIL_PUSH_REFUSED_PREFIX = 'push:emailPushRefused:v1:'; // Legacy single-account keys (pre-multi-account). Migrated lazily on the next // setupPushNotifications / pushBackgroundTask call, then deleted. @@ -67,6 +73,10 @@ function promptDismissedKey(accountId: string): string { return PROMPT_DISMISSED_PREFIX + accountId; } +function emailPushRefusedKey(accountId: string): string { + return EMAIL_PUSH_REFUSED_PREFIX + accountId; +} + export async function readPushAccountIds(): Promise { const raw = await AsyncStorage.getItem(PUSH_ACCOUNT_IDS_KEY); if (!raw) return []; @@ -277,6 +287,106 @@ export function sameEmailPush( return normalizeEmailPush(a) === normalizeEmailPush(b); } +// Stalwart marks the accounts a user owns - their own and the groups they +// belong to - through the create flags of its per-account collection +// capabilities: true there, false on accounts shared with them by ACL. +const OWNERSHIP_FLAGS: ReadonlyArray = [ + [CAPABILITIES.CALENDARS, 'mayCreateCalendar'], + [CAPABILITIES.CONTACTS, 'mayCreateAddressBook'], + [CAPABILITIES.FILES, 'mayCreateTopLevelFileNode'], +]; + +function looksOwned(info: JMAPAccountInfo | undefined): boolean { + if (!info) return false; + if (info.isPersonal) return true; + if (info.isReadOnly) return false; + return OWNERSHIP_FLAGS.some(([urn, flag]) => { + const capability = info.accountCapabilities?.[urn] as Record | undefined; + return capability?.[flag] === true; + }); +} + +/** + * The emailPush maps to try, widest first. Stalwart refuses the whole map as + * `forbidden` when it names an account the user neither owns nor belongs to + * as a group member - a mailbox shared by ACL - and only fans a subscription + * out to owned accounts anyway, so dropping the others loses nothing. The + * session lists both kinds as isPersonal:false, isReadOnly:false, so the + * first fallback keeps the accounts that look owned (see OWNERSHIP_FLAGS) and + * the last keeps only the primary account, which is always accepted. + */ +export function emailPushFallbacks( + desired: Record, + primaryAccountId: string, + accounts: Record | undefined, +): Record[] { + const keep = (predicate: (id: string) => boolean) => + Object.fromEntries( + Object.entries(desired).filter(([id]) => id === primaryAccountId || predicate(id)), + ); + const out = [desired]; + for (const next of [keep((id) => looksOwned(accounts?.[id])), keep(() => false)]) { + // Only steps that actually drop an account are worth another round-trip. + if (Object.keys(next).length < Object.keys(out[out.length - 1]).length) out.push(next); + } + return out; +} + +/** + * Run a subscription write that carries `emailPush`, narrowing the map along + * emailPushFallbacks while the server refuses it as `forbidden`. Resolves to + * the write's result and the map the server took. + */ +async function writeWithEmailPush( + emailPush: Record | null, + write: (emailPush: Record | null) => Promise, +): Promise<{ result: T; emailPush: Record | null }> { + const attempts = emailPush + ? emailPushFallbacks(emailPush, jmapClient.accountId, jmapClient.currentSession?.accounts) + : [null]; + for (let i = 0; ; i++) { + try { + return { result: await write(attempts[i]), emailPush: attempts[i] }; + } catch (err) { + const forbidden = err instanceof JMAPMethodError && err.type === 'forbidden'; + if (!forbidden || i === attempts.length - 1) throw err; + logPhase('jmap', 'emailPush map refused, retrying with fewer accounts'); + } + } +} + +async function readRefusedEmailPushAccounts(accountId: string): Promise { + try { + const parsed = JSON.parse((await AsyncStorage.getItem(emailPushRefusedKey(accountId))) ?? '[]'); + return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === 'string') : []; + } catch { + return []; + } +} + +/** Add the accounts the server dropped from `wanted` to reach `accepted`. */ +async function rememberRefusedEmailPushAccounts( + accountId: string, + refusedBefore: string[], + wanted: Record | null, + accepted: Record | null, +): Promise { + if (!wanted || !accepted) return; + const refusedNow = Object.keys(wanted).filter((id) => !(id in accepted)); + if (refusedNow.length === 0) return; + await AsyncStorage.setItem( + emailPushRefusedKey(accountId), + JSON.stringify(Array.from(new Set([...refusedBefore, ...refusedNow]))), + ); +} + +function withoutAccounts( + config: Record, + accountIds: string[], +): Record { + return Object.fromEntries(Object.entries(config).filter(([id]) => !accountIds.includes(id))); +} + type BulwarkFcmNative = { getToken(): Promise; deleteToken(): Promise; @@ -739,10 +849,18 @@ async function setupPushNotificationsInner( // 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. - // With forceRecreate we skip the reuse and destroy it instead (#841). + // With forceRecreate we skip the reuse and replace it instead (#841). Either + // way the old record keeps delivering until its replacement is verified, so + // a replacement the server refuses never leaves the device without push. logPhase('jmap'); const existingSubs = await listPushSubscriptions().catch(() => []); - const emailPush = serverSupportsEmailPush() ? await buildEmailPushConfig() : null; + // A forced re-registration re-learns which accounts the server accepts in + // the emailPush map. + if (params.forceRecreate) await AsyncStorage.removeItem(emailPushRefusedKey(accountId)); + const refusedBefore = await readRefusedEmailPushAccounts(accountId); + const emailPush = serverSupportsEmailPush() + ? withoutAccounts(await buildEmailPushConfig(), refusedBefore) + : null; const subKey = subscriptionIdKey(accountId); const storedServerId = await AsyncStorage.getItem(subKey); let jmapAccountId: string | null = null; @@ -751,24 +869,26 @@ async function setupPushNotificationsInner( } catch { jmapAccountId = null; } + let replacedServerId: string | null = null; if (storedServerId) { const match = existingSubs.find((s) => s.id === storedServerId); if (match) { if (!params.forceRecreate) { const refreshed = await refreshSubscriptionExpires(match, emailPush); if (refreshed) { + await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush); await addPushAccountId(accountId); await writePushJmapAccountId(accountId, jmapAccountId); logPhase('done', 'reused existing subscription'); return { subscriptionId: storedServerId, verified: true }; } } - // Server rejected the refresh (likely the subscription was already - // deleted server-side) or the caller asked for a fresh record - drop - // the stale id and recreate below. - await destroyPushSubscription(storedServerId).catch(() => undefined); + // Server rejected the refresh or the caller asked for a fresh record: + // create a replacement below and destroy this one once it's verified. + replacedServerId = storedServerId; + } else { + await AsyncStorage.removeItem(subKey); } - await AsyncStorage.removeItem(subKey); } // Reap leftover Stalwart subscriptions that would otherwise starve the new @@ -798,13 +918,17 @@ async function setupPushNotificationsInner( let serverAssignedId: string; try { - serverAssignedId = await createPushSubscription({ - deviceClientId, - url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`), - types: [...PUSH_TYPES], - expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), - ...(emailPush ? { emailPush } : {}), - }); + const created = await writeWithEmailPush(emailPush, (filter) => + createPushSubscription({ + deviceClientId, + url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`), + types: [...PUSH_TYPES], + expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), + ...(filter ? { emailPush: filter } : {}), + }), + ); + serverAssignedId = created.result; + await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush); } catch (err) { const detail = err instanceof Error ? err.message : String(err); throw new PushSetupError('jmap', `The mail server refused the push subscription: ${detail}`); @@ -820,6 +944,9 @@ async function setupPushNotificationsInner( } await AsyncStorage.setItem(subKey, serverAssignedId); + if (replacedServerId) { + await destroyPushSubscription(replacedServerId).catch(() => undefined); + } await addPushAccountId(accountId); await writePushJmapAccountId(accountId, jmapAccountId); logPhase('done', 'subscription verified'); @@ -839,7 +966,8 @@ async function addPushAccountId(accountId: string): Promise { // from what this client wants (a subscription created by an older build still // listens to `Email`/`Mailbox`; a Junk mailbox id can change under us). // Returns false if the server rejects the update, which the caller treats as -// "recreate". +// "replace"; otherwise the emailPush map the update installed (null when it +// left the filter alone). async function refreshSubscriptionExpires( sub: { id: string; @@ -849,7 +977,7 @@ async function refreshSubscriptionExpires( }, // null when the server has no emailPush support - leave the property alone. desiredEmailPush: Record | null, -): Promise { +): Promise | null }> { const typesNeedUpdate = !sameTypes(sub.types, PUSH_TYPES); const emailPushNeedsUpdate = desiredEmailPush !== null && !sameEmailPush(sub.emailPush, desiredEmailPush); @@ -858,18 +986,19 @@ async function refreshSubscriptionExpires( const thresholdMs = SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000; if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) { // Plenty of life left - skip the update round-trip. - return true; + return { emailPush: null }; } } try { - const patch: { - expires?: string; - types?: string[]; - emailPush?: Record; - } = { expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS) }; + const patch: { expires?: string; types?: string[] } = { + expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), + }; if (typesNeedUpdate) patch.types = [...PUSH_TYPES]; - if (emailPushNeedsUpdate && desiredEmailPush) patch.emailPush = desiredEmailPush; - return await updatePushSubscription(sub.id, patch); + const { emailPush } = await writeWithEmailPush( + emailPushNeedsUpdate ? desiredEmailPush : null, + (filter) => updatePushSubscription(sub.id, filter ? { ...patch, emailPush: filter } : patch), + ); + return { emailPush }; } catch { return false; } @@ -891,6 +1020,7 @@ async function clearAccountPushKeys(accountId: string): Promise { deviceClientIdKey(accountId), lastNotifiedKey(accountId), notifiedIdsKey(accountId), + emailPushRefusedKey(accountId), ]); await writePushJmapAccountId(accountId, null); }