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.
170 lines
6.7 KiB
TypeScript
170 lines
6.7 KiB
TypeScript
/**
|
|
* 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 { 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/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("ihasmail-v2");
|
|
const hit = await cache.match("ihasmail-push-verification");
|
|
if (!hit) return;
|
|
const { id, code } = (await hit.json()) as { id?: string; code?: string };
|
|
await cache.delete("ihasmail-push-verification");
|
|
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;
|
|
}
|
|
}
|