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

Background notifications were built, verified against a live server, and then
went quiet a few days later on every device that had them. A JMAP push
subscription expires -- seven days is the ceiling -- and re-registering before
it lapses is the client's job. Nothing did: enableWebPush() was reachable only
from the switch in Settings, so the subscription was registered once, expired,
and stayed expired. Nobody reports that as a bug. They report that push does
not really work.

It is renewed on every app start now, which is the only place it can be: the
registration is a JMAP call and the service worker has no session cookie to
make one with. So the guarantee is that 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. Registering is the same call as turning it
on -- deviceClientId makes a repeat replace rather than accumulate -- so there
is no second path to get wrong.

Two more things in the same area, both of which produce the same silence:

- webPushActive() asked whether the *account* had any subscription, so 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 the device now.
- Turning push on reused an existing browser subscription and gave up if there
  was none. A browser drops or rotates one on its own, and there is no tab open
  to hear the pushsubscriptionchange when it does, so that state was permanent.
  Renewal re-subscribes rather than bailing.

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

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

Checked against the mock: 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. What the
live Stalwart sets for expires is not confirmed -- if it sets none, renewal
correctly does nothing and the other two fixes still stand.
This commit is contained in:
2026-08-31 08:03:43 -07:00
parent e17d109ece
commit 562cee82ce
6 changed files with 238 additions and 15 deletions
+11
View File
@@ -658,6 +658,17 @@ you to the one you were on.
run. Where the server also implements `emailpush`, the payload carries the
sender, subject and preview; without it the notification says only that mail
arrived. Offered only on a device you said was yours.
- **The subscription is renewed on every app start**, because a JMAP push
subscription expires — seven days is the ceiling — and re-registering before
it lapses is the client's job. Renewal can only happen with a page open:
registering is a JMAP call and the service worker has no session to make one
with. So the guarantee is that background notifications keep working as long
as ihasmail is opened now and again, and the two-day renewal window means
once a week is enough. A browser that dropped or rotated its subscription on
its own is re-subscribed at the same moment, rather than left with a switch
that says push is on and a browser that is no longer listening.
Registration is per browser, not per account: a phone having push does not
make it on for the desktop, and each device tracks its own.
- **Stale build reload** — when the server starts serving a build the open tab
did not come from, the tab reloads itself rather than going on talking to a
newer server with older JavaScript. It waits for a moment that is safe: an