From d92a2ca835cccb1adec603a95125aa342ea1ae03 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:57:06 +0100 Subject: [PATCH] fix: renew push for every signed-in account and show each account's push state in the notification settings --- App.tsx | 6 +- locales/rn/en.json | 1 + .../settings/NotificationSettings.tsx | 47 +++++++ src/lib/__tests__/push-notifications.test.ts | 106 ++++++++++++++-- src/lib/push-notifications.ts | 116 ++++++++++++++++++ 5 files changed, 268 insertions(+), 8 deletions(-) diff --git a/App.tsx b/App.tsx index 314ea587..545786ff 100644 --- a/App.tsx +++ b/App.tsx @@ -35,6 +35,7 @@ import { getInitialNotificationTap, getStoredRelayBaseUrl, notificationTapJmapAccountId, + renewOtherPushAccounts, resyncPushNotifications, teardownPushNotificationsForAccount, type NotificationTapPayload, @@ -672,13 +673,16 @@ export default function App() { relayBaseUrl, accountLabel: client.username ?? undefined, }); - if (cancelled) return; } catch (error) { console.warn( '[push] relay setup failed:', error instanceof Error ? error.message : error, ); } + if (cancelled) return; + // The other signed-in accounts' subscriptions expire too (webmail #1054); each + // is renewed through a client of its own. + await renewOtherPushAccounts().catch(() => undefined); }; void doSetup(); diff --git a/locales/rn/en.json b/locales/rn/en.json index d4449b41..1b27dafc 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -1041,6 +1041,7 @@ "sound_channel_hint": "Sound and vibration for mail alerts are controlled by the Android notification channel: long-press a notification or open the system app settings." }, "push": { + "accounts_desc_mobile": "Background notifications of each signed-in account on this device. Switch to an account to turn them on or off for it.", "confirm_disable_message_mobile": "This device will stop receiving new-mail alerts for this account.", "description_mobile": "Receive system notifications for new mail when the app is closed. Delivered via the Bulwark push relay; the relay never sees mail content.", "err_android_only": "Push notifications are only available on Android.", diff --git a/src/components/settings/NotificationSettings.tsx b/src/components/settings/NotificationSettings.tsx index 8377b731..35d2a222 100644 --- a/src/components/settings/NotificationSettings.tsx +++ b/src/components/settings/NotificationSettings.tsx @@ -13,6 +13,7 @@ import Dialog from '../Dialog'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; +import { useAccountStore } from '../../stores/account-store'; import { useAuthStore } from '../../stores/auth-store'; import { useEmailStore } from '../../stores/email-store'; import { useLocaleStore } from '../../stores/locale-store'; @@ -28,12 +29,14 @@ import { mailAccountsWithoutPush, PushSetupError, readPushAccountIds, + readPushAccountStates, revokePushDevice, serverSupportsEmailPush, setStoredRelayBaseUrl, enablePushFromSettings, getStoredPushTransport, setStoredPushTransport, + type PushAccountState, type PushDevice, type PushSetupPhase, type PushTransport, @@ -178,6 +181,22 @@ export function NotificationSettings() { return () => { cancelled = true; }; }, [supported, client, mailboxes, pushStatus.kind]); + // Push is set up per account, and the switch above is the shown account's. + // List every signed-in account's state so the others aren't a guess (webmail #1054). + const accounts = useAccountStore((s) => s.accounts); + const accountIdsKey = accounts.map((a) => a.id).join('\n'); + const [accountStates, setAccountStates] = useState>({}); + useEffect(() => { + if (!supported || accounts.length < 2) return; + let cancelled = false; + void readPushAccountStates(accounts.map((a) => a.id)).then( + (states) => { if (!cancelled) setAccountStates(states); }, + () => undefined, + ); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [supported, accountIdsKey, pushEnabled, pushStatus.kind]); + const describeError = (error: unknown): string => { if (error instanceof PushSetupError) { const phase = PHASE_KEYS[error.phase]; @@ -500,6 +519,34 @@ export function NotificationSettings() { )} + {supported && accounts.length > 1 && ( + + {accounts.map((account, index) => { + const on = accountStates[account.id]?.enabled === true; + const address = account.email || account.username; + const state = on + ? t('settings.notifications.push_all.state_on', 'notifications on') + : t('settings.notifications.push_all.state_off', 'notifications off'); + return ( + + {on ? : } + + ); + })} + + )} + { loaded: true, types: ['EmailDelivery'] as string[], deviceClientId: null as string | null, + // The subscription's expiry, and what an update of it settles on. + expires: null as string | null, + settles: null as string | null, log: [] as unknown[], session: SYNC_SESSION, }, @@ -84,14 +87,29 @@ vi.mock('../../api/jmap-client', () => ({ DETACHED.log.push(['load', id]); return DETACHED.loaded; } + get username() { + return 'bob'; + } + get accountId() { + return 'bob-jmap'; + } async request(calls: Array<[string, Record, string]>) { - const [name, args, id] = calls[0]; - DETACHED.log.push([name, args]); - if (name === 'PushSubscription/get') { - const listed = { id: args.ids[0], types: DETACHED.types, ...(DETACHED.deviceClientId ? { deviceClientId: DETACHED.deviceClientId } : {}) }; - return { methodResponses: [[name, { list: [listed] }, id]] }; - } - return { methodResponses: [[name, { updated: { [Object.keys(args.update)[0]]: null } }, id]] }; + const methodResponses = calls.map(([name, args, id]) => { + DETACHED.log.push([name, args]); + if (name === 'PushSubscription/get') { + const listed = { + id: args.ids[0], + types: DETACHED.types, + ...(DETACHED.deviceClientId ? { deviceClientId: DETACHED.deviceClientId } : {}), + ...(DETACHED.expires ? { expires: DETACHED.expires } : {}), + }; + return [name, { list: [listed] }, id]; + } + const target = Object.keys(args.update)[0]; + if (args.update[target].expires && DETACHED.settles) DETACHED.expires = DETACHED.settles; + return [name, { updated: { [target]: null } }, id]; + }); + return { methodResponses }; } }, })); @@ -126,7 +144,9 @@ import { readPushJmapAccountIds, PushSetupError, pushTypesFor, + readPushAccountStates, refreshPushSubscriptionTypes, + renewOtherPushAccounts, resyncPushNotifications, revokePushDevice, teardownPushNotificationsForAccount, @@ -1683,6 +1703,78 @@ describe('resyncPushNotifications', () => { }); }); +describe('renewOtherPushAccounts (#1054)', () => { + const OTHER = 'bob@other.example.net'; + const OTHER_DCID = 'bobsphonebobsphonebobsphonebobsp'; + const inDays = (days: number) => new Date(Date.now() + days * 86400000).toISOString(); + + beforeEach(async () => { + vi.clearAllMocks(); + await AsyncStorage.clear(); + await AsyncStorage.setItem('push:relayBaseUrl:v1', RELAY); + await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID, OTHER])); + await AsyncStorage.setItem(SUB_KEY, 'existing'); + await AsyncStorage.setItem(`push:subscriptionId:v2:${OTHER}`, 'b'); + await AsyncStorage.setItem(deviceClientIdKey(OTHER), OTHER_DCID); + Object.assign(DETACHED, { loaded: true, deviceClientId: OTHER_DCID, expires: inDays(2), settles: inDays(7), log: [] }); + installFetch({}); + }); + + afterEach(() => { + Object.assign(DETACHED, { deviceClientId: null, expires: null, settles: null }); + }); + + it("extends another account's subscription through a client of its own and records the server's expiry", async () => { + const settles = DETACHED.settles; + + await renewOtherPushAccounts(); + + const calls = DETACHED.log.map((entry) => (entry as unknown[])[0]); + // The loaded account is the launch-time resync's; only the other is touched here. + expect(calls).toEqual(['load', 'PushSubscription/get', 'PushSubscription/set', 'PushSubscription/get']); + expect(DETACHED.log[0]).toEqual(['load', OTHER]); + expect(updateMock).not.toHaveBeenCalled(); + expect(listMock).not.toHaveBeenCalled(); + expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${OTHER}`)).toBe(settles); + // Its relay registration carries this device's current token. + const fetchMock = global.fetch as ReturnType; + const registration = fetchMock.mock.calls.find((c) => String(c[0]) === `${RELAY}/api/push/register`); + expect(JSON.parse(String((registration![1] as RequestInit).body))).toEqual({ + subscriptionId: OTHER_DCID, + fcmToken: 'fcm-token-xyz', + accountLabel: 'bob', + }); + }); + + it('only records the expiry of a subscription with more than a week left', async () => { + DETACHED.expires = inDays(30); + await renewOtherPushAccounts(); + expect(DETACHED.log.map((entry) => (entry as unknown[])[0])).toEqual(['load', 'PushSubscription/get']); + expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${OTHER}`)).toBe(DETACHED.expires); + }); + + it("leaves alone another client's subscription that reuses the id, and an account push was turned off for", async () => { + DETACHED.deviceClientId = 'webmailwebmailwebmailwebmailwebm'; + await renewOtherPushAccounts(); + expect(DETACHED.log.map((entry) => (entry as unknown[])[0])).toEqual(['load', 'PushSubscription/get']); + + DETACHED.log = []; + await AsyncStorage.setItem(`push:optedOut:v1:${OTHER}`, '1'); + await renewOtherPushAccounts(); + expect(DETACHED.log).toEqual([]); + }); + + it('tells the settings which accounts have push on this device', async () => { + await AsyncStorage.setItem(`push:optedOut:v1:${OTHER}`, '1'); + await AsyncStorage.removeItem(`push:subscriptionId:v2:${OTHER}`); + expect(await readPushAccountStates([ACCOUNT_ID, OTHER, 'carl@example.org'])).toEqual({ + [ACCOUNT_ID]: { enabled: true, optedOut: false }, + [OTHER]: { enabled: false, optedOut: true }, + 'carl@example.org': { enabled: false, optedOut: false }, + }); + }); +}); + describe('teardownPushNotificationsForAccount', () => { beforeEach(async () => { vi.clearAllMocks(); diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index 6fb7ff35..bf60fc50 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -34,6 +34,7 @@ import { credentialsKey } from '../api/jmap-client'; import { apnsEnvironment, removeIosPushAccount, writeIosPushAccount } from './ios-push-shared'; import { getUnifiedPushDistributors, + getUnifiedPushEndpoint, isUnifiedPushSupported, registerUnifiedPush, unregisterUnifiedPush, @@ -1853,6 +1854,121 @@ export async function refreshPushSubscriptionTypes(accountId: string): Promise { + const username = jmapClient.username; + const serverUrl = jmapClient.serverUrl; + const served = username && serverUrl ? generateAccountId(username, serverUrl) : null; + const relayBaseUrl = await getStoredRelayBaseUrl(); + for (const accountId of await readPushAccountIds()) { + if (accountId === served) continue; + try { + await renewPushAccount(accountId, relayBaseUrl); + } catch (err) { + logPhase('renew', `${accountId}: ${err instanceof Error ? err.message : String(err)}`); + } + } +} + +type ListedSubscription = { id: string; deviceClientId?: string | null; expires?: string | null }; + +async function renewPushAccount(accountId: string, relayBaseUrl: string | null): Promise { + if (await AsyncStorage.getItem(optedOutKey(accountId))) return; + const subscriptionId = await AsyncStorage.getItem(subscriptionIdKey(accountId)); + if (!subscriptionId) return; + const deviceClientId = await AsyncStorage.getItem(deviceClientIdKey(accountId)); + const client = new JMAPClient(); + if (!(await client.loadAccount(accountId))) return; + if (relayBaseUrl && deviceClientId) { + await registerDeviceWithRelay(relayBaseUrl, deviceClientId, client.username ?? undefined).catch(() => undefined); + } + const using = [CAPABILITIES.CORE]; + const properties = ['id', 'deviceClientId', 'expires']; + const ownIn = (res: Awaited>, callId: string) => + (requireMethodResult(res, callId, 'PushSubscription/get').list as ListedSubscription[] | undefined) + ?.find((s) => isOwnSubscription(s, subscriptionId, deviceClientId)); + const current = ownIn( + await client.request([['PushSubscription/get', { ids: [subscriptionId], properties }, '0']], using), + '0', + ); + if (!current) return; + let expires = current.expires ?? null; + const remainingMs = Date.parse(expires ?? '') - Date.now(); + if (!(remainingMs > SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000)) { + const res = await client.request( + [ + ['PushSubscription/set', { update: { [subscriptionId]: { expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS) } } }, '0'], + // The update's answer doesn't say what the server settled on. + ['PushSubscription/get', { ids: [subscriptionId], properties }, '1'], + ], + using, + ); + expires = ownIn(res, '1')?.expires ?? expires; + } + if (expires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), expires); + let jmapAccountId: string | null = null; + try { + jmapAccountId = client.accountId; + } catch { + jmapAccountId = null; + } + await shareAccountWithExtension(accountId, jmapAccountId, client.currentSession?.apiUrl); +} + +/** + * Register an account's device client id with the relay again, with the + * device token or endpoint this device has now. Never asks for anything: no + * permission prompt, no new UnifiedPush registration. + */ +async function registerDeviceWithRelay(relayBaseUrl: string, deviceClientId: string, accountLabel?: string): Promise { + const transport = await getEffectivePushTransport(); + if (transport === 'apns') { + const token = (await Notifications.getDevicePushTokenAsync()).data; + if (typeof token !== 'string' || !token) return; + await registerWithRelayApns({ relayBaseUrl, subscriptionId: deviceClientId, deviceToken: token, accountLabel }); + } else if (transport === 'unifiedpush') { + const endpoint = await getUnifiedPushEndpoint(); + if (!endpoint) return; + await registerWithRelayUnifiedPush({ relayBaseUrl, subscriptionId: deviceClientId, endpoint, accountLabel }); + } else { + const token = await getFcmToken(); + if (!token) return; + await registerWithRelay({ relayBaseUrl, subscriptionId: deviceClientId, fcmToken: token, accountLabel }); + } +} + +export interface PushAccountState { + // A subscription is recorded for the account on this device. + enabled: boolean; + // The user turned push off for it here (see disablePushForAccount). + optedOut: boolean; +} + +/** What this device knows about each account's push, for the settings. */ +export async function readPushAccountStates(accountIds: string[]): Promise> { + await migrateLegacyPushKeys(); + const out: Record = {}; + for (const accountId of accountIds) { + out[accountId] = { + enabled: (await AsyncStorage.getItem(subscriptionIdKey(accountId))) !== null, + optedOut: (await AsyncStorage.getItem(optedOutKey(accountId))) !== null, + }; + } + return out; +} + /** * 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