/** * 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"; 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. */ const PAYLOAD_PROPS = ["from", "subject", "preview", "receivedAt"]; export interface JmapPushSubscription { id: Id; deviceClientId: string; url: string; expires: string | null; verificationCode?: string | null; } /** 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"; 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")) }, // StateChange notifications are not wanted: the app already has EventSource // while it is open, and this channel exists for when it is not. types: ["Email"], }; 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; } 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], ); if (res.notCreated?.s) throw new Error(String(res.notCreated.s.description ?? res.notCreated.s.type)); return (res.created?.s as { id?: Id } | undefined)?.id ?? null; } /** * 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 { const subs = await listSubscriptions(); for (const s of subs) if (s.deviceClientId === mine) await destroySubscription(s.id); } catch { /* signing out must not fail over this */ } }