Skip to main content

jmap_chat_client/methods/
message.rs

1//! JMAP Chat — Message/* 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//!
11//! SPECIAL: `message_create` additionally inspects `SetResponse.not_created` for
12//! `error_type == "rateLimited"` and surfaces it as `ClientError::RateLimited`.
13
14use jmap_types::{Id, PatchObject, State};
15
16use super::{
17    ChangesResponse, GetResponse, MessageCreateInput, MessagePatch, MessageQueryInput,
18    QueryChangesResponse, QueryResponse, ReactionChange, SetResponse,
19};
20
21/// Reject a `sender_reaction_id` that is empty or contains RFC 6901
22/// JSON Pointer special characters (`/` or `~`).
23///
24/// Shared by both `ReactionChange::Add` and `ReactionChange::Remove`
25/// arms of `message_update`; see the rustdoc on
26/// [`ReactionChange`](super::ReactionChange) for the underlying
27/// JSON-Pointer construction rule.
28fn validate_sender_reaction_id(id: &str) -> Result<(), jmap_base_client::ClientError> {
29    if id.is_empty() {
30        return Err(jmap_base_client::ClientError::InvalidArgument(
31            "message_update: sender_reaction_id may not be empty".into(),
32        ));
33    }
34    if id.contains('/') || id.contains('~') {
35        return Err(jmap_base_client::ClientError::InvalidArgument(
36            "message_update: sender_reaction_id must not contain '/' or '~' \
37             (RFC 6901 JSON Pointer special characters)"
38                .into(),
39        ));
40    }
41    Ok(())
42}
43
44impl super::SessionClient {
45    /// Fetch Message objects by IDs (RFC 8620 §5.1 / JMAP Chat §Message/get).
46    ///
47    /// `ids` is required (non-empty); fetching all messages is impractical.
48    /// Pass `properties: None` to return all fields.
49    ///
50    /// # Errors
51    ///
52    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
53    ///   if `ids` is empty (caller-precondition guard — fetching all
54    ///   messages is impractical and explicitly disallowed).
55    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
56    ///   if the bound session has no primary account for
57    ///   `urn:ietf:params:jmap:chat`.
58    /// - Any transport / protocol variant returned by
59    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call):
60    ///   [`Http`](jmap_base_client::ClientError::Http),
61    ///   [`Parse`](jmap_base_client::ClientError::Parse),
62    ///   [`AuthFailed`](jmap_base_client::ClientError::AuthFailed),
63    ///   [`MethodError`](jmap_base_client::ClientError::MethodError)
64    ///   (wraps RFC 8620 §3.6.2 method-level errors such as
65    ///   `accountNotFound`, `invalidArguments`, `serverFail`),
66    ///   [`MethodNotFound`](jmap_base_client::ClientError::MethodNotFound),
67    ///   [`ResponseTooLarge`](jmap_base_client::ClientError::ResponseTooLarge),
68    ///   or
69    ///   [`UnexpectedResponse`](jmap_base_client::ClientError::UnexpectedResponse).
70    pub async fn message_get(
71        &self,
72        ids: &[Id],
73        properties: Option<&[&str]>,
74    ) -> Result<GetResponse<jmap_chat_types::Message>, jmap_base_client::ClientError> {
75        if ids.is_empty() {
76            return Err(jmap_base_client::ClientError::InvalidArgument(
77                "message_get: ids may not be empty".into(),
78            ));
79        }
80        let (api_url, account_id) = self.session_parts()?;
81        // Omit `properties` when None — see the matching comment on
82        // `chat_get` for the rationale. `ids` is required (non-Option) so it
83        // is always present in the request.
84        let mut args = serde_json::json!({
85            "accountId": account_id,
86            "ids": ids,
87        });
88        if let Some(props) = properties {
89            args["properties"] =
90                serde_json::to_value(props).map_err(jmap_base_client::ClientError::from_parse)?;
91        }
92        let req = super::build_request("Message/get", args, super::USING_CHAT);
93        let resp = self.call_internal(api_url, &req).await?;
94        jmap_base_client::extract_response(&resp, super::CALL_ID)
95    }
96
97    /// Query Message IDs within a Chat (RFC 8620 §5.5 / JMAP Chat §Message/query).
98    ///
99    /// Per spec, either `chat_id` or `has_mention: Some(true)` must be provided.
100    /// Servers MUST return `unsupportedFilter` if neither condition holds.
101    ///
102    /// Sort order is controlled by `input.sort_ascending` (default `false` =
103    /// newest first). With `position:0, limit:N` and `sort_ascending:false`, the
104    /// server returns the N most recent message IDs. Callers displaying messages
105    /// chronologically should set `sort_ascending:true` or reverse after fetching.
106    ///
107    /// # Errors
108    ///
109    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
110    ///   if neither `input.chat_id` nor `input.has_mention == Some(true)`
111    ///   is provided (spec requires at least one to scope the query).
112    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
113    ///   if the bound session has no primary account for
114    ///   `urn:ietf:params:jmap:chat`.
115    /// - Any transport / protocol variant returned by
116    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
117    ///   the matching error list on [`Self::message_get`]. RFC 8620
118    ///   §5.5 defines additional /query method-level errors
119    ///   (`anchorNotFound`, `unsupportedFilter`, `unsupportedSort`,
120    ///   `tooManyChanges`) that surface as
121    ///   [`MethodError`](jmap_base_client::ClientError::MethodError).
122    pub async fn message_query(
123        &self,
124        input: &MessageQueryInput<'_>,
125    ) -> Result<QueryResponse, jmap_base_client::ClientError> {
126        if input.chat_id.is_none() && input.has_mention != Some(true) {
127            return Err(jmap_base_client::ClientError::InvalidArgument(
128                "message_query: chat_id or has_mention=true must be provided".into(),
129            ));
130        }
131        let (api_url, account_id) = self.session_parts()?;
132        let mut filter = serde_json::Map::new();
133        if let Some(id) = input.chat_id {
134            filter.insert("chatId".into(), id.as_ref().into());
135        }
136        if let Some(m) = input.has_mention {
137            filter.insert("hasMention".into(), m.into());
138        }
139        if let Some(a) = input.has_attachment {
140            filter.insert("hasAttachment".into(), a.into());
141        }
142        if let Some(t) = input.text {
143            filter.insert("text".into(), t.into());
144        }
145        if let Some(tid) = input.thread_root_id {
146            filter.insert("threadRootId".into(), tid.as_ref().into());
147        }
148        if let Some(a) = input.after {
149            filter.insert("after".into(), a.as_ref().into());
150        }
151        if let Some(b) = input.before {
152            filter.insert("before".into(), b.as_ref().into());
153        }
154        let filter_val = if filter.is_empty() {
155            serde_json::Value::Null
156        } else {
157            serde_json::Value::Object(filter)
158        };
159        let mut args = serde_json::json!({
160            "accountId": account_id,
161            "filter": filter_val,
162            "sort": [{"property": "sentAt", "isAscending": input.sort_ascending}],
163        });
164        if let Some(p) = input.position {
165            args["position"] = p.into();
166        }
167        if let Some(l) = input.limit {
168            args["limit"] = l.into();
169        }
170        let req = super::build_request("Message/query", args, super::USING_CHAT);
171        let resp = self.call_internal(api_url, &req).await?;
172        jmap_base_client::extract_response(&resp, super::CALL_ID)
173    }
174
175    /// Fetch changes to Message objects since `since_state` (RFC 8620 §5.2 / Message/changes).
176    ///
177    /// # Errors
178    ///
179    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
180    ///   if `since_state` is the empty string (defence-in-depth —
181    ///   `State` constructed via [`State::from`](jmap_types::State::from)
182    ///   accepts empty strings, but an empty `sinceState` is never
183    ///   useful and would otherwise generate a wasted round-trip).
184    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
185    ///   if the bound session has no primary account for
186    ///   `urn:ietf:params:jmap:chat`.
187    /// - Any transport / protocol variant returned by
188    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
189    ///   the matching error list on [`Self::message_get`].
190    pub async fn message_changes(
191        &self,
192        since_state: &State,
193        max_changes: Option<u64>,
194    ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
195        // Defence-in-depth: see `chat_changes`.
196        if since_state.as_ref().is_empty() {
197            return Err(jmap_base_client::ClientError::InvalidArgument(
198                "message_changes: since_state may not be empty".into(),
199            ));
200        }
201        let (api_url, account_id) = self.session_parts()?;
202        let mut args = serde_json::json!({
203            "accountId": account_id,
204            "sinceState": since_state,
205        });
206        if let Some(mc) = max_changes {
207            args["maxChanges"] = mc.into();
208        }
209        let req = super::build_request("Message/changes", args, super::USING_CHAT);
210        let resp = self.call_internal(api_url, &req).await?;
211        jmap_base_client::extract_response(&resp, super::CALL_ID)
212    }
213
214    /// Create (send) a new Message (RFC 8620 §5.3 / JMAP Chat §Message/set).
215    ///
216    /// When `input.client_id` is `None`, a ULID is generated automatically.
217    /// The server maps the creation key to the server-assigned Message id in
218    /// `SetResponse.created`.
219    ///
220    /// # Rate limiting
221    ///
222    /// If the server rejects the message with `error_type == "rateLimited"` in
223    /// `not_created`, this method returns `Err(ClientError::RateLimited)` with
224    /// the `retry_after` timestamp from `serverRetryAfter`. If `serverRetryAfter`
225    /// is absent the method returns `Err(ClientError::UnexpectedResponse)`.
226    ///
227    /// # Return value
228    ///
229    /// Returns `Err(ClientError::RateLimited)` when the server returns a `rateLimited`
230    /// set error with a `serverRetryAfter` field.
231    ///
232    /// For all other server-side rejections (e.g., `invalidProperties`, `forbidden`),
233    /// this method returns `Ok(set_resp)` with the error recorded in
234    /// `set_resp.not_created`. **Callers MUST inspect `not_created` on every `Ok`
235    /// response to confirm the message was actually created.**
236    ///
237    /// # Errors
238    ///
239    /// - [`ClientError::Parse`](jmap_base_client::ClientError::Parse) if
240    ///   serializing the typed `body_type` enum fails (pathological
241    ///   conditions only).
242    /// - [`ClientError::RateLimited`](jmap_base_client::ClientError::RateLimited)
243    ///   if the server rejects the message with `error_type ==
244    ///   "rateLimited"` and supplies a valid `serverRetryAfter`
245    ///   timestamp. The `retry_after` field carries the server-supplied
246    ///   deadline.
247    /// - [`ClientError::UnexpectedResponse`](jmap_base_client::ClientError::UnexpectedResponse)
248    ///   if the server emits a `rateLimited` SetError without
249    ///   `serverRetryAfter`, or with a malformed timestamp value.
250    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
251    ///   if the bound session has no primary account for
252    ///   `urn:ietf:params:jmap:chat`.
253    /// - Any transport / protocol variant returned by
254    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
255    ///   the matching error list on [`Self::message_get`]. All other
256    ///   per-creation failures (e.g. `invalidProperties`, `forbidden`)
257    ///   appear in [`SetResponse::not_created`] on a successful
258    ///   [`Ok`] response (see the "Return value" note above).
259    pub async fn message_create(
260        &self,
261        input: &MessageCreateInput<'_>,
262    ) -> Result<SetResponse, jmap_base_client::ClientError> {
263        let (api_url, account_id) = self.session_parts()?;
264        let client_id = super::resolve_client_id(input.client_id);
265        // Borrow as &str so we can use it both as the json! key and as the
266        // not_created lookup key without moving the String.
267        let client_id_str: &str = &client_id;
268        let mut create_obj = serde_json::json!({
269            "chatId": input.chat_id,
270            "body": input.body,
271            "bodyType": serde_json::to_value(&input.body_type)
272                .map_err(jmap_base_client::ClientError::from_parse)?,
273            "sentAt": input.sent_at.as_ref(),
274        });
275        if let Some(rt) = input.reply_to {
276            create_obj["replyTo"] = rt.as_ref().into();
277        }
278        let args = serde_json::json!({
279            "accountId": account_id,
280            "create": { client_id_str: create_obj },
281        });
282        let req = super::build_request("Message/set", args, super::USING_CHAT);
283        let resp = self.call_internal(api_url, &req).await?;
284        let set_resp: SetResponse = jmap_base_client::extract_response(&resp, super::CALL_ID)?;
285        // Check for server-side rate limiting on the creation key.
286        if let Some(not_created) = &set_resp.not_created {
287            if let Some(err) = not_created.get(client_id_str) {
288                if err.error_type == "rateLimited" {
289                    let retry_after = match super::server_retry_after(err) {
290                        Ok(Some(t)) => t,
291                        Ok(None) => {
292                            return Err(jmap_base_client::ClientError::UnexpectedResponse(
293                                "rateLimited SetError missing serverRetryAfter".into(),
294                            ));
295                        }
296                        Err(super::ServerRetryAfterError::Malformed(raw)) => {
297                            return Err(jmap_base_client::ClientError::UnexpectedResponse(
298                                format!(
299                                    "rateLimited SetError has malformed serverRetryAfter: {raw}"
300                                ),
301                            ));
302                        }
303                    };
304                    return Err(jmap_base_client::ClientError::RateLimited { retry_after });
305                }
306            }
307        }
308        Ok(set_resp)
309    }
310
311    /// Update Message properties (RFC 8620 §5.3 / JMAP Chat §4.5 Message/set).
312    ///
313    /// Issues an `update` operation patching only the fields present in `patch`.
314    /// Supports body edits (author-only), reaction changes (JSON Pointer patch on
315    /// `reactions` map), read-receipt updates (`readAt`), and chat-level deletion
316    /// (`deletedAt` / `deletedForAll`).
317    ///
318    /// If all optional fields are `None`, an empty patch object is sent. RFC 8620
319    /// §5.3 permits this; the server treats it as a no-op but still returns the
320    /// object in `updated`.
321    ///
322    /// # Errors
323    ///
324    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
325    ///   if any [`ReactionChange`] entry carries an empty
326    ///   `sender_reaction_id`, or one containing `/` or `~`
327    ///   (RFC 6901 JSON Pointer reserved characters that would
328    ///   misinterpret the patch path).
329    /// - [`ClientError::Parse`](jmap_base_client::ClientError::Parse) if
330    ///   serializing the typed `body_type` or `read_disposition` enums
331    ///   fails (pathological conditions only).
332    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
333    ///   if the bound session has no primary account for
334    ///   `urn:ietf:params:jmap:chat`.
335    /// - Any transport / protocol variant returned by
336    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
337    ///   the matching error list on [`Self::message_get`]. /set update
338    ///   errors appear in [`SetResponse::not_updated`] rather than
339    ///   as [`Err`].
340    pub async fn message_update(
341        &self,
342        id: &Id,
343        patch: &MessagePatch<'_>,
344    ) -> Result<SetResponse, jmap_base_client::ClientError> {
345        let (api_url, account_id) = self.session_parts()?;
346        let mut patch_map = serde_json::Map::new();
347        if let Some(b) = patch.body {
348            patch_map.insert("body".into(), b.into());
349        }
350        if let Some(bt) = &patch.body_type {
351            patch_map.insert(
352                "bodyType".into(),
353                serde_json::to_value(bt).map_err(jmap_base_client::ClientError::from_parse)?,
354            );
355        }
356        if let Some(ra) = patch.read_at {
357            patch_map.insert("readAt".into(), ra.as_ref().into());
358        }
359        if let Some(rd) = &patch.read_disposition {
360            patch_map.insert(
361                "readDisposition".into(),
362                serde_json::to_value(rd).map_err(jmap_base_client::ClientError::from_parse)?,
363            );
364        }
365        if let Some(da) = patch.deleted_at {
366            patch_map.insert("deletedAt".into(), da.as_ref().into());
367        }
368        if let Some(dfa) = patch.deleted_for_all {
369            patch_map.insert("deletedForAll".into(), dfa.into());
370        }
371        for change in patch.reaction_changes.unwrap_or(&[]) {
372            match change {
373                ReactionChange::Add {
374                    sender_reaction_id,
375                    emoji,
376                    sent_at,
377                } => {
378                    validate_sender_reaction_id(sender_reaction_id)?;
379                    patch_map.insert(
380                        format!("reactions/{sender_reaction_id}"),
381                        serde_json::json!({"emoji": emoji, "sentAt": sent_at.as_ref()}),
382                    );
383                }
384                ReactionChange::Remove { sender_reaction_id } => {
385                    validate_sender_reaction_id(sender_reaction_id)?;
386                    patch_map.insert(
387                        format!("reactions/{sender_reaction_id}"),
388                        serde_json::Value::Null,
389                    );
390                }
391            }
392        }
393        // Wrap the constructed map in a PatchObject (RFC 8620 §5.3) before
394        // serializing. Wire bytes are unchanged because PatchObject is
395        // #[serde(transparent)]; the typed boundary documents the contract.
396        let patch_value = serde_json::Value::Object(PatchObject::from_map(patch_map).into_inner());
397        let args = serde_json::json!({
398            "accountId": account_id,
399            "update": { id.as_ref(): patch_value },
400        });
401        let req = super::build_request("Message/set", args, super::USING_CHAT);
402        let resp = self.call_internal(api_url, &req).await?;
403        jmap_base_client::extract_response(&resp, super::CALL_ID)
404    }
405
406    /// Destroy Message objects (RFC 8620 §5.3 / Message/set destroy).
407    ///
408    /// Permanently removes the listed message IDs from the account.
409    /// `ids` must be non-empty; the guard fires before any network call.
410    ///
411    /// # Errors
412    ///
413    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
414    ///   if `ids` is empty (caller-precondition guard).
415    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
416    ///   if the bound session has no primary account for
417    ///   `urn:ietf:params:jmap:chat`.
418    /// - Any transport / protocol variant returned by
419    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
420    ///   the matching error list on [`Self::message_get`]. /set destroy
421    ///   errors appear in [`SetResponse::not_destroyed`] rather
422    ///   than as [`Err`].
423    pub async fn message_destroy(
424        &self,
425        ids: &[Id],
426    ) -> Result<SetResponse, jmap_base_client::ClientError> {
427        if ids.is_empty() {
428            return Err(jmap_base_client::ClientError::InvalidArgument(
429                "message_destroy: ids may not be empty".into(),
430            ));
431        }
432        let (api_url, account_id) = self.session_parts()?;
433        let args = serde_json::json!({
434            "accountId": account_id,
435            "destroy": ids,
436        });
437        let req = super::build_request("Message/set", args, super::USING_CHAT);
438        let resp = self.call_internal(api_url, &req).await?;
439        jmap_base_client::extract_response(&resp, super::CALL_ID)
440    }
441
442    /// Fetch query-result changes for Message since `since_query_state`
443    /// (RFC 8620 §5.6 / Message/queryChanges).
444    ///
445    /// Returns which message IDs were removed from or added to the query
446    /// result set since the given state. `max_changes` may be `None`.
447    ///
448    /// `filter` and `sort` MUST match the `filter` / `sort` passed to the
449    /// original `Message/query` call that returned `since_query_state` —
450    /// RFC 8620 §5.6 is explicit that the server uses them to compute
451    /// which entries entered or left the result set.
452    ///
453    /// `up_to_id` is the highest-index id the client has cached;
454    /// `calculate_total` requests the new total result count.
455    ///
456    /// # Errors
457    ///
458    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
459    ///   if `since_query_state` is the empty string (defence-in-depth
460    ///   empty-state guard; see [`Self::message_changes`]).
461    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
462    ///   if the bound session has no primary account for
463    ///   `urn:ietf:params:jmap:chat`.
464    /// - Any transport / protocol variant returned by
465    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
466    ///   the matching error list on [`Self::message_get`]. RFC 8620
467    ///   §5.6 also defines `cannotCalculateChanges` (returned when the
468    ///   server cannot honour the request given the supplied filter /
469    ///   sort); it surfaces as
470    ///   [`MethodError`](jmap_base_client::ClientError::MethodError).
471    pub async fn message_query_changes(
472        &self,
473        since_query_state: &State,
474        max_changes: Option<u64>,
475        filter: Option<serde_json::Value>,
476        sort: Option<serde_json::Value>,
477        up_to_id: Option<&Id>,
478        calculate_total: Option<bool>,
479    ) -> Result<QueryChangesResponse, jmap_base_client::ClientError> {
480        // Defence-in-depth: see `chat_changes`.
481        if since_query_state.as_ref().is_empty() {
482            return Err(jmap_base_client::ClientError::InvalidArgument(
483                "message_query_changes: since_query_state may not be empty".into(),
484            ));
485        }
486        let (api_url, account_id) = self.session_parts()?;
487        let mut args = serde_json::json!({
488            "accountId": account_id,
489            "sinceQueryState": since_query_state,
490        });
491        if let Some(f) = filter {
492            args["filter"] = f;
493        }
494        if let Some(s) = sort {
495            args["sort"] = s;
496        }
497        if let Some(mc) = max_changes {
498            args["maxChanges"] = mc.into();
499        }
500        if let Some(uti) = up_to_id {
501            args["upToId"] =
502                serde_json::to_value(uti).map_err(jmap_base_client::ClientError::from_parse)?;
503        }
504        if let Some(ct) = calculate_total {
505            args["calculateTotal"] = ct.into();
506        }
507        let req = super::build_request("Message/queryChanges", args, super::USING_CHAT);
508        let resp = self.call_internal(api_url, &req).await?;
509        jmap_base_client::extract_response(&resp, super::CALL_ID)
510    }
511}