Files
ihasmail/web/src/lib/webpush.ts
T
jcoffey-dev 562cee82ce Renew the push subscription, so it does not lapse in a week
Background notifications were built, verified against a live server, and then
went quiet a few days later on every device that had them. A JMAP push
subscription expires -- seven days is the ceiling -- and re-registering before
it lapses is the client's job. Nothing did: enableWebPush() was reachable only
from the switch in Settings, so the subscription was registered once, expired,
and stayed expired. Nobody reports that as a bug. They report that push does
not really work.

It is renewed on every app start now, which is the only place it can be: the
registration is a JMAP call and the service worker has no session cookie to
make one with. So the guarantee is that push keeps working as long as ihasmail
is opened now and again, and a two-day renewal window against a seven-day
ceiling means once a week is enough. Registering is the same call as turning it
on -- deviceClientId makes a repeat replace rather than accumulate -- so there
is no second path to get wrong.

Two more things in the same area, both of which produce the same silence:

- webPushActive() asked whether the *account* had any subscription, so the
  moment one device had one, every other device showed the switch already on.
  A phone that had never successfully registered, or whose registration had
  since expired, read as on and delivered nothing. It matches on the device now.
- Turning push on reused an existing browser subscription and gave up if there
  was none. A browser drops or rotates one on its own, and there is no tab open
  to hear the pushsubscriptionchange when it does, so that state was permanent.
  Renewal re-subscribes rather than bailing.

Whether this browser has push on is now remembered locally, which is what
renewal keys off. It is per browser rather than per account on purpose: a
subscription is an endpoint and a device, and a phone having push says nothing
about the desktop. It is not kept across sign-out, matching sign-out already
destroying the subscription itself.

The mock is the reason this was invisible in development: it handed back
expires: null, so a client that never renewed worked perfectly against it
forever. It expires a subscription in seven days now, which is what makes
"does this client renew?" a question the mock can answer.

Checked against the mock: a create returns an expiry seven days out that
survives PushSubscription/get and parses, renewing the same deviceClientId
replaces rather than accumulates, and a device with no registration of its own
finds nothing where the old code saw two subscriptions and said yes. What the
live Stalwart sets for expires is not confirmed -- if it sets none, renewal
correctly does nothing and the other two fixes still stand.
2026-08-31 08:03:43 -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);
}