Skip to main content

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, 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    pub async fn read_position_get(
20        &self,
21        ids: Option<&[Id]>,
22    ) -> Result<GetResponse<jmap_chat_types::ReadPosition>, jmap_base_client::ClientError> {
23        let (api_url, account_id) = self.session_parts()?;
24        // Omit `ids` when None — see the matching comment on `chat_get` for
25        // the rationale. ReadPosition/get has no `properties` parameter.
26        let mut args = serde_json::json!({ "accountId": account_id });
27        if let Some(id_slice) = ids {
28            args["ids"] = serde_json::to_value(id_slice).expect("Id slice Serialize is infallible");
29        }
30        let req = super::build_request("ReadPosition/get", args, super::USING_CHAT);
31        let resp = self.call_internal(api_url, &req).await?;
32        jmap_base_client::extract_response(&resp, super::CALL_ID)
33    }
34
35    /// Update the read position for a Chat (JMAP Chat §5 ReadPosition/set).
36    ///
37    /// `read_position_id` is the server-assigned ReadPosition.id (from
38    /// `read_position_get`). `last_read_message_id` is the Message.id of the
39    /// most recent message read. The server updates `lastReadAt` and
40    /// recomputes `Chat.unreadCount`.
41    ///
42    /// `create` and `destroy` are forbidden by the spec; only `update` is issued.
43    pub async fn read_position_update(
44        &self,
45        read_position_id: &Id,
46        last_read_message_id: &Id,
47    ) -> Result<SetResponse, jmap_base_client::ClientError> {
48        let (api_url, account_id) = self.session_parts()?;
49        let args = serde_json::json!({
50            "accountId": account_id,
51            "update": {
52                read_position_id.as_ref(): { "lastReadMessageId": last_read_message_id }
53            },
54        });
55        let req = super::build_request("ReadPosition/set", args, super::USING_CHAT);
56        let resp = self.call_internal(api_url, &req).await?;
57        jmap_base_client::extract_response(&resp, super::CALL_ID)
58    }
59
60    /// Fetch the singleton PresenceStatus record (JMAP Chat §5 PresenceStatus/get).
61    ///
62    /// Per spec there is exactly one PresenceStatus per account; `ids: null`
63    /// retrieves it.
64    pub async fn presence_status_get(
65        &self,
66    ) -> Result<GetResponse<jmap_chat_types::PresenceStatus>, jmap_base_client::ClientError> {
67        let (api_url, account_id) = self.session_parts()?;
68        let args = serde_json::json!({
69            "accountId": account_id,
70            "ids": None::<&[Id]>,
71        });
72        let req = super::build_request("PresenceStatus/get", args, super::USING_CHAT);
73        let resp = self.call_internal(api_url, &req).await?;
74        jmap_base_client::extract_response(&resp, super::CALL_ID)
75    }
76
77    /// Fetch changes to ReadPosition records since `since_state` (JMAP Chat §5 ReadPosition/changes).
78    ///
79    /// `max_changes` may be `None` to let the server choose the limit (RFC 8620 §5.2).
80    pub async fn read_position_changes(
81        &self,
82        since_state: &State,
83        max_changes: Option<u64>,
84    ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
85        // Defence-in-depth: see `chat_changes`.
86        if since_state.as_ref().is_empty() {
87            return Err(jmap_base_client::ClientError::InvalidArgument(
88                "read_position_changes: since_state may not be empty".into(),
89            ));
90        }
91        let (api_url, account_id) = self.session_parts()?;
92        let mut args = serde_json::json!({
93            "accountId": account_id,
94            "sinceState": since_state,
95        });
96        if let Some(mc) = max_changes {
97            args["maxChanges"] = mc.into();
98        }
99        let req = super::build_request("ReadPosition/changes", args, super::USING_CHAT);
100        let resp = self.call_internal(api_url, &req).await?;
101        jmap_base_client::extract_response(&resp, super::CALL_ID)
102    }
103
104    /// Update the PresenceStatus record (JMAP Chat §5 PresenceStatus/set).
105    ///
106    /// Only `update` is issued; `create` and `destroy` are forbidden by the spec.
107    /// Fields absent from `patch` (i.e. `Patch::Keep` or `None`) are omitted from
108    /// the patch and left unchanged server-side.
109    pub async fn presence_status_update(
110        &self,
111        id: &Id,
112        patch: &PresenceStatusPatch<'_>,
113    ) -> Result<SetResponse, jmap_base_client::ClientError> {
114        let (api_url, account_id) = self.session_parts()?;
115        let mut patch_map = serde_json::Map::new();
116        if let Some(p) = &patch.presence {
117            patch_map.insert(
118                "presence".into(),
119                serde_json::to_value(p).map_err(jmap_base_client::ClientError::Parse)?,
120            );
121        }
122        if let Some(entry) = patch
123            .status_text
124            .map_entry()
125            .map_err(jmap_base_client::ClientError::Parse)?
126        {
127            patch_map.insert("statusText".into(), entry);
128        }
129        if let Some(entry) = patch
130            .status_emoji
131            .map_entry()
132            .map_err(jmap_base_client::ClientError::Parse)?
133        {
134            patch_map.insert("statusEmoji".into(), entry);
135        }
136        if let Some(entry) = patch
137            .expires_at
138            .map_entry()
139            .map_err(jmap_base_client::ClientError::Parse)?
140        {
141            patch_map.insert("expiresAt".into(), entry);
142        }
143        if let Some(rs) = patch.receipt_sharing {
144            patch_map.insert("receiptSharing".into(), rs.into());
145        }
146        // Wrap the constructed map in a PatchObject (RFC 8620 §5.3) before
147        // serializing. Wire bytes are unchanged because PatchObject is
148        // #[serde(transparent)]; the typed boundary documents the contract.
149        let patch_value = serde_json::Value::Object(PatchObject::from_map(patch_map).into_inner());
150        let args = serde_json::json!({
151            "accountId": account_id,
152            "update": { id.as_ref(): patch_value },
153        });
154        let req = super::build_request("PresenceStatus/set", args, super::USING_CHAT);
155        let resp = self.call_internal(api_url, &req).await?;
156        jmap_base_client::extract_response(&resp, super::CALL_ID)
157    }
158
159    /// Fetch changes to PresenceStatus records since `since_state` (JMAP Chat §5 PresenceStatus/changes).
160    ///
161    /// `max_changes` may be `None` to let the server choose the limit (RFC 8620 §5.2).
162    pub async fn presence_status_changes(
163        &self,
164        since_state: &State,
165        max_changes: Option<u64>,
166    ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
167        // Defence-in-depth: see `chat_changes`.
168        if since_state.as_ref().is_empty() {
169            return Err(jmap_base_client::ClientError::InvalidArgument(
170                "presence_status_changes: since_state may not be empty".into(),
171            ));
172        }
173        let (api_url, account_id) = self.session_parts()?;
174        let mut args = serde_json::json!({
175            "accountId": account_id,
176            "sinceState": since_state,
177        });
178        if let Some(mc) = max_changes {
179            args["maxChanges"] = mc.into();
180        }
181        let req = super::build_request("PresenceStatus/changes", args, super::USING_CHAT);
182        let resp = self.call_internal(api_url, &req).await?;
183        jmap_base_client::extract_response(&resp, super::CALL_ID)
184    }
185
186    /// Create a PushSubscription with the optional `chatPush` extension
187    /// (RFC 8620 §7.2 / draft-atwood-jmap-chat-push-00 §3).
188    ///
189    /// PushSubscriptions are account-independent: no `accountId` is included
190    /// in the request (RFC 8620 §7.2). When `input.chat_push` is `Some`, the
191    /// `using` array includes `urn:ietf:params:jmap:chat:push` (RFC 8620 §3.3:
192    /// capabilities MUST only be declared when used); otherwise `urn:ietf:params:jmap:core`
193    /// alone is used.
194    ///
195    /// This method issues a `create` operation only. To extend `expires`, set
196    /// the verification code, change `types`, or update `chatPush`, use
197    /// [`push_subscription_update`](Self::push_subscription_update). To
198    /// unsubscribe, use [`push_subscription_destroy`](Self::push_subscription_destroy).
199    ///
200    /// When `input.client_id` is `None`, a ULID is generated automatically.
201    pub async fn push_subscription_create(
202        &self,
203        input: &PushSubscriptionCreateInput<'_>,
204    ) -> Result<PushSubscriptionCreateResponse, jmap_base_client::ClientError> {
205        if input.device_client_id.is_empty() {
206            return Err(jmap_base_client::ClientError::InvalidArgument(
207                "push_subscription_create: device_client_id may not be empty".into(),
208            ));
209        }
210        if input.url.is_empty() {
211            return Err(jmap_base_client::ClientError::InvalidArgument(
212                "push_subscription_create: url may not be empty".into(),
213            ));
214        }
215        // PushSubscriptions are not account-scoped; use api_url without session_parts().
216        let api_url = self.api_url();
217        let client_id = super::resolve_client_id(input.client_id);
218        let mut create_obj = serde_json::json!({
219            "deviceClientId": input.device_client_id,
220            "url": input.url,
221        });
222        if let Some(exp) = input.expires {
223            create_obj["expires"] = exp.as_ref().into();
224        }
225        if let Some(types) = input.types {
226            create_obj["types"] = serde_json::Value::Array(
227                types.iter().copied().map(serde_json::Value::from).collect(),
228            );
229        }
230        let has_chat_push = input.chat_push.is_some();
231        if let Some(cp) = input.chat_push {
232            let mut seen = std::collections::HashSet::new();
233            for (account_id, _) in cp {
234                if !seen.insert(account_id) {
235                    return Err(jmap_base_client::ClientError::InvalidArgument(format!(
236                        "push_subscription_create: duplicate accountId '{}' in chat_push",
237                        account_id
238                    )));
239                }
240            }
241            let mut chat_push_map = serde_json::Map::new();
242            for (account_id, config) in cp {
243                chat_push_map.insert(
244                    account_id.as_ref().to_owned(),
245                    serde_json::to_value(config).map_err(jmap_base_client::ClientError::Parse)?,
246                );
247            }
248            create_obj["chatPush"] = serde_json::Value::Object(chat_push_map);
249        }
250        let args = serde_json::json!({
251            "create": { client_id: create_obj }
252        });
253        // RFC 8620 §3.3: only declare the chatPush capability when it is actually used.
254        let using = if has_chat_push {
255            super::USING_CHAT_PUSH
256        } else {
257            super::USING_CORE
258        };
259        let req = super::build_request("PushSubscription/set", args, using);
260        let resp = self.call_internal(api_url, &req).await?;
261        jmap_base_client::extract_response(&resp, super::CALL_ID)
262    }
263
264    /// Update a PushSubscription (RFC 8620 §7.2.2 `PushSubscription/set` update).
265    ///
266    /// Issues a `PushSubscription/set` request with only the `update` sub-map
267    /// populated. RFC 8620 §7.2 declares `url`, `keys`, and `deviceClientId`
268    /// immutable; to change those, destroy the subscription and create a new
269    /// one. The patchable properties are exposed via [`PushSubscriptionPatch`]:
270    /// `verificationCode`, `expires`, `types`, and the JMAP Chat Push
271    /// extension's `chatPush`.
272    ///
273    /// PushSubscriptions are not account-scoped (RFC 8620 §7.2): no
274    /// `accountId` is sent. When the patch touches `chat_push` or
275    /// `clear_chat_push`, the `using` array includes
276    /// `urn:ietf:params:jmap:chat:push`; otherwise only
277    /// `urn:ietf:params:jmap:core` is declared (RFC 8620 §3.3).
278    ///
279    /// Returns [`jmap_base_client::ClientError::InvalidArgument`] if `id` is
280    /// empty, if both `patch.types` and `patch.clear_types` are set, or if
281    /// both `patch.chat_push` and `patch.clear_chat_push` are set.
282    pub async fn push_subscription_update(
283        &self,
284        id: &Id,
285        patch: &PushSubscriptionPatch<'_>,
286    ) -> Result<SetResponse, jmap_base_client::ClientError> {
287        // Defence-in-depth: typed &Id does not prevent empty Id values.
288        if id.as_ref().is_empty() {
289            return Err(jmap_base_client::ClientError::InvalidArgument(
290                "push_subscription_update: id may not be empty".into(),
291            ));
292        }
293        if patch.types.is_some() && patch.clear_types {
294            return Err(jmap_base_client::ClientError::InvalidArgument(
295                "push_subscription_update: types and clear_types are mutually exclusive".into(),
296            ));
297        }
298        if patch.chat_push.is_some() && patch.clear_chat_push {
299            return Err(jmap_base_client::ClientError::InvalidArgument(
300                "push_subscription_update: chat_push and clear_chat_push are mutually exclusive"
301                    .into(),
302            ));
303        }
304
305        let api_url = self.api_url();
306        let mut patch_map = serde_json::Map::new();
307        if let Some(code) = patch.verification_code {
308            patch_map.insert("verificationCode".into(), code.into());
309        }
310        if let Some(entry) = patch
311            .expires
312            .map_entry()
313            .map_err(jmap_base_client::ClientError::Parse)?
314        {
315            patch_map.insert("expires".into(), entry);
316        }
317        if let Some(types) = patch.types {
318            patch_map.insert(
319                "types".into(),
320                serde_json::Value::Array(
321                    types.iter().copied().map(serde_json::Value::from).collect(),
322                ),
323            );
324        } else if patch.clear_types {
325            patch_map.insert("types".into(), serde_json::Value::Null);
326        }
327        if let Some(cp) = patch.chat_push {
328            let mut seen = std::collections::HashSet::new();
329            for (account_id, _) in cp {
330                if !seen.insert(account_id) {
331                    return Err(jmap_base_client::ClientError::InvalidArgument(format!(
332                        "push_subscription_update: duplicate accountId '{}' in chat_push",
333                        account_id
334                    )));
335                }
336            }
337            let mut chat_push_map = serde_json::Map::new();
338            for (account_id, config) in cp {
339                chat_push_map.insert(
340                    account_id.as_ref().to_owned(),
341                    serde_json::to_value(config).map_err(jmap_base_client::ClientError::Parse)?,
342                );
343            }
344            patch_map.insert("chatPush".into(), serde_json::Value::Object(chat_push_map));
345        } else if patch.clear_chat_push {
346            patch_map.insert("chatPush".into(), serde_json::Value::Null);
347        }
348
349        let patch_value = serde_json::Value::Object(PatchObject::from_map(patch_map).into_inner());
350        let args = serde_json::json!({
351            "update": { id.as_ref(): patch_value }
352        });
353        let using = if patch.chat_push.is_some() || patch.clear_chat_push {
354            super::USING_CHAT_PUSH
355        } else {
356            super::USING_CORE
357        };
358        let req = super::build_request("PushSubscription/set", args, using);
359        let resp = self.call_internal(api_url, &req).await?;
360        jmap_base_client::extract_response(&resp, super::CALL_ID)
361    }
362
363    /// Destroy one or more PushSubscriptions (RFC 8620 §7.2.2 `PushSubscription/set` destroy).
364    ///
365    /// Issues a `PushSubscription/set` request with only the `destroy` array
366    /// populated. PushSubscriptions are not account-scoped (RFC 8620 §7.2):
367    /// no `accountId` is sent. Only `urn:ietf:params:jmap:core` is declared
368    /// — destroying never requires the chatPush capability since it is a
369    /// property-blind operation.
370    ///
371    /// Returns [`jmap_base_client::ClientError::InvalidArgument`] if `ids` is
372    /// empty (a destroy call with no ids would be a no-op round-trip).
373    ///
374    /// Clients SHOULD NOT destroy a PushSubscription they did not create —
375    /// RFC 8620 §7.2 reserves that to clients that recognise the
376    /// `deviceClientId`. This client does not enforce that rule; the server
377    /// may reject the call.
378    pub async fn push_subscription_destroy(
379        &self,
380        ids: &[Id],
381    ) -> Result<SetResponse, jmap_base_client::ClientError> {
382        if ids.is_empty() {
383            return Err(jmap_base_client::ClientError::InvalidArgument(
384                "push_subscription_destroy: ids may not be empty".into(),
385            ));
386        }
387        let api_url = self.api_url();
388        let args = serde_json::json!({
389            "destroy": ids,
390        });
391        let req = super::build_request("PushSubscription/set", args, super::USING_CORE);
392        let resp = self.call_internal(api_url, &req).await?;
393        jmap_base_client::extract_response(&resp, super::CALL_ID)
394    }
395}