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::{SseFrame, parse_sse_block};
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::{AuthProvider, BearerAuth, DefaultTransport, 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 && len > limit
418 {
419 return Err(ClientError::ResponseTooLarge { actual: len, limit });
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 [`connect_ws_with_limit`](crate::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 /// [`BearerAuth`](crate::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 any URL that is not
863 /// `wss://` (plaintext `ws://` is rejected).
864 /// See [`connect_ws_with_limit`](crate::connect_ws_with_limit) for full error semantics.
865 pub async fn connect_ws_session(
866 &self,
867 ws_url: &str,
868 auth_header: Option<crate::auth::AuthHeader<'_>>,
869 ) -> Result<crate::ws::WsSession, ClientError> {
870 crate::ws::connect_ws_with_limit(ws_url, auth_header, self.config.max_ws_message).await
871 }
872
873 /// Open an SSE connection via a [`Session`]-supplied
874 /// URL template (bd:JMAP-6r7c.64).
875 ///
876 /// Type-safe convenience wrapper over [`Self::subscribe_events`] —
877 /// expands `session.event_source_url` internally using the
878 /// caller-supplied [`SubscribeEventsSessionParams`] template
879 /// variables. The caller cannot accidentally pass `session.api_url`
880 /// or any other URL field because no URL is exposed at the call
881 /// site.
882 ///
883 /// Template variables that are `None` expand to an empty string,
884 /// per the RFC 8620 §7.3 default-omission semantics. Most JMAP
885 /// servers accept `types=` (subscribe to all types),
886 /// `closeafter=` (stay-open), and `ping=` (no server pings) as
887 /// defaults; if your server requires explicit values, supply them
888 /// via the params struct.
889 pub async fn subscribe_events_session(
890 &self,
891 session: &Session,
892 params: SubscribeEventsSessionParams<'_>,
893 ) -> Result<futures::stream::BoxStream<'static, Result<SseFrame, ClientError>>, ClientError>
894 {
895 let SubscribeEventsSessionParams {
896 types,
897 close_after,
898 ping,
899 last_event_id,
900 } = params;
901
902 // RFC 8620 §7.3 event_source_url variables: types, closeafter, ping.
903 // ping is u32; format to a String only when needed so the empty-default
904 // path stays allocation-free.
905 let ping_owned = ping.map(|p| p.to_string());
906 let vars = [
907 ("types", types.unwrap_or("")),
908 ("closeafter", close_after.map_or("", CloseAfter::as_str)),
909 ("ping", ping_owned.as_deref().unwrap_or("")),
910 ];
911 let expanded = crate::blob::expand_url_template(session.event_source_url.as_str(), &vars)?;
912 self.subscribe_events(&expanded, last_event_id).await
913 }
914
915 /// Like [`Self::subscribe_events`], but reconnects automatically when
916 /// the connection drops (RFC 8620 §7.3).
917 ///
918 /// Each reconnect sends `Last-Event-ID` with the most recent
919 /// [`SseFrame::id`] seen (or `last_event_id` if no frame carried one
920 /// yet), so the server can replay changes the client missed. Frames are
921 /// yielded exactly as `subscribe_events` yields them; reconnects are
922 /// invisible to the caller.
923 ///
924 /// # Retry rules
925 ///
926 /// - **Retried:** the server closing the stream, transport failures
927 /// (connect, mid-stream body errors), and HTTP 408, 429 and 5xx.
928 /// - **Fatal** (yielded as the last item, then the stream ends):
929 /// `AuthFailed`, other HTTP 4xx, a wrong `Content-Type`,
930 /// `SseFrameTooLarge`, and invalid URLs or header values (including
931 /// a `Last-Event-ID` that is not a valid HTTP field value). Retrying
932 /// these cannot succeed.
933 ///
934 /// The first connection is made without delay. A reconnect after a
935 /// connection that delivered at least one frame is also immediate
936 /// (`closeafter=state` servers close after every event). Otherwise each
937 /// consecutive failure waits per [`SseReconnectPolicy`]: the delay
938 /// doubles from `initial_delay` up to `max_delay`, then equal jitter
939 /// picks a uniform delay in `[d/2, d]` so many clients do not reconnect
940 /// in lockstep. After `max_retries` consecutive failures the stream
941 /// yields the last error (or just ends, if the last failure was a clean
942 /// close) and stops.
943 ///
944 /// The returned stream owns a clone of this client; drop it to stop.
945 pub fn subscribe_events_reconnecting(
946 &self,
947 event_source_url: &str,
948 last_event_id: Option<&str>,
949 policy: SseReconnectPolicy,
950 ) -> futures::stream::BoxStream<'static, Result<SseFrame, ClientError>> {
951 let state = SseReconnectState {
952 client: self.clone(),
953 url: event_source_url.to_owned(),
954 last_event_id: last_event_id.map(str::to_owned),
955 policy,
956 inner: None,
957 delivered: false,
958 failures: 0,
959 };
960 futures::stream::unfold(Some(state), |state| async move {
961 let mut st = state?;
962 loop {
963 let last_err;
964 if let Some(inner) = st.inner.as_mut() {
965 match inner.next().await {
966 Some(Ok(frame)) => {
967 if let Some(id) = &frame.id {
968 st.last_event_id = Some(id.clone());
969 }
970 st.delivered = true;
971 st.failures = 0;
972 return Some((Ok(frame), Some(st)));
973 }
974 Some(Err(e)) if !sse_error_is_retryable(&e) => {
975 return Some((Err(e), None));
976 }
977 Some(Err(e)) => last_err = Some(e),
978 None => last_err = None,
979 }
980 st.inner = None;
981 if st.delivered {
982 continue;
983 }
984 } else {
985 if st.failures > 0 {
986 tokio::time::sleep(st.policy.delay_for(st.failures)).await;
987 }
988 match st
989 .client
990 .subscribe_events(&st.url, st.last_event_id.as_deref())
991 .await
992 {
993 Ok(stream) => {
994 st.inner = Some(stream);
995 st.delivered = false;
996 continue;
997 }
998 Err(e) if !sse_error_is_retryable(&e) => return Some((Err(e), None)),
999 Err(e) => last_err = Some(e),
1000 }
1001 }
1002 // Reached only on a failed attempt: a connect error, or a
1003 // connection that ended before delivering any frame.
1004 st.failures = st.failures.saturating_add(1);
1005 if st.policy.max_retries.is_some_and(|max| st.failures > max) {
1006 return last_err.map(|e| (Err(e), None));
1007 }
1008 }
1009 })
1010 .boxed()
1011 }
1012}
1013
1014/// Backoff policy for [`JmapClient::subscribe_events_reconnecting`].
1015///
1016/// Start from [`Default`] and adjust the public fields:
1017///
1018/// ```rust
1019/// # use std::time::Duration;
1020/// # use jmap_base_client::SseReconnectPolicy;
1021/// let mut policy = SseReconnectPolicy::default();
1022/// policy.max_delay = Duration::from_secs(300);
1023/// policy.max_retries = Some(10);
1024/// ```
1025#[non_exhaustive]
1026#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1027pub struct SseReconnectPolicy {
1028 /// Delay before the first retry; doubled after each further consecutive
1029 /// failure. Default: 1 s.
1030 pub initial_delay: Duration,
1031 /// Upper bound on the delay before jitter is applied. Default: 60 s.
1032 pub max_delay: Duration,
1033 /// Consecutive failed attempts to retry before giving up; `None` retries
1034 /// forever. A frame arriving resets the count. Default: `None`.
1035 pub max_retries: Option<u32>,
1036}
1037
1038impl Default for SseReconnectPolicy {
1039 fn default() -> Self {
1040 Self {
1041 initial_delay: Duration::from_secs(1),
1042 max_delay: Duration::from_secs(60),
1043 max_retries: None,
1044 }
1045 }
1046}
1047
1048impl SseReconnectPolicy {
1049 /// Delay before retry number `failures` (1-based): exponential backoff
1050 /// `d = min(initial_delay * 2^(failures-1), max_delay)`, then equal
1051 /// jitter, uniform in `[d/2, d]`.
1052 fn delay_for(&self, failures: u32) -> Duration {
1053 use std::hash::BuildHasher as _;
1054 let factor = 2u32.saturating_pow(failures.saturating_sub(1));
1055 let base = self
1056 .initial_delay
1057 .saturating_mul(factor)
1058 .min(self.max_delay);
1059 let half = base / 2;
1060 let span = u64::try_from(half.as_nanos()).unwrap_or(u64::MAX);
1061 // ponytail: jitter needs spread, not unpredictability, so a freshly
1062 // keyed std SipHasher stands in for an RNG dependency.
1063 let r = std::collections::hash_map::RandomState::new().hash_one(());
1064 half + Duration::from_nanos(r % span.saturating_add(1))
1065 }
1066}
1067
1068/// Unfold state for [`JmapClient::subscribe_events_reconnecting`].
1069struct SseReconnectState {
1070 client: JmapClient,
1071 url: String,
1072 last_event_id: Option<String>,
1073 policy: SseReconnectPolicy,
1074 inner: Option<futures::stream::BoxStream<'static, Result<SseFrame, ClientError>>>,
1075 /// The current connection has yielded at least one frame.
1076 delivered: bool,
1077 /// Consecutive failed attempts since the last delivered frame.
1078 failures: u32,
1079}
1080
1081/// `true` for errors a later reconnect can plausibly fix: transport
1082/// failures and HTTP 408 / 429 / 5xx. Builder errors (an invalid URL or
1083/// header value, such as a `Last-Event-ID` carrying control characters)
1084/// have no status either, but resending the same request cannot fix them.
1085fn sse_error_is_retryable(e: &ClientError) -> bool {
1086 match e {
1087 ClientError::Http(h) => {
1088 !h.is_builder() && h.status().is_none_or(|s| s == 408 || s == 429 || s >= 500)
1089 }
1090 _ => false,
1091 }
1092}
1093
1094/// The RFC 8620 §7.3 `closeafter` event-source variable, which "MUST be
1095/// one of" these two values.
1096#[non_exhaustive]
1097#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1098pub enum CloseAfter {
1099 /// `"state"`: "The server MUST end the HTTP response after pushing a
1100 /// state event."
1101 State,
1102 /// `"no"`: "The connection is persisted by the server as a standard
1103 /// event-source resource."
1104 No,
1105}
1106
1107impl CloseAfter {
1108 /// The wire value substituted into the URL template.
1109 pub fn as_str(self) -> &'static str {
1110 match self {
1111 Self::State => "state",
1112 Self::No => "no",
1113 }
1114 }
1115}
1116
1117/// Parameters for [`JmapClient::subscribe_events_session`]
1118/// (bd:JMAP-6r7c.64).
1119///
1120/// Carries the RFC 8620 §7.3 event_source_url template variables
1121/// (`types`, `closeafter`, `ping`) plus the optional Last-Event-ID
1122/// header (RFC 8895 §9). `None` template values expand to an empty
1123/// string per the RFC's default-omission semantics.
1124///
1125/// Construct with a struct literal:
1126///
1127/// ```rust,ignore
1128/// client.subscribe_events_session(&session, SubscribeEventsSessionParams {
1129/// types: Some("Email,Mailbox"),
1130/// close_after: Some(CloseAfter::State),
1131/// ping: Some(60),
1132/// last_event_id: Some("evt-1234"),
1133/// }).await?;
1134/// ```
1135#[derive(Debug, Clone, Copy, Default)]
1136pub struct SubscribeEventsSessionParams<'a> {
1137 /// `types` template variable: comma-separated list of JMAP data-type
1138 /// names to subscribe to (`"Email,Mailbox"`), or `"*"` for all types
1139 /// the server supports. `None` expands to an empty string.
1140 pub types: Option<&'a str>,
1141 /// `closeafter` template variable (see [`CloseAfter`]). `None`
1142 /// expands to an empty string.
1143 pub close_after: Option<CloseAfter>,
1144 /// `ping` template variable: server-ping interval in seconds, or
1145 /// `0` to disable. `None` expands to an empty string.
1146 pub ping: Option<u32>,
1147 /// Optional `Last-Event-ID` header for resumption (RFC 8895 §9).
1148 pub last_event_id: Option<&'a str>,
1149}
1150
1151/// Find the method response matching `call_id` in `resp` and deserialize its
1152/// arguments into `T`.
1153///
1154/// Returns [`ClientError::MethodNotFound`] if no invocation with the given
1155/// call_id exists. Returns [`ClientError::MethodError`] if any invocation
1156/// with the matching call_id is a JMAP `"error"` response (RFC 8620 §3.6.2),
1157/// or [`ClientError::Parse`] if that error object has no string `type` or a
1158/// malformed `description` or `limit`.
1159///
1160/// # Multiple invocations sharing a call_id
1161///
1162/// Per RFC 8620 §3.2, a single method call may produce multiple invocations
1163/// in the response — for example, `Foo/copy` with `onSuccessDestroyOriginal:
1164/// true` produces both a `Foo/copy` and an implicit `Foo/set` invocation,
1165/// both stamped with the same call_id (RFC 8620 §5.8 example, lines 3158–
1166/// 3180). This function handles that case by:
1167///
1168/// 1. **Errors take precedence.** If any invocation matching `call_id`
1169/// has method name `"error"`, this function returns that error. A
1170/// success response cannot mask a sibling error response with the same
1171/// call_id — silently returning the success while the server reported
1172/// failure would be data loss for the caller.
1173/// 2. Otherwise, the **first** non-error invocation matching `call_id`
1174/// is deserialized into `T`. In the §5.8 implicit-method case both
1175/// invocations are successes; the first is the primary response and
1176/// is what the caller wants.
1177///
1178/// # Contract: `call_id` is the only matcher (bd:JMAP-6r7c.23)
1179///
1180/// The method-name field of the matching invocation is **not** checked
1181/// against `T`. The function trusts the caller's choice of `call_id` to
1182/// identify the invocation and trusts the server's choice of method name
1183/// to be consistent with the request it was answering. Two consequences
1184/// callers should be aware of:
1185///
1186/// - **Wrong `T` may still parse.** If a caller asks for
1187/// `extract_response::<EmailGetResponse>(&resp, "r1")` but the matching
1188/// invocation is actually a `Mailbox/get` response, `serde_json::from_value`
1189/// will succeed on any structural overlap between the two shapes (both
1190/// carry `accountId`, an `ids`/`list` field, etc.) and return a
1191/// default-shaped `EmailGetResponse`. The function cannot detect this
1192/// because it has no view of what method name the caller expected.
1193/// - **Server ordering is trusted.** When multiple non-error invocations
1194/// share a `call_id`, the function returns the first one in
1195/// `resp.method_responses` order. RFC 8620 §5.8 implies the primary
1196/// response is first, but the spec does not normatively require it; a
1197/// non-conformant server that returns the implicit method first would
1198/// silently get the wrong invocation deserialized as `T`.
1199///
1200/// Callers that need method-name verification or want to disambiguate
1201/// among multiple non-error matches should iterate
1202/// `resp.method_responses` directly. The field is public and each
1203/// [`jmap_types::Invocation`] has public `name`, `arguments` and `call_id`
1204/// fields.
1205///
1206/// # Panics
1207///
1208/// This function does not catch panics from `T`'s [`serde::Deserialize`]
1209/// implementation. If a custom `T` type's `deserialize` impl panics — e.g.
1210/// because of an `.unwrap()` on a sub-field — the panic propagates through
1211/// `extract_response` to the caller's await point. The standard derived
1212/// `Deserialize` impls in the workspace type crates (`jmap-types`,
1213/// `jmap-mail-types`, etc.) do not panic; this caveat only affects
1214/// hand-rolled `Deserialize` impls outside the workspace
1215/// (bd:JMAP-6r7c.44).
1216///
1217/// This function is `pub` so extension crates (`jmap-chat-client`,
1218/// `jmap-mail-client`) can use it to extract typed results from a
1219/// [`jmap_types::JmapResponse`] without depending on internal details.
1220pub fn extract_response<T: serde::de::DeserializeOwned>(
1221 resp: &jmap_types::JmapResponse,
1222 call_id: &str,
1223) -> Result<T, ClientError> {
1224 // Two-pass scan: errors take precedence per §3.6.1, so look for an error
1225 // invocation first; otherwise return the first non-error invocation.
1226 if let Some(args) = find_error_args(resp, call_id) {
1227 let err = <jmap_types::JmapError as serde::Deserialize>::deserialize(args)
1228 .map_err(ClientError::from_parse)?;
1229 return Err(ClientError::MethodError(err));
1230 }
1231
1232 // The early return above already handled the error case; every remaining
1233 // candidate is a success invocation, so we just take the first one.
1234 let inv = resp
1235 .method_responses
1236 .iter()
1237 .find(|inv| inv.call_id == call_id)
1238 .ok_or_else(|| ClientError::MethodNotFound(call_id.to_owned()))?;
1239 <T as serde::Deserialize>::deserialize(&inv.arguments).map_err(ClientError::from_parse)
1240}
1241
1242/// Return the full JMAP `"error"` object for `call_id`, including any
1243/// properties beyond `type` and `description`.
1244///
1245/// RFC 8620 §3.6.2: "Other properties may be present with further
1246/// information". [`ClientError::MethodError`] carries the properties
1247/// [`jmap_types::JmapError`] models; call this with the same `resp` and
1248/// `call_id` after [`extract_response`] returns `MethodError` to read the
1249/// rest.
1250///
1251/// Uses the same lookup as [`extract_response`]: the first invocation
1252/// matching `call_id` whose method name is `"error"`. Returns `None` if
1253/// there is no such invocation or its arguments are not a JSON object.
1254pub fn method_error_details<'a>(
1255 resp: &'a jmap_types::JmapResponse,
1256 call_id: &str,
1257) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
1258 find_error_args(resp, call_id)?.as_object()
1259}
1260
1261/// Arguments of the first `"error"` invocation matching `call_id`.
1262fn find_error_args<'a>(
1263 resp: &'a jmap_types::JmapResponse,
1264 call_id: &str,
1265) -> Option<&'a serde_json::Value> {
1266 resp.method_responses
1267 .iter()
1268 .find(|inv| inv.call_id == call_id && inv.name == "error")
1269 .map(|inv| &inv.arguments)
1270}
1271
1272/// Decode as much valid UTF-8 as possible from `raw` into `buf`, draining
1273/// consumed (or definitively-invalid) bytes from `raw`.
1274///
1275/// Three cases:
1276/// - `raw` is fully valid UTF-8: pushed entirely to `buf`, `raw` cleared.
1277/// - `raw` ends with an **incomplete** multi-byte sequence (`error_len == None`):
1278/// the valid prefix is pushed to `buf` and drained from `raw`; the incomplete
1279/// head bytes stay in `raw` for the next chunk to complete them.
1280/// - `raw` contains a **definitively invalid** byte sequence (`error_len == Some(n)`):
1281/// the valid prefix is pushed to `buf`; the valid prefix AND the `n` invalid
1282/// bytes are drained from `raw` so they do not accumulate.
1283///
1284/// The caller is responsible for capping `raw.len()` before calling this
1285/// function; unbounded growth from a hostile server that never completes a
1286/// sequence is prevented by the 1 MiB `SSE_BUF_SIZE_LIMIT` check in
1287/// `subscribe_events`.
1288fn decode_utf8_chunk(raw: &mut Vec<u8>, buf: &mut String) {
1289 match std::str::from_utf8(raw) {
1290 Ok(s) => {
1291 buf.push_str(s);
1292 raw.clear();
1293 }
1294 Err(e) => {
1295 let valid_up_to = e.valid_up_to();
1296 // valid_up_to is the documented prefix length that IS valid UTF-8
1297 // by construction (std::str::Utf8Error contract). The crate-root
1298 // #![forbid(unsafe_code)] rules out std::str::from_utf8_unchecked,
1299 // which would be the canonical zero-cost path for "I have a slice
1300 // I know is valid UTF-8 but the type system doesn't". With unsafe
1301 // forbidden, .expect() is the cheapest legal alternative; the
1302 // expect message tracks the soundness argument so a future
1303 // reviewer does not "fix" this with unwrap_or_default()
1304 // (which would silently drop valid UTF-8 prefix bytes on a
1305 // hypothetical impossible failure path) (bd:JMAP-6r7c.54).
1306 buf.push_str(
1307 std::str::from_utf8(&raw[..valid_up_to])
1308 .expect("valid_up_to is a valid UTF-8 boundary"),
1309 );
1310 match e.error_len() {
1311 Some(n) => {
1312 // Definitively invalid: drain the valid prefix AND the
1313 // invalid bytes so they do not accumulate in raw.
1314 let drain_end = (valid_up_to + n).min(raw.len());
1315 raw.drain(..drain_end);
1316 }
1317 None => {
1318 // Incomplete multi-byte sequence: drain only the valid
1319 // prefix. The incomplete head stays in raw until the next
1320 // chunk arrives with the missing continuation bytes.
1321 raw.drain(..valid_up_to);
1322 }
1323 }
1324 }
1325 }
1326}
1327
1328/// Extract the URL scheme as a borrowed slice of `url`.
1329///
1330/// Returns the prefix before `"://"`. The returned slice is in the
1331/// *original* case of the input (callers must use [`str::eq_ignore_ascii_case`]
1332/// for the comparison per RFC 3986 §3.1, which says only schemes are
1333/// case-insensitive). Returns `None` if `"://"` is not present in `url`.
1334/// URL templates containing `{variable}` syntax are handled correctly
1335/// because the extraction is a prefix scan, not a full URL parse.
1336///
1337/// Returning a borrowed slice rather than a `to_ascii_lowercase()` String
1338/// avoids allocating on every scheme check; previously each call to
1339/// `url_scheme` produced a fresh String the size of the URL prefix
1340/// (bd:JMAP-6lsm.10).
1341fn url_scheme(url: &str) -> Option<&str> {
1342 url.split_once("://").map(|(scheme, _)| scheme)
1343}
1344
1345/// `true` if `url`'s scheme prefix is `http` or `https` (case-insensitive,
1346/// RFC 3986 §3.1).
1347fn is_http_or_https(url: &str) -> bool {
1348 url_scheme(url)
1349 .is_some_and(|s| s.eq_ignore_ascii_case("http") || s.eq_ignore_ascii_case("https"))
1350}
1351
1352/// Parse and validate a JMAP base URL.
1353///
1354/// The input must be:
1355/// - non-empty
1356/// - syntactically valid (parses with [`url::Url::parse`])
1357/// - http or https scheme (case-insensitive per RFC 3986 §3.1)
1358/// - origin-only: no explicit path component, no query string, no fragment.
1359/// "Origin" here means scheme + host + optional port — the form a JMAP
1360/// server is identified by. Trailing slashes are accepted (the URL
1361/// parser normalizes them away).
1362///
1363/// Examples that PASS: `https://jmap.example.com`,
1364/// `https://jmap.example.com/`, `https://10.0.0.1:8008`,
1365/// `http://localhost:8080`.
1366///
1367/// Examples that FAIL: `""`, `https://example.com/api`,
1368/// `https://example.com?query=1`, `https://example.com#fragment`,
1369/// `ftp://example.com`.
1370///
1371/// Extracted from [`JmapClient::new`] so the constructor reads as
1372/// 'parse-don't-validate' rather than a six-step inline procedure
1373/// (bd:JMAP-6lsm.26).
1374fn parse_base_url(base_url: &str) -> Result<url::Url, ClientError> {
1375 if base_url.is_empty() {
1376 return Err(ClientError::InvalidArgument(
1377 "base_url may not be empty".into(),
1378 ));
1379 }
1380 let parsed = url::Url::parse(base_url)
1381 .map_err(|e| ClientError::InvalidArgument(format!("base_url is not a valid URL: {e}")))?;
1382 // Reject RFC 3986 user-info (`user:password@host`) before any further
1383 // validation. The url crate's Display impl echoes user-info verbatim
1384 // (lossless-round-trip, NOT safe-display — RFC 3986 §7.5 warns
1385 // against the latter), so an unrejected user-info value would surface
1386 // through every subsequent error message, every reqwest::Error's
1387 // url() field, and any tracing instrumentation that captures the
1388 // base URL or a derived ClientError. JMAP authenticates via the
1389 // Authorization header (AuthProvider trait), not URL user-info; the
1390 // user-info component has no legitimate use here (bd:JMAP-6r7c.58).
1391 //
1392 // The error message DOES NOT echo `base_url` back — doing so would
1393 // route the password into the error chain we are trying to keep it
1394 // out of.
1395 if !parsed.username().is_empty() || parsed.password().is_some() {
1396 return Err(ClientError::InvalidArgument(
1397 "base_url must not contain user-info (`user:password@host`); pass credentials via \
1398 AuthProvider instead"
1399 .into(),
1400 ));
1401 }
1402 let scheme = parsed.scheme();
1403 if scheme != "http" && scheme != "https" {
1404 return Err(ClientError::InvalidArgument(format!(
1405 "base_url scheme must be http or https, got: {scheme:?}"
1406 )));
1407 }
1408 let path = parsed.path();
1409 // url::Url::path() returns "/" for root-only URLs (no path segments);
1410 // any value other than "/" means the URL contains an explicit path component.
1411 if path != "/" {
1412 return Err(ClientError::InvalidArgument(format!(
1413 "base_url must not have a path component, got: {path:?}"
1414 )));
1415 }
1416 if parsed.query().is_some() {
1417 return Err(ClientError::InvalidArgument(
1418 "base_url must not have a query string".into(),
1419 ));
1420 }
1421 if parsed.fragment().is_some() {
1422 return Err(ClientError::InvalidArgument(
1423 "base_url must not have a fragment".into(),
1424 ));
1425 }
1426 Ok(parsed)
1427}
1428
1429/// Validate that `url` uses an http or https scheme.
1430///
1431/// Called at the top of each public method that accepts a URL parameter
1432/// (`call`, `subscribe_events`, `upload_blob`, `download_blob`). This is a
1433/// defense-in-depth check: the primary protection is
1434/// [`validate_session_url_schemes`] which rejects bad URLs in the Session
1435/// document at fetch time. This check makes each individual call site
1436/// self-defending against accidentally passing a non-http URL (e.g. from a
1437/// test fixture or a misused API).
1438///
1439/// Returns [`ClientError::InvalidArgument`] if the scheme is not http/https.
1440pub(crate) fn require_http_url(url: &str) -> Result<(), ClientError> {
1441 if !is_http_or_https(url) {
1442 return Err(ClientError::InvalidArgument(format!(
1443 "URL must have http or https scheme, got: {url:?}"
1444 )));
1445 }
1446 Ok(())
1447}
1448
1449/// Validate the *schemes only* of the four session URL fields.
1450///
1451/// Three of the four (`upload_url`, `download_url`, `event_source_url`) are
1452/// RFC 6570 URI templates carrying `{accountId}`, `{blobId}`, `{types}`,
1453/// etc. They cannot be fully parsed as URLs without first being expanded
1454/// with the relevant variables, but the scheme prefix is always concrete
1455/// (templates put the scheme on the left of `://`). This function does the
1456/// minimal check that the scheme prefix is `http://` or `https://` — it
1457/// does NOT verify that the templates carry the required variables, that
1458/// the host is well-formed, or that the path is reachable.
1459///
1460/// Renamed from `validate_session_urls` for accuracy (bd:JMAP-6lsm.23):
1461/// the name implied stronger validation than the function actually
1462/// performs.
1463fn validate_session_url_schemes(session: &Session) -> Result<(), ClientError> {
1464 // Mixed types (JmapUrl, JmapUrlTemplate) deliberately — both expose
1465 // .as_str() identically. Iterating &str values keeps the validation
1466 // loop oblivious to the typed-URL distinction (bd:JMAP-6r7c.40),
1467 // which is the right scope for a scheme check.
1468 for url in [
1469 session.api_url.as_str(),
1470 session.upload_url.as_str(),
1471 session.download_url.as_str(),
1472 session.event_source_url.as_str(),
1473 ] {
1474 if !is_http_or_https(url) {
1475 return Err(ClientError::InvalidSession(format!(
1476 "session URL has non-http/https scheme: {url:?}"
1477 )));
1478 }
1479 }
1480 Ok(())
1481}
1482
1483// ---------------------------------------------------------------------------
1484// Tests
1485// ---------------------------------------------------------------------------
1486
1487#[cfg(test)]
1488mod tests {
1489 use super::SseReconnectPolicy;
1490 use super::decode_utf8_chunk;
1491 use std::time::Duration;
1492
1493 /// Oracle: the `SseReconnectPolicy` / `subscribe_events_reconnecting`
1494 /// contract — retry `n` waits a uniform delay in `[d/2, d]` where
1495 /// `d = min(initial_delay * 2^(n-1), max_delay)`. With 100 ms / 1 s the
1496 /// expected `d` per retry is 100, 200, 400, 800, then capped at 1000
1497 /// (also for an absurd retry count, which must not overflow).
1498 #[test]
1499 fn reconnect_delay_doubles_caps_and_jitters() {
1500 let policy = SseReconnectPolicy {
1501 initial_delay: Duration::from_millis(100),
1502 max_delay: Duration::from_secs(1),
1503 ..SseReconnectPolicy::default()
1504 };
1505 for (failures, d_ms) in [
1506 (1, 100),
1507 (2, 200),
1508 (3, 400),
1509 (4, 800),
1510 (5, 1000),
1511 (500, 1000),
1512 ] {
1513 let d = Duration::from_millis(d_ms);
1514 let mut seen = std::collections::HashSet::new();
1515 for _ in 0..64 {
1516 let got = policy.delay_for(failures);
1517 assert!(
1518 got >= d / 2 && got <= d,
1519 "retry {failures}: {got:?} outside [{:?}, {d:?}]",
1520 d / 2
1521 );
1522 seen.insert(got);
1523 }
1524 assert!(seen.len() > 1, "retry {failures}: delay must be jittered");
1525 }
1526 }
1527
1528 /// Oracle: all-ASCII bytes pushed to buf; raw cleared.
1529 #[test]
1530 fn decode_utf8_chunk_all_ascii() {
1531 let mut raw = b"hello".to_vec();
1532 let mut buf = String::new();
1533 decode_utf8_chunk(&mut raw, &mut buf);
1534 assert_eq!(buf, "hello");
1535 assert!(raw.is_empty());
1536 }
1537
1538 /// Oracle: complete multi-byte codepoint (U+00E9 café = 0xC3 0xA9) pushed fully.
1539 #[test]
1540 fn decode_utf8_chunk_complete_multibyte() {
1541 let mut raw = "café".as_bytes().to_vec();
1542 let mut buf = String::new();
1543 decode_utf8_chunk(&mut raw, &mut buf);
1544 assert_eq!(buf, "café");
1545 assert!(raw.is_empty());
1546 }
1547
1548 /// Oracle: first byte of a 2-byte sequence (U+00E9 = 0xC3 0xA9) arrives alone.
1549 /// error_len == None (incomplete) — the byte must be RETAINED in raw, not dropped.
1550 /// Regression test for the bug where valid_up_to.max(1) discarded this byte.
1551 #[test]
1552 fn decode_utf8_chunk_incomplete_head_retained() {
1553 let mut raw = vec![0xC3u8]; // first byte of 2-byte sequence
1554 let mut buf = String::new();
1555 decode_utf8_chunk(&mut raw, &mut buf);
1556 assert_eq!(buf, "", "no complete codepoints to push");
1557 assert_eq!(raw, vec![0xC3u8], "incomplete head must stay in raw");
1558 }
1559
1560 /// Oracle: valid ASCII prefix then first byte of a 2-byte sequence.
1561 /// Valid prefix goes to buf; incomplete head stays in raw.
1562 #[test]
1563 fn decode_utf8_chunk_prefix_then_incomplete_head() {
1564 let mut raw = vec![b'a', b'b', 0xC3u8];
1565 let mut buf = String::new();
1566 decode_utf8_chunk(&mut raw, &mut buf);
1567 assert_eq!(buf, "ab");
1568 assert_eq!(raw, vec![0xC3u8], "incomplete head must stay in raw");
1569 }
1570
1571 /// Oracle: two-call simulation — incomplete sequence completed on second call.
1572 /// This is the exact HTTP chunk-split scenario the fix targets.
1573 #[test]
1574 fn decode_utf8_chunk_split_sequence_completed() {
1575 // Chunk 1: only the first byte of U+00E9 (é = 0xC3 0xA9)
1576 let mut raw = vec![0xC3u8];
1577 let mut buf = String::new();
1578 decode_utf8_chunk(&mut raw, &mut buf);
1579 assert_eq!(raw, vec![0xC3u8], "incomplete head retained after chunk 1");
1580
1581 // Chunk 2: completion byte
1582 raw.push(0xA9u8);
1583 decode_utf8_chunk(&mut raw, &mut buf);
1584 assert_eq!(buf, "é", "character fully decoded after chunk 2");
1585 assert!(raw.is_empty());
1586 }
1587
1588 /// Oracle: definitively invalid byte (0xFF is never valid in UTF-8) is drained.
1589 #[test]
1590 fn decode_utf8_chunk_invalid_byte_drained() {
1591 let mut raw = vec![0xFFu8];
1592 let mut buf = String::new();
1593 decode_utf8_chunk(&mut raw, &mut buf);
1594 assert_eq!(buf, "");
1595 assert!(raw.is_empty(), "definitively invalid byte must be drained");
1596 }
1597
1598 /// Oracle: valid prefix then definitively invalid byte — prefix pushed and both drained.
1599 #[test]
1600 fn decode_utf8_chunk_prefix_then_invalid_drained() {
1601 let mut raw = vec![b'a', b'b', 0xFFu8];
1602 let mut buf = String::new();
1603 decode_utf8_chunk(&mut raw, &mut buf);
1604 assert_eq!(buf, "ab");
1605 assert!(raw.is_empty(), "prefix and invalid byte must be drained");
1606 }
1607
1608 /// Compile-time assertion that [`JmapClient`] is `Send + Sync + Clone`
1609 /// (bd:JMAP-6r7c.25). A future refactor that adds a non-`Sync` field
1610 /// (e.g. `Rc<T>`, `RefCell<T>`, `Cell<T>`) would fail this test in CI
1611 /// before any downstream consumer that shares a `JmapClient` across
1612 /// tokio tasks is exposed. The body is fenceposts: the function bodies
1613 /// of `assert_send`, `assert_sync`, and `assert_clone` never run; what
1614 /// matters is that the trait-bound monomorphization succeeds.
1615 #[test]
1616 fn jmap_client_is_send_sync_clone() {
1617 fn assert_send<T: Send>() {}
1618 fn assert_sync<T: Sync>() {}
1619 fn assert_clone<T: Clone>() {}
1620 assert_send::<super::JmapClient>();
1621 assert_sync::<super::JmapClient>();
1622 assert_clone::<super::JmapClient>();
1623 }
1624}