Stop duplicate push notifications and piling up subscriptions
Browsers subscribed to Email changes, so every read or move on any client arrived as a push the worker could only show as "New mail". They now subscribe to EmailDelivery, which changes only on delivery; Stalwart sends a delivery to a subscription with an emailPush filter as an EmailPush alone. The payload now names id and threadId, which Stalwart sends only when asked, so notifications carry their actions and open the message. The worker stays quiet while a focused window is open, and the page leaves notifications to the worker where push is on. Every renewal registered a new subscription, on the belief that a repeated deviceClientId replaces the old one. Stalwart keeps both and allows fifteen per account, which filled up. A browser now extends its subscription, clears its own duplicates, replaces them only when its endpoint changed, and on overQuota makes room among other browsers' subscriptions. The server names its subscriptions by installation and removes what its previous process registered, and extends rather than re-creates. Checked live on 0.16.22; the mock now keeps duplicates, enforces the limit and accepts an expiry update. Fixes #375.
This commit is contained in:
+14
-3
@@ -346,6 +346,14 @@ self.addEventListener("push", (event) => {
|
||||
|
||||
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
||||
event.waitUntil((async () => {
|
||||
/*
|
||||
* Someone reading the app already knows. A focused, visible window of this
|
||||
* app gets its new mail from its own event stream, so a notification on
|
||||
* top of it is a second telling of the same thing (#375). Chrome does not
|
||||
* require one while the site is in the foreground.
|
||||
*/
|
||||
const windows = await self.clients.matchAll({ type: "window" });
|
||||
if (windows.some((w) => w.focused && w.visibilityState === "visible")) return;
|
||||
const facts = await readFacts();
|
||||
const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" };
|
||||
/*
|
||||
@@ -361,8 +369,10 @@ self.addEventListener("push", (event) => {
|
||||
if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {});
|
||||
|
||||
if (!emails.length) {
|
||||
// A StateChange, or a payload too large to carry the message. Say
|
||||
// something true rather than inventing a sender.
|
||||
// A delivery from a server that sends StateChange rather than EmailPush
|
||||
// -- the subscription asks for `EmailDelivery` only, so it is new mail --
|
||||
// or a payload too large to carry the message. Say something true
|
||||
// rather than inventing a sender.
|
||||
await self.registration.showNotification(strings.newMail, {
|
||||
icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
|
||||
});
|
||||
@@ -382,7 +392,8 @@ self.addEventListener("push", (event) => {
|
||||
// be drawn.
|
||||
actions: email.id ? actionsFor(facts) : [],
|
||||
data: {
|
||||
url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail`,
|
||||
// The route names a conversation, and `m` the message in it.
|
||||
url: email.id && email.threadId ? `${BASE}/mail/inbox/${email.threadId}?m=${encodeURIComponent(email.id)}` : `${BASE}/mail`,
|
||||
id: email.id || null,
|
||||
title,
|
||||
accountId: facts?.accountId ?? null,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
import { setDeviceTrusted } from "@/lib/storage";
|
||||
import { deviceClientId, isBrowserSubscription, rememberEndpoint, roomToMake, setPushEnabledHere, type JmapPushSubscription } from "@/lib/notify/webpush";
|
||||
import { renewWebPush } from "@/lib/notify/webpushEnable";
|
||||
|
||||
/**
|
||||
* #375: every renewal registered another subscription, on the belief that a
|
||||
* repeated deviceClientId replaces the old one. Stalwart keeps both and allows
|
||||
* fifteen per account, so accounts filled up with "too many subscriptions".
|
||||
*
|
||||
* The server below behaves as a live 0.16.22 was seen to: duplicates are kept,
|
||||
* the sixteenth is refused with overQuota, and an expiry can be extended.
|
||||
*/
|
||||
|
||||
const KEY = "BBvig2GPmqohMJJHMzp6bTKviHibYiVCyAY8gdq2fPhS-9YfO9_0TnhMyZ0a0JxTsbCqd3zm1rEiXsXsL3jveJY";
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const OTHER = (n: number) => `ihasmail-00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
|
||||
|
||||
let server: Array<JmapPushSubscription & { types?: string[] }>;
|
||||
let writes: Array<[string, Record<string, unknown>]>;
|
||||
let seq: number;
|
||||
|
||||
const fakeSub = (endpoint: string) => ({
|
||||
endpoint,
|
||||
toJSON: () => ({ endpoint, keys: { p256dh: "BPub", auth: "auth" } }),
|
||||
getKey: () => null,
|
||||
});
|
||||
let browserSub: ReturnType<typeof fakeSub> | null;
|
||||
|
||||
function install() {
|
||||
client.session = { capabilities: { "urn:ietf:params:jmap:core": { maxCallsInRequest: 16 }, "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: KEY } }, accounts: {}, primaryAccounts: {}, state: "s" } as unknown as JmapSession;
|
||||
vi.stubGlobal("PushManager", function PushManager() {});
|
||||
vi.stubGlobal("Notification", { permission: "granted" });
|
||||
const reg = {
|
||||
pushManager: {
|
||||
getSubscription: async () => browserSub,
|
||||
subscribe: async () => (browserSub = fakeSub("https://push.example/new-endpoint")),
|
||||
},
|
||||
};
|
||||
Object.defineProperty(navigator, "serviceWorker", {
|
||||
configurable: true,
|
||||
value: { ready: Promise.resolve(reg), getRegistration: async () => reg, addEventListener: () => {} },
|
||||
});
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const { methodCalls } = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const methodResponses = methodCalls.map(([name, args, id]) => {
|
||||
if (name === "PushSubscription/get") return [name, { list: server.map((s) => ({ ...s })), notFound: [] }, id];
|
||||
writes.push([name, args]);
|
||||
const created: Record<string, unknown> = {};
|
||||
const notCreated: Record<string, unknown> = {};
|
||||
const updated: Record<string, null> = {};
|
||||
for (const [cid, body] of Object.entries((args.create ?? {}) as Record<string, Record<string, unknown>>)) {
|
||||
if (server.length >= 15) { notCreated[cid] = { type: "overQuota", description: "There are too many subscriptions, please delete some before adding a new one." }; continue; }
|
||||
const sub = { id: `p${seq++}`, deviceClientId: String(body.deviceClientId), expires: new Date(Date.now() + 7 * DAY).toISOString(), verificationCode: null, types: body.types as string[] };
|
||||
server.push(sub);
|
||||
created[cid] = { id: sub.id, expires: sub.expires };
|
||||
}
|
||||
for (const [sid, patch] of Object.entries((args.update ?? {}) as Record<string, Record<string, unknown>>)) {
|
||||
const s = server.find((x) => x.id === sid);
|
||||
if (s && typeof patch.expires === "string") { s.expires = patch.expires; updated[sid] = null; }
|
||||
}
|
||||
const destroy = (args.destroy ?? []) as string[];
|
||||
server = server.filter((s) => !destroy.includes(s.id));
|
||||
return [name, { created, notCreated, updated, destroyed: destroy }, id];
|
||||
});
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response;
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
server = [];
|
||||
writes = [];
|
||||
seq = 1;
|
||||
browserSub = fakeSub("https://push.example/endpoint-a");
|
||||
setDeviceTrusted(true);
|
||||
setPushEnabledHere(true);
|
||||
install();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.session = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const mine = () => server.filter((s) => s.deviceClientId === deviceClientId());
|
||||
const ours = (expiresIn: number, id = `m${seq++}`) => ({ id, deviceClientId: deviceClientId(), expires: new Date(Date.now() + expiresIn).toISOString(), verificationCode: "done" });
|
||||
|
||||
describe("keeping this browser registered", () => {
|
||||
it("registers once, for new mail only, and remembers the endpoint", async () => {
|
||||
await renewWebPush();
|
||||
expect(mine()).toHaveLength(1);
|
||||
expect(mine()[0]!.types).toEqual(["EmailDelivery"]);
|
||||
// Started again straight away: nothing more to do.
|
||||
writes = [];
|
||||
await renewWebPush();
|
||||
expect(writes).toEqual([]);
|
||||
expect(mine()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("leaves a subscription with time on it alone", async () => {
|
||||
rememberEndpoint(browserSub!.endpoint);
|
||||
server.push(ours(6 * DAY));
|
||||
await renewWebPush();
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
it("extends one that is close to expiring instead of adding another", async () => {
|
||||
rememberEndpoint(browserSub!.endpoint);
|
||||
server.push(ours(1 * DAY, "keep"));
|
||||
await renewWebPush();
|
||||
expect(writes.map(([n, a]) => `${n} ${Object.keys(a).join(",")}`)).toEqual(["PushSubscription/set update"]);
|
||||
expect(mine()).toHaveLength(1);
|
||||
expect(Date.parse(mine()[0]!.expires!) - Date.now()).toBeGreaterThan(6 * DAY);
|
||||
});
|
||||
|
||||
it("clears the copies earlier versions left, keeping the newest", async () => {
|
||||
rememberEndpoint(browserSub!.endpoint);
|
||||
server.push(ours(1 * DAY), ours(3 * DAY), ours(6 * DAY, "newest"));
|
||||
await renewWebPush();
|
||||
expect(mine().map((s) => s.id)).toEqual(["newest"]);
|
||||
});
|
||||
|
||||
it("replaces its registrations when the browser's endpoint has changed", async () => {
|
||||
rememberEndpoint("https://push.example/an-old-endpoint");
|
||||
server.push(ours(6 * DAY, "old1"), ours(6 * DAY, "old2"));
|
||||
await renewWebPush();
|
||||
expect(mine()).toHaveLength(1);
|
||||
expect(mine()[0]!.id).not.toMatch(/^old/);
|
||||
expect(localStorage.getItem("ihasmail:pushEndpoint")).toBe(browserSub!.endpoint);
|
||||
});
|
||||
|
||||
it("makes room when the account is full, taking another browser's never-verified one first", async () => {
|
||||
for (let i = 0; i < 13; i++) server.push({ id: `o${i}`, deviceClientId: OTHER(i), expires: new Date(Date.now() + (i + 1) * DAY / 4).toISOString(), verificationCode: "done" });
|
||||
server.push({ id: "unverified", deviceClientId: OTHER(99), expires: new Date(Date.now() + 6 * DAY).toISOString(), verificationCode: null });
|
||||
server.push({ id: "proxy", deviceClientId: "ihasmail-proxy-abcdefghij-12345678", expires: new Date(Date.now() + DAY).toISOString(), verificationCode: "done" });
|
||||
await renewWebPush();
|
||||
expect(mine()).toHaveLength(1);
|
||||
expect(server.find((s) => s.id === "unverified")).toBeUndefined();
|
||||
expect(server.find((s) => s.id === "proxy")).toBeDefined();
|
||||
expect(server).toHaveLength(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("telling subscriptions apart", () => {
|
||||
it("recognizes a browser's id, and not the server's or another client's", () => {
|
||||
const sub = (deviceClientId: string) => ({ id: "x", deviceClientId, expires: null }) as JmapPushSubscription;
|
||||
expect(isBrowserSubscription(sub(OTHER(1)))).toBe(true);
|
||||
expect(isBrowserSubscription(sub("ihasmail-proxy-abcdefghij-12345678"))).toBe(false);
|
||||
expect(isBrowserSubscription(sub("ihasmail-Ab3_x9Qz"))).toBe(false);
|
||||
expect(isBrowserSubscription(sub("some-other-client"))).toBe(false);
|
||||
});
|
||||
|
||||
it("chooses the soonest to expire when every candidate is verified", () => {
|
||||
const subs = [
|
||||
{ id: "later", deviceClientId: OTHER(1), expires: new Date(Date.now() + 5 * DAY).toISOString(), verificationCode: "v" },
|
||||
{ id: "sooner", deviceClientId: OTHER(2), expires: new Date(Date.now() + DAY).toISOString(), verificationCode: "v" },
|
||||
{ id: "me", deviceClientId: OTHER(3), expires: new Date(Date.now()).toISOString(), verificationCode: "v" },
|
||||
];
|
||||
expect(roomToMake(subs, OTHER(3))).toEqual(["sooner"]);
|
||||
});
|
||||
});
|
||||
@@ -125,9 +125,15 @@ describe("what gets registered", () => {
|
||||
expect(subscriptionPayload(fakeSub, null)).not.toHaveProperty("emailPush");
|
||||
});
|
||||
|
||||
it("subscribes to Email changes only, since EventSource covers an open tab", () => {
|
||||
it("subscribes to deliveries only, so reading or moving mail elsewhere sends nothing", () => {
|
||||
client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } });
|
||||
expect((subscriptionPayload(fakeSub, "a1") as Record<string, unknown>).types).toEqual(["Email"]);
|
||||
expect((subscriptionPayload(fakeSub, "a1") as Record<string, unknown>).types).toEqual(["EmailDelivery"]);
|
||||
});
|
||||
|
||||
it("asks for the message and conversation ids, which Stalwart only sends when named", () => {
|
||||
client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY }, "urn:ietf:params:jmap:emailpush": {} });
|
||||
const payload = subscriptionPayload(fakeSub, "a1") as { emailPush: Record<string, { properties: string[] }> };
|
||||
expect(payload.emailPush.a1!.properties).toEqual(expect.arrayContaining(["id", "threadId"]));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -82,14 +82,30 @@ export async function requestNotificationPermission(): Promise<NotificationPermi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a notification from the page, for a tab that is open but not in front.
|
||||
*
|
||||
* Through the service worker's registration where there is one: Android's
|
||||
* Chrome refuses `new Notification()` outright, so notifications from an open
|
||||
* tab never appeared there at all. The tag is the one the service worker uses
|
||||
* for the same message (`ihasmail-<id>`), so if both ever show it, the second
|
||||
* replaces the first instead of stacking beside it.
|
||||
*/
|
||||
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;
|
||||
const { onClick, ...options } = opts;
|
||||
const full = { icon: withBase("/img/icon-192.png"), badge: withBase("/img/favicon-64.png"), ...options };
|
||||
const viaWorker = navigator.serviceWorker?.controller ? navigator.serviceWorker.ready : null;
|
||||
if (viaWorker) {
|
||||
void viaWorker.then((reg) => reg.showNotification(title, full)).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const n = new Notification(title, { icon: withBase("/img/icon-192.png"), badge: withBase("/img/favicon-64.png"), ...opts });
|
||||
const n = new Notification(title, full);
|
||||
n.onclick = () => {
|
||||
window.focus();
|
||||
opts.onClick?.();
|
||||
onClick?.();
|
||||
n.close();
|
||||
};
|
||||
setTimeout(() => n.close(), 8000);
|
||||
|
||||
+126
-10
@@ -24,17 +24,36 @@ 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"];
|
||||
/**
|
||||
* Which Email properties to put in the payload, best first.
|
||||
*
|
||||
* `id` and `threadId` have to be asked for: Stalwart sends only what is named
|
||||
* (0.16.22 source). Without them a notification could not be tagged by
|
||||
* message, carried no Archive or Mark-read button, and opened the inbox rather
|
||||
* than the message.
|
||||
*/
|
||||
const PAYLOAD_PROPS = ["id", "threadId", "from", "subject", "preview", "receivedAt"];
|
||||
|
||||
export interface JmapPushSubscription {
|
||||
id: Id;
|
||||
deviceClientId: string;
|
||||
url: string;
|
||||
/** Write-only: Stalwart never returns it, so a subscription cannot be matched by endpoint. */
|
||||
url?: string;
|
||||
expires: string | null;
|
||||
verificationCode?: string | null;
|
||||
}
|
||||
|
||||
/** A `PushSubscription/set` refusal, with the server's type kept for deciding what to do. */
|
||||
export class PushSetError extends Error {
|
||||
constructor(
|
||||
readonly type: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "PushSetError";
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -126,9 +145,19 @@ export function subscriptionPayload(sub: PushSubscription, accountId: Id | null,
|
||||
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"],
|
||||
/*
|
||||
* New mail, and nothing else.
|
||||
*
|
||||
* `EmailDelivery` changes only when a message is delivered; `Email` changes
|
||||
* on every read, flag and move, from any client, and each of those arrived
|
||||
* here as a push the worker could only show as "New mail" (#375). With an
|
||||
* `emailPush` filter, Stalwart sends a delivery as an EmailPush alone; a
|
||||
* server without emailpush turns it into a StateChange naming
|
||||
* `EmailDelivery`, which is then a true "New mail". An empty or null list
|
||||
* is not "none": Stalwart takes it as every type there is (checked live on
|
||||
* 0.16.22, 2026-09-16).
|
||||
*/
|
||||
types: ["EmailDelivery"],
|
||||
};
|
||||
if (accountId && supportsEmailPush()) {
|
||||
body.emailPush = {
|
||||
@@ -185,9 +214,51 @@ export function setPushEnabledHere(on: boolean): void {
|
||||
*/
|
||||
export const RENEW_WITHIN_MS = 2 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* This browser's registered subscriptions, the one with the most time left
|
||||
* first.
|
||||
*
|
||||
* Plural because Stalwart keeps every create: a second subscription with the
|
||||
* same `deviceClientId` sits beside the first rather than replacing it
|
||||
* (checked live on 0.16.22, 2026-09-16), so an account holds as many as were
|
||||
* ever registered until each one expires.
|
||||
*/
|
||||
export function mySubscriptions(subs: JmapPushSubscription[], deviceId: string): JmapPushSubscription[] {
|
||||
const left = (s: JmapPushSubscription) => (s.expires ? Date.parse(s.expires) || 0 : Number.MAX_SAFE_INTEGER);
|
||||
return subs.filter((s) => s.deviceClientId === deviceId).sort((a, b) => left(b) - left(a));
|
||||
}
|
||||
|
||||
/** 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;
|
||||
return mySubscriptions(subs, deviceId)[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a subscription was registered by a browser running ihasmail, rather
|
||||
* than by the ihasmail server (`ihasmail-proxy-`, or the older eight-character
|
||||
* form) or by another client altogether.
|
||||
*/
|
||||
export function isBrowserSubscription(s: JmapPushSubscription): boolean {
|
||||
return /^ihasmail-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s.deviceClientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which subscriptions to let go of when the account is at its limit.
|
||||
*
|
||||
* Stalwart allows fifteen per account and refuses the sixteenth with
|
||||
* `overQuota` (checked live on 0.16.22, 2026-09-16). Only browser
|
||||
* subscriptions are candidates, never this browser's and never the server's:
|
||||
* one that never verified first, then the one closest to expiring. A device
|
||||
* that loses its subscription this way registers again the next time the app
|
||||
* is opened there, because it no longer finds its own.
|
||||
*/
|
||||
export function roomToMake(subs: JmapPushSubscription[], deviceId: string, count = 1): Id[] {
|
||||
const expiry = (s: JmapPushSubscription) => (s.expires ? Date.parse(s.expires) || 0 : Number.MAX_SAFE_INTEGER);
|
||||
return subs
|
||||
.filter((s) => s.deviceClientId !== deviceId && isBrowserSubscription(s))
|
||||
.sort((a, b) => Number(Boolean(a.verificationCode)) - Number(Boolean(b.verificationCode)) || expiry(a) - expiry(b))
|
||||
.slice(0, count)
|
||||
.map((s) => s.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,10 +296,55 @@ export async function createSubscription(body: Record<string, unknown>): Promise
|
||||
{ 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));
|
||||
const refused = res.notCreated?.s;
|
||||
if (refused) throw new PushSetError(String(refused.type), String(refused.description ?? refused.type));
|
||||
return (res.created?.s as { id?: Id } | undefined)?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a registered subscription more time, rather than registering another.
|
||||
*
|
||||
* Seven days is JMAP's ceiling and what Stalwart grants a new one; the server
|
||||
* may shorten what is asked for, and whatever it keeps is what counts.
|
||||
*/
|
||||
export async function extendSubscription(id: Id, now: number = Date.now()): Promise<void> {
|
||||
const expires = new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString().replace(/\.\d+Z$/, "Z");
|
||||
const res = await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]);
|
||||
const err = res.notUpdated?.[id];
|
||||
if (err) throw new PushSetError(String(err.type), String(err.description ?? err.type));
|
||||
}
|
||||
|
||||
export async function destroySubscriptions(ids: Id[]): Promise<void> {
|
||||
if (!ids.length) return;
|
||||
await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The push endpoint this browser last registered with the server.
|
||||
*
|
||||
* The server never returns a subscription's URL, so this is the only way to
|
||||
* tell a subscription that still points at this browser's endpoint from one
|
||||
* made for an endpoint the browser has since replaced.
|
||||
*/
|
||||
const ENDPOINT_KEY = "ihasmail:pushEndpoint";
|
||||
|
||||
export function registeredEndpoint(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(ENDPOINT_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberEndpoint(endpoint: string | null): void {
|
||||
try {
|
||||
if (endpoint) localStorage.setItem(ENDPOINT_KEY, endpoint);
|
||||
else localStorage.removeItem(ENDPOINT_KEY);
|
||||
} catch {
|
||||
/* private mode: every start is then a fresh registration, which still works */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand back the code the server pushed.
|
||||
*
|
||||
@@ -262,10 +378,10 @@ export async function unsubscribeThisDevice(): Promise<void> {
|
||||
/* 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);
|
||||
await destroySubscriptions(mySubscriptions(await listSubscriptions(), mine).map((s) => s.id));
|
||||
} catch {
|
||||
/* signing out must not fail over this */
|
||||
}
|
||||
rememberEndpoint(null);
|
||||
setPushEnabledHere(false);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,18 @@ import {
|
||||
applicationServerKey,
|
||||
createSubscription,
|
||||
decodeApplicationServerKey,
|
||||
destroySubscriptions,
|
||||
deviceClientId,
|
||||
extendSubscription,
|
||||
findSubscription,
|
||||
listSubscriptions,
|
||||
needsRenewal,
|
||||
mySubscriptions,
|
||||
PushSetError,
|
||||
pushEnabledHere,
|
||||
registeredEndpoint,
|
||||
rememberEndpoint,
|
||||
RENEW_WITHIN_MS,
|
||||
roomToMake,
|
||||
setPushEnabledHere,
|
||||
subscriptionPayload,
|
||||
unsubscribeThisDevice,
|
||||
@@ -64,8 +71,7 @@ async function collectStoredVerification(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe this browser. Safe to call again — the deviceClientId makes a
|
||||
* repeat replace rather than accumulate.
|
||||
* Subscribe this browser. Safe to call again: see `registerThisBrowser`.
|
||||
*
|
||||
* 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
|
||||
@@ -98,11 +104,23 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
|
||||
}
|
||||
|
||||
/**
|
||||
* Get this browser subscribed at the push service and registered at Stalwart.
|
||||
* Get this browser subscribed at the push service and registered at Stalwart,
|
||||
* with exactly one subscription there, and that one current.
|
||||
*
|
||||
* 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.
|
||||
* Shared by turning push on and by renewing it. It used to create a new
|
||||
* subscription every time, on the belief that a repeated `deviceClientId`
|
||||
* replaces the old one. Stalwart keeps both (checked live on 0.16.22), so each
|
||||
* renewal added one, every start inside the renewal window added another, and
|
||||
* the account reached its limit of fifteen -- "too many subscriptions" (#375).
|
||||
* Now:
|
||||
*
|
||||
* - the same endpoint as last time, already registered: extend the newest one
|
||||
* when it is close to expiring, and remove any extra copies;
|
||||
* - anything else -- a new endpoint, nothing registered, an extension the
|
||||
* server refused: remove this browser's old ones and register afresh.
|
||||
*
|
||||
* A registration refused for `overQuota` makes room among other browsers'
|
||||
* subscriptions (`roomToMake`) and is tried once more.
|
||||
*
|
||||
* 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`
|
||||
@@ -117,9 +135,35 @@ async function registerThisBrowser(key: string): Promise<void> {
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: decodeApplicationServerKey(key),
|
||||
}));
|
||||
const accountId = useSession.getState().ownAccountFor(CAP.mail);
|
||||
const inboxId = useMail.getState().roleId("inbox");
|
||||
await createSubscription(subscriptionPayload(sub, accountId, inboxId));
|
||||
const deviceId = deviceClientId();
|
||||
const subs = await listSubscriptions();
|
||||
const mine = mySubscriptions(subs, deviceId);
|
||||
const [newest, ...extra] = mine;
|
||||
|
||||
if (newest && registeredEndpoint() === sub.endpoint) {
|
||||
if (extra.length) await destroySubscriptions(extra.map((s) => s.id));
|
||||
const at = newest.expires ? Date.parse(newest.expires) : Number.NaN;
|
||||
if (!newest.expires || (!Number.isNaN(at) && at - Date.now() > RENEW_WITHIN_MS)) return;
|
||||
try {
|
||||
await extendSubscription(newest.id);
|
||||
return;
|
||||
} catch {
|
||||
/* not extendable: replaced below */
|
||||
}
|
||||
}
|
||||
|
||||
if (mine.length) await destroySubscriptions(mine.map((s) => s.id));
|
||||
const payload = subscriptionPayload(sub, useSession.getState().ownAccountFor(CAP.mail), useMail.getState().roleId("inbox"));
|
||||
try {
|
||||
await createSubscription(payload);
|
||||
} catch (err) {
|
||||
if (!(err instanceof PushSetError) || err.type !== "overQuota") throw err;
|
||||
const room = roomToMake(subs.filter((s) => !mine.includes(s)), deviceId);
|
||||
if (!room.length) throw err;
|
||||
await destroySubscriptions(room);
|
||||
await createSubscription(payload);
|
||||
}
|
||||
rememberEndpoint(sub.endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,7 +185,8 @@ export async function renewWebPush(): Promise<void> {
|
||||
const key = applicationServerKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
if (!needsRenewal(await listSubscriptions(), deviceClientId())) return;
|
||||
// Cheap when nothing is due: one read, and a write only when a
|
||||
// subscription is close to expiring, missing, or duplicated.
|
||||
await registerThisBrowser(key);
|
||||
listenForVerification();
|
||||
} catch {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { withBase } from "@/lib/basePath";
|
||||
import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props";
|
||||
import { type ListQuery, type MailState } from "./types";
|
||||
import { playNewMailSound, showNotification } from "@/lib/notify/notify";
|
||||
import { pushEnabledHere } from "@/lib/notify/webpush";
|
||||
|
||||
/*
|
||||
* `@/store/mail` stays the one public entry. The split below is about file
|
||||
@@ -1262,12 +1263,15 @@ async function notifyNewMail(created: Id[], get: () => MailState) {
|
||||
const fresh = emails.filter((e) => e.mailboxIds[inbox] && !e.keywords.$seen && !e.keywords.$draft);
|
||||
if (!fresh.length) return;
|
||||
if (s.notificationSound) playNewMailSound();
|
||||
if (s.desktopNotifications) {
|
||||
// Where background notifications are on in this browser, the service worker
|
||||
// shows these already; showing them here too was the duplicate in #375.
|
||||
if (s.desktopNotifications && !pushEnabledHere()) {
|
||||
for (const e of fresh.slice(0, 3)) {
|
||||
const from = e.from?.[0];
|
||||
showNotification(from?.name || from?.email || "New message", {
|
||||
body: `${e.subject || "(no subject)"}\n${e.preview ?? ""}`.trim(),
|
||||
tag: e.id,
|
||||
tag: `ihasmail-${e.id}`,
|
||||
data: { url: withBase(`/mail/${inbox}/${e.threadId}?m=${encodeURIComponent(e.id)}`) },
|
||||
onClick: () => {
|
||||
window.location.hash = "";
|
||||
// The one navigation that does not go through wouter -- it is
|
||||
|
||||
Reference in New Issue
Block a user