Skip to main content

jmap_base_client/
request.rs

1//! Base JMAP request and session types: [`JmapRequestBuilder`], [`Session`],
2//! [`AccountInfo`], [`WebSocketCapability`].
3//!
4//! Types that belong to the base JMAP client layer (RFC 8620 §2, §3.3, RFC 8887).
5//! Chat-specific and Mail-specific types live in their own crates.
6//!
7//! Types already in `jmap-types` and NOT redefined here:
8//! `Id`, `UTCDate`, `State`, `Date`, `JmapRequest`, `JmapResponse`, `Invocation`,
9//! `ResultReference`.
10
11use std::collections::BTreeMap;
12use std::collections::HashSet;
13
14use serde::{Deserialize, Serialize};
15
16use jmap_types::{Id, Invocation, JmapRequest, State};
17
18use crate::error::ClientError;
19
20// ---------------------------------------------------------------------------
21// JmapUrl / JmapUrlTemplate (bd:JMAP-6r7c.40)
22// ---------------------------------------------------------------------------
23
24/// A plain JMAP URL — no RFC 6570 template variables expected.
25///
26/// This is the typed counterpart to [`JmapUrlTemplate`] (which requires
27/// expansion before use). The Session document distinguishes the two at
28/// the type level so callers cannot accidentally pass an unexpanded
29/// template (e.g. `https://server/download/{accountId}/{blobId}/{name}`)
30/// to a function that wants a plain URL.
31///
32/// Construct via [`JmapUrl::new`]. The string is taken as-is — no URL
33/// parsing or validation; downstream consumers (reqwest, http crate)
34/// validate at the actual request site. Borrow the inner string via
35/// [`as_str`](Self::as_str) for `&str`-accepting APIs.
36///
37/// Deliberately does NOT implement `Deref<Target = str>`. Auto-coercion
38/// would defeat the type distinction with [`JmapUrlTemplate`]: both
39/// would coerce to `&str` and pass any `&str`-accepting function. Use
40/// `.as_str()` at the call site so the type transition is visible in
41/// code review.
42#[non_exhaustive]
43#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
44#[serde(transparent)]
45pub struct JmapUrl(String);
46
47impl JmapUrl {
48    /// Wrap a string as a plain JMAP URL.
49    pub fn new(url: impl Into<String>) -> Self {
50        Self(url.into())
51    }
52
53    /// Borrow the inner URL string.
54    pub fn as_str(&self) -> &str {
55        &self.0
56    }
57
58    /// Consume the wrapper and return the inner `String`.
59    pub fn into_inner(self) -> String {
60        self.0
61    }
62}
63
64impl AsRef<str> for JmapUrl {
65    fn as_ref(&self) -> &str {
66        &self.0
67    }
68}
69
70impl std::fmt::Display for JmapUrl {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.write_str(&self.0)
73    }
74}
75
76impl PartialEq<str> for JmapUrl {
77    fn eq(&self, other: &str) -> bool {
78        self.0 == other
79    }
80}
81
82impl PartialEq<&str> for JmapUrl {
83    fn eq(&self, other: &&str) -> bool {
84        self.0 == *other
85    }
86}
87
88impl PartialEq<JmapUrl> for str {
89    fn eq(&self, other: &JmapUrl) -> bool {
90        self == other.0
91    }
92}
93
94impl PartialEq<JmapUrl> for &str {
95    fn eq(&self, other: &JmapUrl) -> bool {
96        *self == other.0
97    }
98}
99
100/// An RFC 6570 Level-1 URI template — requires variable substitution
101/// before use as a request URL.
102///
103/// Typed counterpart to [`JmapUrl`]. The template carries placeholders
104/// like `{accountId}` or `{blobId}` that must be expanded via
105/// [`expand_url_template`](crate::expand_url_template) before the result
106/// can be sent to an HTTP client. Passing the unexpanded template
107/// verbatim would produce a request URL containing literal `{...}`
108/// braces, which reqwest percent-encodes to `%7B...%7D` and the server
109/// rejects.
110///
111/// Construct via [`JmapUrlTemplate::new`]. See [`JmapUrl`] for the
112/// rationale behind not implementing `Deref<Target = str>`.
113#[non_exhaustive]
114#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
115#[serde(transparent)]
116pub struct JmapUrlTemplate(String);
117
118impl JmapUrlTemplate {
119    /// Wrap a string as a JMAP URL template.
120    pub fn new(template: impl Into<String>) -> Self {
121        Self(template.into())
122    }
123
124    /// Borrow the inner template string.
125    pub fn as_str(&self) -> &str {
126        &self.0
127    }
128
129    /// Consume the wrapper and return the inner `String`.
130    pub fn into_inner(self) -> String {
131        self.0
132    }
133}
134
135impl AsRef<str> for JmapUrlTemplate {
136    fn as_ref(&self) -> &str {
137        &self.0
138    }
139}
140
141impl std::fmt::Display for JmapUrlTemplate {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        f.write_str(&self.0)
144    }
145}
146
147impl PartialEq<str> for JmapUrlTemplate {
148    fn eq(&self, other: &str) -> bool {
149        self.0 == other
150    }
151}
152
153impl PartialEq<&str> for JmapUrlTemplate {
154    fn eq(&self, other: &&str) -> bool {
155        self.0 == *other
156    }
157}
158
159impl PartialEq<JmapUrlTemplate> for str {
160    fn eq(&self, other: &JmapUrlTemplate) -> bool {
161        self == other.0
162    }
163}
164
165impl PartialEq<JmapUrlTemplate> for &str {
166    fn eq(&self, other: &JmapUrlTemplate) -> bool {
167        *self == other.0
168    }
169}
170
171// ---------------------------------------------------------------------------
172// Username / AccountName (bd:JMAP-6r7c.63)
173// ---------------------------------------------------------------------------
174
175/// The authenticated user's username (RFC 8620 §2 `username` field).
176///
177/// Typically an email address, and therefore PII under GDPR / CCPA.
178/// The wrapper exists to centralise PII handling at the type level:
179///
180/// - **`Display` redacts to `"[REDACTED]"`.** `println!("{}", username)`,
181///   `format!("{username}")`, and `tracing::info!(user = %username, ...)`
182///   all hit `Display` and therefore the redaction. The pre-bd:JMAP-6r7c.63
183///   shape (`pub username: String`) leaked the raw value through every
184///   `Display`-bearing path.
185/// - **`Debug` redacts to `Username("[REDACTED]")`.** Pre-existing
186///   `Session::Debug` already redacted explicitly; keeping the wrapper's
187///   own redaction makes the field safe to print even outside the
188///   `Session::Debug` path (e.g. if a caller stores the `Username` in
189///   a struct of their own and derives `Debug` on it).
190/// - **`Serialize` emits the raw value verbatim** because the wire
191///   format requires it for round-trip. Callers who want to scrub the
192///   field before serialising MUST do so explicitly.
193/// - **[`expose_unredacted`](Self::expose_unredacted)** is the only path
194///   to the raw string. The accessor name is deliberately explicit so
195///   the intent is visible at the call site in code review.
196///
197/// Construct via [`Username::new`] or via deserialize.
198#[non_exhaustive]
199#[derive(Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
200#[serde(transparent)]
201pub struct Username(String);
202
203impl Username {
204    /// Wrap a string as a [`Username`].
205    pub fn new(username: impl Into<String>) -> Self {
206        Self(username.into())
207    }
208
209    /// Return the raw, un-redacted username string.
210    ///
211    /// **Do not log this return value.** This is the only path to the
212    /// raw PII; the explicit accessor name surfaces the intent in code
213    /// review. Use only when the wire format requires the raw value
214    /// (e.g. constructing an `Authorization` header that re-uses the
215    /// username, building an audit log under a separate PII-handling
216    /// policy).
217    pub fn expose_unredacted(&self) -> &str {
218        &self.0
219    }
220
221    /// Consume the wrapper and return the inner `String`.
222    ///
223    /// Same handling guidance as
224    /// [`expose_unredacted`](Self::expose_unredacted) — the caller now
225    /// owns a raw `String` and must handle PII manually from that
226    /// point on.
227    pub fn into_inner(self) -> String {
228        self.0
229    }
230}
231
232impl std::fmt::Display for Username {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        f.write_str("[REDACTED]")
235    }
236}
237
238impl std::fmt::Debug for Username {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        f.debug_tuple("Username").field(&"[REDACTED]").finish()
241    }
242}
243
244impl PartialEq<str> for Username {
245    fn eq(&self, other: &str) -> bool {
246        self.0 == other
247    }
248}
249
250impl PartialEq<&str> for Username {
251    fn eq(&self, other: &&str) -> bool {
252        self.0 == *other
253    }
254}
255
256impl PartialEq<Username> for str {
257    fn eq(&self, other: &Username) -> bool {
258        self == other.0
259    }
260}
261
262impl PartialEq<Username> for &str {
263    fn eq(&self, other: &Username) -> bool {
264        *self == other.0
265    }
266}
267
268/// The human-readable account name (RFC 8620 §2 `name` field on the
269/// per-account object).
270///
271/// Typically the owner's email address, and therefore PII. Shape
272/// mirrors [`Username`]; see that type for the PII-handling rationale.
273#[non_exhaustive]
274#[derive(Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
275#[serde(transparent)]
276pub struct AccountName(String);
277
278impl AccountName {
279    /// Wrap a string as an [`AccountName`].
280    pub fn new(name: impl Into<String>) -> Self {
281        Self(name.into())
282    }
283
284    /// Return the raw, un-redacted account name. Same handling
285    /// guidance as [`Username::expose_unredacted`].
286    pub fn expose_unredacted(&self) -> &str {
287        &self.0
288    }
289
290    /// Consume the wrapper and return the inner `String`. Same
291    /// handling guidance as [`Username::into_inner`].
292    pub fn into_inner(self) -> String {
293        self.0
294    }
295}
296
297impl std::fmt::Display for AccountName {
298    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299        f.write_str("[REDACTED]")
300    }
301}
302
303impl std::fmt::Debug for AccountName {
304    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
305        f.debug_tuple("AccountName").field(&"[REDACTED]").finish()
306    }
307}
308
309impl PartialEq<str> for AccountName {
310    fn eq(&self, other: &str) -> bool {
311        self.0 == other
312    }
313}
314
315impl PartialEq<&str> for AccountName {
316    fn eq(&self, other: &&str) -> bool {
317        self.0 == *other
318    }
319}
320
321impl PartialEq<AccountName> for str {
322    fn eq(&self, other: &AccountName) -> bool {
323        self == other.0
324    }
325}
326
327impl PartialEq<AccountName> for &str {
328    fn eq(&self, other: &AccountName) -> bool {
329        *self == other.0
330    }
331}
332
333// ---------------------------------------------------------------------------
334// JmapRequestBuilder (RFC 8620 §3.3)
335// ---------------------------------------------------------------------------
336
337/// Fluent builder for multi-method [`JmapRequest`] objects.
338///
339/// Collects method calls and produces a [`JmapRequest`] ready for dispatch.
340///
341/// The `using` capability URIs passed to `new` apply to the whole request;
342/// callers must include every capability required by the methods they add.
343///
344/// Spec: RFC 8620 §3.3
345#[derive(Debug)]
346pub struct JmapRequestBuilder {
347    using: Vec<String>,
348    method_calls: Vec<Invocation>,
349    call_ids: HashSet<String>,
350}
351
352impl JmapRequestBuilder {
353    /// Create a new builder with the given capability URIs.
354    ///
355    /// The `using` list MUST include `"urn:ietf:params:jmap:core"` (always
356    /// required by RFC 8620 §3.3) plus every capability URI needed by the
357    /// methods added via [`add_call`](JmapRequestBuilder::add_call). An
358    /// incorrect or empty `using` list will cause the server to return an
359    /// `"unknownCapability"` error — the builder does not validate it.
360    pub fn new(using: &[&str]) -> Self {
361        Self {
362            using: using.iter().map(|&s| s.to_owned()).collect(),
363            method_calls: Vec::new(),
364            call_ids: HashSet::new(),
365        }
366    }
367
368    /// Add one method call to the request.
369    ///
370    /// `call_id` must be unique within this request; callers use it to match
371    /// responses back to the originating call.
372    ///
373    /// Returns `Err(ClientError::InvalidArgument)` if `call_id` has already
374    /// been used in this builder. Duplicate call IDs violate RFC 8620 §3.5.
375    pub fn add_call(
376        &mut self,
377        method: impl Into<String>,
378        args: serde_json::Value,
379        call_id: impl Into<String>,
380    ) -> Result<&mut Self, ClientError> {
381        let call_id = call_id.into();
382        if !self.call_ids.insert(call_id.clone()) {
383            return Err(ClientError::InvalidArgument(format!(
384                "JmapRequestBuilder: duplicate call_id {call_id:?}"
385            )));
386        }
387        self.method_calls
388            .push(Invocation::new(method, args, call_id));
389        Ok(self)
390    }
391
392    /// Consume the builder and produce the [`JmapRequest`].
393    ///
394    /// Returns `Err(ClientError::InvalidArgument)` if no method calls have
395    /// been added. An empty `methodCalls` array is invalid per RFC 8620 §3.3.
396    pub fn build(self) -> Result<JmapRequest, ClientError> {
397        if self.method_calls.is_empty() {
398            return Err(ClientError::InvalidArgument("no method calls added".into()));
399        }
400        Ok(JmapRequest::new(self.using, self.method_calls, None))
401    }
402}
403
404// ---------------------------------------------------------------------------
405// Session (RFC 8620 §2)
406// ---------------------------------------------------------------------------
407
408/// JMAP Session object returned by `GET /.well-known/jmap` (RFC 8620 §2).
409///
410/// Contains only the base RFC 8620 fields. Extension-specific fields
411/// (e.g. JMAP Chat `ownerUserId`) are surfaced by extension crates that
412/// parse the `capabilities` and `accounts` maps.
413///
414/// # `extra` equality is feature-flag-dependent (bd:JMAP-6r7c.43)
415///
416/// The derived `PartialEq` / `Eq` impl's behaviour on the `extra` field
417/// depends on the global `serde_json/preserve_order` feature flag — see
418/// the [crate-level note](crate#extra-field-equality-and-the-serde_jsonpreserve_order-feature-bdjmap-6r7c43)
419/// for the canonical statement.
420#[non_exhaustive]
421#[derive(Clone, PartialEq, Eq, Deserialize)]
422#[serde(rename_all = "camelCase")]
423pub struct Session {
424    /// Map of capability URI → capability object (RFC 8620 §2).
425    ///
426    /// Values are kept as raw JSON so callers can extract extension-specific
427    /// capability objects without this crate knowing their schema.
428    pub capabilities: BTreeMap<String, serde_json::Value>,
429
430    /// Map of account ID → [`AccountInfo`] (RFC 8620 §2).
431    pub accounts: BTreeMap<Id, AccountInfo>,
432
433    /// Map of capability URI → primary account ID (RFC 8620 §2).
434    pub primary_accounts: BTreeMap<String, Id>,
435
436    /// Username associated with the current credentials (RFC 8620 §2).
437    ///
438    /// # ⚠ PII — handle with the same care as a credential (bd:JMAP-6r7c.35, bd:JMAP-6r7c.63)
439    ///
440    /// This field is typically an email address and is therefore PII
441    /// under GDPR / CCPA. The [`Username`] wrapper redacts both
442    /// `Display` and `Debug`:
443    ///
444    /// - `println!("User: {}", session.username)` — `Display` renders
445    ///   `"[REDACTED]"` (per [`Username`]'s impl).
446    /// - `format!("hello {}", session.username)` — same.
447    /// - `tracing::info!(user = %session.username, ...)` — same.
448    /// - `format!("{:?}", session.username)` — `Debug` renders
449    ///   `Username("[REDACTED]")`.
450    ///
451    /// Two paths still expose the raw value, both deliberately
452    /// explicit at the call site:
453    ///
454    /// - [`Username::expose_unredacted`] returns `&str`. Use only when
455    ///   the wire requires it (constructing an `Authorization` header
456    ///   that re-uses the username, audit logging under a separate
457    ///   policy).
458    /// - `serde_json::to_string(&session)?` — `Session` derives
459    ///   `Serialize` for wire round-trip and emits the raw value
460    ///   verbatim. Callers who want to scrub PII before serialising
461    ///   MUST replace or clear the field first.
462    ///
463    /// If you need a non-PII session-scoped identifier, prefer
464    /// [`primary_accounts`](Session::primary_accounts) account IDs
465    /// (RFC 8620 §2's `accountId` is server-opaque and is not PII).
466    pub username: Username,
467
468    /// URL for JMAP API POST requests (RFC 8620 §2).
469    ///
470    /// Typed as [`JmapUrl`] (plain URL — no template variables) to
471    /// distinguish from the template-shaped URL fields below
472    /// (bd:JMAP-6r7c.40). Borrow as `&str` via
473    /// [`JmapUrl::as_str`](crate::JmapUrl::as_str) when calling
474    /// `&str`-accepting APIs.
475    pub api_url: JmapUrl,
476
477    /// URL template for blob downloads (RFC 8620 §2).
478    ///
479    /// URI Template (level 1) containing variables `accountId`, `blobId`,
480    /// `type`, and `name`. Typed as [`JmapUrlTemplate`] so it cannot be
481    /// confused with [`api_url`](Self::api_url) at the type level
482    /// (bd:JMAP-6r7c.40); expand via
483    /// [`expand_url_template`](crate::expand_url_template) before use.
484    pub download_url: JmapUrlTemplate,
485
486    /// URL template for blob uploads (RFC 8620 §2).
487    ///
488    /// URI Template (level 1) containing variable `accountId`. Typed
489    /// as [`JmapUrlTemplate`] (bd:JMAP-6r7c.40); see
490    /// [`download_url`](Self::download_url) for the type-distinction
491    /// rationale.
492    pub upload_url: JmapUrlTemplate,
493
494    /// URL template for SSE push event stream (RFC 8620 §2, §7.3).
495    ///
496    /// URI Template (level 1) containing variables `types`, `closeafter`,
497    /// and `ping`. Typed as [`JmapUrlTemplate`] (bd:JMAP-6r7c.40); see
498    /// [`download_url`](Self::download_url) for the type-distinction
499    /// rationale.
500    pub event_source_url: JmapUrlTemplate,
501
502    /// Opaque session state token (RFC 8620 §2).
503    ///
504    /// Changes whenever any session property changes. Returned in every API
505    /// response as `sessionState`; clients compare to detect staleness.
506    pub state: State,
507
508    /// Catch-all for vendor / site / private extension fields not covered
509    /// by the typed fields above. Preserves unknown fields across
510    /// deserialize/serialize round-trip per workspace extras-preservation
511    /// policy (see workspace AGENTS.md).
512    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
513    pub extra: serde_json::Map<String, serde_json::Value>,
514}
515
516impl Session {
517    /// Returns the primary account ID for the given capability URI, if set.
518    ///
519    /// Example: `session.primary_account_id("urn:ietf:params:jmap:mail")`
520    pub fn primary_account_id(&self, capability: &str) -> Option<&Id> {
521        self.primary_accounts.get(capability)
522    }
523
524    /// Returns the parsed [`WebSocketCapability`] for the JMAP WebSocket
525    /// transport, if advertised (RFC 8887).
526    ///
527    /// - `Ok(None)` — server does not advertise JMAP WebSocket support.
528    /// - `Ok(Some(...))` — WebSocket is supported; use `result.url` to connect.
529    /// - `Err` — capability key is present but the value is malformed.
530    pub fn websocket_capability(&self) -> Result<Option<WebSocketCapability>, ClientError> {
531        self.extension_capability("urn:ietf:params:jmap:websocket")
532    }
533
534    /// Returns the parsed extension-capability object for `capability_uri`,
535    /// deserialized into the caller-supplied type `T` (bd:JMAP-6r7c.22).
536    ///
537    /// Use this when an extension defines a typed capability struct (the
538    /// way `urn:ietf:params:jmap:websocket` maps to [`WebSocketCapability`])
539    /// and you want a typed view instead of poking at the raw
540    /// `serde_json::Value` in [`Session::capabilities`]. Each extension
541    /// `*-client` crate should expose a typed `XxxCapability` struct and
542    /// a thin wrapper like:
543    ///
544    /// ```rust,ignore
545    /// pub fn mail_capability(session: &Session) -> Result<Option<MailCapability>, ClientError> {
546    ///     session.extension_capability("urn:ietf:params:jmap:mail")
547    /// }
548    /// ```
549    ///
550    /// # Returns
551    ///
552    /// - `Ok(None)` — server does not advertise this capability.
553    /// - `Ok(Some(_))` — capability is advertised AND the value parsed into `T`.
554    /// - `Err(ClientError::Parse)` — capability is advertised but the value
555    ///   could not be deserialised into `T`. Indicates either a server bug,
556    ///   a schema-version mismatch, or a `T` type that does not match the
557    ///   spec for `capability_uri`.
558    ///
559    /// The function only inspects the value when the key is present; an
560    /// absent key always returns `Ok(None)` regardless of `T`.
561    pub fn extension_capability<T>(&self, capability_uri: &str) -> Result<Option<T>, ClientError>
562    where
563        T: serde::de::DeserializeOwned,
564    {
565        let Some(raw) = self.capabilities.get(capability_uri) else {
566            return Ok(None);
567        };
568        T::deserialize(raw)
569            .map(Some)
570            .map_err(ClientError::from_parse)
571    }
572
573    /// Returns `true` if the server advertises the JMAP Blob Content
574    /// Identifiers extension (draft-atwood-jmap-cid-00).
575    ///
576    /// Checks for presence of `capabilities["urn:ietf:params:jmap:cid"]`.
577    /// The capability value object is empty per the draft (§2: "no
578    /// capability fields defined at this time"), so the presence of the
579    /// key is sufficient — no value-shape check is required.
580    ///
581    /// When `true`, the server commits to including a `sha256` field
582    /// (the 64-character lowercase-hex SHA-256 digest of the uploaded
583    /// content) on Blob upload responses, and on FileNode objects when
584    /// the JMAP FileNode extension is also supported. See
585    /// [`jmap_cid_types::Sha256`] for the typed wire shape.
586    ///
587    /// Mirrors the `supports_*` capability-probe pattern established by
588    /// `ChatSessionExt::supports_quotas` and
589    /// `ChatSessionExt::supports_refplus` in `jmap-chat-client`.
590    ///
591    /// [`jmap_cid_types::Sha256`]: https://docs.rs/jmap-cid-types
592    pub fn supports_cid(&self) -> bool {
593        self.capabilities.contains_key("urn:ietf:params:jmap:cid")
594    }
595
596    /// Returns the parsed session-level [`DidCapability`] for the JMAP
597    /// Decentralized Identifiers extension, if advertised
598    /// (draft-atwood-jmap-did-00 §Session-Level Capability Object).
599    ///
600    /// - `Ok(None)` — server does not advertise `urn:ietf:params:jmap:did`.
601    /// - `Ok(Some(_))` — the capability is advertised and well-formed.
602    /// - `Err(ClientError::Parse)` — the capability is advertised but its
603    ///   value is malformed, including an empty `supportedMethods`
604    ///   ("Servers MUST support at least one method").
605    ///
606    /// The per-account DID binding is
607    /// [`AccountInfo::did_capability`].
608    ///
609    /// [`DidCapability`]: jmap_did_types::DidCapability
610    pub fn did_capability(&self) -> Result<Option<jmap_did_types::DidCapability>, ClientError> {
611        self.extension_capability(jmap_did_types::JMAP_DID_URI)
612    }
613}
614
615/// Manual `Debug` impl that redacts privacy-sensitive fields (bd:JMAP-sc1b.99).
616///
617/// `Session.username` is the authenticated user's identifier — typically a
618/// full email address, which is PII under GDPR/CCPA. `Session.state` is the
619/// opaque RFC 8620 §2 session-state token; it is not an auth credential, but
620/// it uniquely identifies the client's session and is the same shape of leak
621/// as logging a session cookie. Both are replaced with `"[REDACTED]"` /
622/// `"[opaque]"` in the Debug output.
623///
624/// All other URL/map fields are surfaced — they are deployment metadata and
625/// not credential-grade. `AccountInfo.name` is redacted by `AccountInfo`'s
626/// own manual `Debug` impl, so the `accounts` map below does not leak
627/// owner emails transitively (bd:JMAP-sc1b.104).
628impl std::fmt::Debug for Session {
629    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
630        f.debug_struct("Session")
631            .field("capabilities", &self.capabilities)
632            .field("accounts", &self.accounts)
633            .field("primary_accounts", &self.primary_accounts)
634            .field("username", &"[REDACTED]")
635            .field("api_url", &self.api_url)
636            .field("download_url", &self.download_url)
637            .field("upload_url", &self.upload_url)
638            .field("event_source_url", &self.event_source_url)
639            .field("state", &"[opaque]")
640            .field("extra", &self.extra)
641            .finish()
642    }
643}
644
645// ---------------------------------------------------------------------------
646// AccountInfo (RFC 8620 §2 Account object)
647// ---------------------------------------------------------------------------
648
649/// Per-account metadata in a JMAP Session (RFC 8620 §2).
650///
651/// `Debug` is hand-written to redact `name` because the field's own
652/// definition identifies it as "typically the owner's email address"
653/// (PII under GDPR/CCPA). The other fields are non-credential metadata
654/// and are surfaced directly. See bd:JMAP-sc1b.104.
655///
656/// # `extra` equality is feature-flag-dependent (bd:JMAP-6r7c.43)
657///
658/// The derived `PartialEq` / `Eq` impl's behaviour on the `extra` field
659/// depends on the global `serde_json/preserve_order` feature flag — see
660/// the [crate-level note](crate#extra-field-equality-and-the-serde_jsonpreserve_order-feature-bdjmap-6r7c43)
661/// for the canonical statement.
662#[non_exhaustive]
663#[derive(Clone, PartialEq, Eq, Deserialize)]
664#[serde(rename_all = "camelCase")]
665pub struct AccountInfo {
666    /// Human-readable account name (e.g. the owner's email address).
667    ///
668    /// # ⚠ PII — same handling rules as [`Session::username`] (bd:JMAP-6r7c.35, bd:JMAP-6r7c.63)
669    ///
670    /// Typed as [`AccountName`] (PII wrapper) so `Display`, `Debug`,
671    /// `format!`, and `tracing::*` paths all redact to `"[REDACTED]"`
672    /// rather than leaking the raw value. The only paths that surface
673    /// the raw string are [`AccountName::expose_unredacted`] (explicit
674    /// caller intent) and `serde_json::to_string(&account)?` (wire
675    /// round-trip).
676    ///
677    /// See [`Session::username`] for the full PII discussion and
678    /// recommended non-PII replacement identifiers.
679    pub name: AccountName,
680
681    /// `true` if this is the authenticated user's own personal account.
682    pub is_personal: bool,
683
684    /// `true` if the entire account is read-only for the current user.
685    pub is_read_only: bool,
686
687    /// Map of capability URI → capability object for this account.
688    ///
689    /// Values are kept as raw JSON so extension crates can extract
690    /// their own capability objects.
691    pub account_capabilities: BTreeMap<String, serde_json::Value>,
692
693    /// Catch-all for vendor / site / private extension fields not covered
694    /// by the typed fields above. Preserves unknown fields across
695    /// deserialize/serialize round-trip per workspace extras-preservation
696    /// policy (see workspace AGENTS.md).
697    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
698    pub extra: serde_json::Map<String, serde_json::Value>,
699}
700
701impl AccountInfo {
702    /// Returns the parsed per-account extension-capability object for
703    /// `capability_uri`, deserialized into the caller-supplied type `T`
704    /// (bd:JMAP-6r7c.22).
705    ///
706    /// Per-account counterpart of [`Session::extension_capability`]. Used
707    /// when an extension defines an account-scoped capability shape (e.g.
708    /// per-account quotas, per-account folder roots) rather than a
709    /// server-wide one.
710    ///
711    /// # Returns
712    ///
713    /// - `Ok(None)` — this account does not advertise this capability.
714    /// - `Ok(Some(_))` — capability is advertised AND the value parsed into `T`.
715    /// - `Err(ClientError::Parse)` — capability is advertised but the value
716    ///   could not be deserialised into `T`.
717    pub fn account_extension_capability<T>(
718        &self,
719        capability_uri: &str,
720    ) -> Result<Option<T>, ClientError>
721    where
722        T: serde::de::DeserializeOwned,
723    {
724        let Some(raw) = self.account_capabilities.get(capability_uri) else {
725            return Ok(None);
726        };
727        T::deserialize(raw)
728            .map(Some)
729            .map_err(ClientError::from_parse)
730    }
731
732    /// Returns this account's parsed [`DidAccountCapability`] — the DID
733    /// the server binds to the account — if advertised
734    /// (draft-atwood-jmap-did-00 §Account-Level Capability Object).
735    ///
736    /// - `Ok(None)` — this account does not advertise
737    ///   `urn:ietf:params:jmap:did`.
738    /// - `Ok(Some(_))` — the capability is advertised and well-formed.
739    /// - `Err(ClientError::Parse)` — the capability is advertised but its
740    ///   value is malformed (for example, `did` is missing).
741    ///
742    /// The server-wide counterpart is [`Session::did_capability`].
743    ///
744    /// [`DidAccountCapability`]: jmap_did_types::DidAccountCapability
745    pub fn did_capability(
746        &self,
747    ) -> Result<Option<jmap_did_types::DidAccountCapability>, ClientError> {
748        self.account_extension_capability(jmap_did_types::JMAP_DID_URI)
749    }
750}
751
752/// Manual `Debug` impl that redacts `name` (bd:JMAP-sc1b.104).
753///
754/// `AccountInfo.name` is typically the owner's email address, which is
755/// PII under GDPR/CCPA. The other fields (`is_personal`, `is_read_only`,
756/// `account_capabilities`) are non-credential metadata and are surfaced
757/// directly so `{:?}` output remains useful for debugging.
758///
759/// This redaction closes the transitive leak through `Session.accounts`
760/// — `Session`'s own Debug impl (bd:JMAP-sc1b.99) only redacted
761/// `username` and `state` directly and was silent about the accounts
762/// map. With `AccountInfo` redacting itself, any `{:?}` of a `Session`
763/// is now safe with respect to the canonical email-shaped PII.
764impl std::fmt::Debug for AccountInfo {
765    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
766        f.debug_struct("AccountInfo")
767            .field("name", &"[REDACTED]")
768            .field("is_personal", &self.is_personal)
769            .field("is_read_only", &self.is_read_only)
770            .field("account_capabilities", &self.account_capabilities)
771            .field("extra", &self.extra)
772            .finish()
773    }
774}
775
776// ---------------------------------------------------------------------------
777// WebSocketCapability (RFC 8887)
778// ---------------------------------------------------------------------------
779
780/// Capability object for `"urn:ietf:params:jmap:websocket"` (RFC 8887).
781///
782/// Advertised in `Session.capabilities` when the server supports JMAP over
783/// WebSocket. The `url` field is the `wss://` endpoint to connect to.
784///
785/// # `extra` equality is feature-flag-dependent (bd:JMAP-6r7c.43)
786///
787/// The derived `PartialEq` / `Eq` impl's behaviour on the `extra` field
788/// depends on the global `serde_json/preserve_order` feature flag — see
789/// the [crate-level note](crate#extra-field-equality-and-the-serde_jsonpreserve_order-feature-bdjmap-6r7c43)
790/// for the canonical statement.
791#[non_exhaustive]
792#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
793#[serde(rename_all = "camelCase")]
794pub struct WebSocketCapability {
795    /// The WebSocket endpoint URL (`wss://`).
796    pub url: String,
797
798    /// Whether the server supports push notifications over this WebSocket.
799    #[serde(default)]
800    pub supports_push: bool,
801
802    /// Catch-all for vendor / site / private extension fields not covered
803    /// by the typed fields above. Preserves unknown fields across
804    /// deserialize/serialize round-trip per workspace extras-preservation
805    /// policy (see workspace AGENTS.md).
806    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
807    pub extra: serde_json::Map<String, serde_json::Value>,
808}
809
810// ---------------------------------------------------------------------------
811// Tests
812// ---------------------------------------------------------------------------
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817    use serde_json::json;
818
819    // -----------------------------------------------------------------------
820    // JmapRequestBuilder
821    // -----------------------------------------------------------------------
822
823    /// Oracle: RFC 8620 §3.3 — a request with two method calls serializes to
824    /// a JSON object with a "methodCalls" array containing two 3-element arrays.
825    /// The expected JSON shape is derived directly from the RFC §3.3 example.
826    #[test]
827    fn builder_two_calls_serializes_correctly() {
828        let mut builder =
829            JmapRequestBuilder::new(&["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"]);
830        builder
831            .add_call(
832                "Mailbox/get",
833                json!({"accountId": "A13824", "ids": null}),
834                "r1",
835            )
836            .expect("add_call r1 must succeed");
837        builder
838            .add_call(
839                "Email/get",
840                json!({"accountId": "A13824", "ids": ["e001"]}),
841                "r2",
842            )
843            .expect("add_call r2 must succeed");
844        let req = builder.build().expect("build must succeed with two calls");
845
846        let v = serde_json::to_value(&req).expect("serialize JmapRequest");
847
848        // Oracle: RFC 8620 §3.3 — "using" must be present
849        assert!(v.get("using").is_some(), "must have 'using' field");
850        let using = v["using"].as_array().expect("using must be array");
851        assert_eq!(using.len(), 2);
852        assert!(using.contains(&json!("urn:ietf:params:jmap:core")));
853        assert!(using.contains(&json!("urn:ietf:params:jmap:mail")));
854
855        // Oracle: RFC 8620 §3.3 — "methodCalls" must be present
856        let calls = v["methodCalls"]
857            .as_array()
858            .expect("methodCalls must be array");
859        assert_eq!(calls.len(), 2, "must have exactly 2 method calls");
860
861        // Oracle: RFC 8620 §3.2 — each invocation is [methodName, args, callId]
862        assert_eq!(calls[0][0], json!("Mailbox/get"));
863        assert_eq!(calls[0][2], json!("r1"));
864        assert_eq!(calls[1][0], json!("Email/get"));
865        assert_eq!(calls[1][2], json!("r2"));
866    }
867
868    /// Oracle: RFC 8620 §3.3 — build() with no method calls is invalid;
869    /// must return Err(InvalidArgument) rather than produce an empty batch.
870    #[test]
871    fn builder_returns_err_on_empty_build() {
872        let result = JmapRequestBuilder::new(&["urn:ietf:params:jmap:core"]).build();
873        assert!(
874            matches!(result, Err(ClientError::InvalidArgument(_))),
875            "empty build must return Err(InvalidArgument), got {result:?}"
876        );
877    }
878
879    /// Oracle: RFC 8620 §3.5 — call IDs must be unique within a request.
880    /// Duplicate call ID returns Err(ClientError::InvalidArgument).
881    #[test]
882    fn builder_returns_err_on_duplicate_call_id() {
883        let mut builder = JmapRequestBuilder::new(&["urn:ietf:params:jmap:core"]);
884        builder
885            .add_call("Foo/get", json!({}), "r1")
886            .expect("first add_call must succeed");
887        let result = builder.add_call("Bar/get", json!({}), "r1"); // duplicate
888        assert!(
889            matches!(result, Err(ClientError::InvalidArgument(_))),
890            "duplicate call_id must return Err(InvalidArgument), got {result:?}"
891        );
892    }
893
894    // -----------------------------------------------------------------------
895    // Session
896    // -----------------------------------------------------------------------
897
898    /// Oracle: RFC 8620 §2.1 example Session JSON, transcribed from the RFC text.
899    /// All field names and values come from the RFC, not from the code under test.
900    #[test]
901    fn session_deserializes_rfc8620_example() {
902        // RFC 8620 §2.1 example — hand-transcribed from spec text.
903        let raw = r#"{
904            "capabilities": {
905                "urn:ietf:params:jmap:core": {
906                    "maxSizeUpload": 50000000,
907                    "maxConcurrentUpload": 8,
908                    "maxSizeRequest": 10000000,
909                    "maxConcurrentRequest": 8,
910                    "maxCallsInRequest": 32,
911                    "maxObjectsInGet": 256,
912                    "maxObjectsInSet": 128,
913                    "collationAlgorithms": [
914                        "i;ascii-numeric",
915                        "i;ascii-casemap",
916                        "i;unicode-casemap"
917                    ]
918                },
919                "urn:ietf:params:jmap:mail": {},
920                "urn:ietf:params:jmap:contacts": {},
921                "https://example.com/apis/foobar": {
922                    "maxFoosFinangled": 42
923                }
924            },
925            "accounts": {
926                "A13824": {
927                    "name": "john@example.com",
928                    "isPersonal": true,
929                    "isReadOnly": false,
930                    "accountCapabilities": {
931                        "urn:ietf:params:jmap:mail": {
932                            "maxMailboxesPerEmail": null,
933                            "maxMailboxDepth": 10
934                        },
935                        "urn:ietf:params:jmap:contacts": {}
936                    }
937                },
938                "A97813": {
939                    "name": "jane@example.com",
940                    "isPersonal": false,
941                    "isReadOnly": true,
942                    "accountCapabilities": {
943                        "urn:ietf:params:jmap:mail": {
944                            "maxMailboxesPerEmail": 1,
945                            "maxMailboxDepth": 10
946                        }
947                    }
948                }
949            },
950            "primaryAccounts": {
951                "urn:ietf:params:jmap:mail": "A13824",
952                "urn:ietf:params:jmap:contacts": "A13824"
953            },
954            "username": "john@example.com",
955            "apiUrl": "https://jmap.example.com/api/",
956            "downloadUrl": "https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}",
957            "uploadUrl": "https://jmap.example.com/upload/{accountId}/",
958            "eventSourceUrl": "https://jmap.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
959            "state": "75128aab4b1b"
960        }"#;
961
962        let session: Session =
963            serde_json::from_str(raw).expect("RFC 8620 §2.1 example must deserialize");
964
965        // Oracle: RFC 8620 §2.1
966        assert_eq!(session.username, "john@example.com");
967        assert_eq!(session.api_url, "https://jmap.example.com/api/");
968        assert_eq!(
969            session.upload_url,
970            "https://jmap.example.com/upload/{accountId}/"
971        );
972        assert_eq!(
973            session.download_url,
974            "https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}"
975        );
976        assert_eq!(
977            session.event_source_url,
978            "https://jmap.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}"
979        );
980        assert_eq!(session.state, "75128aab4b1b");
981
982        // Oracle: RFC 8620 §2.1 — capabilities map
983        assert!(
984            session
985                .capabilities
986                .contains_key("urn:ietf:params:jmap:core"),
987            "must have core capability"
988        );
989        assert!(
990            session
991                .capabilities
992                .contains_key("urn:ietf:params:jmap:mail"),
993            "must have mail capability"
994        );
995        assert!(
996            session
997                .capabilities
998                .contains_key("https://example.com/apis/foobar"),
999            "must have vendor capability"
1000        );
1001
1002        // Oracle: RFC 8620 §2.1 — accounts map
1003        assert!(
1004            session.accounts.contains_key("A13824"),
1005            "must have account A13824"
1006        );
1007        assert!(
1008            session.accounts.contains_key("A97813"),
1009            "must have account A97813"
1010        );
1011
1012        // Oracle: RFC 8620 §2.1 — primaryAccounts
1013        assert_eq!(
1014            session
1015                .primary_account_id("urn:ietf:params:jmap:mail")
1016                .map(AsRef::as_ref),
1017            Some("A13824")
1018        );
1019        assert_eq!(
1020            session
1021                .primary_account_id("urn:ietf:params:jmap:contacts")
1022                .map(AsRef::as_ref),
1023            Some("A13824")
1024        );
1025        assert_eq!(
1026            session.primary_account_id("urn:ietf:params:jmap:core"),
1027            None
1028        );
1029    }
1030
1031    // -----------------------------------------------------------------------
1032    // AccountInfo
1033    // -----------------------------------------------------------------------
1034
1035    /// Oracle: RFC 8620 §2.1 example — account A13824 (john@example.com).
1036    /// Field names and values transcribed directly from the RFC.
1037    #[test]
1038    fn account_info_deserializes_rfc8620_example() {
1039        // RFC 8620 §2.1 example account entry
1040        let raw = r#"{
1041            "name": "john@example.com",
1042            "isPersonal": true,
1043            "isReadOnly": false,
1044            "accountCapabilities": {
1045                "urn:ietf:params:jmap:mail": {
1046                    "maxMailboxesPerEmail": null,
1047                    "maxMailboxDepth": 10
1048                },
1049                "urn:ietf:params:jmap:contacts": {}
1050            }
1051        }"#;
1052
1053        let account: AccountInfo =
1054            serde_json::from_str(raw).expect("RFC 8620 §2.1 AccountInfo must deserialize");
1055
1056        // Oracle: RFC 8620 §2 Account object fields
1057        assert_eq!(account.name, "john@example.com");
1058        assert!(account.is_personal, "isPersonal must be true");
1059        assert!(!account.is_read_only, "isReadOnly must be false");
1060        assert!(
1061            account
1062                .account_capabilities
1063                .contains_key("urn:ietf:params:jmap:mail"),
1064            "must have mail capability"
1065        );
1066        assert!(
1067            account
1068                .account_capabilities
1069                .contains_key("urn:ietf:params:jmap:contacts"),
1070            "must have contacts capability"
1071        );
1072
1073        // Oracle: RFC 8620 §2.1 — read-only account (A97813 / jane@example.com)
1074        let raw2 = r#"{
1075            "name": "jane@example.com",
1076            "isPersonal": false,
1077            "isReadOnly": true,
1078            "accountCapabilities": {
1079                "urn:ietf:params:jmap:mail": {
1080                    "maxMailboxesPerEmail": 1,
1081                    "maxMailboxDepth": 10
1082                }
1083            }
1084        }"#;
1085        let account2: AccountInfo = serde_json::from_str(raw2)
1086            .expect("RFC 8620 §2.1 read-only AccountInfo must deserialize");
1087
1088        assert_eq!(account2.name, "jane@example.com");
1089        assert!(!account2.is_personal, "isPersonal must be false");
1090        assert!(account2.is_read_only, "isReadOnly must be true");
1091    }
1092
1093    // -----------------------------------------------------------------------
1094    // WebSocketCapability
1095    // -----------------------------------------------------------------------
1096
1097    /// Oracle: RFC 8887 §3 — WebSocketCapability has url and supportsPush fields.
1098    /// Transcribed from the RFC 8887 capability object definition.
1099    #[test]
1100    fn websocket_capability_deserializes() {
1101        let raw = r#"{"url": "wss://jmap.example.com/ws", "supportsPush": true}"#;
1102        let cap: WebSocketCapability =
1103            serde_json::from_str(raw).expect("WebSocketCapability must deserialize");
1104        assert_eq!(cap.url, "wss://jmap.example.com/ws");
1105        assert!(cap.supports_push);
1106    }
1107
1108    /// Oracle: RFC 8887 §3 — supportsPush defaults to false when absent.
1109    #[test]
1110    fn websocket_capability_supports_push_defaults_false() {
1111        let raw = r#"{"url": "wss://jmap.example.com/ws"}"#;
1112        let cap: WebSocketCapability =
1113            serde_json::from_str(raw).expect("WebSocketCapability must deserialize");
1114        assert_eq!(cap.url, "wss://jmap.example.com/ws");
1115        assert!(!cap.supports_push, "supportsPush must default to false");
1116    }
1117
1118    /// Oracle: Session.websocket_capability() returns Ok(None) when key absent.
1119    #[test]
1120    fn session_websocket_capability_absent_returns_ok_none() {
1121        let raw = r#"{
1122            "capabilities": {},
1123            "accounts": {},
1124            "primaryAccounts": {},
1125            "username": "u@example.com",
1126            "apiUrl": "https://jmap.example.com/api/",
1127            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
1128            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
1129            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
1130            "state": "s1"
1131        }"#;
1132        let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
1133        let result = session.websocket_capability();
1134        assert!(
1135            matches!(result, Ok(None)),
1136            "expected Ok(None), got {result:?}"
1137        );
1138    }
1139
1140    /// Oracle: Session.websocket_capability() returns Ok(Some) when key present and valid.
1141    #[test]
1142    fn session_websocket_capability_present_and_valid() {
1143        let raw = r#"{
1144            "capabilities": {
1145                "urn:ietf:params:jmap:websocket": {
1146                    "url": "wss://jmap.example.com/ws",
1147                    "supportsPush": true
1148                }
1149            },
1150            "accounts": {},
1151            "primaryAccounts": {},
1152            "username": "u@example.com",
1153            "apiUrl": "https://jmap.example.com/api/",
1154            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
1155            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
1156            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
1157            "state": "s1"
1158        }"#;
1159        let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
1160        let ws = session
1161            .websocket_capability()
1162            .expect("must not error")
1163            .expect("websocket capability must be present");
1164        assert_eq!(ws.url, "wss://jmap.example.com/ws");
1165        assert!(ws.supports_push);
1166    }
1167
1168    /// Oracle: `Session::supports_cid()` returns `false` when the JMAP
1169    /// CID capability URI is not present in the capabilities map
1170    /// (bd:JMAP-v9py.14).
1171    ///
1172    /// Mirrors the absent-key precedent of
1173    /// `session_websocket_capability_absent_returns_ok_none`. The test
1174    /// fixture has an empty capabilities map; the negative answer must
1175    /// be `false`, not `Err` or panic.
1176    #[test]
1177    fn supports_cid_returns_false_when_capability_absent() {
1178        let raw = r#"{
1179            "capabilities": {},
1180            "accounts": {},
1181            "primaryAccounts": {},
1182            "username": "u@example.com",
1183            "apiUrl": "https://jmap.example.com/api/",
1184            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
1185            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
1186            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
1187            "state": "s1"
1188        }"#;
1189        let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
1190        assert!(!session.supports_cid());
1191    }
1192
1193    /// Oracle: `Session::supports_cid()` returns `true` when the JMAP
1194    /// CID capability URI is present in the capabilities map, even
1195    /// though the value object is empty per draft-atwood-jmap-cid-00
1196    /// §2 ("no capability fields defined at this time")
1197    /// (bd:JMAP-v9py.14).
1198    #[test]
1199    fn supports_cid_returns_true_when_capability_present_empty_value() {
1200        let raw = r#"{
1201            "capabilities": {
1202                "urn:ietf:params:jmap:cid": {}
1203            },
1204            "accounts": {},
1205            "primaryAccounts": {},
1206            "username": "u@example.com",
1207            "apiUrl": "https://jmap.example.com/api/",
1208            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
1209            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
1210            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
1211            "state": "s1"
1212        }"#;
1213        let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
1214        assert!(session.supports_cid());
1215    }
1216
1217    /// Oracle: `Session::supports_cid()` checks only for the URI key —
1218    /// presence with a non-empty value object (vendor extras inside the
1219    /// CID capability) still returns `true`. The draft reserves the
1220    /// shape of the capability value but does not currently define any
1221    /// fields; a server that pre-populates vendor fields under the URI
1222    /// must still be detected as supporting CID.
1223    #[test]
1224    fn supports_cid_returns_true_when_capability_present_with_extra_fields() {
1225        let raw = r#"{
1226            "capabilities": {
1227                "urn:ietf:params:jmap:cid": {
1228                    "x-vendor-flag": "future-shape"
1229                }
1230            },
1231            "accounts": {},
1232            "primaryAccounts": {},
1233            "username": "u@example.com",
1234            "apiUrl": "https://jmap.example.com/api/",
1235            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
1236            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
1237            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
1238            "state": "s1"
1239        }"#;
1240        let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
1241        assert!(session.supports_cid());
1242    }
1243
1244    /// Oracle: Session's manual Debug impl never reveals the authenticated
1245    /// `username` or the opaque `state` token (bd:JMAP-sc1b.99), AND the
1246    /// `accounts` map does not transitively leak `AccountInfo.name`
1247    /// (bd:JMAP-sc1b.104). Mirrors the canary tripwire pattern used by
1248    /// `bearer_auth_debug_does_not_leak_token` and
1249    /// `basic_auth_debug_does_not_leak_credentials` in auth.rs.
1250    ///
1251    /// The canary literals are independent of the Session's internal state —
1252    /// the test is the oracle, not the code under test. A regression that
1253    /// re-derives `Debug` on `Session` or `AccountInfo`, or that prints the
1254    /// username/state/name via a manual impl, would fail the assertion.
1255    ///
1256    /// We deliberately reuse `CANARY_USER` in two distinct locations
1257    /// (`username` and `accounts["a1"].name`) so a single negative
1258    /// `assert!(!dbg.contains(...))` catches a leak from either path —
1259    /// the same kind of email-shaped PII surfacing through either field
1260    /// is the failure we want to fail loudly.
1261    #[test]
1262    fn session_debug_does_not_leak_username_or_state() {
1263        const CANARY_USER: &str = "CANARY-USERNAME-DO-NOT-LEAK@example.com";
1264        const CANARY_STATE: &str = "CANARY-STATE-TOKEN-DO-NOT-LEAK";
1265        let raw = format!(
1266            r#"{{
1267                "capabilities": {{}},
1268                "accounts": {{
1269                    "a1": {{
1270                        "name": "{CANARY_USER}",
1271                        "isPersonal": true,
1272                        "isReadOnly": false,
1273                        "accountCapabilities": {{}}
1274                    }}
1275                }},
1276                "primaryAccounts": {{}},
1277                "username": "{CANARY_USER}",
1278                "apiUrl": "https://jmap.example.com/api/",
1279                "downloadUrl": "https://jmap.example.com/dl/{{accountId}}/",
1280                "uploadUrl": "https://jmap.example.com/ul/{{accountId}}/",
1281                "eventSourceUrl": "https://jmap.example.com/sse/",
1282                "state": "{CANARY_STATE}"
1283            }}"#
1284        );
1285        let session: Session = serde_json::from_str(&raw).expect("Session must deserialize");
1286
1287        // Sanity-check: the canary really did land in the AccountInfo —
1288        // otherwise an empty accounts map would silently make the
1289        // transitive-leak assertion below tautologically pass.
1290        let account = session
1291            .accounts
1292            .get("a1")
1293            .expect("accounts['a1'] must deserialize");
1294        assert_eq!(account.name, CANARY_USER);
1295
1296        let dbg = format!("{session:?}");
1297        assert!(
1298            !dbg.contains(CANARY_USER),
1299            "Session Debug must not contain the raw username or AccountInfo.name; got: {dbg}"
1300        );
1301        assert!(
1302            !dbg.contains(CANARY_STATE),
1303            "Session Debug must not contain the raw state token; got: {dbg}"
1304        );
1305    }
1306
1307    /// Oracle: AccountInfo's manual Debug impl never reveals the raw
1308    /// `name` field (bd:JMAP-sc1b.104). Independent of the Session-level
1309    /// test above: a regression on AccountInfo alone (e.g. re-deriving
1310    /// `#[derive(Debug)]`) would be caught here without needing the
1311    /// Session wrapper.
1312    #[test]
1313    fn account_info_debug_does_not_leak_name() {
1314        const CANARY_NAME: &str = "CANARY-ACCOUNT-NAME-DO-NOT-LEAK@example.com";
1315        let raw = format!(
1316            r#"{{
1317                "name": "{CANARY_NAME}",
1318                "isPersonal": true,
1319                "isReadOnly": false,
1320                "accountCapabilities": {{}}
1321            }}"#
1322        );
1323        let account: AccountInfo =
1324            serde_json::from_str(&raw).expect("AccountInfo must deserialize");
1325        // Sanity-check that the canary really did populate `name`.
1326        assert_eq!(account.name, CANARY_NAME);
1327
1328        let dbg = format!("{account:?}");
1329        assert!(
1330            !dbg.contains(CANARY_NAME),
1331            "AccountInfo Debug must not contain the raw name; got: {dbg}"
1332        );
1333    }
1334
1335    // ── Extras-preservation policy tests (JMAP-lbdy.9) ─────────────────
1336    //
1337    // Synthetic `acmeCorp*` vendor keys cannot collide with any RFC 8620 /
1338    // RFC 8887 typed field, so the tests are independent of the code under
1339    // test (workspace test-integrity rule).
1340
1341    /// `Session.extra` captures unknown fields on deserialize.
1342    #[test]
1343    fn session_preserves_vendor_extras() {
1344        let raw = json!({
1345            "capabilities": {},
1346            "accounts": {},
1347            "primaryAccounts": {},
1348            "username": "u@example.com",
1349            "apiUrl": "https://jmap.example.com/api/",
1350            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
1351            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
1352            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
1353            "state": "s1",
1354            "acmeCorpDeployment": "prod-eu-west-1"
1355        });
1356        let obj: Session = serde_json::from_value(raw).expect("Session must deserialize");
1357        assert_eq!(
1358            obj.extra.get("acmeCorpDeployment").and_then(|v| v.as_str()),
1359            Some("prod-eu-west-1")
1360        );
1361    }
1362
1363    /// `AccountInfo.extra` captures unknown fields on deserialize.
1364    #[test]
1365    fn account_info_preserves_vendor_extras() {
1366        let raw = json!({
1367            "name": "u@example.com",
1368            "isPersonal": true,
1369            "isReadOnly": false,
1370            "accountCapabilities": {},
1371            "acmeCorpQuotaTier": "gold"
1372        });
1373        let obj: AccountInfo = serde_json::from_value(raw).expect("AccountInfo must deserialize");
1374        assert_eq!(
1375            obj.extra.get("acmeCorpQuotaTier").and_then(|v| v.as_str()),
1376            Some("gold")
1377        );
1378    }
1379
1380    /// `WebSocketCapability.extra` captures unknown fields on deserialize.
1381    #[test]
1382    fn websocket_capability_preserves_vendor_extras() {
1383        let raw = json!({
1384            "url": "wss://jmap.example.com/ws",
1385            "supportsPush": true,
1386            "acmeCorpHeartbeatMs": 30000
1387        });
1388        let obj: WebSocketCapability =
1389            serde_json::from_value(raw).expect("WebSocketCapability must deserialize");
1390        assert_eq!(
1391            obj.extra
1392                .get("acmeCorpHeartbeatMs")
1393                .and_then(|v| v.as_u64()),
1394            Some(30000)
1395        );
1396    }
1397
1398    // -----------------------------------------------------------------------
1399    // Session::extension_capability / AccountInfo::account_extension_capability
1400    // (bd:JMAP-6r7c.22)
1401    // -----------------------------------------------------------------------
1402
1403    /// Hand-written capability struct standing in for any future extension
1404    /// (e.g. JMAP Mail / Calendars / Tasks). Has the same shape as
1405    /// `WebSocketCapability` deliberately — the helper is generic, the
1406    /// caller supplies the schema.
1407    #[derive(Debug, Deserialize, PartialEq)]
1408    #[serde(rename_all = "camelCase")]
1409    struct FakeMailCapability {
1410        max_size_upload: u64,
1411        max_size_request: u64,
1412    }
1413
1414    fn build_session_with_capability(uri: &str, value: serde_json::Value) -> Session {
1415        let raw = json!({
1416            "capabilities": { uri: value },
1417            "accounts": {},
1418            "primaryAccounts": {},
1419            "username": "u@example.com",
1420            "apiUrl": "https://jmap.example.com/api/",
1421            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
1422            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
1423            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
1424            "state": "s1",
1425        });
1426        serde_json::from_value(raw).expect("Session must deserialize")
1427    }
1428
1429    /// Oracle: `Session::extension_capability::<T>` returns `Ok(None)` when
1430    /// the capability key is absent, regardless of `T`.
1431    #[test]
1432    fn extension_capability_absent_returns_ok_none() {
1433        let session = build_session_with_capability(
1434            "urn:ietf:params:jmap:other",
1435            json!({"unrelated": "value"}),
1436        );
1437        let result: Result<Option<FakeMailCapability>, _> =
1438            session.extension_capability("urn:ietf:params:jmap:mail");
1439        assert!(
1440            matches!(result, Ok(None)),
1441            "absent capability key must return Ok(None), got {result:?}"
1442        );
1443    }
1444
1445    /// Oracle: `Session::extension_capability::<T>` returns `Ok(Some(T))`
1446    /// when the capability is present and the value matches `T`.
1447    #[test]
1448    fn extension_capability_present_and_valid_returns_ok_some() {
1449        let session = build_session_with_capability(
1450            "urn:ietf:params:jmap:mail",
1451            json!({"maxSizeUpload": 50000000, "maxSizeRequest": 10000000}),
1452        );
1453        let cap: FakeMailCapability = session
1454            .extension_capability("urn:ietf:params:jmap:mail")
1455            .expect("must not error")
1456            .expect("capability must be present");
1457        assert_eq!(cap.max_size_upload, 50_000_000);
1458        assert_eq!(cap.max_size_request, 10_000_000);
1459    }
1460
1461    /// Oracle: `Session::extension_capability::<T>` returns
1462    /// `Err(ClientError::Parse)` when the capability is present but the
1463    /// value cannot deserialise into `T` (server bug or schema mismatch).
1464    #[test]
1465    fn extension_capability_present_but_malformed_returns_parse_err() {
1466        let session = build_session_with_capability(
1467            "urn:ietf:params:jmap:mail",
1468            // Wrong shape — missing required maxSizeRequest field.
1469            json!({"maxSizeUpload": 50000000}),
1470        );
1471        let result: Result<Option<FakeMailCapability>, _> =
1472            session.extension_capability("urn:ietf:params:jmap:mail");
1473        assert!(
1474            matches!(result, Err(ClientError::Parse(_))),
1475            "malformed capability value must surface as ClientError::Parse, got {result:?}"
1476        );
1477    }
1478
1479    /// Oracle: `Session::websocket_capability()` delegates to
1480    /// `extension_capability` and the existing semantics are preserved
1481    /// (regression test for the refactor).
1482    #[test]
1483    fn websocket_capability_still_works_after_refactor() {
1484        let session = build_session_with_capability(
1485            "urn:ietf:params:jmap:websocket",
1486            json!({"url": "wss://jmap.example.com/ws", "supportsPush": true}),
1487        );
1488        let ws = session
1489            .websocket_capability()
1490            .expect("must not error")
1491            .expect("websocket capability must be present");
1492        assert_eq!(ws.url, "wss://jmap.example.com/ws");
1493        assert!(ws.supports_push);
1494    }
1495
1496    /// Oracle: `AccountInfo::account_extension_capability::<T>` returns
1497    /// `Ok(None)` when the per-account capability key is absent.
1498    #[test]
1499    fn account_extension_capability_absent_returns_ok_none() {
1500        let raw = json!({
1501            "name": "alice@example.com",
1502            "isPersonal": true,
1503            "isReadOnly": false,
1504            "accountCapabilities": {},
1505        });
1506        let acct: AccountInfo = serde_json::from_value(raw).expect("AccountInfo must deserialize");
1507        let result: Result<Option<FakeMailCapability>, _> =
1508            acct.account_extension_capability("urn:ietf:params:jmap:mail");
1509        assert!(
1510            matches!(result, Ok(None)),
1511            "absent per-account capability must return Ok(None), got {result:?}"
1512        );
1513    }
1514
1515    /// Oracle: `AccountInfo::account_extension_capability::<T>` returns
1516    /// `Ok(Some(T))` when the per-account capability is present and valid.
1517    #[test]
1518    fn account_extension_capability_present_and_valid() {
1519        let raw = json!({
1520            "name": "alice@example.com",
1521            "isPersonal": true,
1522            "isReadOnly": false,
1523            "accountCapabilities": {
1524                "urn:ietf:params:jmap:mail": {
1525                    "maxSizeUpload": 50000000,
1526                    "maxSizeRequest": 10000000,
1527                },
1528            },
1529        });
1530        let acct: AccountInfo = serde_json::from_value(raw).expect("AccountInfo must deserialize");
1531        let cap: FakeMailCapability = acct
1532            .account_extension_capability("urn:ietf:params:jmap:mail")
1533            .expect("must not error")
1534            .expect("capability must be present");
1535        assert_eq!(cap.max_size_upload, 50_000_000);
1536    }
1537
1538    // bd:JMAP-6r7c.40 — Typed URL wrappers (JmapUrl, JmapUrlTemplate)
1539
1540    /// `JmapUrl` and `JmapUrlTemplate` are distinct types at the type
1541    /// level. A function that takes `&JmapUrlTemplate` MUST refuse a
1542    /// `&JmapUrl` argument and vice versa. This is the compile-time
1543    /// guard that prevents callers from accidentally passing
1544    /// `session.api_url` (a plain URL) to a function expecting a
1545    /// template, or `session.upload_url` (a template) to a function
1546    /// expecting a plain URL.
1547    ///
1548    /// Implemented as compile-time witness: the function bodies do
1549    /// nothing useful; if either signature compiled with the other
1550    /// type, the test would break the type-distinction invariant.
1551    #[test]
1552    fn jmap_url_and_template_are_distinct_types() {
1553        fn _takes_plain_url(_u: &JmapUrl) {}
1554        fn _takes_template(_t: &JmapUrlTemplate) {}
1555
1556        let plain = JmapUrl::new("https://example.com/api/");
1557        let template = JmapUrlTemplate::new("https://example.com/upload/{accountId}/");
1558
1559        _takes_plain_url(&plain);
1560        _takes_template(&template);
1561
1562        // The interesting non-compilation cases:
1563        //   _takes_plain_url(&template);     // FAILS: expected JmapUrl, got JmapUrlTemplate
1564        //   _takes_template(&plain);          // FAILS: expected JmapUrlTemplate, got JmapUrl
1565        // These cannot be expressed as runtime assertions; the test's
1566        // value is locking in the distinct-types invariant so a future
1567        // refactor that accidentally collapses the wrappers (e.g. a
1568        // `type JmapUrlTemplate = JmapUrl;` alias) breaks the function
1569        // signatures above and the build fails.
1570    }
1571
1572    /// `JmapUrl` round-trips through serde_json as a transparent
1573    /// string. Oracle: hand-written JSON containing a quoted string.
1574    #[test]
1575    fn jmap_url_serde_round_trip() {
1576        let original = JmapUrl::new("https://example.com/api/");
1577        let json = serde_json::to_value(&original).expect("must serialise");
1578        assert_eq!(json, serde_json::json!("https://example.com/api/"));
1579        let restored: JmapUrl = serde_json::from_value(json).expect("must deserialise");
1580        assert_eq!(restored, original);
1581    }
1582
1583    /// `JmapUrlTemplate` round-trips through serde_json as a transparent
1584    /// string.
1585    #[test]
1586    fn jmap_url_template_serde_round_trip() {
1587        let original = JmapUrlTemplate::new("https://example.com/upload/{accountId}/");
1588        let json = serde_json::to_value(&original).expect("must serialise");
1589        assert_eq!(
1590            json,
1591            serde_json::json!("https://example.com/upload/{accountId}/")
1592        );
1593        let restored: JmapUrlTemplate = serde_json::from_value(json).expect("must deserialise");
1594        assert_eq!(restored, original);
1595    }
1596
1597    /// `PartialEq<&str>` and `PartialEq<str>` ergonomics for
1598    /// `assert_eq!(session.api_url, "...")` style assertions in
1599    /// downstream tests.
1600    #[test]
1601    fn jmap_url_partial_eq_str() {
1602        let url = JmapUrl::new("https://example.com/api/");
1603        assert_eq!(url, "https://example.com/api/");
1604        assert_eq!("https://example.com/api/", url);
1605        assert_ne!(url, "https://other.example.com/api/");
1606    }
1607}