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