/** * Web Push: notifications that arrive when ihasmail is not open. * * The existing EventSource channel only lives as long as a tab does, so * "desktop notifications" have really meant "while you are looking". Stalwart * 0.16 signs Web Push with VAPID (RFC 9749) and can carry the message itself in * the payload (draft-ietf-jmap-emailpush), so the browser's own push service * delivers a useful notification with ihasmail closed. * * Nothing in this path touches ihasmail's server. Stalwart talks to the push * service directly; the only thing proxied is the JMAP call that registers the * subscription. That is deliberate — it is why this needs no relay, no extra * service to run, and no third party beyond the browser vendor's push endpoint * that Web Push requires of everyone. * * Verified against the live 0.16.19 before this was written: the server * publishes a real `applicationServerKey`, and `PushSubscription/get` answers a * normal user rather than refusing them. */ import { CAP, client } from "@/jmap/client"; import type { GetResponse, Id, SetResponse } from "@/jmap/types"; import { isDeviceTrusted } from "@/lib/storage"; export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid"; export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush"; /** * Which Email properties to put in the payload, best first. * * `id` and `threadId` have to be asked for: Stalwart sends only what is named * (0.16.22 source). Without them a notification could not be tagged by * message, carried no Archive or Mark-read button, and opened the inbox rather * than the message. */ const PAYLOAD_PROPS = ["id", "threadId", "from", "subject", "preview", "receivedAt"]; export interface JmapPushSubscription { id: Id; deviceClientId: string; /** Write-only: Stalwart never returns it, so a subscription cannot be matched by endpoint. */ url?: string; expires: string | null; verificationCode?: string | null; } /** A `PushSubscription/set` refusal, with the server's type kept for deciding what to do. */ export class PushSetError extends Error { constructor( readonly type: string, message: string, ) { super(message); this.name = "PushSetError"; } } /** The VAPID key this server signs with, or null if it does not do Web Push. */ export function applicationServerKey(): string | null { const cap = client.session?.capabilities?.[VAPID_CAP] as { applicationServerKey?: string } | undefined; return typeof cap?.applicationServerKey === "string" ? cap.applicationServerKey : null; } /** Whether the payload can carry the message, rather than only "something changed". */ export function supportsEmailPush(): boolean { return Boolean(client.session?.capabilities && EMAILPUSH_CAP in client.session.capabilities); } /** Whether this browser and this server can do Web Push at all. */ export function webPushAvailable(): boolean { return ( typeof navigator !== "undefined" && "serviceWorker" in navigator && typeof window !== "undefined" && "PushManager" in window && applicationServerKey() !== null ); } /** * The VAPID key as the Push API wants it. * * It arrives base64url and unpadded; `atob` needs standard base64 with padding. * Getting this wrong fails at subscribe() with an opaque error, which is the * sort of thing worth doing in one place with a name. */ export function decodeApplicationServerKey(key: string): ArrayBuffer { const padded = key.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (key.length % 4)) % 4); const raw = atob(padded); // An ArrayBuffer rather than a Uint8Array: TypeScript 5.7 types the latter // over ArrayBufferLike, which no longer satisfies BufferSource, and // subscribe() wants a BufferSource. 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 buffer; } /** * Base64url, unpadded — the form the W3C Push API produces for its keys. * * Stalwart 0.16 had to be fixed to accept unpadded keys, so this deliberately * does not pad: sending what the browser gave us is the case the server now * handles, and re-padding would be inventing a shape nobody tested. */ export function encodeKey(buffer: ArrayBuffer | null): string { if (!buffer) return ""; const bytes = new Uint8Array(buffer); let binary = ""; for (const b of bytes) binary += String.fromCharCode(b); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } /** A stable id for this browser, so a re-subscribe replaces rather than piles up. */ export function deviceClientId(): string { const KEY = "ihasmail:pushDeviceId"; // An untrusted device gets a per-session id instead of a stored one. It is // the same trade private mode already makes below: re-subscribing will not // reuse it, which costs nothing when push is refused there anyway. if (!isDeviceTrusted()) return `ihasmail-${crypto.randomUUID()}`; try { const existing = localStorage.getItem(KEY); if (existing) return existing; const made = `ihasmail-${crypto.randomUUID()}`; localStorage.setItem(KEY, made); return made; } catch { // Private mode: a per-session id still works, it just will not be reused. return `ihasmail-${Math.random().toString(36).slice(2)}`; } } /** * What to send Stalwart for a browser subscription. * * `inboxId` is the Inbox's mailbox id. It is a parameter rather than something * looked up here because an `inMailbox` condition needs a real id: the first * version of this passed `null`, meaning "the inbox" in the author's head and * nothing at all to the server, which answered "Invalid filter" and refused the * whole subscription. Without an id the filter simply leaves `inMailbox` out * and notifies more widely, which is a worse default but a working one. */ export function subscriptionPayload(sub: PushSubscription, accountId: Id | null, inboxId: Id | null = null): Record { const json = sub.toJSON(); const body: Record = { deviceClientId: deviceClientId(), url: sub.endpoint, keys: { p256dh: json.keys?.p256dh ?? encodeKey(sub.getKey("p256dh")), auth: json.keys?.auth ?? encodeKey(sub.getKey("auth")) }, /* * New mail, and nothing else. * * `EmailDelivery` changes only when a message is delivered; `Email` changes * on every read, flag and move, from any client, and each of those arrived * here as a push the worker could only show as "New mail" (#375). With an * `emailPush` filter, Stalwart sends a delivery as an EmailPush alone; a * server without emailpush turns it into a StateChange naming * `EmailDelivery`, which is then a true "New mail". An empty or null list * is not "none": Stalwart takes it as every type there is (checked live on * 0.16.22, 2026-09-16). */ types: ["EmailDelivery"], }; if (accountId && supportsEmailPush()) { body.emailPush = { [accountId]: { // Only mail that actually lands in the inbox. Filtering here rather // than in the service worker means spam never leaves the server. // Unread mail only, and only in the Inbox when we know which it is. // Filtering here rather than in the service worker means spam and // filed mail never leave the server at all. filter: { ...(inboxId ? { inMailbox: inboxId } : {}), notKeyword: "$seen" }, properties: PAYLOAD_PROPS, urgency: "normal", }, }; } return body; } /** * Whether push was switched on *in this browser*. * * Device-local on purpose. A subscription is a browser and an endpoint, not an * account: turning it on for a phone says nothing about the desktop, and the * account-wide settings file is the wrong place to record it. It is also not in * `KEEP_ON_SIGN_OUT`, so signing out forgets it, which matches sign-out already * destroying the subscription itself. */ const ENABLED_KEY = "ihasmail:pushEnabled"; export function pushEnabledHere(): boolean { if (!isDeviceTrusted()) return false; try { return localStorage.getItem(ENABLED_KEY) === "1"; } catch { return false; } } export function setPushEnabledHere(on: boolean): void { try { if (on) localStorage.setItem(ENABLED_KEY, "1"); else localStorage.removeItem(ENABLED_KEY); } catch { /* private mode: push will not survive the session there anyway */ } } /** * How close to expiry a subscription is re-registered rather than left alone. * * Two days against a ceiling of seven, so an app opened even once over a * weekend keeps its notifications. Renewing is a single idempotent call, so * being early costs almost nothing and being late costs everything. */ export const RENEW_WITHIN_MS = 2 * 24 * 60 * 60 * 1000; /** * This browser's registered subscriptions, the one with the most time left * first. * * Plural because Stalwart keeps every create: a second subscription with the * same `deviceClientId` sits beside the first rather than replacing it * (checked live on 0.16.22, 2026-09-16), so an account holds as many as were * ever registered until each one expires. */ export function mySubscriptions(subs: JmapPushSubscription[], deviceId: string): JmapPushSubscription[] { const left = (s: JmapPushSubscription) => (s.expires ? Date.parse(s.expires) || 0 : Number.MAX_SAFE_INTEGER); return subs.filter((s) => s.deviceClientId === deviceId).sort((a, b) => left(b) - left(a)); } /** This browser's registered subscription, out of everything the account has. */ export function findSubscription(subs: JmapPushSubscription[], deviceId: string): JmapPushSubscription | null { return mySubscriptions(subs, deviceId)[0] ?? null; } /** * Whether a subscription was registered by a browser running ihasmail, rather * than by the ihasmail server (`ihasmail-proxy-`, or the older eight-character * form) or by another client altogether. */ export function isBrowserSubscription(s: JmapPushSubscription): boolean { return /^ihasmail-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s.deviceClientId); } /** * Which subscriptions to let go of when the account is at its limit. * * Stalwart allows fifteen per account and refuses the sixteenth with * `overQuota` (checked live on 0.16.22, 2026-09-16). Only browser * subscriptions are candidates, never this browser's and never the server's: * one that never verified first, then the one closest to expiring. A device * that loses its subscription this way registers again the next time the app * is opened there, because it no longer finds its own. */ export function roomToMake(subs: JmapPushSubscription[], deviceId: string, count = 1): Id[] { const expiry = (s: JmapPushSubscription) => (s.expires ? Date.parse(s.expires) || 0 : Number.MAX_SAFE_INTEGER); return subs .filter((s) => s.deviceClientId !== deviceId && isBrowserSubscription(s)) .sort((a, b) => Number(Boolean(a.verificationCode)) - Number(Boolean(b.verificationCode)) || expiry(a) - expiry(b)) .slice(0, count) .map((s) => s.id); } /** * Whether this browser's subscription needs registering again. * * A JMAP push subscription expires -- seven days is the ceiling -- and it is * the client's job to re-register before it does. Nothing did: `enableWebPush` * was reachable only from the Settings switch, so the * first version of this quietly stopped delivering within a week of being * turned on, and stayed off until somebody thought to toggle it. On a phone, * where the app is opened for a minute at a time and Settings almost never, * that is indistinguishable from the feature not working. * * An expiry that will not parse counts as needing renewal. It should never * happen; if it does, one extra write is the cheaper way to be wrong. */ export function needsRenewal(subs: JmapPushSubscription[], deviceId: string, now: number = Date.now()): boolean { const mine = findSubscription(subs, deviceId); if (!mine) return true; // No expiry: the server is not going to take it away, so leave it alone. if (!mine.expires) return false; const at = Date.parse(mine.expires); if (Number.isNaN(at)) return true; return at - now <= RENEW_WITHIN_MS; } export async function listSubscriptions(): Promise { const res = await client.call>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]); return res.list; } export async function createSubscription(body: Record): Promise { const res = await client.call>( "PushSubscription/set", { create: { s: body } }, [CAP.core, VAPID_CAP, EMAILPUSH_CAP], ); const refused = res.notCreated?.s; if (refused) throw new PushSetError(String(refused.type), String(refused.description ?? refused.type)); return (res.created?.s as { id?: Id } | undefined)?.id ?? null; } /** * Give a registered subscription more time, rather than registering another. * * Seven days is JMAP's ceiling and what Stalwart grants a new one; the server * may shorten what is asked for, and whatever it keeps is what counts. */ export async function extendSubscription(id: Id, now: number = Date.now()): Promise { const expires = new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString().replace(/\.\d+Z$/, "Z"); const res = await client.call>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]); const err = res.notUpdated?.[id]; if (err) throw new PushSetError(String(err.type), String(err.description ?? err.type)); } export async function destroySubscriptions(ids: Id[]): Promise { if (!ids.length) return; await client.call>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]); } /** * The push endpoint this browser last registered with the server. * * The server never returns a subscription's URL, so this is the only way to * tell a subscription that still points at this browser's endpoint from one * made for an endpoint the browser has since replaced. */ const ENDPOINT_KEY = "ihasmail:pushEndpoint"; export function registeredEndpoint(): string | null { try { return localStorage.getItem(ENDPOINT_KEY); } catch { return null; } } export function rememberEndpoint(endpoint: string | null): void { try { if (endpoint) localStorage.setItem(ENDPOINT_KEY, endpoint); else localStorage.removeItem(ENDPOINT_KEY); } catch { /* private mode: every start is then a fresh registration, which still works */ } } /** * Hand back the code the server pushed. * * A JMAP push subscription delivers nothing until this round-trip completes — * the server sends a code over the channel to prove it reaches this client, and * the client echoes it. A subscription left unverified looks registered and is * silent, which is the confusing failure worth being explicit about. */ export async function verifySubscription(id: Id, verificationCode: string): Promise { const res = await client.call>( "PushSubscription/set", { update: { [id]: { verificationCode } } }, [CAP.core, VAPID_CAP], ); const err = res.notUpdated?.[id]; if (err) throw new Error(String(err.description ?? err.type)); } export async function destroySubscription(id: Id): Promise { await client.call>("PushSubscription/set", { destroy: [id] }, [CAP.core, VAPID_CAP]); } /** Remove every subscription this browser registered. Used when signing out. */ export async function unsubscribeThisDevice(): Promise { const mine = deviceClientId(); try { const reg = await navigator.serviceWorker?.getRegistration(); const sub = await reg?.pushManager.getSubscription(); await sub?.unsubscribe(); } catch { /* the browser end is gone or was never there; still clear the server end */ } try { await destroySubscriptions(mySubscriptions(await listSubscriptions(), mine).map((s) => s.id)); } catch { /* signing out must not fail over this */ } rememberEndpoint(null); setPushEnabledHere(false); }