Skip to main content

jmap_chat_client/
session.rs

1//! ChatSessionExt trait for [`jmap_base_client::Session`].
2//!
3//! Adds JMAP Chat extension methods to the base `Session` type.
4//!
5//! Specs:
6//!   - draft-atwood-jmap-chat-00 §3      (ChatCapability fields)
7//!   - draft-atwood-jmap-chat-push-00    (ChatPushCapability fields)
8//!   - draft-atwood-jmap-chat-wss-00     (supports_chat_websocket)
9//!
10//! [`ChatCapability`] and [`ChatPushCapability`] live in `jmap-chat-types`
11//! (the canonical home for chat wire-format types); this module re-exports
12//! them for ergonomic use from the trait signatures below.
13
14pub use jmap_chat_types::{ChatCapability, ChatPushCapability};
15
16// ---------------------------------------------------------------------------
17// ChatSessionExt
18// ---------------------------------------------------------------------------
19
20/// Extension methods for [`jmap_base_client::Session`] that surface
21/// JMAP Chat capability information.
22///
23/// Import this trait to use Chat-specific session helpers:
24/// ```ignore
25/// use jmap_chat_client::ChatSessionExt;
26/// ```
27///
28/// This trait is **sealed**: implementations outside this crate are not
29/// permitted. The crate adds an `impl` only for
30/// [`jmap_base_client::Session`]. Sealing prevents downstream
31/// divergence and keeps adding methods to the trait a non-breaking
32/// change.
33pub trait ChatSessionExt: sealed::Sealed {
34    /// Returns the primary account ID for the JMAP Chat capability, if present.
35    ///
36    /// Reads `primaryAccounts["urn:ietf:params:jmap:chat"]`.
37    ///
38    /// Returns `None` when the server does not declare a primary chat account.
39    fn chat_account_id(&self) -> Option<&str>;
40
41    /// Returns the parsed [`ChatCapability`] for the given account, if present.
42    ///
43    /// Reads `accounts[account_id].accountCapabilities["urn:ietf:params:jmap:chat"]`.
44    ///
45    /// - `Ok(None)` — the account is absent or has no chat capability key.
46    /// - `Ok(Some(...))` — the capability is present and parsed successfully.
47    /// - `Err(ClientError::Parse(...))` — the key is present but malformed JSON.
48    fn chat_capability(
49        &self,
50        account_id: &str,
51    ) -> Result<Option<ChatCapability>, jmap_base_client::ClientError>;
52
53    /// Returns the parsed [`ChatPushCapability`] for the given account, if present.
54    ///
55    /// Reads `accounts[account_id].accountCapabilities["urn:ietf:params:jmap:chat:push"]`.
56    ///
57    /// - `Ok(None)` — the account is absent or has no chat push capability key.
58    /// - `Ok(Some(...))` — the capability is present and parsed successfully.
59    /// - `Err(ClientError::Parse(...))` — the key is present but malformed JSON.
60    fn chat_push_capability(
61        &self,
62        account_id: &str,
63    ) -> Result<Option<ChatPushCapability>, jmap_base_client::ClientError>;
64
65    /// Returns `true` if the server advertises JMAP Chat WebSocket ephemeral events.
66    ///
67    /// Checks for presence of `capabilities["urn:ietf:params:jmap:chat:websocket"]`.
68    /// Use [`jmap_base_client::Session::websocket_capability`] to obtain the actual
69    /// WebSocket URL for connecting.
70    fn supports_chat_websocket(&self) -> bool;
71
72    /// Returns the VAPID public key advertised by the server, if present.
73    ///
74    /// Reads `capabilities["urn:ietf:params:jmap:webpush-vapid"]["vapidPublicKey"]`.
75    ///
76    /// Returns `None` when the capability is absent or when `vapidPublicKey` is missing
77    /// or not a string value.
78    fn vapid_public_key(&self) -> Option<&str>;
79
80    /// Returns `true` if the server supports JMAP RefPlus result references.
81    ///
82    /// Checks for `capabilities["urn:ietf:params:jmap:refplus"]`.
83    fn supports_refplus(&self) -> bool;
84
85    /// Returns `true` if the server supports JMAP Quotas.
86    ///
87    /// Checks for `capabilities["urn:ietf:params:jmap:quota"]`.
88    fn supports_quotas(&self) -> bool;
89}
90
91mod sealed {
92    /// Sealing-trait for [`super::ChatSessionExt`] — see the trait's rustdoc.
93    pub trait Sealed {}
94    impl Sealed for ::jmap_base_client::Session {}
95}
96
97// ---------------------------------------------------------------------------
98// impl ChatSessionExt for jmap_base_client::Session
99// ---------------------------------------------------------------------------
100
101impl ChatSessionExt for jmap_base_client::Session {
102    fn chat_account_id(&self) -> Option<&str> {
103        self.primary_account_id("urn:ietf:params:jmap:chat")
104    }
105
106    fn chat_capability(
107        &self,
108        account_id: &str,
109    ) -> Result<Option<ChatCapability>, jmap_base_client::ClientError> {
110        let Some(account) = self.accounts.get(account_id) else {
111            return Ok(None);
112        };
113        // Delegate to the foundation helper rather than duplicating its
114        // body. Future changes to the helper (extra logging, error
115        // mapping, telemetry) propagate automatically.
116        account.account_extension_capability::<ChatCapability>("urn:ietf:params:jmap:chat")
117    }
118
119    fn chat_push_capability(
120        &self,
121        account_id: &str,
122    ) -> Result<Option<ChatPushCapability>, jmap_base_client::ClientError> {
123        let Some(account) = self.accounts.get(account_id) else {
124            return Ok(None);
125        };
126        // Delegate to the foundation helper — see chat_capability above.
127        account.account_extension_capability::<ChatPushCapability>("urn:ietf:params:jmap:chat:push")
128    }
129
130    fn supports_chat_websocket(&self) -> bool {
131        self.capabilities
132            .contains_key("urn:ietf:params:jmap:chat:websocket")
133    }
134
135    fn vapid_public_key(&self) -> Option<&str> {
136        self.capabilities
137            .get("urn:ietf:params:jmap:webpush-vapid")?
138            .get("vapidPublicKey")?
139            .as_str()
140    }
141
142    fn supports_refplus(&self) -> bool {
143        self.capabilities
144            .contains_key("urn:ietf:params:jmap:refplus")
145    }
146
147    fn supports_quotas(&self) -> bool {
148        self.capabilities.contains_key("urn:ietf:params:jmap:quota")
149    }
150}
151
152// ---------------------------------------------------------------------------
153// Tests
154// ---------------------------------------------------------------------------
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use jmap_base_client::Session;
160    use serde_json::json;
161
162    /// Build a minimal Session value from JSON without hitting the network.
163    /// Caller can inject arbitrary capabilities / accounts.
164    fn make_session(
165        capabilities: serde_json::Value,
166        accounts: serde_json::Value,
167        primary_accounts: serde_json::Value,
168    ) -> Session {
169        let raw = json!({
170            "capabilities": capabilities,
171            "accounts": accounts,
172            "primaryAccounts": primary_accounts,
173            "username": "test@example.com",
174            "apiUrl": "https://jmap.example.com/api/",
175            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
176            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
177            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
178            "state": "s1"
179        });
180        serde_json::from_value(raw).expect("make_session: malformed test JSON")
181    }
182
183    // -----------------------------------------------------------------------
184    // chat_account_id_present
185    // -----------------------------------------------------------------------
186
187    /// Oracle: primaryAccounts["urn:ietf:params:jmap:chat"] = "acct1" →
188    /// chat_account_id() returns Some("acct1").
189    /// Value derived from the JMAP Chat draft §3 (not from code under test).
190    #[test]
191    fn chat_account_id_present() {
192        let session = make_session(
193            json!({}),
194            json!({}),
195            json!({"urn:ietf:params:jmap:chat": "acct1"}),
196        );
197        assert_eq!(session.chat_account_id(), Some("acct1"));
198    }
199
200    // -----------------------------------------------------------------------
201    // chat_account_id_absent
202    // -----------------------------------------------------------------------
203
204    /// Oracle: empty primaryAccounts → chat_account_id() returns None.
205    /// Per RFC 8620 §2, primaryAccounts is a map; an absent key means no
206    /// primary account for that capability.
207    #[test]
208    fn chat_account_id_absent() {
209        let session = make_session(json!({}), json!({}), json!({}));
210        assert!(
211            session.chat_account_id().is_none(),
212            "expected None for missing primaryAccounts entry"
213        );
214    }
215
216    // -----------------------------------------------------------------------
217    // chat_capability_parses
218    // -----------------------------------------------------------------------
219
220    /// Oracle: valid ChatCapability JSON at accounts[id].accountCapabilities
221    /// → Ok(Some(cap)) with correct field values.
222    /// Field names and types from draft-atwood-jmap-chat-00 §3.
223    #[test]
224    fn chat_capability_parses() {
225        let session = make_session(
226            json!({}),
227            json!({
228                "acct1": {
229                    "name": "test@example.com",
230                    "isPersonal": true,
231                    "isReadOnly": false,
232                    "accountCapabilities": {
233                        "urn:ietf:params:jmap:chat": {
234                            "maxBodyBytes": 65536,
235                            "maxAttachmentBytes": 10485760,
236                            "maxAttachmentsPerMessage": 10,
237                            "supportsThreads": true
238                        }
239                    }
240                }
241            }),
242            json!({"urn:ietf:params:jmap:chat": "acct1"}),
243        );
244
245        let cap = session
246            .chat_capability("acct1")
247            .expect("chat_capability must not return Err")
248            .expect("acct1 must have chat capability");
249
250        // Oracle: field values match what was put in the JSON above
251        assert_eq!(cap.max_body_bytes, 65536);
252        assert_eq!(cap.max_attachment_bytes, 10485760);
253        assert_eq!(cap.max_attachments_per_message, 10);
254        assert!(cap.supports_threads);
255    }
256
257    // -----------------------------------------------------------------------
258    // supports_chat_websocket_true
259    // -----------------------------------------------------------------------
260
261    /// Oracle: capabilities contains "urn:ietf:params:jmap:chat:websocket" →
262    /// supports_chat_websocket() returns true.
263    /// Per draft-atwood-jmap-chat-wss-00, presence of this key signals support.
264    #[test]
265    fn supports_chat_websocket_true() {
266        let session = make_session(
267            json!({"urn:ietf:params:jmap:chat:websocket": {}}),
268            json!({}),
269            json!({}),
270        );
271        assert!(
272            session.supports_chat_websocket(),
273            "expected true when capability key is present"
274        );
275    }
276
277    // -----------------------------------------------------------------------
278    // supports_chat_websocket_false
279    // -----------------------------------------------------------------------
280
281    /// Oracle: capabilities does not contain "urn:ietf:params:jmap:chat:websocket" →
282    /// supports_chat_websocket() returns false.
283    #[test]
284    fn supports_chat_websocket_false() {
285        let session = make_session(json!({}), json!({}), json!({}));
286        assert!(
287            !session.supports_chat_websocket(),
288            "expected false when capability key is absent"
289        );
290    }
291
292    // ── Extras-preservation policy tests (JMAP-lbdy.9) ─────────────────
293    //
294    // Each test deserialises wire JSON containing a synthetic `acmeCorp*`
295    // vendor field and asserts it survives in `extra`. The vendor field
296    // names cannot collide with any field defined in
297    // draft-atwood-jmap-chat-00 §3 or draft-atwood-jmap-chat-push-00, so
298    // the tests are independent of the code under test (workspace
299    // test-integrity rule).
300
301    /// Oracle: `supportedBodyTypes` on the wire deserializes into
302    /// `ChatCapability.supported_body_types: Vec<BodyType>` preserving
303    /// order, with canonical wire strings mapped to typed variants.
304    /// The spec (draft-atwood-jmap-chat-00 §3) mandates "text/plain"
305    /// and recommends a defined set of additional values; the client
306    /// trusts the server's advertised list verbatim.
307    #[test]
308    fn chat_capability_supported_body_types_round_trips() {
309        use jmap_chat_types::BodyType;
310        let raw = json!({
311            "maxBodyBytes": 65536,
312            "maxAttachmentBytes": 10485760,
313            "maxAttachmentsPerMessage": 10,
314            "supportsThreads": true,
315            "supportedBodyTypes": [
316                "text/plain",
317                "text/markdown",
318                "application/jmap-chat-rich"
319            ]
320        });
321        let cap: ChatCapability =
322            serde_json::from_value(raw).expect("ChatCapability must deserialize");
323        assert_eq!(
324            cap.supported_body_types,
325            vec![BodyType::Plain, BodyType::Markdown, BodyType::Rich],
326            "supported_body_types must preserve wire order and map canonical strings to typed variants"
327        );
328    }
329
330    /// Oracle: unknown body-type wire strings round-trip via the
331    /// `impl_string_enum!` `Other(String)` catch-all when nested inside
332    /// `Vec<BodyType>`.
333    #[test]
334    fn chat_capability_supported_body_types_unknown_variant_round_trips() {
335        use jmap_chat_types::BodyType;
336        let raw = json!({
337            "maxBodyBytes": 65536,
338            "maxAttachmentBytes": 10485760,
339            "maxAttachmentsPerMessage": 10,
340            "supportsThreads": true,
341            "supportedBodyTypes": [
342                "text/plain",
343                "application/mls-ciphertext"
344            ]
345        });
346        let cap: ChatCapability =
347            serde_json::from_value(raw).expect("ChatCapability must deserialize");
348        assert_eq!(
349            cap.supported_body_types,
350            vec![
351                BodyType::Plain,
352                BodyType::Other("application/mls-ciphertext".to_owned()),
353            ],
354            "unknown wire strings must land in BodyType::Other preserving the original string"
355        );
356    }
357
358    /// Oracle: a server that omits `supportedBodyTypes` deserializes
359    /// to an empty `Vec` via `#[serde(default)]`. This is technically
360    /// non-compliant per spec (`"text/plain"` is mandatory) but the
361    /// client tolerates it — enforcement is the consumer's job.
362    #[test]
363    fn chat_capability_supported_body_types_absent_defaults_empty() {
364        let raw = json!({
365            "maxBodyBytes": 65536,
366            "maxAttachmentBytes": 10485760,
367            "maxAttachmentsPerMessage": 10,
368            "supportsThreads": true
369        });
370        let cap: ChatCapability =
371            serde_json::from_value(raw).expect("ChatCapability must deserialize");
372        assert!(
373            cap.supported_body_types.is_empty(),
374            "missing supportedBodyTypes must default to an empty Vec"
375        );
376    }
377
378    /// `ChatCapability.extra` captures unknown fields on deserialize
379    /// AND survives serialize round-trip.
380    #[test]
381    fn chat_capability_preserves_vendor_extras() {
382        let raw = json!({
383            "maxBodyBytes": 65536,
384            "maxAttachmentBytes": 10485760,
385            "maxAttachmentsPerMessage": 10,
386            "supportsThreads": true,
387            "acmeCorpFeatureFlag": "beta"
388        });
389        let obj: ChatCapability =
390            serde_json::from_value(raw.clone()).expect("ChatCapability must deserialize");
391        assert_eq!(
392            obj.extra
393                .get("acmeCorpFeatureFlag")
394                .and_then(|v| v.as_str()),
395            Some("beta")
396        );
397
398        // Serialize round-trip: the vendor field must survive to the
399        // wire and the typed fields must NOT be duplicated into extra
400        // (no `maxBodyBytes` key inside the flattened-extra payload).
401        let reserialized = serde_json::to_value(&obj).expect("ChatCapability must serialize");
402        assert_eq!(
403            reserialized
404                .get("acmeCorpFeatureFlag")
405                .and_then(|v| v.as_str()),
406            Some("beta"),
407            "vendor field must survive deserialize -> serialize"
408        );
409        assert_eq!(
410            reserialized.get("maxBodyBytes").and_then(|v| v.as_u64()),
411            Some(65536),
412            "typed field must round-trip with its typed value"
413        );
414        // No duplication: extra must NOT have shadowed any typed key.
415        assert!(
416            obj.extra.get("maxBodyBytes").is_none(),
417            "typed field maxBodyBytes must NOT be duplicated into extra"
418        );
419        assert!(
420            obj.extra.get("supportsThreads").is_none(),
421            "typed field supportsThreads must NOT be duplicated into extra"
422        );
423    }
424
425    /// `ChatPushCapability.extra` captures unknown fields on deserialize
426    /// AND survives serialize round-trip.
427    #[test]
428    fn chat_push_capability_preserves_vendor_extras() {
429        let raw = json!({
430            "maxSnippetBytes": 256,
431            "supportedUrgencyValues": ["normal", "high"],
432            "maxMessagesPerPush": 10,
433            "acmeCorpPushTier": "gold"
434        });
435        let obj: ChatPushCapability =
436            serde_json::from_value(raw.clone()).expect("ChatPushCapability must deserialize");
437        assert_eq!(
438            obj.extra.get("acmeCorpPushTier").and_then(|v| v.as_str()),
439            Some("gold")
440        );
441
442        // Serialize round-trip + no-duplication into extra.
443        let reserialized = serde_json::to_value(&obj).expect("ChatPushCapability must serialize");
444        assert_eq!(
445            reserialized
446                .get("acmeCorpPushTier")
447                .and_then(|v| v.as_str()),
448            Some("gold"),
449            "vendor field must survive deserialize -> serialize"
450        );
451        assert_eq!(
452            reserialized.get("maxSnippetBytes").and_then(|v| v.as_u64()),
453            Some(256),
454            "typed field must round-trip with its typed value"
455        );
456        assert!(
457            obj.extra.get("maxSnippetBytes").is_none(),
458            "typed field maxSnippetBytes must NOT be duplicated into extra"
459        );
460        assert!(
461            obj.extra.get("supportedUrgencyValues").is_none(),
462            "typed field supportedUrgencyValues must NOT be duplicated into extra"
463        );
464    }
465
466    // ── Strictness regression tests (bd:JMAP-26di.4) ───────────────────
467    //
468    // Oracle: draft-atwood-jmap-chat-00 §3 lines 171-184 mark
469    // maxBodyBytes / maxAttachmentBytes / maxAttachmentsPerMessage /
470    // supportsThreads as required (not optional). A server returning `{}`
471    // is non-compliant; we surface that as a deserialize error rather
472    // than silently zeroing every cap. Same expectation applies to
473    // draft-atwood-jmap-chat-push-00 lines 90-94 for maxSnippetBytes /
474    // supportedUrgencyValues.
475
476    /// `ChatCapability` from `{}` must FAIL to deserialize. Prior to
477    /// bd:JMAP-26di.4 the struct-level `#[serde(default)]` made this
478    /// succeed with every field silently zeroed, breaking callers that
479    /// trust `max_body_bytes` as a soft validation gate.
480    #[test]
481    fn chat_capability_empty_object_rejected() {
482        let raw = json!({});
483        let result: Result<ChatCapability, _> = serde_json::from_value(raw);
484        assert!(
485            result.is_err(),
486            "ChatCapability {{}} must fail deserialize (missing required fields); got Ok"
487        );
488    }
489
490    /// `ChatCapability` missing only `maxBodyBytes` must FAIL — partial
491    /// silent zeroing is the same hazard as `{}`.
492    #[test]
493    fn chat_capability_missing_max_body_bytes_rejected() {
494        let raw = json!({
495            "maxAttachmentBytes": 10485760,
496            "maxAttachmentsPerMessage": 10,
497            "supportsThreads": true
498        });
499        let result: Result<ChatCapability, _> = serde_json::from_value(raw);
500        assert!(
501            result.is_err(),
502            "ChatCapability without maxBodyBytes must fail deserialize; got Ok"
503        );
504    }
505
506    /// `ChatPushCapability` from `{}` must FAIL to deserialize.
507    /// `maxSnippetBytes` and `supportedUrgencyValues` are spec-required.
508    #[test]
509    fn chat_push_capability_empty_object_rejected() {
510        let raw = json!({});
511        let result: Result<ChatPushCapability, _> = serde_json::from_value(raw);
512        assert!(
513            result.is_err(),
514            "ChatPushCapability {{}} must fail deserialize (missing required fields); got Ok"
515        );
516    }
517
518    /// `ChatPushCapability` missing `supportedUrgencyValues` must FAIL.
519    /// `maxMessagesPerPush` IS optional (spec line 96) and may be omitted,
520    /// but the other two MUST be present.
521    #[test]
522    fn chat_push_capability_missing_supported_urgency_values_rejected() {
523        let raw = json!({
524            "maxSnippetBytes": 256
525        });
526        let result: Result<ChatPushCapability, _> = serde_json::from_value(raw);
527        assert!(
528            result.is_err(),
529            "ChatPushCapability without supportedUrgencyValues must fail deserialize; got Ok"
530        );
531    }
532
533    /// `ChatPushCapability` with both required fields but no
534    /// `maxMessagesPerPush` must SUCCEED — the optional field stays
535    /// optional after the strictness fix.
536    #[test]
537    fn chat_push_capability_optional_max_messages_per_push_absent_succeeds() {
538        let raw = json!({
539            "maxSnippetBytes": 256,
540            "supportedUrgencyValues": ["normal", "high"]
541        });
542        let cap: ChatPushCapability = serde_json::from_value(raw)
543            .expect("ChatPushCapability without maxMessagesPerPush must deserialize");
544        assert_eq!(cap.max_snippet_bytes, 256);
545        assert_eq!(
546            cap.supported_urgency_values,
547            vec![
548                jmap_chat_types::UrgencyLevel::Normal,
549                jmap_chat_types::UrgencyLevel::High,
550            ]
551        );
552        assert!(
553            cap.max_messages_per_push.is_none(),
554            "maxMessagesPerPush optional must default to None when absent"
555        );
556    }
557}