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::HashMap;
12use std::collections::HashSet;
13
14use serde::Deserialize;
15
16use jmap_types::{Invocation, JmapRequest, State};
17
18use crate::error::ClientError;
19
20// ---------------------------------------------------------------------------
21// JmapRequestBuilder (RFC 8620 §3.3)
22// ---------------------------------------------------------------------------
23
24/// Fluent builder for multi-method [`JmapRequest`] objects.
25///
26/// Collects method calls and produces a [`JmapRequest`] ready for dispatch.
27///
28/// The `using` capability URIs passed to `new` apply to the whole request;
29/// callers must include every capability required by the methods they add.
30///
31/// Spec: RFC 8620 §3.3
32#[derive(Debug)]
33pub struct JmapRequestBuilder {
34    using: Vec<String>,
35    method_calls: Vec<Invocation>,
36    call_ids: HashSet<String>,
37}
38
39impl JmapRequestBuilder {
40    /// Create a new builder with the given capability URIs.
41    ///
42    /// The `using` list MUST include `"urn:ietf:params:jmap:core"` (always
43    /// required by RFC 8620 §3.3) plus every capability URI needed by the
44    /// methods added via [`add_call`](JmapRequestBuilder::add_call). An
45    /// incorrect or empty `using` list will cause the server to return an
46    /// `"unknownCapability"` error — the builder does not validate it.
47    pub fn new(using: &[&str]) -> Self {
48        Self {
49            using: using.iter().map(|&s| s.to_owned()).collect(),
50            method_calls: Vec::new(),
51            call_ids: HashSet::new(),
52        }
53    }
54
55    /// Add one method call to the request.
56    ///
57    /// `call_id` must be unique within this request; callers use it to match
58    /// responses back to the originating call.
59    ///
60    /// Returns `Err(ClientError::InvalidArgument)` if `call_id` has already
61    /// been used in this builder. Duplicate call IDs violate RFC 8620 §3.5.
62    pub fn add_call(
63        &mut self,
64        method: impl Into<String>,
65        args: serde_json::Value,
66        call_id: impl Into<String>,
67    ) -> Result<&mut Self, ClientError> {
68        let call_id = call_id.into();
69        if !self.call_ids.insert(call_id.clone()) {
70            return Err(ClientError::InvalidArgument(format!(
71                "JmapRequestBuilder: duplicate call_id {call_id:?}"
72            )));
73        }
74        self.method_calls.push((method.into(), args, call_id));
75        Ok(self)
76    }
77
78    /// Consume the builder and produce the [`JmapRequest`].
79    ///
80    /// Returns `Err(ClientError::InvalidArgument)` if no method calls have
81    /// been added. An empty `methodCalls` array is invalid per RFC 8620 §3.3.
82    pub fn build(self) -> Result<JmapRequest, ClientError> {
83        if self.method_calls.is_empty() {
84            return Err(ClientError::InvalidArgument("no method calls added".into()));
85        }
86        Ok(JmapRequest::new(self.using, self.method_calls, None))
87    }
88}
89
90// ---------------------------------------------------------------------------
91// Session (RFC 8620 §2)
92// ---------------------------------------------------------------------------
93
94/// JMAP Session object returned by `GET /.well-known/jmap` (RFC 8620 §2).
95///
96/// Contains only the base RFC 8620 fields. Extension-specific fields
97/// (e.g. JMAP Chat `ownerUserId`) are surfaced by extension crates that
98/// parse the `capabilities` and `accounts` maps.
99#[non_exhaustive]
100#[derive(Clone, PartialEq, Eq, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct Session {
103    /// Map of capability URI → capability object (RFC 8620 §2).
104    ///
105    /// Values are kept as raw JSON so callers can extract extension-specific
106    /// capability objects without this crate knowing their schema.
107    pub capabilities: HashMap<String, serde_json::Value>,
108
109    /// Map of account ID → [`AccountInfo`] (RFC 8620 §2).
110    pub accounts: HashMap<String, AccountInfo>,
111
112    /// Map of capability URI → primary account ID (RFC 8620 §2).
113    pub primary_accounts: HashMap<String, String>,
114
115    /// Username associated with the current credentials (RFC 8620 §2).
116    pub username: String,
117
118    /// URL for JMAP API POST requests (RFC 8620 §2).
119    pub api_url: String,
120
121    /// URL template for blob downloads (RFC 8620 §2).
122    ///
123    /// URI Template (level 1) containing variables `accountId`, `blobId`,
124    /// `type`, and `name`.
125    pub download_url: String,
126
127    /// URL template for blob uploads (RFC 8620 §2).
128    ///
129    /// URI Template (level 1) containing variable `accountId`.
130    pub upload_url: String,
131
132    /// URL template for SSE push event stream (RFC 8620 §2, §7.3).
133    ///
134    /// URI Template (level 1) containing variables `types`, `closeafter`,
135    /// and `ping`.
136    pub event_source_url: String,
137
138    /// Opaque session state token (RFC 8620 §2).
139    ///
140    /// Changes whenever any session property changes. Returned in every API
141    /// response as `sessionState`; clients compare to detect staleness.
142    pub state: State,
143
144    /// Catch-all for vendor / site / private extension fields not covered
145    /// by the typed fields above. Preserves unknown fields across
146    /// deserialize/serialize round-trip per workspace extras-preservation
147    /// policy (see workspace AGENTS.md).
148    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
149    pub extra: serde_json::Map<String, serde_json::Value>,
150}
151
152impl Session {
153    /// Returns the primary account ID for the given capability URI, if set.
154    ///
155    /// Example: `session.primary_account_id("urn:ietf:params:jmap:mail")`
156    pub fn primary_account_id(&self, capability: &str) -> Option<&str> {
157        self.primary_accounts.get(capability).map(String::as_str)
158    }
159
160    /// Returns the parsed [`WebSocketCapability`] for the JMAP WebSocket
161    /// transport, if advertised (RFC 8887).
162    ///
163    /// - `Ok(None)` — server does not advertise JMAP WebSocket support.
164    /// - `Ok(Some(...))` — WebSocket is supported; use `result.url` to connect.
165    /// - `Err` — capability key is present but the value is malformed.
166    pub fn websocket_capability(&self) -> Result<Option<WebSocketCapability>, ClientError> {
167        let Some(raw) = self.capabilities.get("urn:ietf:params:jmap:websocket") else {
168            return Ok(None);
169        };
170        WebSocketCapability::deserialize(raw)
171            .map(Some)
172            .map_err(ClientError::Parse)
173    }
174
175    /// Returns `true` if the server advertises the JMAP Blob Content
176    /// Identifiers extension (draft-atwood-jmap-cid-00).
177    ///
178    /// Checks for presence of `capabilities["urn:ietf:params:jmap:cid"]`.
179    /// The capability value object is empty per the draft (§2: "no
180    /// capability fields defined at this time"), so the presence of the
181    /// key is sufficient — no value-shape check is required.
182    ///
183    /// When `true`, the server commits to including a `sha256` field
184    /// (the 64-character lowercase-hex SHA-256 digest of the uploaded
185    /// content) on Blob upload responses, and on FileNode objects when
186    /// the JMAP FileNode extension is also supported. See
187    /// [`jmap_cid_types::Sha256`] for the typed wire shape.
188    ///
189    /// Mirrors the `supports_*` capability-probe pattern established by
190    /// `ChatSessionExt::supports_quotas` and
191    /// `ChatSessionExt::supports_refplus` in `jmap-chat-client`.
192    ///
193    /// [`jmap_cid_types::Sha256`]: https://docs.rs/jmap-cid-types
194    pub fn supports_cid(&self) -> bool {
195        self.capabilities.contains_key("urn:ietf:params:jmap:cid")
196    }
197}
198
199/// Manual `Debug` impl that redacts privacy-sensitive fields (bd:JMAP-sc1b.99).
200///
201/// `Session.username` is the authenticated user's identifier — typically a
202/// full email address, which is PII under GDPR/CCPA. `Session.state` is the
203/// opaque RFC 8620 §2 session-state token; it is not an auth credential, but
204/// it uniquely identifies the client's session and is the same shape of leak
205/// as logging a session cookie. Both are replaced with `"[REDACTED]"` /
206/// `"[opaque]"` in the Debug output.
207///
208/// All other URL/map fields are surfaced — they are deployment metadata and
209/// not credential-grade. `AccountInfo.name` is redacted by `AccountInfo`'s
210/// own manual `Debug` impl, so the `accounts` map below does not leak
211/// owner emails transitively (bd:JMAP-sc1b.104).
212impl std::fmt::Debug for Session {
213    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214        f.debug_struct("Session")
215            .field("capabilities", &self.capabilities)
216            .field("accounts", &self.accounts)
217            .field("primary_accounts", &self.primary_accounts)
218            .field("username", &"[REDACTED]")
219            .field("api_url", &self.api_url)
220            .field("download_url", &self.download_url)
221            .field("upload_url", &self.upload_url)
222            .field("event_source_url", &self.event_source_url)
223            .field("state", &"[opaque]")
224            .field("extra", &self.extra)
225            .finish()
226    }
227}
228
229// ---------------------------------------------------------------------------
230// AccountInfo (RFC 8620 §2 Account object)
231// ---------------------------------------------------------------------------
232
233/// Per-account metadata in a JMAP Session (RFC 8620 §2).
234///
235/// `Debug` is hand-written to redact `name` because the field's own
236/// definition identifies it as "typically the owner's email address"
237/// (PII under GDPR/CCPA). The other fields are non-credential metadata
238/// and are surfaced directly. See bd:JMAP-sc1b.104.
239#[non_exhaustive]
240#[derive(Clone, PartialEq, Eq, Deserialize)]
241#[serde(rename_all = "camelCase")]
242pub struct AccountInfo {
243    /// Human-readable account name (e.g. the owner's email address).
244    pub name: String,
245
246    /// `true` if this is the authenticated user's own personal account.
247    pub is_personal: bool,
248
249    /// `true` if the entire account is read-only for the current user.
250    pub is_read_only: bool,
251
252    /// Map of capability URI → capability object for this account.
253    ///
254    /// Values are kept as raw JSON so extension crates can extract
255    /// their own capability objects.
256    pub account_capabilities: HashMap<String, serde_json::Value>,
257
258    /// Catch-all for vendor / site / private extension fields not covered
259    /// by the typed fields above. Preserves unknown fields across
260    /// deserialize/serialize round-trip per workspace extras-preservation
261    /// policy (see workspace AGENTS.md).
262    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
263    pub extra: serde_json::Map<String, serde_json::Value>,
264}
265
266/// Manual `Debug` impl that redacts `name` (bd:JMAP-sc1b.104).
267///
268/// `AccountInfo.name` is typically the owner's email address, which is
269/// PII under GDPR/CCPA. The other fields (`is_personal`, `is_read_only`,
270/// `account_capabilities`) are non-credential metadata and are surfaced
271/// directly so `{:?}` output remains useful for debugging.
272///
273/// This redaction closes the transitive leak through `Session.accounts`
274/// — `Session`'s own Debug impl (bd:JMAP-sc1b.99) only redacted
275/// `username` and `state` directly and was silent about the accounts
276/// map. With `AccountInfo` redacting itself, any `{:?}` of a `Session`
277/// is now safe with respect to the canonical email-shaped PII.
278impl std::fmt::Debug for AccountInfo {
279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280        f.debug_struct("AccountInfo")
281            .field("name", &"[REDACTED]")
282            .field("is_personal", &self.is_personal)
283            .field("is_read_only", &self.is_read_only)
284            .field("account_capabilities", &self.account_capabilities)
285            .field("extra", &self.extra)
286            .finish()
287    }
288}
289
290// ---------------------------------------------------------------------------
291// WebSocketCapability (RFC 8887)
292// ---------------------------------------------------------------------------
293
294/// Capability object for `"urn:ietf:params:jmap:websocket"` (RFC 8887).
295///
296/// Advertised in `Session.capabilities` when the server supports JMAP over
297/// WebSocket. The `url` field is the `wss://` endpoint to connect to.
298#[non_exhaustive]
299#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
300#[serde(rename_all = "camelCase")]
301pub struct WebSocketCapability {
302    /// The WebSocket endpoint URL (`wss://`).
303    pub url: String,
304
305    /// Whether the server supports push notifications over this WebSocket.
306    #[serde(default)]
307    pub supports_push: bool,
308
309    /// Catch-all for vendor / site / private extension fields not covered
310    /// by the typed fields above. Preserves unknown fields across
311    /// deserialize/serialize round-trip per workspace extras-preservation
312    /// policy (see workspace AGENTS.md).
313    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
314    pub extra: serde_json::Map<String, serde_json::Value>,
315}
316
317// ---------------------------------------------------------------------------
318// Tests
319// ---------------------------------------------------------------------------
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use serde_json::json;
325
326    // -----------------------------------------------------------------------
327    // JmapRequestBuilder
328    // -----------------------------------------------------------------------
329
330    /// Oracle: RFC 8620 §3.3 — a request with two method calls serializes to
331    /// a JSON object with a "methodCalls" array containing two 3-element arrays.
332    /// The expected JSON shape is derived directly from the RFC §3.3 example.
333    #[test]
334    fn builder_two_calls_serializes_correctly() {
335        let mut builder =
336            JmapRequestBuilder::new(&["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"]);
337        builder
338            .add_call(
339                "Mailbox/get",
340                json!({"accountId": "A13824", "ids": null}),
341                "r1",
342            )
343            .expect("add_call r1 must succeed");
344        builder
345            .add_call(
346                "Email/get",
347                json!({"accountId": "A13824", "ids": ["e001"]}),
348                "r2",
349            )
350            .expect("add_call r2 must succeed");
351        let req = builder.build().expect("build must succeed with two calls");
352
353        let v = serde_json::to_value(&req).expect("serialize JmapRequest");
354
355        // Oracle: RFC 8620 §3.3 — "using" must be present
356        assert!(v.get("using").is_some(), "must have 'using' field");
357        let using = v["using"].as_array().expect("using must be array");
358        assert_eq!(using.len(), 2);
359        assert!(using.contains(&json!("urn:ietf:params:jmap:core")));
360        assert!(using.contains(&json!("urn:ietf:params:jmap:mail")));
361
362        // Oracle: RFC 8620 §3.3 — "methodCalls" must be present
363        let calls = v["methodCalls"]
364            .as_array()
365            .expect("methodCalls must be array");
366        assert_eq!(calls.len(), 2, "must have exactly 2 method calls");
367
368        // Oracle: RFC 8620 §3.2 — each invocation is [methodName, args, callId]
369        assert_eq!(calls[0][0], json!("Mailbox/get"));
370        assert_eq!(calls[0][2], json!("r1"));
371        assert_eq!(calls[1][0], json!("Email/get"));
372        assert_eq!(calls[1][2], json!("r2"));
373    }
374
375    /// Oracle: RFC 8620 §3.3 — build() with no method calls is invalid;
376    /// must return Err(InvalidArgument) rather than produce an empty batch.
377    #[test]
378    fn builder_returns_err_on_empty_build() {
379        let result = JmapRequestBuilder::new(&["urn:ietf:params:jmap:core"]).build();
380        assert!(
381            matches!(result, Err(ClientError::InvalidArgument(_))),
382            "empty build must return Err(InvalidArgument), got {result:?}"
383        );
384    }
385
386    /// Oracle: RFC 8620 §3.5 — call IDs must be unique within a request.
387    /// Duplicate call ID returns Err(ClientError::InvalidArgument).
388    #[test]
389    fn builder_returns_err_on_duplicate_call_id() {
390        let mut builder = JmapRequestBuilder::new(&["urn:ietf:params:jmap:core"]);
391        builder
392            .add_call("Foo/get", json!({}), "r1")
393            .expect("first add_call must succeed");
394        let result = builder.add_call("Bar/get", json!({}), "r1"); // duplicate
395        assert!(
396            matches!(result, Err(ClientError::InvalidArgument(_))),
397            "duplicate call_id must return Err(InvalidArgument), got {result:?}"
398        );
399    }
400
401    // -----------------------------------------------------------------------
402    // Session
403    // -----------------------------------------------------------------------
404
405    /// Oracle: RFC 8620 §2.1 example Session JSON, transcribed from the RFC text.
406    /// All field names and values come from the RFC, not from the code under test.
407    #[test]
408    fn session_deserializes_rfc8620_example() {
409        // RFC 8620 §2.1 example — hand-transcribed from spec text.
410        let raw = r#"{
411            "capabilities": {
412                "urn:ietf:params:jmap:core": {
413                    "maxSizeUpload": 50000000,
414                    "maxConcurrentUpload": 8,
415                    "maxSizeRequest": 10000000,
416                    "maxConcurrentRequest": 8,
417                    "maxCallsInRequest": 32,
418                    "maxObjectsInGet": 256,
419                    "maxObjectsInSet": 128,
420                    "collationAlgorithms": [
421                        "i;ascii-numeric",
422                        "i;ascii-casemap",
423                        "i;unicode-casemap"
424                    ]
425                },
426                "urn:ietf:params:jmap:mail": {},
427                "urn:ietf:params:jmap:contacts": {},
428                "https://example.com/apis/foobar": {
429                    "maxFoosFinangled": 42
430                }
431            },
432            "accounts": {
433                "A13824": {
434                    "name": "john@example.com",
435                    "isPersonal": true,
436                    "isReadOnly": false,
437                    "accountCapabilities": {
438                        "urn:ietf:params:jmap:mail": {
439                            "maxMailboxesPerEmail": null,
440                            "maxMailboxDepth": 10
441                        },
442                        "urn:ietf:params:jmap:contacts": {}
443                    }
444                },
445                "A97813": {
446                    "name": "jane@example.com",
447                    "isPersonal": false,
448                    "isReadOnly": true,
449                    "accountCapabilities": {
450                        "urn:ietf:params:jmap:mail": {
451                            "maxMailboxesPerEmail": 1,
452                            "maxMailboxDepth": 10
453                        }
454                    }
455                }
456            },
457            "primaryAccounts": {
458                "urn:ietf:params:jmap:mail": "A13824",
459                "urn:ietf:params:jmap:contacts": "A13824"
460            },
461            "username": "john@example.com",
462            "apiUrl": "https://jmap.example.com/api/",
463            "downloadUrl": "https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}",
464            "uploadUrl": "https://jmap.example.com/upload/{accountId}/",
465            "eventSourceUrl": "https://jmap.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
466            "state": "75128aab4b1b"
467        }"#;
468
469        let session: Session =
470            serde_json::from_str(raw).expect("RFC 8620 §2.1 example must deserialize");
471
472        // Oracle: RFC 8620 §2.1
473        assert_eq!(session.username, "john@example.com");
474        assert_eq!(session.api_url, "https://jmap.example.com/api/");
475        assert_eq!(
476            session.upload_url,
477            "https://jmap.example.com/upload/{accountId}/"
478        );
479        assert_eq!(
480            session.download_url,
481            "https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}"
482        );
483        assert_eq!(
484            session.event_source_url,
485            "https://jmap.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}"
486        );
487        assert_eq!(session.state, "75128aab4b1b");
488
489        // Oracle: RFC 8620 §2.1 — capabilities map
490        assert!(
491            session
492                .capabilities
493                .contains_key("urn:ietf:params:jmap:core"),
494            "must have core capability"
495        );
496        assert!(
497            session
498                .capabilities
499                .contains_key("urn:ietf:params:jmap:mail"),
500            "must have mail capability"
501        );
502        assert!(
503            session
504                .capabilities
505                .contains_key("https://example.com/apis/foobar"),
506            "must have vendor capability"
507        );
508
509        // Oracle: RFC 8620 §2.1 — accounts map
510        assert!(
511            session.accounts.contains_key("A13824"),
512            "must have account A13824"
513        );
514        assert!(
515            session.accounts.contains_key("A97813"),
516            "must have account A97813"
517        );
518
519        // Oracle: RFC 8620 §2.1 — primaryAccounts
520        assert_eq!(
521            session.primary_account_id("urn:ietf:params:jmap:mail"),
522            Some("A13824")
523        );
524        assert_eq!(
525            session.primary_account_id("urn:ietf:params:jmap:contacts"),
526            Some("A13824")
527        );
528        assert_eq!(
529            session.primary_account_id("urn:ietf:params:jmap:core"),
530            None
531        );
532    }
533
534    // -----------------------------------------------------------------------
535    // AccountInfo
536    // -----------------------------------------------------------------------
537
538    /// Oracle: RFC 8620 §2.1 example — account A13824 (john@example.com).
539    /// Field names and values transcribed directly from the RFC.
540    #[test]
541    fn account_info_deserializes_rfc8620_example() {
542        // RFC 8620 §2.1 example account entry
543        let raw = r#"{
544            "name": "john@example.com",
545            "isPersonal": true,
546            "isReadOnly": false,
547            "accountCapabilities": {
548                "urn:ietf:params:jmap:mail": {
549                    "maxMailboxesPerEmail": null,
550                    "maxMailboxDepth": 10
551                },
552                "urn:ietf:params:jmap:contacts": {}
553            }
554        }"#;
555
556        let account: AccountInfo =
557            serde_json::from_str(raw).expect("RFC 8620 §2.1 AccountInfo must deserialize");
558
559        // Oracle: RFC 8620 §2 Account object fields
560        assert_eq!(account.name, "john@example.com");
561        assert!(account.is_personal, "isPersonal must be true");
562        assert!(!account.is_read_only, "isReadOnly must be false");
563        assert!(
564            account
565                .account_capabilities
566                .contains_key("urn:ietf:params:jmap:mail"),
567            "must have mail capability"
568        );
569        assert!(
570            account
571                .account_capabilities
572                .contains_key("urn:ietf:params:jmap:contacts"),
573            "must have contacts capability"
574        );
575
576        // Oracle: RFC 8620 §2.1 — read-only account (A97813 / jane@example.com)
577        let raw2 = r#"{
578            "name": "jane@example.com",
579            "isPersonal": false,
580            "isReadOnly": true,
581            "accountCapabilities": {
582                "urn:ietf:params:jmap:mail": {
583                    "maxMailboxesPerEmail": 1,
584                    "maxMailboxDepth": 10
585                }
586            }
587        }"#;
588        let account2: AccountInfo = serde_json::from_str(raw2)
589            .expect("RFC 8620 §2.1 read-only AccountInfo must deserialize");
590
591        assert_eq!(account2.name, "jane@example.com");
592        assert!(!account2.is_personal, "isPersonal must be false");
593        assert!(account2.is_read_only, "isReadOnly must be true");
594    }
595
596    // -----------------------------------------------------------------------
597    // WebSocketCapability
598    // -----------------------------------------------------------------------
599
600    /// Oracle: RFC 8887 §3 — WebSocketCapability has url and supportsPush fields.
601    /// Transcribed from the RFC 8887 capability object definition.
602    #[test]
603    fn websocket_capability_deserializes() {
604        let raw = r#"{"url": "wss://jmap.example.com/ws", "supportsPush": true}"#;
605        let cap: WebSocketCapability =
606            serde_json::from_str(raw).expect("WebSocketCapability must deserialize");
607        assert_eq!(cap.url, "wss://jmap.example.com/ws");
608        assert!(cap.supports_push);
609    }
610
611    /// Oracle: RFC 8887 §3 — supportsPush defaults to false when absent.
612    #[test]
613    fn websocket_capability_supports_push_defaults_false() {
614        let raw = r#"{"url": "wss://jmap.example.com/ws"}"#;
615        let cap: WebSocketCapability =
616            serde_json::from_str(raw).expect("WebSocketCapability must deserialize");
617        assert_eq!(cap.url, "wss://jmap.example.com/ws");
618        assert!(!cap.supports_push, "supportsPush must default to false");
619    }
620
621    /// Oracle: Session.websocket_capability() returns Ok(None) when key absent.
622    #[test]
623    fn session_websocket_capability_absent_returns_ok_none() {
624        let raw = r#"{
625            "capabilities": {},
626            "accounts": {},
627            "primaryAccounts": {},
628            "username": "u@example.com",
629            "apiUrl": "https://jmap.example.com/api/",
630            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
631            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
632            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
633            "state": "s1"
634        }"#;
635        let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
636        let result = session.websocket_capability();
637        assert!(
638            matches!(result, Ok(None)),
639            "expected Ok(None), got {result:?}"
640        );
641    }
642
643    /// Oracle: Session.websocket_capability() returns Ok(Some) when key present and valid.
644    #[test]
645    fn session_websocket_capability_present_and_valid() {
646        let raw = r#"{
647            "capabilities": {
648                "urn:ietf:params:jmap:websocket": {
649                    "url": "wss://jmap.example.com/ws",
650                    "supportsPush": true
651                }
652            },
653            "accounts": {},
654            "primaryAccounts": {},
655            "username": "u@example.com",
656            "apiUrl": "https://jmap.example.com/api/",
657            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
658            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
659            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
660            "state": "s1"
661        }"#;
662        let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
663        let ws = session
664            .websocket_capability()
665            .expect("must not error")
666            .expect("websocket capability must be present");
667        assert_eq!(ws.url, "wss://jmap.example.com/ws");
668        assert!(ws.supports_push);
669    }
670
671    /// Oracle: `Session::supports_cid()` returns `false` when the JMAP
672    /// CID capability URI is not present in the capabilities map
673    /// (bd:JMAP-v9py.14).
674    ///
675    /// Mirrors the absent-key precedent of
676    /// `session_websocket_capability_absent_returns_ok_none`. The test
677    /// fixture has an empty capabilities map; the negative answer must
678    /// be `false`, not `Err` or panic.
679    #[test]
680    fn supports_cid_returns_false_when_capability_absent() {
681        let raw = r#"{
682            "capabilities": {},
683            "accounts": {},
684            "primaryAccounts": {},
685            "username": "u@example.com",
686            "apiUrl": "https://jmap.example.com/api/",
687            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
688            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
689            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
690            "state": "s1"
691        }"#;
692        let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
693        assert!(!session.supports_cid());
694    }
695
696    /// Oracle: `Session::supports_cid()` returns `true` when the JMAP
697    /// CID capability URI is present in the capabilities map, even
698    /// though the value object is empty per draft-atwood-jmap-cid-00
699    /// §2 ("no capability fields defined at this time")
700    /// (bd:JMAP-v9py.14).
701    #[test]
702    fn supports_cid_returns_true_when_capability_present_empty_value() {
703        let raw = r#"{
704            "capabilities": {
705                "urn:ietf:params:jmap:cid": {}
706            },
707            "accounts": {},
708            "primaryAccounts": {},
709            "username": "u@example.com",
710            "apiUrl": "https://jmap.example.com/api/",
711            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
712            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
713            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
714            "state": "s1"
715        }"#;
716        let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
717        assert!(session.supports_cid());
718    }
719
720    /// Oracle: `Session::supports_cid()` checks only for the URI key —
721    /// presence with a non-empty value object (vendor extras inside the
722    /// CID capability) still returns `true`. The draft reserves the
723    /// shape of the capability value but does not currently define any
724    /// fields; a server that pre-populates vendor fields under the URI
725    /// must still be detected as supporting CID.
726    #[test]
727    fn supports_cid_returns_true_when_capability_present_with_extra_fields() {
728        let raw = r#"{
729            "capabilities": {
730                "urn:ietf:params:jmap:cid": {
731                    "x-vendor-flag": "future-shape"
732                }
733            },
734            "accounts": {},
735            "primaryAccounts": {},
736            "username": "u@example.com",
737            "apiUrl": "https://jmap.example.com/api/",
738            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
739            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
740            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
741            "state": "s1"
742        }"#;
743        let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
744        assert!(session.supports_cid());
745    }
746
747    /// Oracle: Session's manual Debug impl never reveals the authenticated
748    /// `username` or the opaque `state` token (bd:JMAP-sc1b.99), AND the
749    /// `accounts` map does not transitively leak `AccountInfo.name`
750    /// (bd:JMAP-sc1b.104). Mirrors the canary tripwire pattern used by
751    /// `bearer_auth_debug_does_not_leak_token` and
752    /// `basic_auth_debug_does_not_leak_credentials` in auth.rs.
753    ///
754    /// The canary literals are independent of the Session's internal state —
755    /// the test is the oracle, not the code under test. A regression that
756    /// re-derives `Debug` on `Session` or `AccountInfo`, or that prints the
757    /// username/state/name via a manual impl, would fail the assertion.
758    ///
759    /// We deliberately reuse `CANARY_USER` in two distinct locations
760    /// (`username` and `accounts["a1"].name`) so a single negative
761    /// `assert!(!dbg.contains(...))` catches a leak from either path —
762    /// the same kind of email-shaped PII surfacing through either field
763    /// is the failure we want to fail loudly.
764    #[test]
765    fn session_debug_does_not_leak_username_or_state() {
766        const CANARY_USER: &str = "CANARY-USERNAME-DO-NOT-LEAK@example.com";
767        const CANARY_STATE: &str = "CANARY-STATE-TOKEN-DO-NOT-LEAK";
768        let raw = format!(
769            r#"{{
770                "capabilities": {{}},
771                "accounts": {{
772                    "a1": {{
773                        "name": "{CANARY_USER}",
774                        "isPersonal": true,
775                        "isReadOnly": false,
776                        "accountCapabilities": {{}}
777                    }}
778                }},
779                "primaryAccounts": {{}},
780                "username": "{CANARY_USER}",
781                "apiUrl": "https://jmap.example.com/api/",
782                "downloadUrl": "https://jmap.example.com/dl/{{accountId}}/",
783                "uploadUrl": "https://jmap.example.com/ul/{{accountId}}/",
784                "eventSourceUrl": "https://jmap.example.com/sse/",
785                "state": "{CANARY_STATE}"
786            }}"#
787        );
788        let session: Session = serde_json::from_str(&raw).expect("Session must deserialize");
789
790        // Sanity-check: the canary really did land in the AccountInfo —
791        // otherwise an empty accounts map would silently make the
792        // transitive-leak assertion below tautologically pass.
793        let account = session
794            .accounts
795            .get("a1")
796            .expect("accounts['a1'] must deserialize");
797        assert_eq!(account.name, CANARY_USER);
798
799        let dbg = format!("{session:?}");
800        assert!(
801            !dbg.contains(CANARY_USER),
802            "Session Debug must not contain the raw username or AccountInfo.name; got: {dbg}"
803        );
804        assert!(
805            !dbg.contains(CANARY_STATE),
806            "Session Debug must not contain the raw state token; got: {dbg}"
807        );
808    }
809
810    /// Oracle: AccountInfo's manual Debug impl never reveals the raw
811    /// `name` field (bd:JMAP-sc1b.104). Independent of the Session-level
812    /// test above: a regression on AccountInfo alone (e.g. re-deriving
813    /// `#[derive(Debug)]`) would be caught here without needing the
814    /// Session wrapper.
815    #[test]
816    fn account_info_debug_does_not_leak_name() {
817        const CANARY_NAME: &str = "CANARY-ACCOUNT-NAME-DO-NOT-LEAK@example.com";
818        let raw = format!(
819            r#"{{
820                "name": "{CANARY_NAME}",
821                "isPersonal": true,
822                "isReadOnly": false,
823                "accountCapabilities": {{}}
824            }}"#
825        );
826        let account: AccountInfo =
827            serde_json::from_str(&raw).expect("AccountInfo must deserialize");
828        // Sanity-check that the canary really did populate `name`.
829        assert_eq!(account.name, CANARY_NAME);
830
831        let dbg = format!("{account:?}");
832        assert!(
833            !dbg.contains(CANARY_NAME),
834            "AccountInfo Debug must not contain the raw name; got: {dbg}"
835        );
836    }
837
838    // ── Extras-preservation policy tests (JMAP-lbdy.9) ─────────────────
839    //
840    // Synthetic `acmeCorp*` vendor keys cannot collide with any RFC 8620 /
841    // RFC 8887 typed field, so the tests are independent of the code under
842    // test (workspace test-integrity rule).
843
844    /// `Session.extra` captures unknown fields on deserialize.
845    #[test]
846    fn session_preserves_vendor_extras() {
847        let raw = json!({
848            "capabilities": {},
849            "accounts": {},
850            "primaryAccounts": {},
851            "username": "u@example.com",
852            "apiUrl": "https://jmap.example.com/api/",
853            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
854            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
855            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
856            "state": "s1",
857            "acmeCorpDeployment": "prod-eu-west-1"
858        });
859        let obj: Session = serde_json::from_value(raw).expect("Session must deserialize");
860        assert_eq!(
861            obj.extra.get("acmeCorpDeployment").and_then(|v| v.as_str()),
862            Some("prod-eu-west-1")
863        );
864    }
865
866    /// `AccountInfo.extra` captures unknown fields on deserialize.
867    #[test]
868    fn account_info_preserves_vendor_extras() {
869        let raw = json!({
870            "name": "u@example.com",
871            "isPersonal": true,
872            "isReadOnly": false,
873            "accountCapabilities": {},
874            "acmeCorpQuotaTier": "gold"
875        });
876        let obj: AccountInfo = serde_json::from_value(raw).expect("AccountInfo must deserialize");
877        assert_eq!(
878            obj.extra.get("acmeCorpQuotaTier").and_then(|v| v.as_str()),
879            Some("gold")
880        );
881    }
882
883    /// `WebSocketCapability.extra` captures unknown fields on deserialize.
884    #[test]
885    fn websocket_capability_preserves_vendor_extras() {
886        let raw = json!({
887            "url": "wss://jmap.example.com/ws",
888            "supportsPush": true,
889            "acmeCorpHeartbeatMs": 30000
890        });
891        let obj: WebSocketCapability =
892            serde_json::from_value(raw).expect("WebSocketCapability must deserialize");
893        assert_eq!(
894            obj.extra
895                .get("acmeCorpHeartbeatMs")
896                .and_then(|v| v.as_u64()),
897            Some(30000)
898        );
899    }
900}