Transport layer -- L2¶
Problem¶
The client currently hardcodes SSE (EventSource) for push notifications and HTTP POST for every JMAP API call. JMAP also defines a WebSocket transport (RFC 8887) that carries both push notifications and request/response pairs on a single persistent connection. WebSocket reduces connection overhead and latency, particularly for mutation-heavy workflows where each flag toggle or move currently opens a new HTTP request.
The client should prefer WebSocket when the server supports it, fall back to SSE for push when WebSocket push is unavailable, and always have HTTP as a last resort for API calls.
Transport negotiation¶
On connection, the gateway reads the JMAP Session object. If the session advertises urn:ietf:params:jmap:websocket, the gateway may open a WebSocket connection for request/response traffic. Push notifications use WebSocket only when that capability has supportsPush: true. If WebSocket is absent or does not support push, the gateway falls back to SSE for push and HTTP for API calls. This is automatic and silent -- no user-facing configuration.
New abstractions¶
Three new traits replace the current monolithic approach.
JmapTransport¶
Handles request/response for JMAP method calls. Two implementations:
- HttpTransport -- current behavior. Each
sendcreates an HTTP POST to the JMAP API URL. Stateless. - WsTransport -- sends requests as JSON frames over a persistent WebSocket. Receives responses (and push notifications) on the same stream. Requires request/response correlation.
The MailGateway trait stays unchanged externally. LiveGateway holds a JmapTransport internally and routes calls through it. When the WS connection drops, LiveGateway transparently falls back to HttpTransport for API calls and emits an event so the supervisor knows to reconnect.
PushTransport¶
Opens a raw push notification stream. Two implementations:
- SsePushTransport -- wraps
jmap-client'sevent_source(). Returns aPushStream. - WsPushTransport -- filters the WS message stream for push notifications only. Shares the underlying WS connection with
WsTransport.
PushTransport is stateless and testable. It opens one connection and returns a stream. It does not reconnect.
ResilientPushStream¶
A wrapper that consumes PushTransport implementations and adds:
- Reconnection with backoff on stream errors
- Automatic WS → SSE fallback when WS fails repeatedly
- A single
PushStreamoutput that the supervisor consumes without knowing which transport is active
The supervisor's current reconnection logic (retry timers, push status tracking) moves into ResilientPushStream. The supervisor becomes a consumer of one stream, not a manager of transport lifecycle.
WebSocket connection lifecycle¶
One WebSocket connection per account. The connection is opened during the first sync trigger (same as the current gateway initialization). The WS stream yields an interleaved sequence of WebSocketMessage::Response and WebSocketMessage::PushNotification. A demultiplexer splits these:
- Responses are routed to the caller that sent the corresponding request (matched by
requestId). - Push notifications are forwarded to the
PushStream.
Push is enabled on the WS connection immediately after open via WebSocketPushEnable with the relevant data types, but only when the Session advertises WebSocket supportsPush: true.
Request/response correlation¶
When a caller sends a JMAP request over WS, the transport assigns a monotonically increasing requestId, sends the frame, and returns a future that resolves when the matching response arrives. A background task reads from the WS stream, matches responses to pending requests by ID, and forwards push notifications to the push stream.
This correlation layer lives in the WsTransport implementation, not in MailGateway or the supervisor.
External fork work: the jmap-client crate (stalwartlabs/jmap-client) provides low-level WS framing via connect_ws() and send_ws() but does not correlate requests to responses internally. The plan is to fork jmap-client, add a correlation layer there (oneshot channels keyed by request ID), and submit the change upstream. PostHaste uses the fork until the PR is accepted. If the upstream rejects the change, the fork continues independently.
HTTP fallback¶
When the WS connection drops (network error, server restart, TLS renegotiation), the transport:
- Switches API calls to
HttpTransportimmediately. In-flight WS requests fail; callers retry through HTTP. - Emits an event (
push.transport_fallback) so the supervisor and UI can reflect the degraded state. - Attempts to reopen the WS connection with exponential backoff.
- On successful WS reconnect, routes new API calls through WS again and re-enables WS push.
Push falls back from WS to SSE through ResilientPushStream, which tries WS first, then SSE, with independent backoff timers for each.
What doesn't change¶
MailGatewaytrait methods stay the same. Callers don't know about transport details.MailServiceis unaffected. It calls gateway methods as before.- The supervisor still triggers syncs on push notifications. It just gets them from
ResilientPushStreaminstead of managing the raw push stream directly. - The mock gateway in tests continues to work without any transport abstraction.
Phasing¶
This is a two-phase implementation:
Phase 1: Extract PushTransport trait and ResilientPushStream. Add WsPushTransport alongside existing SSE. Supervisor consumes the resilient stream. API calls stay HTTP.
Phase 2: Add JmapTransport trait. Implement WsTransport with request correlation. Route API calls through WS with HTTP fallback. This requires the jmap-client fork.
Assertions¶
| ID | Sev. | Assertion |
|---|---|---|
| ws-preferred | MUST | WebSocket is attempted before SSE for push only when the server advertises WebSocket supportsPush |
| sse-fallback | MUST | If WebSocket is unavailable or fails, push falls back to SSE without user intervention |
| http-fallback | MUST | If the WebSocket connection drops, API calls transparently fall back to HTTP |
| gateway-unchanged | MUST | MailGateway trait methods do not change; transport is an internal concern of the gateway |
| correlation-by-id | MUST | WebSocket responses are matched to requests by requestId, not by ordering |
| resilient-stream | MUST | ResilientPushStream reconnects automatically and falls back WS→SSE without supervisor involvement |
| single-ws-per-account | SHOULD | Each account maintains at most one WebSocket connection for both API and push |