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}