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 { setKnownAccounts, type KnownAccount } from './account-utils'; import { freshStoredCredentials } from './stored-token-refresh'; import { generateEmailAvatarColor, getEmailInitials, getFaviconDomain, getFaviconUrl, } from './avatar-utils'; import { singleLine } from './single-line'; import { cleanPreview } from './preview-text'; import { lastNotifiedKey, migrateLegacyPushKeys, notifiedIdsKey, pushSubscriptionDueForExtension, pushSubscriptionNeedsExtension, accountMatchesPushLabel, readPushAccountIds, readPushJmapAccountIds, recordPushSubscriptionExpires, wantedPushSubscriptionExpires, } 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 EMAIL_PROPERTIES = [ 'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt', 'from', 'subject', 'preview', ]; interface PushPersistedSettings { emailNotificationsEnabled?: boolean; senderFavicons?: boolean; deleteAction?: string; } 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 by default, like the // store, and off when unreadable. export async function senderFaviconsAllowed(): Promise { try { const raw = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY); if (!raw) return false; // first launch: the store's default is off const parsed = JSON.parse(raw) as PushPersistedSettings; return parsed.senderFavicons === true; } catch { return false; } } // The "Delete Action" setting, as far as a notification's Delete honours it: // "Move to Trash and mark as read" marks the message read on the way (#86). // "Delete Permanently" still only moves to Trash: a notification never // destroys mail (#68). async function notificationDeleteMarksRead(): Promise { try { const raw = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY); if (!raw) return false; return (JSON.parse(raw) as PushPersistedSettings).deleteAction === 'trash-and-read'; } 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; // Left out for a notification that names no login (showPlainNotification). 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; // Whether Delete asks to unlock the phone first (#85). Delete only ever // moves the message to Trash; with a Trash to move it to, nothing is lost // and the action runs from the lock screen and the open shade. Without // one, or when that is not known, the unlock stays. deleteNeedsUnlock?: boolean; 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; serverUrl?: 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 []; } } export interface PushAccountMatch { accountIds: string[]; // The push names messages, and more than one login may be the one they // were delivered to: see matchAccountsForPush. ambiguous: boolean; } /** * 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, and also tells apart logins that share a JMAP account * id. When neither matches - a payload from a build that predates the id map - * every registered account is checked. * * JMAP account ids repeat across servers (Stalwart starts every server at the * same few), and so do message ids. A push naming messages that still fits * more than one login is `ambiguous`: each login would fetch those ids from * its own server and could show another server's message, so the caller * shows a plain notification instead. So is one whose label fits none of the * logins with its JMAP account id. A push without ids makes each login * look at its own newest mail, which is never another login's. */ export function matchAccountsForPush( payload: RelayPushData, pushAccountIds: string[], jmapAccountIds: Record, registry: RegistryAccount[], ): PushAccountMatch { const byLabel = (ids: string[]) => (payload.accountLabel ? ids.filter((id) => accountMatchesPushLabel(id, payload.accountLabel!, registry.find((a) => a.id === id)?.username)) : []); let accountIds = pushAccountIds; let unsure = false; const byJmapId = payload.jmapAccountId ? pushAccountIds.filter((id) => jmapAccountIds[id] === payload.jmapAccountId) : []; if (byJmapId.length > 0) { // Also a single match has to fit the label: group accounts are subscribed // too, and their ids come from the same short pool, so a push for another // login's group can carry this login's own id. const narrowed = byLabel(byJmapId); accountIds = narrowed.length > 0 ? narrowed : byJmapId; unsure = !!payload.accountLabel && narrowed.length === 0; } else { const labelled = byLabel(pushAccountIds); if (labelled.length > 0) accountIds = labelled; } return { accountIds, ambiguous: (unsure || accountIds.length > 1) && payload.emailIds.length > 0 }; } 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 openDetachedSession(accountId: string): Promise { let creds = await jmapClient.getStoredCredentials(accountId); if (!creds) return null; creds = await freshStoredCredentials(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 }; } /** * The lookup for a push failed: the server, the session or a method answered * with an error. That is not "nothing new" (webmail #1041): the push said * something arrived, so a generic notification is shown instead of none. */ export class PushLookupError extends Error { constructor(message: string) { super(message); this.name = 'PushLookupError'; } } /** The first response, or a PushLookupError when the method failed. */ function methodResult( responses: Array<[string, Record, string]>, method: string, ): Record { const [name, body] = responses[0] ?? []; if (name !== method) { const type = name === 'error' && typeof body?.type === 'string' ? body.type : name ?? 'no response'; throw new PushLookupError(`${method} failed: ${type}`); } return body ?? {}; } 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 ?? []; } /** * The messages to notify, and whether their account has a Trash for the * notification's Delete to move them to (asked in the same request). An * answer without the folders counts as no Trash. */ async function detachedGetEmails( session: DetachedSession, accountId: string, ids: string[], ): Promise<{ emails: Email[]; hasTrash: boolean }> { const responses = await jmapPost(session, [ ['Email/get', { accountId, ids, properties: EMAIL_PROPERTIES }, '0'], ['Mailbox/get', { accountId, properties: ['id', 'role'] }, '1'], ]); const body = methodResult(responses, 'Email/get'); return { emails: (body.list as Email[]) ?? [], hasTrash: answerHasTrash(responses) }; } /** Whether the Mailbox/get answer (call id '1') lists a Trash. */ export function answerHasTrash(responses: Array<[string, Record, string]>): boolean { const answer = responses.find(([name, , id]) => name === 'Mailbox/get' && id === '1'); const list = answer?.[1]?.list; return Array.isArray(list) && list.some((m: Mailbox) => m?.role === 'trash'); } 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 mbBody = methodResult(mailboxResponses, 'Mailbox/get'); 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 qBody = methodResult(queryResponses, 'Email/query'); 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(); // Without the account store loaded, account ids are still the ones the // registry holds (see generateAccountId). setKnownAccounts(registry.filter((a): a is KnownAccount => !!a.username && !!a.serverUrl)); const jmapAccountIds = await readPushJmapAccountIds(); const match = matchAccountsForPush(payload, accountIds, jmapAccountIds, registry); if (match.ambiguous) await showPlainNotification(payload); for (const accountId of match.accountIds) { try { await processAccountForPush(accountId, payload, !match.ambiguous); } catch (err) { console.warn( '[push] background check failed for account', accountId, err instanceof Error ? err.message : err, ); // Something arrived; say so even though the lookup couldn't say what. if (!match.ambiguous) await showLookupFailedNotification(accountId, payload).catch(() => undefined); } } } 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; }); } /** * Push the account's subscription expiry forward from the background (see * pushSubscriptionDueForExtension). Best effort: anything unexpected leaves * it to the next launch. */ async function extendPushSubscription(accountId: string, session: DetachedSession): Promise { const due = await pushSubscriptionDueForExtension(accountId); if (!due) return; const subscriptionOf = (responses: Array<[string, Record, string]>, callId: string) => { const response = responses.find((r) => r[2] === callId); if (response?.[0] !== 'PushSubscription/get') return undefined; return (response[1].list as Array<{ id: string; deviceClientId?: string; expires?: string | null }> | undefined) ?.find((s) => s.id === due.id); }; const properties = ['id', 'deviceClientId', 'expires']; // Stalwart reuses the ids of destroyed subscriptions: extend only our own. const current = subscriptionOf( await jmapPost(session, [['PushSubscription/get', { ids: [due.id], properties }, '0']]), '0', ); if (!current || (current.deviceClientId && due.deviceClientId && current.deviceClientId !== due.deviceClientId)) return; // The recorded expiry was stale: the server's copy has time left. if (current.expires && !pushSubscriptionNeedsExtension(current.expires)) { await recordPushSubscriptionExpires(accountId, current.expires); return; } const responses = await jmapPost(session, [ ['PushSubscription/set', { update: { [due.id]: { expires: wantedPushSubscriptionExpires() } } }, '0'], // The update's answer doesn't say what the server settled on. ['PushSubscription/get', { ids: [due.id], properties }, '1'], ]); const extended = subscriptionOf(responses, '1'); if (extended?.expires) await recordPushSubscriptionExpires(accountId, extended.expires); } // `notify` is false for an ambiguous push (see matchAccountsForPush): each // login it may belong to still keeps its subscription alive. async function processAccountForPush(accountId: string, payload: RelayPushData, notify: boolean): Promise { const session = await openDetachedSession(accountId); if (!session) return; try { if (notify) await notifyForPush(accountId, session, payload); } finally { await extendPushSubscription(accountId, session).catch(() => undefined); } } async function notifyForPush(accountId: string, session: DetachedSession, payload: RelayPushData): Promise { const alreadyNotified = await readNotifiedIds(accountId); let candidates: Email[]; let hasTrash = false; 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; ({ emails: candidates, hasTrash } = 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; ({ emails: candidates, hasTrash } = 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; // No leading style sheet or preheader padding (webmail #1136). const preview = singleLine(cleanPreview(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, deleteNeedsUnlock: !hasTrash, replyLabel, }); } await rememberNotifiedIds(accountId, ordered.map((e) => e.id).reverse()); } /** * New mail without saying which message: for a push more than one login may * belong to. It carries no message and no login, so it has no actions and a * tap opens the app as it is; empty ids, which the tap handler refuses, keep * it from opening anything (NotificationTapStore). */ async function showPlainNotification(payload: RelayPushData): Promise { const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined; if (!native?.showNotification) return; const locale = await notificationLocale(); const label = payload.accountLabel ?? ''; await native.showNotification({ notificationId: `mail-plain:${payload.jmapAccountId ?? ''}:${payload.emailIds[0] ?? ''}`, title: translate(locale, 'notifications.new_email', 'New email'), body: label, initials: getEmailInitials(label, ''), bgColorHex: hslToHex(generateEmailAvatarColor(label, '')), emailId: '', threadId: '', groupKey: 'bulwark-mail:plain', groupTitle: label || 'Bulwark', }); } /** * New mail in `accountId` that couldn't be looked up (webmail #1041): the * account's name and no message, so a tap opens the app as it is. One per * account; the next failure replaces it rather than stacking up. */ async function showLookupFailedNotification(accountId: string, payload: RelayPushData): Promise { const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined; if (!native?.showNotification) return; const locale = await notificationLocale(); const label = payload.accountLabel ?? accountId.split('@')[0] ?? accountId; await native.showNotification({ notificationId: `mail-unknown:${accountId}`, title: translate(locale, 'notifications.new_email', 'New email'), body: label, initials: getEmailInitials(label, ''), bgColorHex: hslToHex(generateEmailAvatarColor(label, '')), emailId: '', threadId: '', groupKey: `bulwark-mail:${accountId}`, groupTitle: label, }); } 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 patch: Record = { mailboxIds: { [trash.id]: true } }; if (await notificationDeleteMarksRead()) patch['keywords/$seen'] = true; const res = await jmapPost(session, [ [ 'Email/set', { accountId: emailAccountId, update: { [emailId]: patch, }, }, '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)}`); } }