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}