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:
2026-09-16 11:36:07 -07:00
parent aa9bf1b9b2
commit 4054f82c37
11 changed files with 528 additions and 38 deletions
+25 -4
View File
@@ -7,6 +7,11 @@ import { ACCOUNT, MASKED, MAX_DELAYED_SEND, MOCK_LOCALE, NO_FUTURE_RELEASE, Obj,
import { NO_KEYWORD_SORT, abRights, blobs, booksFor, calendarsFor, cards, compareBy, emails, eventsFor, fileNodes, fr, identities, mailboxes, mb, nodesFor, participantIdentities, principals, pushSubscriptions, putBlob, recount, rightsCal, seq, sharedCards, sieveScripts, vacationBox } from "./data.js";
import { Handler, MethodError, applyPatch, calendarEventParse, calendarEventSet, directory, genericGet, genericSet, hideShareWithUnlessAsked, matchFilter, matchSubmissionFilter, pick, resolveEvent, setResp, submissionView, submissions } from "./engine.js";
/** Stalwart's limit per account (0.16.22). */
const MAX_PUSH_SUBSCRIPTIONS = 15;
/** What an empty or missing `types` list is taken to mean: everything. */
const ALL_PUSH_TYPES = ["Email", "EmailDelivery", "Mailbox", "Thread", "Identity", "EmailSubmission", "VacationResponse", "CalendarEvent", "Calendar", "ContactCard", "AddressBook", "FileNode", "Quota", "SieveScript", "PushSubscription"];
export const handlers: Record<string, Handler> = {
// 0.16 exposes the account locale here, under a permission ordinary users
// actually have (unlike x:Account below, which needs sysAccountGet).
@@ -197,10 +202,17 @@ export const handlers: Record<string, Handler> = {
notCreated[cid] = { type: "invalidArguments", properties: ["emailPush"], description: "Invalid filter." };
continue;
}
// One per device: re-subscribing replaces rather than accumulates.
/*
* As Stalwart does (checked live on 0.16.22, 2026-09-16): a repeated
* deviceClientId is a second subscription, not a replacement -- this mock
* used to replace, which is how the client's pile-up never showed here
* (#375) -- and an account holds at most fifteen.
*/
const deviceId = String(o.deviceClientId ?? "");
const clash = pushSubscriptions.findIndex((s) => s.deviceClientId === deviceId);
if (clash >= 0) pushSubscriptions.splice(clash, 1);
if (pushSubscriptions.length >= MAX_PUSH_SUBSCRIPTIONS) {
notCreated[cid] = { type: "overQuota", description: "There are too many subscriptions, please delete some before adding a new one." };
continue;
}
const id = `ps${randomUUID().slice(0, 6)}`;
/*
* A subscription expires, and this used to hand back `expires: null`.
@@ -212,7 +224,9 @@ export const handlers: Record<string, Handler> = {
* so "does this client renew?" is a question the mock can answer.
*/
const expires = new Date(Date.now() + PUSH_TTL_MS).toISOString();
pushSubscriptions.push({ id, deviceClientId: deviceId, url: o.url, types: o.types ?? null, emailPush: o.emailPush ?? null, expires, keys, verified: false, code: `v${randomUUID().slice(0, 8)}` });
// An empty or missing list means every type, not none.
const types = Array.isArray(o.types) && o.types.length ? o.types : ALL_PUSH_TYPES;
pushSubscriptions.push({ id, deviceClientId: deviceId, url: o.url, types, emailPush: o.emailPush ?? null, expires, keys, verified: false, code: `v${randomUUID().slice(0, 8)}` });
created[cid] = { id, expires };
state.n++;
}
@@ -224,6 +238,13 @@ export const handlers: Record<string, Handler> = {
if (code !== s.code) { notUpdated[id] = { type: "invalidProperties", properties: ["verificationCode"], description: "Verification code does not match." }; continue; }
s.verified = true;
}
// An expiry can be extended, up to the same seven days a new one gets.
const wanted = (patch as Obj).expires;
if (typeof wanted === "string") {
const at = Math.min(Date.parse(wanted), Date.now() + PUSH_TTL_MS);
if (Number.isNaN(at)) { notUpdated[id] = { type: "invalidProperties", properties: ["expires"] }; continue; }
s.expires = new Date(at).toISOString();
}
updated[id] = null;
state.n++;
}
+52
View File
@@ -133,3 +133,55 @@ test("a tab on the relay is moved to fan-out when its account verifies, and its
assert.match(out.written.at(-1) ?? "", /StateChange/, "the same browser stream now receives fan-out");
} finally { restore(); }
});
test("a new subscription clears what this installation left behind, and only that", async () => {
// What a restart finds: its own subscription from the last process, another
// installation's on the same server, a browser's, and the old id format.
const calls: Array<[string, Record<string, unknown>]> = [];
let ownPrefix = "";
const real = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/.well-known/jmap") || url.includes("/jmap/session")) {
return new Response(JSON.stringify({ apiUrl: "http://127.0.0.1:1/jmap/", primaryAccounts: { "urn:ietf:params:jmap:mail": "a" },
accounts: { a: {} }, capabilities: {}, eventSourceUrl: "", downloadUrl: "", uploadUrl: "", state: "s" }), { status: 200, headers: { "content-type": "application/json" } });
}
const { methodCalls } = JSON.parse(String(init?.body)) as { methodCalls: [string, Record<string, unknown>, string][] };
const [name, args, id] = methodCalls[0]!;
calls.push([name, args]);
let result: Record<string, unknown> = {};
if (name === "PushSubscription/get") {
result = { list: [
{ id: "mine-before", deviceClientId: `${ownPrefix}oldtoken` },
{ id: "other-install", deviceClientId: "ihasmail-proxy-ZZZZZZZZZZ-12345678" },
{ id: "a-browser", deviceClientId: "ihasmail-00000000-0000-4000-8000-000000000001" },
{ id: "old-format", deviceClientId: "ihasmail-Ab3_x9Qz" },
] };
} else if (name === "PushSubscription/set" && args.create) {
const body = (args.create as Record<string, { deviceClientId: string }>).s!;
result = { created: { s: { id: "fresh", expires: new Date(Date.now() + 7 * 86_400_000).toISOString() } } };
calls.at(-1)![1] = { ...args, deviceClientId: body.deviceClientId };
} else {
result = { destroyed: args.destroy };
}
return new Response(JSON.stringify({ methodResponses: [[name, result, id]] }), { status: 200, headers: { "content-type": "application/json" } });
}) as typeof fetch;
try {
// The installation's prefix, learned the way the server makes it: from its first create.
push.prepare("[email protected]", "a", "Basic p");
await new Promise((r) => setTimeout(r, 30));
const firstCreate = calls.find(([n, a]) => n === "PushSubscription/set" && a.create);
const deviceId = String(firstCreate?.[1].deviceClientId ?? "");
assert.match(deviceId, /^ihasmail-proxy-[A-Za-z0-9_-]{10}-[A-Za-z0-9_-]{8}$/, "the server's own prefix, naming the installation");
ownPrefix = deviceId.slice(0, deviceId.lastIndexOf("-") + 1);
calls.length = 0;
push.prepare("[email protected]", "a", "Basic r");
await new Promise((r) => setTimeout(r, 30));
const destroyed = calls.filter(([n, a]) => n === "PushSubscription/set" && a.destroy).flatMap(([, a]) => a.destroy as string[]);
assert.deepEqual(destroyed, ["mine-before"], "only this installation's leftover goes");
assert.ok(calls.some(([n, a]) => n === "PushSubscription/set" && a.create), "and a new one is made");
} finally {
globalThis.fetch = real;
}
});
+57 -4
View File
@@ -23,7 +23,7 @@
* transition loses no events, because a tab opened before verification keeps
* its own relay for its whole life.
*/
import { randomBytes } from "node:crypto";
import { createHash, randomBytes } from "node:crypto";
import type { ServerResponse } from "node:http";
import { config } from "./config.js";
import { absoluteUpstream, getUpstreamSession, upstreamFor } from "./upstream.js";
@@ -71,10 +71,58 @@ async function jmap(entry: AccountPush, calls: unknown[]) {
return (await res.json()) as { methodResponses: [string, Record<string, unknown>, string][] };
}
/*
* Whose subscriptions are whose.
*
* Each process used to register a subscription per account and forget it when
* it stopped -- state here is in memory, and an immutable deployment restarts
* on every deploy -- so each restart left one more behind, receiving 404s until
* it expired. Stalwart keeps them all and allows fifteen per account (checked
* live on 0.16.22, 2026-09-16), which the browser subscriptions count against
* too (#375).
*
* So the device id names the installation -- a hash of the address Stalwart
* posts to, stable across restarts and different for another installation on
* the same server -- and a new subscription first removes the ones this
* installation left before. The `ihasmail-proxy-` prefix keeps them apart from
* the browsers' own, which the web client may clear to make room.
*/
function installationId(): string {
return createHash("sha256").update(`${config.pushUrl}${config.basePath}`).digest("base64url").slice(0, 10);
}
function deviceIdFor(entry: AccountPush): string {
return `ihasmail-proxy-${installationId()}-${entry.token.slice(0, 8)}`;
}
async function removeLeftovers(entry: AccountPush) {
const mine = `ihasmail-proxy-${installationId()}-`;
const r = await jmap(entry, [["PushSubscription/get", { ids: null, properties: ["id", "deviceClientId"] }, "0"]]);
const list = (r.methodResponses[0]?.[1] as { list?: Array<{ id: string; deviceClientId?: string }> }).list ?? [];
const stale = list.filter((s) => s.id !== entry.subscriptionId && String(s.deviceClientId ?? "").startsWith(mine)).map((s) => s.id);
if (stale.length) await jmap(entry, [["PushSubscription/set", { destroy: stale }, "0"]]);
}
/** Give the live subscription another week, rather than registering a second one. */
async function renew(entry: AccountPush) {
const expires = new Date(Date.now() + 7 * 86_400_000).toISOString().replace(/\.\d+Z$/, "Z");
const r = await jmap(entry, [["PushSubscription/set", { update: { [entry.subscriptionId!]: { expires } } }, "0"]]);
const res = r.methodResponses[0]?.[1] as { updated?: Record<string, unknown>; notUpdated?: Record<string, unknown> };
if (!res.updated || !(entry.subscriptionId! in res.updated)) throw new Error("subscription not extended");
const got = await jmap(entry, [["PushSubscription/get", { ids: [entry.subscriptionId], properties: ["expires"] }, "0"]]);
const after = (got.methodResponses[0]?.[1] as { list?: Array<{ expires?: string | null }> }).list?.[0]?.expires;
entry.expires = after ? Date.parse(after) : Date.parse(expires);
}
async function subscribe(entry: AccountPush) {
try {
await removeLeftovers(entry);
} catch (err) {
console.warn(`[ihasmail] push: could not clear old subscriptions for ${entry.username}: ${(err as Error).message}`);
}
const url = `${config.pushUrl!.replace(/\/$/, "")}${config.basePath}/api/push/${entry.token}`;
const r = await jmap(entry, [["PushSubscription/set", {
create: { s: { deviceClientId: `ihasmail-${entry.token.slice(0, 8)}`, url,
create: { s: { deviceClientId: deviceIdFor(entry), url,
types: ["Email", "Mailbox", "Thread", "Identity", "EmailSubmission", "VacationResponse"] } },
}, "0"]]);
const created = (r.methodResponses[0]?.[1] as { created?: Record<string, { id: string; expires?: string }> }).created?.s;
@@ -184,8 +232,13 @@ function startSweeper() {
console.warn(`[ihasmail] push: no verification for ${entry.username} within ${VERIFY_TIMEOUT_MS / 1000}s; relay in use`);
}
if (entry.state === "verified" && entry.expires - now < RENEW_BEFORE_MS) {
entry.state = "pending"; entry.since = now;
subscribe(entry).catch(() => { entry.state = "failed"; });
// Extended in place, which keeps it verified. Only if the server will
// not is a new one registered, and that one has to verify again.
entry.expires = now + RENEW_BEFORE_MS;
renew(entry).catch(() => {
entry.state = "pending"; entry.since = Date.now();
subscribe(entry).catch(() => { entry.state = "failed"; });
});
}
if (entry.tabs.size === 0 && (entry.state === "failed" || now - entry.since > 10 * 60_000)) {
void unsubscribe(entry);