mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
797 lines
32 KiB
TypeScript
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)}`);
|
|
}
|
|
}
|