jmap-base-client 0.1.2

RFC 8620 JMAP base client — auth-agnostic, session fetch, blob, SSE, WebSocket
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
# 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 `JmapRequest` to `session.api_url`, receive `JmapResponse`
- **Blob upload/download** — RFC 8620 §6.1/§6.2 with optional SHA-256 integrity check
- **SSE event stream** — async `Stream` of `SseFrame` values from the server push channel
- **WebSocket transport** — RFC 8887 request/response and `StateChange` push frames
- **Auth** — `BearerAuth`, `BasicAuth`, `NoneAuth`; pluggable via `AuthProvider` trait
- **Transport** — `DefaultTransport` (webpki roots) and `CustomCaTransport` (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`:

```toml
[dependencies]
jmap-base-client = { path = "../crate-jmap-base-client" }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
```

### Basic setup: construct client, fetch session, make a call

```rust
use jmap_base_client::{
    BearerAuth, ClientConfig, JmapClient, JmapRequestBuilder, extract_response,
};
use serde_json::json;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let auth = BearerAuth::new("my-token")?;
    let client = JmapClient::new_plain(auth, "https://jmap.example.com", ClientConfig::default())?;

    // Fetch the session object (GET /.well-known/jmap)
    let session = client.fetch_session().await?;

    // Find the primary account for JMAP Mail
    let account_id = session
        .primary_account_id("urn:ietf:params:jmap:mail")
        .expect("server must have a primary mail account");

    // Build a multi-method request
    let mut builder = JmapRequestBuilder::new(&[
        "urn:ietf:params:jmap:core",
        "urn:ietf:params:jmap:mail",
    ]);
    builder.add_call(
        "Mailbox/get",
        json!({ "accountId": account_id, "ids": null }),
        "r1",
    )?;
    let request = builder.build()?;

    // POST to session.api_url
    let response = client.call(&session.api_url, &request).await?;

    // Extract the typed result for call ID "r1"
    let mailboxes: serde_json::Value = extract_response(&response, "r1")?;
    println!("{mailboxes:#}");

    Ok(())
}
```

### Auth variants

```rust
use jmap_base_client::{BearerAuth, BasicAuth, NoneAuth, JmapClient, ClientConfig};

// Bearer token (most common for modern JMAP servers)
let auth = BearerAuth::new("my-oauth-token")?;
let client = JmapClient::new_plain(auth, "https://jmap.example.com", ClientConfig::default())?;

// HTTP Basic (username + password)
let auth = BasicAuth::new("alice@example.com", "s3cr3t")?;
let client = JmapClient::new_plain(auth, "https://jmap.example.com", ClientConfig::default())?;

// No auth header (public server or pre-authenticated proxy)
let client = JmapClient::new_plain(NoneAuth, "https://jmap.example.com", ClientConfig::default())?;
```

`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

```rust
use jmap_base_client::{BearerAuth, CustomCaTransport, JmapClient, ClientConfig};

let ca_der = std::fs::read("/etc/jmap/private-ca.der")?;
let transport = CustomCaTransport::new(ca_der);
let auth = BearerAuth::new("my-token")?;
let client = JmapClient::new(transport, auth, "https://100.64.1.1:8008", ClientConfig::default())?;
```

`CustomCaTransport` takes a DER-encoded CA certificate. It can be combined with
any `AuthProvider`.

### Blob upload

```rust
use jmap_base_client::expand_url_template;

let data = bytes::Bytes::from(std::fs::read("photo.jpg")?);
let upload_resp = client
    .upload_blob(&session.upload_url, account_id, data, "image/jpeg")
    .await?;

println!("blob ID: {}", upload_resp.blob_id);
println!("size:    {} bytes", upload_resp.size);
```

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

```rust
use jmap_base_client::DownloadBlobParams;

let bytes = client
    .download_blob(DownloadBlobParams {
        download_url_template: &session.download_url,
        account_id,
        blob_id: "Gbc4c377-...",
        name: "photo.jpg",
        accept_type: Some("image/jpeg"),
        expected_sha256: None, // pass Some("hex...") to verify integrity
    })
    .await?;
```

Use `DownloadBlobParams` as a struct literal to avoid confusion between the
string-typed fields.

### SSE event stream

