Files
native/src/lib/push-background-task.ts
T

797 lines
32 KiB
TypeScript

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<boolean> {
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<boolean> {
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<boolean> {
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<LocaleCode> {
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<void>;
}
/**
* 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<string, Record<string, string>> | 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<string, unknown>;
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<string, Record<string, string>>;
}
} catch {
// ignore
}
} else if (d.changed && typeof d.changed === 'object' && !Array.isArray(d.changed)) {
out.changed = d.changed as Record<string, Record<string, string>>;
}
// 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<RegistryAccount[]> {
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<string, string>,
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<string[]> {
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<void> {
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<DetachedSession | null> {
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, any>, string]>,
method: string,
): Record<string, any> {
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<Array<[string, Record<string, any>, 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, any>, 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<string[]> {
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<string, unknown>));
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<void> {
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<void> {
const due = await pushSubscriptionDueForExtension(accountId);
if (!due) return;
const subscriptionOf = (responses: Array<[string, Record<string, any>, 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<void> {
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<void> {
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<void> {
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<void> {
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<string, unknown>;
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<void> {
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<string, unknown> = { 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, any>, 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)}`);
}
}