jmap-base-client
RFC 8620 base JMAP HTTP client. Handles authentication, session fetch, blob upload/download, SSE event streams, and WebSocket connections.
Extension-specific clients (jmap-mail-client, jmap-chat-client) depend on
this crate and add their method implementations as extension traits on
JmapClient.
What it is
- Session fetch — GET
/.well-known/jmap, parse and validate the Session object - API calls — POST typed
JmapRequesttosession.api_url, receiveJmapResponse - Blob upload/download — RFC 8620 §6.1/§6.2 with optional SHA-256 integrity check
- SSE event stream — async
StreamofSseFramevalues from the server push channel - WebSocket transport — RFC 8887 request/response and
StateChangepush frames - Auth —
BearerAuth,BasicAuth,NoneAuth; pluggable viaAuthProvidertrait - Transport —
DefaultTransport(webpki roots) andCustomCaTransport(private CA)
What it's for
The foundation client crate for the jmap-* family. Every extension client
in the workspace (mail, chat, contacts, calendars, tasks, filenode, sharing,
metadata) builds on this crate by adding its own Jmap*Ext extension trait
on JmapClient plus a per-extension SessionClient. Dependencies flow
downward only: jmap-types is the wire-format foundation with no async
deps; this crate brings in tokio, reqwest, and tokio-tungstenite;
extension client crates depend on this crate and add typed methods.
The private transport dependencies (reqwest for HTTP, tokio-tungstenite
for WebSocket) are wrapped in opaque HttpError, WebSocketError, and
InvalidHeaderValueError types so they stay SemVer-isolated — those crates
can be bumped or swapped without breaking downstream extensions.
How to use
Add to Cargo.toml:
[]
= { = "../crate-jmap-base-client" }
= { = "1", = ["rt-multi-thread", "macros"] }
Basic setup: construct client, fetch session, make a call
use ;
use json;
async
Auth variants
use ;
// Bearer token (most common for modern JMAP servers)
let auth = new?;
let client = new_plain?;
// HTTP Basic (username + password)
let auth = new?;
let client = new_plain?;
// No auth header (public server or pre-authenticated proxy)
let client = new_plain?;
BearerAuth::new is fallible: it rejects empty tokens, tokens containing
whitespace (RFC 6750 §2.1), and tokens with non-visible-ASCII bytes.
BasicAuth::new is fallible: it rejects usernames containing : (RFC 7617 §2).
Both pre-validate at construction time so errors surface early rather than on
the first request.
Custom CA: private or self-hosted servers
use ;
let ca_der = read?;
let transport = new;
let auth = new?;
let client = new?;
CustomCaTransport takes a DER-encoded CA certificate. It can be combined with
any AuthProvider.
Blob upload
use expand_url_template;
let data = from;
let upload_resp = client
.upload_blob
.await?;
println!;
println!;
The server-returned SHA-256 digest (if present, from the JMAP-CID extension) is
verified against the locally-computed digest. A mismatch returns
ClientError::BlobIntegrityMismatch.
Blob download
use DownloadBlobParams;
let bytes = client
.download_blob
.await?;
Use DownloadBlobParams as a struct literal to avoid confusion between the
string-typed fields.
SSE event stream
use StreamExt;
use ;
// expand_url_template expands RFC 6570 Level-1 templates from the Session object
let url = expand_url_template?;
let mut stream = client.subscribe_events.await?;
while let Some = stream.next.await
subscribe_events expands to an async Stream of SseFrame values. No
timeout is applied to the stream itself; wrap in tokio::time::timeout if you
need a deadline. The caller is responsible for reconnecting with exponential
backoff and passing the last frame.id as the last_event_id argument.
WebSocket (RFC 8887)
use ;
// Get the WebSocket URL from the session capability
let ws_cap = session
.websocket_capability?
.expect;
let auth_header = client_auth.auth_header; // from your AuthProvider
let mut ws = connect_ws.await?;
// Send a JMAP request over the WebSocket
let mut builder = new;
builder.add_call?;
let req = builder.build?;
ws.send_request.await?;
// Receive frames in a loop
while let Some = ws.next_frame.await
Runnable example
A runnable end-to-end demo lives in examples/session_fetch.rs:
It starts an in-process wiremock server with a hand-written RFC 8620 §2
Session fixture and prints the parsed username, URL fields, capabilities,
and accounts. Set JMAP_TEST_URL (and optionally JMAP_TEST_TOKEN) to point
at a real JMAP endpoint instead of the offline fixture.
API reference
JmapClient
// For public internet servers (webpki trust roots)
;
// For custom transports (private CA, client certs, etc.)
;
pub async ;
pub async ;
pub async ;
pub async ;
pub async ;
base_url must be the server origin (scheme + host + optional port) with no
path component — e.g. "https://jmap.example.com" or
"https://100.64.1.1:8008". Trailing slashes are accepted.
extract_response<T>
;
Finds the invocation matching call_id in resp.method_responses and
deserializes its arguments into T. Returns ClientError::MethodNotFound if
the call ID is absent. Returns ClientError::MethodError if the server
returned a JMAP "error" response for that call (RFC 8620 §3.6.1).
Multiple invocations sharing a call_id. Per RFC 8620 §3.2, a single
method call may produce multiple invocations in the response — for
example, Foo/copy with onSuccessDestroyOriginal: true produces both
a Foo/copy and an implicit Foo/set invocation, both stamped with
the same call_id (RFC 8620 §5.8). extract_response handles this
case by giving errors precedence: if any invocation matching call_id
is a JMAP "error" response, that error is returned even when a
sibling invocation with the same call_id succeeded. A success cannot
mask a sibling error — silently returning success while the server
reported failure would be data loss. Otherwise the first non-error
invocation matching call_id is deserialized into T. See the
function-level rustdoc for the full contract, including the "method
name is not checked against T" caveat.
Extension crates use this function to extract typed results without depending on internal crate details.
JmapRequestBuilder
let mut builder = new;
builder.add_call?;
builder.add_call?;
let request = builder.build?;
new(using)— pass every capability URI required by the methods in this requestadd_call(method, args, call_id)— returnsErron duplicatecall_idbuild()— returnsErrif no calls were added
ClientConfig
ClientConfig is #[non_exhaustive]. Construct it with:
default
// or override individual fields:
ClientConfig
All fields must be > 0; JmapClient::new validates them and returns
ClientError::InvalidArgument on violation.
request_timeout applies to fetch_session, call, upload_blob, and
download_blob. It does not apply to SSE or WebSocket streams, which are
indefinitely long by nature.
Session
Returned by fetch_session. Key fields:
Helper methods:
// Primary account ID for a capability
session.primary_account_id // WebSocket capability object (RFC 8887)
session.websocket_capability
Extension crates extract their own capability objects from
session.capabilities (values are raw serde_json::Value).
expand_url_template
Session URL fields (upload_url, download_url, event_source_url) are RFC
6570 Level-1 URI templates. Expand them before use:
let url = expand_url_template?;
Variables not present in the template are silently ignored. A variable present
in the template but not supplied returns ClientError::InvalidSession.
Auth providers
| Type | Header produced |
|---|---|
BearerAuth::new(token)? |
Authorization: Bearer <token> |
BasicAuth::new(user, password)? |
Authorization: Basic <base64(user:password)> |
NoneAuth |
(no header) |
All three implement AuthProvider. Implement the trait yourself for custom
schemes:
Box<dyn AuthProvider> and Arc<dyn AuthProvider> both implement AuthProvider.
Transport configs
| Type | When to use |
|---|---|
DefaultTransport |
Public internet servers with publicly-trusted TLS |
CustomCaTransport::new(der_bytes) |
Private CA, self-hosted, or Tailscale servers |
DefaultTransport uses the webpki root certificate store. Both set a 10-second
TCP connect timeout.
Box<dyn TransportConfig> implements TransportConfig, enabling factory
functions to return a boxed transport.
How it works
Session fetch — fetch_session GETs {base_url}/.well-known/jmap. The
response body is capped at max_session_body (default: 1 MiB). All URL fields
(api_url, upload_url, download_url, event_source_url) are validated to
have http or https scheme before the Session is returned.
API calls — call POSTs to api_url. The response body is capped at
max_call_body (default: 8 MiB). Auth is injected per-request via
AuthProvider::auth_header(). Both fetch_session and call return
ClientError::AuthFailed on HTTP 401 or 403 before reading the body.
extract_response<T> — scans resp.method_responses (a Vec of
(method_name, args, call_id) triples) for the entry whose call_id matches,
then deserializes args into T. If method_name is "error", returns
ClientError::MethodError with the server-supplied type and optional
description.
Blob upload — upload_blob expands the upload_url template, POSTs the
raw bytes with the given Content-Type, and parses the BlobUploadResponse.
If the server returns a sha256 field (JMAP-CID extension), it is compared
against a locally-computed SHA-256. A mismatch returns
ClientError::BlobIntegrityMismatch.
Blob download — download_blob expands the download_url template and
GETs the blob. The body is streamed chunk-by-chunk and capped at
max_download_body (default: 64 MiB) without buffering the entire response
first. An optional expected_sha256 field enables integrity verification.
SSE stream — subscribe_events GETs the (already-expanded)
event_source_url. The response Content-Type is verified to be
text/event-stream. The chunked body is streamed and accumulated into a string
buffer. Multi-byte UTF-8 codepoints split across HTTP chunks are handled
correctly: the incomplete head bytes are retained between chunks. Each
double-newline-delimited SSE block is parsed into an SseFrame. Buffer growth
is capped at max_sse_frame (default: 1 MiB) per frame.
WebSocket — connect_ws validates the URL scheme (ws:// or wss://),
applies a 10-second connect timeout, and returns a WsSession. Outgoing
requests are wrapped in a WsRequestFrame that injects "@type": "Request"
(RFC 8887 §4.3.2) in a single serialization pass. Incoming text frames are
dispatched on "@type": "StateChange" and "Response" are deserialized into
typed variants; malformed frames and unknown types degrade to WsFrame::Unknown
rather than closing the connection. Incoming messages are capped at
ClientConfig.max_ws_message (default: 1 MiB) per frame.
Extension trait pattern
jmap-mail-client and jmap-chat-client add typed JMAP methods to
JmapClient via extension traits (the Rust orphan rule prevents adding
inherent methods to a type from another crate):
// In jmap-mail-client:
// Caller:
use JmapMailExt;
let emails = client.email_get.await?;
Error types
ClientError covers all failure modes:
| Variant | Meaning |
|---|---|
Http(HttpError) |
Network or TLS error from the HTTP layer; may be retriable. The payload is an opaque wrapper — use HttpError::is_timeout, HttpError::status, etc. to diagnose. |
AuthFailed(u16) |
HTTP 401 or 403; fix credentials before retrying |
Parse(serde_json::Error) |
Malformed server response |
InvalidArgument(String) |
Caller bug (empty token, bad URL, duplicate call ID, etc.) |
InvalidSession(String) |
Server returned a bad Session document |
MethodNotFound(String) |
extract_response call ID not in response |
MethodError { error_type, description } |
Server returned a JMAP error for a method call |
BlobIntegrityMismatch { expected, actual } |
SHA-256 mismatch on upload or download |
ResponseTooLarge { actual, limit } |
Server response exceeded configured size cap |
SseFrameTooLarge { limit } |
Single SSE frame exceeded max_sse_frame; stream terminated |
WebSocket(WebSocketError) |
WebSocket transport error; may be retriable. The payload is an opaque wrapper — use WebSocketError::is_io, WebSocketError::is_protocol, etc. to diagnose. |
UnexpectedResponse(String) |
Server violated the JMAP protocol (wrong Content-Type, etc.) |
Serialize(serde_json::Error) |
Serialization failure in WsSession::send_request |
InvalidHeaderValue(InvalidHeaderValueError) |
A header value contained characters that are not valid HTTP header-value bytes (typically a credential string with non-printable or non-ASCII characters). |
RateLimited { retry_after } |
Server rate-limited the request; retry_after is the absolute UTC instant from RFC 9110 §10.2.3 Retry-After. The base crate does not currently produce this variant — HTTP 429 surfaces as ClientError::Http today (track HttpError::status() == Some(429) to detect it). The variant is part of the stable contract so extension crates that wrap the transport can produce it themselves and so callers can match on it now without an API break later (bd:JMAP-6lsm.3). |
HttpError, WebSocketError, and InvalidHeaderValueError are opaque
wrapper types around the underlying transport-crate errors. They keep
reqwest and tokio-tungstenite as private dependencies of this crate
so the transport can be swapped or its major version bumped without
breaking downstream callers (SemVer-isolation, bd:JMAP-6lsm.22). Use the
wrappers' accessor methods rather than trying to extract the inner
transport error type.
Gotchas
- No automatic SSE reconnect.
subscribe_eventsreturns anasync Streamthat terminates when the server closes the connection. Reconnect logic (with exponential backoff andLast-Event-IDheader) is the caller's responsibility. - No WebSocket ping/pong keepalive.
WsSessiondoes not send RFC 6455 ping frames. If your server closes idle WebSocket connections, implement keepalive in the caller. fetch_sessionis not cached. Callfetch_sessiononce at startup or after receiving aStateChangethat indicates the session state has changed. Calling it on every request adds unnecessary latency.request_timeoutapplies per-call. The timeout covers the entire request-response cycle forfetch_session,call,upload_blob, anddownload_blob. It does not apply to SSE or WebSocket streams, which are indefinitely long by design.- SSE frame size cap terminates the stream. If a single SSE frame (between double newlines) exceeds
ClientConfig::max_sse_frame(default 1 MiB), the stream is terminated withClientError::SseFrameTooLarge. Increase the cap if your server sends large push events. - Cancellation is via drop-future; there is no explicit cancel token. All four async APIs (
fetch_session,call,subscribe_events,connect_ws_session) follow the idiomatic Rust async cancellation model: drop the returned future and the underlying transport is torn down by reqwest's / tungstenite's own drop handling. There is noCancellationToken-style argument, noabort_handle()accessor, and no graceful-shutdown signal. If you need graceful shutdown (release the connection-pool slot, send a WebSocket close frame, etc.), wrap the future intokio::select!against your shutdown signal and let the drop happen on the losing branch — the connection teardown is synchronous. Long-running streams (subscribe_events,WsSession::next_frameloop) are cancel-safe in this sense: dropping the stream / session releases the underlying HTTP / WebSocket connection without corrupting subsequent operations on unrelated streams. SeeJmapClient::subscribe_eventsandWsSessionrustdoc for the per-API contract.