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}