Skip to main content

jmap_mail_client/methods/
email.rs

1//! JMAP Mail — Email/* method implementations on SessionClient.
2//!
3//! Each method follows the standard five-step pattern:
4//!   1. Validate arguments (defence-in-depth empty-state guards).
5//!   2. Call `self.session_parts()?` → `(api_url, account_id)`.
6//!   3. Build args JSON with `serde_json::json!({…})`.
7//!   4. Call `build_request(method_name, args, USING_MAIL)`.
8//!   5. Call `self.call_internal(api_url, &req).await?`.
9//!   6. Call `jmap_base_client::extract_response(&resp, CALL_ID)?`.
10
11use std::collections::HashMap;
12
13use jmap_types::{Id, PatchObject, State};
14
15use super::{
16    ChangesResponse, EmailCopyParams, EmailGetParams, EmailImportInput, EmailImportResponse,
17    EmailParseParams, EmailParseResponse, GetResponse, QueryChangesResponse, QueryResponse,
18    SetResponse,
19};
20
21impl super::SessionClient {
22    /// Fetch Email objects by IDs (RFC 8621 §4.1.8 — Email/get).
23    ///
24    /// If `ids` is `None`, the server returns all Emails for the account,
25    /// SUBJECT TO the server's `maxObjectsInGet` cap (RFC 8620 §5.1).
26    /// For production use, scope the result set via the corresponding
27    /// /query method first and pass explicit ids here to avoid
28    /// `requestTooLarge` errors when the account holds more objects
29    /// than the cap.
30    /// Pass `properties: None` to return all fields.
31    /// Pass `params: None` to use server defaults for body-fetch options.
32    ///
33    /// # Errors
34    ///
35    /// - [`ClientError::InvalidSession`] if the bound session has no
36    ///   primary account for `urn:ietf:params:jmap:mail`.
37    /// - [`ClientError::InvalidArgument`] if `params` is `Some` and
38    ///   serializing it to JSON fails (pathological conditions only —
39    ///   allocation failure, or a vendor value in `params.extra` that
40    ///   itself fails to serialize).
41    /// - Any transport / protocol variant returned by
42    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call):
43    ///   [`Http`](jmap_base_client::ClientError::Http),
44    ///   [`Parse`](jmap_base_client::ClientError::Parse),
45    ///   [`AuthFailed`](jmap_base_client::ClientError::AuthFailed),
46    ///   [`MethodError`](jmap_base_client::ClientError::MethodError)
47    ///   (wraps RFC 8620 §3.6.2 method-level errors such as
48    ///   `accountNotFound`, `invalidArguments`, `serverFail`),
49    ///   [`MethodNotFound`](jmap_base_client::ClientError::MethodNotFound),
50    ///   [`ResponseTooLarge`](jmap_base_client::ClientError::ResponseTooLarge),
51    ///   or
52    ///   [`UnexpectedResponse`](jmap_base_client::ClientError::UnexpectedResponse).
53    ///
54    /// [`ClientError::InvalidSession`]: jmap_base_client::ClientError::InvalidSession
55    /// [`ClientError::InvalidArgument`]: jmap_base_client::ClientError::InvalidArgument
56    pub async fn email_get(
57        &self,
58        ids: Option<&[Id]>,
59        properties: Option<&[&str]>,
60        params: Option<EmailGetParams>,
61    ) -> Result<GetResponse<jmap_mail_types::Email>, jmap_base_client::ClientError> {
62        let (api_url, account_id) = self.session_parts()?;
63        // Omit `ids` / `properties` entirely when None rather than sending
64        // an explicit JSON null. RFC 8620 §5.1 accepts both shapes, but the
65        // crate's other builders (set/changes/query) consistently use the
66        // conditional-add idiom; matching it here keeps the wire request
67        // canonical and avoids "present-but-null vs absent" interop quirks
68        // in proxies / audit loggers.
69        let mut args = serde_json::json!({ "accountId": account_id });
70        if let Some(id_slice) = ids {
71            args["ids"] = serde_json::to_value(id_slice).expect("Id slice Serialize is infallible");
72        }
73        if let Some(props) = properties {
74            args["properties"] =
75                serde_json::to_value(props).expect("&[&str] Serialize is infallible");
76        }
77        if let Some(p) = params {
78            let pv = serde_json::to_value(p).map_err(|e| {
79                jmap_base_client::ClientError::InvalidArgument(format!(
80                    "email_get: failed to serialize params: {e}"
81                ))
82            })?;
83            if let serde_json::Value::Object(map) = pv {
84                // Use `entry().or_insert()` so a caller who put a typed
85                // wire key (e.g. "accountId", "ids", "properties") into
86                // `params.extra` cannot silently clobber the value
87                // computed from the bound session and the typed args.
88                // Typed wins on collision.
89                let args_obj = args
90                    .as_object_mut()
91                    .expect("email_get: args is constructed as Object");
92                for (k, v) in map {
93                    args_obj.entry(k).or_insert(v);
94                }
95            }
96        }
97        let req = super::build_request("Email/get", args, super::USING_MAIL);
98        let resp = self.call_internal(api_url, &req).await?;
99        jmap_base_client::extract_response(&resp, super::CALL_ID)
100    }
101
102    /// Fetch changes to Email objects since `since_state` (RFC 8621 §4.2 — Email/changes).
103    ///
104    /// If `has_more_changes` is true in the response, call again with `new_state`
105    /// as `since_state` until the flag is false.
106    ///
107    /// # `max_changes` spec magic-values (RFC 8620 §5.2)
108    ///
109    /// - `None` omits the wire field and lets the server apply its
110    ///   default cap.
111    /// - `Some(0)` is wire-legal and means "no client limit"; the
112    ///   server may still apply its own cap. This is distinct from
113    ///   `None`: `None` says "I haven't expressed a preference",
114    ///   `Some(0)` says "I want as many entries as the server is
115    ///   willing to return in one round-trip".
116    /// - `Some(n)` with `n > 0` requests at most `n` entries; the
117    ///   server may return fewer.
118    ///
119    /// # Errors
120    ///
121    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
122    ///   if `since_state` is the empty string (defence-in-depth — `State`
123    ///   constructed via [`State::from`](jmap_types::State::from) accepts
124    ///   empty strings, but an empty `sinceState` is never useful and
125    ///   would otherwise generate a wasted round-trip).
126    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
127    ///   if the bound session has no primary account for
128    ///   `urn:ietf:params:jmap:mail`.
129    /// - Any transport / protocol variant returned by
130    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
131    ///   the matching error list on [`Self::email_get`].
132    pub async fn email_changes(
133        &self,
134        since_state: &State,
135        max_changes: Option<u64>,
136    ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
137        // Defence-in-depth: see `thread_changes`.
138        if since_state.as_ref().is_empty() {
139            return Err(jmap_base_client::ClientError::InvalidArgument(
140                "email_changes: since_state may not be empty".into(),
141            ));
142        }
143        let (api_url, account_id) = self.session_parts()?;
144        let mut args = serde_json::json!({
145            "accountId": account_id,
146            "sinceState": since_state,
147        });
148        if let Some(mc) = max_changes {
149            args["maxChanges"] = mc.into();
150        }
151        let req = super::build_request("Email/changes", args, super::USING_MAIL);
152        let resp = self.call_internal(api_url, &req).await?;
153        jmap_base_client::extract_response(&resp, super::CALL_ID)
154    }
155
156    /// Create, update, or destroy Email objects (RFC 8621 §4.3 — Email/set).
157    ///
158    /// Pass `create`, `update`, and/or `destroy` as needed. All three are
159    /// optional; pass `None` to omit any operation from the request.
160    /// Pass `if_in_state: Some(&state)` to use an optimistic-concurrency guard.
161    ///
162    /// `update` is `Option<HashMap<Id, PatchObject>>` (RFC 8620 §5.3). Wire
163    /// format is unchanged from a plain JSON object because [`PatchObject`]
164    /// is `#[serde(transparent)]`; the typed parameter binds the JSON Pointer
165    /// key + null-leaf removal contract to the type system.
166    ///
167    /// # Errors
168    ///
169    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
170    ///   if the bound session has no primary account for
171    ///   `urn:ietf:params:jmap:mail`.
172    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
173    ///   if `update` is `Some` and `serde_json::to_value` fails on the
174    ///   patch map. In practice this happens only under pathological
175    ///   conditions (allocation failure on a very large `HashMap`, or
176    ///   a `PatchObject` whose JSON tree exceeds `serde_json`'s
177    ///   recursion limit). The size of `update` is otherwise bounded
178    ///   only by available memory; the wire request is buffered by the
179    ///   HTTP client (`reqwest::RequestBuilder::json` calls
180    ///   `serde_json::to_vec` upfront), so the transient peak holds
181    ///   the source `HashMap`, the intermediate `serde_json::Value`
182    ///   tree, and the serialized `Vec<u8>` body simultaneously —
183    ///   roughly 3-4× the `HashMap`'s in-memory size. Callers dealing
184    ///   with thousands of patches per call may prefer to batch.
185    /// - Any transport / protocol variant returned by
186    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
187    ///   the matching error list on [`Self::email_get`].
188    pub async fn email_set(
189        &self,
190        create: Option<serde_json::Value>,
191        update: Option<HashMap<Id, PatchObject>>,
192        destroy: Option<Vec<Id>>,
193        if_in_state: Option<&State>,
194    ) -> Result<SetResponse<jmap_mail_types::Email>, jmap_base_client::ClientError> {
195        if create.is_none() && update.is_none() && destroy.is_none() {
196            return Err(jmap_base_client::ClientError::InvalidArgument(
197                "email_set: at least one of create, update, destroy must be Some \
198                 (an all-None /set is a no-op round-trip)"
199                    .into(),
200            ));
201        }
202        let (api_url, account_id) = self.session_parts()?;
203        let mut args = serde_json::json!({
204            "accountId": account_id,
205        });
206        if let Some(s) = if_in_state {
207            args["ifInState"] = s.as_ref().into();
208        }
209        if let Some(c) = create {
210            args["create"] = c;
211        }
212        if let Some(u) = update {
213            args["update"] = serde_json::to_value(&u).map_err(|e| {
214                jmap_base_client::ClientError::InvalidArgument(format!(
215                    "email_set: serializing update map failed: {e}"
216                ))
217            })?;
218        }
219        if let Some(d) = destroy {
220            args["destroy"] = serde_json::to_value(&d).expect("Id Vec Serialize is infallible");
221        }
222        let req = super::build_request("Email/set", args, super::USING_MAIL);
223        let resp = self.call_internal(api_url, &req).await?;
224        jmap_base_client::extract_response(&resp, super::CALL_ID)
225    }
226
227    /// Query Email IDs with optional filter and sort (RFC 8621 §4.4 — Email/query).
228    ///
229    /// Pass `filter: None` and `sort: None` to return all Emails with
230    /// server-default ordering. Use `position` and `limit` for pagination.
231    /// Pass `collapse_threads: Some(true)` to return at most one email per thread.
232    ///
233    /// # Numeric parameter spec magic-values (RFC 8620 §5.5)
234    ///
235    /// - `position: Some(0)` selects the first item (zero-indexed); the
236    ///   spec also accepts negative values, but `u64` does not represent
237    ///   them — pass `None` to omit and use the server default of `0`.
238    /// - `limit: Some(0)` is wire-legal but means "server's default
239    ///   cap", NOT "zero results"; the server is free to return its
240    ///   default page size. Pass `None` to omit (server applies its
241    ///   default), or `Some(n)` with `n > 0` for an explicit cap.
242    ///
243    /// # Errors
244    ///
245    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
246    ///   if the bound session has no primary account for
247    ///   `urn:ietf:params:jmap:mail`.
248    /// - Any transport / protocol variant returned by
249    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
250    ///   the matching error list on [`Self::email_get`]. RFC 8620 §5.5
251    ///   defines additional method-level errors specific to /query
252    ///   (`anchorNotFound`, `unsupportedFilter`, `unsupportedSort`,
253    ///   `tooManyChanges`); they surface here as
254    ///   [`MethodError`](jmap_base_client::ClientError::MethodError)
255    ///   with the corresponding `error_type` string.
256    pub async fn email_query(
257        &self,
258        filter: Option<serde_json::Value>,
259        sort: Option<serde_json::Value>,
260        position: Option<u64>,
261        limit: Option<u64>,
262        collapse_threads: Option<bool>,
263    ) -> Result<QueryResponse, jmap_base_client::ClientError> {
264        let (api_url, account_id) = self.session_parts()?;
265        let mut args = serde_json::json!({
266            "accountId": account_id,
267        });
268        if let Some(f) = filter {
269            args["filter"] = f;
270        }
271        if let Some(s) = sort {
272            args["sort"] = s;
273        }
274        if let Some(p) = position {
275            args["position"] = p.into();
276        }
277        if let Some(l) = limit {
278            args["limit"] = l.into();
279        }
280        if let Some(ct) = collapse_threads {
281            args["collapseThreads"] = ct.into();
282        }
283        let req = super::build_request("Email/query", args, super::USING_MAIL);
284        let resp = self.call_internal(api_url, &req).await?;
285        jmap_base_client::extract_response(&resp, super::CALL_ID)
286    }
287
288    /// Fetch query-result changes for Email since `since_query_state`
289    /// (RFC 8621 §4.5 — Email/queryChanges).
290    ///
291    /// `filter` and `sort` MUST match the `filter` / `sort` passed to the
292    /// original `Email/query` call that returned `since_query_state` —
293    /// RFC 8620 §5.6 is explicit that the server uses them to compute
294    /// which entries entered or left the result set. Omitting them when
295    /// the original query had a non-trivial filter or sort tells the
296    /// server "the original query had no filter and default sort", which
297    /// gives the wrong added/removed deltas (or `cannotCalculateChanges`).
298    ///
299    /// `up_to_id` is the highest-index id the client has cached
300    /// (RFC 8620 §5.6); the server may use it to omit changes past that
301    /// point when both `filter` and `sort` are on immutable properties.
302    ///
303    /// `calculate_total` requests the new total result count.
304    ///
305    /// `max_changes` follows the same magic-value semantics as
306    /// [`Self::email_changes`].
307    ///
308    /// # Errors
309    ///
310    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
311    ///   if `since_query_state` is the empty string (defence-in-depth
312    ///   empty-state guard; see [`Self::email_changes`]).
313    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
314    ///   if the bound session has no primary account for
315    ///   `urn:ietf:params:jmap:mail`.
316    /// - Any transport / protocol variant returned by
317    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
318    ///   the matching error list on [`Self::email_get`]. RFC 8620 §5.6
319    ///   also defines `cannotCalculateChanges` (returned when the server
320    ///   cannot honour the request given the supplied filter / sort);
321    ///   it surfaces as
322    ///   [`MethodError`](jmap_base_client::ClientError::MethodError).
323    #[allow(clippy::too_many_arguments)] // RFC 8620 §5.6 + RFC 8621 §4.5 args
324    pub async fn email_query_changes(
325        &self,
326        since_query_state: &State,
327        max_changes: Option<u64>,
328        collapse_threads: Option<bool>,
329        filter: Option<serde_json::Value>,
330        sort: Option<serde_json::Value>,
331        up_to_id: Option<&Id>,
332        calculate_total: Option<bool>,
333    ) -> Result<QueryChangesResponse, jmap_base_client::ClientError> {
334        // Defence-in-depth: see `thread_changes`.
335        if since_query_state.as_ref().is_empty() {
336            return Err(jmap_base_client::ClientError::InvalidArgument(
337                "email_query_changes: since_query_state may not be empty".into(),
338            ));
339        }
340        let (api_url, account_id) = self.session_parts()?;
341        let mut args = serde_json::json!({
342            "accountId": account_id,
343            "sinceQueryState": since_query_state,
344        });
345        if let Some(f) = filter {
346            args["filter"] = f;
347        }
348        if let Some(s) = sort {
349            args["sort"] = s;
350        }
351        if let Some(mc) = max_changes {
352            args["maxChanges"] = mc.into();
353        }
354        if let Some(uti) = up_to_id {
355            args["upToId"] = serde_json::to_value(uti).expect("Id Serialize is infallible");
356        }
357        if let Some(ct) = calculate_total {
358            args["calculateTotal"] = ct.into();
359        }
360        if let Some(ct) = collapse_threads {
361            args["collapseThreads"] = ct.into();
362        }
363        let req = super::build_request("Email/queryChanges", args, super::USING_MAIL);
364        let resp = self.call_internal(api_url, &req).await?;
365        jmap_base_client::extract_response(&resp, super::CALL_ID)
366    }
367
368    /// Copy Emails from another account (RFC 8621 §4.7 — Email/copy).
369    ///
370    /// `params` carries `fromAccountId` and optional destroy-after-copy flags.
371    /// `create` is a map of creation keys to partial Email objects (with new
372    /// mailboxIds etc.) as described in RFC 8621 §4.7.
373    ///
374    /// # Cross-account error space (RFC 8620 §5.4 + RFC 8621 §4.7)
375    ///
376    /// `Email/copy` is the ONLY method in this crate that crosses
377    /// account boundaries, and it has a method-specific error space
378    /// that ordinary `Email/set` does not. The following codes surface
379    /// as [`ClientError::MethodError`](jmap_base_client::ClientError::MethodError)
380    /// with the corresponding `error_type` string:
381    ///
382    /// - `fromAccountNotFound` — `from_account_id` is not an account
383    ///   the user can access.
384    /// - `fromAccountNotSupportedByMethod` — the source account does
385    ///   not support the JMAP Mail capability.
386    /// - `stateMismatch` — `destroy_from_if_in_state` did not match
387    ///   the current state of Email on the source account.
388    /// - `tooManyCopies` — server-defined cap on copies-per-request
389    ///   exceeded.
390    /// - `tooManyEmails` — server-defined cap on emails-per-copy
391    ///   exceeded.
392    /// - `anchorNotFound` — RFC 8620 §5.4 reserves this for
393    ///   `#`-prefixed result references that cannot be resolved.
394    ///
395    /// Per-entry failures (one per creation key in `create`) appear in
396    /// [`SetResponse::not_created`] as [`SetError`](super::SetError)
397    /// values, NOT as method-level errors. Common per-entry codes
398    /// include `invalidProperties`, `notFound` (when the source email
399    /// id does not exist), `overQuota`, and `alreadyExists`.
400    ///
401    /// # Errors
402    ///
403    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
404    ///   if the bound session has no primary account for
405    ///   `urn:ietf:params:jmap:mail`. (The destination account is the
406    ///   session's primary mail account; the source account is the
407    ///   `from_account_id` argument.)
408    /// - Any transport / protocol variant returned by
409    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
410    ///   the matching error list on [`Self::email_get`].
411    /// - The /copy-specific method-level errors listed in the
412    ///   "Cross-account error space" section above, surfaced as
413    ///   [`MethodError`](jmap_base_client::ClientError::MethodError).
414    pub async fn email_copy(
415        &self,
416        params: EmailCopyParams,
417        create: serde_json::Value,
418    ) -> Result<SetResponse<jmap_mail_types::Email>, jmap_base_client::ClientError> {
419        let (api_url, account_id) = self.session_parts()?;
420        let mut args = serde_json::json!({
421            "accountId": account_id,
422            "fromAccountId": params.from_account_id,
423            "create": create,
424        });
425        if let Some(v) = params.on_success_destroy_original {
426            args["onSuccessDestroyOriginal"] = v.into();
427        }
428        if let Some(v) = params.destroy_from_if_in_state {
429            args["destroyFromIfInState"] = v.as_ref().into();
430        }
431        // Route caller-supplied vendor extras onto the wire (workspace
432        // extras-preservation policy). Use `entry().or_insert()` so a
433        // caller who put a typed wire key into `params.extra` cannot
434        // silently clobber the typed value — typed wins on collision.
435        if !params.extra.is_empty() {
436            let args_obj = args
437                .as_object_mut()
438                .expect("email_copy: args is constructed as Object");
439            for (k, v) in params.extra {
440                args_obj.entry(k).or_insert(v);
441            }
442        }
443        let req = super::build_request("Email/copy", args, super::USING_MAIL);
444        let resp = self.call_internal(api_url, &req).await?;
445        jmap_base_client::extract_response(&resp, super::CALL_ID)
446    }
447
448    /// Import raw RFC 5322 messages into the account (RFC 8621 §4.8 — Email/import).
449    ///
450    /// Each entry in `emails` maps a caller-chosen creation id to an
451    /// [`EmailImportInput`] referencing a previously uploaded blob and the
452    /// target mailbox(es). The blob must have been uploaded via the standard
453    /// blob-upload mechanism on `jmap-base-client` before calling this method.
454    ///
455    /// At least one mailbox id is required per RFC 8621 §4.8; the empty-set
456    /// case is rejected client-side as `InvalidArgument`. An empty `emails`
457    /// map is also rejected — a no-op import is never useful and would
458    /// generate a round-trip with no successful creations.
459    ///
460    /// Pass `if_in_state: Some(&state)` for an optimistic-concurrency guard
461    /// against the Email object state (RFC 8621 §4.8 returns `stateMismatch`
462    /// if the supplied state does not match).
463    ///
464    /// Per-creation failures appear in [`EmailImportResponse::not_created`]
465    /// as [`super::SetError`] values; possible error codes include `alreadyExists`
466    /// (with an `existingId` extra field), `invalidProperties`, `overQuota`,
467    /// and `invalidEmail`.
468    ///
469    /// # Errors
470    ///
471    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
472    ///   if `emails` is empty, if any entry's `mailbox_ids` is empty
473    ///   (RFC 8621 §4.8 requires at least one mailbox per import), or
474    ///   if serializing the `emails` map fails (pathological conditions
475    ///   only — allocation failure, or a vendor value in
476    ///   `EmailImportInput.extra` that itself fails to serialize).
477    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
478    ///   if the bound session has no primary account for
479    ///   `urn:ietf:params:jmap:mail`.
480    /// - Any transport / protocol variant returned by
481    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
482    ///   the matching error list on [`Self::email_get`]. RFC 8621 §4.8
483    ///   defines `maxQuotaReached`, `fromAccountNotFound`, and
484    ///   `stateMismatch` (the last when `if_in_state` does not match);
485    ///   they surface as
486    ///   [`MethodError`](jmap_base_client::ClientError::MethodError).
487    ///   Per-creation failures (e.g. `alreadyExists`, `invalidEmail`)
488    ///   do NOT surface as `Err` — they appear in
489    ///   [`EmailImportResponse::not_created`].
490    pub async fn email_import(
491        &self,
492        emails: &HashMap<String, EmailImportInput<'_>>,
493        if_in_state: Option<&State>,
494    ) -> Result<EmailImportResponse, jmap_base_client::ClientError> {
495        if emails.is_empty() {
496            return Err(jmap_base_client::ClientError::InvalidArgument(
497                "email_import: emails map may not be empty".into(),
498            ));
499        }
500        for (key, input) in emails {
501            if input.mailbox_ids.is_empty() {
502                return Err(jmap_base_client::ClientError::InvalidArgument(format!(
503                    "email_import: mailboxIds for creation id '{key}' may not be empty (RFC 8621 §4.8)"
504                )));
505            }
506        }
507        let (api_url, account_id) = self.session_parts()?;
508        let emails_value = serde_json::to_value(emails).map_err(|e| {
509            jmap_base_client::ClientError::InvalidArgument(format!(
510                "email_import: serializing emails map failed: {e}"
511            ))
512        })?;
513        let mut args = serde_json::json!({
514            "accountId": account_id,
515            "emails": emails_value,
516        });
517        if let Some(s) = if_in_state {
518            args["ifInState"] = s.as_ref().into();
519        }
520        let req = super::build_request("Email/import", args, super::USING_MAIL);
521        let resp = self.call_internal(api_url, &req).await?;
522        jmap_base_client::extract_response(&resp, super::CALL_ID)
523    }
524
525    /// Parse uploaded blobs as RFC 5322 messages without importing them
526    /// (RFC 8621 §4.9 — Email/parse).
527    ///
528    /// Returns Email objects derived from each blob. Per RFC 8621 §4.9 the
529    /// returned Emails have `id`, `mailboxIds`, `keywords`, and `receivedAt`
530    /// set to `null` (the messages are not in the mail store); `threadId`
531    /// MAY be present if the server can predict the assignment.
532    ///
533    /// Pass `params: None` to use server defaults for properties and body
534    /// fetching. The set of properties returned defaults to the spec-listed
535    /// header/body fields documented in RFC 8621 §4.9.
536    ///
537    /// Empty `blob_ids` is rejected as `InvalidArgument` — a no-op parse
538    /// round-trip is never useful.
539    ///
540    /// # Errors
541    ///
542    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
543    ///   if `blob_ids` is empty, or if `params` is `Some` and serializing
544    ///   it to JSON fails (pathological conditions only — allocation
545    ///   failure, or a vendor value in `params.extra` that itself fails
546    ///   to serialize).
547    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
548    ///   if the bound session has no primary account for
549    ///   `urn:ietf:params:jmap:mail`.
550    /// - Any transport / protocol variant returned by
551    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
552    ///   the matching error list on [`Self::email_get`].
553    pub async fn email_parse(
554        &self,
555        blob_ids: &[Id],
556        params: Option<EmailParseParams>,
557    ) -> Result<EmailParseResponse, jmap_base_client::ClientError> {
558        if blob_ids.is_empty() {
559            return Err(jmap_base_client::ClientError::InvalidArgument(
560                "email_parse: blob_ids may not be empty".into(),
561            ));
562        }
563        let (api_url, account_id) = self.session_parts()?;
564        let mut args = serde_json::json!({
565            "accountId": account_id,
566            "blobIds": blob_ids,
567        });
568        if let Some(p) = params {
569            let pv = serde_json::to_value(&p).map_err(|e| {
570                jmap_base_client::ClientError::InvalidArgument(format!(
571                    "email_parse: failed to serialize params: {e}"
572                ))
573            })?;
574            if let serde_json::Value::Object(map) = pv {
575                // Use `entry().or_insert()` so a caller who put a typed
576                // wire key (e.g. "accountId", "blobIds", "properties")
577                // into `params.extra` cannot silently clobber the value
578                // computed from the bound session and the typed args.
579                // Typed wins on collision.
580                let args_obj = args
581                    .as_object_mut()
582                    .expect("email_parse: args is constructed as Object");
583                for (k, v) in map {
584                    args_obj.entry(k).or_insert(v);
585                }
586            }
587        }
588        let req = super::build_request("Email/parse", args, super::USING_MAIL);
589        let resp = self.call_internal(api_url, &req).await?;
590        jmap_base_client::extract_response(&resp, super::CALL_ID)
591    }
592}
593
594// ---------------------------------------------------------------------------
595// Tests
596// ---------------------------------------------------------------------------
597
598#[cfg(test)]
599mod tests {
600    use serde_json::json;
601
602    // email_get_empty_id_returns_invalid_argument was deleted in JMAP-6by7.2
603    // (typed-Id refactor): under `Option<&[Id]>` the empty-Id case becomes
604    // impossible to express through the typed API.
605
606    // The InvalidArgument guards for empty since_state and since_query_state
607    // live in email_changes / email_query_changes production code; testing them
608    // requires a wiremock-backed async harness. See JMAP-sc1b.64.
609
610    // Deleted in JMAP-tco1.5 as Pattern E (vacuous inline tests):
611    //   - email_get_request_shape
612    //   - email_changes_request_includes_since_state
613    //   - email_set_destroy_request_shape
614    //   - email_copy_request_shape
615    //   - email_query_request_shape
616    // Each hand-built `args = json!({...})` and fed it to `build_request`,
617    // never invoking the `email_get` / `email_changes` / `email_set` /
618    // `email_copy` / `email_query` production builders. Real production-path
619    // coverage for these methods is tracked as a wiremock-smoke gap under
620    // JMAP-uuoi (no `tests/email_*.rs` smoke files exist yet).
621    //
622    // `build_request`, `CALL_ID`, and `USING_MAIL` themselves have their
623    // own focused tests in `methods/mod.rs`.
624
625    /// Oracle: Email deserialization from RFC 8621 §4 example JSON subset.
626    /// Only fields present in the fixture are checked; Email has many optional fields.
627    #[test]
628    fn email_get_response_deserializes() {
629        let json = json!({
630            "accountId": "acc1",
631            "state": "s10",
632            "list": [
633                {
634                    "id": "e1",
635                    "blobId": "b1",
636                    "threadId": "t1",
637                    "mailboxIds": { "mb1": true },
638                    "keywords": { "$seen": true },
639                    "size": 1024,
640                    "receivedAt": "2024-01-01T00:00:00Z"
641                }
642            ],
643            "notFound": []
644        });
645        use super::super::GetResponse;
646        let resp: GetResponse<jmap_mail_types::Email> =
647            serde_json::from_value(json).expect("must deserialize Email GetResponse");
648        assert_eq!(resp.list.len(), 1);
649        assert_eq!(resp.list[0].id.as_ref(), "e1");
650    }
651}