jmap_chat_client/methods/chat.rs
1//! JMAP Chat — Chat/* method implementations on SessionClient.
2//!
3//! Each method follows the standard five-step pattern:
4//! 1. Validate arguments (defence-in-depth empty-state guards).
5//! 2. Call `self.session_parts()?` → `(api_url, account_id)`.
6//! 3. Build args JSON with `serde_json::json!({…})`.
7//! 4. Call `build_request(method_name, args, USING_CHAT)`.
8//! 5. Call `self.call_internal(api_url, &req).await?`.
9//! 6. Call `jmap_base_client::extract_response(&resp, CALL_ID)?`.
10
11use jmap_types::{Id, PatchObject, State};
12
13use super::{
14 AddMemberInput, ChangesResponse, ChatCreateInput, ChatPatch, ChatQueryInput, GetResponse,
15 QueryChangesResponse, QueryResponse, SetResponse, TypingResponse, UpdateMemberRoleInput,
16};
17
18impl super::SessionClient {
19 /// Fetch Chat objects by IDs (RFC 8620 §5.1 / JMAP Chat §Chat/get).
20 ///
21 /// If `ids` is `None`, the server returns all Chats for the account,
22 /// SUBJECT TO the server's `maxObjectsInGet` cap (RFC 8620 §5.1).
23 /// For production use, scope the result set via the corresponding
24 /// /query method first and pass explicit ids here to avoid
25 /// `requestTooLarge` errors when the account holds more objects
26 /// than the cap.
27 /// Pass `properties: None` to return all fields.
28 ///
29 /// # Errors
30 ///
31 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
32 /// if the bound session has no primary account for
33 /// `urn:ietf:params:jmap:chat`.
34 /// - Any transport / protocol variant returned by
35 /// [`JmapClient::call`](jmap_base_client::JmapClient::call):
36 /// [`Http`](jmap_base_client::ClientError::Http),
37 /// [`Parse`](jmap_base_client::ClientError::Parse),
38 /// [`AuthFailed`](jmap_base_client::ClientError::AuthFailed),
39 /// [`MethodError`](jmap_base_client::ClientError::MethodError)
40 /// (wraps RFC 8620 §3.6.2 method-level errors such as
41 /// `accountNotFound`, `invalidArguments`, `serverFail`),
42 /// [`MethodNotFound`](jmap_base_client::ClientError::MethodNotFound),
43 /// [`ResponseTooLarge`](jmap_base_client::ClientError::ResponseTooLarge),
44 /// or
45 /// [`UnexpectedResponse`](jmap_base_client::ClientError::UnexpectedResponse).
46 pub async fn chat_get(
47 &self,
48 ids: Option<&[Id]>,
49 properties: Option<&[&str]>,
50 ) -> Result<GetResponse<jmap_chat_types::Chat>, jmap_base_client::ClientError> {
51 let (api_url, account_id) = self.session_parts()?;
52 // Omit `ids` / `properties` entirely when None rather than sending
53 // an explicit JSON null. RFC 8620 §5.1 accepts both shapes, but the
54 // crate's other builders (set/changes/query) consistently use the
55 // conditional-add idiom; matching it here keeps the wire request
56 // canonical and avoids "present-but-null vs absent" interop quirks
57 // in proxies / audit loggers.
58 let mut args = serde_json::json!({ "accountId": account_id });
59 if let Some(id_slice) = ids {
60 args["ids"] = serde_json::to_value(id_slice)
61 .map_err(jmap_base_client::ClientError::from_parse)?;
62 }
63 if let Some(props) = properties {
64 args["properties"] =
65 serde_json::to_value(props).map_err(jmap_base_client::ClientError::from_parse)?;
66 }
67 let req = super::build_request("Chat/get", args, super::USING_CHAT);
68 let resp = self.call_internal(api_url, &req).await?;
69 jmap_base_client::extract_response(&resp, super::CALL_ID)
70 }
71
72 /// Query Chat IDs with optional filter (RFC 8620 §5.5 / JMAP Chat §Chat/query).
73 ///
74 /// Only keys that are `Some` in `input` are included in the filter object;
75 /// an empty filter object is sent as JSON `null`.
76 ///
77 /// # Errors
78 ///
79 /// - [`ClientError::Parse`](jmap_base_client::ClientError::Parse) if
80 /// serializing the typed `filter_kind` enum fails (pathological
81 /// conditions only).
82 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
83 /// if the bound session has no primary account for
84 /// `urn:ietf:params:jmap:chat`.
85 /// - Any transport / protocol variant returned by
86 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
87 /// the matching error list on [`Self::chat_get`]. RFC 8620 §5.5
88 /// defines additional /query method-level errors
89 /// (`anchorNotFound`, `unsupportedFilter`, `unsupportedSort`,
90 /// `tooManyChanges`) that surface as
91 /// [`MethodError`](jmap_base_client::ClientError::MethodError).
92 pub async fn chat_query(
93 &self,
94 input: &ChatQueryInput,
95 ) -> Result<QueryResponse, jmap_base_client::ClientError> {
96 let (api_url, account_id) = self.session_parts()?;
97 let mut filter = serde_json::Map::new();
98 if let Some(k) = &input.filter_kind {
99 let kind_str =
100 serde_json::to_value(k).map_err(jmap_base_client::ClientError::from_parse)?;
101 filter.insert("kind".into(), kind_str);
102 }
103 if let Some(m) = input.filter_muted {
104 filter.insert("muted".into(), m.into());
105 }
106 let filter_val = if filter.is_empty() {
107 serde_json::Value::Null
108 } else {
109 serde_json::Value::Object(filter)
110 };
111 let mut args = serde_json::json!({
112 "accountId": account_id,
113 "filter": filter_val,
114 });
115 if let Some(p) = input.position {
116 args["position"] = p.into();
117 }
118 if let Some(l) = input.limit {
119 args["limit"] = l.into();
120 }
121 let req = super::build_request("Chat/query", args, super::USING_CHAT);
122 let resp = self.call_internal(api_url, &req).await?;
123 jmap_base_client::extract_response(&resp, super::CALL_ID)
124 }
125
126 /// Fetch changes to Chat objects since `since_state` (RFC 8620 §5.2 / Chat/changes).
127 ///
128 /// If `has_more_changes` is true in the response, call again with `new_state`
129 /// as `since_state` until the flag is false.
130 ///
131 /// # Errors
132 ///
133 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
134 /// if `since_state` is the empty string (defence-in-depth —
135 /// `State` constructed via [`State::from`](jmap_types::State::from)
136 /// accepts empty strings, but an empty `sinceState` is never
137 /// useful and would otherwise generate a wasted round-trip).
138 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
139 /// if the bound session has no primary account for
140 /// `urn:ietf:params:jmap:chat`.
141 /// - Any transport / protocol variant returned by
142 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
143 /// the matching error list on [`Self::chat_get`].
144 pub async fn chat_changes(
145 &self,
146 since_state: &State,
147 max_changes: Option<u64>,
148 ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
149 // Defence-in-depth: even with the typed-`State` parameter (a transparent
150 // newtype around `String`), an empty state token is still a logically
151 // invalid value that should be caught client-side rather than producing
152 // a confusing server-side `cannotCalculateChanges` error.
153 if since_state.as_ref().is_empty() {
154 return Err(jmap_base_client::ClientError::InvalidArgument(
155 "chat_changes: since_state may not be empty".into(),
156 ));
157 }
158 let (api_url, account_id) = self.session_parts()?;
159 let mut args = serde_json::json!({
160 "accountId": account_id,
161 "sinceState": since_state,
162 });
163 if let Some(mc) = max_changes {
164 args["maxChanges"] = mc.into();
165 }
166 let req = super::build_request("Chat/changes", args, super::USING_CHAT);
167 let resp = self.call_internal(api_url, &req).await?;
168 jmap_base_client::extract_response(&resp, super::CALL_ID)
169 }
170
171 /// Send a typing indicator for a Chat (JMAP Chat §Chat/typing).
172 ///
173 /// Notifies other participants that the account is (or has stopped) typing.
174 /// The server silently drops the event if `Chat.receiveTypingIndicators` is
175 /// `false` for a recipient (direct/group chats); for channel chats the
176 /// preference has no effect. The server SHOULD rate-limit to one call per
177 /// account per chat per 3 seconds — excess calls MAY be silently discarded.
178 /// Debouncing (send once per keypress, stop event on idle) is the caller's
179 /// responsibility.
180 ///
181 /// # Errors
182 ///
183 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
184 /// if the bound session has no primary account for
185 /// `urn:ietf:params:jmap:chat`.
186 /// - Any transport / protocol variant returned by
187 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
188 /// the matching error list on [`Self::chat_get`].
189 pub async fn chat_typing(
190 &self,
191 chat_id: &Id,
192 typing: bool,
193 ) -> Result<TypingResponse, jmap_base_client::ClientError> {
194 let (api_url, account_id) = self.session_parts()?;
195 let args = serde_json::json!({
196 "accountId": account_id,
197 "chatId": chat_id,
198 "typing": typing,
199 });
200 let req = super::build_request("Chat/typing", args, super::USING_CHAT);
201 let resp = self.call_internal(api_url, &req).await?;
202 jmap_base_client::extract_response(&resp, super::CALL_ID)
203 }
204
205 /// Fetch query-result changes for Chat since `since_query_state`
206 /// (RFC 8620 §5.6 / Chat/queryChanges).
207 ///
208 /// Returns which Chat IDs were removed from or added to the query result set
209 /// since the given state. `max_changes` may be `None`.
210 ///
211 /// `filter` and `sort` MUST match the `filter` / `sort` passed to the
212 /// original `Chat/query` call that returned `since_query_state` —
213 /// RFC 8620 §5.6 is explicit that the server uses them to compute
214 /// which entries entered or left the result set.
215 ///
216 /// `up_to_id` is the highest-index id the client has cached;
217 /// `calculate_total` requests the new total result count.
218 ///
219 /// # Errors
220 ///
221 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
222 /// if `since_query_state` is the empty string (defence-in-depth
223 /// empty-state guard; see [`Self::chat_changes`]).
224 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
225 /// if the bound session has no primary account for
226 /// `urn:ietf:params:jmap:chat`.
227 /// - Any transport / protocol variant returned by
228 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
229 /// the matching error list on [`Self::chat_get`]. RFC 8620 §5.6
230 /// also defines `cannotCalculateChanges` (returned when the
231 /// server cannot honour the request given the supplied filter /
232 /// sort); it surfaces as
233 /// [`MethodError`](jmap_base_client::ClientError::MethodError).
234 pub async fn chat_query_changes(
235 &self,
236 since_query_state: &State,
237 max_changes: Option<u64>,
238 filter: Option<serde_json::Value>,
239 sort: Option<serde_json::Value>,
240 up_to_id: Option<&Id>,
241 calculate_total: Option<bool>,
242 ) -> Result<QueryChangesResponse, jmap_base_client::ClientError> {
243 // Defence-in-depth: see `chat_changes`.
244 if since_query_state.as_ref().is_empty() {
245 return Err(jmap_base_client::ClientError::InvalidArgument(
246 "chat_query_changes: since_query_state may not be empty".into(),
247 ));
248 }
249 let (api_url, account_id) = self.session_parts()?;
250 let mut args = serde_json::json!({
251 "accountId": account_id,
252 "sinceQueryState": since_query_state,
253 });
254 if let Some(f) = filter {
255 args["filter"] = f;
256 }
257 if let Some(s) = sort {
258 args["sort"] = s;
259 }
260 if let Some(mc) = max_changes {
261 args["maxChanges"] = mc.into();
262 }
263 if let Some(uti) = up_to_id {
264 args["upToId"] =
265 serde_json::to_value(uti).map_err(jmap_base_client::ClientError::from_parse)?;
266 }
267 if let Some(ct) = calculate_total {
268 args["calculateTotal"] = ct.into();
269 }
270 let req = super::build_request("Chat/queryChanges", args, super::USING_CHAT);
271 let resp = self.call_internal(api_url, &req).await?;
272 jmap_base_client::extract_response(&resp, super::CALL_ID)
273 }
274
275 /// Create a Chat (JMAP Chat §Chat/set create).
276 ///
277 /// Dispatches to the correct spec `kind` based on the `input` variant:
278 /// `Direct` or `Group`. When `client_id` inside the variant is `None`, a
279 /// ULID is generated automatically.
280 ///
281 /// For `Direct` chats: if one already exists with the given `contact_id`,
282 /// the server returns it in `SetResponse.updated` rather than `created`
283 /// (dedup rule per spec).
284 ///
285 /// Channel Chats are NOT created via `Chat/set` — per
286 /// draft-atwood-jmap-chat-00 §Chat (line 436) they are created via
287 /// `Space/set` with the `addChannels` patch key. Use
288 /// [`super::SessionClient::space_update`] with
289 /// [`super::SpacePatch::add_channels`] to create a Channel.
290 ///
291 /// # Errors
292 ///
293 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
294 /// if `input` is a `Group` variant with an empty `name`
295 /// (caller-precondition guard — Group chats require a non-empty
296 /// display name).
297 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
298 /// if the bound session has no primary account for
299 /// `urn:ietf:params:jmap:chat`.
300 /// - Any transport / protocol variant returned by
301 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
302 /// the matching error list on [`Self::chat_get`]. JMAP Chat-spec
303 /// /set errors (`invalidProperties`, `forbidden`, `overQuota`,
304 /// etc.) on a single creation appear in
305 /// [`SetResponse::not_created`] rather than as
306 /// [`Err`]; only method-level failures surface as
307 /// [`MethodError`](jmap_base_client::ClientError::MethodError).
308 pub async fn chat_create(
309 &self,
310 input: &ChatCreateInput<'_>,
311 ) -> Result<SetResponse, jmap_base_client::ClientError> {
312 let (api_url, account_id) = self.session_parts()?;
313 let (create_obj, client_id_opt) = match input {
314 ChatCreateInput::Direct {
315 client_id,
316 contact_id,
317 } => {
318 let obj = serde_json::json!({
319 "kind": "direct",
320 "contactId": contact_id,
321 });
322 (obj, *client_id)
323 }
324 ChatCreateInput::Group {
325 client_id,
326 name,
327 member_ids,
328 description,
329 avatar_blob_id,
330 message_expiry_seconds,
331 } => {
332 if name.is_empty() {
333 return Err(jmap_base_client::ClientError::InvalidArgument(
334 "chat_create: name may not be empty".into(),
335 ));
336 }
337 let mut obj = serde_json::json!({
338 "kind": "group",
339 "name": name,
340 "memberIds": member_ids,
341 });
342 if let Some(d) = description {
343 obj["description"] = (*d).into();
344 }
345 if let Some(b) = avatar_blob_id {
346 obj["avatarBlobId"] = b.as_ref().into();
347 }
348 if let Some(s) = message_expiry_seconds {
349 obj["messageExpirySeconds"] = (*s).into();
350 }
351 (obj, *client_id)
352 }
353 };
354 let client_id = super::resolve_client_id(client_id_opt);
355 let args = serde_json::json!({
356 "accountId": account_id,
357 "create": { client_id: create_obj },
358 });
359 let req = super::build_request("Chat/set", args, super::USING_CHAT);
360 let resp = self.call_internal(api_url, &req).await?;
361 jmap_base_client::extract_response(&resp, super::CALL_ID)
362 }
363
364 /// Update Chat properties (JMAP Chat §Chat/set update).
365 ///
366 /// Issues an `update` operation patching only the fields present in `patch`.
367 /// Use `Patch::Set(v)` to set nullable fields, `Patch::Clear` to null-clear
368 /// them, and `Patch::Keep` (default) to leave them unchanged. Slice fields
369 /// default to `None` for no-change.
370 ///
371 /// If all fields are `Keep`/`None`, an empty patch is sent — RFC 8620 §5.3
372 /// permits this; the server treats it as a no-op but still returns the chat
373 /// in `updated`.
374 ///
375 /// # Errors
376 ///
377 /// - [`ClientError::Parse`](jmap_base_client::ClientError::Parse) if
378 /// serializing a typed sub-field of `patch` fails — specifically a
379 /// `Clearable` entry's value, a member `role` enum, or the
380 /// `update_member_roles` entries (pathological conditions only).
381 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
382 /// if the bound session has no primary account for
383 /// `urn:ietf:params:jmap:chat`.
384 /// - Any transport / protocol variant returned by
385 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
386 /// the matching error list on [`Self::chat_get`]. JMAP Chat-spec
387 /// /set update errors appear in
388 /// [`SetResponse::not_updated`] rather than as
389 /// [`Err`]; only method-level failures surface as
390 /// [`MethodError`](jmap_base_client::ClientError::MethodError).
391 pub async fn chat_update(
392 &self,
393 id: &Id,
394 patch: &ChatPatch<'_>,
395 ) -> Result<SetResponse, jmap_base_client::ClientError> {
396 let (api_url, account_id) = self.session_parts()?;
397 let mut patch_map = serde_json::Map::new();
398
399 if let Some(m) = patch.muted {
400 patch_map.insert("muted".into(), m.into());
401 }
402 if let Some(entry) = patch
403 .mute_until
404 .map_entry()
405 .map_err(jmap_base_client::ClientError::from_parse)?
406 {
407 patch_map.insert("muteUntil".into(), entry);
408 }
409 if let Some(rti) = patch.receive_typing_indicators {
410 patch_map.insert("receiveTypingIndicators".into(), rti.into());
411 }
412 if let Some(ids) = patch.pinned_message_ids {
413 patch_map.insert(
414 "pinnedMessageIds".into(),
415 serde_json::to_value(ids).map_err(jmap_base_client::ClientError::from_parse)?,
416 );
417 }
418 if let Some(entry) = patch
419 .message_expiry_seconds
420 .map_entry()
421 .map_err(jmap_base_client::ClientError::from_parse)?
422 {
423 patch_map.insert("messageExpirySeconds".into(), entry);
424 }
425 if let Some(rs) = patch.receipt_sharing {
426 patch_map.insert("receiptSharing".into(), rs.into());
427 }
428 if let Some(n) = patch.name {
429 patch_map.insert("name".into(), n.into());
430 }
431 if let Some(entry) = patch
432 .description
433 .map_entry()
434 .map_err(jmap_base_client::ClientError::from_parse)?
435 {
436 patch_map.insert("description".into(), entry);
437 }
438 if let Some(entry) = patch
439 .avatar_blob_id
440 .map_entry()
441 .map_err(jmap_base_client::ClientError::from_parse)?
442 {
443 patch_map.insert("avatarBlobId".into(), entry);
444 }
445 if let Some(members) = patch.add_members {
446 if !members.is_empty() {
447 let arr = members
448 .iter()
449 .map(|m: &AddMemberInput<'_>| {
450 let mut obj = serde_json::json!({ "id": m.id });
451 if let Some(role) = &m.role {
452 obj["role"] = serde_json::to_value(role)
453 .map_err(jmap_base_client::ClientError::from_parse)?;
454 }
455 Ok(obj)
456 })
457 .collect::<Result<Vec<_>, jmap_base_client::ClientError>>()?;
458 patch_map.insert("addMembers".into(), serde_json::Value::Array(arr));
459 }
460 }
461 if let Some(rm) = patch.remove_members {
462 if !rm.is_empty() {
463 patch_map.insert(
464 "removeMembers".into(),
465 serde_json::to_value(rm).map_err(jmap_base_client::ClientError::from_parse)?,
466 );
467 }
468 }
469 if let Some(umr) = patch.update_member_roles {
470 if !umr.is_empty() {
471 let arr = umr
472 .iter()
473 .map(|u: &UpdateMemberRoleInput<'_>| {
474 Ok(serde_json::json!({
475 "id": u.id,
476 "role": serde_json::to_value(&u.role)
477 .map_err(jmap_base_client::ClientError::from_parse)?,
478 }))
479 })
480 .collect::<Result<Vec<_>, jmap_base_client::ClientError>>()?;
481 patch_map.insert("updateMemberRoles".into(), serde_json::Value::Array(arr));
482 }
483 }
484
485 // Wrap the constructed map in a PatchObject (RFC 8620 §5.3) before
486 // serializing. Wire bytes are unchanged because PatchObject is
487 // #[serde(transparent)]; the typed boundary documents that this
488 // value is a JMAP patch, not arbitrary JSON.
489 let patch_value = serde_json::Value::Object(PatchObject::from_map(patch_map).into_inner());
490 let args = serde_json::json!({
491 "accountId": account_id,
492 "update": { id.as_ref(): patch_value },
493 });
494 let req = super::build_request("Chat/set", args, super::USING_CHAT);
495 let resp = self.call_internal(api_url, &req).await?;
496 jmap_base_client::extract_response(&resp, super::CALL_ID)
497 }
498
499 /// Destroy Chat objects (RFC 8620 §5.3 / Chat/set destroy).
500 ///
501 /// Permanently removes the listed Chat IDs from the account.
502 /// `ids` must be non-empty; the guard fires before any network call.
503 ///
504 /// # Errors
505 ///
506 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
507 /// if `ids` is empty (caller-precondition guard — a no-op destroy
508 /// is never useful and would generate a wasted round-trip).
509 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
510 /// if the bound session has no primary account for
511 /// `urn:ietf:params:jmap:chat`.
512 /// - Any transport / protocol variant returned by
513 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
514 /// the matching error list on [`Self::chat_get`]. JMAP Chat-spec
515 /// /set destroy errors appear in
516 /// [`SetResponse::not_destroyed`] rather than as
517 /// [`Err`].
518 pub async fn chat_destroy(
519 &self,
520 ids: &[Id],
521 ) -> Result<SetResponse, jmap_base_client::ClientError> {
522 if ids.is_empty() {
523 return Err(jmap_base_client::ClientError::InvalidArgument(
524 "chat_destroy: ids may not be empty".into(),
525 ));
526 }
527 let (api_url, account_id) = self.session_parts()?;
528 let args = serde_json::json!({
529 "accountId": account_id,
530 "destroy": ids,
531 });
532 let req = super::build_request("Chat/set", args, super::USING_CHAT);
533 let resp = self.call_internal(api_url, &req).await?;
534 jmap_base_client::extract_response(&resp, super::CALL_ID)
535 }
536}