```rust
use futures::StreamExt;
use jmap_base_client::{SseEvent, expand_url_template};

// expand_url_template expands RFC 6570 Level-1 templates from the Session object
let url = expand_url_template(
    &session.event_source_url,
    &[
        ("types", "*"),
        ("closeafter", "no"),
        ("ping", "0"),
    ],
)?;

let mut stream = client.subscribe_events(&url, None).await?;

while let Some(frame) = stream.next().await {
    let frame = frame?;
    match frame.event {
        SseEvent::StateChange(sc) => {
            for (account_id, changes) in &sc.changed {
                for (type_name, new_state) in changes {
                    println!("{account_id}: {type_name} changed to {new_state}");
                }
            }
        }
        SseEvent::Unknown { event_type, .. } => {
            // keepalive ping or unrecognized event type — safe to ignore
            let _ = event_type;
        }
    }
    // Track frame.id for Last-Event-ID on reconnect
}
```

`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)

```rust
use jmap_base_client::{connect_ws, WsFrame, JmapRequestBuilder};

// Get the WebSocket URL from the session capability
let ws_cap = session
    .websocket_capability()?
    .expect("server must support JMAP WebSocket");

let auth_header = client_auth.auth_header(); // from your AuthProvider
let mut ws = connect_ws(&ws_cap.url, auth_header).await?;

// Send a JMAP request over the WebSocket
let mut builder = JmapRequestBuilder::new(&["urn:ietf:params:jmap:core"]);
builder.add_call("Core/echo", serde_json::json!({}), "c1")?;
let req = builder.build()?;
ws.send_request(&req, Some("c1")).await?;

// Receive frames in a loop
while let Some(frame) = ws.next_frame().await {
    match frame? {
        WsFrame::StateChange(sc) => { /* handle push */ }
        WsFrame::Response(resp) => { /* handle method response */ }
        WsFrame::Unknown { .. } => { /* forward-compat: ignore */ }
    }
}
```

### Runnable example

A runnable end-to-end demo lives in [`examples/session_fetch.rs`](examples/session_fetch.rs):

```sh
cargo run --example session_fetch -p jmap-base-client
```

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`

```rust
// For public internet servers (webpki trust roots)
pub fn new_plain(
    auth: impl AuthProvider + 'static,
    base_url: &str,
    config: ClientConfig,
) -> Result<Self, ClientError>;

// For custom transports (private CA, client certs, etc.)
pub fn new(
    transport: impl TransportConfig,
    auth: impl AuthProvider + 'static,
    base_url: &str,
    config: ClientConfig,
) -> Result<Self, ClientError>;

pub async fn fetch_session(&self) -> Result<Session, ClientError>;

pub async fn call(
    &self,
    api_url: &str,
    req: &JmapRequest,
) -> Result<JmapResponse, ClientError>;

pub async fn upload_blob(
    &self,
    upload_url_template: &str,
    account_id: &str,
    data: bytes::Bytes,
    content_type: &str,
) -> Result<BlobUploadResponse, ClientError>;

pub async fn download_blob(
    &self,
    params: DownloadBlobParams<'_>,
) -> Result<bytes::Bytes, ClientError>;

pub async fn subscribe_events(
    &self,
    event_source_url: &str,      // expand template first with expand_url_template()
    last_event_id: Option<&str>,
) -> Result<BoxStream<'static, Result<SseFrame, ClientError>>, ClientError>;
```

`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>`

```rust
pub fn extract_response<T: DeserializeOwned>(
    resp: &JmapResponse,
    call_id: &str,
) -> Result<T, ClientError>;
```

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`

```rust
let mut builder = JmapRequestBuilder::new(&[
    "urn:ietf:params:jmap:core",
    "urn:ietf:params:jmap:mail",
]);
builder.add_call("Email/get", json!({ "accountId": id, "ids": ids }), "c1")?;
builder.add_call("Mailbox/get", json!({ "accountId": id, "ids": null }), "c2")?;
let request = builder.build()?;
```

- `new(using)` — pass every capability URI required by the methods in this request
- `add_call(method, args, call_id)` — returns `Err` on duplicate `call_id`
- `build()` — returns `Err` if no calls were added

### `ClientConfig`

```rust
#[non_exhaustive]
pub struct ClientConfig {
    pub request_timeout: Duration,   // default: 30 s
    pub max_session_body: u64,        // default: 1 MiB
    pub max_call_body: u64,           // default: 8 MiB
    pub max_download_body: u64,       // default: 64 MiB
    pub max_upload_response_body: u64, // default: 1 MiB (response only)
    pub max_sse_frame: usize,         // default: 1 MiB
    pub max_ws_message: usize,        // default: 1 MiB (per WebSocket frame)
}
```

`ClientConfig` is `#[non_exhaustive]`. Construct it with:

