Browsers subscribed to Email changes, so every read or move on any client arrived as a push the worker could only show as "New mail". They now subscribe to EmailDelivery, which changes only on delivery; Stalwart sends a delivery to a subscription with an emailPush filter as an EmailPush alone. The payload now names id and threadId, which Stalwart sends only when asked, so notifications carry their actions and open the message. The worker stays quiet while a focused window is open, and the page leaves notifications to the worker where push is on. Every renewal registered a new subscription, on the belief that a repeated deviceClientId replaces the old one. Stalwart keeps both and allows fifteen per account, which filled up. A browser now extends its subscription, clears its own duplicates, replaces them only when its endpoint changed, and on overQuota makes room among other browsers' subscriptions. The server names its subscriptions by installation and removes what its previous process registered, and extends rather than re-creates. Checked live on 0.16.22; the mock now keeps duplicates, enforces the limit and accepts an expiry update. Fixes #375.
388 lines
16 KiB
TypeScript
388 lines
16 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.
|
|
*
|
|
* `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<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")) },
|
|
/*
|
|
* 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<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],
|
|
);
|
|
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<void> {
|
|
const expires = new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString().replace(/\.\d+Z$/, "Z");
|
|
const res = await client.call<SetResponse<JmapPushSubscription>>("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<void> {
|
|
if (!ids.length) return;
|
|
await client.call<SetResponse<JmapPushSubscription>>("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<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 {
|
|
await destroySubscriptions(mySubscriptions(await listSubscriptions(), mine).map((s) => s.id));
|
|
} catch {
|
|
/* signing out must not fail over this */
|
|
}
|
|
rememberEndpoint(null);
|
|
setPushEnabledHere(false);
|
|
}
|