From 6846d859fdbf99168d06548ef4ef8e83bbdd3686 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 14 May 2026 13:50:52 +0200 Subject: [PATCH] feat: implement multi-account support for push notifications and enhance teardown logic --- App.tsx | 16 +- .../settings/NotificationSettings.tsx | 22 +- src/lib/push-background-task.ts | 180 +++++++++---- src/lib/push-notifications.ts | 244 +++++++++++++++--- src/stores/__tests__/auth-store.test.ts | 1 + src/stores/auth-store.ts | 16 +- 6 files changed, 373 insertions(+), 106 deletions(-) diff --git a/App.tsx b/App.tsx index fd1b5463..a45808e0 100644 --- a/App.tsx +++ b/App.tsx @@ -15,7 +15,7 @@ import { getInitialNotificationTap, getStoredRelayBaseUrl, setupPushNotifications, - teardownPushNotifications, + teardownPushNotificationsForAccount, type NotificationTapPayload, } from './src/lib/push-notifications'; import type { MainTabsParamList, RootStackParamList } from './src/navigation/types'; @@ -289,18 +289,24 @@ export default function App() { // Re-register the device with the configured relay once authenticated, // and whenever the FCM token rotates. Honours the user's notification - // preference - flipping it off tears down the active subscription so the - // FirebaseMessagingService stops receiving pings for this device. + // preference - flipping it off tears down THIS account's subscription so + // notifications stop arriving for it. Other logged-in accounts keep their + // setups intact. const emailNotificationsEnabled = useSettingsStore( (s) => s.emailNotificationsEnabled, ); + const activeAccountId = useAuthStore((s) => s.activeAccountId); React.useEffect(() => { if (!isAuthenticated || !client) return; let cancelled = false; const doSetup = async () => { if (!emailNotificationsEnabled) { - await teardownPushNotifications().catch(() => undefined); + if (activeAccountId) { + await teardownPushNotificationsForAccount(activeAccountId).catch( + () => undefined, + ); + } return; } const relayBaseUrl = await getStoredRelayBaseUrl(); @@ -328,7 +334,7 @@ export default function App() { cancelled = true; unsubscribe(); }; - }, [client, isAuthenticated, emailNotificationsEnabled]); + }, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]); React.useEffect(() => { if (!isAuthenticated || !client) { diff --git a/src/components/settings/NotificationSettings.tsx b/src/components/settings/NotificationSettings.tsx index b8c39a48..92631df1 100644 --- a/src/components/settings/NotificationSettings.tsx +++ b/src/components/settings/NotificationSettings.tsx @@ -17,9 +17,10 @@ import { useSettingsStore, type NotificationSound } from '../../stores/settings- import { DEFAULT_RELAY_BASE_URL, getStoredRelayBaseUrl, + readPushAccountIds, setStoredRelayBaseUrl, setupPushNotifications, - teardownPushNotifications, + teardownPushNotificationsForAccount, } from '../../lib/push-notifications'; const SOUNDS: { value: NotificationSound; label: string }[] = [ @@ -40,6 +41,7 @@ export function NotificationSettings() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const username = useAuthStore((s) => s.username); + const activeAccountId = useAuthStore((s) => s.activeAccountId); const hydrated = useSettingsStore((s) => s.hydrated); const hydrate = useSettingsStore((s) => s.hydrate); @@ -106,10 +108,20 @@ export function NotificationSettings() { setConfirmDisable(false); setPushStatus({ kind: 'busy', message: 'Disabling…' }); try { - await teardownPushNotifications(); - await setStoredRelayBaseUrl(null); - setRelayUrl(DEFAULT_RELAY_BASE_URL); - setHasSaved(false); + if (activeAccountId) { + await teardownPushNotificationsForAccount(activeAccountId); + } + // The relay base URL is a device-wide setting shared with any other + // accounts that are still using push. Only clear it (and reset the UI + // to "not configured") when no accounts are using push any more — + // otherwise the remaining accounts would lose their relay URL and + // their next setup attempt would no-op. + const remaining = await readPushAccountIds(); + if (remaining.length === 0) { + await setStoredRelayBaseUrl(null); + setRelayUrl(DEFAULT_RELAY_BASE_URL); + setHasSaved(false); + } setPushStatus({ kind: 'idle' }); } catch (error) { setPushStatus({ diff --git a/src/lib/push-background-task.ts b/src/lib/push-background-task.ts index 4b8655b1..74948af8 100644 --- a/src/lib/push-background-task.ts +++ b/src/lib/push-background-task.ts @@ -8,13 +8,22 @@ import { getFaviconDomain, getFaviconUrl, } from './avatar-utils'; +import { + deviceClientIdKey, + lastNotifiedKey, + migrateLegacyPushKeys, + readPushAccountIds, +} from './push-notifications'; -const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1'; -const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1'; // Mirrors STORAGE_KEY in `stores/settings-store.ts`. The headless task can't // import the Zustand store (would pull in React) so we read AsyncStorage // directly. Keep this in sync if the store key ever changes. const SETTINGS_STORAGE_KEY = 'webmail:settings:v1'; +// Mirrors the persist name in `stores/account-store.ts`. We use it to find +// the user's active account at the end of the headless task so we can leave +// the jmapClient singleton in a state consistent with what the UI expects +// when the app resumes. +const ACCOUNT_REGISTRY_KEY = 'account-registry'; interface PushPersistedSettings { emailNotificationsEnabled?: boolean; @@ -48,71 +57,144 @@ interface BulwarkFcmNative { showNotification(opts: ShowNotificationOptions): Promise; } +// The relay forwards each JMAP push to FCM tagged with the deviceClientId it +// received the push on (the URL slot from createPushSubscription). The exact +// key the relay uses isn't part of any client-controlled contract, so rather +// than guessing a field name we scan all string values in the data payload +// and look for one that matches a deviceClientId we've registered locally. +// Returns null if nothing matches (e.g. legacy relay payload format) so the +// caller falls back to iterating every known account. +async function identifyAccountFromFcmData( + data: unknown, + accountIds: string[], +): Promise { + if (!data || typeof data !== 'object') return null; + + const reverseMap = new Map(); + for (const accountId of accountIds) { + const dcid = await AsyncStorage.getItem(deviceClientIdKey(accountId)); + if (dcid) reverseMap.set(dcid, accountId); + } + if (reverseMap.size === 0) return null; + + for (const value of Object.values(data as Record)) { + if (typeof value === 'string') { + const match = reverseMap.get(value); + if (match) return match; + } + } + return null; +} + +// Read the persisted active account directly from AsyncStorage (Zustand's +// persist middleware stores the JSON-serialised state under its `name`). +async function readActiveAccountId(): Promise { + try { + const raw = await AsyncStorage.getItem(ACCOUNT_REGISTRY_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as { state?: { activeAccountId?: string | null } }; + return parsed.state?.activeAccountId ?? null; + } catch { + return null; + } +} + // Fired by BulwarkPushTaskService when a data FCM message arrives. Runs in a // short-lived headless JS runtime - keep it fast, catch all errors, and always // resolve so the native service can release its wake lock. -export async function pushBackgroundTask(_data: unknown): Promise { +export async function pushBackgroundTask(data: unknown): Promise { + let activeAccountId: string | null = null; try { if (!(await emailNotificationsAllowed())) return; - const accountId = await AsyncStorage.getItem(PUSH_ACCOUNT_ID_KEY); - if (!accountId) return; + await migrateLegacyPushKeys(); - const loaded = await jmapClient.loadAccount(accountId); - if (!loaded) return; + const accountIds = await readPushAccountIds(); + if (accountIds.length === 0) return; - const mailboxes = await getMailboxes(); - const inbox = mailboxes.find((m) => m.role === 'inbox'); - if (!inbox) return; + activeAccountId = await readActiveAccountId(); - const { ids } = await queryEmails(inbox.id, { - filter: { notKeyword: '$seen' }, - limit: 1, - }); - if (ids.length === 0) return; + const matched = await identifyAccountFromFcmData(data, accountIds); + const accountsToCheck = matched ? [matched] : accountIds; - const emailId = ids[0]; - const previouslyNotified = await AsyncStorage.getItem(LAST_NOTIFIED_EMAIL_ID_KEY); - if (previouslyNotified === emailId) return; - - const [email] = await getEmails([emailId]); - if (!email) return; - - const from = email.from?.[0]; - const name = from?.name ?? ''; - const address = from?.email ?? ''; - const title = name || address || 'New mail'; - const body = email.subject || '(no subject)'; - const initials = getEmailInitials(name, address); - const bgColorHex = hslToHex(generateEmailAvatarColor(name, address)); - const faviconDomain = getFaviconDomain(address); - const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined; - - const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined; - if (!native?.showNotification) return; - - await native.showNotification({ - notificationId: `mail:${emailId}`, - title, - body, - initials, - bgColorHex, - iconUrl, - emailId, - threadId: email.threadId, - subject: email.subject ?? undefined, - accountId, - }); - - await AsyncStorage.setItem(LAST_NOTIFIED_EMAIL_ID_KEY, emailId); + for (const accountId of accountsToCheck) { + try { + await processAccountForPush(accountId); + } catch (err) { + console.warn( + '[push] background check failed for account', + accountId, + err instanceof Error ? err.message : err, + ); + } + } } catch (error) { console.warn( '[push] background task failed:', error instanceof Error ? error.message : error, ); + } finally { + // Leave the singleton bound to the user's active account so the UI sees + // a consistent jmapClient state when the app resumes. Without this, the + // last account processed above would remain loaded and the UI's cached + // email-store data would not match what the next JMAP request returns. + if (activeAccountId) { + await jmapClient.loadAccount(activeAccountId).catch(() => undefined); + } } } +async function processAccountForPush(accountId: string): Promise { + const loaded = await jmapClient.loadAccount(accountId); + if (!loaded) return; + + const mailboxes = await getMailboxes(); + const inbox = mailboxes.find((m) => m.role === 'inbox'); + if (!inbox) return; + + const { ids } = await queryEmails(inbox.id, { + filter: { notKeyword: '$seen' }, + limit: 1, + }); + if (ids.length === 0) return; + + const emailId = ids[0]; + const lastKey = lastNotifiedKey(accountId); + const previouslyNotified = await AsyncStorage.getItem(lastKey); + if (previouslyNotified === emailId) return; + + const [email] = await getEmails([emailId]); + if (!email) return; + + const from = email.from?.[0]; + const name = from?.name ?? ''; + const address = from?.email ?? ''; + const title = name || address || 'New mail'; + const body = email.subject || '(no subject)'; + const initials = getEmailInitials(name, address); + const bgColorHex = hslToHex(generateEmailAvatarColor(name, address)); + const faviconDomain = getFaviconDomain(address); + const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined; + + const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined; + if (!native?.showNotification) return; + + await native.showNotification({ + notificationId: `mail:${emailId}`, + title, + body, + initials, + bgColorHex, + iconUrl, + emailId, + threadId: email.threadId, + subject: email.subject ?? undefined, + accountId, + }); + + await AsyncStorage.setItem(lastKey, emailId); +} + function hslToHex(hsl: string): string { const match = hsl.match(/hsl\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)/); if (!match) return '#2563eb'; diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index f73a03fb..8c3f67b5 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -11,12 +11,88 @@ import { jmapClient } from '../api/jmap-client'; import { generateAccountId } from './account-utils'; // Persist identifiers across launches so we reuse the same JMAP subscription -// after app restarts. deviceClientId also routes to the relay slot. -const DEVICE_CLIENT_ID_KEY = 'push:deviceClientId:v1'; -const SUBSCRIPTION_ID_KEY = 'push:subscriptionId:v1'; +// after app restarts. Each account gets its own deviceClientId so the hosted +// relay can distinguish per-account pushes via the URL slot it forwards. const RELAY_BASE_URL_KEY = 'push:relayBaseUrl:v1'; -const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1'; -const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1'; +const PUSH_ACCOUNT_IDS_KEY = 'push:accountIds:v1'; +const DEVICE_CLIENT_ID_PREFIX = 'push:deviceClientId:v2:'; +const SUBSCRIPTION_ID_PREFIX = 'push:subscriptionId:v2:'; +export const LAST_NOTIFIED_EMAIL_ID_PREFIX = 'push:lastNotifiedEmailId:v2:'; + +// Legacy single-account keys (pre-multi-account). Migrated lazily on the next +// setupPushNotifications / pushBackgroundTask call, then deleted. +const LEGACY_DEVICE_CLIENT_ID_KEY = 'push:deviceClientId:v1'; +const LEGACY_SUBSCRIPTION_ID_KEY = 'push:subscriptionId:v1'; +const LEGACY_PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1'; +const LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1'; + +export function deviceClientIdKey(accountId: string): string { + return DEVICE_CLIENT_ID_PREFIX + accountId; +} + +function subscriptionIdKey(accountId: string): string { + return SUBSCRIPTION_ID_PREFIX + accountId; +} + +export function lastNotifiedKey(accountId: string): string { + return LAST_NOTIFIED_EMAIL_ID_PREFIX + accountId; +} + +export async function readPushAccountIds(): Promise { + const raw = await AsyncStorage.getItem(PUSH_ACCOUNT_IDS_KEY); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter((s): s is string => typeof s === 'string'); + } catch { + return []; + } +} + +async function writePushAccountIds(ids: string[]): Promise { + const deduped = Array.from(new Set(ids)); + if (deduped.length === 0) { + await AsyncStorage.removeItem(PUSH_ACCOUNT_IDS_KEY); + } else { + await AsyncStorage.setItem(PUSH_ACCOUNT_IDS_KEY, JSON.stringify(deduped)); + } +} + +// One-shot migration from the pre-multi-account schema. If the legacy +// PUSH_ACCOUNT_ID_KEY exists, treat that account as the only pre-existing +// setup: reuse the legacy deviceClientId and JMAP subscription id under +// the new per-account keys so the user doesn't lose push on upgrade. +export async function migrateLegacyPushKeys(): Promise { + const legacyAccountId = await AsyncStorage.getItem(LEGACY_PUSH_ACCOUNT_ID_KEY); + if (!legacyAccountId) return; + + const legacyDcid = await AsyncStorage.getItem(LEGACY_DEVICE_CLIENT_ID_KEY); + const legacySubId = await AsyncStorage.getItem(LEGACY_SUBSCRIPTION_ID_KEY); + const legacyLastId = await AsyncStorage.getItem(LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY); + + if (legacyDcid) { + await AsyncStorage.setItem(deviceClientIdKey(legacyAccountId), legacyDcid); + } + if (legacySubId) { + await AsyncStorage.setItem(subscriptionIdKey(legacyAccountId), legacySubId); + } + if (legacyLastId) { + await AsyncStorage.setItem(lastNotifiedKey(legacyAccountId), legacyLastId); + } + + const ids = await readPushAccountIds(); + if (!ids.includes(legacyAccountId)) { + await writePushAccountIds([...ids, legacyAccountId]); + } + + await AsyncStorage.multiRemove([ + LEGACY_PUSH_ACCOUNT_ID_KEY, + LEGACY_DEVICE_CLIENT_ID_KEY, + LEGACY_SUBSCRIPTION_ID_KEY, + LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY, + ]); +} // Hosted relay so users don't need to run their own Firebase project. The // relay only ever sees FCM tokens + JMAP state-id hashes - no mail content. @@ -72,11 +148,12 @@ function randomClientId(): string { return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); } -async function getOrCreateDeviceClientId(): Promise { - const existing = await AsyncStorage.getItem(DEVICE_CLIENT_ID_KEY); +async function getOrCreateDeviceClientId(accountId: string): Promise { + const key = deviceClientIdKey(accountId); + const existing = await AsyncStorage.getItem(key); if (existing) return existing; const next = randomClientId(); - await AsyncStorage.setItem(DEVICE_CLIENT_ID_KEY, next); + await AsyncStorage.setItem(key, next); return next; } @@ -184,20 +261,24 @@ export async function setupPushNotifications( const fcmToken = await native.getToken(); if (!fcmToken) throw new Error('FCM token unavailable'); - const deviceClientId = await getOrCreateDeviceClientId(); - await setStoredRelayBaseUrl(relayBaseUrl); - - // Persist the account id so the background push task can reload credentials - // from SecureStore without the main app being running. + // setupPushNotifications operates on the currently-loaded jmapClient. We + // need its username/serverUrl up-front so we can key per-account state. const username = jmapClient.username; const serverUrl = jmapClient.serverUrl; - if (username && serverUrl) { - await AsyncStorage.setItem( - PUSH_ACCOUNT_ID_KEY, - generateAccountId(username, serverUrl), - ); + if (!username || !serverUrl) { + throw new Error('No account loaded - cannot set up push'); } + const accountId = generateAccountId(username, serverUrl); + await migrateLegacyPushKeys(); + + const deviceClientId = await getOrCreateDeviceClientId(accountId); + await setStoredRelayBaseUrl(relayBaseUrl); + + // Register this account's device-client-id with the relay. Multiple + // accounts on the same device end up as separate registrations sharing + // one fcmToken - the relay forwards each push individually so the headless + // task can identify the source account via the FCM data payload. await registerWithRelay({ relayBaseUrl, subscriptionId: deviceClientId, @@ -208,26 +289,31 @@ export async function setupPushNotifications( // 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. const existingSubs = await listPushSubscriptions().catch(() => []); - const storedServerId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY); + const subKey = subscriptionIdKey(accountId); + const storedServerId = await AsyncStorage.getItem(subKey); if (storedServerId) { const match = existingSubs.find((s) => s.id === storedServerId); if (match) { const refreshed = await refreshSubscriptionExpires(match); if (refreshed) { + await addPushAccountId(accountId); return { subscriptionId: storedServerId, verified: true }; } // Server rejected the refresh (likely the subscription was already // deleted server-side) - drop the stale id and recreate below. await destroyPushSubscription(storedServerId).catch(() => undefined); } - await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY); + await AsyncStorage.removeItem(subKey); } - // Reap any leftover Stalwart subscriptions still bound to this device. - // These pile up when a previous enable attempt failed mid-flow (verify - // race, network blip, app killed). Stalwart per-account rate-limits - // PushVerification posts, so leaving stragglers around blocks the new - // one's verification - the symptom is a confusing "code does not match". + // Reap any leftover Stalwart subscriptions still bound to this account's + // deviceClientId. These pile up when a previous enable attempt failed + // mid-flow (verify race, network blip, app killed). Stalwart per-account + // rate-limits PushVerification posts, so leaving stragglers around blocks + // the new one's verification - the symptom is a confusing "code does not + // match". Only reap matches for THIS account's deviceClientId; other + // accounts' subscriptions on this server (rare but possible if the same + // JMAP server backs multiple accounts) must be left alone. const stragglers = existingSubs.filter( (s) => s.deviceClientId === deviceClientId && s.id !== storedServerId, ); @@ -245,11 +331,19 @@ export async function setupPushNotifications( const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId); await verifyPushSubscription(serverAssignedId, verificationCode); - await AsyncStorage.setItem(SUBSCRIPTION_ID_KEY, serverAssignedId); + await AsyncStorage.setItem(subKey, serverAssignedId); + await addPushAccountId(accountId); return { subscriptionId: serverAssignedId, verified: true }; } +async function addPushAccountId(accountId: string): Promise { + const ids = await readPushAccountIds(); + if (!ids.includes(accountId)) { + await writePushAccountIds([...ids, accountId]); + } +} + // Push the subscription's expires forward when it's getting close to the // server's ceiling. Returns false if the server rejects the update, which the // caller treats as "recreate". @@ -273,28 +367,92 @@ async function refreshSubscriptionExpires( } } +async function deregisterFromRelay( + relayBaseUrl: string, + deviceClientId: string, +): Promise { + await fetch( + buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`), + { method: 'DELETE' }, + ).catch(() => undefined); +} + /** - * Tear down push on logout / disable. Removes the server-side JMAP - * subscription, tells the relay to drop the mapping, and clears the local - * FCM token. Never throws - callers treat teardown as best effort. + * Tear down push for a single account. Destroys that account's JMAP + * subscription (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. + * + * If no accounts have push left after removal, also deletes the FCM token + * so the device stops receiving FCM messages entirely. Never throws - + * callers treat teardown as best effort. + */ +export async function teardownPushNotificationsForAccount( + accountId: string, +): Promise { + await migrateLegacyPushKeys(); + + const subKey = subscriptionIdKey(accountId); + const storedSubId = await AsyncStorage.getItem(subKey); + const dcidKey = deviceClientIdKey(accountId); + const storedDcid = await AsyncStorage.getItem(dcidKey); + const relayBaseUrl = await getStoredRelayBaseUrl(); + + if (storedSubId) { + await destroyPushSubscription(storedSubId).catch(() => undefined); + } + if (relayBaseUrl && storedDcid) { + await deregisterFromRelay(relayBaseUrl, storedDcid); + } + + await AsyncStorage.multiRemove([subKey, dcidKey, lastNotifiedKey(accountId)]); + + const remaining = (await readPushAccountIds()).filter((id) => id !== accountId); + await writePushAccountIds(remaining); + + // If this was the last account with push, kill the FCM token so the device + // truly goes silent. Otherwise the token stays alive so the remaining + // accounts keep receiving pushes. + if (remaining.length === 0) { + const native = getNative(); + if (native) { + await native.deleteToken().catch(() => undefined); + } + } +} + +/** + * 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 + * account's JMAP server at the moment we need to call destroy on it. The + * FCM token is always deleted so no push gets through regardless. */ export async function teardownPushNotifications(): Promise { - const storedId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY); - const relayBaseUrl = await getStoredRelayBaseUrl(); - const deviceClientId = await AsyncStorage.getItem(DEVICE_CLIENT_ID_KEY); + await migrateLegacyPushKeys(); - if (storedId) { - await destroyPushSubscription(storedId).catch(() => undefined); - await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY); + const accountIds = await readPushAccountIds(); + const relayBaseUrl = await getStoredRelayBaseUrl(); + + for (const accountId of accountIds) { + const subKey = subscriptionIdKey(accountId); + const dcidKey = deviceClientIdKey(accountId); + const storedSubId = await AsyncStorage.getItem(subKey); + const storedDcid = await AsyncStorage.getItem(dcidKey); + + 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). + await destroyPushSubscription(storedSubId).catch(() => undefined); + } + if (relayBaseUrl && storedDcid) { + await deregisterFromRelay(relayBaseUrl, storedDcid); + } + await AsyncStorage.multiRemove([subKey, dcidKey, lastNotifiedKey(accountId)]); } - if (relayBaseUrl && deviceClientId) { - await fetch( - buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`), - { method: 'DELETE' }, - ).catch(() => undefined); - } - await AsyncStorage.removeItem(PUSH_ACCOUNT_ID_KEY); - await AsyncStorage.removeItem(LAST_NOTIFIED_EMAIL_ID_KEY); + + await AsyncStorage.removeItem(PUSH_ACCOUNT_IDS_KEY); + const native = getNative(); if (native) { await native.deleteToken().catch(() => undefined); diff --git a/src/stores/__tests__/auth-store.test.ts b/src/stores/__tests__/auth-store.test.ts index 5c8006f3..918b200b 100644 --- a/src/stores/__tests__/auth-store.test.ts +++ b/src/stores/__tests__/auth-store.test.ts @@ -25,6 +25,7 @@ vi.mock('../../api/jmap-client', () => ({ vi.mock('../../lib/push-notifications', () => ({ teardownPushNotifications: vi.fn(async () => undefined), + teardownPushNotificationsForAccount: vi.fn(async () => undefined), })); import { jmapClient } from '../../api/jmap-client'; diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 3becd6e0..b979728f 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -6,7 +6,10 @@ import { useEmailStore } from './email-store'; import { useContactsStore } from './contacts-store'; import { useCalendarStore } from './calendar-store'; import { generateAccountId } from '../lib/account-utils'; -import { teardownPushNotifications } from '../lib/push-notifications'; +import { + teardownPushNotifications, + teardownPushNotificationsForAccount, +} from '../lib/push-notifications'; // Persist middleware hydrates asynchronously on cold start. Without this // guard, restoreSession() can read the account-store before AsyncStorage has @@ -143,9 +146,14 @@ export const useAuthStore = create((set, get) => ({ const accountStore = useAccountStore.getState(); const currentId = get().activeAccountId; - // Best-effort: revoke the JMAP PushSubscription and drop the relay - // mapping before we lose credentials. Do not abort logout on failure. - await teardownPushNotifications().catch(() => undefined); + // Best-effort: revoke this account's JMAP PushSubscription and drop its + // relay mapping before we lose credentials. Other logged-in accounts' + // push setups remain untouched. Do not abort logout on failure. + if (currentId) { + await teardownPushNotificationsForAccount(currentId).catch(() => undefined); + } else { + await teardownPushNotifications().catch(() => undefined); + } // Clear credentials for this account first if (currentId) {