```rust
ClientConfig::default()

// or override individual fields:
ClientConfig {
    request_timeout: Duration::from_secs(60),
    max_download_body: 256 * 1024 * 1024,
    ..ClientConfig::default()
}
```

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:

```rust
pub struct Session {
    pub capabilities: HashMap<String, serde_json::Value>,
    pub accounts: HashMap<String, AccountInfo>,
    pub primary_accounts: HashMap<String, String>,
    pub username: String,
    pub api_url: String,
    pub download_url: String,   // RFC 6570 Level-1 template
    pub upload_url: String,     // RFC 6570 Level-1 template
    pub event_source_url: String, // RFC 6570 Level-1 template
    pub state: State,
}
```

Helper methods:

```rust
// Primary account ID for a capability
session.primary_account_id("urn:ietf:params:jmap:mail") -> Option<&str>

// WebSocket capability object (RFC 8887)
session.websocket_capability() -> Result<Option<WebSocketCapability>, ClientError>
```

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:

```rust
let url = expand_url_template(
    &session.upload_url,
    &[("accountId", "A13824")],
)?;
```

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:

```rust
pub trait AuthProvider: Send + Sync {
    fn auth_header(&self) -> Option<(&str, &str)>; // (header-name, header-value) or None
}
```

`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):

```rust
// In jmap-mail-client:
pub trait JmapMailExt {
    async fn email_get(
        &self,
        session: &Session,
        account_id: &str,
        ids: &[&str],
    ) -> Result<EmailGetResponse, ClientError>;
}

impl JmapMailExt for JmapClient {
    async fn email_get(&self, session: &Session, account_id: &str, ids: &[&str])
        -> Result<EmailGetResponse, ClientError>
    {
        let mut builder = JmapRequestBuilder::new(&[
            "urn:ietf:params:jmap:core",
            "urn:ietf:params:jmap:mail",
        ]);
        builder.add_call(
            "Email/get",
            serde_json::json!({ "accountId": account_id, "ids": ids }),
            "c1",
        )?;
        let resp = self.call(&session.api_url, &builder.build()?).await?;
        extract_response(&resp, "c1")
    }
}

// Caller:
use jmap_mail_client::JmapMailExt;
let emails = client.email_get(&session, account_id, &[]).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_events` returns an `async Stream` that terminates when the server closes the connection. Reconnect logic (with exponential backoff and `Last-Event-ID` header) is the caller's responsibility.
- **No WebSocket ping/pong keepalive.** `WsSession` does not send RFC 6455 ping frames. If your server closes idle WebSocket connections, implement keepalive in the caller.
- **`fetch_session` is not cached.** Call `fetch_session` once at startup or after receiving a `StateChange` that indicates the session state has changed. Calling it on every request adds unnecessary latency.
- **`request_timeout` applies per-call.** The timeout covers the entire request-response cycle for `fetch_session`, `call`, `upload_blob`, and `download_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 with `ClientError::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 no `CancellationToken`-style argument, no `abort_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 in `tokio::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_frame` loop) are cancel-safe in this sense: dropping the stream / session releases the underlying HTTP / WebSocket connection without corrupting subsequent operations on unrelated streams. See `JmapClient::subscribe_events` and `WsSession` rustdoc for the per-API contract.

---

## References

- [RFC 8620](https://www.rfc-editor.org/rfc/rfc8620) — JMAP Core (session,
  blob, SSE, request/response format)
- [RFC 8621](https://www.rfc-editor.org/rfc/rfc8621) — JMAP for Mail
- [RFC 8887](https://www.rfc-editor.org/rfc/rfc8887) — JMAP over WebSocket
- [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750) — Bearer Token Usage
- [RFC 7617](https://www.rfc-editor.org/rfc/rfc7617) — HTTP Basic Authentication
- [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570) — URI Template