mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
1224 lines
52 KiB
TypeScript
1224 lines
52 KiB
TypeScript
// Browser-side Web Push setup. Mirrors the React Native flow in
|
|
// repos/react-native/src/lib/push-notifications.ts so the relay sees the same
|
|
// shape from both clients - the only differences are which native API
|
|
// produces the push token (PushManager.subscribe here, FCM there) and which
|
|
// register endpoint we hit on the relay.
|
|
|
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
|
import type { AccountInfo, EmailPushConfig, Mailbox } from '@/lib/jmap/types';
|
|
import { isJmapSetError, jmapErrorType } from '@/lib/jmap/jmap-error';
|
|
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
|
|
import { isBuiltInHost, relayAllowed } from '@/lib/outgoing-hosts';
|
|
import { noThirdPartyTraffic } from '@/lib/third-party-mode';
|
|
import { IS_LITE } from '@/lib/lite';
|
|
import {
|
|
EMPTY_NOTIFICATION_MUTES,
|
|
isAccountMuted,
|
|
isMailboxMuted,
|
|
mailboxOverridesForAccount,
|
|
type NotificationMutes,
|
|
} from '@/lib/notification-mutes';
|
|
|
|
// Per-account keys: a single browser may be signed in to multiple accounts,
|
|
// each with its own JMAP PushSubscription and its own relay record. Scoping
|
|
// the deviceClientId per account is what makes per-account notifications work
|
|
// at all - the relay keys subscriptions on subscriptionId (= deviceClientId),
|
|
// so a globally-shared key meant re-registering account B overwrote A.
|
|
const DEVICE_CLIENT_ID_PREFIX = 'bulwark.push.deviceClientId.v1.';
|
|
const SUBSCRIPTION_ID_PREFIX = 'bulwark.push.subscriptionId.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
|
|
// provoked again on every page load. Re-register and disable clear it.
|
|
const EMAIL_PUSH_REFUSED_PREFIX = 'bulwark.push.emailPushRefused.v1.';
|
|
|
|
function deviceClientIdKey(accountId: string): string {
|
|
return DEVICE_CLIENT_ID_PREFIX + accountId;
|
|
}
|
|
|
|
function subscriptionIdKey(accountId: string): string {
|
|
return SUBSCRIPTION_ID_PREFIX + accountId;
|
|
}
|
|
|
|
/**
|
|
* The relay base a registration was made with. The server's PushSubscription
|
|
* points at it, so a later change of relay (the admin's list, or the built-in
|
|
* one going away with no third-party traffic, #903) needs a new record.
|
|
*/
|
|
const RELAY_BASE_PREFIX = 'bulwark.push.relayBase.v1.';
|
|
function relayBaseKey(accountId: string): string {
|
|
return RELAY_BASE_PREFIX + accountId;
|
|
}
|
|
|
|
function emailPushRefusedKey(accountId: string): string {
|
|
return EMAIL_PUSH_REFUSED_PREFIX + accountId;
|
|
}
|
|
|
|
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
|
|
const SW_SCOPE = `${BASE_PATH}/`;
|
|
const SW_URL = `${BASE_PATH}/sw.js`;
|
|
|
|
// Re-exported for callers that already import the relay default from here.
|
|
// The relay list itself lives in lib/push-relays.ts.
|
|
export { DEFAULT_RELAY_BASE_URL };
|
|
|
|
// Match the mobile app's lifetime hint. The JMAP server may clamp this down.
|
|
const SUBSCRIPTION_EXPIRES_DAYS = 90;
|
|
const SUBSCRIPTION_REFRESH_THRESHOLD_DAYS = 7;
|
|
|
|
// 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 a separate StateChange channel and is unaffected.
|
|
const PUSH_TYPES = ['EmailDelivery'] as const;
|
|
|
|
// 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 SW dedupes on them.
|
|
const EMAIL_PUSH_PROPERTIES = ['id', 'threadId'];
|
|
|
|
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(client: IJMAPClient): boolean {
|
|
try {
|
|
return EMAIL_PUSH_CAPABILITY in (client.getCapabilities() ?? {});
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The delivery filter we want on every account the subscription fans out to.
|
|
*
|
|
* Default mode: 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.
|
|
*
|
|
* Inbox-only mode: same `$junk` keyword guard, but the mailbox condition
|
|
* requires the message to be IN the account's Inbox rather than merely NOT
|
|
* in Junk - mail a Sieve rule files into any other folder stays silent. A
|
|
* group account with no Inbox we can see gets a filter that never matches:
|
|
* leaving it out of the map would make the server fall back to unfiltered
|
|
* pushes for that account.
|
|
*
|
|
* 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
|
|
* enableWebPush narrows the map when the server refuses it.
|
|
*
|
|
* Muted accounts and folders (#1169) are cut out of the same filter. In the
|
|
* default mode 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. In inbox-only mode a muted Inbox silences the account.
|
|
*/
|
|
export async function buildEmailPushConfig(
|
|
client: IJMAPClient,
|
|
inboxOnly: boolean,
|
|
mutes: NotificationMutes = EMPTY_NOTIFICATION_MUTES,
|
|
): Promise<Record<string, EmailPushConfig>> {
|
|
const primary = client.getAccountId();
|
|
// Every such account that has any mailbox gets a filter entry - a group
|
|
// account without a Junk-role mailbox still needs one so its subscription
|
|
// isn't left unfiltered.
|
|
const accountIds = new Set<string>([primary]);
|
|
const junkByAccount = new Map<string, string[]>();
|
|
const inboxByAccount = new Map<string, string>();
|
|
const mailboxIdsByAccount = new Map<string, Set<string>>();
|
|
// In inbox-only mode a swallowed fetch failure would masquerade as "no Inbox
|
|
// mailbox" below, and with mutes it would drop them all and unmute
|
|
// everything, so let the real error surface; otherwise the default mode can
|
|
// still degrade to keyword-only filtering on a transient miss.
|
|
const hasMutes = mutes.accounts.length > 0 || Object.keys(mutes.mailboxes).length > 0;
|
|
const mailboxes = inboxOnly || hasMutes
|
|
? await client.getAllMailboxes()
|
|
: await client.getAllMailboxes().catch(() => [] as Mailbox[]);
|
|
for (const m of mailboxes) {
|
|
const accountId = m.accountId || primary;
|
|
if (accountId !== primary && !client.hasAccountCapability(EMAIL_PUSH_CAPABILITY, accountId)) continue;
|
|
accountIds.add(accountId);
|
|
// 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);
|
|
if (m.role === 'junk') {
|
|
const junk = junkByAccount.get(accountId) ?? [];
|
|
junk.push(serverId);
|
|
junkByAccount.set(accountId, junk);
|
|
}
|
|
if (m.role === 'inbox') inboxByAccount.set(accountId, serverId);
|
|
}
|
|
|
|
const config: Record<string, EmailPushConfig> = {};
|
|
for (const accountId of accountIds) {
|
|
const junkIds = junkByAccount.get(accountId) ?? [];
|
|
// 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 primary account always has an Inbox; missing it means its
|
|
// Mailbox/get failed, and muting it would be worse than failing loudly.
|
|
if (!inboxId && accountId === primary) {
|
|
throw new Error(`No Inbox mailbox 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));
|
|
}
|
|
|
|
function sameEmailPush(
|
|
a: Record<string, EmailPushConfig> | null | undefined,
|
|
b: Record<string, EmailPushConfig>,
|
|
): boolean {
|
|
if (!a) return false;
|
|
return normalizeEmailPush(a) === normalizeEmailPush(b);
|
|
}
|
|
|
|
// 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 client has to work it out.
|
|
*/
|
|
function emailPushCapabilityIsExact(client: IJMAPClient): boolean {
|
|
return client.hasAccountCapability(PRINCIPALS_OWNER_CAPABILITY, client.getAccountId());
|
|
}
|
|
|
|
// 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. A user
|
|
// without calendars, contacts or files has none of the flags, so their groups
|
|
// don't look owned either; ownGroupAccounts finds those on 0.16.
|
|
const OWNERSHIP_FLAGS: ReadonlyArray<readonly [string, string]> = [
|
|
['urn:ietf:params:jmap:calendars', 'mayCreateCalendar'],
|
|
['urn:ietf:params:jmap:contacts', 'mayCreateAddressBook'],
|
|
['urn:ietf:params:jmap:filenode', 'mayCreateTopLevelFileNode'],
|
|
];
|
|
|
|
function looksOwned(info: AccountInfo | null): 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;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Which of `accountIds` are groups the user belongs to, as far as Stalwart
|
|
* 0.16 shows it: it gives a group's members an identity for each of the
|
|
* group's addresses and names the group's account after its address. An
|
|
* account shared by ACL has neither. A group whose identity the member
|
|
* deleted goes unrecognised.
|
|
*/
|
|
function ownGroupAccounts(client: IJMAPClient, accountIds: Iterable<string>, identityEmails: Iterable<string>): Set<string> {
|
|
const addresses = new Set(Array.from(identityEmails, (email) => email.trim().toLowerCase()));
|
|
return new Set(Array.from(accountIds).filter((id) => {
|
|
const info = client.getAccountInfo(id);
|
|
return !!info && !info.isPersonal && addresses.has(info.name.trim().toLowerCase());
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* On 1.0 the map already holds only the accounts the capabilities allow, so
|
|
* the only fallback is the primary account, which is always accepted. 0.16
|
|
* lists both kinds of account as isPersonal:false, isReadOnly:false, so its
|
|
* first fallback keeps the groups the user's identities reveal and the
|
|
* accounts that look owned (see OWNERSHIP_FLAGS), and its last the primary
|
|
* account alone.
|
|
*/
|
|
async function emailPushFallbacks(
|
|
client: IJMAPClient,
|
|
desired: Record<string, EmailPushConfig>,
|
|
): Promise<Record<string, EmailPushConfig>[]> {
|
|
const primary = client.getAccountId();
|
|
const keep = (predicate: (id: string) => boolean) =>
|
|
Object.fromEntries(Object.entries(desired).filter(([id]) => id === primary || predicate(id)));
|
|
const steps = [keep(() => false)];
|
|
if (!emailPushCapabilityIsExact(client)) {
|
|
// Without the identities no group is recognised, as before.
|
|
const identities = await client.getIdentities().catch(() => []);
|
|
const groups = ownGroupAccounts(client, Object.keys(desired), identities.map((identity) => identity.email));
|
|
steps.unshift(keep((id) => groups.has(id) || looksOwned(client.getAccountInfo(id))));
|
|
}
|
|
const out = [desired];
|
|
for (const next of steps) {
|
|
// 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 with a `forbidden` SetError.
|
|
* Anything else - a method-level error, 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>(
|
|
client: IJMAPClient,
|
|
emailPush: Record<string, EmailPushConfig> | null,
|
|
write: (emailPush: Record<string, EmailPushConfig> | null) => Promise<T>,
|
|
): Promise<{ result: T; emailPush: Record<string, EmailPushConfig> | null }> {
|
|
let attempts: (Record<string, EmailPushConfig> | null)[] = [emailPush];
|
|
for (let i = 0; ; i++) {
|
|
try {
|
|
return { result: await write(attempts[i]), emailPush: attempts[i] };
|
|
} catch (err) {
|
|
// Only a refused map is worth retrying with a narrower one.
|
|
if (!emailPush || !isJmapSetError(err) || jmapErrorType(err) !== 'forbidden') throw err;
|
|
// The narrower maps are worked out only once the server refused one.
|
|
if (i === 0) attempts = await emailPushFallbacks(client, emailPush);
|
|
if (i === attempts.length - 1) throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
function readRefusedEmailPushAccounts(accountId: string): string[] {
|
|
try {
|
|
const parsed: unknown = JSON.parse(localStorage.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`. */
|
|
function rememberRefusedEmailPushAccounts(
|
|
accountId: string,
|
|
refusedBefore: string[],
|
|
wanted: Record<string, EmailPushConfig> | null,
|
|
accepted: Record<string, EmailPushConfig> | null,
|
|
): void {
|
|
if (!wanted || !accepted) return;
|
|
const refusedNow = Object.keys(wanted).filter((id) => !(id in accepted));
|
|
if (refusedNow.length === 0) return;
|
|
localStorage.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. 0.16 doesn't say, so there it is the accounts the server refused
|
|
* in the map and the ones that don't look owned, minus the groups the user's
|
|
* identities (`identityEmails`) reveal: a group member without calendars,
|
|
* contacts or files has no create flag to go by.
|
|
*/
|
|
export function mailAccountsWithoutPush(
|
|
client: IJMAPClient,
|
|
accountIds: Iterable<string>,
|
|
identityEmails: Iterable<string> = [],
|
|
): string[] {
|
|
const primary = client.getAccountId();
|
|
const others = Array.from(new Set(accountIds)).filter((id) => id && id !== primary);
|
|
if (others.length === 0) return [];
|
|
if (emailPushCapabilityIsExact(client)) {
|
|
return others.filter((id) => !client.hasAccountCapability(EMAIL_PUSH_CAPABILITY, id));
|
|
}
|
|
const groups = ownGroupAccounts(client, others, identityEmails);
|
|
const refused = readRefusedEmailPushAccounts(primary);
|
|
return others.filter((id) => !groups.has(id) && (refused.includes(id) || !looksOwned(client.getAccountInfo(id))));
|
|
}
|
|
|
|
export interface EnableWebPushParams {
|
|
client: IJMAPClient;
|
|
// Optional - falls back to DEFAULT_RELAY_BASE_URL.
|
|
relayBaseUrl?: string;
|
|
// Free-form label the relay shows in /metrics; never returned in pushes.
|
|
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;
|
|
// Scope OS push notifications to Inbox-only mail. Mirrors settings-store's
|
|
// pushNotifyInboxOnly. Required, not optional: it changes the server-side
|
|
// delivery filter, so every caller must decide explicitly.
|
|
inboxOnly: boolean;
|
|
// Muted accounts and folders, settings-store's notificationMutes (#1169).
|
|
mutes?: NotificationMutes;
|
|
}
|
|
|
|
export interface EnableWebPushResult {
|
|
subscriptionId: string;
|
|
}
|
|
|
|
export class WebPushUnsupportedError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'WebPushUnsupportedError';
|
|
}
|
|
}
|
|
|
|
export function isWebPushSupported(): boolean {
|
|
if (typeof window === 'undefined') return false;
|
|
// No service worker is registered in the static Lite build (see
|
|
// components/service-worker-registration.tsx), so push cannot be enabled.
|
|
if (IS_LITE) return false;
|
|
return (
|
|
'serviceWorker' in navigator &&
|
|
'PushManager' in window &&
|
|
'Notification' in window
|
|
);
|
|
}
|
|
|
|
/** Every relay request is built here, so the built-in relay can't be reached in the no-third-party mode (#903). */
|
|
function buildRelayUrl(base: string, suffix: string): string {
|
|
if (!relayAllowed(base)) throw new Error('The built-in push relay is off: this server does not contact outside services');
|
|
return base.replace(/\/+$/, '') + suffix;
|
|
}
|
|
|
|
function expiresFromNow(days: number): string {
|
|
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
|
|
}
|
|
|
|
function randomDeviceClientId(): string {
|
|
const bytes = new Uint8Array(16);
|
|
crypto.getRandomValues(bytes);
|
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
function getOrCreateDeviceClientId(accountId: string): string {
|
|
const key = deviceClientIdKey(accountId);
|
|
const existing = localStorage.getItem(key);
|
|
if (existing) return existing;
|
|
const next = randomDeviceClientId();
|
|
localStorage.setItem(key, next);
|
|
return next;
|
|
}
|
|
|
|
function anyOtherAccountHasSubscription(accountId: string): boolean {
|
|
const skip = subscriptionIdKey(accountId);
|
|
for (let i = 0; i < localStorage.length; i++) {
|
|
const k = localStorage.key(i);
|
|
if (k && k !== skip && k.startsWith(SUBSCRIPTION_ID_PREFIX)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// PushManager.subscribe wants the VAPID public key as a BufferSource.
|
|
// Returning a Uint8Array<ArrayBuffer> (not the wider ArrayBufferLike that
|
|
// includes SharedArrayBuffer) keeps strict TS happy on lib.dom 2024+.
|
|
function urlBase64ToUint8Array(base64Url: string): Uint8Array<ArrayBuffer> {
|
|
const padding = '='.repeat((4 - (base64Url.length % 4)) % 4);
|
|
const base64 = (base64Url + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
const raw = atob(base64);
|
|
const buffer = new ArrayBuffer(raw.length);
|
|
const out = new Uint8Array(buffer);
|
|
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
|
return out;
|
|
}
|
|
|
|
// A browser that doesn't expose the key it subscribed with counts as a
|
|
// mismatch: we can't prove the subscription belongs to this relay.
|
|
function sameKey(existing: ArrayBuffer | null | undefined, expected: Uint8Array): boolean {
|
|
if (!existing) return false;
|
|
const bytes = new Uint8Array(existing);
|
|
if (bytes.length !== expected.length) return false;
|
|
return bytes.every((byte, i) => byte === expected[i]);
|
|
}
|
|
|
|
function readPushKey(
|
|
sub: PushSubscription,
|
|
name: 'p256dh' | 'auth',
|
|
): string {
|
|
const raw = sub.getKey(name);
|
|
if (!raw) throw new Error(`PushSubscription is missing the ${name} key`);
|
|
// Browsers want application/json over the wire so encode as base64url.
|
|
let binary = '';
|
|
const bytes = new Uint8Array(raw);
|
|
for (let i = 0; i < bytes.byteLength; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
}
|
|
|
|
async function fetchVapidPublicKey(relayBaseUrl: string): Promise<string> {
|
|
const res = await fetch(buildRelayUrl(relayBaseUrl, '/api/push/vapid-public-key'));
|
|
if (!res.ok) {
|
|
if (res.status === 503) {
|
|
throw new Error('The push relay does not have Web Push configured');
|
|
}
|
|
throw new Error(`Failed to fetch VAPID key: ${res.status}`);
|
|
}
|
|
const body = (await res.json()) as { publicKey?: string };
|
|
if (!body.publicKey) throw new Error('Relay returned an empty VAPID key');
|
|
return body.publicKey;
|
|
}
|
|
|
|
async function ensurePermission(): Promise<void> {
|
|
if (Notification.permission === 'granted') return;
|
|
if (Notification.permission === 'denied') {
|
|
throw new Error('Notifications are blocked - allow them in browser settings to continue');
|
|
}
|
|
const result = await Notification.requestPermission();
|
|
if (result !== 'granted') {
|
|
throw new Error('Notification permission was not granted');
|
|
}
|
|
}
|
|
|
|
async function ensureServiceWorker(): Promise<ServiceWorkerRegistration> {
|
|
// The webmail's PWA already registers /sw.js for installability. If it
|
|
// hasn't been picked up yet (e.g. first load), kick it ourselves so the
|
|
// push handler is in place.
|
|
let registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
|
|
if (!registration) {
|
|
registration = await navigator.serviceWorker.register(SW_URL, { scope: SW_SCOPE });
|
|
}
|
|
await navigator.serviceWorker.ready;
|
|
return registration;
|
|
}
|
|
|
|
async function registerWithRelay(params: {
|
|
relayBaseUrl: string;
|
|
subscriptionId: string;
|
|
// Subset of PushSubscriptionJSON we actually serialise. Inlined so eslint's
|
|
// no-undef rule (which doesn't know about DOM type-only globals) is happy.
|
|
subscription: {
|
|
endpoint: string;
|
|
keys: { p256dh: string; auth: string };
|
|
};
|
|
accountLabel?: string;
|
|
}): Promise<void> {
|
|
const { endpoint, keys } = params.subscription;
|
|
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
|
throw new Error('Browser returned an incomplete PushSubscription');
|
|
}
|
|
const res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register/web'), {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
subscriptionId: params.subscriptionId,
|
|
subscription: { endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } },
|
|
accountLabel: params.accountLabel,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`Relay register failed: ${res.status}`);
|
|
}
|
|
}
|
|
|
|
/** 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 lets enableWebPush
|
|
* clear its own abandoned attempts - and dead siblings left by cleared site data
|
|
* that regenerated the deviceClientId - without disturbing another live device
|
|
* or the mobile app 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 one verify
|
|
// window even in the unlucky case.
|
|
const timeoutAt = Date.now() + 75_000;
|
|
let delay = 400;
|
|
while (Date.now() < timeoutAt) {
|
|
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;
|
|
}
|
|
await new Promise((r) => setTimeout(r, delay));
|
|
delay = Math.min(delay * 1.5, 2000);
|
|
}
|
|
throw new Error('Timed out waiting for PushVerification from the JMAP server');
|
|
}
|
|
|
|
// Push the subscription's expiry forward and re-sync `types` / the delivery
|
|
// filter when they drift. Resolves to false when the server refused the
|
|
// update with a SetError (the subscription is gone, or no narrower map is
|
|
// accepted), which the caller answers with a replacement; otherwise to the
|
|
// emailPush map the update installed (null when it left the filter alone).
|
|
// Every other failure - the network, a timeout, rate limiting, a method-level
|
|
// error - says nothing about the subscription and is thrown.
|
|
async function refreshSubscriptionExpires(
|
|
client: IJMAPClient,
|
|
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,
|
|
): Promise<false | { emailPush: Record<string, EmailPushConfig> | null }> {
|
|
const typesNeedUpdate = !sameTypes(sub.types, PUSH_TYPES);
|
|
// Also re-sync the delivery filter: a subscription created before this
|
|
// client learned about emailPush has none, and the Junk mailbox id can
|
|
// change under us.
|
|
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) return { emailPush: null };
|
|
}
|
|
const patch: { expires?: string; types?: string[] } = {
|
|
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
|
|
};
|
|
if (typesNeedUpdate) patch.types = [...PUSH_TYPES];
|
|
try {
|
|
const { emailPush } = await writeWithEmailPush(
|
|
client,
|
|
emailPushNeedsUpdate ? desiredEmailPush : null,
|
|
(filter) => client.updatePushSubscription(sub.id, filter ? { ...patch, emailPush: filter } : patch),
|
|
);
|
|
return { emailPush };
|
|
} catch (err) {
|
|
if (isJmapSetError(err)) return false;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function enableWebPush(
|
|
params: EnableWebPushParams,
|
|
): Promise<EnableWebPushResult> {
|
|
if (!isWebPushSupported()) {
|
|
throw new WebPushUnsupportedError(
|
|
'This browser does not support Web Push. On iOS the site needs to be installed to the home screen.',
|
|
);
|
|
}
|
|
|
|
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
|
|
if (!relayBaseUrl) throw new Error('relayBaseUrl is required');
|
|
|
|
await ensurePermission();
|
|
const registration = await ensureServiceWorker();
|
|
|
|
const vapidPublicKey = await fetchVapidPublicKey(relayBaseUrl);
|
|
|
|
// Reuse an existing browser PushSubscription when possible - resubscribing
|
|
// with the same VAPID key produces the same endpoint, but the call still
|
|
// costs a network round-trip the user can feel.
|
|
//
|
|
// Only reuse it if it was made with THIS relay's key. A subscription is
|
|
// bound to the VAPID key it was created with, so one left over from another
|
|
// relay (or from before this relay rotated its keys) is rejected by the push
|
|
// service on every send - Mozilla answers 401 "VAPID public key mismatch" -
|
|
// while isWebPushEnabled keeps reporting push as on.
|
|
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
|
|
// A stale or broken local record makes Firefox reject the lookup itself with
|
|
// `AbortError: Error retrieving push subscription`, which would abort the
|
|
// whole enable. There is nothing to reuse in that case, so carry on to
|
|
// subscribe() and let its own error surface if the push service is really
|
|
// unreachable.
|
|
let pushSubscription = await registration.pushManager.getSubscription().catch(() => null);
|
|
if (
|
|
pushSubscription
|
|
&& !sameKey(pushSubscription.options?.applicationServerKey, applicationServerKey)
|
|
) {
|
|
// An unsubscribe that fails leaves the old record in place; subscribing
|
|
// with the right key is what matters, so don't abort the enable for it.
|
|
await pushSubscription.unsubscribe().catch(() => undefined);
|
|
pushSubscription = null;
|
|
}
|
|
if (!pushSubscription) {
|
|
pushSubscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey,
|
|
});
|
|
}
|
|
|
|
const accountId = params.client.getAccountId();
|
|
const deviceClientId = getOrCreateDeviceClientId(accountId);
|
|
|
|
await registerWithRelay({
|
|
relayBaseUrl,
|
|
subscriptionId: deviceClientId,
|
|
subscription: {
|
|
endpoint: pushSubscription.endpoint,
|
|
keys: {
|
|
p256dh: readPushKey(pushSubscription, 'p256dh'),
|
|
auth: readPushKey(pushSubscription, 'auth'),
|
|
},
|
|
},
|
|
accountLabel: params.accountLabel,
|
|
});
|
|
|
|
// Reuse the JMAP-side PushSubscription if the server still has it, just
|
|
// refreshing the expiry so it doesn't time out between sessions. With
|
|
// forceRecreate we replace it instead: the account set a Stalwart
|
|
// subscription fans out to is fixed at creation time, so refreshing
|
|
// `expires` carries stale permissions forward and only a new record picks up
|
|
// revoked shared-mailbox access (#841). Either way the old record keeps
|
|
// delivering until its replacement is verified, so a replacement the server
|
|
// refuses never leaves the browser 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 and push
|
|
// would read as off from then on.
|
|
const existingSubs = await params.client.listPushSubscriptions();
|
|
// A forced re-registration re-learns which accounts the server accepts in
|
|
// the emailPush map.
|
|
if (params.forceRecreate) localStorage.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(params.client) ? [] : readRefusedEmailPushAccounts(accountId);
|
|
const emailPush = serverSupportsEmailPush(params.client)
|
|
? withoutAccounts(await buildEmailPushConfig(params.client, params.inboxOnly, params.mutes), refusedBefore)
|
|
: null;
|
|
const subIdKey = subscriptionIdKey(accountId);
|
|
const storedServerId = localStorage.getItem(subIdKey);
|
|
let replacedServerId: string | null = null;
|
|
if (storedServerId) {
|
|
const match = existingSubs.find((s) => s.id === storedServerId);
|
|
if (match) {
|
|
if (!params.forceRecreate) {
|
|
const refreshed = await refreshSubscriptionExpires(params.client, match, emailPush);
|
|
if (refreshed) {
|
|
rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush);
|
|
return { subscriptionId: storedServerId };
|
|
}
|
|
}
|
|
// The server refused the refresh, or the caller asked for a fresh
|
|
// record: create a replacement below and destroy this one once the
|
|
// replacement is verified.
|
|
replacedServerId = storedServerId;
|
|
}
|
|
// When the server no longer has it (expired, or revoked elsewhere), the
|
|
// stored id stays until the replacement is verified below: it is
|
|
// what tells resyncWebPush this account opted in, so dropping it here
|
|
// would end recovery for good after one interrupted attempt.
|
|
}
|
|
|
|
// Reap leftover subscriptions that would otherwise starve the new one's
|
|
// verification. Stalwart emits only one PushVerification per account per
|
|
// ~60s; up to 0.16.13 it picks the first unverified subscription it stores,
|
|
// usually the oldest, so a single stale straggler blocks every fresh attempt
|
|
// - the symptom is the confusing "Timed out waiting for PushVerification"
|
|
// error (0.16.14 and later pick the newest). 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 browser,
|
|
// always safe to reap.
|
|
// - a different deviceClientId: could be another live device or the mobile
|
|
// app 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 params.client.destroyPushSubscription(s.id).catch(() => undefined);
|
|
continue;
|
|
}
|
|
if (await relayReportsDead(relayBaseUrl, s.deviceClientId)) {
|
|
await params.client.destroyPushSubscription(s.id).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
const create = () => writeWithEmailPush(params.client, emailPush, (filter) =>
|
|
params.client.createPushSubscription({
|
|
deviceClientId,
|
|
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
|
|
types: [...PUSH_TYPES],
|
|
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
|
|
...(filter ? { emailPush: filter } : {}),
|
|
}),
|
|
);
|
|
let created: Awaited<ReturnType<typeof create>>;
|
|
try {
|
|
created = await create();
|
|
} catch (err) {
|
|
// Stalwart caps the subscriptions of an account (15 by default) and counts
|
|
// the one being replaced, so at the cap 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.
|
|
if (!params.forceRecreate || !replacedServerId || jmapErrorType(err) !== 'overQuota') throw err;
|
|
await params.client.destroyPushSubscription(replacedServerId);
|
|
localStorage.removeItem(subIdKey);
|
|
replacedServerId = null;
|
|
created = await create();
|
|
}
|
|
const serverAssignedId = created.result;
|
|
rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush);
|
|
|
|
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
|
|
await params.client.verifyPushSubscription(serverAssignedId, verificationCode);
|
|
localStorage.setItem(subIdKey, serverAssignedId);
|
|
localStorage.setItem(relayBaseKey(accountId), relayBaseUrl);
|
|
if (replacedServerId) {
|
|
await params.client.destroyPushSubscription(replacedServerId).catch(() => undefined);
|
|
}
|
|
|
|
return { subscriptionId: serverAssignedId };
|
|
}
|
|
|
|
export interface DisableWebPushParams {
|
|
client: IJMAPClient;
|
|
relayBaseUrl?: string;
|
|
}
|
|
|
|
// Best-effort teardown: clear the JMAP subscription, the relay mapping, and
|
|
// (only when no other accounts still need it) the browser-wide
|
|
// PushSubscription. Any single failure is swallowed so the user always ends
|
|
// up in a "disabled" state locally.
|
|
export async function disableWebPush(params: DisableWebPushParams): Promise<void> {
|
|
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
|
|
const accountId = params.client.getAccountId();
|
|
|
|
const subIdKey = subscriptionIdKey(accountId);
|
|
const devIdKey = deviceClientIdKey(accountId);
|
|
|
|
const storedServerId = localStorage.getItem(subIdKey);
|
|
const deviceClientId = localStorage.getItem(devIdKey);
|
|
|
|
// 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, a failed
|
|
// enable, or site data cleared between sessions can leave a registration this
|
|
// client no longer tracks - and Stalwart keeps fanning StateChanges out to it
|
|
// until it expires, so "disable" has to mean gone (#841).
|
|
const idsToDestroy = new Set<string>();
|
|
if (storedServerId) idsToDestroy.add(storedServerId);
|
|
if (deviceClientId) {
|
|
const existingSubs = await params.client.listPushSubscriptions().catch(() => []);
|
|
for (const s of existingSubs) {
|
|
if (s.deviceClientId === deviceClientId) idsToDestroy.add(s.id);
|
|
}
|
|
}
|
|
for (const id of idsToDestroy) {
|
|
await params.client.destroyPushSubscription(id).catch(() => undefined);
|
|
}
|
|
localStorage.removeItem(subIdKey);
|
|
localStorage.removeItem(relayBaseKey(accountId));
|
|
// Enabling again starts from the full emailPush map.
|
|
localStorage.removeItem(emailPushRefusedKey(accountId));
|
|
|
|
if (deviceClientId && relayBaseUrl && relayAllowed(relayBaseUrl)) {
|
|
await fetch(
|
|
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
|
|
{ method: 'DELETE' },
|
|
).catch(() => undefined);
|
|
}
|
|
// Keep the deviceClientId around so a later re-enable for this account
|
|
// reuses the same relay subscriptionId rather than scattering orphans.
|
|
|
|
// The browser-wide PushSubscription is shared by every account on this
|
|
// origin, so only tear it down if no other account is still using it.
|
|
if (
|
|
!anyOtherAccountHasSubscription(accountId)
|
|
&& typeof navigator !== 'undefined'
|
|
&& 'serviceWorker' in navigator
|
|
) {
|
|
const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
|
|
const sub = await registration?.pushManager.getSubscription();
|
|
if (sub) await sub.unsubscribe().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 browser you're looking at.
|
|
isThisDevice: boolean;
|
|
relayStatus: PushRelayStatus;
|
|
}
|
|
|
|
/**
|
|
* Every push registration the JMAP server holds for this account, annotated
|
|
* with whether it is this browser 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: {
|
|
client: IJMAPClient;
|
|
relayBaseUrl?: string;
|
|
}): Promise<PushDevice[]> {
|
|
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
|
|
const accountId = params.client.getAccountId();
|
|
const thisDeviceClientId = typeof localStorage === 'undefined'
|
|
? null
|
|
: localStorage.getItem(deviceClientIdKey(accountId));
|
|
|
|
const subs = await params.client.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: {
|
|
client: IJMAPClient;
|
|
device: Pick<PushDevice, 'id' | 'deviceClientId' | 'isThisDevice'>;
|
|
relayBaseUrl?: string;
|
|
}): Promise<void> {
|
|
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
|
|
|
|
if (params.device.isThisDevice) {
|
|
await disableWebPush({ client: params.client, relayBaseUrl });
|
|
return;
|
|
}
|
|
|
|
await params.client.destroyPushSubscription(params.device.id);
|
|
if (relayBaseUrl && relayAllowed(relayBaseUrl) && params.device.deviceClientId) {
|
|
await fetch(
|
|
buildRelayUrl(
|
|
relayBaseUrl,
|
|
`/api/push/register/${encodeURIComponent(params.device.deviceClientId)}`,
|
|
),
|
|
{ method: 'DELETE' },
|
|
).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
export async function isWebPushEnabled(accountId: string): Promise<boolean> {
|
|
if (!isWebPushSupported()) return false;
|
|
if (Notification.permission !== 'granted') return false;
|
|
const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
|
|
if (!registration) return false;
|
|
const sub = await registration.pushManager.getSubscription();
|
|
return sub !== null && localStorage.getItem(subscriptionIdKey(accountId)) !== null;
|
|
}
|
|
|
|
// When each account was last re-synced during this page load, and whether
|
|
// that attempt worked. Once a day is enough for the drift between sessions
|
|
// (a client update that changed what we subscribe to, a recreated Junk
|
|
// mailbox), but not only once: Stalwart clamps `expires` to 7 days, so a tab
|
|
// or installed app left open for a week would otherwise let the subscription
|
|
// lapse and push stop silently.
|
|
const RESYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
// After a failed attempt: soon again, but not on every return to the tab.
|
|
const RESYNC_RETRY_MS = 15 * 60 * 1000;
|
|
interface ResyncAttempt {
|
|
at: number;
|
|
ok: boolean;
|
|
}
|
|
const lastResync = new Map<string, ResyncAttempt>();
|
|
// Re-syncs still running, per account. One at a time per login: they share
|
|
// the browser's single PushSubscription, and a parallel re-create would race
|
|
// on it. Also what lets the onboarding prompt wait instead of asking the
|
|
// user to enable push the re-sync is just restoring (#1321).
|
|
const inFlightResync = new Map<string, Promise<boolean>>();
|
|
|
|
export interface ResyncWebPushParams {
|
|
client: IJMAPClient;
|
|
relayBaseUrl?: string;
|
|
accountLabel?: string;
|
|
// Mirrors settings-store's pushNotifyInboxOnly - see EnableWebPushParams.
|
|
inboxOnly: boolean;
|
|
mutes?: NotificationMutes;
|
|
// Skip the once-a-day limit: the filter itself changed (a folder was
|
|
// muted) and has to reach the server now.
|
|
force?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Bring an already-enabled push registration up to date without any user
|
|
* action: refresh its expiry and install/repair the delivery filter. Nothing
|
|
* here can prompt - it requires a saved opt-in and granted permission -
|
|
* and every failure is swallowed because the app must not care whether the
|
|
* background touch-up worked. A failed re-sync leaves the registration and
|
|
* its key as they were. Returns true when a re-sync actually ran.
|
|
*/
|
|
export function resyncWebPush(params: ResyncWebPushParams): Promise<boolean> {
|
|
let accountId: string;
|
|
try {
|
|
accountId = params.client.getAccountId();
|
|
} catch {
|
|
return Promise.resolve(false);
|
|
}
|
|
if (!accountId) return Promise.resolve(false);
|
|
const pending = inFlightResync.get(accountId);
|
|
if (pending) {
|
|
if (!params.force) return pending;
|
|
// The filter changed while a re-sync runs: send it once that one is done.
|
|
return pending.then(() => resyncWebPush(params));
|
|
}
|
|
// Registered before the first await, so a waiter that asks in the same
|
|
// tick already sees this run.
|
|
const run: Promise<boolean> = runResync(accountId, params).finally(() => {
|
|
if (inFlightResync.get(accountId) === run) inFlightResync.delete(accountId);
|
|
});
|
|
inFlightResync.set(accountId, run);
|
|
return run;
|
|
}
|
|
|
|
async function runResync(accountId: string, params: ResyncWebPushParams): Promise<boolean> {
|
|
try {
|
|
// The browser may have lost its endpoint while our saved opt-in and
|
|
// server registration survived. Recreate it through the normal enable
|
|
// flow instead of skipping the account in that state, which is what
|
|
// isWebPushEnabled (it requires a live browser subscription) would do.
|
|
if (!isWebPushSupported() || Notification.permission !== 'granted'
|
|
|| !localStorage.getItem(subscriptionIdKey(accountId))) return false;
|
|
// Registered with the built-in relay before third-party traffic was
|
|
// turned off, and no relay of the organisation's own to move to: drop the
|
|
// server's PushSubscription so the mail server stops pinging that relay
|
|
// (#903). disableWebPush skips the relay itself.
|
|
if (!params.relayBaseUrl && noThirdPartyTraffic()) {
|
|
await disableWebPush({ client: params.client });
|
|
return false;
|
|
}
|
|
// The registration points at another relay than the one to use now (the
|
|
// admin changed the list, or the built-in relay went away with no
|
|
// third-party traffic): make a new server record that points here. A
|
|
// registration from before the relay was remembered counts as the
|
|
// built-in one in that mode.
|
|
const current = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
|
|
const registeredWith = localStorage.getItem(relayBaseKey(accountId));
|
|
const moved = !!current && (
|
|
(registeredWith !== null && registeredWith !== current)
|
|
|| (noThirdPartyTraffic() && (registeredWith === null || isBuiltInHost(registeredWith)) && !isBuiltInHost(current))
|
|
);
|
|
if (!moved && !params.force && !(await resyncDue(accountId))) return false;
|
|
await enableWebPush({
|
|
client: params.client,
|
|
forceRecreate: moved,
|
|
relayBaseUrl: params.relayBaseUrl,
|
|
accountLabel: params.accountLabel,
|
|
inboxOnly: params.inboxOnly,
|
|
mutes: params.mutes,
|
|
});
|
|
lastResync.set(accountId, { at: Date.now(), ok: true });
|
|
return true;
|
|
} catch {
|
|
// A failed attempt must not wait a day for the next one, nor rerun the
|
|
// whole enable on every tab focus.
|
|
lastResync.set(accountId, { at: Date.now(), ok: false });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function resyncDue(accountId: string): Promise<boolean> {
|
|
const last = lastResync.get(accountId);
|
|
if (!last) return true;
|
|
const elapsed = Date.now() - last.at;
|
|
if (!last.ok) return elapsed >= RESYNC_RETRY_MS;
|
|
if (elapsed >= RESYNC_INTERVAL_MS) return true;
|
|
// Inside the daily limit, but the browser may have dropped the subscription
|
|
// since (Safari does when site data is cleared, Firefox when the push
|
|
// service expires it). A tab that stays open would otherwise go without
|
|
// push until it is reloaded. The check is local, so it can run on every
|
|
// return to the tab.
|
|
return browserSubscriptionLost();
|
|
}
|
|
|
|
// Whether the browser no longer holds a push subscription for our service
|
|
// worker. A missing registration counts as lost too: enableWebPush puts it
|
|
// back.
|
|
async function browserSubscriptionLost(): Promise<boolean> {
|
|
const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE).catch(() => null);
|
|
if (!registration) return true;
|
|
const sub = await registration.pushManager.getSubscription().catch(() => null);
|
|
return sub === null;
|
|
}
|
|
|
|
/**
|
|
* Settles once no re-sync is running for the account; at once when none is.
|
|
* isWebPushEnabled says "off" while a re-sync recreates the browser
|
|
* subscription, so a caller that would act on "off" - the onboarding prompt -
|
|
* waits for this first (#1321).
|
|
*/
|
|
export function waitForWebPushResync(accountId: string): Promise<void> {
|
|
const pending = inFlightResync.get(accountId);
|
|
if (!pending) return Promise.resolve();
|
|
// A forced update may have queued up behind this run.
|
|
return pending.then(() => waitForWebPushResync(accountId));
|
|
}
|
|
|
|
// Test hook: forget which accounts were re-synced during this page load.
|
|
export function resetWebPushResyncState(): void {
|
|
lastResync.clear();
|
|
inFlightResync.clear();
|
|
}
|
|
|
|
export interface BulkWebPushTarget {
|
|
accountId: string;
|
|
client: IJMAPClient;
|
|
accountLabel?: string;
|
|
}
|
|
|
|
export interface BulkWebPushResult {
|
|
enabled: string[];
|
|
/** Accounts left untouched, with the reason the first failure gave. */
|
|
failed: Array<{ accountId: string; error: unknown }>;
|
|
}
|
|
|
|
/**
|
|
* Enable push for several accounts against one browser subscription.
|
|
*
|
|
* The browser only ever holds a single PushSubscription: `enableWebPush` reuses
|
|
* it and registers one server-side subscription per account, which is why every
|
|
* account has to be walked separately. Sequential on purpose - the first call
|
|
* is the one that may prompt for permission, and parallel prompts race.
|
|
*
|
|
* A browser-level refusal (unsupported, permission denied or dismissed) applies
|
|
* to every account, so it stops the walk instead of failing them one by one.
|
|
*/
|
|
export async function enableWebPushForAccounts(
|
|
targets: BulkWebPushTarget[],
|
|
// inboxOnly mirrors settings-store's pushNotifyInboxOnly, as for a single account.
|
|
options: { relayBaseUrl?: string; forceRecreate?: boolean; inboxOnly: boolean; mutes?: NotificationMutes },
|
|
): Promise<BulkWebPushResult> {
|
|
const result: BulkWebPushResult = { enabled: [], failed: [] };
|
|
let browserRefusal: unknown;
|
|
for (const target of targets) {
|
|
if (browserRefusal !== undefined) {
|
|
result.failed.push({ accountId: target.accountId, error: browserRefusal });
|
|
continue;
|
|
}
|
|
try {
|
|
await enableWebPush({
|
|
client: target.client,
|
|
relayBaseUrl: options.relayBaseUrl,
|
|
accountLabel: target.accountLabel,
|
|
forceRecreate: options.forceRecreate,
|
|
inboxOnly: options.inboxOnly,
|
|
mutes: options.mutes,
|
|
});
|
|
result.enabled.push(target.accountId);
|
|
} catch (error) {
|
|
if (error instanceof WebPushUnsupportedError || !isWebPushSupported()
|
|
|| Notification.permission !== 'granted') browserRefusal = error;
|
|
result.failed.push({ accountId: target.accountId, error });
|
|
}
|
|
}
|
|
return result;
|
|
}
|