Notifications that arrive when ihasmail is closed #68

Closed
opened 2026-08-26 20:51:30 +00:00 by jcoffey-dev · 0 comments
Owner

ihasmail's notifications came from EventSource, which lives exactly as long as a tab does — so "desktop notifications" has always quietly meant while you are looking. That switch is now labelled as much, and a second one does the thing people assumed the first one did.

Why this route rather than a relay

Stalwart 0.16 signs Web Push with VAPID (RFC 9749) — without which, per Stalwart's own tracker, Chromium and Safari cannot receive push at all. The server pushes straight to the browser's push service. ihasmail's server isn't in the delivery path, there's no relay to deploy, and nothing beyond the browser vendor's endpoint that Web Push requires of everyone.

Worth naming the comparison honestly: Bulwark Mail has push, routed through a hosted relay (notifications.relay.bulwarkmail.org) by default. That's a reasonable trade for convenience; it just sits badly against a project with no database, a stateless server, and settings kept in the user's own mail store.

Probed live before writing anything

An advertised capability is not a configured one:

probe result
webpush-vapid capability real applicationServerKey published — no key generation needed
PushSubscription/get 200 { list: [] } — answers an ordinary user, not forbidden
emailpush capability advertised (empty object, per the draft)

And the emailpush draft defines a filter, an ordered properties list and an urgency — so the payload carries sender and subject, spam is filtered server-side and never leaves it, and when the payload won't fit the server drops properties from the end rather than failing the notification. Hence properties is ordered by what matters.

Three things easy to get wrong

The verification handshake. A JMAP subscription delivers nothing until the client echoes back a code the server pushed — and the service worker can't answer it, having no credentials. It forwards the code to a tab, or caches it when no tab was open to forward to. A subscription left unverified looks registered and is silent.

Key encoding. The W3C Push API emits unpadded base64url and Stalwart 0.16 was fixed to accept exactly that, so nothing pads on the way out. The VAPID key needs padding on the way in for atob. Getting that backwards fails at subscribe() with an opaque error, so it lives in one named function with tests — including that the live key decodes to 65 bytes starting 0x04, an uncompressed P-256 point.

Sign-out. A subscription belongs to the account, not the session. Without teardown, a shared machine keeps notifying for a mailbox nobody is signed into.

Testing

262 web + 75 server tests, typecheck, build and node --check on the service worker all clean.

The mock models the JMAP half — refusing padded keys and non-https endpoints, creating subscriptions unverified, replacing per deviceClientId, and never returning keys (write-only in JMAP). All exercised.

Not verified end to end: an actual notification arriving. That needs a real browser, a real push service and real delivery — it's live testing or nothing. I deliberately didn't subscribe from a test tab, since that would register a real subscription with Google's push service pointing at a mock.

Safari also requires the PWA to be installed before it will grant permission at all — worth knowing before concluding it's broken there.

Merged 2026-08-26 as coffey-labs/ihasmail@d7e9e94794

Rebuilt from: git history, session transcript.

ihasmail's notifications came from EventSource, which lives exactly as long as a tab does — so "desktop notifications" has always quietly meant *while you are looking*. That switch is now labelled as much, and a second one does the thing people assumed the first one did. ## Why this route rather than a relay Stalwart 0.16 signs Web Push with VAPID ([RFC 9749](https://datatracker.ietf.org/doc/rfc9749/)) — without which, per [Stalwart's own tracker](https://support.stalw.art/t/implement-rfc-9749-vapid-for-jmap-web-push-without-it-chromium-and-safari-cannot-receive-push-at-all/1073), Chromium and Safari cannot receive push at all. The server pushes **straight to the browser's push service**. ihasmail's server isn't in the delivery path, there's no relay to deploy, and nothing beyond the browser vendor's endpoint that Web Push requires of everyone. Worth naming the comparison honestly: Bulwark Mail has push, routed through a hosted relay (`notifications.relay.bulwarkmail.org`) by default. That's a reasonable trade for convenience; it just sits badly against a project with no database, a stateless server, and settings kept in the user's own mail store. ## Probed live before writing anything An advertised capability is not a configured one: | probe | result | |---|---| | `webpush-vapid` capability | real `applicationServerKey` published — no key generation needed | | `PushSubscription/get` | `200 { list: [] }` — answers an ordinary user, not `forbidden` | | `emailpush` capability | advertised (empty object, per the draft) | And the [emailpush draft](https://datatracker.ietf.org/doc/draft-ietf-jmap-emailpush/) defines a `filter`, an **ordered** `properties` list and an `urgency` — so the payload carries sender and subject, spam is filtered server-side and never leaves it, and when the payload won't fit the server drops properties *from the end* rather than failing the notification. Hence `properties` is ordered by what matters. ## Three things easy to get wrong **The verification handshake.** A JMAP subscription delivers nothing until the client echoes back a code the server pushed — and the service worker can't answer it, having no credentials. It forwards the code to a tab, or caches it when no tab was open to forward to. A subscription left unverified looks registered and is silent. **Key encoding.** The W3C Push API emits *unpadded* base64url and Stalwart 0.16 was fixed to accept exactly that, so nothing pads on the way out. The VAPID key needs padding on the way *in* for `atob`. Getting that backwards fails at `subscribe()` with an opaque error, so it lives in one named function with tests — including that the live key decodes to 65 bytes starting `0x04`, an uncompressed P-256 point. **Sign-out.** A subscription belongs to the account, not the session. Without teardown, a shared machine keeps notifying for a mailbox nobody is signed into. ## Testing 262 web + 75 server tests, typecheck, build and `node --check` on the service worker all clean. The mock models the JMAP half — refusing padded keys and non-https endpoints, creating subscriptions **unverified**, replacing per `deviceClientId`, and never returning `keys` (write-only in JMAP). All exercised. **Not verified end to end: an actual notification arriving.** That needs a real browser, a real push service and real delivery — it's live testing or nothing. I deliberately didn't subscribe from a test tab, since that would register a real subscription with Google's push service pointing at a mock. Safari also requires the PWA to be installed before it will grant permission at all — worth knowing before concluding it's broken there. **Merged** 2026-08-26 as coffey-labs/ihasmail@d7e9e9479462 <sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.