Skip to main content

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}