Merge pull request #389 from Coffey-Labs/fix/push-subscriptions
Stop duplicate push notifications and piling up subscriptions
This commit is contained in:
@@ -48,6 +48,8 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
|||||||
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
|
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
|
||||||
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
|
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
|
||||||
|
|
||||||
|
- **Push subscriptions are not replaced by a repeated `deviceClientId`, and an account holds fifteen.** ihasmail registered a new subscription on every renewal believing the old one would be replaced, as the mock did. **Confirmed live (0.16.22, 2026-09-16)**: a second create with the same `deviceClientId` leaves both in place, the sixteenth create is refused with `overQuota`, "There are too many subscriptions, please delete some before adding a new one.", and `update` of `expires` is accepted. `PushSubscription/get` does not return `url` (nor `keys`), so a subscription can only be matched by its `deviceClientId`. A `types` of `[]` or `null` is stored as *every* type, not none. Read from the 0.16.22 source: `EmailDelivery` changes only on delivery, a delivery reaches a subscription with an `emailPush` filter as an EmailPush alone, and the payload carries `id` and `threadId` only when they are named in `properties`. Browsers now subscribe to `EmailDelivery` only, extend rather than re-create, clear their own duplicates and make room on `overQuota`; the server removes what its previous process registered. The mock follows all of it ([#375](https://github.com/Coffey-Labs/ihasmail/issues/375)).
|
||||||
|
|
||||||
- **A contact photo has to be a `data:` URI; Stalwart refuses one given as a `blobId`.** RFC 9610 lets JMAP put a `blobId` in a JSContact `Media` object, and ihasmail uploaded the photo and saved it that way, which the mock accepted. Stalwart does not: **confirmed live (0.16.22, 2026-09-16)**, a `ContactCard/set` create with `media.*.blobId` fails with `invalidProperties` on `media`, "blobIds in media is not supported." The RFC 9553 `uri` form with a `data:image/jpeg;base64,…` value is accepted on create and on update, and `ContactCard/get` returns it unchanged; a 134 KB one was accepted. Photos are now saved inline, and the mock refuses a `blobId` the same way ([#376](https://github.com/Coffey-Labs/ihasmail/issues/376)).
|
- **A contact photo has to be a `data:` URI; Stalwart refuses one given as a `blobId`.** RFC 9610 lets JMAP put a `blobId` in a JSContact `Media` object, and ihasmail uploaded the photo and saved it that way, which the mock accepted. Stalwart does not: **confirmed live (0.16.22, 2026-09-16)**, a `ContactCard/set` create with `media.*.blobId` fails with `invalidProperties` on `media`, "blobIds in media is not supported." The RFC 9553 `uri` form with a `data:image/jpeg;base64,…` value is accepted on create and on update, and `ContactCard/get` returns it unchanged; a 134 KB one was accepted. Photos are now saved inline, and the mock refuses a `blobId` the same way ([#376](https://github.com/Coffey-Labs/ihasmail/issues/376)).
|
||||||
|
|
||||||
- **Administration was built from Stalwart's source, and the first live run found the one thing the source reading got wrong.** Accounts and Domains were written on 2026-09-13 against the 0.16.22 source and a mock reproducing it, deployed the same day, and exercised against the live server from an administrator's session. On that server the Accounts list did not load: `x:Account/query` answered **`unsupportedFilter - type`**. A registry filter is keyed by the property's name *as it appears on the object*, and the discriminator is `@type`, so `{"type": "User"}` names nothing the server knows and fails the whole query; `{"@type": "User"}` is accepted. The research that fed the build had listed the field as `type`, and the mock took it without complaint — which is how it shipped. Fixed in [#336](https://github.com/Coffey-Labs/ihasmail/pull/336), and the mock now refuses any filter name the real server does not index, answering the way Stalwart does. Everything else was **confirmed live (2026-09-13)**, mostly read-only, with the domain writes made on a throwaway domain created for the purpose and removed afterwards:
|
- **Administration was built from Stalwart's source, and the first live run found the one thing the source reading got wrong.** Accounts and Domains were written on 2026-09-13 against the 0.16.22 source and a mock reproducing it, deployed the same day, and exercised against the live server from an administrator's session. On that server the Accounts list did not load: `x:Account/query` answered **`unsupportedFilter - type`**. A registry filter is keyed by the property's name *as it appears on the object*, and the discriminator is `@type`, so `{"type": "User"}` names nothing the server knows and fails the whole query; `{"@type": "User"}` is accepted. The research that fed the build had listed the field as `type`, and the mock took it without complaint — which is how it shipped. Fixed in [#336](https://github.com/Coffey-Labs/ihasmail/pull/336), and the mock now refuses any filter name the real server does not index, answering the way Stalwart does. Everything else was **confirmed live (2026-09-13)**, mostly read-only, with the domain writes made on a throwaway domain created for the purpose and removed afterwards:
|
||||||
|
|||||||
@@ -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 { 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";
|
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> = {
|
export const handlers: Record<string, Handler> = {
|
||||||
// 0.16 exposes the account locale here, under a permission ordinary users
|
// 0.16 exposes the account locale here, under a permission ordinary users
|
||||||
// actually have (unlike x:Account below, which needs sysAccountGet).
|
// 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." };
|
notCreated[cid] = { type: "invalidArguments", properties: ["emailPush"], description: "Invalid filter." };
|
||||||
continue;
|
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 deviceId = String(o.deviceClientId ?? "");
|
||||||
const clash = pushSubscriptions.findIndex((s) => s.deviceClientId === deviceId);
|
if (pushSubscriptions.length >= MAX_PUSH_SUBSCRIPTIONS) {
|
||||||
if (clash >= 0) pushSubscriptions.splice(clash, 1);
|
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)}`;
|
const id = `ps${randomUUID().slice(0, 6)}`;
|
||||||
/*
|
/*
|
||||||
* A subscription expires, and this used to hand back `expires: null`.
|
* 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.
|
* so "does this client renew?" is a question the mock can answer.
|
||||||
*/
|
*/
|
||||||
const expires = new Date(Date.now() + PUSH_TTL_MS).toISOString();
|
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 };
|
created[cid] = { id, expires };
|
||||||
state.n++;
|
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; }
|
if (code !== s.code) { notUpdated[id] = { type: "invalidProperties", properties: ["verificationCode"], description: "Verification code does not match." }; continue; }
|
||||||
s.verified = true;
|
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;
|
updated[id] = null;
|
||||||
state.n++;
|
state.n++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
assert.match(out.written.at(-1) ?? "", /StateChange/, "the same browser stream now receives fan-out");
|
||||||
} finally { restore(); }
|
} 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
@@ -23,7 +23,7 @@
|
|||||||
* transition loses no events, because a tab opened before verification keeps
|
* transition loses no events, because a tab opened before verification keeps
|
||||||
* its own relay for its whole life.
|
* 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 type { ServerResponse } from "node:http";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { absoluteUpstream, getUpstreamSession, upstreamFor } from "./upstream.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][] };
|
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) {
|
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 url = `${config.pushUrl!.replace(/\/$/, "")}${config.basePath}/api/push/${entry.token}`;
|
||||||
const r = await jmap(entry, [["PushSubscription/set", {
|
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"] } },
|
types: ["Email", "Mailbox", "Thread", "Identity", "EmailSubmission", "VacationResponse"] } },
|
||||||
}, "0"]]);
|
}, "0"]]);
|
||||||
const created = (r.methodResponses[0]?.[1] as { created?: Record<string, { id: string; expires?: string }> }).created?.s;
|
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`);
|
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) {
|
if (entry.state === "verified" && entry.expires - now < RENEW_BEFORE_MS) {
|
||||||
entry.state = "pending"; entry.since = now;
|
// Extended in place, which keeps it verified. Only if the server will
|
||||||
subscribe(entry).catch(() => { entry.state = "failed"; });
|
// 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)) {
|
if (entry.tabs.size === 0 && (entry.state === "failed" || now - entry.since > 10 * 60_000)) {
|
||||||
void unsubscribe(entry);
|
void unsubscribe(entry);
|
||||||
|
|||||||
+14
-3
@@ -346,6 +346,14 @@ self.addEventListener("push", (event) => {
|
|||||||
|
|
||||||
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
||||||
event.waitUntil((async () => {
|
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 facts = await readFacts();
|
||||||
const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" };
|
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 ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {});
|
||||||
|
|
||||||
if (!emails.length) {
|
if (!emails.length) {
|
||||||
// A StateChange, or a payload too large to carry the message. Say
|
// A delivery from a server that sends StateChange rather than EmailPush
|
||||||
// something true rather than inventing a sender.
|
// -- 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, {
|
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` },
|
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.
|
// be drawn.
|
||||||
actions: email.id ? actionsFor(facts) : [],
|
actions: email.id ? actionsFor(facts) : [],
|
||||||
data: {
|
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,
|
id: email.id || null,
|
||||||
title,
|
title,
|
||||||
accountId: facts?.accountId ?? null,
|
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");
|
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 } });
|
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 {
|
export function showNotification(title: string, opts: NotificationOptions & { onClick?: () => void } = {}): void {
|
||||||
if (!("Notification" in window) || Notification.permission !== "granted") return;
|
if (!("Notification" in window) || Notification.permission !== "granted") return;
|
||||||
if (document.visibilityState === "visible" && document.hasFocus()) 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 {
|
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 = () => {
|
n.onclick = () => {
|
||||||
window.focus();
|
window.focus();
|
||||||
opts.onClick?.();
|
onClick?.();
|
||||||
n.close();
|
n.close();
|
||||||
};
|
};
|
||||||
setTimeout(() => n.close(), 8000);
|
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 VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid";
|
||||||
export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush";
|
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 {
|
export interface JmapPushSubscription {
|
||||||
id: Id;
|
id: Id;
|
||||||
deviceClientId: string;
|
deviceClientId: string;
|
||||||
url: string;
|
/** Write-only: Stalwart never returns it, so a subscription cannot be matched by endpoint. */
|
||||||
|
url?: string;
|
||||||
expires: string | null;
|
expires: string | null;
|
||||||
verificationCode?: 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. */
|
/** The VAPID key this server signs with, or null if it does not do Web Push. */
|
||||||
export function applicationServerKey(): string | null {
|
export function applicationServerKey(): string | null {
|
||||||
const cap = client.session?.capabilities?.[VAPID_CAP] as { applicationServerKey?: string } | undefined;
|
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(),
|
deviceClientId: deviceClientId(),
|
||||||
url: sub.endpoint,
|
url: sub.endpoint,
|
||||||
keys: { p256dh: json.keys?.p256dh ?? encodeKey(sub.getKey("p256dh")), auth: json.keys?.auth ?? encodeKey(sub.getKey("auth")) },
|
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.
|
* New mail, and nothing else.
|
||||||
types: ["Email"],
|
*
|
||||||
|
* `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()) {
|
if (accountId && supportsEmailPush()) {
|
||||||
body.emailPush = {
|
body.emailPush = {
|
||||||
@@ -185,9 +214,51 @@ export function setPushEnabledHere(on: boolean): void {
|
|||||||
*/
|
*/
|
||||||
export const RENEW_WITHIN_MS = 2 * 24 * 60 * 60 * 1000;
|
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. */
|
/** This browser's registered subscription, out of everything the account has. */
|
||||||
export function findSubscription(subs: JmapPushSubscription[], deviceId: string): JmapPushSubscription | null {
|
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 } },
|
{ create: { s: body } },
|
||||||
[CAP.core, VAPID_CAP, EMAILPUSH_CAP],
|
[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;
|
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.
|
* 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 */
|
/* the browser end is gone or was never there; still clear the server end */
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const subs = await listSubscriptions();
|
await destroySubscriptions(mySubscriptions(await listSubscriptions(), mine).map((s) => s.id));
|
||||||
for (const s of subs) if (s.deviceClientId === mine) await destroySubscription(s.id);
|
|
||||||
} catch {
|
} catch {
|
||||||
/* signing out must not fail over this */
|
/* signing out must not fail over this */
|
||||||
}
|
}
|
||||||
|
rememberEndpoint(null);
|
||||||
setPushEnabledHere(false);
|
setPushEnabledHere(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,18 @@ import {
|
|||||||
applicationServerKey,
|
applicationServerKey,
|
||||||
createSubscription,
|
createSubscription,
|
||||||
decodeApplicationServerKey,
|
decodeApplicationServerKey,
|
||||||
|
destroySubscriptions,
|
||||||
deviceClientId,
|
deviceClientId,
|
||||||
|
extendSubscription,
|
||||||
findSubscription,
|
findSubscription,
|
||||||
listSubscriptions,
|
listSubscriptions,
|
||||||
needsRenewal,
|
mySubscriptions,
|
||||||
|
PushSetError,
|
||||||
pushEnabledHere,
|
pushEnabledHere,
|
||||||
|
registeredEndpoint,
|
||||||
|
rememberEndpoint,
|
||||||
|
RENEW_WITHIN_MS,
|
||||||
|
roomToMake,
|
||||||
setPushEnabledHere,
|
setPushEnabledHere,
|
||||||
subscriptionPayload,
|
subscriptionPayload,
|
||||||
unsubscribeThisDevice,
|
unsubscribeThisDevice,
|
||||||
@@ -64,8 +71,7 @@ async function collectStoredVerification(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe this browser. Safe to call again — the deviceClientId makes a
|
* Subscribe this browser. Safe to call again: see `registerThisBrowser`.
|
||||||
* repeat replace rather than accumulate.
|
|
||||||
*
|
*
|
||||||
* Returns why it could not, rather than throwing, because every reason is
|
* 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
|
* 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
|
* Shared by turning push on and by renewing it. It used to create a new
|
||||||
* call: `deviceClientId` makes a repeat registration replace rather than
|
* subscription every time, on the belief that a repeated `deviceClientId`
|
||||||
* accumulate, so there is no separate "update" path to get wrong.
|
* 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.
|
* 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`
|
* 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,
|
userVisibleOnly: true,
|
||||||
applicationServerKey: decodeApplicationServerKey(key),
|
applicationServerKey: decodeApplicationServerKey(key),
|
||||||
}));
|
}));
|
||||||
const accountId = useSession.getState().ownAccountFor(CAP.mail);
|
const deviceId = deviceClientId();
|
||||||
const inboxId = useMail.getState().roleId("inbox");
|
const subs = await listSubscriptions();
|
||||||
await createSubscription(subscriptionPayload(sub, accountId, inboxId));
|
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();
|
const key = applicationServerKey();
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
try {
|
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);
|
await registerThisBrowser(key);
|
||||||
listenForVerification();
|
listenForVerification();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { withBase } from "@/lib/basePath";
|
|||||||
import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props";
|
import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props";
|
||||||
import { type ListQuery, type MailState } from "./types";
|
import { type ListQuery, type MailState } from "./types";
|
||||||
import { playNewMailSound, showNotification } from "@/lib/notify/notify";
|
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
|
* `@/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);
|
const fresh = emails.filter((e) => e.mailboxIds[inbox] && !e.keywords.$seen && !e.keywords.$draft);
|
||||||
if (!fresh.length) return;
|
if (!fresh.length) return;
|
||||||
if (s.notificationSound) playNewMailSound();
|
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)) {
|
for (const e of fresh.slice(0, 3)) {
|
||||||
const from = e.from?.[0];
|
const from = e.from?.[0];
|
||||||
showNotification(from?.name || from?.email || "New message", {
|
showNotification(from?.name || from?.email || "New message", {
|
||||||
body: `${e.subject || "(no subject)"}\n${e.preview ?? ""}`.trim(),
|
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: () => {
|
onClick: () => {
|
||||||
window.location.hash = "";
|
window.location.hash = "";
|
||||||
// The one navigation that does not go through wouter -- it is
|
// The one navigation that does not go through wouter -- it is
|
||||||
|
|||||||
Reference in New Issue
Block a user