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

2373 lines
98 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 { isJMAPSetError, JMAPMethodError, requireMethodResult } from '../api/jmap-result';
import { CAPABILITIES } from '../api/types';
import type { EmailPushConfig, JMAPAccountInfo, JMAPSession, Mailbox, PushSubscription } from '../api/types';
import { generateAccountId } from './account-utils';
import {
EMPTY_NOTIFICATION_MUTES,
isAccountMuted,
isMailboxMuted,
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 * as Notifications from 'expo-notifications';
import { credentialsKey } from '../api/jmap-client';
import { apnsEnvironment, removeIosPushAccount, writeIosPushAccount } from './ios-push-shared';
import {
getUnifiedPushDistributors,
getUnifiedPushEndpoint,
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). Forgotten when the server behind the
// account is replaced, which loses every subscription (forgetPushServerState).
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. Stalwart (0.16 and 1.0) clamps every
// expiry to seven days, so there each start refreshes it.
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;
}
}
/**
* Whether the session lists emailpush among the account's own capabilities.
* Not jmapClient.hasAccountCapability: that falls back to the session-level
* capability, which Stalwart advertises whatever the account.
*/
function accountListsEmailPush(accountId: string): boolean {
const caps = jmapClient.currentSession?.accounts?.[accountId]?.accountCapabilities;
return !!caps && EMAIL_PUSH_CAPABILITY in caps;
}
// RFC 9670. Stalwart 1.0 lists it in every account's accountCapabilities,
// 0.16 in none.
const PRINCIPALS_OWNER_CAPABILITY = 'urn:ietf:params:jmap:principals:owner';
/**
* Whether the account capabilities name the accounts a subscription reaches:
* Stalwart 1.0 lists emailpush on the user's own account and their groups
* only. 0.16.16 to 0.16.23 list it on every account, ACL shares included, and
* 0.16.6 to 0.16.15 on none, so there the app has to work it out.
*/
function emailPushCapabilityIsExact(): boolean {
try {
const caps = jmapClient.currentSession?.accounts?.[jmapClient.accountId]?.accountCapabilities;
return !!caps && PRINCIPALS_OWNER_CAPABILITY in caps;
} 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.
*
* Only the primary account and the accounts that list emailpush in their
* accountCapabilities go in the map. Stalwart fans a subscription out to the
* user's own account and their groups only, and refuses the whole map as
* `forbidden` when it names a mailbox shared by ACL. 1.0 lists emailpush on
* exactly those accounts; 0.16.16 to 0.16.23 list it on every account, and
* the setup narrows the map when the server refuses it.
*
* 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.
*
* `inboxOnly` (webmail #983, Settings > Notifications > Inbox only) lets
* through only mail that lands in each account's Inbox; a muted Inbox, or an
* account without one, then never pushes.
*/
export async function buildEmailPushConfig(
mutes: NotificationMutes = EMPTY_NOTIFICATION_MUTES,
inboxOnly = false,
): Promise<Record<string, EmailPushConfig>> {
const primary = jmapClient.accountId;
const junkByAccount = new Map<string, string[]>([[primary, []]]);
const mailboxIdsByAccount = new Map<string, Set<string>>();
const inboxByAccount = new Map<string, 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;
// In inbox-only mode a swallowed failure would read as "no Inbox" too.
const all = loaded ?? (hasMutes || inboxOnly
? [...(await getMailboxes()), ...(await getSharedMailboxes())]
: [
...(await getMailboxes().catch(() => [] as Mailbox[])),
...(await getSharedMailboxes().catch(() => [] as Mailbox[])),
]);
for (const m of all) {
const accountId = m.accountId || primary;
if (accountId !== primary && !accountListsEmailPush(accountId)) continue;
// 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);
if (m.role === 'inbox') inboxByAccount.set(accountId, serverId);
}
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 (inboxOnly) {
const inboxId = inboxByAccount.get(accountId);
// The own account always has an Inbox; missing it means the folder
// list is incomplete, and silencing the account would be worse.
if (!inboxId && accountId === primary) {
throw new Error(`No Inbox found for account ${accountId}; cannot build an inbox-only push filter`);
}
conditions.push(inboxId && !isMailboxMuted(mutes, accountId, inboxId) ? { inMailbox: inboxId } : NEVER_MATCH);
} else 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 the subscription with a
* `forbidden` SetError. Anything else - a refused method call (which can be
* `forbidden` too), overQuota, a failed request - would fail the same way
* with a narrower map, so it is thrown as is. 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 mapRefused = isJMAPSetError(err) && err.type === 'forbidden';
if (!mapRefused || i === attempts.length - 1) throw err;
logPhase('jmap', 'emailPush map refused, retrying with fewer accounts');
}
}
}
/**
* Whether a refused subscription write may be about its types. A failed
* request says nothing about them, and a SetError naming other properties
* (the push URL, the emailPush map) or meaning the subscription limit or a
* missing record would fail the same way with fewer types; retrying those
* with the mail types alone would only drop device sync's on a write that
* later goes through.
*/
function mayConcernTypes(err: unknown): boolean {
if (!(err instanceof JMAPMethodError)) return false;
if (!isJMAPSetError(err)) return true;
if (err.type !== 'invalidProperties') return false;
return !err.properties || err.properties.includes('types');
}
/**
* Run a subscription write with the account's push types and, when the server
* refuses it over them 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) || !mayConcernTypes(err)) 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)));
}
/**
* Which of `accountIds` (accounts the user sees mailboxes in) background
* notifications don't reach: mailboxes others share with the user by ACL.
* Stalwart only fans a subscription out to the user's own account and their
* groups, whatever the emailPush map says, on every version.
*
* 1.0 leaves emailpush out of exactly those accounts' capabilities, and that
* settles it: a group without calendars, contacts or files looks unowned, and
* a refusal remembered under 0.16 could only keep a group out. 0.16.16 to
* 0.16.23 list emailpush everywhere (and 0.16.6 to 0.16.15 nowhere), so there
* it is the accounts the server refused in the map, and the ones that don't
* look owned.
*/
export async function mailAccountsWithoutPush(accountIds: Iterable<string>): Promise<string[]> {
let primary: string;
try {
primary = jmapClient.accountId;
} catch {
return [];
}
const others = Array.from(new Set(accountIds)).filter((id) => id && id !== primary);
if (others.length === 0) return [];
if (emailPushCapabilityIsExact()) return others.filter((id) => !accountListsEmailPush(id));
if (serverSupportsEmailPush()) {
const withoutEmailPush = others.filter((id) => !accountListsEmailPush(id));
if (withoutEmailPush.length > 0) return withoutEmailPush;
}
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
const refused = username && serverUrl
? await readRefusedEmailPushAccounts(generateAccountId(username, serverUrl))
: [];
const accounts = jmapClient.currentSession?.accounts;
return others.filter((id) => refused.includes(id) || !looksOwned(accounts?.[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: FCM or UnifiedPush on Android, APNs on iOS. */
export function isPushSupported(): boolean {
return Platform.OS === 'ios' || getNative() !== null || isUnifiedPushSupported();
}
export type PushTransport = 'fcm' | 'unifiedpush' | 'apns';
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> {
// iOS has one way in: APNs, through the relay.
if (Platform.OS === 'ios') return 'apns';
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;
}
}
/**
* Whether the server refused the push address itself. Stalwart checks the
* URL when a subscription is created: 0.16.27 and 1.0 refuse plain http and
* private or reserved IP addresses, and 1.0 also refuses any host that is not
* a public domain name (localhost, .local, .lan, .internal, a single label).
*/
function isRefusedPushUrl(err: unknown): boolean {
return isJMAPSetError(err) && err.type === 'invalidProperties' && !!err.properties?.includes('url');
}
// A subscription write the mail server refused or could not take. The
// refusals a user can do something about read as plain text; anything else
// keeps the error's own message.
function subscriptionError(err: unknown, relayBaseUrl: string): PushSetupError {
if (isRefusedPushUrl(err)) {
return new PushSetupError(
'jmap',
t(
'settings.notifications.push.err_url_refused',
'The mail server refuses to send notifications to the relay at {url}. It only accepts https addresses on a public domain name, not a private IP address or a local name such as localhost, .local or .lan. Use the hosted relay, or give your relay a public domain name.',
{ url: relayBaseUrl },
),
);
}
const type = err instanceof JMAPMethodError ? err.type : undefined;
if (type === 'forbidden') {
return new PushSetupError(
'jmap',
t('settings.notifications.push.err_forbidden', "The mail server doesn't allow background notifications for this account."),
);
}
if (type === 'overQuota') {
// Stalwart allows 15 subscriptions per account by default, and a
// replacement is created before the one it replaces is destroyed.
return new PushSetupError(
'jmap',
t(
'settings.notifications.push.err_over_quota',
'This account has too many push registrations on the mail server (the limit is usually 15). Revoke devices you no longer use in the notification settings, then try again.',
),
);
}
const detail = err instanceof Error ? err.message : String(err);
return new PushSetupError(
'jmap',
t('settings.notifications.push.err_jmap_refused', 'The mail server refused the push subscription: {detail}', { detail }),
);
}
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;
}
/**
* Whether push works for the account right now: it is set up, and the
* system still lets the app show notifications (they can be turned off in
* the phone's settings while the app is in the background). A failed check
* reads as off (webmail #1148).
*/
export async function isPushActiveForAccount(accountId: string): Promise<boolean> {
try {
if (!(await isPushEnabledForAccount(accountId))) return false;
const permission = await Notifications.getPermissionsAsync();
return permission.granted !== false;
} catch {
return false;
}
}
/**
* Whether Settings > Notifications should read the push state again when
* the app comes back: not while an enable or disable runs, nor right after
* one failed, which would wipe its message (webmail #1148).
*/
export function shouldRecheckPushState(status: 'idle' | 'busy' | 'enabled' | 'error'): boolean {
return status !== 'busy' && status !== 'error';
}
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 === 'ios') {
const current = await Notifications.getPermissionsAsync();
if (current.granted) return true;
const asked = await Notifications.requestPermissionsAsync({
ios: { allowAlert: true, allowBadge: true, allowSound: true },
});
return asked.granted;
}
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 APNs device token (hex). iOS hands it out once notifications are
// allowed; expo-notifications asks the OS for it.
async function getApnsTokenOrThrow(): Promise<string> {
let token: string | null = null;
try {
const result = await Notifications.getDevicePushTokenAsync();
token = typeof result.data === 'string' ? result.data : null;
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new PushSetupError(
'token',
t('settings.notifications.push.err_apns_token', 'iOS could not issue a push token ({detail}).', { detail }),
);
}
if (!token) {
throw new PushSetupError('token', t('settings.notifications.push.err_apns_empty', 'iOS returned an empty push 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 registerWithRelayApns(params: {
relayBaseUrl: string;
subscriptionId: string;
deviceToken: string;
accountLabel?: string;
}): Promise<void> {
let res: Response;
try {
res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register/apns'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
subscriptionId: params.subscriptionId,
deviceToken: params.deviceToken,
environment: apnsEnvironment(),
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.status === 503) {
throw new PushSetupError(
'relay',
t('settings.notifications.push.err_relay_no_apns', 'The push relay at {url} does not deliver to iPhones and iPads yet.', {
url: params.relayBaseUrl,
}),
);
}
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),
}),
);
}
}
/**
* Tell the iOS notification extension how to reach an account: which stored
* credentials to use and the JMAP API URL. Without it the extension shows the
* relay's placeholder text.
*
* The extension finds the entry by the push's JMAP account id alone
* (NotificationService.swift), and those ids repeat across servers. While
* another login with push has the same id, neither gets an entry: the
* placeholder says there is new mail, where one login's credentials would
* fetch and show a message of the other's server. Run again for the login
* that is left once the other is gone (the next resync does). The entry also
* names the username, which the extension compares with the push's label: a
* group account of another login can have this login's id.
*/
export async function shareAccountWithExtension(
accountId: string,
jmapAccountId: string | null,
apiUrl: string | undefined = jmapClient.currentSession?.apiUrl,
username: string | null | undefined = jmapClient.username,
): Promise<void> {
if (Platform.OS !== 'ios' || !jmapAccountId) return;
const byLocal = await readPushJmapAccountIds();
if (Object.keys(byLocal).some((local) => local !== accountId && byLocal[local] === jmapAccountId)) {
await removeIosPushAccount(jmapAccountId).catch(() => undefined);
return;
}
if (!apiUrl) return;
await writeIosPushAccount(jmapAccountId, {
credentialsKey: credentialsKey(accountId),
apiUrl,
...(username ? { label: username } : {}),
...(await sharedSubscription(accountId)),
}).catch(() => undefined);
}
/**
* The account's subscription as the iOS native renewal needs it
* (IosPushAccount.subscriptionId). Nothing for an account the user turned
* push off for, or without a recorded subscription: nothing to keep alive.
*/
async function sharedSubscription(
accountId: string,
): Promise<{ subscriptionId?: string; deviceClientId?: string; subscriptionExpires?: string }> {
const [subscriptionId, deviceClientId, expires, optedOut] = await Promise.all([
AsyncStorage.getItem(subscriptionIdKey(accountId)),
AsyncStorage.getItem(deviceClientIdKey(accountId)),
AsyncStorage.getItem(subscriptionExpiresKey(accountId)),
AsyncStorage.getItem(optedOutKey(accountId)),
]);
if (!subscriptionId || optedOut) return {};
return {
subscriptionId,
...(deviceClientId ? { deviceClientId } : {}),
...(expires ? { subscriptionExpires: expires } : {}),
};
}
/**
* Whether a local account (username@host) is the login a relay label names.
* The label is the username the account registered with the relay.
*/
export function accountMatchesPushLabel(localAccountId: string, label: string, username?: string): boolean {
const wanted = label.toLowerCase();
return username?.toLowerCase() === wanted || localAccountId.toLowerCase().startsWith(`${wanted}@`);
}
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>>();
/**
* The local account the singleton client serves with a live session; null
* while it has none, such as during an account change, when it holds the
* credentials of the account being signed in to before its session is up.
* The setup asks the server through the client, so it must know the client
* still serves the account it is setting up: a listing of another account's
* subscriptions took the account's own for revoked and turned its push off.
*/
function servedAccountId(): string | null {
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
if (!jmapClient.isConnected || !username || !serverUrl) return null;
return generateAccountId(username, serverUrl);
}
function accountChangedError(): PushSetupError {
return new PushSetupError(
'account',
t('settings.notifications.push.err_account_changed', 'The account changed while push was being set up. Try again.'),
);
}
/**
* 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 rollback: SetupRollback = {};
const run = setupPushNotificationsInner(params, rollback)
.catch(async (err: unknown) => {
await rollback.relayMove?.().catch(() => undefined);
throw err;
})
.finally(() => {
inFlightSetups.delete(key);
});
inFlightSetups.set(key, run);
return run;
}
function logPhase(phase: string, detail?: string): void {
console.log(`[push] ${phase}${detail ? `: ${detail}` : ''}`);
}
// What a failed setup run has to undo, set by the run as it goes.
interface SetupRollback {
relayMove?: () => Promise<void>;
}
async function setupPushNotificationsInner(
params: PushSetupParams,
rollback: SetupRollback,
): 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 === 'apns' && Platform.OS !== 'ios') {
throw new PushSetupError('platform', 'APNs is only available on iOS.');
}
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;
let apnsToken: string | null = null;
if (transport === 'unifiedpush') {
upEndpoint = await getUnifiedPushEndpointOrThrow(relayBaseUrl);
} else if (transport === 'apns') {
apnsToken = await getApnsTokenOrThrow();
} 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 accountId = servedAccountId();
if (!accountId) {
throw new PushSetupError('account', t('settings.notifications.push.err_no_account', 'No account loaded - cannot set up push.'));
}
await migrateLegacyPushKeys();
const deviceClientId = await getOrCreateDeviceClientId(accountId);
// The relay a working registration goes through, put back should a move
// to another relay fail.
const previousRelayBaseUrl = await getStoredRelayBaseUrl();
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);
const registerWith = async (relay: string, endpoint: UnifiedPushEndpoint | null) => {
if (apnsToken) {
await registerWithRelayApns({
relayBaseUrl: relay,
subscriptionId: deviceClientId,
deviceToken: apnsToken,
accountLabel: params.accountLabel,
});
} else if (endpoint) {
await registerWithRelayUnifiedPush({
relayBaseUrl: relay,
subscriptionId: deviceClientId,
endpoint,
accountLabel: params.accountLabel,
});
} else {
await registerWithRelay({
relayBaseUrl: relay,
subscriptionId: deviceClientId,
fcmToken: fcmToken!,
accountLabel: params.accountLabel,
});
}
};
// Undo this run's relay steps once a move to another relay fails. The new
// URL may be another name of the relay in use, so the record dropped from
// it can be the working one; and a UnifiedPush distributor now holds the
// new relay's VAPID key, which may make it turn away the previous relay's
// pushes. Registering with the previous relay again puts both back; the
// record itself only for an account that had a working subscription
// through it.
const restoreRelay = async (relay: string, hadSubscription: boolean) => {
const endpoint = upEndpoint ? await getUnifiedPushEndpointOrThrow(relay) : null;
if (hadSubscription) await registerWith(relay, endpoint);
};
// Until a subscription through the new relay is verified, whatever still
// works goes through the relay used before, whichever step failed (the
// server refused the address, the subscription limit, a verification that
// never came): keep that one stored, since token refreshes, the settings,
// the device list and the next launch all follow it, and drop the record
// this run left on the new relay.
let match: PushSubscription | undefined;
if (previousRelayBaseUrl !== relayBaseUrl) {
rollback.relayMove = async () => {
await setStoredRelayBaseUrl(previousRelayBaseUrl);
await deregisterFromRelay(relayBaseUrl, deviceClientId);
if (previousRelayBaseUrl) await restoreRelay(previousRelayBaseUrl, !!match).catch(() => undefined);
};
}
await registerWith(relayBaseUrl, upEndpoint);
// 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.
//
// A failed listing ends the run here: taken for "the server has no
// subscription", it would drop the key of a working registration.
logPhase('jmap');
// From here on the server is asked through the client (see servedAccountId).
if (servedAccountId() !== accountId) throw accountChangedError();
let existingSubs: PushSubscription[];
try {
existingSubs = await listPushSubscriptions();
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new PushSetupError(
'jmap',
t('settings.notifications.push.err_jmap_list', 'Could not read the push registrations from the mail server: {detail}', { detail }),
);
}
// A forced re-registration re-learns which accounts the server accepts in
// the emailPush map.
if (params.forceRecreate) await AsyncStorage.removeItem(emailPushRefusedKey(accountId));
// On 1.0 the capabilities the map is built from already leave out every
// account the server refuses; a list remembered under 0.16 could only keep
// a group out.
const refusedBefore = emailPushCapabilityIsExact() ? [] : await readRefusedEmailPushAccounts(accountId);
const emailPush = serverSupportsEmailPush()
? withoutAccounts(
await buildEmailPushConfig(useSettingsStore.getState().notificationMutes, useSettingsStore.getState().pushNotifyInboxOnly),
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;
// Ours only if it was made for this device: Stalwart's ids are short and
// sequential, so once the server is reinstalled at the same address the
// recorded id can name another device's subscription.
match = storedServerId
? existingSubs.find((s) => s.id === storedServerId && (!s.deviceClientId || s.deviceClientId === deviceClientId))
: undefined;
if (storedServerId) {
if (match) {
if (match.expires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), match.expires);
if (!params.forceRecreate) {
let refreshed: Awaited<ReturnType<typeof refreshSubscriptionExpires>>;
try {
refreshed = await refreshSubscriptionExpires(match, emailPush, types);
} catch (err) {
// Nothing the server said about the subscription (a failed request,
// rate limiting, a refused method call): it keeps delivering, and
// the next run tries again.
throw subscriptionError(err, relayBaseUrl);
}
if (refreshed) {
// The update's answer doesn't say what the server settled on, and
// the expiry recorded above is from before it. Left there, it would
// read as lapsed days before the server's copy is, which turns the
// background extension off for an account without new mail.
if (refreshed.updated) await recordServerExpiry(accountId, match.id, deviceClientId);
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush);
await addPushAccountId(accountId);
await writePushJmapAccountId(accountId, jmapAccountId);
await shareAccountWithExtension(accountId, jmapAccountId);
await AsyncStorage.removeItem(optedOutKey(accountId));
logPhase('done', 'reused existing subscription');
return { subscriptionId: storedServerId, verified: true };
}
}
// The server refused the refresh with a SetError, or the caller asked
// for a fresh record: create a replacement below and destroy this one
// once it's verified.
replacedServerId = storedServerId;
} else {
// The server no longer has it: it lapsed, or the server behind the
// account was replaced. A revocation from another device is caught
// before this by the launch-time resync (wasRevokedOnServer). Either
// way it is re-created below.
await AsyncStorage.removeItem(subKey);
}
}
// Reap leftover Stalwart subscriptions that would otherwise starve the new
// one's verification. Stalwart (0.16 and 1.0) emits only one
// PushVerification per account per 60 s, to the newest unverified
// subscription, and sends the next one when that window has passed: every
// straggler created or touched just before ours delays it by a window, and
// a swarm of them ends in 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 === match?.id) 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;
const create = () => 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 } : {}),
}),
),
);
try {
let created: Awaited<ReturnType<typeof create>>;
try {
created = await create();
} catch (err) {
// Stalwart caps an account's subscriptions (15 by default) and counts
// the one being replaced, so at the cap a forced re-register could never
// create its replacement. The user asked for a new record: free the old
// one's place and try once more. Should that fail too, push is off and
// the error says why. Not when this run moves to another relay: Stalwart
// counts the subscriptions before it looks at the URL, so a relay it
// refuses would only show up after the working record is gone.
const relayChanged = previousRelayBaseUrl !== relayBaseUrl;
if (!params.forceRecreate || !replacedServerId || relayChanged || !isJMAPSetError(err) || err.type !== 'overQuota') throw err;
logPhase('jmap', 'subscription limit reached, replacing the old record first');
await destroyPushSubscription(replacedServerId);
await AsyncStorage.removeItem(subKey);
replacedServerId = null;
created = await create();
}
serverAssignedId = created.result.id;
serverExpires = created.result.expires;
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush);
} catch (err) {
throw subscriptionError(err, relayBaseUrl);
}
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 }),
);
}
rollback.relayMove = undefined;
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 shareAccountWithExtension(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 when the server refuses the
// update with a SetError, also with the mail types alone (the subscription is
// gone, or no narrower map is accepted), which the caller answers with a
// replacement; otherwise the emailPush map the update installed (null when it
// left the filter alone) and whether it wrote anything. Every other failure -
// the network, a timeout, rate limiting, a refused method call - says nothing
// about the subscription and is thrown.
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; updated: boolean }> {
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, updated: false };
}
}
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, updated: true };
});
} catch (err) {
if (isJMAPSetError(err)) return false;
throw err;
}
}
/**
* Record the expiry the server holds for the account's subscription, read
* back after an update. Best effort: without an answer the recorded one
* stays, and the background extension asks the server itself.
*/
async function recordServerExpiry(accountId: string, subscriptionId: string, deviceClientId: string): Promise<void> {
try {
const current = (await listPushSubscriptions()).find(
(s) => s.id === subscriptionId && (!s.deviceClientId || s.deviceClientId === deviceClientId),
);
if (current?.expires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), current.expires);
} catch {
// The next run reads it again.
}
}
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> {
if (Platform.OS === 'ios') {
const jmapAccountId = (await readPushJmapAccountIds())[accountId];
if (jmapAccountId) await removeIosPushAccount(jmapAccountId);
}
await AsyncStorage.multiRemove([
subscriptionIdKey(accountId),
deviceClientIdKey(accountId),
lastNotifiedKey(accountId),
notifiedIdsKey(accountId),
emailPushRefusedKey(accountId),
subscriptionExpiresKey(accountId),
]);
await writePushJmapAccountId(accountId, null);
}
/**
* Forget what push learned from the account's server: the ring of notified
* message ids (a different server hands the same short ids to new mail, which
* would then never notify), the accounts it refused in the emailPush map, and
* the recorded subscription's expiry. A replaced server holds none of the old
* subscriptions, which must not count as revoked: without the expiry the next
* resync re-creates it (see lib/server-change).
*/
export async function forgetPushServerState(accountId: string): Promise<void> {
await AsyncStorage.multiRemove([
lastNotifiedKey(accountId),
notifiedIdsKey(accountId),
emailPushRefusedKey(accountId),
subscriptionExpiresKey(accountId),
]);
}
/**
* Forget the recorded subscription's expiry alone, for an account whose
* server may have been replaced unseen (lib/server-change): the next resync
* then re-creates a subscription the server lacks instead of taking it for
* revoked.
*/
export async function forgetPushSubscriptionExpiry(accountId: string): Promise<void> {
await AsyncStorage.removeItem(subscriptionExpiresKey(accountId));
}
// Stalwart (0.16 and 1.0) keeps a subscription for seven days from its last
// refresh, and only the app's start refreshes it. Someone who reads new mail
// from the notifications without opening the app would lose push after a
// week, so a push handled in the background extends the subscription too,
// at most about once a day: once less than this much of it is left.
const BACKGROUND_EXTEND_BELOW_MS = 6 * 24 * 60 * 60 * 1000;
/** Whether a subscription expiring then is due for a background extension. */
export function pushSubscriptionNeedsExtension(expires: string | null | undefined): boolean {
const at = Date.parse(expires ?? '');
return !Number.isFinite(at) || at - Date.now() < BACKGROUND_EXTEND_BELOW_MS;
}
/**
* The account's subscription when a background push may have to extend it:
* it is recorded, and its recorded expiry is less than six days away, has
* passed or is unknown. Only the first is certain: the record can be older
* than the server's copy (a refresh whose answer never came back), so the
* caller reads the server's expiry before it extends anything. A push arriving
* at all says some subscription still delivers. Carries this device's client
* id, so the caller can make sure a server that reused the id hands back our
* own subscription.
*/
export async function pushSubscriptionDueForExtension(
accountId: string,
): Promise<{ id: string; deviceClientId: string | null } | null> {
if (await AsyncStorage.getItem(optedOutKey(accountId))) return null;
const id = await AsyncStorage.getItem(subscriptionIdKey(accountId));
if (!id) return null;
if (!pushSubscriptionNeedsExtension(await AsyncStorage.getItem(subscriptionExpiresKey(accountId)))) return null;
return { id, deviceClientId: await AsyncStorage.getItem(deviceClientIdKey(accountId)) };
}
/** The expiry to ask for when extending a subscription; servers clamp it. */
export function wantedPushSubscriptionExpires(): string {
return expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS);
}
/** Record the expiry the server reported for the account's subscription. */
export async function recordPushSubscriptionExpires(accountId: string, expires: string): Promise<void> {
await AsyncStorage.setItem(subscriptionExpiresKey(accountId), expires);
}
/**
* Tear down push for a single account. Destroys every JMAP subscription the
* server holds for this device and tells the relay to drop its mapping.
* Other accounts' push setups are untouched. The account the jmapClient
* serves goes through it; another signed-in account (removed from the
* account list while a different one is open) through a client of its own,
* never the singleton: subscription ids repeat across accounts ("b" in every
* Stalwart account), so a destroy by id there could end the open account's
* own push.
*
* 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).
// The recorded id alone is trusted only when the server can't be asked: on
// a server reinstalled at the same address it can name another device's.
const server = await pushSubscriptionsOf(accountId);
const idsToDestroy = new Set<string>();
const existing = server && storedDcid ? await server.list().catch(() => null) : null;
if (existing) {
for (const s of existing) {
const recordedWithoutDevice = s.id === storedSubId && !s.deviceClientId;
if (s.deviceClientId === storedDcid || recordedWithoutDevice) idsToDestroy.add(s.id);
}
} else if (storedSubId) {
idsToDestroy.add(storedSubId);
}
for (const id of idsToDestroy) {
await server?.destroy(id).catch(() => undefined);
}
if (relayBaseUrl && storedDcid) {
await deregisterFromRelay(relayBaseUrl, storedDcid);
}
await clearAccountPushKeys(accountId);
const remaining = (await readPushAccountIds()).filter((id) => id !== accountId);
await writePushAccountIds(remaining);
}
/**
* Listing and destroying the account's push subscriptions, through the
* jmapClient when it serves the account, else through a client of its own.
* Null when the account can't be loaded: its server is left alone then.
*/
async function pushSubscriptionsOf(accountId: string): Promise<{
list: () => Promise<ListedSubscription[]>;
destroy: (id: string) => Promise<void>;
} | null> {
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
if (username && serverUrl && generateAccountId(username, serverUrl) === accountId) {
return { list: listPushSubscriptions, destroy: destroyPushSubscription };
}
const client = new JMAPClient();
if (!(await client.loadAccount(accountId).catch(() => false))) return null;
const using = [CAPABILITIES.CORE];
return {
list: async () => {
const res = await client.request(
[['PushSubscription/get', { ids: null, properties: ['id', 'deviceClientId'] }, '0']],
using,
);
return (requireMethodResult(res, '0', 'PushSubscription/get').list as ListedSubscription[] | undefined) ?? [];
},
destroy: async (id) => {
const res = await client.request([['PushSubscription/set', { destroy: [id] }, '0']], using);
requireMethodResult(res, '0', 'PushSubscription/set');
},
};
}
/**
* 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, or one on a server that was
* replaced since, or may have been) 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 deviceClientId = await AsyncStorage.getItem(deviceClientIdKey(accountId));
const subs = await listPushSubscriptions();
return !subs.some((s) => isOwnSubscription(s, storedServerId, deviceClientId));
}
/**
* Whether a subscription the server lists is the one this device recorded.
* Stalwart hands out short ids and gives a destroyed subscription's id to the
* next one created ("b" in every account), so the id alone can name another
* client's subscription on the same account, such as the webmail's. A
* subscription listed without a device client id, or one recorded before the
* device kept its own, is taken on the id.
*/
function isOwnSubscription(
s: { id: string; deviceClientId?: string | null },
subscriptionId: string,
deviceClientId: string | null,
): boolean {
return s.id === subscriptionId && (!s.deviceClientId || !deviceClientId || s.deviceClientId === deviceClientId);
}
/**
* 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. A server replaced at
* the account's address loses every subscription too; that is told apart by
* its fingerprint, which forgets the expiry (forgetPushServerState), so the
* subscription is re-created there. Resolves to null when it left push off.
*/
export async function resyncPushNotifications(
params: PushSetupParams,
): Promise<PushSetupResult | null> {
// Without a live session (an account change under way) there is nothing
// to ask; the caller runs this again once the session is up.
const accountId = servedAccountId();
if (!accountId) return null;
if (await AsyncStorage.getItem(optedOutKey(accountId))) return null;
const revoked = await wasRevokedOnServer(accountId);
// The listing went to whatever account the client served meanwhile.
if (servedAccountId() !== accountId) return null;
if (revoked) {
logPhase('revoked', 'subscription gone before it was due to expire; leaving push off');
await disablePushForAccount(accountId);
return null;
}
return setupPushNotifications(params);
}
export interface PushEnableOutcome {
error: unknown;
// The relay the settings should show afterwards.
relayBaseUrl: string;
// Whether the account has a subscription recorded once the run is over.
enabled: boolean;
}
/**
* The enable and re-register buttons of the notification settings: run the
* setup and say what it left behind. When the mail server refuses a new
* relay, or the move to it fails otherwise, the setup goes back to the one in
* use; the relay field must show that one again rather than the new address,
* since the device list asks
* the relay in the field about each device and a revoke goes to it. With no
* relay in use the typed one stays, so the user can correct it.
*/
export async function enablePushFromSettings(params: PushSetupParams): Promise<PushEnableOutcome> {
const accountId = jmapClient.username && jmapClient.serverUrl
? generateAccountId(jmapClient.username, jmapClient.serverUrl)
: null;
let error: unknown = null;
try {
await setupPushNotifications(params);
} catch (err) {
error = err;
}
const typed = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
return {
error,
relayBaseUrl: (await getStoredRelayBaseUrl()) ?? typed,
enabled: accountId ? await isPushEnabledForAccount(accountId) : false,
};
}
/**
* 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;
const deviceClientId = await AsyncStorage.getItem(deviceClientIdKey(accountId));
try {
if (served) {
const types = await pushTypesFor(accountId, jmapClient.currentSession);
const current = (await listPushSubscriptions()).find((s) => isOwnSubscription(s, subscriptionId, deviceClientId));
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', 'deviceClientId', 'types'] }, '0']],
using,
);
const list = requireMethodResult(res, '0', 'PushSubscription/get').list as
| Array<{ id: string; deviceClientId?: string | null; types?: string[] | null }>
| undefined;
const current = list?.find((s) => isOwnSubscription(s, subscriptionId, deviceClientId));
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.
}
}
/**
* Keep push alive for the signed-in accounts the app is not showing (webmail #1054).
* The launch-time resync works on the loaded account only, and Stalwart keeps
* a subscription for seven days from its last refresh, so another account's
* push stopped about a week after the user last switched to it. Each account
* goes through a client of its own, never the singleton: its registration
* with the relay is renewed (the device token may have rotated since), its
* subscription's expiry pushed forward and the expiry the server settled on
* recorded. A subscription the server no longer has - lapsed, or revoked
* elsewhere - is left to the account's own resync, which can tell the two
* apart and verify a new one; so are its delivery filter and push types.
* Best effort: whatever fails is tried again on the next launch.
*/
export async function renewOtherPushAccounts(): Promise<void> {
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
const served = username && serverUrl ? generateAccountId(username, serverUrl) : null;
const relayBaseUrl = await getStoredRelayBaseUrl();
for (const accountId of await readPushAccountIds()) {
if (accountId === served) continue;
try {
await renewPushAccount(accountId, relayBaseUrl);
} catch (err) {
logPhase('renew', `${accountId}: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
type ListedSubscription = { id: string; deviceClientId?: string | null; expires?: string | null };
async function renewPushAccount(accountId: string, relayBaseUrl: string | null): Promise<void> {
if (await AsyncStorage.getItem(optedOutKey(accountId))) return;
const subscriptionId = await AsyncStorage.getItem(subscriptionIdKey(accountId));
if (!subscriptionId) return;
const deviceClientId = await AsyncStorage.getItem(deviceClientIdKey(accountId));
const client = new JMAPClient();
if (!(await client.loadAccount(accountId))) return;
if (relayBaseUrl && deviceClientId) {
await registerDeviceWithRelay(relayBaseUrl, deviceClientId, client.username ?? undefined).catch(() => undefined);
}
const using = [CAPABILITIES.CORE];
const properties = ['id', 'deviceClientId', 'expires'];
const ownIn = (res: Awaited<ReturnType<JMAPClient['request']>>, callId: string) =>
(requireMethodResult(res, callId, 'PushSubscription/get').list as ListedSubscription[] | undefined)
?.find((s) => isOwnSubscription(s, subscriptionId, deviceClientId));
const current = ownIn(
await client.request([['PushSubscription/get', { ids: [subscriptionId], properties }, '0']], using),
'0',
);
if (!current) return;
let expires = current.expires ?? null;
const remainingMs = Date.parse(expires ?? '') - Date.now();
if (!(remainingMs > SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000)) {
const res = await client.request(
[
['PushSubscription/set', { update: { [subscriptionId]: { expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS) } } }, '0'],
// The update's answer doesn't say what the server settled on.
['PushSubscription/get', { ids: [subscriptionId], properties }, '1'],
],
using,
);
expires = ownIn(res, '1')?.expires ?? expires;
}
if (expires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), expires);
let jmapAccountId: string | null = null;
try {
jmapAccountId = client.accountId;
} catch {
jmapAccountId = null;
}
await shareAccountWithExtension(accountId, jmapAccountId, client.currentSession?.apiUrl, client.username);
}
/**
* Register an account's device client id with the relay again, with the
* device token or endpoint this device has now. Never asks for anything: no
* permission prompt, no new UnifiedPush registration.
*/
async function registerDeviceWithRelay(relayBaseUrl: string, deviceClientId: string, accountLabel?: string): Promise<void> {
const transport = await getEffectivePushTransport();
if (transport === 'apns') {
const token = (await Notifications.getDevicePushTokenAsync()).data;
if (typeof token !== 'string' || !token) return;
await registerWithRelayApns({ relayBaseUrl, subscriptionId: deviceClientId, deviceToken: token, accountLabel });
} else if (transport === 'unifiedpush') {
const endpoint = await getUnifiedPushEndpoint();
if (!endpoint) return;
await registerWithRelayUnifiedPush({ relayBaseUrl, subscriptionId: deviceClientId, endpoint, accountLabel });
} else {
const token = await getFcmToken();
if (!token) return;
await registerWithRelay({ relayBaseUrl, subscriptionId: deviceClientId, fcmToken: token, accountLabel });
}
}
export interface PushAccountState {
// A subscription is recorded for the account on this device.
enabled: boolean;
// The user turned push off for it here (see disablePushForAccount).
optedOut: boolean;
}
/** What this device knows about each account's push, for the settings. */
export async function readPushAccountStates(accountIds: string[]): Promise<Record<string, PushAccountState>> {
await migrateLegacyPushKeys();
const out: Record<string, PushAccountState> = {};
for (const accountId of accountIds) {
out[accountId] = {
enabled: (await AsyncStorage.getItem(subscriptionIdKey(accountId))) !== null,
optedOut: (await AsyncStorage.getItem(optedOutKey(accountId))) !== null,
};
}
return out;
}
/**
* 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 expires server-side instead (Stalwart keeps
// one for at most seven days without a refresh).
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 === 'ios') {
const response = await Notifications.getLastNotificationResponseAsync().catch(() => null);
if (!response || response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return null;
const handledKey = 'push:lastHandledResponse:v1';
const id = response.notification.request.identifier;
// The last response survives app restarts; open it once.
if ((await AsyncStorage.getItem(handledKey)) === id) return null;
const payload = await iosTapPayload(response.notification.request.content.data);
if (payload) await AsyncStorage.setItem(handledKey, id);
return payload;
}
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 === 'ios') {
const sub = Notifications.addNotificationResponseReceivedListener((response) => {
if (response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return;
const id = response.notification.request.identifier;
void iosTapPayload(response.notification.request.content.data).then((payload) => {
if (!payload) return;
void AsyncStorage.setItem('push:lastHandledResponse:v1', id);
listener(payload);
});
});
return () => sub.remove();
}
if (Platform.OS !== 'android') return () => undefined;
const emitter = new NativeEventEmitter(NativeModules.BulwarkFcm);
const sub = emitter.addListener('fcm:notificationTap', listener);
return () => sub.remove();
}
/**
* A tapped iOS mail notification as the app opens it. The relay puts the
* JMAP account and message ids into the payload's `body`, which
* expo-notifications hands over as `data`; the notification extension adds
* the message (and thread) it showed. Calendar reminders and other local
* notifications carry no `kind` and are left to their own handlers.
*/
export async function iosTapPayload(data: unknown): Promise<NotificationTapPayload | null> {
if (!data || typeof data !== 'object') return null;
const d = data as Record<string, unknown>;
if (d.kind !== 'jmap-email-push' && d.kind !== 'jmap-state-change') return null;
const jmapAccountId = typeof d.accountId === 'string' ? d.accountId : '';
const ids = Array.isArray(d.emailIds) ? d.emailIds.filter((v): v is string => typeof v === 'string') : [];
const emailId = typeof d.emailId === 'string' && d.emailId ? d.emailId : ids[0];
if (!emailId) return null;
// The local account the subscription belongs to: the one whose push
// setup recorded this JMAP account id. Ids repeat across servers: with
// more than one such login the relay's label (the username) picks one, and
// when it can't, the app just opens rather than show another login's
// message on a guess. A single login has to fit the label too: the push
// may be for another login's group account, whose id comes from the same
// short pool.
const byLocal = await readPushJmapAccountIds();
let candidates = Object.keys(byLocal).filter((local) => byLocal[local] === jmapAccountId);
const label = typeof d.accountLabel === 'string' ? d.accountLabel : '';
if (candidates.length > 1 || (label && candidates.length === 1)) {
candidates = label ? candidates.filter((local) => accountMatchesPushLabel(local, label)) : [];
if (candidates.length !== 1) return null;
}
const accountId = candidates[0];
return {
emailId,
threadId: typeof d.threadId === 'string' ? d.threadId : '',
accountId,
jmapAccountId: jmapAccountId || undefined,
};
}