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 orderedproperties 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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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:
webpush-vapidcapabilityapplicationServerKeypublished — no key generation neededPushSubscription/get200 { list: [] }— answers an ordinary user, notforbiddenemailpushcapabilityAnd the emailpush draft defines a
filter, an orderedpropertieslist and anurgency— 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. Hencepropertiesis 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 atsubscribe()with an opaque error, so it lives in one named function with tests — including that the live key decodes to 65 bytes starting0x04, 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 --checkon 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 returningkeys(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.