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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user