Stop duplicate push notifications and piling up subscriptions #389

Closed
opened 2026-09-16 18:36:04 +00:00 by jcoffey-dev · 0 comments
Owner

Summary

#375 described two problems, and both are real. Some of the report's guesses about the cause were not: sw.js has no polling or SSE handler, and its only notifications come from the push event.

1. Ghost and duplicate notifications

  • Cause: browsers subscribed with types: ["Email"]. Every read, flag or move on any client became a StateChange push, and the worker showed each one as "New mail".
  • Fix: subscribe with types: ["EmailDelivery"]. From the 0.16.22 source:
    • EmailDelivery changes only on delivery.
    • A delivery reaches a subscription that has an emailPush filter as an EmailPush alone, and not at all if the filter doesn't match.
    • A server without emailpush turns the delivery into a StateChange naming EmailDelivery, so the worker's generic "New mail" is now true.
    • types: [] would have been wrong: live, Stalwart stores [] and null as every type.
  • Missing message ids: PAYLOAD_PROPS didn't ask for id or threadId, and Stalwart only sends what is named. Notifications therefore had no per-message tag, no Archive or Mark-read buttons, and opened the inbox. Both are now requested, and the link is /mail/inbox/<threadId>?m=<id>, which is the shape the router expects.
  • Focused window: the worker skips notifying when a focused, visible window of the app is open.
  • In-page duplicates:
    • The page's notifyNewMail doesn't notify when push is on in this browser.
    • Where it does notify, it goes through the service worker's registration, because Android Chrome throws on new Notification().
    • It uses the worker's ihasmail-<id> tag, so the two can't stack.

2. "Too many subscriptions"

Checked live (0.16.22):

  • A repeated deviceClientId does not replace the earlier subscription; both are kept.
  • An account holds 15; the 16th is refused with overQuota.
  • An expires update is accepted.
  • get never returns url.

Browser. registerThisBrowser used to create a subscription on every call, including every renewal and every app start in the renewal window. Now:

  • Same endpoint, already registered: it removes duplicates, and extends the newest subscription only when it is close to expiry.
  • Different or unknown endpoint, or an extension that fails: it removes this browser's subscriptions and creates one.
  • overQuota: roomToMake removes one other browser's subscription and tries once more, choosing an unverified one first, then the soonest to expire. The server's subscriptions and this device's own are never candidates, and the device that loses one re-registers the next time the app opens there.
  • Endpoint: the last endpoint registered is kept in localStorage, because the server never returns url.

Server (push.ts, subscribe mode only; relay mode never creates subscriptions). The proxy created a subscription per account per process and never removed the previous one, so each restart left one behind.

  • Its deviceClientId is now ihasmail-proxy-<installation>-<token>. The installation part is a hash of PUSH_URL plus BASE_PATH.
  • subscribe() first removes subscriptions carrying this installation's prefix, and leaves other installations', browsers' and old-format ones alone.
  • Renewal extends expires in place, which keeps the subscription verified, and falls back to a new one.

Mock: it keeps duplicates, enforces 15 with Stalwart's overQuota message, stores an empty or missing types as every type, and accepts an expires update.

KNOWN-ISSUES.md records the live results and what was read from the source.

Fixes #375.

Related issues

Fixes #375.

Translations

Adds none. npm run i18n:check reports the same counts as main.

Testing

  • New push-registration.test.ts: a fake server with the live-observed behavior drives renewWebPush. It covers:

    • one registration, with ["EmailDelivery"];
    • nothing done on the next start;
    • a subscription with time on it left alone;
    • an update, not a create, near expiry;
    • duplicates reduced to the newest;
    • a new endpoint replacing old registrations;
    • a full account making room by taking an unverified browser subscription and never the server's.

    It also tests isBrowserSubscription and roomToMake. All 8 fail against main.

  • webpush.test.ts: the types expectation is now ["EmailDelivery"], and a new test checks that id and threadId are requested.

  • push.test.ts: a restart removes only this installation's leftover (not another installation's, a browser's, or an old-format one), and the new id carries the installation prefix.

  • npm test (1,344 web tests and 250 server tests), npm run typecheck, npm run build -w web and npm run i18n:check are clean.

  • Not checked in a browser: registering real Web Push requires granting notification permission in Chrome. The Stalwart behavior these changes rely on was probed live with a throwaway account, and all test subscriptions were deleted afterwards.

Merged 2026-09-16 as coffey-labs/ihasmail@ebf678be73

Rebuilt from: git history, session transcript.

