Renew the push subscription, so it does not lapse in a week #143

Closed
opened 2026-08-31 15:03:58 +00:00 by jcoffey-dev · 0 comments
Owner

Web Push was built, verified against a live server, and then goes quiet a few days later on every device that has it turned on.

A JMAP push subscription expires — seven days is the ceiling — and re-registering before it lapses is the client's job. Nothing did it: enableWebPush() was reachable from exactly one place, the switch in Settings (NotificationsSettings.tsx:57). So the subscription was registered once, expired, and stayed expired. Nobody reports that as a bug; they report that push doesn't really work.

The fix

Renewal on app start (App.tsx), which is the only place it can happen — registering is a JMAP call and the service worker has no session cookie to make one with. So the guarantee is "push keeps working as long as ihasmail is opened now and again", and a two-day renewal window against a seven-day ceiling means once a week is enough. Renewing is the same call as turning it on, since deviceClientId makes a repeat registration replace rather than accumulate — no second path to get wrong.

Two more things in the same area, each producing the same silence:

  • webPushActive() asked whether the account had any subscription. The moment one device had one, every other device showed the switch already on. A phone that had never successfully registered — or whose registration had since expired — read as "on" and delivered nothing. It matches on deviceClientId now.
  • Turning push on reused an existing browser subscription and gave up if there was none. Browsers drop and rotate subscriptions on their own, and there's no tab open to hear the pushsubscriptionchange when it happens, so that state was permanent. Renewal re-subscribes instead of bailing.

Whether this browser has push on is now remembered locally, which is what renewal keys off. Per browser rather than per account on purpose: a subscription is an endpoint and a device, so a phone having push says nothing about the desktop. Not kept across sign-out, matching sign-out already destroying the subscription.

Why this was invisible in development

The mock handed back expires: null, so a client that never renewed worked against it perfectly, forever. It expires a subscription in seven days now — which is what makes "does this client renew?" a question the mock can answer at all.

Checks

  • 415 web tests / 109 server tests pass; typecheck and build clean. Eight new tests cover needsRenewal and findSubscription — the lifecycle had no coverage before, only key encoding and payload shape.
  • Exercised against the mock over raw JMAP: a create returns an expiry seven days out that survives PushSubscription/get and parses; renewing the same deviceClientId replaces rather than accumulates; and a device with no registration of its own finds nothing, where the old code saw two subscriptions and said yes.

One thing not confirmed: what the live Stalwart actually sets for expires. If it sets none, renewal correctly does nothing and the other two fixes still stand on their own — so this is safe either way, but worth checking on the live instance, because it decides whether the expiry half of this was the real cause.

Merged 2026-08-31 as coffey-labs/ihasmail@2bf3137484

Rebuilt from: git history, session transcript.

Web Push was built, verified against a live server, and then goes quiet a few days later on every device that has it turned on. A JMAP push subscription **expires** — seven days is the ceiling — and re-registering before it lapses is the client's job. Nothing did it: `enableWebPush()` was reachable from exactly one place, the switch in Settings (`NotificationsSettings.tsx:57`). So the subscription was registered once, expired, and stayed expired. Nobody reports that as a bug; they report that push doesn't really work. ## The fix **Renewal on app start** (`App.tsx`), which is the only place it can happen — registering is a JMAP call and the service worker has no session cookie to make one with. So the guarantee is "push keeps working as long as ihasmail is opened now and again", and a two-day renewal window against a seven-day ceiling means once a week is enough. Renewing is the *same call* as turning it on, since `deviceClientId` makes a repeat registration replace rather than accumulate — no second path to get wrong. Two more things in the same area, each producing the same silence: - **`webPushActive()` asked whether the account had *any* subscription.** The moment one device had one, every other device showed the switch already on. A phone that had never successfully registered — or whose registration had since expired — read as "on" and delivered nothing. It matches on `deviceClientId` now. - **Turning push on reused an existing browser subscription and gave up if there was none.** Browsers drop and rotate subscriptions on their own, and there's no tab open to hear the `pushsubscriptionchange` when it happens, so that state was permanent. Renewal re-subscribes instead of bailing. Whether this browser has push on is now remembered locally, which is what renewal keys off. Per browser rather than per account on purpose: a subscription is an endpoint and a device, so a phone having push says nothing about the desktop. Not kept across sign-out, matching sign-out already destroying the subscription. ## Why this was invisible in development The mock handed back `expires: null`, so a client that never renewed worked against it perfectly, forever. It expires a subscription in seven days now — which is what makes "does this client renew?" a question the mock can answer at all. ## Checks - 415 web tests / 109 server tests pass; typecheck and build clean. Eight new tests cover `needsRenewal` and `findSubscription` — the lifecycle had no coverage before, only key encoding and payload shape. - Exercised against the mock over raw JMAP: a create returns an expiry seven days out that survives `PushSubscription/get` and parses; renewing the same `deviceClientId` replaces rather than accumulates; and a device with no registration of its own finds nothing, where the old code saw two subscriptions and said yes. **One thing not confirmed:** what the live Stalwart actually sets for `expires`. If it sets none, renewal correctly does nothing and the other two fixes still stand on their own — so this is safe either way, but worth checking on the live instance, because it decides whether the expiry half of this was the real cause. **Merged** 2026-08-31 as coffey-labs/ihasmail@2bf31374842b <sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.