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}