From 00faceb25f07cdb7f3baa2e91e32dee9df62382f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:53:53 +0200 Subject: [PATCH] fix: subscribe to EmailDelivery only, use the relay's email ids and route pushes per account (#45) --- App.tsx | 19 +- .../bulwarkmobile/BulwarkFcmModule.kt | 61 +- src/api/push.ts | 8 +- src/api/types.ts | 4 +- .../__tests__/push-background-task.test.ts | 89 +++ src/lib/__tests__/push-notifications.test.ts | 168 ++++- src/lib/push-background-task.ts | 303 ++++++--- src/lib/push-notifications.ts | 598 +++++++++++++++--- src/stores/account-store.ts | 3 + 9 files changed, 1066 insertions(+), 187 deletions(-) create mode 100644 src/lib/__tests__/push-background-task.test.ts diff --git a/App.tsx b/App.tsx index 134c43ed..cbbae0d2 100644 --- a/App.tsx +++ b/App.tsx @@ -44,6 +44,7 @@ import { useLocaleStore } from './src/stores/locale-store'; import { useNetworkStore } from './src/stores/network-store'; import { useUpdatesStore } from './src/stores/updates-store'; import { UpdateBanner } from './src/components/UpdateBanner'; +import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt'; import { OfflineCacheBanner } from './src/components/OfflineCacheBanner'; import { useOfflineCacheStore } from './src/stores/offline-cache-store'; import { useOutboxStore } from './src/stores/outbox-store'; @@ -101,6 +102,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps + { const unsubscribe = addMessageListener((payload) => { - console.log('[push] fcm message', payload.title); + const raw = payload?.data?.changed; + if (!raw) return; + try { + const changed = JSON.parse(raw) as Record>; + if (!changed || typeof changed !== 'object') return; + void useEmailStore.getState().handleStateChange({ '@type': 'StateChange', changed }); + } catch { + // malformed payload - ignore + } }); return unsubscribe; }, []); diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt index f772b5b9..06b1f72a 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt @@ -66,6 +66,10 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) val threadId = options.getString("threadId") val subject = options.takeIf { it.hasKey("subject") }?.getString("subject") val accountId = options.takeIf { it.hasKey("accountId") }?.getString("accountId") + val groupKey = options.takeIf { it.hasKey("groupKey") }?.getString("groupKey") + ?: accountId?.let { "bulwark-mail:$it" } + val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle") + ?: accountId ?: "Bulwark Mail" // Bitmap fetch + draw off the bridge thread so the caller doesn't // block waiting for the favicon request. @@ -74,8 +78,9 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) ?: makeLetterAvatar(initials, bgColorHex) postNotification( notificationId, title, body, largeIcon, bgColorHex, - emailId, threadId, subject, accountId, + emailId, threadId, subject, accountId, groupKey, ) + if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId) promise.resolve(null) } } @@ -96,6 +101,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) threadId: String?, subject: String?, accountId: String?, + groupKey: String?, ) { val ctx = reactApplicationContext val intent = Intent(ctx, MainActivity::class.java).apply { @@ -122,11 +128,64 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) .setAutoCancel(true) .setPriority(NotificationCompat.PRIORITY_HIGH) .setContentIntent(pending) + if (groupKey != null) { + builder.setGroup(groupKey) + builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) + } val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager manager.notify(notificationId, notificationId.hashCode(), builder.build()) } + // One summary per account group so several deliveries collapse into a + // single "N new messages" entry (Android renders the "+N more" itself + // from the children). Tapping the summary opens the app on the inbox; + // the per-message children carry the deep link. + private fun postGroupSummary(groupKey: String, groupTitle: String, colorHex: String, accountId: String?) { + val ctx = reactApplicationContext + val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val children = manager.activeNotifications.filter { + it.groupKey?.endsWith(groupKey) == true && + (it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0 + } + val count = maxOf(children.size, 1) + val inbox = NotificationCompat.InboxStyle() + children.sortedByDescending { it.postTime }.take(5).forEach { sbn -> + val extras = sbn.notification.extras + val line = listOfNotNull( + extras.getCharSequence(android.app.Notification.EXTRA_TITLE), + extras.getCharSequence(android.app.Notification.EXTRA_TEXT), + ).joinToString(" ") + if (line.isNotBlank()) inbox.addLine(line) + } + inbox.setBigContentTitle(groupTitle) + inbox.setSummaryText(if (count == 1) "1 new message" else "$count new messages") + + val intent = Intent(ctx, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId) + } + val pending = PendingIntent.getActivity( + ctx, + groupKey.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val summary = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_email) + .setContentTitle(groupTitle) + .setContentText(if (count == 1) "1 new message" else "$count new messages") + .setStyle(inbox) + .setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb"))) + .setGroup(groupKey) + .setGroupSummary(true) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) + .setAutoCancel(true) + .setContentIntent(pending) + .build() + manager.notify(groupKey, groupKey.hashCode(), summary) + } + private fun makeLetterAvatar(initials: String, bgHex: String): Bitmap { val size = 192 val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) diff --git a/src/api/push.ts b/src/api/push.ts index 8b76ecfe..41d80e71 100644 --- a/src/api/push.ts +++ b/src/api/push.ts @@ -1,6 +1,6 @@ import { jmapClient } from './jmap-client'; import { CAPABILITIES } from './types'; -import type { PushSubscription, StateChange } from './types'; +import type { EmailPushConfig, PushSubscription, StateChange } from './types'; export type StateChangeHandler = (change: StateChange) => void; @@ -27,6 +27,9 @@ export async function createPushSubscription(params: { // ISO date. Servers may clamp to their own ceiling - we send the maximum we // want and accept whatever Stalwart returns. expires?: string; + // draft-ietf-jmap-emailpush delivery filter, only when the server advertises + // urn:ietf:params:jmap:emailpush (see serverSupportsEmailPush). + emailPush?: Record; }): Promise { const created: Record = { deviceClientId: params.deviceClientId, @@ -34,6 +37,7 @@ export async function createPushSubscription(params: { types: params.types, }; if (params.expires) created.expires = params.expires; + if (params.emailPush) created.emailPush = params.emailPush; const res = await jmapClient.request( [ @@ -63,7 +67,7 @@ export async function createPushSubscription(params: { */ export async function updatePushSubscription( id: string, - patch: { expires?: string; types?: string[] }, + patch: { expires?: string; types?: string[]; emailPush?: Record }, ): Promise { const res = await jmapClient.request( [ diff --git a/src/api/types.ts b/src/api/types.ts index 2a0ff413..23d9fc3d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -573,7 +573,9 @@ export interface FileNode { blobId?: string | null; size?: number; created?: string; - updated?: string; + // Last modification time (UTCDate). Older builds asked for a non-existent + // `updated` property, which the server silently left undefined (#700). + modified?: string; // JMAP Sharing (RFC 9670). Populated only when the server advertises the // principals capability and the properties are requested explicitly. A node // is shared-out when `shareWith` has entries; `myRights` describes what the diff --git a/src/lib/__tests__/push-background-task.test.ts b/src/lib/__tests__/push-background-task.test.ts new file mode 100644 index 00000000..e0c419a9 --- /dev/null +++ b/src/lib/__tests__/push-background-task.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { accountId: 'jmap-primary', loadAccount: vi.fn(async () => true) }, +})); +vi.mock('../../api/email', () => ({ + getEmails: vi.fn(async () => []), + getMailboxes: vi.fn(async () => []), + queryEmails: vi.fn(async () => ({ ids: [] })), +})); + +import { + matchAccountsForPush, + parseRelayPushData, + selectNotifiableEmails, +} from '../push-background-task'; +import type { Email } from '../../api/types'; + +describe('parseRelayPushData', () => { + it('decodes the relay FCM data payload (all values are strings)', () => { + const parsed = parseRelayPushData({ + kind: 'jmap-email-push', + accountLabel: 'alice', + accountId: 'a1', + emailIds: JSON.stringify(['m1', 'm2']), + changed: JSON.stringify({ a1: { EmailDelivery: 's1' } }), + }); + expect(parsed).toEqual({ + kind: 'jmap-email-push', + accountLabel: 'alice', + jmapAccountId: 'a1', + emailIds: ['m1', 'm2'], + changed: { a1: { EmailDelivery: 's1' } }, + }); + }); + + it('falls back to the first key of `changed` for the account id', () => { + const parsed = parseRelayPushData({ + kind: 'jmap-state-change', + emailIds: '[]', + changed: JSON.stringify({ a9: { Email: 'x' } }), + }); + expect(parsed.jmapAccountId).toBe('a9'); + expect(parsed.emailIds).toEqual([]); + }); + + it('tolerates garbage', () => { + expect(parseRelayPushData(null).emailIds).toEqual([]); + expect(parseRelayPushData({ emailIds: '{not json' }).emailIds).toEqual([]); + expect(parseRelayPushData({ kind: 'weird' }).kind).toBeNull(); + }); +}); + +describe('matchAccountsForPush', () => { + const accounts = ['alice@mail.example.com', 'bob@mail.example.com']; + const registry = [ + { id: 'alice@mail.example.com', username: 'alice' }, + { id: 'bob@mail.example.com', username: 'bob' }, + ]; + + it('matches on the recorded JMAP account id first', () => { + const payload = parseRelayPushData({ accountId: 'jb', accountLabel: 'alice' }); + expect(matchAccountsForPush(payload, accounts, { 'bob@mail.example.com': 'jb' }, registry)) + .toEqual(['bob@mail.example.com']); + }); + + it('falls back to the relay accountLabel (username)', () => { + const payload = parseRelayPushData({ accountId: 'unknown', accountLabel: 'alice' }); + expect(matchAccountsForPush(payload, accounts, {}, registry)).toEqual(['alice@mail.example.com']); + }); + + it('checks every account when nothing matches', () => { + const payload = parseRelayPushData({ accountId: 'unknown', accountLabel: 'carol' }); + expect(matchAccountsForPush(payload, accounts, {}, registry)).toEqual(accounts); + }); +}); + +describe('selectNotifiableEmails', () => { + const email = (id: string, keywords: Record = {}): Email => + ({ id, threadId: 't', keywords, mailboxIds: {}, size: 0, receivedAt: '', hasAttachment: false } as Email); + + it('drops read, junk and already-notified messages', () => { + const out = selectNotifiableEmails( + [email('a'), email('b', { $seen: true }), email('c', { $junk: true }), email('d')], + ['d'], + ); + expect(out.map((e) => e.id)).toEqual(['a']); + }); +}); diff --git a/src/lib/__tests__/push-notifications.test.ts b/src/lib/__tests__/push-notifications.test.ts index 138a9da3..d9191abb 100644 --- a/src/lib/__tests__/push-notifications.test.ts +++ b/src/lib/__tests__/push-notifications.test.ts @@ -24,7 +24,20 @@ vi.mock('react-native', () => { }); vi.mock('../../api/jmap-client', () => ({ - jmapClient: { username: 'user@example.com', serverUrl: 'https://mail.example.com' }, + jmapClient: { + username: 'user@example.com', + serverUrl: 'https://mail.example.com', + accountId: 'jmap-primary', + currentSession: { capabilities: { 'urn:ietf:params:jmap:core': {} } }, + }, +})); + +vi.mock('../../api/email', () => ({ + getMailboxes: vi.fn(async () => [ + { id: 'inbox', role: 'inbox', accountId: 'jmap-primary' }, + { id: 'junk', role: 'junk', accountId: 'jmap-primary' }, + ]), + getSharedMailboxes: vi.fn(async () => []), })); vi.mock('../../api/push', () => ({ @@ -36,12 +49,22 @@ vi.mock('../../api/push', () => ({ })); import AsyncStorage from '@react-native-async-storage/async-storage'; -import { setupPushNotifications, deviceClientIdKey } from '../push-notifications'; +import { + setupPushNotifications, + deviceClientIdKey, + isValidRelayUrl, + readPushJmapAccountIds, + PushSetupError, + teardownPushNotificationsForAccount, +} from '../push-notifications'; import { listPushSubscriptions, createPushSubscription, destroyPushSubscription, + updatePushSubscription, } from '../../api/push'; +import { jmapClient } from '../../api/jmap-client'; +import { NativeModules } from 'react-native'; import { generateAccountId } from '../account-utils'; const OUR_DCID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; @@ -76,6 +99,8 @@ function installFetch(states: Record): void { const destroyMock = destroyPushSubscription as ReturnType; const listMock = listPushSubscriptions as ReturnType; const createMock = createPushSubscription as ReturnType; +const updateMock = updatePushSubscription as ReturnType; +const SUB_KEY = 'push:subscriptionId:v2:' + ACCOUNT_ID; function sub(id: string, deviceClientId: string) { return { id, deviceClientId, expires: new Date(Date.now() + 86400000).toISOString(), types: ['Email'] }; @@ -147,3 +172,142 @@ describe('setupPushNotifications leftover reaping', () => { expect(new Set(results.map((r) => r.subscriptionId)).size).toBe(1); }); }); + +describe('setupPushNotifications subscription shape', () => { + beforeEach(async () => { + vi.clearAllMocks(); + await AsyncStorage.clear(); + await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID); + (jmapClient as { currentSession: unknown }).currentSession = { + capabilities: { 'urn:ietf:params:jmap:core': {} }, + }; + listMock.mockResolvedValue([]); + updateMock.mockResolvedValue(true); + installFetch({}); + }); + + it('subscribes to EmailDelivery only', async () => { + await setupPushNotifications({ relayBaseUrl: RELAY }); + expect(createMock).toHaveBeenCalledTimes(1); + expect(createMock.mock.calls[0][0].types).toEqual(['EmailDelivery']); + expect(createMock.mock.calls[0][0].emailPush).toBeUndefined(); + }); + + it('records the JMAP account id so pushes can be routed per account', async () => { + await setupPushNotifications({ relayBaseUrl: RELAY }); + expect(await readPushJmapAccountIds()).toEqual({ [ACCOUNT_ID]: 'jmap-primary' }); + }); + + it('adds a junk-excluding emailPush filter when the server advertises emailpush', async () => { + (jmapClient as { currentSession: unknown }).currentSession = { + capabilities: { 'urn:ietf:params:jmap:emailpush': {} }, + }; + await setupPushNotifications({ relayBaseUrl: RELAY }); + const emailPush = createMock.mock.calls[0][0].emailPush; + expect(emailPush['jmap-primary']).toEqual({ + filter: { + operator: 'AND', + conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: ['junk'] }], + }, + properties: ['id', 'threadId'], + urgency: 'high', + }); + }); + + it('patches types on an existing subscription that still listens to Email/Mailbox', async () => { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + listMock.mockResolvedValue([ + { id: 'existing', deviceClientId: OUR_DCID, expires: new Date(Date.now() + 80 * 86400000).toISOString(), types: ['Email', 'EmailDelivery', 'Mailbox'] }, + ]); + const result = await setupPushNotifications({ relayBaseUrl: RELAY }); + expect(result.subscriptionId).toBe('existing'); + expect(createMock).not.toHaveBeenCalled(); + expect(updateMock).toHaveBeenCalledTimes(1); + expect(updateMock.mock.calls[0][1].types).toEqual(['EmailDelivery']); + }); + + it('leaves a healthy subscription alone', async () => { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + listMock.mockResolvedValue([ + { id: 'existing', deviceClientId: OUR_DCID, expires: new Date(Date.now() + 80 * 86400000).toISOString(), types: ['EmailDelivery'] }, + ]); + await setupPushNotifications({ relayBaseUrl: RELAY }); + expect(updateMock).not.toHaveBeenCalled(); + expect(createMock).not.toHaveBeenCalled(); + }); + + it('forceRecreate destroys the recorded subscription and creates a new one', async () => { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + listMock.mockResolvedValue([ + { id: 'existing', deviceClientId: OUR_DCID, expires: new Date(Date.now() + 80 * 86400000).toISOString(), types: ['EmailDelivery'] }, + ]); + const result = await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate: true }); + expect(destroyMock.mock.calls.map((c) => c[0])).toContain('existing'); + expect(createMock).toHaveBeenCalledTimes(1); + expect(result.subscriptionId).toBe('new-server-id'); + }); + + it('rejects plain-http relay URLs', async () => { + await expect(setupPushNotifications({ relayBaseUrl: 'http://relay.example.com' })).rejects.toMatchObject({ phase: 'relay' }); + expect(createMock).not.toHaveBeenCalled(); + }); + + it('surfaces the relay error body and phase when registration fails', async () => { + global.fetch = vi.fn(async () => ({ + ok: false, + status: 400, + text: async () => JSON.stringify({ error: 'Invalid fcmToken' }), + })) as unknown as typeof fetch; + const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e); + expect(err).toBeInstanceOf(PushSetupError); + expect(err.phase).toBe('relay'); + expect(err.message).toContain('Invalid fcmToken'); + }); + + it('tags a Firebase token failure with the token phase', async () => { + const native = (NativeModules as { BulwarkFcm: { getToken: ReturnType } }).BulwarkFcm; + native.getToken.mockRejectedValueOnce(new Error('SERVICE_NOT_AVAILABLE')); + const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e); + expect(err.phase).toBe('token'); + expect(err.message).toContain('SERVICE_NOT_AVAILABLE'); + }); +}); + +describe('teardownPushNotificationsForAccount', () => { + beforeEach(async () => { + vi.clearAllMocks(); + await AsyncStorage.clear(); + installFetch({}); + }); + + it('destroys every server subscription for this device but keeps the FCM token', async () => { + await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID); + await AsyncStorage.setItem(SUB_KEY, 'recorded'); + await AsyncStorage.setItem('push:relayBaseUrl:v1', RELAY); + listMock.mockResolvedValue([ + sub('recorded', OUR_DCID), + sub('untracked', OUR_DCID), + sub('foreign', 'ffffffffffffffffffffffffffffffff'), + ]); + await teardownPushNotificationsForAccount(ACCOUNT_ID); + const destroyed = destroyMock.mock.calls.map((c) => c[0]); + expect(destroyed).toContain('recorded'); + expect(destroyed).toContain('untracked'); + expect(destroyed).not.toContain('foreign'); + const native = (NativeModules as { BulwarkFcm: { deleteToken: ReturnType } }).BulwarkFcm; + expect(native.deleteToken).not.toHaveBeenCalled(); + expect(await AsyncStorage.getItem(SUB_KEY)).toBeNull(); + }); +}); + +describe('isValidRelayUrl', () => { + it('requires https except for loopback development hosts', () => { + expect(isValidRelayUrl('https://relay.example.com')).toBe(true); + expect(isValidRelayUrl('https://relay.example.com/')).toBe(true); + expect(isValidRelayUrl('http://relay.example.com')).toBe(false); + expect(isValidRelayUrl('http://localhost:3003')).toBe(true); + expect(isValidRelayUrl('http://10.0.2.2:3003')).toBe(true); + expect(isValidRelayUrl('relay.example.com')).toBe(false); + expect(isValidRelayUrl('')).toBe(false); + }); +}); diff --git a/src/lib/push-background-task.ts b/src/lib/push-background-task.ts index 74948af8..e01825fb 100644 --- a/src/lib/push-background-task.ts +++ b/src/lib/push-background-task.ts @@ -2,6 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { NativeModules } from 'react-native'; import { jmapClient } from '../api/jmap-client'; import { getEmails, getMailboxes, queryEmails } from '../api/email'; +import type { Email } from '../api/types'; import { generateEmailAvatarColor, getEmailInitials, @@ -9,10 +10,11 @@ import { getFaviconUrl, } from './avatar-utils'; import { - deviceClientIdKey, lastNotifiedKey, migrateLegacyPushKeys, + notifiedIdsKey, readPushAccountIds, + readPushJmapAccountIds, } from './push-notifications'; // Mirrors STORAGE_KEY in `stores/settings-store.ts`. The headless task can't @@ -22,9 +24,17 @@ 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. +// when the app resumes, and to map the relay's `accountLabel` (= username) +// back to a local account when the JMAP account id is not known yet. const ACCOUNT_REGISTRY_KEY = 'account-registry'; +// How many recently-notified ids to remember per account. Enough to cover a +// burst of deliveries without growing the AsyncStorage record unbounded. +const NOTIFIED_IDS_LIMIT = 200; +// Legacy state-change payloads carry no ids; look at this many newest unread +// inbox messages and notify the ones we have not shown yet. +const LEGACY_QUERY_LIMIT = 5; + interface PushPersistedSettings { emailNotificationsEnabled?: boolean; } @@ -51,54 +61,145 @@ interface ShowNotificationOptions { threadId: string; subject?: string; accountId: string; + // Android notification group: one per account so the tray bundles + // several deliveries under a "+N more" summary instead of stacking them. + groupKey: string; + groupTitle: string; } 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; +/** + * What the relay puts in the FCM data payload (repos/relay/src/fcm.ts). All + * values are strings; `emailIds` and `changed` are JSON-encoded. + */ +export interface RelayPushData { + kind: 'jmap-email-push' | 'jmap-state-change' | null; + accountLabel: string | null; + // JMAP primary account id the push was generated for. + jmapAccountId: string | null; + emailIds: string[]; + changed: Record> | 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 { +export function parseRelayPushData(data: unknown): RelayPushData { + const out: RelayPushData = { + kind: null, + accountLabel: null, + jmapAccountId: null, + emailIds: [], + changed: null, + }; + if (!data || typeof data !== 'object') return out; + const d = data as Record; + if (d.kind === 'jmap-email-push' || d.kind === 'jmap-state-change') out.kind = d.kind; + if (typeof d.accountLabel === 'string' && d.accountLabel) out.accountLabel = d.accountLabel; + if (typeof d.accountId === 'string' && d.accountId) out.jmapAccountId = d.accountId; + if (typeof d.emailIds === 'string') { + try { + const parsed = JSON.parse(d.emailIds) as unknown; + if (Array.isArray(parsed)) { + out.emailIds = parsed.filter((id): id is string => typeof id === 'string' && id.length > 0); + } + } catch { + // malformed - treat as no ids + } + } else if (Array.isArray(d.emailIds)) { + out.emailIds = d.emailIds.filter((id): id is string => typeof id === 'string' && id.length > 0); + } + if (typeof d.changed === 'string') { + try { + const parsed = JSON.parse(d.changed) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + out.changed = parsed as Record>; + } + } catch { + // ignore + } + } else if (d.changed && typeof d.changed === 'object') { + out.changed = d.changed as Record>; + } + // Older relays only sent `changed`; the account id is its first key. + if (!out.jmapAccountId && out.changed) { + out.jmapAccountId = Object.keys(out.changed)[0] ?? null; + } + return out; +} + +interface RegistryAccount { + id: string; + username?: string; +} + +async function readAccountRegistry(): Promise<{ accounts: RegistryAccount[]; activeAccountId: string | null }> { 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; + if (!raw) return { accounts: [], activeAccountId: null }; + const parsed = JSON.parse(raw) as { + state?: { accounts?: RegistryAccount[]; activeAccountId?: string | null }; + }; + return { + accounts: Array.isArray(parsed.state?.accounts) ? parsed.state!.accounts! : [], + activeAccountId: parsed.state?.activeAccountId ?? null, + }; } catch { - return null; + return { accounts: [], activeAccountId: null }; } } +/** + * Pick the local account(s) a relay payload belongs to. Primary key is the + * JMAP account id recorded at setup; the relay's `accountLabel` (= username) + * is a weaker fallback. When neither matches - a payload from a build that + * predates the id map - every registered account is checked. + */ +export function matchAccountsForPush( + payload: RelayPushData, + pushAccountIds: string[], + jmapAccountIds: Record, + registry: RegistryAccount[], +): string[] { + if (payload.jmapAccountId) { + const byJmapId = pushAccountIds.filter((id) => jmapAccountIds[id] === payload.jmapAccountId); + if (byJmapId.length > 0) return byJmapId; + } + if (payload.accountLabel) { + const label = payload.accountLabel.toLowerCase(); + const byLabel = pushAccountIds.filter((id) => { + const entry = registry.find((a) => a.id === id); + const username = entry?.username?.toLowerCase(); + return username === label || id.toLowerCase().startsWith(`${label}@`); + }); + if (byLabel.length > 0) return byLabel; + } + return pushAccountIds; +} + +async function readNotifiedIds(accountId: string): Promise { + try { + const raw = await AsyncStorage.getItem(notifiedIdsKey(accountId)); + if (raw) { + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed)) return parsed.filter((s): s is string => typeof s === 'string'); + } + } catch { + // fall through + } + // Pre-ring builds stored a single id; honour it so the upgrade does not + // re-notify the newest message. + const legacy = await AsyncStorage.getItem(lastNotifiedKey(accountId)); + return legacy ? [legacy] : []; +} + +async function rememberNotifiedIds(accountId: string, ids: string[]): Promise { + const current = await readNotifiedIds(accountId); + const next = [...ids, ...current.filter((id) => !ids.includes(id))].slice(0, NOTIFIED_IDS_LIMIT); + await AsyncStorage.setItem(notifiedIdsKey(accountId), JSON.stringify(next)); + if (ids.length > 0) await AsyncStorage.setItem(lastNotifiedKey(accountId), ids[0]); +} + // 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. @@ -112,14 +213,16 @@ export async function pushBackgroundTask(data: unknown): Promise { const accountIds = await readPushAccountIds(); if (accountIds.length === 0) return; - activeAccountId = await readActiveAccountId(); + const registry = await readAccountRegistry(); + activeAccountId = registry.activeAccountId; - const matched = await identifyAccountFromFcmData(data, accountIds); - const accountsToCheck = matched ? [matched] : accountIds; + const payload = parseRelayPushData(data); + const jmapAccountIds = await readPushJmapAccountIds(); + const accountsToCheck = matchAccountsForPush(payload, accountIds, jmapAccountIds, registry.accounts); for (const accountId of accountsToCheck) { try { - await processAccountForPush(accountId); + await processAccountForPush(accountId, payload); } catch (err) { console.warn( '[push] background check failed for account', @@ -144,55 +247,97 @@ export async function pushBackgroundTask(data: unknown): Promise { } } -async function processAccountForPush(accountId: string): Promise { +/** Messages worth a notification: unread, not junk, not shown before. */ +export function selectNotifiableEmails(emails: Email[], alreadyNotified: readonly string[]): Email[] { + const seen = new Set(alreadyNotified); + return emails.filter((email) => { + if (!email?.id || seen.has(email.id)) return false; + const keywords = email.keywords ?? {}; + if (keywords.$seen || keywords.$junk) return false; + return true; + }); +} + +async function processAccountForPush(accountId: string, payload: RelayPushData): 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 alreadyNotified = await readNotifiedIds(accountId); + let candidates: Email[]; - const { ids } = await queryEmails(inbox.id, { - filter: { notKeyword: '$seen' }, - limit: 1, - }); - if (ids.length === 0) return; + if (payload.emailIds.length > 0) { + // EmailPush (or a relay that forwards ids): fetch exactly the delivered + // messages. The push may concern a shared account the user has access + // to, in which case the ids live under that JMAP account. + const fresh = payload.emailIds.filter((id) => !alreadyNotified.includes(id)); + if (fresh.length === 0) return; + let accountOverride: string | undefined; + try { + if (payload.jmapAccountId && payload.jmapAccountId !== jmapClient.accountId) { + accountOverride = payload.jmapAccountId; + } + } catch { + accountOverride = undefined; + } + candidates = await getEmails(fresh, accountOverride); + } else { + // Legacy `jmap-state-change` payload without ids: look at the newest + // unread inbox messages and notify the ones not shown before. The ring + // of notified ids (rather than a single "last" id) is what keeps a + // message read elsewhere from surfacing the next older one. + 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: LEGACY_QUERY_LIMIT, + }); + const fresh = ids.filter((id) => !alreadyNotified.includes(id)); + if (fresh.length === 0) return; + candidates = await getEmails(fresh); + } - 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 toNotify = selectNotifiableEmails(candidates, alreadyNotified); + if (toNotify.length === 0) return; 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, - }); + const groupKey = `bulwark-mail:${accountId}`; + const groupTitle = payload.accountLabel ?? accountId.split('@')[0] ?? accountId; - await AsyncStorage.setItem(lastKey, emailId); + // Oldest first so the newest ends up on top of the tray. + const ordered = [...toNotify].sort( + (a, b) => new Date(a.receivedAt ?? 0).getTime() - new Date(b.receivedAt ?? 0).getTime(), + ); + for (const email of ordered) { + 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; + + await native.showNotification({ + notificationId: `mail:${email.id}`, + title, + body, + initials, + bgColorHex, + iconUrl, + emailId: email.id, + threadId: email.threadId, + subject: email.subject ?? undefined, + accountId, + groupKey, + groupTitle, + }); + } + + await rememberNotifiedIds(accountId, ordered.map((e) => e.id).reverse()); } function hslToHex(hsl: string): string { diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index 12850b8c..2372cc22 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -7,7 +7,9 @@ import { updatePushSubscription, verifyPushSubscription, } from '../api/push'; +import { getMailboxes, getSharedMailboxes } from '../api/email'; import { jmapClient } from '../api/jmap-client'; +import type { EmailPushConfig, Mailbox } from '../api/types'; import { generateAccountId } from './account-utils'; // Persist identifiers across launches so we reuse the same JMAP subscription @@ -15,9 +17,17 @@ import { generateAccountId } from './account-utils'; // relay can distinguish per-account pushes via the URL slot it forwards. const RELAY_BASE_URL_KEY = 'push:relayBaseUrl:v1'; const PUSH_ACCOUNT_IDS_KEY = 'push:accountIds:v1'; +// Local account id (username@host) → JMAP primary account id. The relay tags +// every forwarded push with the JMAP account id it came from, which is the +// only reliable key for routing a payload to the right local account. +const PUSH_JMAP_ACCOUNT_IDS_KEY = 'push:jmapAccountIds: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:'; +// Ring of recently notified message ids per account (replaces the single +// 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:'; // Legacy single-account keys (pre-multi-account). Migrated lazily on the next // setupPushNotifications / pushBackgroundTask call, then deleted. @@ -38,6 +48,14 @@ export function lastNotifiedKey(accountId: string): string { return LAST_NOTIFIED_EMAIL_ID_PREFIX + accountId; } +export function notifiedIdsKey(accountId: string): string { + return NOTIFIED_IDS_PREFIX + accountId; +} + +function promptDismissedKey(accountId: string): string { + return PROMPT_DISMISSED_PREFIX + accountId; +} + export async function readPushAccountIds(): Promise { const raw = await AsyncStorage.getItem(PUSH_ACCOUNT_IDS_KEY); if (!raw) return []; @@ -59,6 +77,34 @@ async function writePushAccountIds(ids: string[]): Promise { } } +/** Local account id → JMAP primary account id, for every account with push. */ +export async function readPushJmapAccountIds(): Promise> { + const raw = await AsyncStorage.getItem(PUSH_JMAP_ACCOUNT_IDS_KEY); + if (!raw) return {}; + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(parsed as Record)) { + if (typeof v === 'string') out[k] = v; + } + return out; + } catch { + return {}; + } +} + +async function writePushJmapAccountId(accountId: string, jmapAccountId: string | null): Promise { + const map = await readPushJmapAccountIds(); + if (jmapAccountId) map[accountId] = jmapAccountId; + else delete map[accountId]; + if (Object.keys(map).length === 0) { + await AsyncStorage.removeItem(PUSH_JMAP_ACCOUNT_IDS_KEY); + } else { + await AsyncStorage.setItem(PUSH_JMAP_ACCOUNT_IDS_KEY, JSON.stringify(map)); + } +} + // 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 @@ -99,9 +145,39 @@ export async function migrateLegacyPushKeys(): Promise { // Power users can override this from the settings screen. export const DEFAULT_RELAY_BASE_URL = 'https://notifications.relay.bulwarkmail.org'; -// Types the mobile app wants StateChange pings for. Submission is excluded - -// outgoing mail state changes don't belong in a user-visible push. -const PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox'] as const; +/** + * A relay must be reachable over TLS: the registration carries the FCM token + * and the JMAP server posts push bodies to it. Plain http is only allowed for + * loopback / the Android emulator host so a local relay can be developed + * against. + */ +export function isValidRelayUrl(value: string): boolean { + const trimmed = value.trim(); + const m = /^(https?):\/\/([^/?#:]+)(?::\d{1,5})?(?:[/?#].*)?$/i.exec(trimmed); + if (!m) return false; + if (m[1].toLowerCase() === 'https') return true; + const host = m[2].toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '10.0.2.2'; +} + +// Only `EmailDelivery` state-changes when new mail is actually delivered. +// `Email` fires for any mutation (sending, drafting, moving, marking read, +// deleting) and `Mailbox` fires for mailbox edits - both produced spurious +// system notifications, so we keep them out of the push subscription. +// In-app sync uses the separate SSE channel and is unaffected. +export const PUSH_TYPES = ['EmailDelivery'] as const; + +// draft-ietf-jmap-emailpush (Stalwart >= 0.16.16). `EmailDelivery` alone +// still fires for every ingested message - including spam the server files +// straight into Junk - because the server can't know which folders a client +// cares about. With `emailPush` the server evaluates a per-account filter +// against each new message before pushing and stays silent on a miss, so +// junk-filed mail never wakes the device. Older servers don't advertise the +// capability and get the plain EmailDelivery subscription as before. +export const EMAIL_PUSH_CAPABILITY = 'urn:ietf:params:jmap:emailpush'; + +// Only ids: the relay stays content-blind and the headless task dedupes on them. +const EMAIL_PUSH_PROPERTIES = ['id', 'threadId']; // Maximum expires we ask the server for. Stalwart (and other JMAP servers) // may clamp this down; whatever they return is what we get. Without this, @@ -117,6 +193,79 @@ function expiresFromNow(days: number): string { return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(); } +export function sameTypes(a: readonly string[] | null | undefined, b: readonly string[]): boolean { + if (!a || a.length !== b.length) return false; + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + return sortedA.every((t, i) => t === sortedB[i]); +} + +export function serverSupportsEmailPush(): boolean { + try { + return EMAIL_PUSH_CAPABILITY in (jmapClient.currentSession?.capabilities ?? {}); + } catch { + return false; + } +} + +/** + * The delivery filter we want on every account the subscription fans out to: + * skip anything the spam filter tagged `$junk` and anything that lives only in + * a Junk-role mailbox (Sieve `fileinto` doesn't set the keyword). The two are + * ANDed so a stale mailbox id - the user deleted and recreated Junk - degrades + * to keyword-only filtering rather than letting everything through. + */ +export async function buildEmailPushConfig(): Promise> { + const primary = jmapClient.accountId; + const junkByAccount = new Map([[primary, []]]); + const own = await getMailboxes().catch(() => [] as Mailbox[]); + const shared = await getSharedMailboxes().catch(() => [] as Mailbox[]); + for (const m of [...own, ...shared]) { + const accountId = m.accountId || primary; + const junk = junkByAccount.get(accountId) ?? []; + // Shared-account mailboxes carry a client-side ":" id; + // the server only knows the original. + if (m.role === 'junk') junk.push(m.originalId ?? m.id); + junkByAccount.set(accountId, junk); + } + + const config: Record = {}; + for (const [accountId, junkIds] of junkByAccount) { + const conditions: Record[] = [{ notKeyword: '$junk' }]; + if (junkIds.length > 0) conditions.push({ inMailboxOtherThan: [...junkIds].sort() }); + config[accountId] = { + // Always the operator form: that's how the server echoes it back, so a + // stored config compares equal to a freshly built one. + filter: { operator: 'AND', conditions }, + properties: [...EMAIL_PUSH_PROPERTIES], + urgency: 'high', + }; + } + return config; +} + +function normalizeEmailPush(value: unknown): string { + const sortKeys = (v: unknown): unknown => { + if (Array.isArray(v)) return v.map(sortKeys); + if (v && typeof v === 'object') { + return Object.keys(v as Record).sort().reduce>((acc, k) => { + acc[k] = sortKeys((v as Record)[k]); + return acc; + }, {}); + } + return v; + }; + return JSON.stringify(sortKeys(value ?? null)); +} + +export function sameEmailPush( + a: Record | null | undefined, + b: Record, +): boolean { + if (!a) return false; + return normalizeEmailPush(a) === normalizeEmailPush(b); +} + type BulwarkFcmNative = { getToken(): Promise; deleteToken(): Promise; @@ -127,10 +276,21 @@ function getNative(): BulwarkFcmNative | null { return (NativeModules as Record).BulwarkFcm as BulwarkFcmNative | undefined ?? null; } +/** True on platforms that have a push transport wired up (Android/FCM only). */ +export function isPushSupported(): boolean { + return getNative() !== null; +} + export interface PushSetupParams { // Optional - falls back to the hosted relay if omitted. relayBaseUrl?: string; accountLabel?: string; + // Destroy the recorded server-side subscription and create a brand-new one + // instead of refreshing the existing record's expiry. Stalwart binds the set + // of accounts a subscription fans out to at creation time, so a subscription + // that outlives a permission change keeps pushing for mailboxes the user can + // no longer read - recreating is the only client-side remedy (#841). + forceRecreate?: boolean; } export interface PushSetupResult { @@ -138,6 +298,26 @@ export interface PushSetupResult { verified: boolean; } +/** Which step of the enable flow failed - lets the UI say what went wrong. */ +export type PushSetupPhase = + | 'platform' + | 'permission' + | 'token' + | 'account' + | 'relay' + | 'jmap' + | 'verify'; + +export class PushSetupError extends Error { + readonly phase: PushSetupPhase; + + constructor(phase: PushSetupPhase, message: string) { + super(message); + this.name = 'PushSetupError'; + this.phase = phase; + } +} + function randomClientId(): string { const bytes = new Uint8Array(16); if (globalThis.crypto?.getRandomValues) { @@ -174,6 +354,20 @@ export async function setStoredRelayBaseUrl(url: string | null): Promise { } } +/** Whether this account has a JMAP subscription recorded on this device. */ +export async function isPushEnabledForAccount(accountId: string): Promise { + await migrateLegacyPushKeys(); + return (await AsyncStorage.getItem(subscriptionIdKey(accountId))) !== null; +} + +export async function wasPushPromptDismissed(accountId: string): Promise { + return (await AsyncStorage.getItem(promptDismissedKey(accountId))) !== null; +} + +export async function dismissPushPrompt(accountId: string): Promise { + await AsyncStorage.setItem(promptDismissedKey(accountId), String(Date.now())); +} + export async function requestNotificationPermission(): Promise { if (Platform.OS !== 'android') return true; if (Platform.Version < 33) return true; @@ -193,55 +387,114 @@ export async function getFcmToken(): Promise { } } +// Firebase rejects getToken() on devices without Google Play services (or +// with a broken Firebase configuration), and briefly right after a +// deleteToken(). Turn the raw native rejection into a phase-tagged error the +// settings pane can explain. +async function getFcmTokenOrThrow(native: BulwarkFcmNative): Promise { + let token: string | null = null; + try { + token = await native.getToken(); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new PushSetupError( + 'token', + `Firebase could not issue a device token (${detail}). Push needs Google Play services on this device.`, + ); + } + if (!token) { + throw new PushSetupError('token', 'Firebase returned an empty device token.'); + } + return token; +} + function buildRelayUrl(base: string, suffix: string): string { return base.replace(/\/+$/, '') + suffix; } +async function readRelayError(res: Response): Promise { + try { + const text = await res.text(); + if (!text) return `HTTP ${res.status}`; + try { + const body = JSON.parse(text) as { error?: unknown }; + if (typeof body.error === 'string') return `${body.error} (HTTP ${res.status})`; + } catch { + // not JSON + } + return `HTTP ${res.status}: ${text.slice(0, 200)}`; + } catch { + return `HTTP ${res.status}`; + } +} + async function registerWithRelay(params: { relayBaseUrl: string; subscriptionId: string; fcmToken: string; accountLabel?: string; }): Promise { - const res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register'), { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - subscriptionId: params.subscriptionId, - fcmToken: params.fcmToken, - accountLabel: params.accountLabel, - }), - }); + let res: Response; + try { + res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + subscriptionId: params.subscriptionId, + fcmToken: params.fcmToken, + accountLabel: params.accountLabel, + }), + }); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new PushSetupError('relay', `Could not reach the push relay at ${params.relayBaseUrl} (${detail}).`); + } if (!res.ok) { - throw new Error(`Relay register failed: ${res.status}`); + throw new PushSetupError('relay', `The push relay rejected the registration: ${await readRelayError(res)}`); + } +} + +/** The relay's view of a subscription - see relayStatusFor. */ +export type PushRelayStatus = 'active' | 'inactive' | 'unknown'; + +/** + * Ask the relay what it knows about a subscription. `inactive` means the relay + * recognises the record and it is provably dead - it has never forwarded a push + * and isn't freshly registered. `unknown` covers everything we cannot vouch for: + * the relay doesn't recognise the id, an older relay without this endpoint, or a + * network blip. Callers must treat `unknown` as "leave it alone", never as dead. + */ +async function relayStatusFor( + relayBaseUrl: string, + subscriptionId: string, +): Promise { + if (!relayBaseUrl || !subscriptionId) return 'unknown'; + try { + const res = await fetch( + buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`), + ); + if (!res.ok) return 'unknown'; + const body = (await res.json()) as { active?: unknown }; + if (body.active === true) return 'active'; + if (body.active === false) return 'inactive'; + return 'unknown'; + } catch { + return 'unknown'; } } /** - * Ask the relay whether a leftover subscription is dead. Returns true ONLY when - * the relay positively reports it inactive - a record it knows about that has - * never forwarded a push and isn't freshly registered. Every other outcome (the - * relay doesn't recognise the id, an older relay without this endpoint, a - * network blip, or a live subscription) returns false, so we never reap - * anything we can't confirm is dead. This is what lets setup clear its own - * abandoned attempts - and dead siblings left by reinstalls that regenerated - * the deviceClientId - without disturbing another live device or the PWA that - * shares the account. + * Returns true ONLY when the relay positively reports a subscription inactive, + * so we never reap anything we can't confirm is dead. This is what lets setup + * clear its own abandoned attempts - and dead siblings left by reinstalls that + * regenerated the deviceClientId - without disturbing another live device or + * the PWA that shares the account. */ async function relayReportsDead( relayBaseUrl: string, subscriptionId: string, ): Promise { - try { - const res = await fetch( - buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`), - ); - if (!res.ok) return false; - const body = (await res.json()) as { active?: unknown }; - return body.active === false; - } catch { - return false; - } + return (await relayStatusFor(relayBaseUrl, subscriptionId)) === 'inactive'; } async function pollVerificationCode( @@ -254,18 +507,31 @@ async function pollVerificationCode( // verify window even in the unlucky case. const timeoutAt = Date.now() + 75_000; let delay = 400; + let lastRelayError: string | null = null; while (Date.now() < timeoutAt) { - const res = await fetch( - buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`), - ); - if (res.ok) { - const body = (await res.json()) as { verificationCode?: string | null }; - if (body.verificationCode) return body.verificationCode; + try { + const res = await fetch( + buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`), + ); + if (res.ok) { + const body = (await res.json()) as { verificationCode?: string | null }; + if (body.verificationCode) return body.verificationCode; + lastRelayError = null; + } else { + lastRelayError = await readRelayError(res); + } + } catch (err) { + lastRelayError = err instanceof Error ? err.message : String(err); } await new Promise((r) => setTimeout(r, delay)); delay = Math.min(delay * 1.5, 2000); } - throw new Error('Timed out waiting for PushVerification from JMAP server'); + throw new PushSetupError( + 'verify', + lastRelayError + ? `The relay never received the verification code from the mail server (last relay response: ${lastRelayError}).` + : 'The mail server did not send a verification code to the relay within 75 s. Check that the server can reach the relay URL.', + ); } // Coalesce concurrent setup attempts per account. App.tsx re-runs its push @@ -297,27 +563,39 @@ export function setupPushNotifications( return run; } +function logPhase(phase: string, detail?: string): void { + console.log(`[push] ${phase}${detail ? `: ${detail}` : ''}`); +} + async function setupPushNotificationsInner( params: PushSetupParams, ): Promise { const native = getNative(); - if (!native) throw new Error('Push notifications require Android'); + if (!native) { + throw new PushSetupError('platform', 'Push notifications are only available on Android.'); + } const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, ''); - if (!relayBaseUrl) throw new Error('relayBaseUrl is required'); + if (!relayBaseUrl) throw new PushSetupError('relay', 'relayBaseUrl is required'); + if (!isValidRelayUrl(relayBaseUrl)) { + throw new PushSetupError('relay', 'The relay URL must use https://.'); + } + logPhase('permission'); const granted = await requestNotificationPermission(); - if (!granted) throw new Error('Notification permission denied'); + if (!granted) { + throw new PushSetupError('permission', 'Notification permission was not granted.'); + } - const fcmToken = await native.getToken(); - if (!fcmToken) throw new Error('FCM token unavailable'); + logPhase('token'); + const fcmToken = await getFcmTokenOrThrow(native); // 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) { - throw new Error('No account loaded - cannot set up push'); + throw new PushSetupError('account', 'No account loaded - cannot set up push.'); } const accountId = generateAccountId(username, serverUrl); @@ -328,8 +606,9 @@ async function setupPushNotificationsInner( // 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. + // one fcmToken - the relay forwards each push individually and tags it + // with the JMAP account id so the headless task can route it. + logPhase('relay', relayBaseUrl); await registerWithRelay({ relayBaseUrl, subscriptionId: deviceClientId, @@ -339,19 +618,33 @@ 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). + logPhase('jmap'); const existingSubs = await listPushSubscriptions().catch(() => []); + const emailPush = serverSupportsEmailPush() ? await buildEmailPushConfig() : null; const subKey = subscriptionIdKey(accountId); const storedServerId = await AsyncStorage.getItem(subKey); + let jmapAccountId: string | null = null; + try { + jmapAccountId = jmapClient.accountId; + } catch { + jmapAccountId = null; + } 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 }; + if (!params.forceRecreate) { + const refreshed = await refreshSubscriptionExpires(match, emailPush); + if (refreshed) { + 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) - drop the stale id and recreate below. + // deleted server-side) or the caller asked for a fresh record - drop + // the stale id and recreate below. await destroyPushSubscription(storedServerId).catch(() => undefined); } await AsyncStorage.removeItem(subKey); @@ -382,18 +675,33 @@ async function setupPushNotificationsInner( } } - const serverAssignedId = await createPushSubscription({ - deviceClientId, - url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`), - types: [...PUSH_TYPES], - expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), - }); + 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 } : {}), + }); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new PushSetupError('jmap', `The mail server refused the push subscription: ${detail}`); + } + logPhase('verify'); const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId); - await verifyPushSubscription(serverAssignedId, verificationCode); + try { + await verifyPushSubscription(serverAssignedId, verificationCode); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new PushSetupError('verify', `The mail server rejected the verification code: ${detail}`); + } await AsyncStorage.setItem(subKey, serverAssignedId); await addPushAccountId(accountId); + await writePushJmapAccountId(accountId, jmapAccountId); + logPhase('done', 'subscription verified'); return { subscriptionId: serverAssignedId, verified: true }; } @@ -406,12 +714,25 @@ async function addPushAccountId(accountId: string): Promise { } // 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". +// server's ceiling, and re-sync `types` / the delivery filter when they drift +// 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". async function refreshSubscriptionExpires( - sub: { id: string; expires?: string | null }, + sub: { + id: string; + expires?: string | null; + types?: string[] | null; + emailPush?: Record | null; + }, + // null when the server has no emailPush support - leave the property alone. + desiredEmailPush: Record | null, ): Promise { - if (sub.expires) { + const typesNeedUpdate = !sameTypes(sub.types, PUSH_TYPES); + const emailPushNeedsUpdate = + desiredEmailPush !== null && !sameEmailPush(sub.emailPush, desiredEmailPush); + if (!typesNeedUpdate && !emailPushNeedsUpdate && sub.expires) { const remainingMs = new Date(sub.expires).getTime() - Date.now(); const thresholdMs = SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000; if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) { @@ -420,9 +741,14 @@ async function refreshSubscriptionExpires( } } try { - return await updatePushSubscription(sub.id, { - expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), - }); + const patch: { + expires?: string; + types?: string[]; + emailPush?: Record; + } = { expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS) }; + if (typesNeedUpdate) patch.types = [...PUSH_TYPES]; + if (emailPushNeedsUpdate && desiredEmailPush) patch.emailPush = desiredEmailPush; + return await updatePushSubscription(sub.id, patch); } catch { return false; } @@ -438,48 +764,59 @@ async function deregisterFromRelay( ).catch(() => undefined); } +async function clearAccountPushKeys(accountId: string): Promise { + await AsyncStorage.multiRemove([ + subscriptionIdKey(accountId), + deviceClientIdKey(accountId), + lastNotifiedKey(accountId), + notifiedIdsKey(accountId), + ]); + await writePushJmapAccountId(accountId, null); +} + /** - * 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. + * 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. * - * 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. + * The FCM token is deliberately left alive: the relay mapping is gone so + * nothing gets forwarded, and deleting the token makes the next getToken() + * fail for a while (native #45 "Disable then Enable" race). Only the + * logout-all path (`teardownPushNotifications`) kills the token. */ 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 storedSubId = await AsyncStorage.getItem(subscriptionIdKey(accountId)); + const storedDcid = await AsyncStorage.getItem(deviceClientIdKey(accountId)); const relayBaseUrl = await getStoredRelayBaseUrl(); - if (storedSubId) { - await destroyPushSubscription(storedSubId).catch(() => undefined); + // Destroy every subscription the server holds for this device, not just the + // id we happen to have recorded - a destroy that lost its round-trip or a + // failed enable can leave a registration this client no longer tracks (#841). + const idsToDestroy = new Set(); + if (storedSubId) idsToDestroy.add(storedSubId); + if (storedDcid) { + const existing = await listPushSubscriptions().catch(() => []); + for (const s of existing) { + if (s.deviceClientId === storedDcid) idsToDestroy.add(s.id); + } + } + for (const id of idsToDestroy) { + await destroyPushSubscription(id).catch(() => undefined); } if (relayBaseUrl && storedDcid) { await deregisterFromRelay(relayBaseUrl, storedDcid); } - await AsyncStorage.multiRemove([subKey, dcidKey, lastNotifiedKey(accountId)]); + await clearAccountPushKeys(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); - } - } } /** @@ -495,10 +832,8 @@ export async function teardownPushNotifications(): Promise { 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); + const storedSubId = await AsyncStorage.getItem(subscriptionIdKey(accountId)); + const storedDcid = await AsyncStorage.getItem(deviceClientIdKey(accountId)); if (storedSubId) { // Will only succeed if the jmapClient happens to be authenticated to @@ -509,10 +844,10 @@ export async function teardownPushNotifications(): Promise { if (relayBaseUrl && storedDcid) { await deregisterFromRelay(relayBaseUrl, storedDcid); } - await AsyncStorage.multiRemove([subKey, dcidKey, lastNotifiedKey(accountId)]); + await clearAccountPushKeys(accountId); } - await AsyncStorage.removeItem(PUSH_ACCOUNT_IDS_KEY); + await AsyncStorage.multiRemove([PUSH_ACCOUNT_IDS_KEY, PUSH_JMAP_ACCOUNT_IDS_KEY]); const native = getNative(); if (native) { @@ -520,9 +855,76 @@ export async function teardownPushNotifications(): Promise { } } +export interface PushDevice { + // The JMAP PushSubscription id - what you destroy to revoke it. + id: string; + // Client-chosen id the relay keys its endpoint mapping on. + deviceClientId: string; + expires: string | null; + types: string[] | null; + // True when this registration belongs to the device you're looking at. + isThisDevice: boolean; + relayStatus: PushRelayStatus; +} + +/** + * Every push registration the JMAP server holds for this account, annotated + * with whether it is this device and what the relay makes of it. + * + * Stalwart hides a subscription's url and verified state from clients, so + * deviceClientId is the only handle we get. That's enough to spot our own + * registration and to ask the relay about the rest - but registrations made + * against a different relay, or by a non-Bulwark client, come back `unknown` + * rather than dead, and the UI must present them as revocable-but-unclassified. + */ +export async function listPushDevices(params: { + accountId: string; + relayBaseUrl?: string; +}): Promise { + const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, ''); + const thisDeviceClientId = await AsyncStorage.getItem(deviceClientIdKey(params.accountId)); + + const subs = await listPushSubscriptions(); + return Promise.all( + subs.map(async (s) => ({ + id: s.id, + deviceClientId: s.deviceClientId, + expires: s.expires ?? null, + types: s.types ?? null, + isThisDevice: thisDeviceClientId !== null && s.deviceClientId === thisDeviceClientId, + relayStatus: await relayStatusFor(relayBaseUrl, s.deviceClientId), + })), + ); +} + +/** + * Revoke one registration. Destroying the JMAP subscription stops the server + * fanning StateChanges to it; dropping the relay mapping stops the relay + * forwarding anything already in flight and frees the deviceClientId. Revoking + * this device runs the full local teardown so the UI doesn't keep claiming push + * is on. + */ +export async function revokePushDevice(params: { + accountId: string; + device: Pick; + relayBaseUrl?: string; +}): Promise { + const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, ''); + + if (params.device.isThisDevice) { + await teardownPushNotificationsForAccount(params.accountId); + return; + } + + await destroyPushSubscription(params.device.id); + if (relayBaseUrl && params.device.deviceClientId) { + await deregisterFromRelay(relayBaseUrl, params.device.deviceClientId); + } +} + export type FcmMessageListener = (payload: { - title: string; - body: string; + title?: string; + body?: string; data: Record; }) => void; diff --git a/src/stores/account-store.ts b/src/stores/account-store.ts index 2bde3865..b89e5e5e 100644 --- a/src/stores/account-store.ts +++ b/src/stores/account-store.ts @@ -16,6 +16,9 @@ export interface AccountEntry { hasError: boolean; errorMessage?: string; isDefault: boolean; + // JMAP primary account id (from the session's primaryAccounts). Recorded + // when push is set up so a relay payload can be routed to this entry. + jmapAccountId?: string; } interface AccountState {