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. 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.
Cargo.toml
[]
= { = "../crate-jmap-base-client" }
= { = "1", = ["rt-multi-thread", "macros"] }
How to use
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
Examples
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).
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 1 MiB.
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(reqwest::Error) |
Network or TLS error; may be retriable |
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(tungstenite::Error) |
WebSocket transport error; may be retriable |
UnexpectedResponse(String) |
Server violated the JMAP protocol (wrong Content-Type, etc.) |
Serialize(serde_json::Error) |
Serialization failure in WsSession::send_request |
InvalidHeaderValue |
Non-printable-ASCII bytes in a credential string |
Crate family
jmap-types
└── jmap-base-client (this crate)
├── jmap-mail-client RFC 8621 Email, Mailbox, Thread methods
└── jmap-chat-client JMAP Chat extension methods
Dependencies flow downward only. Type crates (jmap-types, jmap-mail-types,
jmap-chat-types) have no async dependencies. This crate brings in tokio and
reqwest.
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.