diff --git a/App.tsx b/App.tsx index 17772b27..dee0d125 100644 --- a/App.tsx +++ b/App.tsx @@ -20,7 +20,7 @@ import { addTokenRefreshListener, getInitialNotificationTap, getStoredRelayBaseUrl, - setupPushNotifications, + resyncPushNotifications, teardownPushNotificationsForAccount, type NotificationTapPayload, } from './src/lib/push-notifications'; @@ -432,7 +432,8 @@ export default function App() { // and whenever the FCM token rotates. Honours the user's notification // preference - flipping it off tears down THIS account's subscription so // notifications stop arriving for it. Other logged-in accounts keep their - // setups intact. + // setups intact. An account the user turned push off for, or whose device + // registration was revoked, stays off (see resyncPushNotifications). const emailNotificationsEnabled = useSettingsStore( (s) => s.emailNotificationsEnabled, ); @@ -453,7 +454,7 @@ export default function App() { const relayBaseUrl = await getStoredRelayBaseUrl(); if (!relayBaseUrl) return; try { - await setupPushNotifications({ + await resyncPushNotifications({ relayBaseUrl, accountLabel: client.username ?? undefined, }); diff --git a/src/api/__tests__/push.test.ts b/src/api/__tests__/push.test.ts index 4aa8fc78..fd4c0880 100644 --- a/src/api/__tests__/push.test.ts +++ b/src/api/__tests__/push.test.ts @@ -100,6 +100,30 @@ describe('push operations', () => { }); }); + describe('createPushSubscription', () => { + const params = { + deviceClientId: 'd', url: 'https://relay/x', types: ['EmailDelivery'], expires: '2026-12-01T00:00:00Z', + }; + + it('returns the expiry the server clamped the subscription to', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [[ + 'PushSubscription/set', + { created: { new: { id: 's1', keys: null, expires: '2026-10-01T00:00:00Z' } } }, + '0', + ]], + }); + await expect(createPushSubscription(params)).resolves.toEqual({ id: 's1', expires: '2026-10-01T00:00:00Z' }); + }); + + it('keeps the requested expiry when the server does not echo one', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['PushSubscription/set', { created: { new: { id: 's1' } } }, '0']], + }); + await expect(createPushSubscription(params)).resolves.toEqual({ id: 's1', expires: '2026-12-01T00:00:00Z' }); + }); + }); + describe('PushSubscription/set refusals', () => { const forbidden = { type: 'forbidden', diff --git a/src/api/push.ts b/src/api/push.ts index c2c0abe3..429e86f9 100644 --- a/src/api/push.ts +++ b/src/api/push.ts @@ -40,9 +40,10 @@ 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). A refusal - * throws a JMAPMethodError carrying the server's SetError type, so callers - * can tell a `forbidden` emailPush map from other failures. + * relay so the relay can route incoming pushes to an Expo token) and the + * expiry the server settled on. 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; @@ -54,7 +55,7 @@ export async function createPushSubscription(params: { // draft-ietf-jmap-emailpush delivery filter, only when the server advertises // urn:ietf:params:jmap:emailpush (see serverSupportsEmailPush). emailPush?: Record; -}): Promise { +}): Promise<{ id: string; expires: string | null }> { const created: Record = { deviceClientId: params.deviceClientId, url: params.url, @@ -76,11 +77,13 @@ export async function createPushSubscription(params: { ); const body = requireMethodResult(res, '0', 'PushSubscription/set'); assertSetResult(body, ['new'], 'push subscription'); - const result = body.created?.new as { id?: string } | undefined; + const result = body.created?.new as { id?: string; expires?: string | null } | undefined; if (!result?.id) { throw new Error(`PushSubscription/set create failed: ${JSON.stringify(body)}`); } - return result.id; + // The server only echoes `expires` when it changed it (Stalwart clamps it to + // seven days); otherwise the requested value stands. + return { id: result.id, expires: result.expires ?? params.expires ?? null }; } /** diff --git a/src/components/settings/NotificationSettings.tsx b/src/components/settings/NotificationSettings.tsx index a11580fa..07225873 100644 --- a/src/components/settings/NotificationSettings.tsx +++ b/src/components/settings/NotificationSettings.tsx @@ -18,6 +18,7 @@ import { useLocaleStore } from '../../stores/locale-store'; import { useSettingsStore } from '../../stores/settings-store'; import { DEFAULT_RELAY_BASE_URL, + disablePushForAccount, getStoredRelayBaseUrl, isPushEnabledForAccount, isPushSupported, @@ -28,7 +29,6 @@ import { revokePushDevice, setStoredRelayBaseUrl, setupPushNotifications, - teardownPushNotificationsForAccount, getStoredPushTransport, setStoredPushTransport, type PushDevice, @@ -206,7 +206,7 @@ export function NotificationSettings() { }); try { if (activeAccountId) { - await teardownPushNotificationsForAccount(activeAccountId); + await disablePushForAccount(activeAccountId); } // The relay base URL is a device-wide setting shared with any other // accounts that are still using push. Only clear it when no accounts diff --git a/src/lib/__tests__/push-notifications.test.ts b/src/lib/__tests__/push-notifications.test.ts index 871cf266..df8a5905 100644 --- a/src/lib/__tests__/push-notifications.test.ts +++ b/src/lib/__tests__/push-notifications.test.ts @@ -36,6 +36,12 @@ vi.mock('react-native', () => { }; }); +// What createPushSubscription resolves to: the new id and the expiry the +// server settled on (unknown here unless a test says otherwise). +const { CREATED } = vi.hoisted(() => ({ + CREATED: { id: 'new-server-id', expires: null as string | null }, +})); + vi.mock('../../api/jmap-client', () => ({ jmapClient: { username: 'user@example.com', @@ -55,7 +61,7 @@ vi.mock('../../api/email', () => ({ vi.mock('../../api/push', () => ({ listPushSubscriptions: vi.fn(async () => []), - createPushSubscription: vi.fn(async () => 'new-server-id'), + createPushSubscription: vi.fn(async () => CREATED), verifyPushSubscription: vi.fn(async () => undefined), destroyPushSubscription: vi.fn(async () => undefined), updatePushSubscription: vi.fn(async () => undefined), @@ -65,9 +71,13 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { setupPushNotifications, deviceClientIdKey, + disablePushForAccount, isValidRelayUrl, + readPushAccountIds, readPushJmapAccountIds, PushSetupError, + resyncPushNotifications, + revokePushDevice, teardownPushNotificationsForAccount, } from '../push-notifications'; import { @@ -394,7 +404,7 @@ describe('setupPushNotifications with ACL-shared accounts (B18)', () => { capabilities: { 'urn:ietf:params:jmap:core': {} }, }; sharedMock.mockResolvedValue([]); - createMock.mockImplementation(async () => 'new-server-id'); + createMock.mockImplementation(async () => CREATED); updateMock.mockImplementation(async () => undefined); }); @@ -454,7 +464,7 @@ describe('setupPushNotifications with ACL-shared accounts (B18)', () => { const accept = refuseUnless(['jmap-primary', 'team']); createMock.mockImplementation(async (params: { emailPush?: Record }) => { accept(params.emailPush); - return 'new-server-id'; + return CREATED; }); const result = await setupPushNotifications({ relayBaseUrl: RELAY }); @@ -576,7 +586,7 @@ describe('setupPushNotifications over UnifiedPush', () => { 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'; + return CREATED; }); try { @@ -588,7 +598,7 @@ describe('setupPushNotifications over UnifiedPush', () => { (jmapClient as { currentSession: unknown }).currentSession = { capabilities: { 'urn:ietf:params:jmap:core': {} }, }; - createMock.mockImplementation(async () => 'new-server-id'); + createMock.mockImplementation(async () => CREATED); } }); @@ -631,6 +641,131 @@ describe('setupPushNotifications over UnifiedPush', () => { }); }); +describe('resyncPushNotifications', () => { + const OPTED_OUT_KEY = 'push:optedOut:v1:' + ACCOUNT_ID; + const EXPIRES_KEY = 'push:subscriptionExpires:v1:' + ACCOUNT_ID; + const inDays = (days: number) => new Date(Date.now() + days * 86400000).toISOString(); + + beforeEach(async () => { + vi.clearAllMocks(); + await AsyncStorage.clear(); + await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID); + await AsyncStorage.setItem('push:relayBaseUrl:v1', RELAY); + listMock.mockResolvedValue([]); + installFetch({}); + }); + + afterEach(() => { + createMock.mockImplementation(async () => CREATED); + }); + + it('keeps a healthy registration up to date', async () => { + const expires = inDays(3); + await AsyncStorage.setItem(SUB_KEY, 'existing'); + listMock.mockResolvedValue([ + { id: 'existing', deviceClientId: OUR_DCID, expires, types: ['EmailDelivery'] }, + ]); + + const result = await resyncPushNotifications({ relayBaseUrl: RELAY }); + + expect(result?.subscriptionId).toBe('existing'); + expect(updateMock).toHaveBeenCalledTimes(1); + // The expiry the server reported is what a later resync measures against. + expect(await AsyncStorage.getItem(EXPIRES_KEY)).toBe(expires); + }); + + it('leaves an account alone after the user turned push off for it', async () => { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID])); + listMock.mockResolvedValue([sub('existing', OUR_DCID)]); + + await disablePushForAccount(ACCOUNT_ID); + vi.clearAllMocks(); + const result = await resyncPushNotifications({ relayBaseUrl: RELAY }); + + expect(result).toBeNull(); + expect(listMock).not.toHaveBeenCalled(); + expect(createMock).not.toHaveBeenCalled(); + expect(await readPushAccountIds()).toEqual([]); + }); + + it('leaves push off after this device was revoked from the device list', async () => { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + listMock.mockResolvedValue([sub('existing', OUR_DCID)]); + + await revokePushDevice({ + accountId: ACCOUNT_ID, + device: { id: 'existing', deviceClientId: OUR_DCID, isThisDevice: true }, + relayBaseUrl: RELAY, + }); + const result = await resyncPushNotifications({ relayBaseUrl: RELAY }); + + expect(result).toBeNull(); + expect(createMock).not.toHaveBeenCalled(); + }); + + it('does not re-register a subscription another device revoked', async () => { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + await AsyncStorage.setItem(EXPIRES_KEY, inDays(5)); + await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID])); + listMock.mockResolvedValue([]); + + const result = await resyncPushNotifications({ relayBaseUrl: RELAY }); + + expect(result).toBeNull(); + expect(createMock).not.toHaveBeenCalled(); + expect(await AsyncStorage.getItem(SUB_KEY)).toBeNull(); + expect(await readPushAccountIds()).toEqual([]); + expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).not.toBeNull(); + }); + + it('re-creates a subscription that simply lapsed', async () => { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + await AsyncStorage.setItem(EXPIRES_KEY, inDays(-1)); + listMock.mockResolvedValue([]); + + const result = await resyncPushNotifications({ relayBaseUrl: RELAY }); + + expect(result?.subscriptionId).toBe('new-server-id'); + expect(createMock).toHaveBeenCalledTimes(1); + }); + + it('re-creates a missing subscription whose expiry it never learned', async () => { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + listMock.mockResolvedValue([]); + + const result = await resyncPushNotifications({ relayBaseUrl: RELAY }); + + expect(result?.subscriptionId).toBe('new-server-id'); + }); + + it('changes nothing when the server cannot be asked', async () => { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + await AsyncStorage.setItem(EXPIRES_KEY, inDays(5)); + listMock.mockRejectedValueOnce(new Error('network down')); + + await expect(resyncPushNotifications({ relayBaseUrl: RELAY })).rejects.toThrow('network down'); + + expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing'); + expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull(); + }); + + it('turns push back on when the user enables it again', async () => { + await disablePushForAccount(ACCOUNT_ID); + createMock.mockImplementation(async () => ({ id: 'new-server-id', expires: inDays(7) })); + + await setupPushNotifications({ relayBaseUrl: RELAY }); + + expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull(); + expect(await AsyncStorage.getItem(EXPIRES_KEY)).not.toBeNull(); + vi.clearAllMocks(); + listMock.mockResolvedValue([ + { id: 'new-server-id', deviceClientId: OUR_DCID, expires: inDays(7), types: ['EmailDelivery'] }, + ]); + expect((await resyncPushNotifications({ relayBaseUrl: RELAY }))?.subscriptionId).toBe('new-server-id'); + }); +}); + describe('teardownPushNotificationsForAccount', () => { beforeEach(async () => { vi.clearAllMocks(); diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index c3b02680..9f60cf06 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -45,6 +45,14 @@ const PROMPT_DISMISSED_PREFIX = 'push:promptDismissed:v1:'; // 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:'; +// When the recorded subscription expires, as the server last reported it. Lets +// the launch-time resync tell a subscription that lapsed (re-create it) from +// one that was revoked (leave push off). +const SUBSCRIPTION_EXPIRES_PREFIX = 'push:subscriptionExpires:v1:'; +// Set when the user turned push off for the account - the settings toggle, or +// revoking this device here or from elsewhere. The launch-time resync leaves +// such an account alone until push is enabled for it again. +const OPTED_OUT_PREFIX = 'push:optedOut:v1:'; // Legacy single-account keys (pre-multi-account). Migrated lazily on the next // setupPushNotifications / pushBackgroundTask call, then deleted. @@ -77,6 +85,14 @@ function emailPushRefusedKey(accountId: string): string { return EMAIL_PUSH_REFUSED_PREFIX + accountId; } +function subscriptionExpiresKey(accountId: string): string { + return SUBSCRIPTION_EXPIRES_PREFIX + accountId; +} + +function optedOutKey(accountId: string): string { + return OPTED_OUT_PREFIX + accountId; +} + export async function readPushAccountIds(): Promise { const raw = await AsyncStorage.getItem(PUSH_ACCOUNT_IDS_KEY); if (!raw) return []; @@ -209,6 +225,10 @@ const SUBSCRIPTION_EXPIRES_DAYS = 90; // When an existing subscription has less than this much lifetime left, push // expires forward on the next app start. 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 +// little off) it may simply have lapsed, and is re-created. +const REVOKED_EXPIRY_MARGIN_MS = 24 * 60 * 60 * 1000; function expiresFromNow(days: number): string { return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(); @@ -873,12 +893,14 @@ async function setupPushNotificationsInner( if (storedServerId) { const match = existingSubs.find((s) => s.id === storedServerId); if (match) { + if (match.expires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), match.expires); 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); + await AsyncStorage.removeItem(optedOutKey(accountId)); logPhase('done', 'reused existing subscription'); return { subscriptionId: storedServerId, verified: true }; } @@ -917,6 +939,7 @@ async function setupPushNotificationsInner( } let serverAssignedId: string; + let serverExpires: string | null; try { const created = await writeWithEmailPush(emailPush, (filter) => createPushSubscription({ @@ -927,7 +950,8 @@ async function setupPushNotificationsInner( ...(filter ? { emailPush: filter } : {}), }), ); - serverAssignedId = created.result; + serverAssignedId = created.result.id; + serverExpires = created.result.expires; await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush); } catch (err) { const detail = err instanceof Error ? err.message : String(err); @@ -944,11 +968,13 @@ async function setupPushNotificationsInner( } await AsyncStorage.setItem(subKey, serverAssignedId); + if (serverExpires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), serverExpires); if (replacedServerId) { await destroyPushSubscription(replacedServerId).catch(() => undefined); } await addPushAccountId(accountId); await writePushJmapAccountId(accountId, jmapAccountId); + await AsyncStorage.removeItem(optedOutKey(accountId)); logPhase('done', 'subscription verified'); return { subscriptionId: serverAssignedId, verified: true }; @@ -1021,6 +1047,7 @@ async function clearAccountPushKeys(accountId: string): Promise { lastNotifiedKey(accountId), notifiedIdsKey(accountId), emailPushRefusedKey(accountId), + subscriptionExpiresKey(accountId), ]); await writePushJmapAccountId(accountId, null); } @@ -1070,6 +1097,57 @@ export async function teardownPushNotificationsForAccount( await writePushAccountIds(remaining); } +/** + * Turn push off for one account because the user asked to: the settings + * toggle, or revoking this device (here, or from another device - see + * resyncPushNotifications). Tears the registration down and remembers the + * choice, so the launch-time resync doesn't quietly register the account + * again. Enabling push for it again clears the mark. + */ +export async function disablePushForAccount(accountId: string): Promise { + await teardownPushNotificationsForAccount(accountId); + await AsyncStorage.setItem(optedOutKey(accountId), String(Date.now())); +} + +/** + * True when the server no longer has this account's recorded subscription + * although it wasn't due to expire - someone revoked it. Without a known + * expiry (a registration made by an older build) nothing can be told apart, + * so it counts as not revoked. Throws when the server can't be asked. + */ +async function wasRevokedOnServer(accountId: string): Promise { + const storedServerId = await AsyncStorage.getItem(subscriptionIdKey(accountId)); + const expires = Date.parse((await AsyncStorage.getItem(subscriptionExpiresKey(accountId))) ?? ''); + if (!storedServerId || !Number.isFinite(expires)) return false; + if (expires - Date.now() < REVOKED_EXPIRY_MARGIN_MS) return false; + const subs = await listPushSubscriptions(); + return !subs.some((s) => s.id === storedServerId); +} + +/** + * Bring the loaded account's registration up to date without user action - + * on launch and when the push token or endpoint rotates. Skips an account the + * user turned push off for, and honours a revocation: when the server dropped + * the subscription before it was due to expire, push is turned off for the + * account instead of being silently registered again. Resolves to null when + * it left push off. + */ +export async function resyncPushNotifications( + params: PushSetupParams, +): Promise { + const username = jmapClient.username; + const serverUrl = jmapClient.serverUrl; + if (!username || !serverUrl) return null; + const accountId = generateAccountId(username, serverUrl); + if (await AsyncStorage.getItem(optedOutKey(accountId))) return null; + if (await wasRevokedOnServer(accountId)) { + logPhase('revoked', 'subscription gone before it was due to expire; leaving push off'); + await disablePushForAccount(accountId); + return null; + } + return setupPushNotifications(params); +} + /** * Tear down push for ALL accounts on this device. Used by the logout-all * flow; best-effort because we typically aren't authenticated to every @@ -1166,7 +1244,7 @@ export async function revokePushDevice(params: { const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, ''); if (params.device.isThisDevice) { - await teardownPushNotificationsForAccount(params.accountId); + await disablePushForAccount(params.accountId); return; }