Notifications that arrive when ihasmail is closed
ihasmail's notifications came from EventSource, which lives exactly as
long as a tab does -- so "desktop notifications" has always quietly
meant "while you are looking". That switch is now labelled as much, and
a second one does the thing people assumed the first one did.
Stalwart 0.16 signs Web Push with VAPID (RFC 9749) and can put the
message itself in the payload (draft-ietf-jmap-emailpush). The server
pushes straight to the browser's own push service: ihasmail's server is
not in the delivery path, there is no relay to run, and nothing beyond
the browser vendor's endpoint that Web Push requires of everyone.
Checked against the live 0.16.19 before any of this was written, because
an advertised capability is not a configured one:
- the session publishes a real applicationServerKey, so no key
generation or server configuration is needed
- PushSubscription/get answers an ordinary user rather than refusing
- emailpush is advertised, and its draft defines a filter, an ordered
properties list and an urgency -- so the payload can carry sender and
subject, and the server drops properties from the end when it will
not fit rather than failing the notification
Three things this gets right that are easy to get wrong:
- The verification handshake. A JMAP subscription delivers nothing
until the client echoes back a code the server pushed, and the
service worker cannot answer it -- no credentials in that context.
It forwards the code to a tab, or leaves it in the cache when no tab
was open to forward it to.
- Key encoding. The W3C Push API produces unpadded base64url and
Stalwart 0.16 was fixed to accept exactly that, so nothing here pads
on the way out. The VAPID key needs padding on the way *in* for
atob; getting that backwards fails at subscribe() with an opaque
error, so it lives in one named function with tests.
- Sign-out. A subscription belongs to the account, not the session.
Without tearing it down, a shared machine keeps notifying for a
mailbox nobody is signed into -- which is somebody else's mail.
The mock models the JMAP half, including refusing padded keys and
non-https endpoints, and creating subscriptions *unverified*. Delivery
cannot be mocked -- it runs through the browser vendor's real push
service -- but a mock that marked a subscription verified on creation
would let a client ship without the handshake, and the symptom in
production is "registered, and silent".
Not verified end to end: an actual notification arriving. That needs a
real browser, a real push service and real delivery, so it is live
testing or nothing.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 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. */
|
||||
export function subscriptionPayload(sub: PushSubscription, accountId: Id | 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.
|
||||
filter: { inMailbox: null, notKeyword: "$seen" },
|
||||
properties: PAYLOAD_PROPS,
|
||||
urgency: "normal",
|
||||
},
|
||||
};
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
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 */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user