jmap_base_client/client.rs
1//! Auth-agnostic base JMAP HTTP client (RFC 8620).
2//!
3//! Provides [`JmapClient`] for session fetch, API calls, blob transfer,
4//! SSE event streaming, and [`extract_response`] for parsing method results.
5
6use std::sync::Arc;
7use std::time::Duration;
8
9use futures::StreamExt;
10
11use crate::auth::{AuthProvider, DefaultTransport, TransportConfig};
12use crate::error::ClientError;
13use crate::request::Session;
14use crate::sse::{parse_sse_block, SseFrame};
15
16/// Internal state threaded through the `subscribe_events` unfold loop.
17struct SseStreamState<S> {
18 stream: S,
19 /// Accumulates raw bytes from the HTTP stream before UTF-8 decoding.
20 /// Incomplete multi-byte sequences remain here until the next chunk
21 /// completes them, preventing stream termination when a codepoint is
22 /// split across adjacent chunks.
23 raw_buf: Vec<u8>,
24 buf: String,
25 /// Byte offset from which the next delimiter scan begins.
26 /// Must always be a valid UTF-8 char boundary of `buf`.
27 scan_from: usize,
28}
29
30/// Per-client configuration for timeouts and body size limits.
31///
32/// Use [`ClientConfig::default()`] for production defaults (30s timeout, RFC-safe caps).
33///
34/// This type is `#[non_exhaustive]`: callers outside this crate must use
35/// `..ClientConfig::default()` when constructing it, allowing new fields to
36/// be added in minor versions without breaking callers.
37///
38/// # Field-type rationale (bd:JMAP-6r7c.21)
39///
40/// The fields mix `u64` and `usize` integer types. The split is deliberate
41/// and tracks the underlying transport's expectations:
42///
43/// - **HTTP body caps are `u64`** (`max_session_body`, `max_call_body`,
44/// `max_download_body`, `max_upload_response_body`). `reqwest` reports
45/// `Content-Length` as `u64`, and an HTTP body can in principle exceed
46/// `usize::MAX` on a 32-bit target (4 GiB) without exceeding 64-bit
47/// limits; the cap comparison happens before accumulation.
48/// - **In-memory frame/message caps are `usize`** (`max_sse_frame`,
49/// `max_ws_message`). These bound a `Vec<u8>` or `String` that must
50/// fit in process memory; `usize` is the address-space-native type
51/// and matches what `tokio_tungstenite::tungstenite::protocol::WebSocketConfig`
52/// expects.
53///
54/// A future minor release MAY consolidate on `u64` with internal
55/// `usize::try_from` casts at the call sites that need it. The mixed-int
56/// API is a small ergonomic cost (callers cannot pass the same untyped
57/// integer literal to both kinds of field without a `_u64` or `_usize`
58/// suffix or `as usize` cast) traded for transparency to the underlying
59/// transport's contract.
60///
61/// # No cross-field invariants are enforced
62///
63/// `validate()` checks each field independently. There is intentionally
64/// no constraint that `max_sse_frame <= max_call_body` or similar — they
65/// govern different threats (per-frame buffer cap vs whole-body cap)
66/// and a deployment may rationally configure them in either order.
67/// Document the chosen values in your own deployment notes.
68#[non_exhaustive]
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ClientConfig {
71 /// Timeout for HTTP request/response cycles (fetch_session, call, upload_blob, download_blob).
72 /// Does NOT apply to SSE or WebSocket streams (which are indefinite by nature).
73 /// Must be > 0. Use `Duration::from_secs(30)` for a 30-second timeout.
74 /// Default: 30 seconds.
75 ///
76 /// `Duration::ZERO` is forbidden because `reqwest::RequestBuilder::timeout`
77 /// treats `Duration::ZERO` as "no per-request timeout" (only the
78 /// client-level `connect_timeout` applies), not "instant fail". A future
79 /// maintainer who thinks "zero means disable timeout, why not allow it?"
80 /// is reading reqwest's semantics correctly but missing that **this
81 /// crate intentionally forbids the no-timeout configuration**: a
82 /// caller-visible 30-second-by-default timeout protects against
83 /// indefinite hangs on stalled servers and against a slowloris-style
84 /// resource leak in JMAP clients that hold one outstanding request per
85 /// account. `validate()` enforces the positive-timeout invariant
86 /// (bd:JMAP-6r7c.29). Do not relax this without re-deriving the
87 /// no-timeout DoS argument.
88 pub request_timeout: std::time::Duration,
89 /// Maximum response body for fetch_session. Default: 1 MiB.
90 pub max_session_body: u64,
91 /// Maximum response body for call(). Default: 8 MiB.
92 pub max_call_body: u64,
93 /// Maximum response body for download_blob(). Default: 64 MiB.
94 pub max_download_body: u64,
95 /// Maximum size in bytes of the JSON response body returned by the
96 /// server in reply to `upload_blob()`. Does NOT cap the size of the
97 /// blob being uploaded — the JMAP server enforces that via its
98 /// `maxSizeUpload` capability. This field caps only the small JSON
99 /// envelope the server returns describing the stored blob.
100 /// Default: 1 MiB.
101 pub max_upload_response_body: u64,
102 /// Maximum byte length of a single SSE frame; applied independently to
103 /// the raw incoming bytes (pre-UTF-8 decode) and the decoded text.
104 /// Protects against memory exhaustion from a hostile or misbehaving
105 /// server that sends a single very large frame. Must be > 0.
106 /// Default: 1 MiB.
107 ///
108 /// **Memory residency note (bd:JMAP-6lsm.7):** because the raw byte
109 /// buffer and the decoded text buffer are tracked separately, the
110 /// in-flight footprint while parsing a single frame can momentarily
111 /// reach ~2 × `max_sse_frame` (raw bytes accumulated up to the limit,
112 /// plus decoded text not yet drained). If you tune this value for a
113 /// tight memory budget, plan for that 2× peak. The independent
114 /// tracking is correct for the streaming UTF-8 decoder (which needs
115 /// the raw buffer to be at least one full frame to handle split
116 /// multi-byte sequences across HTTP chunks); see `decode_utf8_chunk`
117 /// for the rationale.
118 pub max_sse_frame: usize,
119 /// Maximum byte length of a single WebSocket message (and frame). Mirrors
120 /// `max_sse_frame` for the WebSocket transport. Used by
121 /// [`JmapClient::connect_ws_session`] and threaded through to
122 /// `tokio_tungstenite::tungstenite::protocol::WebSocketConfig`'s
123 /// `max_message_size` and `max_frame_size`. Must be > 0.
124 /// Default: 1 MiB. (bd:JMAP-6lsm.5)
125 pub max_ws_message: usize,
126}
127
128impl Default for ClientConfig {
129 fn default() -> Self {
130 ClientConfig {
131 request_timeout: std::time::Duration::from_secs(30),
132 max_session_body: 1024 * 1024,
133 max_call_body: 8 * 1024 * 1024,
134 max_download_body: 64 * 1024 * 1024,
135 max_upload_response_body: 1024 * 1024,
136 max_sse_frame: 1024 * 1024,
137 max_ws_message: 1024 * 1024,
138 }
139 }
140}
141
142impl ClientConfig {
143 /// Validate that all config fields satisfy their constraints.
144 ///
145 /// Called automatically by [`JmapClient::new`]. Callers may also call
146 /// this directly to pre-validate a config before passing it to the
147 /// constructor.
148 ///
149 /// # Errors
150 ///
151 /// Returns [`ClientError::InvalidArgument`] when any field is zero or
152 /// out-of-range.
153 pub fn validate(&self) -> Result<(), ClientError> {
154 if self.max_session_body == 0 {
155 return Err(ClientError::InvalidArgument(
156 "ClientConfig.max_session_body must be > 0".into(),
157 ));
158 }
159 if self.max_call_body == 0 {
160 return Err(ClientError::InvalidArgument(
161 "ClientConfig.max_call_body must be > 0".into(),
162 ));
163 }
164 if self.max_download_body == 0 {
165 return Err(ClientError::InvalidArgument(
166 "ClientConfig.max_download_body must be > 0".into(),
167 ));
168 }
169 if self.max_upload_response_body == 0 {
170 return Err(ClientError::InvalidArgument(
171 "ClientConfig.max_upload_response_body must be > 0".into(),
172 ));
173 }
174 if self.request_timeout == std::time::Duration::ZERO {
175 // Duration::ZERO would let reqwest run requests without a
176 // per-request timeout (reqwest treats ZERO as "no timeout").
177 // Reject explicitly — see ClientConfig::request_timeout
178 // rustdoc for the DoS-resistance rationale (bd:JMAP-6r7c.29).
179 return Err(ClientError::InvalidArgument(
180 "ClientConfig.request_timeout must be > 0; Duration::ZERO would disable the per-request timeout in reqwest, not 'fail immediately'. Use Duration::from_secs(30) or similar."
181 .into(),
182 ));
183 }
184 if self.max_sse_frame == 0 {
185 return Err(ClientError::InvalidArgument(
186 "ClientConfig.max_sse_frame must be > 0".into(),
187 ));
188 }
189 if self.max_ws_message == 0 {
190 return Err(ClientError::InvalidArgument(
191 "ClientConfig.max_ws_message must be > 0".into(),
192 ));
193 }
194 Ok(())
195 }
196}
197
198/// Auth-agnostic JMAP base HTTP client.
199///
200/// Construct with [`JmapClient::new`] or [`JmapClient::new_plain`].
201/// Extension-specific clients (`jmap-chat-client`, `jmap-mail-client`) depend
202/// on this crate and add their method implementations via `impl JmapClient`.
203///
204/// # Thread-safety (bd:JMAP-6r7c.25)
205///
206/// `JmapClient` is `Send + Sync + Clone`. Share by clone across threads or
207/// `tokio::spawn` tasks; the underlying `reqwest::Client` is reference-counted
208/// (Arc-backed) and the [`AuthProvider`] trait requires `Send + Sync` on every
209/// implementation. A compile-time assertion in this crate's test suite pins
210/// the `Send + Sync` contract: a future refactor that adds a non-`Sync` field
211/// (e.g. `Rc<T>`, `RefCell<T>`, `Cell<T>`) would break the assertion in CI
212/// before any downstream consumer is exposed.
213#[derive(Clone)]
214pub struct JmapClient {
215 pub(crate) base_url: url::Url,
216 pub(crate) auth: Arc<dyn AuthProvider>,
217 pub(crate) http: reqwest::Client,
218 pub(crate) config: ClientConfig,
219}
220
221impl std::fmt::Debug for JmapClient {
222 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223 f.debug_struct("JmapClient")
224 .field("base_url", &self.base_url)
225 .field("config", &self.config)
226 .finish_non_exhaustive()
227 }
228}
229
230impl JmapClient {
231 /// Create a new client.
232 ///
233 /// `transport` configures the underlying HTTP client (TLS trust roots,
234 /// client certificates, timeouts). `auth` injects per-request credentials
235 /// (Bearer token, Basic credentials, or none). The two are independent so
236 /// any transport can be paired with any credential scheme — for example,
237 /// `CustomCaTransport` with `BearerAuth`. `base_url` must be the server
238 /// origin (scheme, host, optional port) with no path, query, or fragment
239 /// — e.g. `"https://100.64.1.1:8008"`. Trailing slashes are normalized
240 /// away by the URL parser and are therefore accepted.
241 pub fn new(
242 transport: impl TransportConfig,
243 auth: impl AuthProvider + 'static,
244 base_url: &str,
245 config: ClientConfig,
246 ) -> Result<Self, ClientError> {
247 // Parse-don't-validate: parse_base_url returns a fully-validated
248 // url::Url so the constructor body can read as four facts (parse,
249 // validate config, build transport, construct Self) rather than
250 // a procedure (bd:JMAP-6lsm.26).
251 let parsed = parse_base_url(base_url)?;
252 config.validate()?;
253 // Unwrap the opaque HttpClient at construction time (bd:JMAP-6r7c.36).
254 // The wrapper exists to keep reqwest::Client out of the
255 // TransportConfig trait signature; once the client is owned by
256 // JmapClient, the inner reqwest::Client is the working type for
257 // internal request building. into_inner is pub(crate) so external
258 // code cannot reach inside the opaque wrapper.
259 let http = transport.build_client()?.into_inner();
260 Ok(Self {
261 base_url: parsed,
262 auth: Arc::new(auth),
263 http,
264 config,
265 })
266 }
267
268 /// Convenience constructor for servers with publicly-trusted TLS.
269 ///
270 /// Equivalent to `JmapClient::new(DefaultTransport, auth, base_url, config)`.
271 /// Use [`JmapClient::new`] when you need a custom transport (e.g.
272 /// `CustomCaTransport` for a private-CA server).
273 pub fn new_plain(
274 auth: impl AuthProvider + 'static,
275 base_url: &str,
276 config: ClientConfig,
277 ) -> Result<Self, ClientError> {
278 Self::new(DefaultTransport, auth, base_url, config)
279 }
280
281 /// Create a new client sharing an existing `Arc<dyn AuthProvider>`
282 /// (bd:JMAP-6r7c.27).
283 ///
284 /// `JmapClient::new` and `new_plain` take `auth` by value and wrap it
285 /// in a fresh `Arc` internally. That is the ergonomic case for a
286 /// caller constructing one client with one auth provider. It is the
287 /// wrong shape for a caller who:
288 ///
289 /// - Operates multiple `JmapClient` instances against different
290 /// shards or accounts but uses the **same** credential holder
291 /// (e.g. a shared OAuth token-refresh state machine, a shared
292 /// service-account principal).
293 /// - Wants a credential refresh in one client to be visible to all
294 /// sibling clients without rebuilding each one.
295 ///
296 /// This constructor takes a pre-built `Arc<dyn AuthProvider>` so
297 /// callers can clone the Arc and pass clones to multiple
298 /// `JmapClient::new_with_shared_auth` calls. The auth provider is
299 /// then shared by reference-count, and any interior-mutable state
300 /// (e.g. an `RwLock<TokenState>` inside a custom `OAuthAuth`
301 /// implementation that holds a refreshable bearer) is genuinely
302 /// shared across all sibling clients.
303 ///
304 /// Arguments mirror [`JmapClient::new`] otherwise.
305 ///
306 /// ```rust,ignore
307 /// use std::sync::Arc;
308 /// use jmap_base_client::{auth::{AuthProvider, BearerAuth, DefaultTransport}, client::{JmapClient, ClientConfig}};
309 ///
310 /// let auth: Arc<dyn AuthProvider> = Arc::new(BearerAuth::new("token")?);
311 /// let shard_a = JmapClient::new_with_shared_auth(
312 /// DefaultTransport,
313 /// auth.clone(),
314 /// "https://a.example.com",
315 /// ClientConfig::default(),
316 /// )?;
317 /// let shard_b = JmapClient::new_with_shared_auth(
318 /// DefaultTransport,
319 /// auth,
320 /// "https://b.example.com",
321 /// ClientConfig::default(),
322 /// )?;
323 /// ```
324 pub fn new_with_shared_auth(
325 transport: impl TransportConfig,
326 auth: Arc<dyn AuthProvider>,
327 base_url: &str,
328 config: ClientConfig,
329 ) -> Result<Self, ClientError> {
330 let parsed = parse_base_url(base_url)?;
331 config.validate()?;
332 // Unwrap the opaque HttpClient at construction time (bd:JMAP-6r7c.36).
333 let http = transport.build_client()?.into_inner();
334 Ok(Self {
335 base_url: parsed,
336 auth,
337 http,
338 config,
339 })
340 }
341
342 /// Apply the auth header (if any) to a request builder.
343 ///
344 /// Centralises the repeated `if let Some(...) = self.auth.auth_header()` pattern
345 /// that every HTTP method uses. Callers: `fetch_session`, `call`,
346 /// `subscribe_events`, `upload_blob`, `download_blob`.
347 pub(crate) fn inject_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
348 if let Some(header) = self.auth.auth_header() {
349 builder.header(header.name(), header.expose_value())
350 } else {
351 builder
352 }
353 }
354
355 /// Returns `Err(ClientError::AuthFailed)` when the HTTP status indicates an
356 /// authentication or authorization failure.
357 ///
358 /// Specifically handles:
359 /// - 401 Unauthorized (RFC 7235 §3.1) — missing or invalid credentials
360 /// - 403 Forbidden (RFC 7235 §3.2) — credentials present but insufficient
361 ///
362 /// Called before reading the response body so callers can distinguish
363 /// permanent auth failures from transient errors without consuming the body.
364 pub(crate) fn check_auth_status(status: reqwest::StatusCode) -> Result<(), ClientError> {
365 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
366 Err(ClientError::AuthFailed(status.as_u16()))
367 } else {
368 Ok(())
369 }
370 }
371
372 /// Returns `Err(ClientError::RateLimited)` for HTTP 429 Too Many Requests
373 /// (RFC 6585 §4), with `retry_after` taken from the `Retry-After` header
374 /// (RFC 9110 §10.2.3).
375 ///
376 /// Called after [`Self::check_auth_status`] and before
377 /// `error_for_status`, which would otherwise turn the 429 into an
378 /// opaque [`ClientError::Http`].
379 pub(crate) fn check_rate_limit(resp: &reqwest::Response) -> Result<(), ClientError> {
380 if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
381 let retry_after = resp
382 .headers()
383 .get(reqwest::header::RETRY_AFTER)
384 .and_then(|v| v.to_str().ok());
385 Err(ClientError::rate_limited(
386 retry_after,
387 std::time::SystemTime::now(),
388 ))
389 } else {
390 Ok(())
391 }
392 }
393}
394
395/// Read a response body into memory, capped at `limit` bytes.
396///
397/// Performs the Content-Length pre-check as a fast-path rejection for honest
398/// oversized responses, then streams the body chunk-by-chunk and enforces the
399/// cap before each accumulation. This prevents buffering a response that
400/// exceeds the limit when Content-Length is absent (chunked Transfer-Encoding)
401/// or under-reported by a hostile server — without per-chunk streaming, a
402/// `.bytes().await` call would buffer the entire response before the
403/// post-buffer length check could fire (bd:JMAP-6r7c.1).
404///
405/// Threat model: matters when connecting to untrusted servers (federation
406/// peers, third-party JMAP services, DNS-hijacked endpoints, MITM scenarios).
407/// On low-memory devices the peak allocation from naive buffering can OOM-kill
408/// the client even if the post-buffer check would otherwise reject.
409///
410/// Returns `ClientError::ResponseTooLarge` when either the advertised
411/// Content-Length or the accumulated chunk total exceeds `limit`.
412pub(crate) async fn read_capped_body(
413 resp: reqwest::Response,
414 limit: u64,
415) -> Result<Vec<u8>, ClientError> {
416 if let Some(len) = resp.content_length() {
417 if len > limit {
418 return Err(ClientError::ResponseTooLarge { actual: len, limit });
419 }
420 }
421
422 let mut stream = resp.bytes_stream();
423 let mut body: Vec<u8> = Vec::new();
424 while let Some(chunk) = stream.next().await {
425 let chunk = chunk.map_err(ClientError::from_reqwest)?;
426 let new_len = body.len() as u64 + chunk.len() as u64;
427 if new_len > limit {
428 return Err(ClientError::ResponseTooLarge {
429 actual: new_len,
430 limit,
431 });
432 }
433 body.extend_from_slice(&chunk);
434 }
435 Ok(body)
436}
437
438impl JmapClient {
439 /// Fetch the JMAP Session object from `{base_url}/.well-known/jmap` (RFC 8620 §2).
440 ///
441 /// The response body is capped at 1 MiB. Returns `ClientError::ResponseTooLarge`
442 /// if the server sends more. Session URL fields (`apiUrl`, `uploadUrl`,
443 /// `downloadUrl`, `eventSourceUrl`) are validated to have http/https scheme;
444 /// a non-http scheme returns `ClientError::InvalidSession`.
445 ///
446 /// Returns `ClientError::AuthFailed` on HTTP 401 or 403, and
447 /// `ClientError::RateLimited` on HTTP 429.
448 ///
449 /// # Charset
450 ///
451 /// The response body MUST be UTF-8-encoded JSON (RFC 8259 §8.1). A server
452 /// that sends UTF-16 or UTF-32 JSON — even with a matching
453 /// `charset=utf-16` `Content-Type` parameter — will fail to parse as
454 /// `ClientError::Parse`; the error does not specifically call out the
455 /// charset mismatch. Every shipped JMAP server uses UTF-8, but a
456 /// non-conformant server can produce a confusing parse error
457 /// (bd:JMAP-6r7c.28).
458 pub async fn fetch_session(&self) -> Result<Session, ClientError> {
459 let limit = self.config.max_session_body;
460 let url = self.base_url.join(".well-known/jmap").map_err(|e| {
461 // base_url was pre-validated in JmapClient::new; this path is
462 // unreachable in practice but must be handled for completeness.
463 ClientError::InvalidSession(format!("cannot construct session URL: {e}"))
464 })?;
465
466 let req = self.inject_auth(self.http.get(url).timeout(self.config.request_timeout));
467
468 let resp = {
469 let raw_resp = req.send().await.map_err(ClientError::from_reqwest)?;
470 Self::check_auth_status(raw_resp.status())?;
471 Self::check_rate_limit(&raw_resp)?;
472 raw_resp
473 .error_for_status()
474 .map_err(ClientError::from_reqwest)?
475 };
476
477 // Stream the body chunk-by-chunk with the cap enforced before each
478 // accumulation. The Content-Length pre-check inside read_capped_body
479 // is a fast-path rejection for honest oversized responses; the
480 // streaming cap is the actual DOS guard against a hostile server
481 // that under-reports Content-Length or omits it entirely under
482 // chunked Transfer-Encoding (bd:JMAP-6r7c.1).
483 let body = read_capped_body(resp, limit).await?;
484
485 let session: Session = serde_json::from_slice(&body).map_err(ClientError::from_parse)?;
486
487 validate_session_url_schemes(&session)?;
488
489 Ok(session)
490 }
491
492 /// POST a [`jmap_types::JmapRequest`] to `api_url` and return the parsed [`jmap_types::JmapResponse`]
493 /// (RFC 8620 §3.3).
494 ///
495 /// `api_url` is taken as an explicit parameter (not from `self`) because the
496 /// caller holds a [`Session`] and selects the correct URL from it.
497 ///
498 /// The response body is capped at 8 MiB. Returns `ClientError::ResponseTooLarge`
499 /// if the server sends more.
500 ///
501 /// Returns `ClientError::AuthFailed` on HTTP 401 or 403, and
502 /// `ClientError::RateLimited` on HTTP 429.
503 ///
504 /// # Charset
505 ///
506 /// The response body MUST be UTF-8-encoded JSON (RFC 8259 §8.1). A server
507 /// that sends UTF-16 or UTF-32 JSON — even with a matching
508 /// `charset=utf-16` `Content-Type` parameter — will fail to parse as
509 /// `ClientError::Parse`; the error does not specifically call out the
510 /// charset mismatch. Every shipped JMAP server uses UTF-8, but a
511 /// non-conformant server can produce a confusing parse error
512 /// (bd:JMAP-6r7c.28).
513 ///
514 /// # See also
515 ///
516 /// Prefer [`JmapClient::call_session`] when you have a [`Session`] —
517 /// it picks the correct URL field automatically and prevents the
518 /// "I passed `session.upload_url` instead of `session.api_url`"
519 /// confusion (bd:JMAP-6r7c.39).
520 pub async fn call(
521 &self,
522 api_url: &str,
523 req: &jmap_types::JmapRequest,
524 ) -> Result<jmap_types::JmapResponse, ClientError> {
525 require_http_url(api_url)?;
526 let limit = self.config.max_call_body;
527
528 let builder = self.inject_auth(
529 self.http
530 .post(api_url)
531 .json(req)
532 .timeout(self.config.request_timeout),
533 );
534
535 let resp = {
536 let raw_resp = builder.send().await.map_err(ClientError::from_reqwest)?;
537 Self::check_auth_status(raw_resp.status())?;
538 Self::check_rate_limit(&raw_resp)?;
539 raw_resp
540 .error_for_status()
541 .map_err(ClientError::from_reqwest)?
542 };
543
544 // Stream the body chunk-by-chunk with the cap enforced before each
545 // accumulation (bd:JMAP-6r7c.1). See `read_capped_body` for the DOS
546 // rationale; without per-chunk streaming, a server that under-reports
547 // or omits Content-Length can force unbounded allocation here.
548 let body = read_capped_body(resp, limit).await?;
549
550 let jmap_resp: jmap_types::JmapResponse =
551 serde_json::from_slice(&body).map_err(ClientError::from_parse)?;
552
553 Ok(jmap_resp)
554 }
555
556 /// POST a [`jmap_types::JmapRequest`] to the `api_url` field of `session`
557 /// and return the parsed response (bd:JMAP-6r7c.39).
558 ///
559 /// Type-safe alternative to [`JmapClient::call`]: takes a [`Session`]
560 /// reference and reads `session.api_url` internally. The
561 /// "I passed `session.upload_url` instead of `session.api_url`"
562 /// confusion is impossible at the call site because the caller does
563 /// not select a URL — only `Session::api_url` is used.
564 ///
565 /// Same body cap, auth, and error semantics as [`JmapClient::call`].
566 pub async fn call_session(
567 &self,
568 session: &Session,
569 req: &jmap_types::JmapRequest,
570 ) -> Result<jmap_types::JmapResponse, ClientError> {
571 self.call(session.api_url.as_str(), req).await
572 }
573
574 /// Open an SSE connection to `event_source_url` and return an async stream
575 /// of parsed [`SseFrame`]s (RFC 8620 §7.3).
576 ///
577 /// # URI template expansion
578 ///
579 /// `Session.event_source_url` is a URI template (RFC 6570 Level-1) with
580 /// variables `types`, `closeafter`, and `ping`. You **must** expand it
581 /// before passing it to this function, or the server will receive the
582 /// literal text `{types}` in the URL and return an error. Use
583 /// [`expand_url_template`](crate::expand_url_template):
584 ///
585 /// ```rust,ignore
586 /// let url = jmap_base_client::expand_url_template(
587 /// &session.event_source_url,
588 /// &[("types", "*"), ("closeafter", "no"), ("ping", "0")],
589 /// )?;
590 /// let stream = client.subscribe_events(&url, None).await?;
591 /// ```
592 ///
593 /// If `last_event_id` is `Some`, sends a `Last-Event-ID` header so the
594 /// server can resume from where the previous stream left off.
595 ///
596 /// Buffer growth is capped at [`ClientConfig::max_sse_frame`] bytes per
597 /// frame (default: 1 MiB). If a single SSE frame exceeds this limit the
598 /// stream yields `ClientError::SseFrameTooLarge` and terminates.
599 ///
600 /// No timeout is applied to this call or to the resulting stream. The
601 /// connect timeout (10 s, TCP only) is the only deadline enforced. If the
602 /// server stalls before sending HTTP response headers, or later goes silent
603 /// on the open connection, this call or the stream will hang indefinitely.
604 /// Wrap the entire call and/or stream iteration in [`tokio::time::timeout`]
605 /// if you need to bound either phase.
606 ///
607 /// Returns `ClientError::AuthFailed` on HTTP 401 or 403 before the stream
608 /// starts.
609 ///
610 /// # Stream drop and cancellation (bd:JMAP-6r7c.24)
611 ///
612 /// The returned `BoxStream` may be dropped at any point — mid-frame,
613 /// while awaiting `StreamExt::next`, or from inside a `tokio::select!`
614 /// losing-branch cancellation. Dropping is always safe and always
615 /// synchronous:
616 ///
617 /// - **Partial frame bytes are discarded.** Any bytes accumulated in
618 /// the internal `raw_buf` or `buf` that have not yet been parsed
619 /// into an [`SseFrame`] are lost. There is no buffering or replay
620 /// inside the client — the server is the source of truth for what
621 /// was emitted vs what was acknowledged.
622 /// - **The underlying HTTP connection is released.** The
623 /// `reqwest::Response::bytes_stream` held inside the stream is
624 /// dropped along with the stream; reqwest returns the connection
625 /// to its pool (or closes it) per its own pool policy.
626 /// - **Resumption is the caller's job.** If you want to resume from
627 /// the last successfully-parsed frame, capture the most recent
628 /// `SseFrame::id` (if the server sets one) and pass it as
629 /// `last_event_id` on the next `subscribe_events` call. The
630 /// server will replay events from that point per RFC 8895 §9.
631 ///
632 /// `tokio::select!` cancellation is the canonical use case: a caller
633 /// racing the SSE stream against a shutdown signal can drop the
634 /// stream by selecting the shutdown branch without leaking the HTTP
635 /// connection or memory.
636 pub async fn subscribe_events(
637 &self,
638 event_source_url: &str,
639 last_event_id: Option<&str>,
640 ) -> Result<futures::stream::BoxStream<'static, Result<SseFrame, ClientError>>, ClientError>
641 {
642 require_http_url(event_source_url)?;
643 let mut req = self
644 .http
645 .get(event_source_url)
646 .header("Accept", "text/event-stream");
647 if let Some(id) = last_event_id {
648 req = req.header("Last-Event-ID", id);
649 }
650 let req = self.inject_auth(req);
651
652 let resp = req.send().await.map_err(ClientError::from_reqwest)?;
653 Self::check_auth_status(resp.status())?;
654 let resp = resp.error_for_status().map_err(ClientError::from_reqwest)?;
655
656 // Verify Content-Type before streaming. A misconfigured server returning
657 // application/json would silently produce no events (no SSE delimiter found).
658 {
659 // to_ascii_lowercase(): media types are case-insensitive per RFC 7231 §3.1.1.1.
660 let ct = resp
661 .headers()
662 .get(reqwest::header::CONTENT_TYPE)
663 .and_then(|v| v.to_str().ok())
664 .unwrap_or("")
665 .to_ascii_lowercase();
666 // RFC 7231 §3.1.1.1 / RFC 9110 §8.3: the media-type "essence" (type
667 // + "/" + subtype) is bounded by ';', SP, HTAB, or end-of-string.
668 // A naive starts_with("text/event-stream") would accept
669 // "text/event-streamish" or "text/event-stream2" — exactly the bug
670 // JMAP-6lsm.2 flagged. Split off the parameter list / trailing
671 // whitespace and compare the essence exactly.
672 let essence = ct
673 .split(|c: char| c == ';' || c.is_whitespace())
674 .next()
675 .unwrap_or("");
676 if essence != "text/event-stream" {
677 return Err(ClientError::UnexpectedResponse(format!(
678 "subscribe_events: expected Content-Type text/event-stream, got: {ct:?}"
679 )));
680 }
681 }
682
683 let byte_stream = resp.bytes_stream();
684 let sse_frame_limit = self.config.max_sse_frame;
685
686 Ok(futures::stream::unfold(
687 Some(SseStreamState {
688 stream: byte_stream,
689 raw_buf: Vec::new(),
690 buf: String::new(),
691 scan_from: 0, // invariant: valid UTF-8 char boundary of buf; 0 always satisfies this
692 }),
693 move |state| async move {
694 let SseStreamState {
695 mut stream,
696 mut raw_buf,
697 mut buf,
698 mut scan_from,
699 } = state?;
700 loop {
701 // Search for any double-newline delimiter (LF/CRLF/CR variants).
702 // scan_from is set to old_len.saturating_sub(3) after each append
703 // so we only re-scan the overlap region. 3 bytes back is the
704 // minimum that covers all delimiter prefixes that can straddle a
705 // chunk boundary:
706 // - `\r\n\r\n` (4 bytes): longest prefix that fits in one chunk
707 // but is incomplete is `\r\n\r` (3 bytes) — exactly covered.
708 // - `\n\r\n` (3 bytes): longest incomplete prefix is `\n\r` (2
709 // bytes) — covered by the 3-byte overlap.
710 // - `\n\n` and `\r\r` (2 bytes each): longest incomplete prefix
711 // is 1 byte — covered by the 3-byte overlap.
712 // Since \r and \n are single-byte UTF-8 codepoints, 3 bytes back
713 // is always a valid char boundary — no adjustment needed.
714 //
715 // Do not simplify to "rescan the whole buffer" (bd:JMAP-6r7c.19).
716 // The four-delimiter min_by_key + 3-byte overlap pattern is
717 // load-bearing for four independent reasons:
718 //
719 // 1. Overlap-rescan is O(chunk_size) per chunk; whole-buffer
720 // rescan is O(n) per chunk, i.e. O(n²) over the lifetime
721 // of a long-lived SSE stream. JMAP push streams can run
722 // for hours with thousands of small events; the
723 // difference is measurable.
724 // 2. All four delimiter forms are necessary. The WHATWG
725 // EventSource spec + RFC 8895 line-terminator rules let
726 // a single server emit mixed line endings within one
727 // frame. min_by_key over all four catches the mixed
728 // `\n\r\n` case that neither `\n\n` nor `\r\n\r\n`
729 // alone catches. Removing forms produces silent
730 // frame-misalignment on those servers.
731 // 3. The walk-back to a UTF-8 char boundary on the
732 // `scan_from = old_len.saturating_sub(3)` assignment
733 // below is needed because the byte preceding scan_from
734 // might be in the middle of a multi-byte codepoint;
735 // `buf[scan_from..]` would panic on the next `.find()`
736 // without it.
737 // 4. Tests in tests/client_tests.rs pin all three line-
738 // ending variants against hand-crafted server bodies:
739 // `test_subscribe_events_crlf_line_endings`,
740 // `test_subscribe_events_lf_crlf_frame_delimiter`,
741 // and `test_subscribe_events_cr_line_endings`. The
742 // mixed `\n\r\n` case (the second test) is the
743 // trickiest and is the canonical example of why all
744 // four delimiter searches are present.
745 //
746 // Resist the "just rescan the whole buffer" refactor.
747 let frame_end = [
748 buf[scan_from..]
749 .find("\r\n\r\n")
750 .map(|p| (scan_from + p, 4usize)),
751 buf[scan_from..]
752 .find("\n\n")
753 .map(|p| (scan_from + p, 2usize)),
754 buf[scan_from..]
755 .find("\r\r")
756 .map(|p| (scan_from + p, 2usize)),
757 // Mixed: LF-terminated last field line followed by a
758 // CRLF-terminated blank line. Not detected by \n\n (the
759 // LFs are separated by a CR) or \r\n\r\n (no leading \r\n).
760 buf[scan_from..]
761 .find("\n\r\n")
762 .map(|p| (scan_from + p, 3usize)),
763 ]
764 .into_iter()
765 .flatten()
766 .min_by_key(|&(pos, _)| pos);
767
768 if let Some((pos, delim_len)) = frame_end {
769 let frame = {
770 let slice = &buf[..pos];
771 if slice.contains('\r') {
772 slice.replace("\r\n", "\n").replace('\r', "\n")
773 } else {
774 slice.to_owned()
775 }
776 };
777 buf.drain(..pos + delim_len);
778 scan_from = 0; // 0 satisfies the UTF-8 char boundary invariant
779 let sse_frame = parse_sse_block(&frame);
780 return Some((
781 Ok(sse_frame),
782 Some(SseStreamState {
783 stream,
784 raw_buf,
785 buf,
786 scan_from,
787 }),
788 ));
789 }
790
791 match stream.next().await {
792 None => return None,
793 Some(Err(e)) => {
794 return Some((Err(ClientError::from_reqwest(e)), None));
795 }
796 Some(Ok(bytes)) => {
797 // Accumulate raw bytes first. A multi-byte UTF-8 codepoint
798 // may be split across adjacent HTTP chunks; decode only the
799 // valid prefix and leave the remainder in raw_buf until the
800 // next chunk completes the sequence.
801 raw_buf.extend_from_slice(&bytes);
802 // Cap raw_buf to prevent OOM on persistent invalid UTF-8 input.
803 // Use the same limit as the decoded buf cap.
804 if raw_buf.len() > sse_frame_limit {
805 return Some((
806 Err(ClientError::SseFrameTooLarge {
807 limit: sse_frame_limit,
808 }),
809 None,
810 ));
811 }
812 let old_len = buf.len();
813 decode_utf8_chunk(&mut raw_buf, &mut buf);
814 scan_from = old_len.saturating_sub(3);
815 // Walk backward to a valid UTF-8 char boundary so that
816 // buf[scan_from..] never panics on multibyte characters.
817 while scan_from > 0 && !buf.is_char_boundary(scan_from) {
818 scan_from -= 1;
819 }
820 // Guard against unbounded buffer growth from a hostile server.
821 // Yield the error and terminate (state = None).
822 if buf.len() > sse_frame_limit {
823 return Some((
824 Err(ClientError::SseFrameTooLarge {
825 limit: sse_frame_limit,
826 }),
827 None,
828 ));
829 }
830 }
831 }
832 }
833 },
834 )
835 .boxed())
836 }
837
838 /// Open a WebSocket connection to `ws_url` using this client's
839 /// configured `max_ws_message` byte cap.
840 ///
841 /// Convenience wrapper around [`crate::ws::connect_ws_with_limit`] that
842 /// passes [`ClientConfig::max_ws_message`] as the per-message /
843 /// per-frame byte cap. Mirrors the [`Self::subscribe_events`]
844 /// pattern of "the JmapClient method uses ClientConfig; the free
845 /// function takes an explicit value".
846 ///
847 /// `ws_url` must come from the session document's WebSocket capability
848 /// URL. `auth_header` is an optional `(name, value)` pair for the
849 /// upgrade request; the auth provider on this client is NOT used here
850 /// because some servers attach WebSocket auth via cookie or session
851 /// header rather than the same scheme as HTTP requests.
852 ///
853 /// # Security
854 ///
855 /// The `auth_header` value is a credential and must not be logged or
856 /// echoed back to other systems. Treat it with the same care as a
857 /// [`crate::auth::BearerAuth`] token. Transport errors raised by this
858 /// method are constructed without the original credential bytes, but
859 /// downstream code that inspects [`ClientError`] should still avoid
860 /// printing or storing the `auth_header` itself.
861 ///
862 /// Returns [`ClientError::InvalidArgument`] for non-`ws://`/`wss://` URLs.
863 /// See [`crate::ws::connect_ws_with_limit`] for full error semantics.
864 pub async fn connect_ws_session(
865 &self,
866 ws_url: &str,
867 auth_header: Option<crate::auth::AuthHeader<'_>>,
868 ) -> Result<crate::ws::WsSession, ClientError> {
869 crate::ws::connect_ws_with_limit(ws_url, auth_header, self.config.max_ws_message).await
870 }
871
872 /// Open an SSE connection via a [`Session`]-supplied
873 /// URL template (bd:JMAP-6r7c.64).
874 ///
875 /// Type-safe convenience wrapper over [`Self::subscribe_events`] —
876 /// expands `session.event_source_url` internally using the
877 /// caller-supplied [`SubscribeEventsSessionParams`] template
878 /// variables. The caller cannot accidentally pass `session.api_url`
879 /// or any other URL field because no URL is exposed at the call
880 /// site.
881 ///
882 /// Template variables that are `None` expand to an empty string,
883 /// per the RFC 8620 §7.3 default-omission semantics. Most JMAP
884 /// servers accept `types=` (subscribe to all types),
885 /// `closeafter=` (stay-open), and `ping=` (no server pings) as
886 /// defaults; if your server requires explicit values, supply them
887 /// via the params struct.
888 pub async fn subscribe_events_session(
889 &self,
890 session: &Session,
891 params: SubscribeEventsSessionParams<'_>,
892 ) -> Result<futures::stream::BoxStream<'static, Result<SseFrame, ClientError>>, ClientError>
893 {
894 let SubscribeEventsSessionParams {
895 types,
896 close_after,
897 ping,
898 last_event_id,
899 } = params;
900
901 // RFC 8620 §7.3 event_source_url variables: types, closeafter, ping.
902 // ping is u32; format to a String only when needed so the empty-default
903 // path stays allocation-free.
904 let ping_owned = ping.map(|p| p.to_string());
905 let vars = [
906 ("types", types.unwrap_or("")),
907 ("closeafter", close_after.unwrap_or("")),
908 ("ping", ping_owned.as_deref().unwrap_or("")),
909 ];
910 let expanded = crate::blob::expand_url_template(session.event_source_url.as_str(), &vars)?;
911 self.subscribe_events(&expanded, last_event_id).await
912 }
913
914 /// Like [`Self::subscribe_events`], but reconnects automatically when
915 /// the connection drops (RFC 8620 §7.3).
916 ///
917 /// Each reconnect sends `Last-Event-ID` with the most recent
918 /// [`SseFrame::id`] seen (or `last_event_id` if no frame carried one
919 /// yet), so the server can replay changes the client missed. Frames are
920 /// yielded exactly as `subscribe_events` yields them; reconnects are
921 /// invisible to the caller.
922 ///
923 /// # Retry rules
924 ///
925 /// - **Retried:** the server closing the stream, transport failures
926 /// (connect, mid-stream body errors), and HTTP 408, 429 and 5xx.
927 /// - **Fatal** (yielded as the last item, then the stream ends):
928 /// `AuthFailed`, other HTTP 4xx, a wrong `Content-Type`,
929 /// `SseFrameTooLarge`, and invalid URLs or header values (including
930 /// a `Last-Event-ID` that is not a valid HTTP field value). Retrying
931 /// these cannot succeed.
932 ///
933 /// The first connection is made without delay. A reconnect after a
934 /// connection that delivered at least one frame is also immediate
935 /// (`closeafter=state` servers close after every event). Otherwise each
936 /// consecutive failure waits per [`SseReconnectPolicy`]: the delay
937 /// doubles from `initial_delay` up to `max_delay`, then equal jitter
938 /// picks a uniform delay in `[d/2, d]` so many clients do not reconnect
939 /// in lockstep. After `max_retries` consecutive failures the stream
940 /// yields the last error (or just ends, if the last failure was a clean
941 /// close) and stops.
942 ///
943 /// The returned stream owns a clone of this client; drop it to stop.
944 pub fn subscribe_events_reconnecting(
945 &self,
946 event_source_url: &str,
947 last_event_id: Option<&str>,
948 policy: SseReconnectPolicy,
949 ) -> futures::stream::BoxStream<'static, Result<SseFrame, ClientError>> {
950 let state = SseReconnectState {
951 client: self.clone(),
952 url: event_source_url.to_owned(),
953 last_event_id: last_event_id.map(str::to_owned),
954 policy,
955 inner: None,
956 delivered: false,
957 failures: 0,
958 };
959 futures::stream::unfold(Some(state), |state| async move {
960 let mut st = state?;
961 loop {
962 let last_err;
963 if let Some(inner) = st.inner.as_mut() {
964 match inner.next().await {
965 Some(Ok(frame)) => {
966 if let Some(id) = &frame.id {
967 st.last_event_id = Some(id.clone());
968 }
969 st.delivered = true;
970 st.failures = 0;
971 return Some((Ok(frame), Some(st)));
972 }
973 Some(Err(e)) if !sse_error_is_retryable(&e) => {
974 return Some((Err(e), None));
975 }
976 Some(Err(e)) => last_err = Some(e),
977 None => last_err = None,
978 }
979 st.inner = None;
980 if st.delivered {
981 continue;
982 }
983 } else {
984 if st.failures > 0 {
985 tokio::time::sleep(st.policy.delay_for(st.failures)).await;
986 }
987 match st
988 .client
989 .subscribe_events(&st.url, st.last_event_id.as_deref())
990 .await
991 {
992 Ok(stream) => {
993 st.inner = Some(stream);
994 st.delivered = false;
995 continue;
996 }
997 Err(e) if !sse_error_is_retryable(&e) => return Some((Err(e), None)),
998 Err(e) => last_err = Some(e),
999 }
1000 }
1001 // Reached only on a failed attempt: a connect error, or a
1002 // connection that ended before delivering any frame.
1003 st.failures = st.failures.saturating_add(1);
1004 if st.policy.max_retries.is_some_and(|max| st.failures > max) {
1005 return last_err.map(|e| (Err(e), None));
1006 }
1007 }
1008 })
1009 .boxed()
1010 }
1011}
1012
1013/// Backoff policy for [`JmapClient::subscribe_events_reconnecting`].
1014///
1015/// Start from [`Default`] and adjust the public fields:
1016///
1017/// ```rust
1018/// # use std::time::Duration;
1019/// # use jmap_base_client::SseReconnectPolicy;
1020/// let mut policy = SseReconnectPolicy::default();
1021/// policy.max_delay = Duration::from_secs(300);
1022/// policy.max_retries = Some(10);
1023/// ```
1024#[non_exhaustive]
1025#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1026pub struct SseReconnectPolicy {
1027 /// Delay before the first retry; doubled after each further consecutive
1028 /// failure. Default: 1 s.
1029 pub initial_delay: Duration,
1030 /// Upper bound on the delay before jitter is applied. Default: 60 s.
1031 pub max_delay: Duration,
1032 /// Consecutive failed attempts to retry before giving up; `None` retries
1033 /// forever. A frame arriving resets the count. Default: `None`.
1034 pub max_retries: Option<u32>,
1035}
1036
1037impl Default for SseReconnectPolicy {
1038 fn default() -> Self {
1039 Self {
1040 initial_delay: Duration::from_secs(1),
1041 max_delay: Duration::from_secs(60),
1042 max_retries: None,
1043 }
1044 }
1045}
1046
1047impl SseReconnectPolicy {
1048 /// Delay before retry number `failures` (1-based): exponential backoff
1049 /// `d = min(initial_delay * 2^(failures-1), max_delay)`, then equal
1050 /// jitter, uniform in `[d/2, d]`.
1051 fn delay_for(&self, failures: u32) -> Duration {
1052 use std::hash::BuildHasher as _;
1053 let factor = 2u32.saturating_pow(failures.saturating_sub(1));
1054 let base = self
1055 .initial_delay
1056 .saturating_mul(factor)
1057 .min(self.max_delay);
1058 let half = base / 2;
1059 let span = u64::try_from(half.as_nanos()).unwrap_or(u64::MAX);
1060 // ponytail: jitter needs spread, not unpredictability, so a freshly
1061 // keyed std SipHasher stands in for an RNG dependency.
1062 let r = std::collections::hash_map::RandomState::new().hash_one(());
1063 half + Duration::from_nanos(r % span.saturating_add(1))
1064 }
1065}
1066
1067/// Unfold state for [`JmapClient::subscribe_events_reconnecting`].
1068struct SseReconnectState {
1069 client: JmapClient,
1070 url: String,
1071 last_event_id: Option<String>,
1072 policy: SseReconnectPolicy,
1073 inner: Option<futures::stream::BoxStream<'static, Result<SseFrame, ClientError>>>,
1074 /// The current connection has yielded at least one frame.
1075 delivered: bool,
1076 /// Consecutive failed attempts since the last delivered frame.
1077 failures: u32,
1078}
1079
1080/// `true` for errors a later reconnect can plausibly fix: transport
1081/// failures and HTTP 408 / 429 / 5xx. Builder errors (an invalid URL or
1082/// header value, such as a `Last-Event-ID` carrying control characters)
1083/// have no status either, but resending the same request cannot fix them.
1084fn sse_error_is_retryable(e: &ClientError) -> bool {
1085 match e {
1086 ClientError::Http(h) => {
1087 !h.is_builder() && h.status().is_none_or(|s| s == 408 || s == 429 || s >= 500)
1088 }
1089 _ => false,
1090 }
1091}
1092
1093/// Parameters for [`JmapClient::subscribe_events_session`]
1094/// (bd:JMAP-6r7c.64).
1095///
1096/// Carries the RFC 8620 §7.3 event_source_url template variables
1097/// (`types`, `closeafter`, `ping`) plus the optional Last-Event-ID
1098/// header (RFC 8895 §9). `None` template values expand to an empty
1099/// string per the RFC's default-omission semantics.
1100///
1101/// Construct with a struct literal:
1102///
1103/// ```rust,ignore
1104/// client.subscribe_events_session(&session, SubscribeEventsSessionParams {
1105/// types: Some("Email,Mailbox"),
1106/// close_after: Some("state"),
1107/// ping: Some(60),
1108/// last_event_id: Some("evt-1234"),
1109/// }).await?;
1110/// ```
1111#[derive(Debug, Clone, Copy, Default)]
1112pub struct SubscribeEventsSessionParams<'a> {
1113 /// `types` template variable: comma-separated list of JMAP data-type
1114 /// names to subscribe to (`"Email,Mailbox"`), or `"*"` for all types
1115 /// the server supports. `None` expands to an empty string.
1116 pub types: Option<&'a str>,
1117 /// `closeafter` template variable: `"state"` to close after the
1118 /// first state-change push, `"no"` for stay-open. `None` expands to
1119 /// an empty string.
1120 pub close_after: Option<&'a str>,
1121 /// `ping` template variable: server-ping interval in seconds, or
1122 /// `0` to disable. `None` expands to an empty string.
1123 pub ping: Option<u32>,
1124 /// Optional `Last-Event-ID` header for resumption (RFC 8895 §9).
1125 pub last_event_id: Option<&'a str>,
1126}
1127
1128/// Find the method response matching `call_id` in `resp` and deserialize its
1129/// arguments into `T`.
1130///
1131/// Returns [`ClientError::MethodNotFound`] if no invocation with the given
1132/// call_id exists. Returns [`ClientError::MethodError`] if any invocation
1133/// with the matching call_id is a JMAP `"error"` response (RFC 8620 §3.6.1).
1134///
1135/// # Multiple invocations sharing a call_id
1136///
1137/// Per RFC 8620 §3.2, a single method call may produce multiple invocations
1138/// in the response — for example, `Foo/copy` with `onSuccessDestroyOriginal:
1139/// true` produces both a `Foo/copy` and an implicit `Foo/set` invocation,
1140/// both stamped with the same call_id (RFC 8620 §5.8 example, lines 3158–
1141/// 3180). This function handles that case by:
1142///
1143/// 1. **Errors take precedence.** If any invocation matching `call_id`
1144/// has method name `"error"`, this function returns that error. A
1145/// success response cannot mask a sibling error response with the same
1146/// call_id — silently returning the success while the server reported
1147/// failure would be data loss for the caller.
1148/// 2. Otherwise, the **first** non-error invocation matching `call_id`
1149/// is deserialized into `T`. In the §5.8 implicit-method case both
1150/// invocations are successes; the first is the primary response and
1151/// is what the caller wants.
1152///
1153/// # Contract: `call_id` is the only matcher (bd:JMAP-6r7c.23)
1154///
1155/// The method-name field of the matching invocation is **not** checked
1156/// against `T`. The function trusts the caller's choice of `call_id` to
1157/// identify the invocation and trusts the server's choice of method name
1158/// to be consistent with the request it was answering. Two consequences
1159/// callers should be aware of:
1160///
1161/// - **Wrong `T` may still parse.** If a caller asks for
1162/// `extract_response::<EmailGetResponse>(&resp, "r1")` but the matching
1163/// invocation is actually a `Mailbox/get` response, `serde_json::from_value`
1164/// will succeed on any structural overlap between the two shapes (both
1165/// carry `accountId`, an `ids`/`list` field, etc.) and return a
1166/// default-shaped `EmailGetResponse`. The function cannot detect this
1167/// because it has no view of what method name the caller expected.
1168/// - **Server ordering is trusted.** When multiple non-error invocations
1169/// share a `call_id`, the function returns the first one in
1170/// `resp.method_responses` order. RFC 8620 §5.8 implies the primary
1171/// response is first, but the spec does not normatively require it; a
1172/// non-conformant server that returns the implicit method first would
1173/// silently get the wrong invocation deserialized as `T`.
1174///
1175/// Callers that need method-name verification or want to disambiguate
1176/// among multiple non-error matches should iterate
1177/// `resp.method_responses` directly. The field is public and each
1178/// [`jmap_types::Invocation`] has public `name`, `arguments` and `call_id`
1179/// fields.
1180///
1181/// # Panics
1182///
1183/// This function does not catch panics from `T`'s [`serde::Deserialize`]
1184/// implementation. If a custom `T` type's `deserialize` impl panics — e.g.
1185/// because of an `.unwrap()` on a sub-field — the panic propagates through
1186/// `extract_response` to the caller's await point. The standard derived
1187/// `Deserialize` impls in the workspace type crates (`jmap-types`,
1188/// `jmap-mail-types`, etc.) do not panic; this caveat only affects
1189/// hand-rolled `Deserialize` impls outside the workspace
1190/// (bd:JMAP-6r7c.44).
1191///
1192/// This function is `pub` so extension crates (`jmap-chat-client`,
1193/// `jmap-mail-client`) can use it to extract typed results from a
1194/// [`jmap_types::JmapResponse`] without depending on internal details.
1195pub fn extract_response<T: serde::de::DeserializeOwned>(
1196 resp: &jmap_types::JmapResponse,
1197 call_id: &str,
1198) -> Result<T, ClientError> {
1199 // Two-pass scan: errors take precedence per §3.6.1, so look for an error
1200 // invocation first; otherwise return the first non-error invocation.
1201 if let Some(args) = find_error_args(resp, call_id) {
1202 let err_type = args
1203 .get("type")
1204 .and_then(|v| v.as_str())
1205 .unwrap_or("serverError") // safe: fallback literal, not user input
1206 .to_owned();
1207 let description = args
1208 .get("description")
1209 .and_then(|v| v.as_str())
1210 .map(str::to_owned);
1211 return Err(ClientError::MethodError {
1212 error_type: err_type,
1213 description,
1214 });
1215 }
1216
1217 // The early return above already handled the error case; every remaining
1218 // candidate is a success invocation, so we just take the first one.
1219 let inv = resp
1220 .method_responses
1221 .iter()
1222 .find(|inv| inv.call_id == call_id)
1223 .ok_or_else(|| ClientError::MethodNotFound(call_id.to_owned()))?;
1224 <T as serde::Deserialize>::deserialize(&inv.arguments).map_err(ClientError::from_parse)
1225}
1226
1227/// Return the full JMAP `"error"` object for `call_id`, including any
1228/// properties beyond `type` and `description`.
1229///
1230/// RFC 8620 §3.6.2: "Other properties may be present with further
1231/// information". [`ClientError::MethodError`] carries only `type` and
1232/// `description`; call this with the same `resp` and `call_id` after
1233/// [`extract_response`] returns `MethodError` to read the rest.
1234///
1235/// Uses the same lookup as [`extract_response`]: the first invocation
1236/// matching `call_id` whose method name is `"error"`. Returns `None` if
1237/// there is no such invocation or its arguments are not a JSON object.
1238pub fn method_error_details<'a>(
1239 resp: &'a jmap_types::JmapResponse,
1240 call_id: &str,
1241) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
1242 find_error_args(resp, call_id)?.as_object()
1243}
1244
1245/// Arguments of the first `"error"` invocation matching `call_id`.
1246fn find_error_args<'a>(
1247 resp: &'a jmap_types::JmapResponse,
1248 call_id: &str,
1249) -> Option<&'a serde_json::Value> {
1250 resp.method_responses
1251 .iter()
1252 .find(|inv| inv.call_id == call_id && inv.name == "error")
1253 .map(|inv| &inv.arguments)
1254}
1255
1256/// Decode as much valid UTF-8 as possible from `raw` into `buf`, draining
1257/// consumed (or definitively-invalid) bytes from `raw`.
1258///
1259/// Three cases:
1260/// - `raw` is fully valid UTF-8: pushed entirely to `buf`, `raw` cleared.
1261/// - `raw` ends with an **incomplete** multi-byte sequence (`error_len == None`):
1262/// the valid prefix is pushed to `buf` and drained from `raw`; the incomplete
1263/// head bytes stay in `raw` for the next chunk to complete them.
1264/// - `raw` contains a **definitively invalid** byte sequence (`error_len == Some(n)`):
1265/// the valid prefix is pushed to `buf`; the valid prefix AND the `n` invalid
1266/// bytes are drained from `raw` so they do not accumulate.
1267///
1268/// The caller is responsible for capping `raw.len()` before calling this
1269/// function; unbounded growth from a hostile server that never completes a
1270/// sequence is prevented by the 1 MiB `SSE_BUF_SIZE_LIMIT` check in
1271/// `subscribe_events`.
1272fn decode_utf8_chunk(raw: &mut Vec<u8>, buf: &mut String) {
1273 match std::str::from_utf8(raw) {
1274 Ok(s) => {
1275 buf.push_str(s);
1276 raw.clear();
1277 }
1278 Err(e) => {
1279 let valid_up_to = e.valid_up_to();
1280 // valid_up_to is the documented prefix length that IS valid UTF-8
1281 // by construction (std::str::Utf8Error contract). The crate-root
1282 // #![forbid(unsafe_code)] rules out std::str::from_utf8_unchecked,
1283 // which would be the canonical zero-cost path for "I have a slice
1284 // I know is valid UTF-8 but the type system doesn't". With unsafe
1285 // forbidden, .expect() is the cheapest legal alternative; the
1286 // expect message tracks the soundness argument so a future
1287 // reviewer does not "fix" this with unwrap_or_default()
1288 // (which would silently drop valid UTF-8 prefix bytes on a
1289 // hypothetical impossible failure path) (bd:JMAP-6r7c.54).
1290 buf.push_str(
1291 std::str::from_utf8(&raw[..valid_up_to])
1292 .expect("valid_up_to is a valid UTF-8 boundary"),
1293 );
1294 match e.error_len() {
1295 Some(n) => {
1296 // Definitively invalid: drain the valid prefix AND the
1297 // invalid bytes so they do not accumulate in raw.
1298 let drain_end = (valid_up_to + n).min(raw.len());
1299 raw.drain(..drain_end);
1300 }
1301 None => {
1302 // Incomplete multi-byte sequence: drain only the valid
1303 // prefix. The incomplete head stays in raw until the next
1304 // chunk arrives with the missing continuation bytes.
1305 raw.drain(..valid_up_to);
1306 }
1307 }
1308 }
1309 }
1310}
1311
1312/// Extract the URL scheme as a borrowed slice of `url`.
1313///
1314/// Returns the prefix before `"://"`. The returned slice is in the
1315/// *original* case of the input (callers must use [`str::eq_ignore_ascii_case`]
1316/// for the comparison per RFC 3986 §3.1, which says only schemes are
1317/// case-insensitive). Returns `None` if `"://"` is not present in `url`.
1318/// URL templates containing `{variable}` syntax are handled correctly
1319/// because the extraction is a prefix scan, not a full URL parse.
1320///
1321/// Returning a borrowed slice rather than a `to_ascii_lowercase()` String
1322/// avoids allocating on every scheme check; previously each call to
1323/// `url_scheme` produced a fresh String the size of the URL prefix
1324/// (bd:JMAP-6lsm.10).
1325fn url_scheme(url: &str) -> Option<&str> {
1326 url.split_once("://").map(|(scheme, _)| scheme)
1327}
1328
1329/// `true` if `url`'s scheme prefix is `http` or `https` (case-insensitive,
1330/// RFC 3986 §3.1).
1331fn is_http_or_https(url: &str) -> bool {
1332 url_scheme(url)
1333 .is_some_and(|s| s.eq_ignore_ascii_case("http") || s.eq_ignore_ascii_case("https"))
1334}
1335
1336/// Parse and validate a JMAP base URL.
1337///
1338/// The input must be:
1339/// - non-empty
1340/// - syntactically valid (parses with [`url::Url::parse`])
1341/// - http or https scheme (case-insensitive per RFC 3986 §3.1)
1342/// - origin-only: no explicit path component, no query string, no fragment.
1343/// "Origin" here means scheme + host + optional port — the form a JMAP
1344/// server is identified by. Trailing slashes are accepted (the URL
1345/// parser normalizes them away).
1346///
1347/// Examples that PASS: `https://jmap.example.com`,
1348/// `https://jmap.example.com/`, `https://10.0.0.1:8008`,
1349/// `http://localhost:8080`.
1350///
1351/// Examples that FAIL: `""`, `https://example.com/api`,
1352/// `https://example.com?query=1`, `https://example.com#fragment`,
1353/// `ftp://example.com`.
1354///
1355/// Extracted from [`JmapClient::new`] so the constructor reads as
1356/// 'parse-don't-validate' rather than a six-step inline procedure
1357/// (bd:JMAP-6lsm.26).
1358fn parse_base_url(base_url: &str) -> Result<url::Url, ClientError> {
1359 if base_url.is_empty() {
1360 return Err(ClientError::InvalidArgument(
1361 "base_url may not be empty".into(),
1362 ));
1363 }
1364 let parsed = url::Url::parse(base_url)
1365 .map_err(|e| ClientError::InvalidArgument(format!("base_url is not a valid URL: {e}")))?;
1366 // Reject RFC 3986 user-info (`user:password@host`) before any further
1367 // validation. The url crate's Display impl echoes user-info verbatim
1368 // (lossless-round-trip, NOT safe-display — RFC 3986 §7.5 warns
1369 // against the latter), so an unrejected user-info value would surface
1370 // through every subsequent error message, every reqwest::Error's
1371 // url() field, and any tracing instrumentation that captures the
1372 // base URL or a derived ClientError. JMAP authenticates via the
1373 // Authorization header (AuthProvider trait), not URL user-info; the
1374 // user-info component has no legitimate use here (bd:JMAP-6r7c.58).
1375 //
1376 // The error message DOES NOT echo `base_url` back — doing so would
1377 // route the password into the error chain we are trying to keep it
1378 // out of.
1379 if !parsed.username().is_empty() || parsed.password().is_some() {
1380 return Err(ClientError::InvalidArgument(
1381 "base_url must not contain user-info (`user:password@host`); pass credentials via \
1382 AuthProvider instead"
1383 .into(),
1384 ));
1385 }
1386 let scheme = parsed.scheme();
1387 if scheme != "http" && scheme != "https" {
1388 return Err(ClientError::InvalidArgument(format!(
1389 "base_url scheme must be http or https, got: {scheme:?}"
1390 )));
1391 }
1392 let path = parsed.path();
1393 // url::Url::path() returns "/" for root-only URLs (no path segments);
1394 // any value other than "/" means the URL contains an explicit path component.
1395 if path != "/" {
1396 return Err(ClientError::InvalidArgument(format!(
1397 "base_url must not have a path component, got: {path:?}"
1398 )));
1399 }
1400 if parsed.query().is_some() {
1401 return Err(ClientError::InvalidArgument(
1402 "base_url must not have a query string".into(),
1403 ));
1404 }
1405 if parsed.fragment().is_some() {
1406 return Err(ClientError::InvalidArgument(
1407 "base_url must not have a fragment".into(),
1408 ));
1409 }
1410 Ok(parsed)
1411}
1412
1413/// Validate that `url` uses an http or https scheme.
1414///
1415/// Called at the top of each public method that accepts a URL parameter
1416/// (`call`, `subscribe_events`, `upload_blob`, `download_blob`). This is a
1417/// defense-in-depth check: the primary protection is
1418/// [`validate_session_url_schemes`] which rejects bad URLs in the Session
1419/// document at fetch time. This check makes each individual call site
1420/// self-defending against accidentally passing a non-http URL (e.g. from a
1421/// test fixture or a misused API).
1422///
1423/// Returns [`ClientError::InvalidArgument`] if the scheme is not http/https.
1424pub(crate) fn require_http_url(url: &str) -> Result<(), ClientError> {
1425 if !is_http_or_https(url) {
1426 return Err(ClientError::InvalidArgument(format!(
1427 "URL must have http or https scheme, got: {url:?}"
1428 )));
1429 }
1430 Ok(())
1431}
1432
1433/// Validate the *schemes only* of the four session URL fields.
1434///
1435/// Three of the four (`upload_url`, `download_url`, `event_source_url`) are
1436/// RFC 6570 URI templates carrying `{accountId}`, `{blobId}`, `{types}`,
1437/// etc. They cannot be fully parsed as URLs without first being expanded
1438/// with the relevant variables, but the scheme prefix is always concrete
1439/// (templates put the scheme on the left of `://`). This function does the
1440/// minimal check that the scheme prefix is `http://` or `https://` — it
1441/// does NOT verify that the templates carry the required variables, that
1442/// the host is well-formed, or that the path is reachable.
1443///
1444/// Renamed from `validate_session_urls` for accuracy (bd:JMAP-6lsm.23):
1445/// the name implied stronger validation than the function actually
1446/// performs.
1447fn validate_session_url_schemes(session: &Session) -> Result<(), ClientError> {
1448 // Mixed types (JmapUrl, JmapUrlTemplate) deliberately — both expose
1449 // .as_str() identically. Iterating &str values keeps the validation
1450 // loop oblivious to the typed-URL distinction (bd:JMAP-6r7c.40),
1451 // which is the right scope for a scheme check.
1452 for url in [
1453 session.api_url.as_str(),
1454 session.upload_url.as_str(),
1455 session.download_url.as_str(),
1456 session.event_source_url.as_str(),
1457 ] {
1458 if !is_http_or_https(url) {
1459 return Err(ClientError::InvalidSession(format!(
1460 "session URL has non-http/https scheme: {url:?}"
1461 )));
1462 }
1463 }
1464 Ok(())
1465}
1466
1467// ---------------------------------------------------------------------------
1468// Tests
1469// ---------------------------------------------------------------------------
1470
1471#[cfg(test)]
1472mod tests {
1473 use super::decode_utf8_chunk;
1474 use super::SseReconnectPolicy;
1475 use std::time::Duration;
1476
1477 /// Oracle: the `SseReconnectPolicy` / `subscribe_events_reconnecting`
1478 /// contract — retry `n` waits a uniform delay in `[d/2, d]` where
1479 /// `d = min(initial_delay * 2^(n-1), max_delay)`. With 100 ms / 1 s the
1480 /// expected `d` per retry is 100, 200, 400, 800, then capped at 1000
1481 /// (also for an absurd retry count, which must not overflow).
1482 #[test]
1483 fn reconnect_delay_doubles_caps_and_jitters() {
1484 let policy = SseReconnectPolicy {
1485 initial_delay: Duration::from_millis(100),
1486 max_delay: Duration::from_secs(1),
1487 ..SseReconnectPolicy::default()
1488 };
1489 for (failures, d_ms) in [
1490 (1, 100),
1491 (2, 200),
1492 (3, 400),
1493 (4, 800),
1494 (5, 1000),
1495 (500, 1000),
1496 ] {
1497 let d = Duration::from_millis(d_ms);
1498 let mut seen = std::collections::HashSet::new();
1499 for _ in 0..64 {
1500 let got = policy.delay_for(failures);
1501 assert!(
1502 got >= d / 2 && got <= d,
1503 "retry {failures}: {got:?} outside [{:?}, {d:?}]",
1504 d / 2
1505 );
1506 seen.insert(got);
1507 }
1508 assert!(seen.len() > 1, "retry {failures}: delay must be jittered");
1509 }
1510 }
1511
1512 /// Oracle: all-ASCII bytes pushed to buf; raw cleared.
1513 #[test]
1514 fn decode_utf8_chunk_all_ascii() {
1515 let mut raw = b"hello".to_vec();
1516 let mut buf = String::new();
1517 decode_utf8_chunk(&mut raw, &mut buf);
1518 assert_eq!(buf, "hello");
1519 assert!(raw.is_empty());
1520 }
1521
1522 /// Oracle: complete multi-byte codepoint (U+00E9 café = 0xC3 0xA9) pushed fully.
1523 #[test]
1524 fn decode_utf8_chunk_complete_multibyte() {
1525 let mut raw = "café".as_bytes().to_vec();
1526 let mut buf = String::new();
1527 decode_utf8_chunk(&mut raw, &mut buf);
1528 assert_eq!(buf, "café");
1529 assert!(raw.is_empty());
1530 }
1531
1532 /// Oracle: first byte of a 2-byte sequence (U+00E9 = 0xC3 0xA9) arrives alone.
1533 /// error_len == None (incomplete) — the byte must be RETAINED in raw, not dropped.
1534 /// Regression test for the bug where valid_up_to.max(1) discarded this byte.
1535 #[test]
1536 fn decode_utf8_chunk_incomplete_head_retained() {
1537 let mut raw = vec![0xC3u8]; // first byte of 2-byte sequence
1538 let mut buf = String::new();
1539 decode_utf8_chunk(&mut raw, &mut buf);
1540 assert_eq!(buf, "", "no complete codepoints to push");
1541 assert_eq!(raw, vec![0xC3u8], "incomplete head must stay in raw");
1542 }
1543
1544 /// Oracle: valid ASCII prefix then first byte of a 2-byte sequence.
1545 /// Valid prefix goes to buf; incomplete head stays in raw.
1546 #[test]
1547 fn decode_utf8_chunk_prefix_then_incomplete_head() {
1548 let mut raw = vec![b'a', b'b', 0xC3u8];
1549 let mut buf = String::new();
1550 decode_utf8_chunk(&mut raw, &mut buf);
1551 assert_eq!(buf, "ab");
1552 assert_eq!(raw, vec![0xC3u8], "incomplete head must stay in raw");
1553 }
1554
1555 /// Oracle: two-call simulation — incomplete sequence completed on second call.
1556 /// This is the exact HTTP chunk-split scenario the fix targets.
1557 #[test]
1558 fn decode_utf8_chunk_split_sequence_completed() {
1559 // Chunk 1: only the first byte of U+00E9 (é = 0xC3 0xA9)
1560 let mut raw = vec![0xC3u8];
1561 let mut buf = String::new();
1562 decode_utf8_chunk(&mut raw, &mut buf);
1563 assert_eq!(raw, vec![0xC3u8], "incomplete head retained after chunk 1");
1564
1565 // Chunk 2: completion byte
1566 raw.push(0xA9u8);
1567 decode_utf8_chunk(&mut raw, &mut buf);
1568 assert_eq!(buf, "é", "character fully decoded after chunk 2");
1569 assert!(raw.is_empty());
1570 }
1571
1572 /// Oracle: definitively invalid byte (0xFF is never valid in UTF-8) is drained.
1573 #[test]
1574 fn decode_utf8_chunk_invalid_byte_drained() {
1575 let mut raw = vec![0xFFu8];
1576 let mut buf = String::new();
1577 decode_utf8_chunk(&mut raw, &mut buf);
1578 assert_eq!(buf, "");
1579 assert!(raw.is_empty(), "definitively invalid byte must be drained");
1580 }
1581
1582 /// Oracle: valid prefix then definitively invalid byte — prefix pushed and both drained.
1583 #[test]
1584 fn decode_utf8_chunk_prefix_then_invalid_drained() {
1585 let mut raw = vec![b'a', b'b', 0xFFu8];
1586 let mut buf = String::new();
1587 decode_utf8_chunk(&mut raw, &mut buf);
1588 assert_eq!(buf, "ab");
1589 assert!(raw.is_empty(), "prefix and invalid byte must be drained");
1590 }
1591
1592 /// Compile-time assertion that [`JmapClient`] is `Send + Sync + Clone`
1593 /// (bd:JMAP-6r7c.25). A future refactor that adds a non-`Sync` field
1594 /// (e.g. `Rc<T>`, `RefCell<T>`, `Cell<T>`) would fail this test in CI
1595 /// before any downstream consumer that shares a `JmapClient` across
1596 /// tokio tasks is exposed. The body is fenceposts: the function bodies
1597 /// of `assert_send`, `assert_sync`, and `assert_clone` never run; what
1598 /// matters is that the trait-bound monomorphization succeeds.
1599 #[test]
1600 fn jmap_client_is_send_sync_clone() {
1601 fn assert_send<T: Send>() {}
1602 fn assert_sync<T: Sync>() {}
1603 fn assert_clone<T: Clone>() {}
1604 assert_send::<super::JmapClient>();
1605 assert_sync::<super::JmapClient>();
1606 assert_clone::<super::JmapClient>();
1607 }
1608}