Files
SRCmail/app/(main)
Bernd Rodler 2416f1863b feat(jmap): JMAP-over-WebSocket push (RFC 8887), preferred over SSE
Phase 1 step 6 of VNCprodbuild, resolving the step-5 DECISION gate (human
confirmed: WebSocket push, not polling, not quit-to-tray).

lib/jmap/client.ts: getWebSocketUrl() discovers the push endpoint from the
session's own urn:ietf:params:jmap:websocket capability (mirrors
getEventSourceUrl()'s existing pattern) - not hardcoded to any one server,
rewritten to the client's own host the same way apiUrl/downloadUrl/
eventSourceUrl already are (rewriteWebSocketUrl(), scheme-aware since ws/wss
can never share an origin string with the client's http/https serverUrl).
setupPushNotifications() now tries WS first when advertised, falling back to
the existing SSE/polling chain when not. connectWebSocket() subscribes via
WebSocketPushEnable and routes incoming StateChange frames through the exact
same stateChangeCallback that SSE/polling already feed - so
stores/email-store.ts's handleStateChange (mailbox/email refresh, scheduled
mail, calendar, filters) and handleNewEmailNotification (the new-mail toast/
sound signal) all work unchanged regardless of which transport delivered the
change.

Reconnect/backoff: exponential with full jitter (1s base, 30s cap - unlike
SSE's fixed 3s retry, explicitly requested since a long-lived WebSocket can
be dropped by sleep/network-switch/idle-proxy repeatedly in a row). An
app-level heartbeat (Core/echo every 30s, force-reconnect after 90s of
silence) catches connections that report readyState OPEN long after the
underlying path is actually gone, mirroring the existing SSE ping monitor.

Circuit breaker (wsConsecutiveFailures/wsPermanentlyDisabled): gives up on
WS after 5 CONSECUTIVE handshake failures (never reaching "open" - a
connection that opened fine and dropped later doesn't count) and falls back
to SSE/polling for the rest of the client instance's life. This is not
theoretical - verified empirically against the actual sandbox server this
was built against:

  curl -i -H "Connection: Upgrade" -H "Upgrade: websocket" \
    -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: ..." \
    -H "Sec-WebSocket-Protocol: jmap" https://stalwart.sandbox.vnc.de/jmap/ws
  -> 401 Unauthorized, WWW-Authenticate: Bearer/Basic

Stalwart's /jmap/ws requires the same HTTP Authorization header as every
other JMAP endpoint on the upgrade request itself, and the browser
WebSocket constructor cannot attach custom headers to that handshake (a
WHATWG spec restriction - credentials-in-URL is also explicitly rejected).
Every connection attempt from this renderer-side client will therefore fail
against Stalwart specifically and fall back to SSE (which keeps working
exactly as before - zero regression). Implemented for real anyway, not
stubbed: it's fully spec-correct and activates automatically against any
server whose WS endpoint doesn't share this auth model (e.g. behind a
cookie-authenticating proxy), and the alternative (opening it from
Electron's main process via a header-capable client, which would need raw
credentials piped over IPC from the renderer) is a materially bigger
security-sensitive change than what was scoped here. Documented in detail
in the code comments above the new fields.

lib/jmap/client-interface.ts + lib/demo/demo-client.ts: getWebSocketUrl()
added to the interface (demo client returns null, matching
getEventSourceUrl's existing stub).

app/(main)/[locale]/page.tsx: the existing "new mail arrived" effect (which
already plays a sound, transport-agnostically, whenever
stores/email-store.ts sets newEmailNotification for a genuine new top-of-
inbox message) now also calls lib/electron-bridge.ts's
showElectronNotification() when isElectronShell() - firing the native
notification bridge built in the step-3 commit, gated on the same
emailNotificationsEnabled setting the sound already uses. Fallback title/
body text ("New mail" / "(no subject)") matches public/sw.js's existing
push-notification fallback strings rather than introducing new i18n keys
for a rarely-hit edge case.

Verified: full lib/__tests__ JMAP suite green (158/158 across 13 files,
excluding one pre-existing unrelated flaky test - jmap-client-resilience's
ping-failure-reconnect-ordering assertion uses real timers and fails
~75% of the time on both this branch's base commit and this change,
confirmed by running the untouched baseline the same way). npm run
test:electron still green (4/4) after a full rebuild.
2026-08-04 13:25:49 +02:00
..