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}