Skip to main content

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}