jmap_chat_client/methods/misc.rs
1//! Miscellaneous JMAP Chat method implementations on SessionClient.
2//!
3//! Covers the smaller object surfaces — `ReadPosition/*`, `PresenceStatus/*`,
4//! and `PushSubscription/*` (RFC 8620 §7.2 plus the JMAP Chat Push extension,
5//! draft-atwood-jmap-chat-push-00).
6
7use jmap_types::{Id, PatchObject, State};
8
9use super::{
10 ChangesResponse, GetResponse, Patch, PresenceStatusPatch, PushSubscriptionCreateInput,
11 PushSubscriptionCreateResponse, PushSubscriptionPatch, SetResponse,
12};
13
14impl super::SessionClient {
15 /// Fetch ReadPosition objects by IDs (JMAP Chat §5 ReadPosition/get).
16 ///
17 /// If `ids` is `None`, returns all ReadPosition records for the account.
18 /// The server creates one ReadPosition per Chat automatically.
19 ///
20 /// # Errors
21 ///
22 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
23 /// if the bound session has no primary account for
24 /// `urn:ietf:params:jmap:chat`.
25 /// - Any transport / protocol variant returned by
26 /// [`JmapClient::call`](jmap_base_client::JmapClient::call):
27 /// [`Http`](jmap_base_client::ClientError::Http),
28 /// [`Parse`](jmap_base_client::ClientError::Parse),
29 /// [`AuthFailed`](jmap_base_client::ClientError::AuthFailed),
30 /// [`MethodError`](jmap_base_client::ClientError::MethodError)
31 /// (wraps RFC 8620 §3.6.2 method-level errors such as
32 /// `accountNotFound`, `invalidArguments`, `serverFail`),
33 /// [`MethodNotFound`](jmap_base_client::ClientError::MethodNotFound),
34 /// [`ResponseTooLarge`](jmap_base_client::ClientError::ResponseTooLarge),
35 /// or
36 /// [`UnexpectedResponse`](jmap_base_client::ClientError::UnexpectedResponse).
37 pub async fn read_position_get(
38 &self,
39 ids: Option<&[Id]>,
40 ) -> Result<GetResponse<jmap_chat_types::ReadPosition>, jmap_base_client::ClientError> {
41 let (api_url, account_id) = self.session_parts()?;
42 // Omit `ids` when None — see the matching comment on `chat_get` for
43 // the rationale. ReadPosition/get has no `properties` parameter.
44 let mut args = serde_json::json!({ "accountId": account_id });
45 if let Some(id_slice) = ids {
46 args["ids"] = serde_json::to_value(id_slice)
47 .map_err(jmap_base_client::ClientError::from_parse)?;
48 }
49 let req = super::build_request("ReadPosition/get", args, super::USING_CHAT);
50 let resp = self.call_internal(api_url, &req).await?;
51 jmap_base_client::extract_response(&resp, super::CALL_ID)
52 }
53
54 /// Update the read position for a Chat (JMAP Chat §5 ReadPosition/set).
55 ///
56 /// `read_position_id` is the server-assigned ReadPosition.id (from
57 /// `read_position_get`). `last_read_message_id` is the Message.id of the
58 /// most recent message read. The server updates `lastReadAt` and
59 /// recomputes `Chat.unreadCount`.
60 ///
61 /// `create` and `destroy` are forbidden by the spec; only `update` is issued.
62 ///
63 /// # Errors
64 ///
65 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
66 /// if the bound session has no primary account for
67 /// `urn:ietf:params:jmap:chat`.
68 /// - Any transport / protocol variant returned by
69 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
70 /// the matching error list on [`Self::read_position_get`]. /set
71 /// update errors appear in [`SetResponse::not_updated`] rather
72 /// than as [`Err`].
73 pub async fn read_position_update(
74 &self,
75 read_position_id: &Id,
76 last_read_message_id: &Id,
77 ) -> Result<SetResponse, jmap_base_client::ClientError> {
78 let (api_url, account_id) = self.session_parts()?;
79 let args = serde_json::json!({
80 "accountId": account_id,
81 "update": {
82 read_position_id.as_ref(): { "lastReadMessageId": last_read_message_id }
83 },
84 });
85 let req = super::build_request("ReadPosition/set", args, super::USING_CHAT);
86 let resp = self.call_internal(api_url, &req).await?;
87 jmap_base_client::extract_response(&resp, super::CALL_ID)
88 }
89
90 /// Fetch the singleton PresenceStatus record (JMAP Chat §5 PresenceStatus/get).
91 ///
92 /// Per spec there is exactly one PresenceStatus per account; `ids: null`
93 /// retrieves it.
94 ///
95 /// # Errors
96 ///
97 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
98 /// if the bound session has no primary account for
99 /// `urn:ietf:params:jmap:chat`.
100 /// - Any transport / protocol variant returned by
101 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
102 /// the matching error list on [`Self::read_position_get`].
103 pub async fn presence_status_get(
104 &self,
105 ) -> Result<GetResponse<jmap_chat_types::PresenceStatus>, jmap_base_client::ClientError> {
106 let (api_url, account_id) = self.session_parts()?;
107 let args = serde_json::json!({
108 "accountId": account_id,
109 "ids": None::<&[Id]>,
110 });
111 let req = super::build_request("PresenceStatus/get", args, super::USING_CHAT);
112 let resp = self.call_internal(api_url, &req).await?;
113 jmap_base_client::extract_response(&resp, super::CALL_ID)
114 }
115
116 /// Fetch changes to ReadPosition records since `since_state` (JMAP Chat §5 ReadPosition/changes).
117 ///
118 /// `max_changes` may be `None` to let the server choose the limit (RFC 8620 §5.2).
119 ///
120 /// # Errors
121 ///
122 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
123 /// if `since_state` is the empty string (defence-in-depth —
124 /// `State` constructed via [`State::from`](jmap_types::State::from)
125 /// accepts empty strings, but an empty `sinceState` is never
126 /// useful and would otherwise generate a wasted round-trip).
127 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
128 /// if the bound session has no primary account for
129 /// `urn:ietf:params:jmap:chat`.
130 /// - Any transport / protocol variant returned by
131 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
132 /// the matching error list on [`Self::read_position_get`].
133 pub async fn read_position_changes(
134 &self,
135 since_state: &State,
136 max_changes: Option<u64>,
137 ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
138 // Defence-in-depth: see `chat_changes`.
139 if since_state.as_ref().is_empty() {
140 return Err(jmap_base_client::ClientError::InvalidArgument(
141 "read_position_changes: since_state may not be empty".into(),
142 ));
143 }
144 let (api_url, account_id) = self.session_parts()?;
145 let mut args = serde_json::json!({
146 "accountId": account_id,
147 "sinceState": since_state,
148 });
149 if let Some(mc) = max_changes {
150 args["maxChanges"] = mc.into();
151 }
152 let req = super::build_request("ReadPosition/changes", args, super::USING_CHAT);
153 let resp = self.call_internal(api_url, &req).await?;
154 jmap_base_client::extract_response(&resp, super::CALL_ID)
155 }
156
157 /// Update the PresenceStatus record (JMAP Chat §5 PresenceStatus/set).
158 ///
159 /// Only `update` is issued; `create` and `destroy` are forbidden by the spec.
160 /// Fields absent from `patch` (i.e. `Patch::Keep` or `None`) are omitted from
161 /// the patch and left unchanged server-side.
162 ///
163 /// # Errors
164 ///
165 /// - [`ClientError::Parse`](jmap_base_client::ClientError::Parse) if
166 /// serializing the typed `presence` enum or any `Clearable`
167 /// entry (`status_text`, `status_emoji`, `expires_at`) fails
168 /// (pathological conditions only).
169 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
170 /// if the bound session has no primary account for
171 /// `urn:ietf:params:jmap:chat`.
172 /// - Any transport / protocol variant returned by
173 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
174 /// the matching error list on [`Self::read_position_get`]. /set
175 /// update errors appear in [`SetResponse::not_updated`] rather
176 /// than as [`Err`].
177 pub async fn presence_status_update(
178 &self,
179 id: &Id,
180 patch: &PresenceStatusPatch<'_>,
181 ) -> Result<SetResponse, jmap_base_client::ClientError> {
182 let (api_url, account_id) = self.session_parts()?;
183 let mut patch_map = serde_json::Map::new();
184 if let Some(p) = &patch.presence {
185 patch_map.insert(
186 "presence".into(),
187 serde_json::to_value(p).map_err(jmap_base_client::ClientError::from_parse)?,
188 );
189 }
190 if let Some(entry) = patch
191 .status_text
192 .map_entry()
193 .map_err(jmap_base_client::ClientError::from_parse)?
194 {
195 patch_map.insert("statusText".into(), entry);
196 }
197 if let Some(entry) = patch
198 .status_emoji
199 .map_entry()
200 .map_err(jmap_base_client::ClientError::from_parse)?
201 {
202 patch_map.insert("statusEmoji".into(), entry);
203 }
204 if let Some(entry) = patch
205 .expires_at
206 .map_entry()
207 .map_err(jmap_base_client::ClientError::from_parse)?
208 {
209 patch_map.insert("expiresAt".into(), entry);
210 }
211 if let Some(rs) = patch.receipt_sharing {
212 patch_map.insert("receiptSharing".into(), rs.into());
213 }
214 // Wrap the constructed map in a PatchObject (RFC 8620 §5.3) before
215 // serializing. Wire bytes are unchanged because PatchObject is
216 // #[serde(transparent)]; the typed boundary documents the contract.
217 let patch_value = serde_json::Value::Object(PatchObject::from_map(patch_map).into_inner());
218 let args = serde_json::json!({
219 "accountId": account_id,
220 "update": { id.as_ref(): patch_value },
221 });
222 let req = super::build_request("PresenceStatus/set", args, super::USING_CHAT);
223 let resp = self.call_internal(api_url, &req).await?;
224 jmap_base_client::extract_response(&resp, super::CALL_ID)
225 }
226
227 /// Fetch changes to PresenceStatus records since `since_state` (JMAP Chat §5 PresenceStatus/changes).
228 ///
229 /// `max_changes` may be `None` to let the server choose the limit (RFC 8620 §5.2).
230 ///
231 /// # Errors
232 ///
233 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
234 /// if `since_state` is the empty string (defence-in-depth
235 /// empty-state guard).
236 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
237 /// if the bound session has no primary account for
238 /// `urn:ietf:params:jmap:chat`.
239 /// - Any transport / protocol variant returned by
240 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
241 /// the matching error list on [`Self::read_position_get`].
242 pub async fn presence_status_changes(
243 &self,
244 since_state: &State,
245 max_changes: Option<u64>,
246 ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
247 // Defence-in-depth: see `chat_changes`.
248 if since_state.as_ref().is_empty() {
249 return Err(jmap_base_client::ClientError::InvalidArgument(
250 "presence_status_changes: since_state may not be empty".into(),
251 ));
252 }
253 let (api_url, account_id) = self.session_parts()?;
254 let mut args = serde_json::json!({
255 "accountId": account_id,
256 "sinceState": since_state,
257 });
258 if let Some(mc) = max_changes {
259 args["maxChanges"] = mc.into();
260 }
261 let req = super::build_request("PresenceStatus/changes", args, super::USING_CHAT);
262 let resp = self.call_internal(api_url, &req).await?;
263 jmap_base_client::extract_response(&resp, super::CALL_ID)
264 }
265
266 /// Create a PushSubscription with the optional `chatPush` extension
267 /// (RFC 8620 §7.2 / draft-atwood-jmap-chat-push-00 §3).
268 ///
269 /// PushSubscriptions are account-independent: no `accountId` is included
270 /// in the request (RFC 8620 §7.2). When `input.chat_push` is `Some`, the
271 /// `using` array includes `urn:ietf:params:jmap:chat:push` (RFC 8620 §3.3:
272 /// capabilities MUST only be declared when used); otherwise `urn:ietf:params:jmap:core`
273 /// alone is used.
274 ///
275 /// This method issues a `create` operation only. To extend `expires`, set
276 /// the verification code, change `types`, or update `chatPush`, use
277 /// [`push_subscription_update`](Self::push_subscription_update). To
278 /// unsubscribe, use [`push_subscription_destroy`](Self::push_subscription_destroy).
279 ///
280 /// When `input.client_id` is `None`, a ULID is generated automatically.
281 ///
282 /// # Errors
283 ///
284 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
285 /// if `input.device_client_id` or `input.url` is empty
286 /// (caller-precondition guards), or if `input.chat_push` carries
287 /// duplicate `accountId` keys in the slice.
288 /// - [`ClientError::Parse`](jmap_base_client::ClientError::Parse) if
289 /// serializing any typed [`ChatPushConfig`](jmap_chat_types::ChatPushConfig)
290 /// value fails (pathological conditions only).
291 /// - Any transport / protocol variant returned by
292 /// [`JmapClient::call`](jmap_base_client::JmapClient::call):
293 /// [`Http`](jmap_base_client::ClientError::Http),
294 /// [`Parse`](jmap_base_client::ClientError::Parse),
295 /// [`AuthFailed`](jmap_base_client::ClientError::AuthFailed),
296 /// [`MethodError`](jmap_base_client::ClientError::MethodError)
297 /// (wraps RFC 8620 §3.6.2 method-level errors; servers that do
298 /// not advertise the `chatPush` capability return
299 /// `unknownCapability` when the input includes a chat-push
300 /// sub-object),
301 /// [`MethodNotFound`](jmap_base_client::ClientError::MethodNotFound),
302 /// [`ResponseTooLarge`](jmap_base_client::ClientError::ResponseTooLarge),
303 /// or
304 /// [`UnexpectedResponse`](jmap_base_client::ClientError::UnexpectedResponse).
305 ///
306 /// Note: `push_subscription_*` methods are not account-scoped (RFC
307 /// 8620 §7.2) and therefore do NOT call `session_parts()`. The
308 /// session-derived account check that other `Self::*` methods make
309 /// before any wire call is skipped here; missing-account problems
310 /// surface only as transport / method-level errors from the server.
311 pub async fn push_subscription_create(
312 &self,
313 input: &PushSubscriptionCreateInput<'_>,
314 ) -> Result<PushSubscriptionCreateResponse, jmap_base_client::ClientError> {
315 if input.device_client_id.is_empty() {
316 return Err(jmap_base_client::ClientError::InvalidArgument(
317 "push_subscription_create: device_client_id may not be empty".into(),
318 ));
319 }
320 if input.url.is_empty() {
321 return Err(jmap_base_client::ClientError::InvalidArgument(
322 "push_subscription_create: url may not be empty".into(),
323 ));
324 }
325 // PushSubscriptions are not account-scoped; use api_url without session_parts().
326 let api_url = self.api_url();
327 let client_id = super::resolve_client_id(input.client_id);
328 let mut create_obj = serde_json::json!({
329 "deviceClientId": input.device_client_id,
330 "url": input.url,
331 });
332 if let Some(exp) = input.expires {
333 create_obj["expires"] = exp.as_ref().into();
334 }
335 if let Some(types) = input.types {
336 create_obj["types"] = serde_json::Value::Array(
337 types.iter().copied().map(serde_json::Value::from).collect(),
338 );
339 }
340 let has_chat_push = input.chat_push.is_some();
341 if let Some(cp) = input.chat_push {
342 let chat_push_map = build_chat_push_map(cp, "push_subscription_create")?;
343 create_obj["chatPush"] = serde_json::Value::Object(chat_push_map);
344 }
345 let args = serde_json::json!({
346 "create": { client_id: create_obj }
347 });
348 // RFC 8620 §3.3: only declare the chatPush capability when it is actually used.
349 let using = if has_chat_push {
350 super::USING_CHAT_PUSH
351 } else {
352 super::USING_CORE
353 };
354 let req = super::build_request("PushSubscription/set", args, using);
355 let resp = self.call_internal(api_url, &req).await?;
356 jmap_base_client::extract_response(&resp, super::CALL_ID)
357 }
358
359 /// Update a PushSubscription (RFC 8620 §7.2.2 `PushSubscription/set` update).
360 ///
361 /// Issues a `PushSubscription/set` request with only the `update` sub-map
362 /// populated. RFC 8620 §7.2 declares `url`, `keys`, and `deviceClientId`
363 /// immutable; to change those, destroy the subscription and create a new
364 /// one. The patchable properties are exposed via [`PushSubscriptionPatch`]:
365 /// `verificationCode`, `expires`, `types`, and the JMAP Chat Push
366 /// extension's `chatPush`.
367 ///
368 /// PushSubscriptions are not account-scoped (RFC 8620 §7.2): no
369 /// `accountId` is sent. When the patch touches `chat_push` at all
370 /// (any non-[`Patch::Keep`] value), the `using` array includes
371 /// `urn:ietf:params:jmap:chat:push`; otherwise only
372 /// `urn:ietf:params:jmap:core` is declared (RFC 8620 §3.3).
373 ///
374 /// # Errors
375 ///
376 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
377 /// if `id` is empty (defence-in-depth — typed `&Id` does not
378 /// itself prevent empty values), or if `patch.chat_push` is
379 /// [`Patch::Set`] and the slice contains duplicate `accountId`
380 /// keys.
381 /// - [`ClientError::Parse`](jmap_base_client::ClientError::Parse) if
382 /// serializing the `expires` `Clearable` entry or any
383 /// [`ChatPushConfig`](jmap_chat_types::ChatPushConfig) value
384 /// fails (pathological conditions only).
385 /// - Any transport / protocol variant returned by
386 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
387 /// the matching error list on [`Self::push_subscription_create`].
388 /// /set update errors appear in [`SetResponse::not_updated`]
389 /// rather than as [`Err`].
390 pub async fn push_subscription_update(
391 &self,
392 id: &Id,
393 patch: &PushSubscriptionPatch<'_>,
394 ) -> Result<SetResponse, jmap_base_client::ClientError> {
395 // Defence-in-depth: typed &Id does not prevent empty Id values.
396 if id.as_ref().is_empty() {
397 return Err(jmap_base_client::ClientError::InvalidArgument(
398 "push_subscription_update: id may not be empty".into(),
399 ));
400 }
401
402 let api_url = self.api_url();
403 let mut patch_map = serde_json::Map::new();
404 if let Some(code) = patch.verification_code {
405 patch_map.insert("verificationCode".into(), code.into());
406 }
407 if let Some(entry) = patch
408 .expires
409 .map_entry()
410 .map_err(jmap_base_client::ClientError::from_parse)?
411 {
412 patch_map.insert("expires".into(), entry);
413 }
414 match &patch.types {
415 Patch::Keep => {}
416 Patch::Clear => {
417 patch_map.insert("types".into(), serde_json::Value::Null);
418 }
419 Patch::Set(types) => {
420 patch_map.insert(
421 "types".into(),
422 serde_json::Value::Array(
423 types.iter().copied().map(serde_json::Value::from).collect(),
424 ),
425 );
426 }
427 }
428 match &patch.chat_push {
429 Patch::Keep => {}
430 Patch::Clear => {
431 patch_map.insert("chatPush".into(), serde_json::Value::Null);
432 }
433 Patch::Set(cp) => {
434 let chat_push_map = build_chat_push_map(cp, "push_subscription_update")?;
435 patch_map.insert("chatPush".into(), serde_json::Value::Object(chat_push_map));
436 }
437 }
438
439 let patch_value = serde_json::Value::Object(PatchObject::from_map(patch_map).into_inner());
440 let args = serde_json::json!({
441 "update": { id.as_ref(): patch_value }
442 });
443 let using = if patch.chat_push.is_keep() {
444 super::USING_CORE
445 } else {
446 super::USING_CHAT_PUSH
447 };
448 let req = super::build_request("PushSubscription/set", args, using);
449 let resp = self.call_internal(api_url, &req).await?;
450 jmap_base_client::extract_response(&resp, super::CALL_ID)
451 }
452
453 /// Destroy one or more PushSubscriptions (RFC 8620 §7.2.2 `PushSubscription/set` destroy).
454 ///
455 /// Issues a `PushSubscription/set` request with only the `destroy` array
456 /// populated. PushSubscriptions are not account-scoped (RFC 8620 §7.2):
457 /// no `accountId` is sent. Only `urn:ietf:params:jmap:core` is declared
458 /// — destroying never requires the chatPush capability since it is a
459 /// property-blind operation.
460 ///
461 /// Clients SHOULD NOT destroy a PushSubscription they did not create —
462 /// RFC 8620 §7.2 reserves that to clients that recognise the
463 /// `deviceClientId`. This client does not enforce that rule; the server
464 /// may reject the call.
465 ///
466 /// # Errors
467 ///
468 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
469 /// if `ids` is empty (a destroy call with no ids would be a no-op
470 /// round-trip).
471 /// - Any transport / protocol variant returned by
472 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
473 /// the matching error list on [`Self::push_subscription_create`].
474 /// /set destroy errors appear in [`SetResponse::not_destroyed`]
475 /// rather than as [`Err`].
476 pub async fn push_subscription_destroy(
477 &self,
478 ids: &[Id],
479 ) -> Result<SetResponse, jmap_base_client::ClientError> {
480 if ids.is_empty() {
481 return Err(jmap_base_client::ClientError::InvalidArgument(
482 "push_subscription_destroy: ids may not be empty".into(),
483 ));
484 }
485 let api_url = self.api_url();
486 let args = serde_json::json!({
487 "destroy": ids,
488 });
489 let req = super::build_request("PushSubscription/set", args, super::USING_CORE);
490 let resp = self.call_internal(api_url, &req).await?;
491 jmap_base_client::extract_response(&resp, super::CALL_ID)
492 }
493}
494
495// ---------------------------------------------------------------------------
496// chat_push map builder (shared by push_subscription_create + _update)
497// ---------------------------------------------------------------------------
498
499/// Build a `chatPush` wire object from a slice of `(accountId, config)`
500/// pairs (draft-atwood-jmap-chat-push-00 §3.1).
501///
502/// Returns `InvalidArgument` if the slice contains a duplicate `accountId`
503/// — duplicate accountIds in the patch are an argument-validation error
504/// regardless of whether the request can otherwise be sent. The
505/// `context` string is included in the error message so callers can
506/// distinguish `push_subscription_create` from `push_subscription_update`
507/// in diagnostics.
508///
509/// Single-pass: each insert detects collision via the returned previous
510/// value, replacing the previous two-pass (HashSet duplicate check then
511/// serialize-and-insert) idiom that was hand-duplicated across two
512/// call sites (bd:JMAP-26di.55).
513fn build_chat_push_map(
514 cp: &[(&Id, jmap_chat_types::ChatPushConfig)],
515 context: &'static str,
516) -> Result<serde_json::Map<String, serde_json::Value>, jmap_base_client::ClientError> {
517 let mut chat_push_map = serde_json::Map::new();
518 for (account_id, config) in cp {
519 let key = account_id.as_ref().to_owned();
520 let value =
521 serde_json::to_value(config).map_err(jmap_base_client::ClientError::from_parse)?;
522 if chat_push_map.insert(key, value).is_some() {
523 return Err(jmap_base_client::ClientError::InvalidArgument(format!(
524 "{context}: duplicate accountId '{account_id}' in chat_push"
525 )));
526 }
527 }
528 Ok(chat_push_map)
529}