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.
This commit is contained in:
2026-09-15 23:17:50 -07:00
parent 5cc31037c1
commit bd6a605d61
95 changed files with 104 additions and 104 deletions
@@ -0,0 +1,306 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import {
applicationServerKey,
decodeApplicationServerKey,
encodeKey,
findSubscription,
needsRenewal,
RENEW_WITHIN_MS,
subscriptionPayload,
pushEnabledHere,
setPushEnabledHere,
supportsEmailPush,
unsubscribeThisDevice,
webPushAvailable,
type JmapPushSubscription,
} from "@/lib/notify/webpush";
import { setDeviceTrusted } from "@/lib/storage";
import type { JmapSession } from "@/jmap/types";
/**
* The key encoding is where this breaks silently. `subscribe()` fails with an
* opaque error on a mis-decoded VAPID key, and Stalwart 0.16 had to be fixed to
* accept the *unpadded* base64url the W3C Push API produces — so re-padding on
* the way out would be sending a shape the server has not been tested against.
*
* The real key from the live 0.16.19 is used below rather than a made-up one:
* its length is what exercises the padding arithmetic.
*/
const LIVE_KEY = "BBvig2GPmqohMJJHMzp6bTKviHibYiVCyAY8gdq2fPhS-9YfO9_0TnhMyZ0a0JxTsbCqd3zm1rEiXsXsL3jveJY";
function session(caps: Record<string, unknown>): JmapSession {
return { capabilities: caps, accounts: {}, primaryAccounts: {}, state: "s" } as unknown as JmapSession;
}
afterEach(() => {
client.session = null;
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("the VAPID key", () => {
it("is read from the capability the server publishes", () => {
client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } });
expect(applicationServerKey()).toBe(LIVE_KEY);
});
it("is null when the server does not do Web Push, rather than an empty string", () => {
client.session = session({ "urn:ietf:params:jmap:core": {} });
expect(applicationServerKey()).toBeNull();
});
it("decodes to the 65 bytes of an uncompressed P-256 point", () => {
const buf = decodeApplicationServerKey(LIVE_KEY);
expect(buf.byteLength).toBe(65);
// 0x04 marks an uncompressed EC point; the Push API rejects anything else.
expect(new Uint8Array(buf)[0]).toBe(0x04);
});
it("handles base64url without padding, which is how it arrives", () => {
expect(LIVE_KEY).not.toContain("=");
expect(LIVE_KEY).toMatch(/[-_]/);
expect(() => decodeApplicationServerKey(LIVE_KEY)).not.toThrow();
});
it("returns an ArrayBuffer, which is what subscribe() accepts", () => {
expect(decodeApplicationServerKey(LIVE_KEY)).toBeInstanceOf(ArrayBuffer);
});
});
describe("encoding keys for the server", () => {
it("produces unpadded base64url, the form Stalwart was fixed to accept", () => {
// 5 bytes: a length that would be padded with "===" in standard base64.
const buf = new Uint8Array([1, 2, 3, 4, 5]).buffer;
const out = encodeKey(buf);
expect(out).not.toContain("=");
expect(out).not.toContain("+");
expect(out).not.toContain("/");
});
it("round-trips through the decoder", () => {
const bytes = new Uint8Array([0, 255, 128, 64, 32, 16]);
expect(new Uint8Array(decodeApplicationServerKey(encodeKey(bytes.buffer)))).toEqual(bytes);
});
it("gives an empty string rather than throwing on a missing key", () => {
expect(encodeKey(null)).toBe("");
});
});
describe("what gets registered", () => {
const fakeSub = {
endpoint: "https://push.example/abc",
toJSON: () => ({ keys: { p256dh: "cGRoLWtleQ", auth: "YXV0aA" } }),
getKey: () => null,
} as unknown as PushSubscription;
it("asks for the message itself when the server supports emailpush", () => {
client.session = session({
"urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY },
"urn:ietf:params:jmap:emailpush": {},
});
const body = subscriptionPayload(fakeSub, "a1") as Record<string, any>;
expect(body.url).toBe("https://push.example/abc");
expect(body.keys).toEqual({ p256dh: "cGRoLWtleQ", auth: "YXV0aA" });
expect(body.emailPush.a1.properties).toContain("subject");
expect(body.emailPush.a1.properties).toContain("from");
// Order is priority: the server drops from the end when the payload is
// too large, so the sender must outrank the preview.
const props: string[] = body.emailPush.a1.properties;
expect(props.indexOf("from")).toBeLessThan(props.indexOf("preview"));
});
it("omits emailPush entirely when the server does not support it", () => {
client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } });
expect(supportsEmailPush()).toBe(false);
expect(subscriptionPayload(fakeSub, "a1")).not.toHaveProperty("emailPush");
});
it("omits emailPush when there is no account to scope it to", () => {
client.session = session({
"urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY },
"urn:ietf:params:jmap:emailpush": {},
});
expect(subscriptionPayload(fakeSub, null)).not.toHaveProperty("emailPush");
});
it("subscribes to Email changes only, since EventSource covers an open tab", () => {
client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } });
expect((subscriptionPayload(fakeSub, "a1") as Record<string, unknown>).types).toEqual(["Email"]);
});
});
describe("availability", () => {
it("is false without a push key, however capable the browser", () => {
client.session = session({ "urn:ietf:params:jmap:core": {} });
expect(webPushAvailable()).toBe(false);
});
});
describe("the emailPush filter", () => {
/**
* This is the bug that reached production: `inMailbox: null` read as "the
* inbox" and meant nothing to the server, which answered "Invalid filter"
* and refused the subscription outright. The original tests checked the
* property ordering and never looked at the filter at all.
*/
const fakeSub = {
endpoint: "https://push.example/abc",
toJSON: () => ({ keys: { p256dh: "cGRoLWtleQ", auth: "YXV0aA" } }),
getKey: () => null,
} as unknown as PushSubscription;
const withEmailPush = () => {
client.session = session({
"urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY },
"urn:ietf:params:jmap:emailpush": {},
});
};
it("never sends a condition with a null or undefined value", () => {
withEmailPush();
for (const inbox of ["mb1", null]) {
const body = subscriptionPayload(fakeSub, "a1", inbox) as Record<string, any>;
const filter = body.emailPush.a1.filter as Record<string, unknown>;
for (const [k, v] of Object.entries(filter)) {
expect(v, `${k} was ${String(v)} with inbox=${String(inbox)}`).not.toBeNull();
expect(v, k).not.toBeUndefined();
}
}
});
it("uses the real mailbox id when it knows one", () => {
withEmailPush();
const body = subscriptionPayload(fakeSub, "a1", "mbInbox") as Record<string, any>;
expect(body.emailPush.a1.filter.inMailbox).toBe("mbInbox");
});
it("leaves inMailbox out entirely when it does not, rather than sending null", () => {
withEmailPush();
const filter = (subscriptionPayload(fakeSub, "a1", null) as Record<string, any>).emailPush.a1.filter;
expect(filter).not.toHaveProperty("inMailbox");
// Still narrowed to unread: notifying more widely beats not notifying.
expect(filter.notKeyword).toBe("$seen");
});
});
/**
* Keeping a subscription alive.
*
* The failure this guards against leaves no trace anywhere: the switch says
* background notifications are on, the browser still holds a subscription, and
* the server quietly stopped delivering days ago because the registration
* expired and nothing renewed it. Nobody reports that as a bug — they report
* that push "doesn't really work".
*/
const sub = (deviceClientId: string, expires: string | null): JmapPushSubscription =>
({ id: `i-${deviceClientId}`, deviceClientId, url: "https://push.example/x", expires });
const MINE = "ihasmail-this-browser";
const NOW = Date.parse("2026-09-01T12:00:00Z");
const inDays = (n: number) => new Date(NOW + n * 24 * 60 * 60 * 1000).toISOString();
describe("finding this browser's subscription", () => {
it("matches on the device id rather than taking the first one", () => {
const subs = [sub("ihasmail-desktop", null), sub(MINE, null), sub("ihasmail-tablet", null)];
expect(findSubscription(subs, MINE)?.deviceClientId).toBe(MINE);
});
it("finds nothing when only other devices are registered", () => {
// The bug this replaces: any subscription at all counted as this one, so a
// phone that had never registered read as already on and stayed silent.
expect(findSubscription([sub("ihasmail-desktop", null)], MINE)).toBe(null);
});
});
describe("needsRenewal", () => {
it("renews when this browser is not registered at all", () => {
expect(needsRenewal([], MINE, NOW)).toBe(true);
expect(needsRenewal([sub("ihasmail-desktop", inDays(6))], MINE, NOW)).toBe(true);
});
it("leaves a subscription alone while it has time on it", () => {
expect(needsRenewal([sub(MINE, inDays(6))], MINE, NOW)).toBe(false);
expect(needsRenewal([sub(MINE, inDays(3))], MINE, NOW)).toBe(false);
});
it("renews inside the window, so a weekend does not lose it", () => {
expect(needsRenewal([sub(MINE, inDays(2))], MINE, NOW)).toBe(true);
expect(needsRenewal([sub(MINE, inDays(1))], MINE, NOW)).toBe(true);
expect(RENEW_WITHIN_MS).toBeLessThan(7 * 24 * 60 * 60 * 1000);
});
it("renews one that has already lapsed", () => {
expect(needsRenewal([sub(MINE, inDays(-1))], MINE, NOW)).toBe(true);
});
it("leaves a subscription with no expiry alone", () => {
// A server that never expires one has nothing to renew, and rewriting the
// registration on every cold start would be a JMAP call for nothing.
expect(needsRenewal([sub(MINE, null)], MINE, NOW)).toBe(false);
});
it("renews rather than trusts an expiry it cannot read", () => {
expect(needsRenewal([sub(MINE, "whenever")], MINE, NOW)).toBe(true);
});
});
/**
* Whether push is on *in this browser* is the flag the renewal on app start
* keys off, so the two endings that can clear it have to be told apart.
*
* Signing out clears it, alongside destroying the subscription itself: a
* browser left notifying for a mailbox nobody is signed into is somebody
* else's mail on a shared machine. A session merely expiring must not, because
* that path -- which is what a deploy does to everyone at once -- leaves the
* subscription registered and has no session left to remove it with. That half
* is enforced by `KEEP_ON_SIGN_OUT` and tested in storage.test.ts.
*/
describe("remembering that push is on here", () => {
let store: Map<string, string>;
beforeEach(() => {
store = new Map();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
});
setDeviceTrusted(true);
});
afterEach(() => {
setDeviceTrusted(false);
Reflect.deleteProperty(globalThis, "localStorage");
});
it("round-trips, and is off until something turns it on", () => {
expect(pushEnabledHere()).toBe(false);
setPushEnabledHere(true);
expect(pushEnabledHere()).toBe(true);
setPushEnabledHere(false);
expect(pushEnabledHere()).toBe(false);
});
it("stays off on a device nobody said was theirs", () => {
// Push is refused there anyway; reading the flag as set would start the
// renewal trying on every load for a subscription that cannot exist.
setPushEnabledHere(true);
setDeviceTrusted(false);
expect(pushEnabledHere()).toBe(false);
});
it("is cleared by signing out, even when the server end cannot be reached", () => {
setPushEnabledHere(true);
vi.spyOn(client, "call").mockRejectedValue(new Error("offline"));
return unsubscribeThisDevice().then(() => {
// The subscription may well survive at the server; this browser must
// still stop believing it has push, or renewal would resurrect it.
expect(pushEnabledHere()).toBe(false);
});
});
});
+121
View File
@@ -0,0 +1,121 @@
import { withBase } from "../basePath";
let baseTitle = "ihasmail";
let faviconCanvas: HTMLCanvasElement | null = null;
let baseFavicon: HTMLImageElement | null = null;
export function setBaseTitle(t: string) {
baseTitle = t;
}
/*
* The unread count on the installed app's icon.
*
* The title and the favicon below are the same idea for a tab, and an
* installed app has neither: in `display: standalone` there is no tab strip
* and no favicon anywhere on screen, so everything this file did for the
* unread count vanished at exactly the moment somebody put ihasmail on a home
* screen. The Badging API is where the count goes instead, and it is the one
* thing every phone user expects a mail icon to do.
*
* Silently nothing where it is unsupported, and silently nothing on iOS until
* notification permission has been granted, which is that platform's condition
* for showing a badge at all. Neither is worth reporting: a count that does not
* appear is not a failure anybody can act on.
*/
function setIconBadge(count: number): void {
if (!("setAppBadge" in navigator)) return;
const done = count > 0 ? navigator.setAppBadge(count) : navigator.clearAppBadge();
void done.catch(() => {
/* unsupported, or not permitted on this platform */
});
}
/** Update document title, favicon and app icon badge with unread count. */
export function setUnreadBadge(count: number): void {
document.title = count > 0 ? `(${count > 999 ? "999+" : count}) ${baseTitle}` : baseTitle;
setIconBadge(count);
try {
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"][type="image/png"]');
if (!link) return;
if (!baseFavicon) {
baseFavicon = new Image();
baseFavicon.src = withBase("/img/favicon-64.png");
baseFavicon.onload = () => setUnreadBadge(count);
return;
}
if (!baseFavicon.complete) return;
if (count <= 0) {
link.href = withBase("/img/favicon-64.png");
return;
}
faviconCanvas ??= document.createElement("canvas");
const c = faviconCanvas;
c.width = 64;
c.height = 64;
const ctx = c.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, 64, 64);
ctx.drawImage(baseFavicon, 0, 0, 64, 64);
ctx.fillStyle = "#dc2626";
ctx.beginPath();
ctx.arc(46, 18, 16, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#fff";
ctx.font = "bold 22px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(count > 99 ? "99" : String(count), 46, 19);
link.href = c.toDataURL("image/png");
} catch {
/* ignore */
}
}
export async function requestNotificationPermission(): Promise<NotificationPermission> {
if (!("Notification" in window)) return "denied";
if (Notification.permission !== "default") return Notification.permission;
try {
return await Notification.requestPermission();
} catch {
return "denied";
}
}
export function showNotification(title: string, opts: NotificationOptions & { onClick?: () => void } = {}): void {
if (!("Notification" in window) || Notification.permission !== "granted") return;
if (document.visibilityState === "visible" && document.hasFocus()) return;
try {
const n = new Notification(title, { icon: withBase("/img/icon-192.png"), badge: withBase("/img/favicon-64.png"), ...opts });
n.onclick = () => {
window.focus();
opts.onClick?.();
n.close();
};
setTimeout(() => n.close(), 8000);
} catch {
/* ignore */
}
}
let audioCtx: AudioContext | null = null;
/** Short, soft "ding" using WebAudio (no asset needed). */
export function playNewMailSound(): void {
try {
audioCtx ??= new AudioContext();
const ctx = audioCtx;
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = "sine";
o.frequency.setValueAtTime(880, ctx.currentTime);
o.frequency.exponentialRampToValueAtTime(1320, ctx.currentTime + 0.08);
g.gain.setValueAtTime(0.0001, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.15, ctx.currentTime + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.4);
o.connect(g).connect(ctx.destination);
o.start();
o.stop(ctx.currentTime + 0.45);
} catch {
/* ignore */
}
}
+271
View File
@@ -0,0 +1,271 @@
/**
* 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);
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Turning Web Push on and off, and completing the handshake it needs.
*
* Kept apart from `webpush.ts` so that module stays pure JMAP and stays
* testable: everything here touches the browser's service worker and
* permission prompt, none of which exists under a test runner.
*/
import { CAP } from "@/jmap/client";
import { withBase } from "../basePath";
import { SW_CACHE_NAME } from "../sw/swCache";
import { isDeviceTrusted } from "@/lib/storage";
import { useSession } from "@/store/session";
import { useMail } from "@/store/mail";
import {
applicationServerKey,
createSubscription,
decodeApplicationServerKey,
deviceClientId,
findSubscription,
listSubscriptions,
needsRenewal,
pushEnabledHere,
setPushEnabledHere,
subscriptionPayload,
unsubscribeThisDevice,
verifySubscription,
webPushAvailable,
} from "@/lib/notify/webpush";
let listening = false;
/**
* Watch for the verification code the server pushes.
*
* The service worker cannot answer it — a JMAP call needs the session cookie
* and this is a background context — so it forwards the code here, or leaves it
* in the cache when no tab was open to forward it to.
*/
export function listenForVerification(): void {
if (listening || typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
listening = true;
navigator.serviceWorker.addEventListener("message", (e: MessageEvent) => {
const d = e.data as { type?: string; id?: string; code?: string } | undefined;
if (d?.type === "push-verification" && d.id && d.code) void verifySubscription(d.id, d.code).catch(() => {});
});
void collectStoredVerification();
}
/** Pick up a code that arrived while no tab was open. */
async function collectStoredVerification(): Promise<void> {
try {
const cache = await caches.open(SW_CACHE_NAME);
// The same absolute key the worker writes. Relative would be resolved
// against this document's URL, which is a different place on every route.
const key = withBase("/ihasmail-push-verification");
const hit = await cache.match(key);
if (!hit) return;
const { id, code } = (await hit.json()) as { id?: string; code?: string };
await cache.delete(key);
if (id && code) await verifySubscription(id, code);
} catch {
/* nothing waiting, or no cache: not a failure */
}
}
/**
* Subscribe this browser. Safe to call again — the deviceClientId makes a
* repeat replace rather than accumulate.
*
* Returns why it could not, rather than throwing, because every reason is
* something to tell the user plainly: an old server, a browser without push, a
* permission they declined.
*/
export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reason: string }> {
if (!webPushAvailable()) {
return { ok: false, reason: "This browser or mail server does not support background notifications." };
}
if (Notification.permission === "denied") {
return { ok: false, reason: "Notifications are blocked for this site in your browser's settings." };
}
// A subscription outlives the tab and belongs to the account, not the
// session -- so on a machine the user has told us is not theirs, it would go
// on delivering their mail to it long after they had gone.
if (!isDeviceTrusted()) {
return { ok: false, reason: "Background notifications need a device you have marked as your own. Sign in again with \u201CThis is my own device\u201D ticked." };
}
const key = applicationServerKey();
if (!key) return { ok: false, reason: "This mail server does not publish a push key." };
try {
await registerThisBrowser(key);
setPushEnabledHere(true);
listenForVerification();
return { ok: true };
} catch (err) {
return { ok: false, reason: (err as Error).message || "Could not subscribe to notifications." };
}
}
/**
* Get this browser subscribed at the push service and registered at Stalwart.
*
* Shared by turning push on and by renewing it, because they are the same
* call: `deviceClientId` makes a repeat registration replace rather than
* accumulate, so there is no separate "update" path to get wrong.
*
* The local subscription is created when it is missing rather than only reused.
* A browser may drop or rotate one on its own -- a `pushsubscriptionchange`
* nobody was open to hear -- and the version that only reused an existing one
* gave up there, leaving push off for good with the switch still saying it was
* on.
*/
async function registerThisBrowser(key: string): Promise<void> {
const reg = await navigator.serviceWorker.ready;
const sub = (await reg.pushManager.getSubscription()) ?? (await reg.pushManager.subscribe({
// Web Push requires it, and Chrome refuses a subscription without it.
userVisibleOnly: true,
applicationServerKey: decodeApplicationServerKey(key),
}));
const accountId = useSession.getState().ownAccountFor(CAP.mail);
const inboxId = useMail.getState().roleId("inbox");
await createSubscription(subscriptionPayload(sub, accountId, inboxId));
}
/**
* Keep a subscription alive, from app start.
*
* Renewal has to happen here rather than in the service worker: registering
* with Stalwart is a JMAP call, and a JMAP call needs the session cookie that
* only a page has. So the guarantee is "push keeps working as long as ihasmail
* is opened now and again", and the renewal window is wide enough that once a
* week is enough.
*
* Silent by design. Every reason to stop is a normal state -- push was never
* turned on here, the permission is gone, the device is not trusted any more --
* and none of them is news to deliver on a cold start.
*/
export async function renewWebPush(): Promise<void> {
if (!pushEnabledHere() || !webPushAvailable()) return;
if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
const key = applicationServerKey();
if (!key) return;
try {
if (!needsRenewal(await listSubscriptions(), deviceClientId())) return;
await registerThisBrowser(key);
listenForVerification();
} catch {
/* offline, or the server said no: the next start tries again */
}
}
/** Remove this browser's subscription, at the browser and at the server. */
export async function disableWebPush(): Promise<void> {
await unsubscribeThisDevice();
}
/**
* Whether *this browser* has a subscription registered at the server.
*
* The device has to match. This used to answer "does the account have any
* subscription at all", which is true the moment one other device has one --
* so a phone that had never successfully registered, or whose registration had
* since expired, showed the switch already on and delivered nothing. The
* account-wide question is not one this switch is asking.
*/
export async function webPushActive(): Promise<boolean> {
try {
const reg = await navigator.serviceWorker?.getRegistration();
if (!(await reg?.pushManager.getSubscription())) return false;
return Boolean(findSubscription(await listSubscriptions(), deviceClientId()));
} catch {
return false;
}
}