Files
native/src/lib/push-notifications.ts
T

1532 lines
60 KiB
TypeScript

import { NativeEventEmitter, NativeModules, PermissionsAndroid, Platform } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
createPushSubscription,
destroyPushSubscription,
listPushSubscriptions,
updatePushSubscription,
verifyPushSubscription,
} from '../api/push';
import { getMailboxes, getSharedMailboxes } from '../api/email';
import { loadedMailboxes } from './mailbox-source';
import { jmapClient, JMAPClient } from '../api/jmap-client';
import { JMAPMethodError, requireMethodResult } from '../api/jmap-result';
import { CAPABILITIES } from '../api/types';
import type { EmailPushConfig, JMAPAccountInfo, JMAPSession, Mailbox } from '../api/types';
import { generateAccountId } from './account-utils';
import {
EMPTY_NOTIFICATION_MUTES,
isAccountMuted,
mailboxOverridesForAccount,
type NotificationMutes,
} from './notification-mutes';
import { useSettingsStore } from '../stores/settings-store';
import { t } from '../stores/locale-store';
import {
authorityOfType,
DEVICE_SYNC_STORAGE_KEY,
deviceSyncPushTypes,
persistedAccounts,
} from '../device-sync/app/prefs';
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type Authority } from '../device-sync/types';
import {
getUnifiedPushDistributors,
isUnifiedPushSupported,
registerUnifiedPush,
unregisterUnifiedPush,
UnifiedPushRegisterError,
type UnifiedPushEndpoint,
} from './unified-push';
// Persist identifiers across launches so we reuse the same JMAP subscription
// after app restarts. Each account gets its own deviceClientId so the hosted
// relay can distinguish per-account pushes via the URL slot it forwards.
const RELAY_BASE_URL_KEY = 'push:relayBaseUrl:v1';
// Which transport carries pushes to this device: FCM (default) or a
// UnifiedPush distributor. Device-wide, like the relay URL.
const PUSH_TRANSPORT_KEY = 'push:transport:v1';
const PUSH_ACCOUNT_IDS_KEY = 'push:accountIds:v1';
// Local account id (username@host) → JMAP primary account id. The relay tags
// every forwarded push with the JMAP account id it came from, which is the
// only reliable key for routing a payload to the right local account.
const PUSH_JMAP_ACCOUNT_IDS_KEY = 'push:jmapAccountIds:v1';
const DEVICE_CLIENT_ID_PREFIX = 'push:deviceClientId:v2:';
const SUBSCRIPTION_ID_PREFIX = 'push:subscriptionId:v2:';
export const LAST_NOTIFIED_EMAIL_ID_PREFIX = 'push:lastNotifiedEmailId:v2:';
// Ring of recently notified message ids per account (replaces the single
// lastNotified id, which could not tell "already shown" from "older mail").
const NOTIFIED_IDS_PREFIX = 'push:notifiedIds:v1:';
const PROMPT_DISMISSED_PREFIX = 'push:promptDismissed:v1:';
// JMAP account ids the server refused in this account's emailPush map (see
// emailPushFallbacks). Left out of the map from then on, so a refusal isn't
// re-provoked on every launch.
const EMAIL_PUSH_REFUSED_PREFIX = 'push:emailPushRefused:v1:';
// When the recorded subscription expires, as the server last reported it. Lets
// the launch-time resync tell a subscription that lapsed (re-create it) from
// one that was revoked (leave push off).
const SUBSCRIPTION_EXPIRES_PREFIX = 'push:subscriptionExpires:v1:';
// Set when the user turned push off for the account - the settings toggle, or
// revoking this device here or from elsewhere. The launch-time resync leaves
// such an account alone until push is enabled for it again.
const OPTED_OUT_PREFIX = 'push:optedOut:v1:';
// Legacy single-account keys (pre-multi-account). Migrated lazily on the next
// setupPushNotifications / pushBackgroundTask call, then deleted.
const LEGACY_DEVICE_CLIENT_ID_KEY = 'push:deviceClientId:v1';
const LEGACY_SUBSCRIPTION_ID_KEY = 'push:subscriptionId:v1';
const LEGACY_PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
const LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
export function deviceClientIdKey(accountId: string): string {
return DEVICE_CLIENT_ID_PREFIX + accountId;
}
function subscriptionIdKey(accountId: string): string {
return SUBSCRIPTION_ID_PREFIX + accountId;
}
export function lastNotifiedKey(accountId: string): string {
return LAST_NOTIFIED_EMAIL_ID_PREFIX + accountId;
}
export function notifiedIdsKey(accountId: string): string {
return NOTIFIED_IDS_PREFIX + accountId;
}
function promptDismissedKey(accountId: string): string {
return PROMPT_DISMISSED_PREFIX + accountId;
}
function emailPushRefusedKey(accountId: string): string {
return EMAIL_PUSH_REFUSED_PREFIX + accountId;
}
function subscriptionExpiresKey(accountId: string): string {
return SUBSCRIPTION_EXPIRES_PREFIX + accountId;
}
function optedOutKey(accountId: string): string {
return OPTED_OUT_PREFIX + accountId;
}
export async function readPushAccountIds(): Promise<string[]> {
const raw = await AsyncStorage.getItem(PUSH_ACCOUNT_IDS_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((s): s is string => typeof s === 'string');
} catch {
return [];
}
}
async function writePushAccountIds(ids: string[]): Promise<void> {
const deduped = Array.from(new Set(ids));
if (deduped.length === 0) {
await AsyncStorage.removeItem(PUSH_ACCOUNT_IDS_KEY);
} else {
await AsyncStorage.setItem(PUSH_ACCOUNT_IDS_KEY, JSON.stringify(deduped));
}
}
/** Local account id → JMAP primary account id, for every account with push. */
export async function readPushJmapAccountIds(): Promise<Record<string, string>> {
const raw = await AsyncStorage.getItem(PUSH_JMAP_ACCOUNT_IDS_KEY);
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string') out[k] = v;
}
return out;
} catch {
return {};
}
}
async function writePushJmapAccountId(accountId: string, jmapAccountId: string | null): Promise<void> {
const map = await readPushJmapAccountIds();
if (jmapAccountId) map[accountId] = jmapAccountId;
else delete map[accountId];
if (Object.keys(map).length === 0) {
await AsyncStorage.removeItem(PUSH_JMAP_ACCOUNT_IDS_KEY);
} else {
await AsyncStorage.setItem(PUSH_JMAP_ACCOUNT_IDS_KEY, JSON.stringify(map));
}
}
// One-shot migration from the pre-multi-account schema. If the legacy
// PUSH_ACCOUNT_ID_KEY exists, treat that account as the only pre-existing
// setup: reuse the legacy deviceClientId and JMAP subscription id under
// the new per-account keys so the user doesn't lose push on upgrade.
export async function migrateLegacyPushKeys(): Promise<void> {
const legacyAccountId = await AsyncStorage.getItem(LEGACY_PUSH_ACCOUNT_ID_KEY);
if (!legacyAccountId) return;
const legacyDcid = await AsyncStorage.getItem(LEGACY_DEVICE_CLIENT_ID_KEY);
const legacySubId = await AsyncStorage.getItem(LEGACY_SUBSCRIPTION_ID_KEY);
const legacyLastId = await AsyncStorage.getItem(LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY);
if (legacyDcid) {
await AsyncStorage.setItem(deviceClientIdKey(legacyAccountId), legacyDcid);
}
if (legacySubId) {
await AsyncStorage.setItem(subscriptionIdKey(legacyAccountId), legacySubId);
}
if (legacyLastId) {
await AsyncStorage.setItem(lastNotifiedKey(legacyAccountId), legacyLastId);
}
const ids = await readPushAccountIds();
if (!ids.includes(legacyAccountId)) {
await writePushAccountIds([...ids, legacyAccountId]);
}
await AsyncStorage.multiRemove([
LEGACY_PUSH_ACCOUNT_ID_KEY,
LEGACY_DEVICE_CLIENT_ID_KEY,
LEGACY_SUBSCRIPTION_ID_KEY,
LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY,
]);
}
// Hosted relay so users don't need to run their own Firebase project. The
// relay only ever sees FCM tokens + JMAP state-id hashes - no mail content.
// Power users can override this from the settings screen.
export const DEFAULT_RELAY_BASE_URL = 'https://notifications.relay.bulwarkmail.org';
/**
* A relay must be reachable over TLS: the registration carries the FCM token
* and the JMAP server posts push bodies to it. Plain http is only allowed for
* loopback / the Android emulator host so a local relay can be developed
* against.
*/
export function isValidRelayUrl(value: string): boolean {
const trimmed = value.trim();
const m = /^(https?):\/\/([^/?#:]+)(?::\d{1,5})?(?:[/?#].*)?$/i.exec(trimmed);
if (!m) return false;
if (m[1].toLowerCase() === 'https') return true;
const host = m[2].toLowerCase();
return host === 'localhost' || host === '127.0.0.1' || host === '10.0.2.2';
}
// Only `EmailDelivery` state-changes when new mail is actually delivered.
// `Email` fires for any mutation (sending, drafting, moving, marking read,
// deleting) and `Mailbox` fires for mailbox edits - both produced spurious
// system notifications, so we keep them out of the push subscription.
// In-app sync uses the separate SSE channel and is unaffected.
export const PUSH_TYPES = ['EmailDelivery'] as const;
// The capability a server advertises for the data types of an authority.
const DEVICE_SYNC_CAPABILITIES: Record<Authority, string> = {
[CONTACTS_AUTHORITY]: CAPABILITIES.CONTACTS,
[CALENDAR_AUTHORITY]: CAPABILITIES.CALENDARS,
};
/**
* The push types of one account's subscription: `EmailDelivery`, plus the
* contact and calendar types while the account syncs them to the device
* (#34, docs/device-sync.md "Triggers"): the native push router turns those
* into device syncs, and a push carrying only them never notifies. Only the
* types of a capability the account's session advertises: a server without
* contacts or calendars may refuse the whole subscription over them.
*
* Read from the device sync store's persisted JSON rather than the store, so
* code that runs without it (the push task) never creates it.
*/
export async function pushTypesFor(accountId: string, session: JMAPSession | null): Promise<string[]> {
let extra: string[] = [];
try {
const capabilities = session?.capabilities ?? {};
extra = deviceSyncPushTypes(persistedAccounts(await AsyncStorage.getItem(DEVICE_SYNC_STORAGE_KEY))[accountId])
.filter((type) => {
const authority = authorityOfType(type);
return !!authority && DEVICE_SYNC_CAPABILITIES[authority] in capabilities;
});
} catch {
extra = [];
}
return [...PUSH_TYPES, ...extra];
}
// draft-ietf-jmap-emailpush (Stalwart >= 0.16.16). `EmailDelivery` alone
// still fires for every ingested message - including spam the server files
// straight into Junk - because the server can't know which folders a client
// cares about. With `emailPush` the server evaluates a per-account filter
// against each new message before pushing and stays silent on a miss, so
// junk-filed mail never wakes the device. Older servers don't advertise the
// capability and get the plain EmailDelivery subscription as before.
export const EMAIL_PUSH_CAPABILITY = 'urn:ietf:params:jmap:emailpush';
// Only ids: the relay stays content-blind and the headless task dedupes on them.
const EMAIL_PUSH_PROPERTIES = ['id', 'threadId'];
// Maximum expires we ask the server for. Stalwart (and other JMAP servers)
// may clamp this down; whatever they return is what we get. Without this,
// the server picks its own (often short) default and the subscription
// silently expires between app updates - so push stops arriving until the
// user re-enables it from settings.
const SUBSCRIPTION_EXPIRES_DAYS = 90;
// When an existing subscription has less than this much lifetime left, push
// expires forward on the next app start.
const SUBSCRIPTION_REFRESH_THRESHOLD_DAYS = 7;
// A recorded subscription missing from the server was revoked only if it still
// had at least this long to live; closer to its expiry (or with the clock a
// little off) it may simply have lapsed, and is re-created.
const REVOKED_EXPIRY_MARGIN_MS = 24 * 60 * 60 * 1000;
function expiresFromNow(days: number): string {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
}
export function sameTypes(a: readonly string[] | null | undefined, b: readonly string[]): boolean {
if (!a || a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((t, i) => t === sortedB[i]);
}
export function serverSupportsEmailPush(): boolean {
try {
return EMAIL_PUSH_CAPABILITY in (jmapClient.currentSession?.capabilities ?? {});
} catch {
return false;
}
}
/**
* The delivery filter we want on every account the subscription fans out to:
* skip anything the spam filter tagged `$junk` and anything that lives only in
* a Junk-role mailbox (Sieve `fileinto` doesn't set the keyword). The two are
* ANDed so a stale mailbox id - the user deleted and recreated Junk - degrades
* to keyword-only filtering rather than letting everything through.
*
* Muted accounts and folders (#78) are cut out of the same filter: a muted
* folder joins Junk in the "not only in" list; a muted account only lets
* through mail in the folders the user unmuted again, or nothing at all.
*/
export async function buildEmailPushConfig(
mutes: NotificationMutes = EMPTY_NOTIFICATION_MUTES,
): Promise<Record<string, EmailPushConfig>> {
const primary = jmapClient.accountId;
const junkByAccount = new Map<string, string[]>([[primary, []]]);
const mailboxIdsByAccount = new Map<string, Set<string>>();
// The mail store loads the same folders at the same moment (sign-in,
// start): reuse its list rather than a second Mailbox/get of our own.
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
const loaded = username && serverUrl ? await loadedMailboxes(generateAccountId(username, serverUrl)) : null;
// With mutes a swallowed fetch failure would look like "no such folder"
// and unmute everything, so let the real error surface; otherwise the
// filter can still degrade to keyword-only on a transient miss.
const hasMutes = mutes.accounts.length > 0 || Object.keys(mutes.mailboxes).length > 0;
const all = loaded ?? (hasMutes
? [...(await getMailboxes()), ...(await getSharedMailboxes())]
: [
...(await getMailboxes().catch(() => [] as Mailbox[])),
...(await getSharedMailboxes().catch(() => [] as Mailbox[])),
]);
for (const m of all) {
const accountId = m.accountId || primary;
// Shared-account mailboxes carry a client-side "<account>:<id>" id;
// the server only knows the original.
const serverId = m.originalId ?? m.id;
const ids = mailboxIdsByAccount.get(accountId) ?? new Set<string>();
ids.add(serverId);
mailboxIdsByAccount.set(accountId, ids);
const junk = junkByAccount.get(accountId) ?? [];
if (m.role === 'junk') junk.push(serverId);
junkByAccount.set(accountId, junk);
}
const config: Record<string, EmailPushConfig> = {};
for (const [accountId, junkIds] of junkByAccount) {
// Settings for folders that no longer exist are left out.
const existing = mailboxIdsByAccount.get(accountId) ?? new Set<string>();
const overrides = mailboxOverridesForAccount(mutes, accountId);
const conditions: Record<string, unknown>[] = [{ notKeyword: '$junk' }];
if (isAccountMuted(mutes, accountId)) {
const allowed = overrides.unmuted
.filter((id) => existing.has(id) && !junkIds.includes(id))
.sort();
if (allowed.length === 0) conditions.push(NEVER_MATCH);
else if (allowed.length === 1) conditions.push({ inMailbox: allowed[0] });
else conditions.push({ operator: 'OR', conditions: allowed.map((id) => ({ inMailbox: id })) });
} else {
const excluded = new Set([...junkIds, ...overrides.muted.filter((id) => existing.has(id))]);
if (excluded.size > 0) conditions.push({ inMailboxOtherThan: [...excluded].sort() });
}
config[accountId] = {
// Always the operator form: that's how the server echoes it back, so a
// stored config compares equal to a freshly built one.
filter: { operator: 'AND', conditions },
properties: [...EMAIL_PUSH_PROPERTIES],
urgency: 'high',
};
}
return config;
}
// Contradicts the `notKeyword: '$junk'` every filter starts with, so the AND
// never matches: the account stays in the map but never pushes.
const NEVER_MATCH = { hasKeyword: '$junk' } as const;
function normalizeEmailPush(value: unknown): string {
const sortKeys = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(sortKeys);
if (v && typeof v === 'object') {
return Object.keys(v as Record<string, unknown>).sort().reduce<Record<string, unknown>>((acc, k) => {
acc[k] = sortKeys((v as Record<string, unknown>)[k]);
return acc;
}, {});
}
return v;
};
return JSON.stringify(sortKeys(value ?? null));
}
export function sameEmailPush(
a: Record<string, EmailPushConfig> | null | undefined,
b: Record<string, EmailPushConfig>,
): boolean {
if (!a) return false;
return normalizeEmailPush(a) === normalizeEmailPush(b);
}
// Stalwart marks the accounts a user owns - their own and the groups they
// belong to - through the create flags of its per-account collection
// capabilities: true there, false on accounts shared with them by ACL.
const OWNERSHIP_FLAGS: ReadonlyArray<readonly [string, string]> = [
[CAPABILITIES.CALENDARS, 'mayCreateCalendar'],
[CAPABILITIES.CONTACTS, 'mayCreateAddressBook'],
[CAPABILITIES.FILES, 'mayCreateTopLevelFileNode'],
];
function looksOwned(info: JMAPAccountInfo | undefined): boolean {
if (!info) return false;
if (info.isPersonal) return true;
if (info.isReadOnly) return false;
return OWNERSHIP_FLAGS.some(([urn, flag]) => {
const capability = info.accountCapabilities?.[urn] as Record<string, unknown> | undefined;
return capability?.[flag] === true;
});
}
/**
* The emailPush maps to try, widest first. Stalwart refuses the whole map as
* `forbidden` when it names an account the user neither owns nor belongs to
* as a group member - a mailbox shared by ACL - and only fans a subscription
* out to owned accounts anyway, so dropping the others loses nothing. The
* session lists both kinds as isPersonal:false, isReadOnly:false, so the
* first fallback keeps the accounts that look owned (see OWNERSHIP_FLAGS) and
* the last keeps only the primary account, which is always accepted.
*/
export function emailPushFallbacks(
desired: Record<string, EmailPushConfig>,
primaryAccountId: string,
accounts: Record<string, JMAPAccountInfo> | undefined,
): Record<string, EmailPushConfig>[] {
const keep = (predicate: (id: string) => boolean) =>
Object.fromEntries(
Object.entries(desired).filter(([id]) => id === primaryAccountId || predicate(id)),
);
const out = [desired];
for (const next of [keep((id) => looksOwned(accounts?.[id])), keep(() => false)]) {
// Only steps that actually drop an account are worth another round-trip.
if (Object.keys(next).length < Object.keys(out[out.length - 1]).length) out.push(next);
}
return out;
}
/**
* Run a subscription write that carries `emailPush`, narrowing the map along
* emailPushFallbacks while the server refuses it as `forbidden`. Resolves to
* the write's result and the map the server took.
*/
async function writeWithEmailPush<T>(
emailPush: Record<string, EmailPushConfig> | null,
write: (emailPush: Record<string, EmailPushConfig> | null) => Promise<T>,
): Promise<{ result: T; emailPush: Record<string, EmailPushConfig> | null }> {
const attempts = emailPush
? emailPushFallbacks(emailPush, jmapClient.accountId, jmapClient.currentSession?.accounts)
: [null];
for (let i = 0; ; i++) {
try {
return { result: await write(attempts[i]), emailPush: attempts[i] };
} catch (err) {
const forbidden = err instanceof JMAPMethodError && err.type === 'forbidden';
if (!forbidden || i === attempts.length - 1) throw err;
logPhase('jmap', 'emailPush map refused, retrying with fewer accounts');
}
}
}
/**
* Run a subscription write with the account's push types and, when the server
* refuses it while they include device sync's, once more with the mail types
* alone: mail push never depends on device sync (#34).
*/
async function writeWithPushTypes<T>(
types: readonly string[],
write: (types: readonly string[]) => Promise<T>,
): Promise<T> {
try {
return await write(types);
} catch (err) {
if (sameTypes(types, PUSH_TYPES)) throw err;
logPhase('jmap', 'push types refused, retrying with the mail types alone');
return write(PUSH_TYPES);
}
}
async function readRefusedEmailPushAccounts(accountId: string): Promise<string[]> {
try {
const parsed = JSON.parse((await AsyncStorage.getItem(emailPushRefusedKey(accountId))) ?? '[]');
return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === 'string') : [];
} catch {
return [];
}
}
/** Add the accounts the server dropped from `wanted` to reach `accepted`. */
async function rememberRefusedEmailPushAccounts(
accountId: string,
refusedBefore: string[],
wanted: Record<string, EmailPushConfig> | null,
accepted: Record<string, EmailPushConfig> | null,
): Promise<void> {
if (!wanted || !accepted) return;
const refusedNow = Object.keys(wanted).filter((id) => !(id in accepted));
if (refusedNow.length === 0) return;
await AsyncStorage.setItem(
emailPushRefusedKey(accountId),
JSON.stringify(Array.from(new Set([...refusedBefore, ...refusedNow]))),
);
}
function withoutAccounts(
config: Record<string, EmailPushConfig>,
accountIds: string[],
): Record<string, EmailPushConfig> {
return Object.fromEntries(Object.entries(config).filter(([id]) => !accountIds.includes(id)));
}
type BulwarkFcmNative = {
getToken(): Promise<string>;
deleteToken(): Promise<void>;
};
function getNative(): BulwarkFcmNative | null {
if (Platform.OS !== 'android') return null;
return (NativeModules as Record<string, unknown>).BulwarkFcm as BulwarkFcmNative | undefined ?? null;
}
/** True on platforms that have a push transport wired up (Android only). */
export function isPushSupported(): boolean {
return getNative() !== null || isUnifiedPushSupported();
}
export type PushTransport = 'fcm' | 'unifiedpush';
export async function getStoredPushTransport(): Promise<PushTransport | null> {
const raw = await AsyncStorage.getItem(PUSH_TRANSPORT_KEY);
return raw === 'fcm' || raw === 'unifiedpush' ? raw : null;
}
export async function setStoredPushTransport(transport: PushTransport | null): Promise<void> {
if (!transport) {
await AsyncStorage.removeItem(PUSH_TRANSPORT_KEY);
} else {
await AsyncStorage.setItem(PUSH_TRANSPORT_KEY, transport);
}
}
export async function getEffectivePushTransport(): Promise<PushTransport> {
return (await getStoredPushTransport()) ?? 'fcm';
}
export interface PushSetupParams {
// Optional - falls back to the hosted relay if omitted.
relayBaseUrl?: string;
accountLabel?: string;
// Destroy the recorded server-side subscription and create a brand-new one
// instead of refreshing the existing record's expiry. Stalwart binds the set
// of accounts a subscription fans out to at creation time, so a subscription
// that outlives a permission change keeps pushing for mailboxes the user can
// no longer read - recreating is the only client-side remedy (#841).
forceRecreate?: boolean;
}
export interface PushSetupResult {
subscriptionId: string;
verified: boolean;
}
/** Which step of the enable flow failed - lets the UI say what went wrong. */
export type PushSetupPhase =
| 'platform'
| 'permission'
| 'token'
| 'distributor'
| 'account'
| 'relay'
| 'jmap'
| 'verify';
export class PushSetupError extends Error {
readonly phase: PushSetupPhase;
constructor(phase: PushSetupPhase, message: string) {
super(message);
this.name = 'PushSetupError';
this.phase = phase;
}
}
function randomClientId(): string {
const bytes = new Uint8Array(16);
if (globalThis.crypto?.getRandomValues) {
globalThis.crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
}
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
async function getOrCreateDeviceClientId(accountId: string): Promise<string> {
const key = deviceClientIdKey(accountId);
const existing = await AsyncStorage.getItem(key);
if (existing) return existing;
const next = randomClientId();
await AsyncStorage.setItem(key, next);
return next;
}
export async function getStoredRelayBaseUrl(): Promise<string | null> {
return AsyncStorage.getItem(RELAY_BASE_URL_KEY);
}
export async function getEffectiveRelayBaseUrl(): Promise<string> {
const stored = await AsyncStorage.getItem(RELAY_BASE_URL_KEY);
return stored ?? DEFAULT_RELAY_BASE_URL;
}
export async function setStoredRelayBaseUrl(url: string | null): Promise<void> {
if (!url) {
await AsyncStorage.removeItem(RELAY_BASE_URL_KEY);
} else {
await AsyncStorage.setItem(RELAY_BASE_URL_KEY, url.replace(/\/+$/, ''));
}
}
/** Whether this account has a JMAP subscription recorded on this device. */
export async function isPushEnabledForAccount(accountId: string): Promise<boolean> {
await migrateLegacyPushKeys();
return (await AsyncStorage.getItem(subscriptionIdKey(accountId))) !== null;
}
export async function wasPushPromptDismissed(accountId: string): Promise<boolean> {
return (await AsyncStorage.getItem(promptDismissedKey(accountId))) !== null;
}
export async function dismissPushPrompt(accountId: string): Promise<void> {
await AsyncStorage.setItem(promptDismissedKey(accountId), String(Date.now()));
}
export async function requestNotificationPermission(): Promise<boolean> {
if (Platform.OS !== 'android') return true;
if (Platform.Version < 33) return true;
const status = await PermissionsAndroid.request(
'android.permission.POST_NOTIFICATIONS' as Parameters<typeof PermissionsAndroid.request>[0],
);
return status === PermissionsAndroid.RESULTS.GRANTED;
}
export async function getFcmToken(): Promise<string | null> {
const native = getNative();
if (!native) return null;
try {
return await native.getToken();
} catch {
return null;
}
}
// Firebase rejects getToken() on devices without Google Play services (or
// with a broken Firebase configuration), and briefly right after a
// deleteToken(). Turn the raw native rejection into a phase-tagged error the
// settings pane can explain - and point de-Googled devices at UnifiedPush
// when a distributor is around to take over.
async function getFcmTokenOrThrow(native: BulwarkFcmNative): Promise<string> {
let token: string | null = null;
try {
token = await native.getToken();
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
const distributors = await getUnifiedPushDistributors().catch(() => []);
throw new PushSetupError(
'token',
distributors.length > 0
? t(
'settings.notifications.push.err_fcm_token_up',
'Firebase could not issue a device token ({detail}). Push over FCM needs Google Play services on this device. This device has a UnifiedPush distributor installed - switch the delivery method to UnifiedPush instead.',
{ detail },
)
: t(
'settings.notifications.push.err_fcm_token',
'Firebase could not issue a device token ({detail}). Push over FCM needs Google Play services on this device.',
{ detail },
),
);
}
if (!token) {
throw new PushSetupError('token', t('settings.notifications.push.err_fcm_empty', 'Firebase returned an empty device token.'));
}
return token;
}
/**
* The relay's VAPID public key, when it has one. Passed to the UnifiedPush
* distributor at registration so distributors that pin the application server
* accept the relay's pushes; registration proceeds without it (503 on relays
* without configured VAPID keys - UnifiedPush delivery still works there).
*/
async function fetchRelayVapidPublicKey(relayBaseUrl: string): Promise<string | null> {
try {
const res = await fetch(buildRelayUrl(relayBaseUrl, '/api/push/vapid-public-key'));
if (!res.ok) return null;
const body = (await res.json()) as { publicKey?: unknown };
return typeof body.publicKey === 'string' && body.publicKey ? body.publicKey : null;
} catch {
return null;
}
}
/** Acquire a UnifiedPush endpoint, mapping failures onto setup phases. */
async function getUnifiedPushEndpointOrThrow(
relayBaseUrl: string,
): Promise<UnifiedPushEndpoint> {
const vapid = await fetchRelayVapidPublicKey(relayBaseUrl);
try {
return await registerUnifiedPush({ vapid });
} catch (err) {
if (err instanceof UnifiedPushRegisterError) {
const phase = err.reason === 'failed' ? 'token' : 'distributor';
throw new PushSetupError(phase, err.message);
}
throw new PushSetupError('token', err instanceof Error ? err.message : String(err));
}
}
function buildRelayUrl(base: string, suffix: string): string {
return base.replace(/\/+$/, '') + suffix;
}
async function readRelayError(res: Response): Promise<string> {
try {
const text = await res.text();
if (!text) return `HTTP ${res.status}`;
try {
const body = JSON.parse(text) as { error?: unknown };
if (typeof body.error === 'string') return `${body.error} (HTTP ${res.status})`;
} catch {
// not JSON
}
return `HTTP ${res.status}: ${text.slice(0, 200)}`;
} catch {
return `HTTP ${res.status}`;
}
}
async function registerWithRelay(params: {
relayBaseUrl: string;
subscriptionId: string;
fcmToken: string;
accountLabel?: string;
}): Promise<void> {
let res: Response;
try {
res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
subscriptionId: params.subscriptionId,
fcmToken: params.fcmToken,
accountLabel: params.accountLabel,
}),
});
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new PushSetupError(
'relay',
t('settings.notifications.push.err_relay_unreachable', 'Could not reach the push relay at {url} ({detail}).', {
url: params.relayBaseUrl,
detail,
}),
);
}
if (!res.ok) {
throw new PushSetupError(
'relay',
t('settings.notifications.push.err_relay_rejected', 'The push relay rejected the registration: {detail}', {
detail: await readRelayError(res),
}),
);
}
}
async function registerWithRelayUnifiedPush(params: {
relayBaseUrl: string;
subscriptionId: string;
endpoint: UnifiedPushEndpoint;
accountLabel?: string;
}): Promise<void> {
const { endpoint } = params;
let res: Response;
try {
res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register/unifiedpush'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
subscriptionId: params.subscriptionId,
endpoint: endpoint.url,
keys:
endpoint.p256dh && endpoint.auth
? { p256dh: endpoint.p256dh, auth: endpoint.auth }
: null,
accountLabel: params.accountLabel,
}),
});
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new PushSetupError(
'relay',
t('settings.notifications.push.err_relay_unreachable', 'Could not reach the push relay at {url} ({detail}).', {
url: params.relayBaseUrl,
detail,
}),
);
}
if (!res.ok) {
throw new PushSetupError(
'relay',
t('settings.notifications.push.err_relay_rejected', 'The push relay rejected the registration: {detail}', {
detail: await readRelayError(res),
}),
);
}
}
/** The relay's view of a subscription - see relayStatusFor. */
export type PushRelayStatus = 'active' | 'inactive' | 'unknown';
/**
* Ask the relay what it knows about a subscription. `inactive` means the relay
* recognises the record and it is provably dead - it has never forwarded a push
* and isn't freshly registered. `unknown` covers everything we cannot vouch for:
* the relay doesn't recognise the id, an older relay without this endpoint, or a
* network blip. Callers must treat `unknown` as "leave it alone", never as dead.
*/
async function relayStatusFor(
relayBaseUrl: string,
subscriptionId: string,
): Promise<PushRelayStatus> {
if (!relayBaseUrl || !subscriptionId) return 'unknown';
try {
const res = await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`),
);
if (!res.ok) return 'unknown';
const body = (await res.json()) as { active?: unknown };
if (body.active === true) return 'active';
if (body.active === false) return 'inactive';
return 'unknown';
} catch {
return 'unknown';
}
}
/**
* Returns true ONLY when the relay positively reports a subscription inactive,
* so we never reap anything we can't confirm is dead. This is what lets setup
* clear its own abandoned attempts - and dead siblings left by reinstalls that
* regenerated the deviceClientId - without disturbing another live device or
* the PWA that shares the account.
*/
async function relayReportsDead(
relayBaseUrl: string,
subscriptionId: string,
): Promise<boolean> {
return (await relayStatusFor(relayBaseUrl, subscriptionId)) === 'inactive';
}
async function pollVerificationCode(
relayBaseUrl: string,
subscriptionId: string,
): Promise<string> {
// Stalwart per-account rate-limits PushVerification posts (default 60s).
// If there are leftover unverified subscriptions on the account, our new
// one queues up behind them - so we wait long enough to clear at least one
// verify window even in the unlucky case.
const timeoutAt = Date.now() + 75_000;
let delay = 400;
let lastRelayError: string | null = null;
while (Date.now() < timeoutAt) {
try {
const res = await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`),
);
if (res.ok) {
const body = (await res.json()) as { verificationCode?: string | null };
if (body.verificationCode) return body.verificationCode;
lastRelayError = null;
} else {
lastRelayError = await readRelayError(res);
}
} catch (err) {
lastRelayError = err instanceof Error ? err.message : String(err);
}
await new Promise((r) => setTimeout(r, delay));
delay = Math.min(delay * 1.5, 2000);
}
throw new PushSetupError(
'verify',
lastRelayError
? t(
'settings.notifications.push.err_verify_relay',
'The relay never received the verification code from the mail server (last relay response: {detail}).',
{ detail: lastRelayError },
)
: t(
'settings.notifications.push.err_verify_timeout',
'The mail server did not send a verification code to the relay within 75 s. Check that the server can reach the relay URL.',
),
);
}
// Coalesce concurrent setup attempts per account. App.tsx re-runs its push
// effect as auth state settles during startup and again on every FCM token
// refresh; because a failing attempt blocks for up to 75s polling for the
// verification code, those re-fires overlap. Without this guard each
// overlapping run mints its OWN deviceClientId and JMAP subscription, and the
// resulting swarm starves Stalwart's one-PushVerification-per-60s slot so none
// of them ever verifies (the symptom is a perpetual "Timed out waiting for
// PushVerification"). Callers share the first in-flight run instead.
const inFlightSetups = new Map<string, Promise<PushSetupResult>>();
/**
* Full setup flow: ask permission, fetch the device's FCM token, register
* with the relay, create a JMAP PushSubscription, poll for the verification
* code, and finalise the subscription. Concurrent calls for the same account
* are coalesced onto a single in-flight run.
*/
export function setupPushNotifications(
params: PushSetupParams,
): Promise<PushSetupResult> {
const key = `${jmapClient.username ?? ''}@${jmapClient.serverUrl ?? ''}`;
const existing = inFlightSetups.get(key);
if (existing) return existing;
const run = setupPushNotificationsInner(params).finally(() => {
inFlightSetups.delete(key);
});
inFlightSetups.set(key, run);
return run;
}
function logPhase(phase: string, detail?: string): void {
console.log(`[push] ${phase}${detail ? `: ${detail}` : ''}`);
}
async function setupPushNotificationsInner(
params: PushSetupParams,
): Promise<PushSetupResult> {
const transport = await getEffectivePushTransport();
const native = getNative();
if (transport === 'fcm' && !native) {
throw new PushSetupError('platform', t('settings.notifications.push.err_android_only', 'Push notifications are only available on Android.'));
}
if (transport === 'unifiedpush' && !isUnifiedPushSupported()) {
throw new PushSetupError('platform', t('settings.notifications.push.err_up_android_only', 'UnifiedPush is only available on Android.'));
}
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
if (!relayBaseUrl) throw new PushSetupError('relay', 'relayBaseUrl is required');
if (!isValidRelayUrl(relayBaseUrl)) {
throw new PushSetupError('relay', t('settings.notifications.push.err_relay_https', 'The relay URL must use https://.'));
}
logPhase('permission');
const granted = await requestNotificationPermission();
if (!granted) {
throw new PushSetupError('permission', t('settings.notifications.push.err_permission', 'Notification permission was not granted.'));
}
logPhase('token', transport);
let fcmToken: string | null = null;
let upEndpoint: UnifiedPushEndpoint | null = null;
if (transport === 'unifiedpush') {
upEndpoint = await getUnifiedPushEndpointOrThrow(relayBaseUrl);
} else {
fcmToken = await getFcmTokenOrThrow(native!);
}
// setupPushNotifications operates on the currently-loaded jmapClient. We
// need its username/serverUrl up-front so we can key per-account state.
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
if (!username || !serverUrl) {
throw new PushSetupError('account', t('settings.notifications.push.err_no_account', 'No account loaded - cannot set up push.'));
}
const accountId = generateAccountId(username, serverUrl);
await migrateLegacyPushKeys();
const deviceClientId = await getOrCreateDeviceClientId(accountId);
await setStoredRelayBaseUrl(relayBaseUrl);
// Register this account's device-client-id with the relay. Multiple
// accounts on the same device end up as separate registrations sharing
// one FCM token / UnifiedPush endpoint - the relay forwards each push
// individually and tags it with the JMAP account id so the headless task
// can route it.
logPhase('relay', relayBaseUrl);
if (upEndpoint) {
await registerWithRelayUnifiedPush({
relayBaseUrl,
subscriptionId: deviceClientId,
endpoint: upEndpoint,
accountLabel: params.accountLabel,
});
} else {
await registerWithRelay({
relayBaseUrl,
subscriptionId: deviceClientId,
fcmToken: fcmToken!,
accountLabel: params.accountLabel,
});
}
// Reuse the previous JMAP subscription when the server still has it, but
// push the expiry forward so it doesn't time out before the next app start.
// With forceRecreate we skip the reuse and replace it instead (#841). Either
// way the old record keeps delivering until its replacement is verified, so
// a replacement the server refuses never leaves the device without push.
logPhase('jmap');
const existingSubs = await listPushSubscriptions().catch(() => []);
// A forced re-registration re-learns which accounts the server accepts in
// the emailPush map.
if (params.forceRecreate) await AsyncStorage.removeItem(emailPushRefusedKey(accountId));
const refusedBefore = await readRefusedEmailPushAccounts(accountId);
const emailPush = serverSupportsEmailPush()
? withoutAccounts(await buildEmailPushConfig(useSettingsStore.getState().notificationMutes), refusedBefore)
: null;
const subKey = subscriptionIdKey(accountId);
const storedServerId = await AsyncStorage.getItem(subKey);
const types = await pushTypesFor(accountId, jmapClient.currentSession);
let jmapAccountId: string | null = null;
try {
jmapAccountId = jmapClient.accountId;
} catch {
jmapAccountId = null;
}
let replacedServerId: string | null = null;
if (storedServerId) {
const match = existingSubs.find((s) => s.id === storedServerId);
if (match) {
if (match.expires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), match.expires);
if (!params.forceRecreate) {
const refreshed = await refreshSubscriptionExpires(match, emailPush, types);
if (refreshed) {
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush);
await addPushAccountId(accountId);
await writePushJmapAccountId(accountId, jmapAccountId);
await AsyncStorage.removeItem(optedOutKey(accountId));
logPhase('done', 'reused existing subscription');
return { subscriptionId: storedServerId, verified: true };
}
}
// Server rejected the refresh or the caller asked for a fresh record:
// create a replacement below and destroy this one once it's verified.
replacedServerId = storedServerId;
} else {
await AsyncStorage.removeItem(subKey);
}
}
// Reap leftover Stalwart subscriptions that would otherwise starve the new
// one's verification. Stalwart emits only one PushVerification per account
// per ~60s and picks the oldest unverified subscription, so a single stale
// straggler blocks every fresh attempt - the symptom is the confusing
// "Timed out waiting for PushVerification" error. We can't read a
// subscription's verified state or URL over JMAP (Stalwart hides both), only
// its deviceClientId, so we decide what's safe to remove like this:
// - same deviceClientId as ours: a previous attempt from THIS device,
// always safe to reap.
// - a different deviceClientId: could be another live device or the PWA on
// this account. Ask the relay whether it's still alive and only reap the
// ones it confirms are dead. Anything live - or anything the relay can't
// vouch for (a different relay, a non-Bulwark client, a network blip) -
// is left untouched.
for (const s of existingSubs) {
if (s.id === storedServerId) continue;
if (s.deviceClientId === deviceClientId) {
await destroyPushSubscription(s.id).catch(() => undefined);
continue;
}
if (await relayReportsDead(relayBaseUrl, s.deviceClientId)) {
await destroyPushSubscription(s.id).catch(() => undefined);
}
}
let serverAssignedId: string;
let serverExpires: string | null;
try {
const created = await writeWithPushTypes(types, (wanted) =>
writeWithEmailPush(emailPush, (filter) =>
createPushSubscription({
deviceClientId,
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
types: [...wanted],
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
...(filter ? { emailPush: filter } : {}),
}),
),
);
serverAssignedId = created.result.id;
serverExpires = created.result.expires;
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new PushSetupError(
'jmap',
t('settings.notifications.push.err_jmap_refused', 'The mail server refused the push subscription: {detail}', { detail }),
);
}
logPhase('verify');
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
try {
await verifyPushSubscription(serverAssignedId, verificationCode);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new PushSetupError(
'verify',
t('settings.notifications.push.err_verify_rejected', 'The mail server rejected the verification code: {detail}', { detail }),
);
}
await AsyncStorage.setItem(subKey, serverAssignedId);
if (serverExpires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), serverExpires);
if (replacedServerId) {
await destroyPushSubscription(replacedServerId).catch(() => undefined);
}
await addPushAccountId(accountId);
await writePushJmapAccountId(accountId, jmapAccountId);
await AsyncStorage.removeItem(optedOutKey(accountId));
logPhase('done', 'subscription verified');
return { subscriptionId: serverAssignedId, verified: true };
}
async function addPushAccountId(accountId: string): Promise<void> {
const ids = await readPushAccountIds();
if (!ids.includes(accountId)) {
await writePushAccountIds([...ids, accountId]);
}
}
// Push the subscription's expires forward when it's getting close to the
// server's ceiling, and re-sync `types` / the delivery filter when they drift
// from what this client wants (a subscription created by an older build still
// listens to `Email`/`Mailbox`; device sync was turned on or off; a Junk
// mailbox id can change under us). Returns false if the server rejects the
// update, also with the mail types alone, which the caller treats as
// "replace"; otherwise the emailPush map the update installed (null when it
// left the filter alone).
async function refreshSubscriptionExpires(
sub: {
id: string;
expires?: string | null;
types?: string[] | null;
emailPush?: Record<string, EmailPushConfig> | null;
},
// null when the server has no emailPush support - leave the property alone.
desiredEmailPush: Record<string, EmailPushConfig> | null,
types: readonly string[],
): Promise<false | { emailPush: Record<string, EmailPushConfig> | null }> {
const typesNeedUpdate = !sameTypes(sub.types, types);
const emailPushNeedsUpdate =
desiredEmailPush !== null && !sameEmailPush(sub.emailPush, desiredEmailPush);
if (!typesNeedUpdate && !emailPushNeedsUpdate && sub.expires) {
const remainingMs = new Date(sub.expires).getTime() - Date.now();
const thresholdMs = SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000;
if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) {
// Plenty of life left - skip the update round-trip.
return { emailPush: null };
}
}
try {
return await writeWithPushTypes(types, async (wanted) => {
const patch: { expires?: string; types?: string[] } = {
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
};
if (!sameTypes(sub.types, wanted)) patch.types = [...wanted];
const { emailPush } = await writeWithEmailPush(
emailPushNeedsUpdate ? desiredEmailPush : null,
(filter) => updatePushSubscription(sub.id, filter ? { ...patch, emailPush: filter } : patch),
);
return { emailPush };
});
} catch {
return false;
}
}
async function deregisterFromRelay(
relayBaseUrl: string,
deviceClientId: string,
): Promise<void> {
await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
{ method: 'DELETE' },
).catch(() => undefined);
}
async function clearAccountPushKeys(accountId: string): Promise<void> {
await AsyncStorage.multiRemove([
subscriptionIdKey(accountId),
deviceClientIdKey(accountId),
lastNotifiedKey(accountId),
notifiedIdsKey(accountId),
emailPushRefusedKey(accountId),
subscriptionExpiresKey(accountId),
]);
await writePushJmapAccountId(accountId, null);
}
/**
* Tear down push for a single account. Destroys every JMAP subscription the
* server holds for this device (assumes the jmapClient is currently
* authenticated to that account; the active-account logout flow guarantees
* this) and tells the relay to drop its mapping. Other accounts' push setups
* are untouched.
*
* The FCM token is deliberately left alive: the relay mapping is gone so
* nothing gets forwarded, and deleting the token makes the next getToken()
* fail for a while (native #45 "Disable then Enable" race). Only the
* logout-all path (`teardownPushNotifications`) kills the token.
*/
export async function teardownPushNotificationsForAccount(
accountId: string,
): Promise<void> {
await migrateLegacyPushKeys();
const storedSubId = await AsyncStorage.getItem(subscriptionIdKey(accountId));
const storedDcid = await AsyncStorage.getItem(deviceClientIdKey(accountId));
const relayBaseUrl = await getStoredRelayBaseUrl();
// Destroy every subscription the server holds for this device, not just the
// id we happen to have recorded - a destroy that lost its round-trip or a
// failed enable can leave a registration this client no longer tracks (#841).
const idsToDestroy = new Set<string>();
if (storedSubId) idsToDestroy.add(storedSubId);
if (storedDcid) {
const existing = await listPushSubscriptions().catch(() => []);
for (const s of existing) {
if (s.deviceClientId === storedDcid) idsToDestroy.add(s.id);
}
}
for (const id of idsToDestroy) {
await destroyPushSubscription(id).catch(() => undefined);
}
if (relayBaseUrl && storedDcid) {
await deregisterFromRelay(relayBaseUrl, storedDcid);
}
await clearAccountPushKeys(accountId);
const remaining = (await readPushAccountIds()).filter((id) => id !== accountId);
await writePushAccountIds(remaining);
}
/**
* Turn push off for one account because the user asked to: the settings
* toggle, or revoking this device (here, or from another device - see
* resyncPushNotifications). Tears the registration down and remembers the
* choice, so the launch-time resync doesn't quietly register the account
* again. Enabling push for it again clears the mark.
*/
export async function disablePushForAccount(accountId: string): Promise<void> {
await teardownPushNotificationsForAccount(accountId);
await AsyncStorage.setItem(optedOutKey(accountId), String(Date.now()));
}
/**
* True when the server no longer has this account's recorded subscription
* although it wasn't due to expire - someone revoked it. Without a known
* expiry (a registration made by an older build) nothing can be told apart,
* so it counts as not revoked. Throws when the server can't be asked.
*/
async function wasRevokedOnServer(accountId: string): Promise<boolean> {
const storedServerId = await AsyncStorage.getItem(subscriptionIdKey(accountId));
const expires = Date.parse((await AsyncStorage.getItem(subscriptionExpiresKey(accountId))) ?? '');
if (!storedServerId || !Number.isFinite(expires)) return false;
if (expires - Date.now() < REVOKED_EXPIRY_MARGIN_MS) return false;
const subs = await listPushSubscriptions();
return !subs.some((s) => s.id === storedServerId);
}
/**
* Bring the loaded account's registration up to date without user action -
* on launch and when the push token or endpoint rotates. Skips an account the
* user turned push off for, and honours a revocation: when the server dropped
* the subscription before it was due to expire, push is turned off for the
* account instead of being silently registered again. Resolves to null when
* it left push off.
*/
export async function resyncPushNotifications(
params: PushSetupParams,
): Promise<PushSetupResult | null> {
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
if (!username || !serverUrl) return null;
const accountId = generateAccountId(username, serverUrl);
if (await AsyncStorage.getItem(optedOutKey(accountId))) return null;
if (await wasRevokedOnServer(accountId)) {
logPhase('revoked', 'subscription gone before it was due to expire; leaving push off');
await disablePushForAccount(accountId);
return null;
}
return setupPushNotifications(params);
}
/**
* Re-apply an account's push types to its subscription after device sync was
* turned on or off for it (#34). The account the client serves goes through
* the api/push helpers; another signed-in account through a client of its
* own, never the singleton. Best effort: the next resync of the account
* applies them anyway.
*/
export async function refreshPushSubscriptionTypes(accountId: string): Promise<void> {
if (await AsyncStorage.getItem(optedOutKey(accountId))) return;
const subscriptionId = await AsyncStorage.getItem(subscriptionIdKey(accountId));
if (!subscriptionId) return;
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
const served = !!username && !!serverUrl && generateAccountId(username, serverUrl) === accountId;
try {
if (served) {
const types = await pushTypesFor(accountId, jmapClient.currentSession);
const current = (await listPushSubscriptions()).find((s) => s.id === subscriptionId);
if (!current || sameTypes(current.types, types)) return;
await updatePushSubscription(subscriptionId, { types: [...types] });
return;
}
const client = new JMAPClient();
if (!(await client.loadAccount(accountId))) return;
const types = await pushTypesFor(accountId, client.currentSession);
const using = [CAPABILITIES.CORE];
const res = await client.request(
[['PushSubscription/get', { ids: [subscriptionId], properties: ['id', 'types'] }, '0']],
using,
);
const current = (requireMethodResult(res, '0', 'PushSubscription/get').list as Array<{ id: string; types?: string[] | null }> | undefined)
?.find((s) => s.id === subscriptionId);
if (!current || sameTypes(current.types, types)) return;
await client.request(
[['PushSubscription/set', { update: { [subscriptionId]: { types: [...types] } } }, '0']],
using,
);
} catch {
// The next resync re-applies them.
}
}
/**
* Tear down push for ALL accounts on this device. Used by the logout-all
* flow; best-effort because we typically aren't authenticated to every
* account's JMAP server at the moment we need to call destroy on it. The
* FCM token is always deleted so no push gets through regardless.
*/
export async function teardownPushNotifications(): Promise<void> {
await migrateLegacyPushKeys();
const accountIds = await readPushAccountIds();
const relayBaseUrl = await getStoredRelayBaseUrl();
for (const accountId of accountIds) {
const storedSubId = await AsyncStorage.getItem(subscriptionIdKey(accountId));
const storedDcid = await AsyncStorage.getItem(deviceClientIdKey(accountId));
if (storedSubId) {
// Will only succeed if the jmapClient happens to be authenticated to
// this account right now. We don't switch the client to attempt each
// one; the subscription will expire server-side instead (90-day TTL).
await destroyPushSubscription(storedSubId).catch(() => undefined);
}
if (relayBaseUrl && storedDcid) {
await deregisterFromRelay(relayBaseUrl, storedDcid);
}
await clearAccountPushKeys(accountId);
}
await AsyncStorage.multiRemove([PUSH_ACCOUNT_IDS_KEY, PUSH_JMAP_ACCOUNT_IDS_KEY]);
const native = getNative();
if (native) {
await native.deleteToken().catch(() => undefined);
}
// Also release any UnifiedPush registration so the distributor stops
// holding a channel for us. No-op when never registered.
await unregisterUnifiedPush().catch(() => undefined);
}
export interface PushDevice {
// The JMAP PushSubscription id - what you destroy to revoke it.
id: string;
// Client-chosen id the relay keys its endpoint mapping on.
deviceClientId: string;
expires: string | null;
types: string[] | null;
// True when this registration belongs to the device you're looking at.
isThisDevice: boolean;
relayStatus: PushRelayStatus;
}
/**
* Every push registration the JMAP server holds for this account, annotated
* with whether it is this device and what the relay makes of it.
*
* Stalwart hides a subscription's url and verified state from clients, so
* deviceClientId is the only handle we get. That's enough to spot our own
* registration and to ask the relay about the rest - but registrations made
* against a different relay, or by a non-Bulwark client, come back `unknown`
* rather than dead, and the UI must present them as revocable-but-unclassified.
*/
export async function listPushDevices(params: {
accountId: string;
relayBaseUrl?: string;
}): Promise<PushDevice[]> {
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
const thisDeviceClientId = await AsyncStorage.getItem(deviceClientIdKey(params.accountId));
const subs = await listPushSubscriptions();
return Promise.all(
subs.map(async (s) => ({
id: s.id,
deviceClientId: s.deviceClientId,
expires: s.expires ?? null,
types: s.types ?? null,
isThisDevice: thisDeviceClientId !== null && s.deviceClientId === thisDeviceClientId,
relayStatus: await relayStatusFor(relayBaseUrl, s.deviceClientId),
})),
);
}
/**
* Revoke one registration. Destroying the JMAP subscription stops the server
* fanning StateChanges to it; dropping the relay mapping stops the relay
* forwarding anything already in flight and frees the deviceClientId. Revoking
* this device runs the full local teardown so the UI doesn't keep claiming push
* is on.
*/
export async function revokePushDevice(params: {
accountId: string;
device: Pick<PushDevice, 'id' | 'deviceClientId' | 'isThisDevice'>;
relayBaseUrl?: string;
}): Promise<void> {
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
if (params.device.isThisDevice) {
await disablePushForAccount(params.accountId);
return;
}
await destroyPushSubscription(params.device.id);
if (relayBaseUrl && params.device.deviceClientId) {
await deregisterFromRelay(relayBaseUrl, params.device.deviceClientId);
}
}
export type FcmMessageListener = (payload: {
title?: string;
body?: string;
data: Record<string, string>;
}) => void;
export function addMessageListener(listener: FcmMessageListener): () => void {
if (Platform.OS !== 'android') return () => undefined;
const emitter = new NativeEventEmitter(NativeModules.BulwarkFcm);
const sub = emitter.addListener('fcm:message', listener);
return () => sub.remove();
}
export type FcmTokenListener = (payload: { token: string }) => void;
export function addTokenRefreshListener(listener: FcmTokenListener): () => void {
if (Platform.OS !== 'android') return () => undefined;
const emitter = new NativeEventEmitter(NativeModules.BulwarkFcm);
const sub = emitter.addListener('fcm:newToken', listener);
return () => sub.remove();
}
export interface NotificationTapPayload {
emailId: string;
threadId: string;
subject?: string;
// Identifies which logged-in account the notification was generated for.
// Optional for back-compat: older notifications already on the system tray
// won't carry this and will fall back to the active account on tap.
accountId?: string;
// The JMAP account the message lives in: a group or shared mailbox's account
// when it isn't the user's own. Absent on older notifications.
jmapAccountId?: string;
// Notification action tapped (e.g. 'reply' to jump straight to Compose)
action?: 'reply';
}
/**
* The JMAP account to open a tapped notification's message against, in the
* form the EmailThread route takes: the group or shared account it was
* delivered from, or undefined for the user's own mail - including
* notifications posted before the field existed (#839).
*/
export function notificationTapJmapAccountId(payload: NotificationTapPayload): string | undefined {
if (!payload.jmapAccountId) return undefined;
let primary: string | null = null;
try {
primary = jmapClient.accountId;
} catch {
primary = null;
}
return payload.jmapAccountId === primary ? undefined : payload.jmapAccountId;
}
// Returns - and clears - any pending "notification tap" that launched the app
// before JS was ready to handle it. Subsequent taps while running are delivered
// via addNotificationTapListener.
export async function getInitialNotificationTap(): Promise<NotificationTapPayload | null> {
if (Platform.OS !== 'android') return null;
const native = (NativeModules as Record<string, unknown>).BulwarkFcm as
| { getInitialNotification?: () => Promise<NotificationTapPayload | null> }
| undefined;
if (!native?.getInitialNotification) return null;
try {
return (await native.getInitialNotification()) ?? null;
} catch {
return null;
}
}
export function addNotificationTapListener(
listener: (payload: NotificationTapPayload) => void,
): () => void {
if (Platform.OS !== 'android') return () => undefined;
const emitter = new NativeEventEmitter(NativeModules.BulwarkFcm);
const sub = emitter.addListener('fcm:notificationTap', listener);
return () => sub.remove();
}