## Summary #375 described two problems, and both are real. Some of the report's guesses about the cause were not: `sw.js` has no polling or SSE handler, and its only notifications come from the `push` event. ### 1. Ghost and duplicate notifications - **Cause:** browsers subscribed with `types: ["Email"]`. Every read, flag or move on any client became a StateChange push, and the worker showed each one as "New mail". - **Fix:** subscribe with `types: ["EmailDelivery"]`. From the 0.16.22 source: - `EmailDelivery` changes only on delivery. - A delivery reaches a subscription that has an `emailPush` filter as an EmailPush alone, and not at all if the filter doesn't match. - A server without emailpush turns the delivery into a StateChange naming `EmailDelivery`, so the worker's generic "New mail" is now true. - `types: []` would have been wrong: live, Stalwart stores `[]` and `null` as *every* type. - **Missing message ids:** `PAYLOAD_PROPS` didn't ask for `id` or `threadId`, and Stalwart only sends what is named. Notifications therefore had no per-message tag, no Archive or Mark-read buttons, and opened the inbox. Both are now requested, and the link is `/mail/inbox/<threadId>?m=<id>`, which is the shape the router expects. - **Focused window:** the worker skips notifying when a focused, visible window of the app is open. - **In-page duplicates:** - The page's `notifyNewMail` doesn't notify when push is on in this browser. - Where it does notify, it goes through the service worker's registration, because Android Chrome throws on `new Notification()`. - It uses the worker's `ihasmail-<id>` tag, so the two can't stack. ### 2. "Too many subscriptions" **Checked live (0.16.22):** - A repeated `deviceClientId` does **not** replace the earlier subscription; both are kept. - An account holds **15**; the 16th is refused with `overQuota`. - An `expires` update is accepted. - `get` never returns `url`. **Browser.** `registerThisBrowser` used to create a subscription on every call, including every renewal and every app start in the renewal window. Now: - **Same endpoint, already registered:** it removes duplicates, and extends the newest subscription only when it is close to expiry. - **Different or unknown endpoint, or an extension that fails:** it removes this browser's subscriptions and creates one. - **`overQuota`:** `roomToMake` removes one other browser's subscription and tries once more, choosing an unverified one first, then the soonest to expire. The server's subscriptions and this device's own are never candidates, and the device that loses one re-registers the next time the app opens there. - **Endpoint:** the last endpoint registered is kept in localStorage, because the server never returns `url`. **Server (`push.ts`, `subscribe` mode only; `relay` mode never creates subscriptions).** The proxy created a subscription per account per process and never removed the previous one, so each restart left one behind. - Its `deviceClientId` is now `ihasmail-proxy-<installation>-<token>`. The installation part is a hash of `PUSH_URL` plus `BASE_PATH`. - `subscribe()` first removes subscriptions carrying this installation's prefix, and leaves other installations', browsers' and old-format ones alone. - Renewal extends `expires` in place, which keeps the subscription verified, and falls back to a new one. **Mock:** it keeps duplicates, enforces 15 with Stalwart's `overQuota` message, stores an empty or missing `types` as every type, and accepts an `expires` update. **KNOWN-ISSUES.md** records the live results and what was read from the source. Fixes #375. ## Related issues Fixes #375. ## Translations Adds none. `npm run i18n:check` reports the same counts as `main`. ## Testing - **New `push-registration.test.ts`:** a fake server with the live-observed behavior drives `renewWebPush`. It covers: - one registration, with `["EmailDelivery"]`; - nothing done on the next start; - a subscription with time on it left alone; - an update, not a create, near expiry; - duplicates reduced to the newest; - a new endpoint replacing old registrations; - a full account making room by taking an unverified browser subscription and never the server's. It also tests `isBrowserSubscription` and `roomToMake`. **All 8 fail against `main`.** - **`webpush.test.ts`:** the types expectation is now `["EmailDelivery"]`, and a new test checks that `id` and `threadId` are requested. - **`push.test.ts`:** a restart removes only this installation's leftover (not another installation's, a browser's, or an old-format one), and the new id carries the installation prefix. - `npm test` (1,344 web tests and 250 server tests), `npm run typecheck`, `npm run build -w web` and `npm run i18n:check` are clean. - **Not checked in a browser:** registering real Web Push requires granting notification permission in Chrome. The Stalwart behavior these changes rely on was probed live with a throwaway account, and all test subscriptions were deleted afterwards. **Merged** 2026-09-16 as coffey-labs/ihasmail@ebf678be73be <sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.