Background notifications were built, verified against a live server, and then went quiet a few days later on every device that had them. A JMAP push subscription expires -- seven days is the ceiling -- and re-registering before it lapses is the client's job. Nothing did: enableWebPush() was reachable only from the switch in Settings, so the subscription was registered once, expired, and stayed expired. Nobody reports that as a bug. They report that push does not really work. It is renewed on every app start now, which is the only place it can be: the registration is a JMAP call and the service worker has no session cookie to make one with. So the guarantee is that push keeps working as long as ihasmail is opened now and again, and a two-day renewal window against a seven-day ceiling means once a week is enough. Registering is the same call as turning it on -- deviceClientId makes a repeat replace rather than accumulate -- so there is no second path to get wrong. Two more things in the same area, both of which produce the same silence: - webPushActive() asked whether the *account* had any subscription, so the moment one device had one, every other device showed the switch already on. A phone that had never successfully registered, or whose registration had since expired, read as on and delivered nothing. It matches on the device now. - Turning push on reused an existing browser subscription and gave up if there was none. A browser drops or rotates one on its own, and there is no tab open to hear the pushsubscriptionchange when it does, so that state was permanent. Renewal re-subscribes rather than bailing. Whether this browser has push on is now remembered locally, which is what renewal keys off. It is per browser rather than per account on purpose: a subscription is an endpoint and a device, and a phone having push says nothing about the desktop. It is not kept across sign-out, matching sign-out already destroying the subscription itself. The mock is the reason this was invisible in development: it handed back expires: null, so a client that never renewed worked perfectly against it forever. It expires a subscription in seven days now, which is what makes "does this client renew?" a question the mock can answer. Checked against the mock: a create returns an expiry seven days out that survives PushSubscription/get and parses, renewing the same deviceClientId replaces rather than accumulates, and a device with no registration of its own finds nothing where the old code saw two subscriptions and said yes. What the live Stalwart sets for expires is not confirmed -- if it sets none, renewal correctly does nothing and the other two fixes still stand.
243 lines
9.7 KiB
TypeScript
243 lines
9.7 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { client } from "@/jmap/client";
|
|
import {
|
|
applicationServerKey,
|
|
decodeApplicationServerKey,
|
|
encodeKey,
|
|
findSubscription,
|
|
needsRenewal,
|
|
RENEW_WITHIN_MS,
|
|
subscriptionPayload,
|
|
supportsEmailPush,
|
|
webPushAvailable,
|
|
type JmapPushSubscription,
|
|
} from "@/lib/webpush";
|
|
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();
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|