Files
ihasmail/web/src/lib/notify/webpush.ts
T
jcoffey-dev bd6a605d61 Group six more clusters out of web/src/lib
Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.

  lib/mailbox/  archiveDate, emptyFolder, folderMove, labelTree,
                mailboxName, mailboxRoute
  lib/sieve/    sieve, sieveApply, sieveFolders
  lib/input/    keyboard, swipe, touch, listSelection, dropUpload
  lib/notify/   notify, webpush, webpushEnable
  lib/sw/       swCache, swFacts, staleBuild
  lib/text/     html, markdown, text, emlName

FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:

  - appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
    *Files*, where the client keeps signature images and synced settings.
    It stays flat.
  - format holds no formatting of text. It re-exports the date and clock
    formatters, so it belongs with dates/datetime, not with text/.
  - preview is the file viewer deciding what it can show without
    downloading, and source is where to point someone asking for this
    instance's AGPL source. Neither is about text.
  - notify is not Web Push. It is the tab title, the favicon badge and
    the new-mail sound -- in-app notification, which is why it sits with
    webpush rather than under sw/ with the service worker's own concerns.

threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.

No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
2026-09-15 23:17:50 -07:00

272 lines
11 KiB
TypeScript

/**
* 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. */
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";
// 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<string, unknown> {
const json = sub.toJSON();
const body: Record<string, unknown> = {
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;
}
/**
* 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 subscription, out of everything the account has. */
export function findSubscription(subs: JmapPushSubscription[], deviceId: string): JmapPushSubscription | null {
return subs.find((s) => s.deviceClientId === deviceId) ?? null;
}
/**
* 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<JmapPushSubscription[]> {
const res = await client.call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]);
return res.list;
}
export async function createSubscription(body: Record<string, unknown>): Promise<Id | null> {
const res = await client.call<SetResponse<JmapPushSubscription>>(
"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<void> {
const res = await client.call<SetResponse<JmapPushSubscription>>(
"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<void> {
await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: [id] }, [CAP.core, VAPID_CAP]);
}
/** Remove every subscription this browser registered. Used when signing out. */
export async function unsubscribeThisDevice(): Promise<void> {
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 */
}
setPushEnabledHere(false);
}