Skip to main content

jmap_mail_client/methods/
submission.rs

1//! JMAP Mail — EmailSubmission/* method implementations on SessionClient.
2//!
3//! Implements RFC 8621 §7.1-7.5.
4//!
5//! Each method follows the standard six-step pattern:
6//!   1. Validate arguments (defence-in-depth empty-state guards).
7//!   2. Call `self.session_parts()?` → `(api_url, account_id)`.
8//!   3. Build args JSON with `serde_json::json!({…})`.
9//!   4. Call `build_request(method_name, args, USING_MAIL)`.
10//!   5. Call `self.call_internal(api_url, &req).await?`.
11//!   6. Call `jmap_base_client::extract_response(&resp, CALL_ID)?`.
12//!
13//! Wire key notes (RFC 8621 §7):
14//!   - Object field for submission creation time:     "sendAt"       (§7.1)
15//!   - Sort property for /query:                      "sentAt"       (§7.3, line 4513)
16//!   - Success hooks on /set:                         "onSuccessUpdateEmail",
17//!     "onSuccessDestroyEmail" (§7.5)
18
19use std::collections::HashMap;
20
21use jmap_types::{Id, PatchObject, State};
22
23use super::{
24    ChangesResponse, EmailSubmissionSetParams, GetResponse, QueryChangesResponse, QueryResponse,
25    SetResponse,
26};
27
28impl super::SessionClient {
29    /// Fetch EmailSubmission objects by IDs (RFC 8621 §7.1 — EmailSubmission/get).
30    ///
31    /// If `ids` is `None`, the server returns all submissions for the account.
32    /// Pass `properties: None` to return all fields.
33    pub async fn email_submission_get(
34        &self,
35        ids: Option<&[Id]>,
36        properties: Option<&[&str]>,
37    ) -> Result<GetResponse<jmap_mail_types::EmailSubmission>, jmap_base_client::ClientError> {
38        let (api_url, account_id) = self.session_parts()?;
39        // Omit `ids` / `properties` when None — see the matching comment on
40        // `email_get` for the rationale (consistent with set/changes/query).
41        let mut args = serde_json::json!({ "accountId": account_id });
42        if let Some(id_slice) = ids {
43            args["ids"] = serde_json::to_value(id_slice).expect("Id slice Serialize is infallible");
44        }
45        if let Some(props) = properties {
46            args["properties"] = serde_json::Value::Array(
47                props.iter().copied().map(serde_json::Value::from).collect(),
48            );
49        }
50        let req = super::build_request("EmailSubmission/get", args, super::USING_MAIL);
51        let resp = self.call_internal(api_url, &req).await?;
52        jmap_base_client::extract_response(&resp, super::CALL_ID)
53    }
54
55    /// Fetch changes to EmailSubmission objects since `since_state`
56    /// (RFC 8621 §7.2 — EmailSubmission/changes).
57    pub async fn email_submission_changes(
58        &self,
59        since_state: &State,
60        max_changes: Option<u64>,
61    ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
62        // Defence-in-depth: see `thread_changes`.
63        if since_state.as_ref().is_empty() {
64            return Err(jmap_base_client::ClientError::InvalidArgument(
65                "email_submission_changes: since_state may not be empty".into(),
66            ));
67        }
68        let (api_url, account_id) = self.session_parts()?;
69        let mut args = serde_json::json!({
70            "accountId": account_id,
71            "sinceState": since_state,
72        });
73        if let Some(mc) = max_changes {
74            args["maxChanges"] = mc.into();
75        }
76        let req = super::build_request("EmailSubmission/changes", args, super::USING_MAIL);
77        let resp = self.call_internal(api_url, &req).await?;
78        jmap_base_client::extract_response(&resp, super::CALL_ID)
79    }
80
81    /// Query EmailSubmission IDs with optional filter and sort
82    /// (RFC 8621 §7.3 — EmailSubmission/query).
83    ///
84    /// The sort property for this object type is `"sentAt"` (RFC 8621 §7.3, line 4513),
85    /// not `"sendAt"` (which is an object field).  Callers constructing the sort
86    /// argument should use `"sentAt"` as the property name.
87    pub async fn email_submission_query(
88        &self,
89        filter: Option<serde_json::Value>,
90        sort: Option<serde_json::Value>,
91        position: Option<u64>,
92        limit: Option<u64>,
93    ) -> Result<QueryResponse, jmap_base_client::ClientError> {
94        let (api_url, account_id) = self.session_parts()?;
95        let mut args = serde_json::json!({
96            "accountId": account_id,
97        });
98        if let Some(f) = filter {
99            args["filter"] = f;
100        }
101        if let Some(s) = sort {
102            args["sort"] = s;
103        }
104        if let Some(p) = position {
105            args["position"] = p.into();
106        }
107        if let Some(l) = limit {
108            args["limit"] = l.into();
109        }
110        let req = super::build_request("EmailSubmission/query", args, super::USING_MAIL);
111        let resp = self.call_internal(api_url, &req).await?;
112        jmap_base_client::extract_response(&resp, super::CALL_ID)
113    }
114
115    /// Fetch query-result changes for EmailSubmission since `since_query_state`
116    /// (RFC 8621 §7.4 — EmailSubmission/queryChanges).
117    pub async fn email_submission_query_changes(
118        &self,
119        since_query_state: &State,
120        max_changes: Option<u64>,
121        filter: Option<serde_json::Value>,
122        sort: Option<serde_json::Value>,
123    ) -> Result<QueryChangesResponse, jmap_base_client::ClientError> {
124        // Defence-in-depth: see `thread_changes`.
125        if since_query_state.as_ref().is_empty() {
126            return Err(jmap_base_client::ClientError::InvalidArgument(
127                "email_submission_query_changes: since_query_state may not be empty".into(),
128            ));
129        }
130        let (api_url, account_id) = self.session_parts()?;
131        let mut args = serde_json::json!({
132            "accountId": account_id,
133            "sinceQueryState": since_query_state,
134        });
135        if let Some(mc) = max_changes {
136            args["maxChanges"] = mc.into();
137        }
138        if let Some(f) = filter {
139            args["filter"] = f;
140        }
141        if let Some(s) = sort {
142            args["sort"] = s;
143        }
144        let req = super::build_request("EmailSubmission/queryChanges", args, super::USING_MAIL);
145        let resp = self.call_internal(api_url, &req).await?;
146        jmap_base_client::extract_response(&resp, super::CALL_ID)
147    }
148
149    /// Create, update, or destroy EmailSubmission objects
150    /// (RFC 8621 §7.5 — EmailSubmission/set).
151    ///
152    /// The optional `params` argument carries the two success-hook fields:
153    ///
154    /// - `on_success_update_email` — a `PatchObject` map (keyed by submission
155    ///   creation key) of patches to apply to the associated Email when the
156    ///   submission is created successfully (RFC 8621 §7.5).
157    /// - `on_success_destroy_email` — IDs (or `#`-reference creation keys) of
158    ///   Email objects to destroy when the submission is created successfully
159    ///   (RFC 8621 §7.5).
160    pub async fn email_submission_set(
161        &self,
162        create: Option<serde_json::Value>,
163        update: Option<HashMap<Id, PatchObject>>,
164        destroy: Option<Vec<Id>>,
165        if_in_state: Option<&State>,
166        params: Option<EmailSubmissionSetParams>,
167    ) -> Result<SetResponse<jmap_mail_types::EmailSubmission>, jmap_base_client::ClientError> {
168        let (api_url, account_id) = self.session_parts()?;
169        let mut args = serde_json::json!({
170            "accountId": account_id,
171        });
172        // Merge success-hook params into the top-level args object (RFC 8621 §7.5).
173        // These are method-level arguments, not nested under a key.
174        if let Some(p) = params {
175            if let Some(v) = p.on_success_update_email {
176                args["onSuccessUpdateEmail"] = serde_json::to_value(&v).map_err(|e| {
177                    jmap_base_client::ClientError::InvalidArgument(format!(
178                        "email_submission_set: serializing onSuccessUpdateEmail failed: {e}"
179                    ))
180                })?;
181            }
182            if let Some(v) = p.on_success_destroy_email {
183                args["onSuccessDestroyEmail"] = serde_json::Value::Array(
184                    v.into_iter().map(serde_json::Value::String).collect(),
185                );
186            }
187        }
188        if let Some(s) = if_in_state {
189            args["ifInState"] = serde_json::Value::String(s.as_ref().to_owned());
190        }
191        if let Some(c) = create {
192            args["create"] = c;
193        }
194        if let Some(u) = update {
195            args["update"] = serde_json::to_value(&u).map_err(|e| {
196                jmap_base_client::ClientError::InvalidArgument(format!(
197                    "email_submission_set: serializing update map failed: {e}"
198                ))
199            })?;
200        }
201        if let Some(d) = destroy {
202            args["destroy"] = serde_json::to_value(&d).expect("Id Vec Serialize is infallible");
203        }
204        let req = super::build_request("EmailSubmission/set", args, super::USING_MAIL);
205        let resp = self.call_internal(api_url, &req).await?;
206        jmap_base_client::extract_response(&resp, super::CALL_ID)
207    }
208}
209
210// ---------------------------------------------------------------------------
211// Tests
212// ---------------------------------------------------------------------------
213
214#[cfg(test)]
215mod tests {
216    use serde_json::json;
217
218    // submission_get_empty_id_guard and submission_set_empty_destroy_id_guard
219    // were deleted in JMAP-6by7.2 (typed-Id refactor): under `Option<&[Id]>`
220    // and `Option<Vec<Id>>` the empty-Id case becomes impossible to express
221    // through the typed API.
222
223    // The InvalidArgument guards for empty since_state and since_query_state
224    // live in email_submission_changes / email_submission_query_changes
225    // production code; testing them requires a wiremock-backed async harness.
226    // See JMAP-sc1b.64.
227
228    // Deleted in JMAP-tco1.5 as Pattern E (vacuous inline tests):
229    //   - submission_get_request_shape
230    //   - submission_changes_request_shape
231    //   - submission_query_request_includes_filter
232    //   - submission_query_changes_request_shape
233    //   - submission_set_on_success_update_email_request_shape
234    //   - submission_set_on_success_destroy_email_request_shape
235    // Each hand-built `args = json!({...})` and fed it to `build_request`,
236    // never invoking the `email_submission_get` / `email_submission_changes` /
237    // `email_submission_query` / `email_submission_query_changes` /
238    // `email_submission_set` production builders.
239    //
240    // Real production-path coverage:
241    //   - tests/submission_get_changes.rs:
242    //       email_submission_get_round_trip,
243    //       email_submission_get_specific_ids,
244    //       email_submission_changes_round_trip,
245    //       email_submission_changes_no_max_changes
246    //   - tests/submission_query.rs:
247    //       email_submission_query_with_filter,
248    //       email_submission_query_no_filter,
249    //       email_submission_query_changes_round_trip,
250    //       email_submission_query_changes_with_filter_and_sort
251    //   - tests/submission_set.rs:
252    //       email_submission_set_create_round_trip,
253    //       email_submission_set_on_success_update_email,
254    //       email_submission_set_no_on_success_when_none
255    //
256    // Specific-flag passthrough coverage that may be lost (`onSuccessDestroyEmail`)
257    // is tracked under JMAP-uuoi for a follow-up wiremock smoke test —
258    // there is no current wiremock test that asserts the
259    // `onSuccessDestroyEmail` array field reaches the wire.
260    //
261    // `build_request`, `CALL_ID`, and `USING_MAIL` themselves have their
262    // own focused tests in `methods/mod.rs`.
263
264    // ── Response deserialization tests ───────────────────────────────────────
265
266    /// Oracle: GetResponse<EmailSubmission> deserializes from RFC 8621 §7.1 response shape.
267    /// JSON constructed from §7 field descriptions (not derived from code).
268    #[test]
269    fn submission_get_response_deserializes() {
270        let json_val = json!({
271            "accountId": "acc1",
272            "state": "s5",
273            "list": [
274                {
275                    "id": "sub1",
276                    "identityId": "ident1",
277                    "emailId": "eml1",
278                    "threadId": "thr1",
279                    "envelope": null,
280                    "sendAt": "2024-06-15T10:00:00Z",
281                    "undoStatus": "final",
282                    "deliveryStatus": null,
283                    "dsnBlobIds": [],
284                    "mdnBlobIds": []
285                }
286            ],
287            "notFound": []
288        });
289
290        use super::super::GetResponse;
291        let resp: GetResponse<jmap_mail_types::EmailSubmission> =
292            serde_json::from_value(json_val).expect("must deserialize EmailSubmission GetResponse");
293        assert_eq!(resp.state, "s5");
294        assert_eq!(resp.list.len(), 1);
295        assert_eq!(resp.list[0].id.as_ref(), "sub1");
296    }
297}