Files
native/src/lib/push-background-task.ts
T
Linus Rath e67e240840 feat: ask android to sync when contacts or calendars change #34
With device sync on, Android should hear about a change as soon as the
app does, not only at the next periodic sync. This adds what tells it:

- The app's own contact and calendar edits, StateChanges on the live
  stream for `AddressBook`/`ContactCard` and `Calendar`/`CalendarEvent`,
  and the app coming back to the foreground request a sync of that
  account and authority. Requests within 5 s fold into one, and a
  StateChange the engine caused itself (its own upload echoing back) is
  skipped.
- While the app is closed, the account's push subscription carries
  those types too. The FCM and UnifiedPush services hand every push to
  the native router from f743ab0, which requests the syncs, and start
  the mail push task only when a push has `EmailDelivery`; the task also
  returns early for such pushes, so a contact change can never show the
  "newest unread mail" notification.
- At launch, on foreground and when Android's accounts change, the app
  checks Android's accounts against its own: an account removed in
  Android Settings shows sync off and is not recreated, and one whose app
  account is gone stops syncing and posts "Sign in again", keeping its
  data.

The functions that turn sync on and off (`src/device-sync/app/lifecycle.ts`)
come with this; the settings that call them land in a later commit, so
nothing here runs before an account has device sync on.

Checked: the new store, lifecycle, trigger and push tests, `npm run
typecheck`, `npm test` and `npm run i18n:check` on this commit, and the
debug build.
2026-09-27 04:36:51 +02:00

532 lines
20 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 { refreshOAuthAccessToken, type OAuthTokens } from './oauth';
import {
generateEmailAvatarColor,
getEmailInitials,
getFaviconDomain,
getFaviconUrl,
} from './avatar-utils';
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<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 when unreadable.
export async function senderFaviconsAllowed(): Promise<boolean> {
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<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;
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;
}
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') {
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;
}
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 [];
}
}
/**
* 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<string, string>,
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<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 ensureFreshCredentials(
accountId: string,
creds: StoredCredentials,
): Promise<StoredCredentials> {
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<DetachedSession | null> {
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<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 ?? [];
}
async function detachedGetEmails(
session: DetachedSession,
accountId: string,
ids: string[],
): Promise<Email[]> {
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<string[]> {
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[]) ?? [];
}
/**
* 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 `EmailDelivery` there
* is nothing to notify, and the legacy path (the newest unread mail) must
* not run for it. An empty or missing `changed` map keeps the old behaviour.
*/
export function carriesNoMail(payload: RelayPushData): boolean {
if (payload.kind !== 'jmap-state-change' || !payload.changed) return false;
const accounts = Object.values(payload.changed);
if (accounts.length === 0) return false;
return !accounts.some((types) => !!types && typeof types === 'object' && 'EmailDelivery' in types);
}
// 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();
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<void> {
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;
await native.showNotification({
notificationId: `mail:${email.id}`,
title,
body,
initials,
bgColorHex,
iconUrl,
emailId: email.id,
threadId: email.threadId,
subject: email.subject ?? undefined,
accountId,
jmapAccountId: emailAccountId,
groupKey,
groupTitle,
});
}
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)}`;
}