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
10use serde::Deserialize;
11
12// ---------------------------------------------------------------------------
13// ChatCapability
14// ---------------------------------------------------------------------------
15
16/// Account-level capability object for `"urn:ietf:params:jmap:chat"`.
17///
18/// Found at `accounts[id].accountCapabilities["urn:ietf:params:jmap:chat"]`.
19///
20/// Spec: draft-atwood-jmap-chat-00 §3
21#[non_exhaustive]
22#[derive(Debug, Clone, Default, Deserialize)]
23#[serde(rename_all = "camelCase")]
24#[serde(default)]
25pub struct ChatCapability {
26    /// Maximum UTF-8 byte length of a Message body.
27    pub max_body_bytes: u64,
28    /// Maximum single attachment blob size in bytes.
29    pub max_attachment_bytes: u64,
30    /// Maximum number of attachments per message.
31    pub max_attachments_per_message: u64,
32    /// Whether the server supports the optional thread model.
33    pub supports_threads: bool,
34    /// The set of Message `bodyType` values this server understands
35    /// (draft-atwood-jmap-chat-00 §3).
36    ///
37    /// Spec requirements for compliant servers:
38    ///
39    /// - MUST include `"text/plain"`.
40    /// - SHOULD include `"text/markdown"` (RFC 7763 CommonMark).
41    /// - SHOULD include `"application/jmap-chat-rich"`.
42    /// - SHOULD include `"application/mls-ciphertext"` for E2EE
43    ///   deployments.
44    /// - MAY include `"application/mimi-content"`.
45    ///
46    /// An empty `Vec` is non-compliant per spec (`"text/plain"` is
47    /// mandatory) but the client tolerates it via `Default` — the
48    /// consumer is responsible for enforcing the MUST and acting
49    /// accordingly (e.g. refusing to send rich messages to a server
50    /// that does not advertise the matching `bodyType`).
51    #[serde(default)]
52    pub supported_body_types: Vec<String>,
53    /// Catch-all for vendor / site / private extension fields not covered
54    /// by the typed fields above. Preserves unknown fields across
55    /// deserialize/serialize round-trip per workspace extras-preservation
56    /// policy (see workspace AGENTS.md).
57    ///
58    /// Per draft-atwood-jmap-chat-00 §3 (revised 2026-05-11, spec commit
59    /// `80d5e11`), the five aggregate-count caps `maxGroupMembers`,
60    /// `maxSpaceMembers`, `maxRolesPerSpace`, `maxChannelsPerSpace`, and
61    /// `maxCategoriesPerSpace` are no longer advertised on this
62    /// capability — they are implementation-defined and enforced via
63    /// standard `overQuota` SetError (RFC 8620 §5.3) at `Chat/set` and
64    /// `Space/set` time. Servers that still emit them will round-trip
65    /// the values harmlessly through `extra`.
66    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
67    pub extra: serde_json::Map<String, serde_json::Value>,
68}
69
70// ---------------------------------------------------------------------------
71// ChatPushCapability
72// ---------------------------------------------------------------------------
73
74/// Account-level capability object for `"urn:ietf:params:jmap:chat:push"`.
75///
76/// Found at `accounts[id].accountCapabilities["urn:ietf:params:jmap:chat:push"]`.
77///
78/// Spec: draft-atwood-jmap-chat-push-00
79#[non_exhaustive]
80#[derive(Debug, Clone, Default, Deserialize)]
81#[serde(rename_all = "camelCase", default)]
82pub struct ChatPushCapability {
83    /// Maximum byte length of a `bodySnippet` in `ChatMessagePush`.
84    /// Truncation occurs on a UTF-8 boundary.
85    pub max_snippet_bytes: u64,
86    /// Supported Web Push urgency values.
87    /// MUST include at least `"normal"` and `"high"`.
88    pub supported_urgency_values: Vec<String>,
89    /// Maximum number of `ChatMessageEntry` objects per push payload.
90    /// `None` means the server does not impose a bound.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub max_messages_per_push: Option<u64>,
93    /// Catch-all for vendor / site / private extension fields not covered
94    /// by the typed fields above. Preserves unknown fields across
95    /// deserialize/serialize round-trip per workspace extras-preservation
96    /// policy (see workspace AGENTS.md).
97    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
98    pub extra: serde_json::Map<String, serde_json::Value>,
99}
100
101// ---------------------------------------------------------------------------
102// ChatSessionExt
103// ---------------------------------------------------------------------------
104
105/// Extension methods for [`jmap_base_client::Session`] that surface
106/// JMAP Chat capability information.
107///
108/// Import this trait to use Chat-specific session helpers:
109/// ```ignore
110/// use jmap_chat_client::ChatSessionExt;
111/// ```
112pub trait ChatSessionExt {
113    /// Returns the primary account ID for the JMAP Chat capability, if present.
114    ///
115    /// Reads `primaryAccounts["urn:ietf:params:jmap:chat"]`.
116    ///
117    /// Returns `None` when the server does not declare a primary chat account.
118    fn chat_account_id(&self) -> Option<&str>;
119
120    /// Returns the parsed [`ChatCapability`] for the given account, if present.
121    ///
122    /// Reads `accounts[account_id].accountCapabilities["urn:ietf:params:jmap:chat"]`.
123    ///
124    /// - `Ok(None)` — the account is absent or has no chat capability key.
125    /// - `Ok(Some(...))` — the capability is present and parsed successfully.
126    /// - `Err(ClientError::Parse(...))` — the key is present but malformed JSON.
127    fn chat_capability(
128        &self,
129        account_id: &str,
130    ) -> Result<Option<ChatCapability>, jmap_base_client::ClientError>;
131
132    /// Returns the parsed [`ChatPushCapability`] for the given account, if present.
133    ///
134    /// Reads `accounts[account_id].accountCapabilities["urn:ietf:params:jmap:chat:push"]`.
135    ///
136    /// - `Ok(None)` — the account is absent or has no chat push capability key.
137    /// - `Ok(Some(...))` — the capability is present and parsed successfully.
138    /// - `Err(ClientError::Parse(...))` — the key is present but malformed JSON.
139    fn chat_push_capability(
140        &self,
141        account_id: &str,
142    ) -> Result<Option<ChatPushCapability>, jmap_base_client::ClientError>;
143
144    /// Returns `true` if the server advertises JMAP Chat WebSocket ephemeral events.
145    ///
146    /// Checks for presence of `capabilities["urn:ietf:params:jmap:chat:websocket"]`.
147    /// Use [`jmap_base_client::Session::websocket_capability`] to obtain the actual
148    /// WebSocket URL for connecting.
149    fn supports_chat_websocket(&self) -> bool;
150
151    /// Returns the VAPID public key advertised by the server, if present.
152    ///
153    /// Reads `capabilities["urn:ietf:params:jmap:webpush-vapid"]["vapidPublicKey"]`.
154    ///
155    /// Returns `None` when the capability is absent or when `vapidPublicKey` is missing
156    /// or not a string value.
157    fn vapid_public_key(&self) -> Option<&str>;
158
159    /// Returns `true` if the server supports JMAP RefPlus result references.
160    ///
161    /// Checks for `capabilities["urn:ietf:params:jmap:refplus"]`.
162    fn supports_refplus(&self) -> bool;
163
164    /// Returns `true` if the server supports JMAP Quotas.
165    ///
166    /// Checks for `capabilities["urn:ietf:params:jmap:quota"]`.
167    fn supports_quotas(&self) -> bool;
168}
169
170// ---------------------------------------------------------------------------
171// impl ChatSessionExt for jmap_base_client::Session
172// ---------------------------------------------------------------------------
173
174impl ChatSessionExt for jmap_base_client::Session {
175    fn chat_account_id(&self) -> Option<&str> {
176        self.primary_account_id("urn:ietf:params:jmap:chat")
177    }
178
179    fn chat_capability(
180        &self,
181        account_id: &str,
182    ) -> Result<Option<ChatCapability>, jmap_base_client::ClientError> {
183        let Some(account) = self.accounts.get(account_id) else {
184            return Ok(None);
185        };
186        let Some(raw) = account
187            .account_capabilities
188            .get("urn:ietf:params:jmap:chat")
189        else {
190            return Ok(None);
191        };
192        ChatCapability::deserialize(raw)
193            .map(Some)
194            .map_err(jmap_base_client::ClientError::Parse)
195    }
196
197    fn chat_push_capability(
198        &self,
199        account_id: &str,
200    ) -> Result<Option<ChatPushCapability>, jmap_base_client::ClientError> {
201        let Some(account) = self.accounts.get(account_id) else {
202            return Ok(None);
203        };
204        let Some(raw) = account
205            .account_capabilities
206            .get("urn:ietf:params:jmap:chat:push")
207        else {
208            return Ok(None);
209        };
210        ChatPushCapability::deserialize(raw)
211            .map(Some)
212            .map_err(jmap_base_client::ClientError::Parse)
213    }
214
215    fn supports_chat_websocket(&self) -> bool {
216        self.capabilities
217            .contains_key("urn:ietf:params:jmap:chat:websocket")
218    }
219
220    fn vapid_public_key(&self) -> Option<&str> {
221        self.capabilities
222            .get("urn:ietf:params:jmap:webpush-vapid")?
223            .get("vapidPublicKey")?
224            .as_str()
225    }
226
227    fn supports_refplus(&self) -> bool {
228        self.capabilities
229            .contains_key("urn:ietf:params:jmap:refplus")
230    }
231
232    fn supports_quotas(&self) -> bool {
233        self.capabilities.contains_key("urn:ietf:params:jmap:quota")
234    }
235}
236
237// ---------------------------------------------------------------------------
238// Tests
239// ---------------------------------------------------------------------------
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use jmap_base_client::Session;
245    use serde_json::json;
246
247    /// Build a minimal Session value from JSON without hitting the network.
248    /// Caller can inject arbitrary capabilities / accounts.
249    fn make_session(
250        capabilities: serde_json::Value,
251        accounts: serde_json::Value,
252        primary_accounts: serde_json::Value,
253    ) -> Session {
254        let raw = json!({
255            "capabilities": capabilities,
256            "accounts": accounts,
257            "primaryAccounts": primary_accounts,
258            "username": "test@example.com",
259            "apiUrl": "https://jmap.example.com/api/",
260            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
261            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
262            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
263            "state": "s1"
264        });
265        serde_json::from_value(raw).expect("make_session: malformed test JSON")
266    }
267
268    // -----------------------------------------------------------------------
269    // chat_account_id_present
270    // -----------------------------------------------------------------------
271
272    /// Oracle: primaryAccounts["urn:ietf:params:jmap:chat"] = "acct1" →
273    /// chat_account_id() returns Some("acct1").
274    /// Value derived from the JMAP Chat draft §3 (not from code under test).
275    #[test]
276    fn chat_account_id_present() {
277        let session = make_session(
278            json!({}),
279            json!({}),
280            json!({"urn:ietf:params:jmap:chat": "acct1"}),
281        );
282        assert_eq!(session.chat_account_id(), Some("acct1"));
283    }
284
285    // -----------------------------------------------------------------------
286    // chat_account_id_absent
287    // -----------------------------------------------------------------------
288
289    /// Oracle: empty primaryAccounts → chat_account_id() returns None.
290    /// Per RFC 8620 §2, primaryAccounts is a map; an absent key means no
291    /// primary account for that capability.
292    #[test]
293    fn chat_account_id_absent() {
294        let session = make_session(json!({}), json!({}), json!({}));
295        assert!(
296            session.chat_account_id().is_none(),
297            "expected None for missing primaryAccounts entry"
298        );
299    }
300
301    // -----------------------------------------------------------------------
302    // chat_capability_parses
303    // -----------------------------------------------------------------------
304
305    /// Oracle: valid ChatCapability JSON at accounts[id].accountCapabilities
306    /// → Ok(Some(cap)) with correct field values.
307    /// Field names and types from draft-atwood-jmap-chat-00 §3.
308    #[test]
309    fn chat_capability_parses() {
310        let session = make_session(
311            json!({}),
312            json!({
313                "acct1": {
314                    "name": "test@example.com",
315                    "isPersonal": true,
316                    "isReadOnly": false,
317                    "accountCapabilities": {
318                        "urn:ietf:params:jmap:chat": {
319                            "maxBodyBytes": 65536,
320                            "maxAttachmentBytes": 10485760,
321                            "maxAttachmentsPerMessage": 10,
322                            "supportsThreads": true
323                        }
324                    }
325                }
326            }),
327            json!({"urn:ietf:params:jmap:chat": "acct1"}),
328        );
329
330        let cap = session
331            .chat_capability("acct1")
332            .expect("chat_capability must not return Err")
333            .expect("acct1 must have chat capability");
334
335        // Oracle: field values match what was put in the JSON above
336        assert_eq!(cap.max_body_bytes, 65536);
337        assert_eq!(cap.max_attachment_bytes, 10485760);
338        assert_eq!(cap.max_attachments_per_message, 10);
339        assert!(cap.supports_threads);
340    }
341
342    // -----------------------------------------------------------------------
343    // supports_chat_websocket_true
344    // -----------------------------------------------------------------------
345
346    /// Oracle: capabilities contains "urn:ietf:params:jmap:chat:websocket" →
347    /// supports_chat_websocket() returns true.
348    /// Per draft-atwood-jmap-chat-wss-00, presence of this key signals support.
349    #[test]
350    fn supports_chat_websocket_true() {
351        let session = make_session(
352            json!({"urn:ietf:params:jmap:chat:websocket": {}}),
353            json!({}),
354            json!({}),
355        );
356        assert!(
357            session.supports_chat_websocket(),
358            "expected true when capability key is present"
359        );
360    }
361
362    // -----------------------------------------------------------------------
363    // supports_chat_websocket_false
364    // -----------------------------------------------------------------------
365
366    /// Oracle: capabilities does not contain "urn:ietf:params:jmap:chat:websocket" →
367    /// supports_chat_websocket() returns false.
368    #[test]
369    fn supports_chat_websocket_false() {
370        let session = make_session(json!({}), json!({}), json!({}));
371        assert!(
372            !session.supports_chat_websocket(),
373            "expected false when capability key is absent"
374        );
375    }
376
377    // ── Extras-preservation policy tests (JMAP-lbdy.9) ─────────────────
378    //
379    // Each test deserialises wire JSON containing a synthetic `acmeCorp*`
380    // vendor field and asserts it survives in `extra`. The vendor field
381    // names cannot collide with any field defined in
382    // draft-atwood-jmap-chat-00 §3 or draft-atwood-jmap-chat-push-00, so
383    // the tests are independent of the code under test (workspace
384    // test-integrity rule).
385
386    /// Oracle: `supportedBodyTypes` on the wire deserializes into
387    /// `ChatCapability.supported_body_types: Vec<String>` preserving
388    /// order. The spec (draft-atwood-jmap-chat-00 §3) mandates
389    /// "text/plain" and recommends a defined set of additional values;
390    /// the client trusts the server's advertised list verbatim.
391    #[test]
392    fn chat_capability_supported_body_types_round_trips() {
393        let raw = json!({
394            "maxBodyBytes": 65536,
395            "maxAttachmentBytes": 10485760,
396            "maxAttachmentsPerMessage": 10,
397            "supportsThreads": true,
398            "supportedBodyTypes": [
399                "text/plain",
400                "text/markdown",
401                "application/jmap-chat-rich"
402            ]
403        });
404        let cap: ChatCapability =
405            serde_json::from_value(raw).expect("ChatCapability must deserialize");
406        assert_eq!(
407            cap.supported_body_types,
408            vec![
409                "text/plain".to_owned(),
410                "text/markdown".to_owned(),
411                "application/jmap-chat-rich".to_owned(),
412            ],
413            "supported_body_types must preserve wire order"
414        );
415    }
416
417    /// Oracle: a server that omits `supportedBodyTypes` deserializes
418    /// to an empty `Vec` via `#[serde(default)]`. This is technically
419    /// non-compliant per spec (`"text/plain"` is mandatory) but the
420    /// client tolerates it — enforcement is the consumer's job.
421    #[test]
422    fn chat_capability_supported_body_types_absent_defaults_empty() {
423        let raw = json!({
424            "maxBodyBytes": 65536,
425            "maxAttachmentBytes": 10485760,
426            "maxAttachmentsPerMessage": 10,
427            "supportsThreads": true
428        });
429        let cap: ChatCapability =
430            serde_json::from_value(raw).expect("ChatCapability must deserialize");
431        assert!(
432            cap.supported_body_types.is_empty(),
433            "missing supportedBodyTypes must default to an empty Vec"
434        );
435    }
436
437    /// `ChatCapability.extra` captures unknown fields on deserialize.
438    #[test]
439    fn chat_capability_preserves_vendor_extras() {
440        let raw = json!({
441            "maxBodyBytes": 65536,
442            "maxAttachmentBytes": 10485760,
443            "maxAttachmentsPerMessage": 10,
444            "supportsThreads": true,
445            "acmeCorpFeatureFlag": "beta"
446        });
447        let obj: ChatCapability =
448            serde_json::from_value(raw).expect("ChatCapability must deserialize");
449        assert_eq!(
450            obj.extra
451                .get("acmeCorpFeatureFlag")
452                .and_then(|v| v.as_str()),
453            Some("beta")
454        );
455    }
456
457    /// `ChatPushCapability.extra` captures unknown fields on deserialize.
458    #[test]
459    fn chat_push_capability_preserves_vendor_extras() {
460        let raw = json!({
461            "maxSnippetBytes": 256,
462            "supportedUrgencyValues": ["normal", "high"],
463            "maxMessagesPerPush": 10,
464            "acmeCorpPushTier": "gold"
465        });
466        let obj: ChatPushCapability =
467            serde_json::from_value(raw).expect("ChatPushCapability must deserialize");
468        assert_eq!(
469            obj.extra.get("acmeCorpPushTier").and_then(|v| v.as_str()),
470            Some("gold")
471        );
472    }
473}