import AsyncStorage from '@react-native-async-storage/async-storage'; import { NativeModules } from 'react-native'; import { jmapClient, type StoredCredentials } from '../api/jmap-client'; import { CAPABILITIES } from '../api/types'; import type { Email, JMAPMethodCall, JMAPSession, Mailbox } from '../api/types'; import { detectDeviceLocale, isSupportedLocale, translate, type LocaleCode } from '../i18n'; import { secureFetch } from './client-cert'; import { refreshOAuthAccessToken, type OAuthTokens } from './oauth'; import { generateEmailAvatarColor, getEmailInitials, getFaviconDomain, getFaviconUrl, } from './avatar-utils'; import { singleLine } from './single-line'; import { lastNotifiedKey, migrateLegacyPushKeys, notifiedIdsKey, readPushAccountIds, readPushJmapAccountIds, } from './push-notifications'; // 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 STORAGE_KEY in `stores/locale-store.ts`, for the same reason. const LOCALE_STORAGE_KEY = 'webmail:locale:v1'; // Mirrors the persist name in `stores/account-store.ts`. Used 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; const TOKEN_REFRESH_LEEWAY_MS = 60_000; const EMAIL_PROPERTIES = [ 'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt', 'from', 'subject', 'preview', ]; interface PushPersistedSettings { emailNotificationsEnabled?: boolean; senderFavicons?: boolean; } async function emailNotificationsAllowed(): Promise { try { const raw = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY); if (!raw) return true; // first launch: default to on const parsed = JSON.parse(raw) as PushPersistedSettings; return parsed.emailNotificationsEnabled !== false; } catch { return true; } } // The "Sender Favicons" setting covers notification icons as well as the // in-app avatars: with it off the tray shows initials, and the sender's domain // and the device's IP never reach the icon service. Off when unreadable. export async function senderFaviconsAllowed(): Promise { try { const raw = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY); if (!raw) return true; // first launch: the store's default is on const parsed = JSON.parse(raw) as PushPersistedSettings; return parsed.senderFavicons !== false; } catch { return false; } } /** The app's language: the in-app override, else the device's. */ export async function notificationLocale(): Promise { try { const raw = await AsyncStorage.getItem(LOCALE_STORAGE_KEY); const override = raw ? (JSON.parse(raw) as { override?: unknown }).override : null; if (typeof override === 'string' && isSupportedLocale(override)) return override; } catch { /* the device language it is */ } return detectDeviceLocale(); } interface ShowNotificationOptions { notificationId: string; title: string; body: string; preview?: string; initials: string; bgColorHex: string; iconUrl?: string; emailId: string; threadId: string; subject?: string; accountId: string; // The JMAP account the message lives in - a group or shared mailbox's // account rather than the user's own - so a tap opens it there. jmapAccountId: 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; markReadLabel?: string; deleteLabel?: string; replyLabel?: string; } interface BulwarkFcmNative { showNotification(opts: ShowNotificationOptions): Promise; } /** * 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; } 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' && !Array.isArray(d.changed)) { 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 { try { const raw = await AsyncStorage.getItem(ACCOUNT_REGISTRY_KEY); if (!raw) return []; const parsed = JSON.parse(raw) as { state?: { accounts?: RegistryAccount[] } }; return Array.isArray(parsed.state?.accounts) ? parsed.state!.accounts! : []; } catch { return []; } } /** * 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]); } // ─── Detached JMAP access ─────────────────────────────── // The headless task may run inside the live RN instance (app foregrounded a // moment ago) where the UI is using the singleton jmapClient. Re-binding the // singleton to another account mid-flight sends UI requests with the wrong // credentials, so every request here goes through its own session fetched // from the account's stored credentials - same pattern as api/unified-inbox. interface DetachedSession { apiUrl: string; authHeader: string; jmapAccountId: string; } function authHeaderFor(creds: StoredCredentials): string { if (creds.accessToken) return `Bearer ${creds.accessToken}`; return `Basic ${btoa(`${creds.username}:${creds.password}`)}`; } function originOf(url: string): string | null { const m = url.match(/^(https?:\/\/[^/?#]+)/i); return m ? m[1] : null; } // Same intent as JMAPClient.rewriteSessionUrls: point the advertised apiUrl at // the origin we actually connected to (servers often self-report unreachable // container-internal hosts). function rewriteApiUrl(session: JMAPSession, serverUrl: string): string { const serverOrigin = originOf(serverUrl); const apiOrigin = originOf(session.apiUrl); if (!apiOrigin || !serverOrigin || apiOrigin === serverOrigin) return session.apiUrl; return serverOrigin + session.apiUrl.slice(apiOrigin.length); } async function ensureFreshCredentials( accountId: string, creds: StoredCredentials, ): Promise { if ( !creds.accessToken || !creds.refreshToken || !creds.tokenEndpoint || !creds.clientId || creds.expiresAt == null ) { return creds; } if (creds.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return creds; try { const tokens: OAuthTokens = { accessToken: creds.accessToken, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt, tokenEndpoint: creds.tokenEndpoint, clientId: creds.clientId, }; const next = await refreshOAuthAccessToken(tokens); const updated: StoredCredentials = { ...creds, accessToken: next.accessToken, refreshToken: next.refreshToken ?? creds.refreshToken, expiresAt: next.expiresAt, tokenEndpoint: next.tokenEndpoint, clientId: next.clientId, }; const current = await jmapClient.getStoredCredentials(accountId); if (!current || current.accessToken !== updated.accessToken) { await jmapClient.setStoredCredentials(accountId, updated); } return updated; } catch { return creds; } } async function openDetachedSession(accountId: string): Promise { let creds = await jmapClient.getStoredCredentials(accountId); if (!creds) return null; creds = await ensureFreshCredentials(accountId, creds); const baseUrl = creds.serverUrl.replace(/\/+$/, ''); const authHeader = authHeaderFor(creds); const res = await secureFetch(`${baseUrl}/.well-known/jmap`, { headers: { Authorization: authHeader, Accept: 'application/json' }, }); if (!res.ok) throw new Error(`Session discovery failed: ${res.status}`); const session = (await res.json()) as JMAPSession; const jmapAccountId = session.primaryAccounts?.[CAPABILITIES.MAIL] || session.primaryAccounts?.[CAPABILITIES.CORE] || Object.keys(session.accounts ?? {})[0] || null; if (!jmapAccountId) return null; return { apiUrl: rewriteApiUrl(session, baseUrl), authHeader, jmapAccountId }; } async function jmapPost( session: DetachedSession, methodCalls: JMAPMethodCall[], ): Promise, string]>> { const response = await secureFetch(session.apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: session.authHeader }, body: JSON.stringify({ using: [CAPABILITIES.CORE, CAPABILITIES.MAIL], methodCalls }), }); if (!response.ok) throw new Error(`JMAP request failed: ${response.status}`); const body = await response.json(); return body.methodResponses ?? []; } async function detachedGetEmails( session: DetachedSession, accountId: string, ids: string[], ): Promise { const responses = await jmapPost(session, [ ['Email/get', { accountId, ids, properties: EMAIL_PROPERTIES }, '0'], ]); const [name, body] = responses[0] ?? []; if (name !== 'Email/get') return []; return (body.list as Email[]) ?? []; } async function detachedNewestUnreadInboxIds( session: DetachedSession, limit: number, ): Promise { const accountId = session.jmapAccountId; const mailboxResponses = await jmapPost(session, [ ['Mailbox/get', { accountId, properties: ['id', 'role'] }, '0'], ]); const [mbName, mbBody] = mailboxResponses[0] ?? []; if (mbName !== 'Mailbox/get') return []; const inbox = ((mbBody.list as Mailbox[]) ?? []).find((m) => m.role === 'inbox'); if (!inbox) return []; const queryResponses = await jmapPost(session, [ [ 'Email/query', { accountId, filter: { inMailbox: inbox.id, notKeyword: '$seen' }, sort: [{ property: 'receivedAt', isAscending: false }], limit, }, '0', ], ]); const [qName, qBody] = queryResponses[0] ?? []; if (qName !== 'Email/query') return []; return (qBody.ids as string[]) ?? []; } // Types that may mean new mail: `EmailDelivery`, and `Email`/`Mailbox`, which // new mail changes too. A subscription made by an older build listens to them // until the launch-time resync replaces its types, and a server without // `EmailDelivery` has only them. Keep in sync with MAIL_TYPES in PushRouting.kt. const MAIL_TYPES = ['EmailDelivery', 'Email', 'Mailbox']; /** * A StateChange push in which no account got new mail: only contact or * calendar changes, which the native push router already turned into device * syncs (#34, docs/device-sync.md "Triggers"). Without a mail type there is * nothing to notify, and the legacy path (the newest unread mail) must not * run for it. A missing map, or one that names no type, keeps the old * behaviour, and an account entry it can't read may be mail. The same rules * as PushRouting.decide, so the two never disagree about a push. */ export function carriesNoMail(payload: RelayPushData): boolean { if (payload.kind !== 'jmap-state-change' || !payload.changed) return false; const entries: unknown[] = Object.values(payload.changed); if (entries.some((types) => !types || typeof types !== 'object' || Array.isArray(types))) return false; const types = entries.flatMap((entry) => Object.keys(entry as Record)); if (types.length === 0) return false; return !types.some((type) => MAIL_TYPES.includes(type)); } // 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. Never touches the // singleton jmapClient's session (see "Detached JMAP access" above). export async function pushBackgroundTask(data: unknown): Promise { try { const payload = parseRelayPushData(data); if (carriesNoMail(payload)) return; if (!(await emailNotificationsAllowed())) return; await migrateLegacyPushKeys(); const accountIds = await readPushAccountIds(); if (accountIds.length === 0) return; const registry = await readAccountRegistry(); const jmapAccountIds = await readPushJmapAccountIds(); const accountsToCheck = matchAccountsForPush(payload, accountIds, jmapAccountIds, registry); for (const accountId of accountsToCheck) { try { await processAccountForPush(accountId, payload); } 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, ); } } /** 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 session = await openDetachedSession(accountId); if (!session) return; const alreadyNotified = await readNotifiedIds(accountId); let candidates: Email[]; let emailAccountId = session.jmapAccountId; 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; emailAccountId = payload.jmapAccountId ?? session.jmapAccountId; candidates = await detachedGetEmails(session, emailAccountId, fresh); } 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 ids = await detachedNewestUnreadInboxIds(session, LEGACY_QUERY_LIMIT); const fresh = ids.filter((id) => !alreadyNotified.includes(id)); if (fresh.length === 0) return; candidates = await detachedGetEmails(session, session.jmapAccountId, fresh); } const toNotify = selectNotifiableEmails(candidates, alreadyNotified); if (toNotify.length === 0) return; const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined; if (!native?.showNotification) return; const groupKey = `bulwark-mail:${accountId}`; const groupTitle = payload.accountLabel ?? accountId.split('@')[0] ?? accountId; const favicons = await senderFaviconsAllowed(); const locale = await notificationLocale(); // 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 || translate(locale, 'notifications.new_email', 'New email'); const body = email.subject || translate(locale, 'email_viewer.no_subject', '(No Subject)'); const initials = getEmailInitials(name, address); const bgColorHex = hslToHex(generateEmailAvatarColor(name, address)); const faviconDomain = favicons ? getFaviconDomain(address) : null; const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined; const preview = email.preview ? singleLine(email.preview) : undefined; const markReadLabel = translate(locale, 'email_viewer.mark_read', 'Mark as read'); const deleteLabel = translate(locale, 'email_viewer.delete', 'Delete'); const replyLabel = translate(locale, 'email_viewer.reply', 'Reply'); await native.showNotification({ notificationId: `mail:${email.id}`, title, body, preview, initials, bgColorHex, iconUrl, emailId: email.id, threadId: email.threadId, subject: email.subject ?? undefined, accountId, jmapAccountId: emailAccountId, groupKey, groupTitle, markReadLabel, deleteLabel, replyLabel, }); } await rememberNotifiedIds(accountId, ordered.map((e) => e.id).reverse()); } function hslToHex(hsl: string): string { const match = hsl.match(/hsl\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)/); if (!match) return '#2563eb'; const h = Number(match[1]); const s = Number(match[2]) / 100; const l = Number(match[3]) / 100; const a = s * Math.min(l, 1 - l); const component = (n: number): string => { const k = (n + h / 30) % 12; const c = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); return Math.round(255 * c).toString(16).padStart(2, '0'); }; return `#${component(0)}${component(8)}${component(4)}`; } export interface NotificationActionPayload { action: 'markRead' | 'delete'; emailId: string; accountId: string; jmapAccountId?: string; } export function parseNotificationActionPayload(data: unknown): NotificationActionPayload | null { if (!data || typeof data !== 'object') return null; const d = data as Record; const action = (d.action ?? d['bulwark.notification.action']) as 'markRead' | 'delete' | undefined; const emailId = (d.emailId ?? d['bulwark.notification.emailId']) as string | undefined; const accountId = (d.accountId ?? d['bulwark.notification.accountId']) as string | undefined; const jmapAccountId = (d.jmapAccountId ?? d['bulwark.notification.jmapAccountId']) as string | undefined; if ((action === 'markRead' || action === 'delete') && emailId && accountId) { return { action, emailId, accountId, jmapAccountId }; } return null; } export async function handleNotificationAction(data: unknown): Promise { const payload = parseNotificationActionPayload(data); if (!payload) { console.warn('[push] invalid notification action payload', data); return; } const { action, emailId, accountId, jmapAccountId } = payload; console.log(`[push] executing notification action "${action}" for email ${emailId}`); try { const session = await openDetachedSession(accountId); if (!session) { console.warn('[push] failed to open detached session for account', accountId); return; } const emailAccountId = jmapAccountId ?? session.jmapAccountId; if (action === 'markRead') { const res = await jmapPost(session, [ [ 'Email/set', { accountId: emailAccountId, update: { [emailId]: { 'keywords/$seen': true }, }, }, '0', ], ]); assertEmailUpdated(res, emailId); } else if (action === 'delete') { const mbRes = await jmapPost(session, [ ['Mailbox/get', { accountId: emailAccountId, properties: ['id', 'role'] }, '0'], ]); const [mbName, mbBody] = mbRes[0] ?? []; const mailboxes = mbName === 'Mailbox/get' ? ((mbBody?.list as Mailbox[]) ?? []) : []; const trash = mailboxes.find((m) => m.role === 'trash'); // Never destroy from a notification: without a Trash (a shared // account, or a Mailbox/get that failed) the message stays put. if (!trash) { console.warn('[push] no trash mailbox for notification delete', emailAccountId); return; } const res = await jmapPost(session, [ [ 'Email/set', { accountId: emailAccountId, update: { [emailId]: { mailboxIds: { [trash.id]: true } }, }, }, '0', ], ]); assertEmailUpdated(res, emailId); } } catch (err) { console.warn('[push] notification action failed', action, emailId, err); } } function assertEmailUpdated(responses: Array<[string, Record, string]>, emailId: string): void { const [name, body] = responses[0] ?? []; if (name !== 'Email/set' || !body?.updated || !(emailId in body.updated)) { throw new Error(`Email/set did not update ${emailId}: ${JSON.stringify(body?.notUpdated?.[emailId] ?? body ?? null)}`); } }