Skip to main content

jmap_tasks_client/methods/
task_notification.rs

1//! JMAP Tasks — TaskNotification/* method implementations on SessionClient.
2//!
3//! TaskNotification/set is destroy-only. Any create or update entries from
4//! the client would be rejected by the server with `forbidden`, but we
5//! expose a typed API that only allows destroy to avoid client-side confusion.
6
7use jmap_types::{Id, State};
8
9use super::{ChangesResponse, GetResponse, QueryChangesResponse, QueryResponse, SetResponse};
10
11impl super::SessionClient {
12    /// Fetch TaskNotification objects by IDs (draft-tasks-06 §5.2).
13    ///
14    /// If `ids` is `None`, the server returns all TaskNotifications for the account,
15    /// SUBJECT TO the server's `maxObjectsInGet` cap (RFC 8620 §5.1).
16    /// For production use, scope the result set via the corresponding
17    /// /query method first and pass explicit ids here to avoid
18    /// `requestTooLarge` errors when the account holds more objects
19    /// than the cap.
20    ///
21    /// # Errors
22    ///
23    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
24    ///   if the bound session has no primary account for
25    ///   `urn:ietf:params:jmap:tasks`.
26    /// - Any transport / protocol variant returned by
27    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call):
28    ///   [`Http`](jmap_base_client::ClientError::Http),
29    ///   [`Parse`](jmap_base_client::ClientError::Parse),
30    ///   [`AuthFailed`](jmap_base_client::ClientError::AuthFailed),
31    ///   [`MethodError`](jmap_base_client::ClientError::MethodError)
32    ///   (wraps RFC 8620 §3.6.2 method-level errors such as
33    ///   `accountNotFound`, `invalidArguments`, `serverFail`),
34    ///   [`MethodNotFound`](jmap_base_client::ClientError::MethodNotFound),
35    ///   [`ResponseTooLarge`](jmap_base_client::ClientError::ResponseTooLarge),
36    ///   or
37    ///   [`UnexpectedResponse`](jmap_base_client::ClientError::UnexpectedResponse).
38    pub async fn task_notification_get(
39        &self,
40        ids: Option<&[Id]>,
41        properties: Option<&[&str]>,
42    ) -> Result<GetResponse<jmap_tasks_types::TaskNotification>, jmap_base_client::ClientError>
43    {
44        let (api_url, account_id) = self.session_parts()?;
45        // Omit `ids` / `properties` when None — see the matching comment on
46        // `task_list_get` for the rationale (consistent with set/changes/query).
47        let mut args = serde_json::json!({ "accountId": account_id });
48        if let Some(id_slice) = ids {
49            args["ids"] = serde_json::to_value(id_slice).expect("Id slice Serialize is infallible");
50        }
51        if let Some(props) = properties {
52            args["properties"] =
53                serde_json::to_value(props).expect("&[&str] Serialize is infallible");
54        }
55        let req = super::build_request("TaskNotification/get", args, super::USING_TASKS);
56        let resp = self.call_internal(api_url, &req).await?;
57        jmap_base_client::extract_response(&resp, super::CALL_ID)
58    }
59
60    /// Fetch changes to TaskNotification objects since `since_state`
61    /// (draft-tasks-06 §5.3).
62    ///
63    /// # Errors
64    ///
65    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
66    ///   if `since_state` is the empty string (defence-in-depth —
67    ///   `State` constructed via [`State::from`](jmap_types::State::from)
68    ///   accepts empty strings, but an empty `sinceState` is never
69    ///   useful and would otherwise generate a wasted round-trip).
70    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
71    ///   if the bound session has no primary account for
72    ///   `urn:ietf:params:jmap:tasks`.
73    /// - Any transport / protocol variant returned by
74    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
75    ///   the matching error list on [`Self::task_notification_get`].
76    pub async fn task_notification_changes(
77        &self,
78        since_state: &State,
79        max_changes: Option<u64>,
80    ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
81        // Defence-in-depth: see task_list_changes.
82        if since_state.as_ref().is_empty() {
83            return Err(jmap_base_client::ClientError::InvalidArgument(
84                "task_notification_changes: since_state may not be empty".into(),
85            ));
86        }
87        let (api_url, account_id) = self.session_parts()?;
88        let mut args = serde_json::json!({
89            "accountId": account_id,
90            "sinceState": since_state,
91        });
92        if let Some(mc) = max_changes {
93            args["maxChanges"] = mc.into();
94        }
95        let req = super::build_request("TaskNotification/changes", args, super::USING_TASKS);
96        let resp = self.call_internal(api_url, &req).await?;
97        jmap_base_client::extract_response(&resp, super::CALL_ID)
98    }
99
100    /// Destroy TaskNotification objects (draft-tasks-06 §5.4).
101    ///
102    /// **Destroy-only**: TaskNotification/set only supports `destroy`.
103    /// The server creates notifications automatically; clients may only remove them.
104    ///
105    /// Passing an empty `destroy` list is valid and produces an empty /set response.
106    ///
107    /// # Errors
108    ///
109    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
110    ///   if the bound session has no primary account for
111    ///   `urn:ietf:params:jmap:tasks`.
112    /// - Any transport / protocol variant returned by
113    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
114    ///   the matching error list on [`Self::task_notification_get`].
115    pub async fn task_notification_set(
116        &self,
117        destroy: Vec<Id>,
118    ) -> Result<SetResponse, jmap_base_client::ClientError> {
119        let (api_url, account_id) = self.session_parts()?;
120        let args = serde_json::json!({
121            "accountId": account_id,
122            "destroy": destroy,
123        });
124        let req = super::build_request("TaskNotification/set", args, super::USING_TASKS);
125        let resp = self.call_internal(api_url, &req).await?;
126        jmap_base_client::extract_response(&resp, super::CALL_ID)
127    }
128
129    /// Query TaskNotification IDs with optional filter and sort
130    /// (draft-tasks-06 §5.5).
131    ///
132    /// # Errors
133    ///
134    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
135    ///   if the bound session has no primary account for
136    ///   `urn:ietf:params:jmap:tasks`.
137    /// - Any transport / protocol variant returned by
138    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
139    ///   the matching error list on [`Self::task_notification_get`].
140    ///   RFC 8620 §5.5 defines additional /query method-level errors
141    ///   (`anchorNotFound`, `unsupportedFilter`, `unsupportedSort`,
142    ///   `tooManyChanges`) that surface as
143    ///   [`MethodError`](jmap_base_client::ClientError::MethodError).
144    pub async fn task_notification_query(
145        &self,
146        filter: Option<serde_json::Value>,
147        sort: Option<serde_json::Value>,
148        position: Option<u64>,
149        limit: Option<u64>,
150    ) -> Result<QueryResponse, jmap_base_client::ClientError> {
151        let (api_url, account_id) = self.session_parts()?;
152        let mut args = serde_json::json!({
153            "accountId": account_id,
154        });
155        if let Some(f) = filter {
156            args["filter"] = f;
157        }
158        if let Some(s) = sort {
159            args["sort"] = s;
160        }
161        if let Some(p) = position {
162            args["position"] = p.into();
163        }
164        if let Some(l) = limit {
165            args["limit"] = l.into();
166        }
167        let req = super::build_request("TaskNotification/query", args, super::USING_TASKS);
168        let resp = self.call_internal(api_url, &req).await?;
169        jmap_base_client::extract_response(&resp, super::CALL_ID)
170    }
171
172    /// Fetch query-result changes for TaskNotification since `since_query_state`
173    /// (draft-tasks-06 §5.6).
174    ///
175    /// `filter` and `sort` MUST match the `filter` / `sort` passed to the
176    /// original `TaskNotification/query` call that returned
177    /// `since_query_state` — RFC 8620 §5.6 is explicit that the server uses
178    /// them to compute which entries entered or left the result set.
179    ///
180    /// `up_to_id` is the highest-index id the client has cached;
181    /// `calculate_total` requests the new total result count.
182    ///
183    /// # Errors
184    ///
185    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
186    ///   if `since_query_state` is the empty string (defence-in-depth
187    ///   empty-state guard; see [`Self::task_notification_changes`]).
188    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
189    ///   if the bound session has no primary account for
190    ///   `urn:ietf:params:jmap:tasks`.
191    /// - Any transport / protocol variant returned by
192    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
193    ///   the matching error list on [`Self::task_notification_get`].
194    ///   RFC 8620 §5.6 also defines `cannotCalculateChanges` (returned
195    ///   when the server cannot honour the request given the supplied
196    ///   filter / sort); it surfaces as
197    ///   [`MethodError`](jmap_base_client::ClientError::MethodError).
198    pub async fn task_notification_query_changes(
199        &self,
200        since_query_state: &State,
201        max_changes: Option<u64>,
202        filter: Option<serde_json::Value>,
203        sort: Option<serde_json::Value>,
204        up_to_id: Option<&Id>,
205        calculate_total: Option<bool>,
206    ) -> Result<QueryChangesResponse, jmap_base_client::ClientError> {
207        // Defence-in-depth: see task_list_changes.
208        if since_query_state.as_ref().is_empty() {
209            return Err(jmap_base_client::ClientError::InvalidArgument(
210                "task_notification_query_changes: since_query_state may not be empty".into(),
211            ));
212        }
213        let (api_url, account_id) = self.session_parts()?;
214        let mut args = serde_json::json!({
215            "accountId": account_id,
216            "sinceQueryState": since_query_state,
217        });
218        if let Some(f) = filter {
219            args["filter"] = f;
220        }
221        if let Some(s) = sort {
222            args["sort"] = s;
223        }
224        if let Some(mc) = max_changes {
225            args["maxChanges"] = mc.into();
226        }
227        if let Some(uti) = up_to_id {
228            args["upToId"] = serde_json::to_value(uti).expect("Id Serialize is infallible");
229        }
230        if let Some(ct) = calculate_total {
231            args["calculateTotal"] = ct.into();
232        }
233        let req = super::build_request("TaskNotification/queryChanges", args, super::USING_TASKS);
234        let resp = self.call_internal(api_url, &req).await?;
235        jmap_base_client::extract_response(&resp, super::CALL_ID)
236    }
237}
238
239// ---------------------------------------------------------------------------
240// Tests — see tests/task_notification_tests.rs (wiremock-backed end-to-end)
241// ---------------------------------------------------------------------------
242//
243// `task_notification_set_destroy_only_serialization` and
244// `task_notification_set_uses_tasks_capability` were vacuous: they
245// hand-built `args` Values and fed them to `build_request`, never
246// exercising the production `task_notification_set` builder. Deleted in
247// JMAP-tco1.20.
248//
249// Real production-path coverage:
250//   - task_notification_get_round_trip
251//   - task_notification_changes_round_trip
252//   - task_notification_set_destroy_only_wire_format
253//   - task_notification_set_empty_destroy_succeeds
254//   - task_notification_query_with_filter
255//   - task_notification_query_changes_round_trip
256// in tests/task_notification_tests.rs.
257//
258// Specific-flag passthrough coverage that may be lost is tracked
259// under JMAP-uuoi for follow-up wiremock smoke tests.
260//
261// `build_request`, `CALL_ID`, and `USING_TASKS` themselves have their
262// own focused tests in `methods/mod.rs`.
263//
264// The InvalidArgument guard for empty since_state lives in
265// task_notification_changes production code; testing it requires a
266// wiremock-backed async harness. See JMAP-sc1b.64.