Skip to main content

jmap_mail_client/methods/
mod.rs

1//! Typed JMAP Mail method wrappers — response types, SessionClient,
2//! constants, and helpers.
3//!
4//! Response types mirror RFC 8620 standard shapes (§5.1 /get, §5.5 /query,
5//! §5.2 /changes, §5.3 /set). Method implementations live in sub-modules and
6//! operate on `SessionClient`.
7
8pub mod email;
9pub mod identity;
10pub mod mailbox;
11pub mod search_snippet;
12pub mod submission;
13pub mod thread;
14pub mod vacation;
15
16use std::collections::HashMap;
17
18use jmap_types::{Id, State};
19
20// ---------------------------------------------------------------------------
21// Response types (RFC 8620 §5)
22// ---------------------------------------------------------------------------
23//
24// Re-exported from `jmap-types::methods` so all `jmap-*-client` crates share
25// one canonical set of /get, /set, /changes, /query, /queryChanges shapes.
26// The wire format is identical to the previous local definitions.
27
28pub use jmap_types::{
29    AddedItem, ChangesResponse, GetResponse, QueryChangesResponse, QueryResponse, SetError,
30    SetResponse,
31};
32
33// ---------------------------------------------------------------------------
34// Input parameter types (RFC 8621 method-specific args)
35// ---------------------------------------------------------------------------
36
37/// Extra args for Email/get (RFC 8621 §4.1.8).
38///
39/// Controls which body properties to fetch and whether to inline body values.
40#[derive(Debug, Default, Clone, serde::Serialize)]
41#[serde(rename_all = "camelCase")]
42pub struct EmailGetParams {
43    /// Override the set of body part properties returned (RFC 8621 §4.1.8).
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub body_properties: Option<Vec<String>>,
46    /// If `true`, inline values for text/plain body parts (RFC 8621 §4.1.8).
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub fetch_text_body_values: Option<bool>,
49    /// If `true`, inline values for text/html body parts (RFC 8621 §4.1.8).
50    ///
51    /// Wire name is `fetchHTMLBodyValues` (HTML uppercase) per RFC 8621
52    /// §4.2 line 2327 and the §4.2 example at line 2438. The default
53    /// camelCase serde rename would produce `fetchHtmlBodyValues`, which
54    /// a strict server treats as an unknown invocation argument and
55    /// silently drops, causing HTML body values to never be inlined.
56    #[serde(
57        rename = "fetchHTMLBodyValues",
58        skip_serializing_if = "Option::is_none"
59    )]
60    pub fetch_html_body_values: Option<bool>,
61    /// If `true`, inline values for all body parts (RFC 8621 §4.1.8).
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub fetch_all_body_values: Option<bool>,
64    /// Truncate body values to at most this many octets (RFC 8621 §4.1.8).
65    ///
66    /// When set, each returned `EmailBodyValue` whose UTF-8 byte length
67    /// exceeds this limit is truncated; the truncation point is rounded
68    /// down to a UTF-8 character boundary so the returned string is
69    /// always valid UTF-8 (trailing bytes that would result in an
70    /// incomplete code point are dropped). Consequently the actual
71    /// returned length may be a few octets short of this limit when
72    /// the cut would fall inside a multi-byte UTF-8 sequence.
73    ///
74    /// Truncated `EmailBodyValue` objects have their `isTruncated`
75    /// property set to `true`. Callers that need the full body of a
76    /// truncated value should download the raw blob via
77    /// [`JmapClient::download_blob`](jmap_base_client::JmapClient::download_blob).
78    ///
79    /// Spec magic-value: `Some(0)` is equivalent to omitting the field —
80    /// RFC 8621 §4.1.8 says "If positive, ..." which means the truncation
81    /// only applies for strictly-positive values. Pass `None` to omit
82    /// the wire field entirely (server uses its default policy), or
83    /// `Some(n)` with `n > 0` for an explicit truncation cap. Passing
84    /// `Some(0)` is wire-legal but semantically identical to omission
85    /// — prefer `None` for clarity.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub max_body_value_bytes: Option<u64>,
88    /// Catch-all for vendor / site / private extension fields not covered
89    /// by the typed fields above. Preserves unknown fields across
90    /// deserialize/serialize round-trip per workspace extras-preservation
91    /// policy (see workspace AGENTS.md).
92    ///
93    /// **Constraint**: keys in `extra` MUST NOT collide with the
94    /// typed-field wire names above (the camelCase spelling — e.g.
95    /// `"accountId"`, `"ids"`, `"properties"`, `"blobIds"`,
96    /// `"fromAccountId"`, etc.). On collision the typed-field value
97    /// wins on the wire and the `extra` value is silently dropped at
98    /// serialization. Place vendor extensions under vendor-prefixed
99    /// keys (e.g. `"acmeCorpFoo"`) to avoid the collision class.
100    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
101    pub extra: serde_json::Map<String, serde_json::Value>,
102}
103
104/// Extra args for Email/copy (RFC 8621 §4.7).
105#[derive(Debug, Clone, serde::Serialize)]
106#[serde(rename_all = "camelCase")]
107pub struct EmailCopyParams {
108    /// The account to copy from (RFC 8621 §4.7).
109    pub from_account_id: Id,
110    /// If `true`, destroy originals after successful copy (RFC 8620 §5.4).
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub on_success_destroy_original: Option<bool>,
113    /// If-in-state guard for the source account destroy step (RFC 8620 §5.4).
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub destroy_from_if_in_state: Option<jmap_types::State>,
116    /// Catch-all for vendor / site / private extension fields not covered
117    /// by the typed fields above. Preserves unknown fields across
118    /// deserialize/serialize round-trip per workspace extras-preservation
119    /// policy (see workspace AGENTS.md).
120    ///
121    /// **Constraint**: keys in `extra` MUST NOT collide with the
122    /// typed-field wire names above (the camelCase spelling — e.g.
123    /// `"accountId"`, `"ids"`, `"properties"`, `"blobIds"`,
124    /// `"fromAccountId"`, etc.). On collision the typed-field value
125    /// wins on the wire and the `extra` value is silently dropped at
126    /// serialization. Place vendor extensions under vendor-prefixed
127    /// keys (e.g. `"acmeCorpFoo"`) to avoid the collision class.
128    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
129    pub extra: serde_json::Map<String, serde_json::Value>,
130}
131
132/// Extra args for Mailbox/set (RFC 8621 §2.5).
133#[derive(Debug, Default, Clone, serde::Serialize)]
134#[serde(rename_all = "camelCase")]
135pub struct MailboxSetParams {
136    /// If `true`, destroy all emails in the mailbox when the mailbox itself is
137    /// destroyed (RFC 8621 §2.5).
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub on_destroy_remove_emails: Option<bool>,
140    /// Catch-all for vendor / site / private extension fields not covered
141    /// by the typed fields above. Preserves unknown fields across
142    /// deserialize/serialize round-trip per workspace extras-preservation
143    /// policy (see workspace AGENTS.md).
144    ///
145    /// **Constraint**: keys in `extra` MUST NOT collide with the
146    /// typed-field wire names above (the camelCase spelling — e.g.
147    /// `"accountId"`, `"ids"`, `"properties"`, `"blobIds"`,
148    /// `"fromAccountId"`, etc.). On collision the typed-field value
149    /// wins on the wire and the `extra` value is silently dropped at
150    /// serialization. Place vendor extensions under vendor-prefixed
151    /// keys (e.g. `"acmeCorpFoo"`) to avoid the collision class.
152    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
153    pub extra: serde_json::Map<String, serde_json::Value>,
154}
155
156/// Extra args for EmailSubmission/set (RFC 8621 §7.5).
157///
158/// These two fields are method-level arguments on `EmailSubmission/set` (not
159/// nested inside a create/update object). They let the caller atomically
160/// modify or destroy related Email objects when a submission is created
161/// successfully, without a separate round-trip.
162///
163/// Example use case: remove the `$draft` keyword from the email after
164/// submission succeeds.
165#[derive(Debug, Default, Clone, serde::Serialize)]
166#[serde(rename_all = "camelCase")]
167pub struct EmailSubmissionSetParams {
168    /// Map of creation key → [`jmap_types::PatchObject`] to apply to the
169    /// associated Email if the submission is created successfully
170    /// (RFC 8621 §7.5).
171    ///
172    /// Keys that start with `"#"` are result references to creation keys in
173    /// the same `create` map. Wire format is unchanged from a plain JSON
174    /// object because `PatchObject` is `#[serde(transparent)]`.
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub on_success_update_email: Option<HashMap<String, jmap_types::PatchObject>>,
177
178    /// Email IDs (or `#`-prefixed creation keys) to destroy if the submission
179    /// is created successfully (RFC 8621 §7.5).
180    ///
181    /// Typically used to destroy the draft email after successful submission.
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub on_success_destroy_email: Option<Vec<String>>,
184
185    /// Catch-all for vendor / site / private extension fields not covered
186    /// by the typed fields above. Preserves unknown fields across
187    /// deserialize/serialize round-trip per workspace extras-preservation
188    /// policy (see workspace AGENTS.md).
189    ///
190    /// **Constraint**: keys in `extra` MUST NOT collide with the
191    /// typed-field wire names above (the camelCase spelling — e.g.
192    /// `"accountId"`, `"ids"`, `"properties"`, `"blobIds"`,
193    /// `"fromAccountId"`, etc.). On collision the typed-field value
194    /// wins on the wire and the `extra` value is silently dropped at
195    /// serialization. Place vendor extensions under vendor-prefixed
196    /// keys (e.g. `"acmeCorpFoo"`) to avoid the collision class.
197    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
198    pub extra: serde_json::Map<String, serde_json::Value>,
199}
200
201/// Per-creation EmailImport object for [`Email/import`](super::SessionClient::email_import)
202/// (RFC 8621 §4.8).
203///
204/// The raw [RFC 5322] message must already have been uploaded as a blob; the
205/// caller passes that blob's id here. At least one mailbox id MUST be
206/// supplied; the empty case is rejected by `email_import` as `InvalidArgument`.
207///
208/// [RFC 5322]: https://www.rfc-editor.org/rfc/rfc5322
209#[derive(Debug, Clone, serde::Serialize)]
210#[serde(rename_all = "camelCase")]
211pub struct EmailImportInput<'a> {
212    /// Blob id of the uploaded raw RFC 5322 message.
213    pub blob_id: &'a Id,
214    /// Mailbox ids the new Email is assigned to. Wire shape is `{id: true}`;
215    /// callers supply the set as a slice. At least one id is required (RFC 8621 §4.8).
216    #[serde(serialize_with = "ser_mailbox_id_set")]
217    pub mailbox_ids: &'a [Id],
218    /// Keywords to apply to the Email. Wire shape is `{keyword: true}`.
219    /// Defaults to an empty map per RFC 8621 §4.8 when `None`.
220    #[serde(
221        skip_serializing_if = "Option::is_none",
222        serialize_with = "ser_opt_keyword_set"
223    )]
224    pub keywords: Option<&'a [&'a str]>,
225    /// `receivedAt` to set on the imported Email. Defaults to the most recent
226    /// `Received` header or import time per RFC 8621 §4.8 when `None`.
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub received_at: Option<&'a jmap_types::UTCDate>,
229    /// Catch-all for vendor / site / private extension fields not covered
230    /// by the typed fields above. Preserves unknown fields across
231    /// deserialize/serialize round-trip per workspace extras-preservation
232    /// policy (see workspace AGENTS.md).
233    ///
234    /// **Constraint**: keys in `extra` MUST NOT collide with the
235    /// typed-field wire names above (the camelCase spelling — e.g.
236    /// `"accountId"`, `"ids"`, `"properties"`, `"blobIds"`,
237    /// `"fromAccountId"`, etc.). On collision the typed-field value
238    /// wins on the wire and the `extra` value is silently dropped at
239    /// serialization. Place vendor extensions under vendor-prefixed
240    /// keys (e.g. `"acmeCorpFoo"`) to avoid the collision class.
241    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
242    pub extra: serde_json::Map<String, serde_json::Value>,
243}
244
245fn ser_mailbox_id_set<S: serde::Serializer>(ids: &&[Id], s: S) -> Result<S::Ok, S::Error> {
246    use serde::ser::SerializeMap;
247    let mut m = s.serialize_map(Some(ids.len()))?;
248    for id in *ids {
249        m.serialize_entry(id.as_ref(), &true)?;
250    }
251    m.end()
252}
253
254fn ser_opt_keyword_set<S: serde::Serializer>(
255    kws: &Option<&[&str]>,
256    s: S,
257) -> Result<S::Ok, S::Error> {
258    use serde::ser::SerializeMap;
259    let kws = kws.expect("skip_serializing_if guarantees Some");
260    let mut m = s.serialize_map(Some(kws.len()))?;
261    for k in kws {
262        m.serialize_entry(k, &true)?;
263    }
264    m.end()
265}
266
267/// Per-creation success entry in an [`EmailImportResponse`] (RFC 8621 §4.8).
268///
269/// The server reports the new Email's `id`, `blobId` (may differ from the
270/// caller-supplied blob id if the server normalised the message), `threadId`,
271/// and `size` for each successfully imported message.
272#[non_exhaustive]
273#[derive(Debug, Clone, serde::Deserialize)]
274#[serde(rename_all = "camelCase")]
275pub struct EmailImportCreated {
276    /// Server-assigned Email id.
277    pub id: Id,
278    /// Blob id of the canonical raw message (may differ from the input blob id).
279    pub blob_id: Id,
280    /// Server-assigned Thread id this Email belongs to.
281    pub thread_id: Id,
282    /// Size of the canonical raw message in bytes.
283    pub size: u64,
284    /// Catch-all for vendor / site / private extension fields not covered
285    /// by the typed fields above. Preserves unknown fields across
286    /// deserialize/serialize round-trip per workspace extras-preservation
287    /// policy (see workspace AGENTS.md).
288    ///
289    /// **Constraint**: keys in `extra` MUST NOT collide with the
290    /// typed-field wire names above (the camelCase spelling — e.g.
291    /// `"accountId"`, `"ids"`, `"properties"`, `"blobIds"`,
292    /// `"fromAccountId"`, etc.). On collision the typed-field value
293    /// wins on the wire and the `extra` value is silently dropped at
294    /// serialization. Place vendor extensions under vendor-prefixed
295    /// keys (e.g. `"acmeCorpFoo"`) to avoid the collision class.
296    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
297    pub extra: serde_json::Map<String, serde_json::Value>,
298}
299
300/// Response to [`Email/import`](super::SessionClient::email_import) (RFC 8621 §4.8).
301#[non_exhaustive]
302#[derive(Debug, Clone, serde::Deserialize)]
303#[serde(rename_all = "camelCase")]
304pub struct EmailImportResponse {
305    /// The account this response refers to.
306    pub account_id: Id,
307    /// State token before the import, or `null` if the server cannot supply one.
308    #[serde(default)]
309    pub old_state: Option<State>,
310    /// State token after the import.
311    pub new_state: State,
312    /// Successfully imported Emails keyed by creation id.
313    #[serde(default)]
314    pub created: Option<HashMap<String, EmailImportCreated>>,
315    /// Failures keyed by creation id (RFC 8621 §4.8 errors include
316    /// `alreadyExists`, `invalidProperties`, `overQuota`, `invalidEmail`).
317    #[serde(default)]
318    pub not_created: Option<HashMap<String, SetError>>,
319    /// Catch-all for vendor / site / private extension fields not covered
320    /// by the typed fields above. Preserves unknown fields across
321    /// deserialize/serialize round-trip per workspace extras-preservation
322    /// policy (see workspace AGENTS.md).
323    ///
324    /// **Constraint**: keys in `extra` MUST NOT collide with the
325    /// typed-field wire names above (the camelCase spelling — e.g.
326    /// `"accountId"`, `"ids"`, `"properties"`, `"blobIds"`,
327    /// `"fromAccountId"`, etc.). On collision the typed-field value
328    /// wins on the wire and the `extra` value is silently dropped at
329    /// serialization. Place vendor extensions under vendor-prefixed
330    /// keys (e.g. `"acmeCorpFoo"`) to avoid the collision class.
331    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
332    pub extra: serde_json::Map<String, serde_json::Value>,
333}
334
335/// Extra args for [`Email/parse`](super::SessionClient::email_parse) (RFC 8621 §4.9).
336///
337/// Mirrors the body-fetch options of [`EmailGetParams`] plus a `properties`
338/// override. All fields are optional; absent fields use server defaults.
339#[derive(Debug, Default, Clone, serde::Serialize)]
340#[serde(rename_all = "camelCase")]
341pub struct EmailParseParams {
342    /// Override the set of Email properties returned per parsed message
343    /// (RFC 8621 §4.9). When `None`, the server returns the default set
344    /// documented in the spec.
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub properties: Option<Vec<String>>,
347    /// Override the set of body-part properties returned (RFC 8621 §4.9).
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub body_properties: Option<Vec<String>>,
350    /// If `true`, inline values for text/plain body parts (RFC 8621 §4.9).
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub fetch_text_body_values: Option<bool>,
353    /// If `true`, inline values for text/html body parts (RFC 8621 §4.9).
354    ///
355    /// Wire name is `fetchHTMLBodyValues` (HTML uppercase) per RFC 8621
356    /// §4.9 line 3163. The default camelCase serde rename would produce
357    /// `fetchHtmlBodyValues`, which a strict server treats as an unknown
358    /// invocation argument and silently drops.
359    #[serde(
360        rename = "fetchHTMLBodyValues",
361        skip_serializing_if = "Option::is_none"
362    )]
363    pub fetch_html_body_values: Option<bool>,
364    /// If `true`, inline values for all body parts (RFC 8621 §4.9).
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub fetch_all_body_values: Option<bool>,
367    /// Truncate body values to at most this many octets (RFC 8621 §4.9).
368    ///
369    /// `Email/parse` reuses the same truncation contract as
370    /// [`EmailGetParams::max_body_value_bytes`]: the truncation point
371    /// is rounded down to a UTF-8 character boundary so the returned
372    /// string is always valid UTF-8, and truncated `EmailBodyValue`
373    /// objects have `isTruncated` set to `true`. Callers that need
374    /// the full body of a truncated value should download the raw
375    /// blob via
376    /// [`JmapClient::download_blob`](jmap_base_client::JmapClient::download_blob).
377    ///
378    /// Spec magic-value: `Some(0)` is equivalent to omitting the field
379    /// per RFC 8621 §4.9 ("If positive, ..."). Pass `None` to omit the
380    /// wire field entirely (server uses its default policy), or
381    /// `Some(n)` with `n > 0` for an explicit truncation cap.
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub max_body_value_bytes: Option<u64>,
384    /// Catch-all for vendor / site / private extension fields not covered
385    /// by the typed fields above. Preserves unknown fields across
386    /// deserialize/serialize round-trip per workspace extras-preservation
387    /// policy (see workspace AGENTS.md).
388    ///
389    /// **Constraint**: keys in `extra` MUST NOT collide with the
390    /// typed-field wire names above (the camelCase spelling — e.g.
391    /// `"accountId"`, `"ids"`, `"properties"`, `"blobIds"`,
392    /// `"fromAccountId"`, etc.). On collision the typed-field value
393    /// wins on the wire and the `extra` value is silently dropped at
394    /// serialization. Place vendor extensions under vendor-prefixed
395    /// keys (e.g. `"acmeCorpFoo"`) to avoid the collision class.
396    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
397    pub extra: serde_json::Map<String, serde_json::Value>,
398}
399
400/// Response to [`Email/parse`](super::SessionClient::email_parse) (RFC 8621 §4.9).
401///
402/// Per RFC 8621 §4.9, parsed Email objects have `id`, `mailboxIds`,
403/// `keywords`, and `receivedAt` set to `null`; callers should not rely on
404/// those fields being populated.
405#[non_exhaustive]
406#[derive(Debug, Clone, serde::Deserialize)]
407#[serde(rename_all = "camelCase")]
408pub struct EmailParseResponse {
409    /// The account this response refers to.
410    pub account_id: Id,
411    /// Parsed Emails keyed by source blob id.
412    #[serde(default)]
413    pub parsed: Option<HashMap<Id, jmap_mail_types::Email>>,
414    /// Blob ids whose contents were not parseable as RFC 5322 messages.
415    #[serde(default)]
416    pub not_parsable: Option<Vec<Id>>,
417    /// Blob ids that could not be found in the account's blob store.
418    #[serde(default)]
419    pub not_found: Option<Vec<Id>>,
420    /// Catch-all for vendor / site / private extension fields not covered
421    /// by the typed fields above. Preserves unknown fields across
422    /// deserialize/serialize round-trip per workspace extras-preservation
423    /// policy (see workspace AGENTS.md).
424    ///
425    /// **Constraint**: keys in `extra` MUST NOT collide with the
426    /// typed-field wire names above (the camelCase spelling — e.g.
427    /// `"accountId"`, `"ids"`, `"properties"`, `"blobIds"`,
428    /// `"fromAccountId"`, etc.). On collision the typed-field value
429    /// wins on the wire and the `extra` value is silently dropped at
430    /// serialization. Place vendor extensions under vendor-prefixed
431    /// keys (e.g. `"acmeCorpFoo"`) to avoid the collision class.
432    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
433    pub extra: serde_json::Map<String, serde_json::Value>,
434}
435
436// ---------------------------------------------------------------------------
437// Constants
438// ---------------------------------------------------------------------------
439
440/// The call-id embedded in every single-method JMAP request produced by
441/// [`build_request`]. Pass directly to `jmap_base_client::extract_response`.
442pub(crate) const CALL_ID: &str = "r1";
443
444/// Capability URIs for JMAP Mail method calls (RFC 8621 §1.3.1).
445///
446/// Use for Email/*, Mailbox/*, Thread/*, and SearchSnippet/* methods —
447/// i.e. anything covered by `urn:ietf:params:jmap:mail`.
448pub(crate) const USING_MAIL: &[&str] =
449    &["urn:ietf:params:jmap:core", jmap_mail_types::JMAP_MAIL_URI];
450
451/// Capability URIs for JMAP Mail Submission method calls (RFC 8621 §1.3.2).
452///
453/// Use for Identity/* and EmailSubmission/* methods. Includes
454/// `urn:ietf:params:jmap:mail` because EmailSubmission references
455/// emailId / threadId (mail-typed) and the onSuccessUpdateEmail /
456/// onSuccessDestroyEmail mechanism produces implicit Email/set or
457/// Email/destroy invocations on the same request.
458pub(crate) const USING_SUBMISSION: &[&str] = &[
459    "urn:ietf:params:jmap:core",
460    jmap_mail_types::JMAP_MAIL_URI,
461    jmap_mail_types::JMAP_SUBMISSION_URI,
462];
463
464/// Capability URIs for JMAP VacationResponse method calls (RFC 8621 §1.3.3).
465///
466/// Use for VacationResponse/* methods. Does NOT include
467/// `urn:ietf:params:jmap:mail` — vacation responses do not reference any
468/// mail-typed fields and stand on their own as a vacation-protocol object.
469pub(crate) const USING_VACATION: &[&str] = &[
470    "urn:ietf:params:jmap:core",
471    jmap_mail_types::JMAP_VACATIONRESPONSE_URI,
472];
473
474// ---------------------------------------------------------------------------
475// build_request helper
476// ---------------------------------------------------------------------------
477
478/// Build a single-method JMAP request.
479///
480/// `using` is the complete `using` array for the request (RFC 8620 §3.3).
481/// Use the pre-defined constant [`USING_MAIL`] for standard calls.
482///
483/// The embedded call-id is [`CALL_ID`]; pass it directly to
484/// `jmap_base_client::extract_response`.
485pub(crate) fn build_request(
486    method: &str,
487    args: serde_json::Value,
488    using: &[&str],
489) -> jmap_types::JmapRequest {
490    let using_vec: Vec<String> = using.iter().map(|&s| s.to_owned()).collect();
491    let invocation: jmap_types::Invocation = (method.to_owned(), args, CALL_ID.to_owned());
492    jmap_types::JmapRequest::new(using_vec, vec![invocation], None)
493}
494
495// ---------------------------------------------------------------------------
496// SessionClient — session-bound client
497// ---------------------------------------------------------------------------
498
499/// A `JmapClient` bound to a JMAP session.
500///
501/// Obtain via [`JmapMailExt::with_mail_session`](crate::JmapMailExt::with_mail_session).
502/// All JMAP Mail methods are available on this type without needing to pass
503/// `&Session` on every call.
504///
505/// # Session lifecycle
506///
507/// `SessionClient` captures the `Session` at construction time. After
508/// re-fetching the session via `JmapClient::fetch_session`, construct a new
509/// `SessionClient` with the updated session. Reusing a stale `SessionClient`
510/// after session expiry will result in `unknownAccount` or similar errors
511/// from the server.
512///
513/// `Clone` is derived because `JmapClient` is itself cheap-to-clone (it
514/// already implements `Clone` and `with_mail_session` clones one
515/// internally), enabling parallel-task fan-out with one bound session.
516///
517/// `Debug` is implemented manually to redact the inner `JmapClient` (which
518/// holds an HTTP client and is intentionally not `Debug` in
519/// `jmap-base-client`); only the `Session` is shown. This lets callers
520/// embed a `SessionClient` in a `#[derive(Debug)]` struct without manual
521/// impls of their own.
522///
523/// # Thread safety
524///
525/// `SessionClient` is `Send + Sync`. Both
526/// [`jmap_base_client::JmapClient`] (backed by `reqwest::Client`) and
527/// [`jmap_base_client::Session`] (plain serde-derived data) are
528/// `Send + Sync` per jmap-base-client's contract, so this type can be
529/// shared across async tasks via `Arc<SessionClient>` or cloned for
530/// per-task ownership (`Clone` is cheap — see above).
531///
532/// A `Send + Sync` regression in a future jmap-base-client release
533/// would be a major-version-breaking change for this crate. A
534/// compile-time assertion in `tests/` guards against the regression
535/// landing silently — see `_assert_session_client_send_sync` in
536/// `methods/mod.rs`.
537#[non_exhaustive]
538#[derive(Clone)]
539pub struct SessionClient {
540    pub(crate) client: jmap_base_client::JmapClient,
541    pub(crate) session: jmap_base_client::Session,
542}
543
544impl std::fmt::Debug for SessionClient {
545    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
546        f.debug_struct("SessionClient")
547            // The inner JmapClient is not Debug — show a placeholder so
548            // callers know it is present without leaking HTTP-client
549            // internals.
550            .field("client", &"<JmapClient>")
551            .field("session", &self.session)
552            .finish()
553    }
554}
555
556impl SessionClient {
557    /// Borrow the underlying [`JmapClient`](jmap_base_client::JmapClient).
558    ///
559    /// Useful for ad-hoc operations outside the typed JMAP method surface —
560    /// for example, calling `JmapClient::upload` / `JmapClient::download_blob`
561    /// for attachment transfer, or constructing a `JmapClient::event_source`
562    /// subscription using the bound session's `event_source_url`.
563    ///
564    /// Returns a borrow so callers do not pay the small clone cost of
565    /// `JmapClient::clone` unless they need an owned handle.
566    pub fn client(&self) -> &jmap_base_client::JmapClient {
567        &self.client
568    }
569
570    /// Borrow the captured [`Session`](jmap_base_client::Session).
571    ///
572    /// `SessionClient` captures the `Session` at construction time. After
573    /// re-fetching the session via `JmapClient::fetch_session`, callers
574    /// should construct a new `SessionClient`. This accessor lets a caller
575    /// compare the captured session's `state` field against a freshly
576    /// fetched session to detect staleness, or inspect
577    /// `accountCapabilities` / `primary_accounts` for capability-specific
578    /// metadata not exposed via the typed JMAP method surface.
579    pub fn session(&self) -> &jmap_base_client::Session {
580        &self.session
581    }
582
583    /// Return the primary mail account id for `urn:ietf:params:jmap:mail`,
584    /// or `Err(InvalidSession)` if the session has no primary account for
585    /// that capability.
586    ///
587    /// The captured session contract is "this `SessionClient` is bound to
588    /// the JMAP Mail capability"; if the underlying primary-accounts map
589    /// no longer carries `urn:ietf:params:jmap:mail`, the session is
590    /// effectively useless for this crate's methods. This accessor
591    /// surfaces that contract for callers who need the account id outside
592    /// the JMAP method calls (e.g. to thread it into a blob-upload URL
593    /// template).
594    pub fn mail_account_id(&self) -> Result<&str, jmap_base_client::ClientError> {
595        self.session
596            .primary_account_id(jmap_mail_types::JMAP_MAIL_URI)
597            .ok_or_else(|| {
598                jmap_base_client::ClientError::InvalidSession(
599                    "no primary account for urn:ietf:params:jmap:mail".into(),
600                )
601            })
602    }
603
604    /// Extract `(api_url, mail_account_id)` from the bound session.
605    ///
606    /// Returns `Err(InvalidSession)` if there is no primary account for
607    /// `urn:ietf:params:jmap:mail`.
608    pub(crate) fn session_parts(&self) -> Result<(&str, &str), jmap_base_client::ClientError> {
609        let api_url = self.session.api_url.as_str();
610        let account_id = self
611            .session
612            .primary_account_id(jmap_mail_types::JMAP_MAIL_URI)
613            .ok_or_else(|| {
614                jmap_base_client::ClientError::InvalidSession(
615                    "no primary account for urn:ietf:params:jmap:mail".into(),
616                )
617            })?;
618        Ok((api_url, account_id))
619    }
620
621    /// Forward a JMAP request to the underlying HTTP client.
622    pub(crate) async fn call_internal(
623        &self,
624        api_url: &str,
625        req: &jmap_types::JmapRequest,
626    ) -> Result<jmap_types::JmapResponse, jmap_base_client::ClientError> {
627        self.client.call(api_url, req).await
628    }
629}
630
631/// Compile-time assertion that [`SessionClient`] is `Send + Sync`.
632///
633/// The `# Thread safety` section of [`SessionClient`]'s rustdoc promises
634/// auto-trait inheritance from
635/// [`jmap_base_client::JmapClient`] and
636/// [`jmap_base_client::Session`]. If a future jmap-base-client release
637/// adds a `!Sync` interior-mutability field to either, this assertion
638/// fails at compile time — flagging the regression at the dependency
639/// upgrade rather than at the downstream caller's "cannot send between
640/// threads safely" error.
641#[allow(dead_code)]
642fn _assert_session_client_send_sync() {
643    fn assert_send_sync<T: Send + Sync>() {}
644    assert_send_sync::<SessionClient>();
645}
646
647// ---------------------------------------------------------------------------
648// Tests
649// ---------------------------------------------------------------------------
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use serde_json::json;
655
656    /// Oracle: build_request produces the correct method name.
657    /// Expected: invocation[0] == method name, invocation[2] == CALL_ID.
658    /// The expected values are literals from the code spec, not derived from
659    /// the function under test.
660    #[test]
661    fn build_request_method_name_and_call_id() {
662        let req = build_request(
663            "Email/get",
664            json!({"accountId": "acc1", "ids": null}),
665            USING_MAIL,
666        );
667        let v = serde_json::to_value(&req).expect("serialize JmapRequest");
668
669        let calls = v["methodCalls"]
670            .as_array()
671            .expect("methodCalls must be array");
672        assert_eq!(calls.len(), 1, "must have exactly 1 method call");
673        assert_eq!(calls[0][0], json!("Email/get"), "method name must match");
674        assert_eq!(calls[0][2], json!("r1"), "call_id must be CALL_ID constant");
675    }
676
677    /// Oracle: USING_MAIL contains exactly the two RFC 8621 capability URIs.
678    /// Expected values are taken directly from RFC 8621 §1.3.1.
679    #[test]
680    fn using_mail_contains_correct_uris() {
681        let req = build_request("Email/get", json!({}), USING_MAIL);
682        let v = serde_json::to_value(&req).expect("serialize");
683        let using = v["using"].as_array().expect("using must be array");
684        assert_eq!(using.len(), 2);
685        assert!(
686            using.contains(&json!("urn:ietf:params:jmap:core")),
687            "must include jmap:core"
688        );
689        assert!(
690            using.contains(&json!("urn:ietf:params:jmap:mail")),
691            "must include jmap:mail"
692        );
693    }
694
695    /// Oracle: USING_SUBMISSION contains core + mail + submission.
696    /// Expected values are taken directly from RFC 8621 §1.3.2. The
697    /// inclusion of `urn:ietf:params:jmap:mail` is required because
698    /// EmailSubmission references emailId / threadId and the
699    /// onSuccessUpdateEmail / onSuccessDestroyEmail mechanism produces
700    /// implicit Email/set or Email/destroy invocations on the same request.
701    #[test]
702    fn using_submission_contains_correct_uris() {
703        let req = build_request("EmailSubmission/get", json!({}), USING_SUBMISSION);
704        let v = serde_json::to_value(&req).expect("serialize");
705        let using = v["using"].as_array().expect("using must be array");
706        assert_eq!(using.len(), 3);
707        assert!(
708            using.contains(&json!("urn:ietf:params:jmap:core")),
709            "must include jmap:core"
710        );
711        assert!(
712            using.contains(&json!("urn:ietf:params:jmap:mail")),
713            "must include jmap:mail (EmailSubmission references mail-typed fields)"
714        );
715        assert!(
716            using.contains(&json!("urn:ietf:params:jmap:submission")),
717            "must include jmap:submission"
718        );
719    }
720
721    /// Oracle: USING_VACATION contains exactly core + vacationresponse.
722    /// Expected values are taken directly from RFC 8621 §1.3.3. The
723    /// `urn:ietf:params:jmap:mail` URI is NOT included — VacationResponse
724    /// does not reference any mail-typed fields.
725    #[test]
726    fn using_vacation_contains_correct_uris() {
727        let req = build_request("VacationResponse/get", json!({}), USING_VACATION);
728        let v = serde_json::to_value(&req).expect("serialize");
729        let using = v["using"].as_array().expect("using must be array");
730        assert_eq!(using.len(), 2);
731        assert!(
732            using.contains(&json!("urn:ietf:params:jmap:core")),
733            "must include jmap:core"
734        );
735        assert!(
736            using.contains(&json!("urn:ietf:params:jmap:vacationresponse")),
737            "must include jmap:vacationresponse"
738        );
739        assert!(
740            !using.contains(&json!("urn:ietf:params:jmap:mail")),
741            "must NOT include jmap:mail (VacationResponse is a standalone capability)"
742        );
743    }
744
745    /// Oracle: session_parts returns InvalidSession when no primary account
746    /// for mail capability. Expected error kind from base client AGENTS.md.
747    #[test]
748    fn session_parts_err_no_primary_account() {
749        let session_json = json!({
750            "capabilities": {},
751            "accounts": {},
752            "primaryAccounts": {},
753            "username": "user@example.com",
754            "apiUrl": "https://jmap.example.com/api/",
755            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
756            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
757            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
758            "state": "s1"
759        });
760        let session: jmap_base_client::Session =
761            serde_json::from_value(session_json).expect("session must deserialize");
762
763        let result = session.primary_account_id("urn:ietf:params:jmap:mail");
764        assert!(
765            result.is_none(),
766            "must return None when mail capability is not in primaryAccounts"
767        );
768    }
769
770    /// Oracle: GetResponse<T> deserializes from RFC 8620 §5.1 shape.
771    /// The JSON shape is taken from RFC 8620 §5.1, not from the code.
772    #[test]
773    fn get_response_deserializes() {
774        let json = json!({
775            "accountId": "acc1",
776            "state": "s42",
777            "list": [],
778            "notFound": ["missing1"]
779        });
780        let resp: GetResponse<serde_json::Value> =
781            serde_json::from_value(json).expect("GetResponse must deserialize");
782        assert_eq!(resp.account_id, "acc1");
783        assert_eq!(resp.state, "s42");
784        assert!(resp.list.is_empty());
785        assert_eq!(
786            resp.not_found.as_deref(),
787            Some(["missing1".into()].as_slice())
788        );
789    }
790
791    /// Oracle: ChangesResponse deserializes from RFC 8620 §5.2 shape.
792    #[test]
793    fn changes_response_deserializes() {
794        let json = json!({
795            "accountId": "acc1",
796            "oldState": "s10",
797            "newState": "s11",
798            "hasMoreChanges": false,
799            "created": ["id1"],
800            "updated": ["id2"],
801            "destroyed": []
802        });
803        let resp: ChangesResponse =
804            serde_json::from_value(json).expect("ChangesResponse must deserialize");
805        assert_eq!(resp.old_state, "s10");
806        assert_eq!(resp.new_state, "s11");
807        assert!(!resp.has_more_changes);
808    }
809
810    /// Oracle: SetResponse deserializes from RFC 8620 §5.3 shape.
811    #[test]
812    fn set_response_deserializes() {
813        let json = json!({
814            "accountId": "acc1",
815            "oldState": "s10",
816            "newState": "s11",
817            "created": null,
818            "updated": null,
819            "destroyed": ["id1"],
820            "notCreated": null,
821            "notUpdated": null,
822            "notDestroyed": null
823        });
824        let resp: SetResponse = serde_json::from_value(json).expect("SetResponse must deserialize");
825        assert_eq!(resp.new_state, "s11");
826        assert_eq!(resp.destroyed.as_deref(), Some(["id1".into()].as_slice()));
827    }
828
829    /// Oracle: SetResponse<T>.updated must accept null values per RFC 8620
830    /// §5.3 wire type "Id[Foo|null]|null" (rfc8620.txt line 2043).
831    ///
832    /// The server returns null for a successfully updated object when the
833    /// patch was applied verbatim with no server-set property deltas to
834    /// report. A typed SetResponse<Email> must deserialize this shape rather
835    /// than failing because `null` cannot become Email.
836    ///
837    /// Independent oracle: hand-written JSON fixture mirroring the spec
838    /// wire shape directly — not generated by any code in this crate.
839    #[test]
840    fn set_response_updated_accepts_null_values() {
841        let json = json!({
842            "accountId": "acc1",
843            "oldState": "s1",
844            "newState": "s2",
845            "updated": {
846                "M1": null,
847                "M2": null
848            }
849        });
850        let resp: SetResponse<jmap_mail_types::Email> = serde_json::from_value(json)
851            .expect("SetResponse must accept Id[Foo|null] per RFC 8620 §5.3");
852        let updated = resp.updated.expect("updated must be Some");
853        assert_eq!(updated.len(), 2, "two ids in updated map");
854        assert!(
855            updated
856                .get(&Id::from("M1"))
857                .expect("M1 key present")
858                .is_none(),
859            "M1 value must be None (null)"
860        );
861        assert!(
862            updated
863                .get(&Id::from("M2"))
864                .expect("M2 key present")
865                .is_none(),
866            "M2 value must be None (null)"
867        );
868    }
869
870    /// Oracle: SetResponse<T>.updated also accepts non-null Foo values per
871    /// RFC 8620 §5.3 — the union "Id[Foo|null]" must round-trip both arms.
872    /// Server returns a Foo object when server-set or computed properties
873    /// changed beyond what the client patched (rfc8620.txt lines 2048-2051).
874    #[test]
875    fn set_response_updated_accepts_object_values() {
876        let json = json!({
877            "accountId": "acc1",
878            "oldState": "s1",
879            "newState": "s2",
880            "updated": {
881                "M1": { "id": "M1", "subject": "Hello" }
882            }
883        });
884        let resp: SetResponse<serde_json::Value> = serde_json::from_value(json)
885            .expect("SetResponse must accept Id[Foo] per RFC 8620 §5.3");
886        let updated = resp.updated.expect("updated must be Some");
887        let m1 = updated
888            .get(&Id::from("M1"))
889            .expect("M1 key present")
890            .as_ref()
891            .expect("M1 value must be Some when server reports deltas");
892        assert_eq!(m1["subject"], json!("Hello"));
893    }
894
895    /// Oracle: QueryChangesResponse deserializes from RFC 8620 §5.6 shape.
896    #[test]
897    fn query_changes_response_deserializes() {
898        let json = json!({
899            "accountId": "acc1",
900            "oldQueryState": "qs1",
901            "newQueryState": "qs2",
902            "total": 5,
903            "removed": ["id3"],
904            "added": [{"id": "id4", "index": 0}]
905        });
906        let resp: QueryChangesResponse =
907            serde_json::from_value(json).expect("QueryChangesResponse must deserialize");
908        assert_eq!(resp.old_query_state, "qs1");
909        assert_eq!(resp.new_query_state, "qs2");
910        assert_eq!(resp.total, Some(5));
911        assert_eq!(resp.removed.len(), 1);
912        assert_eq!(resp.added.len(), 1);
913        assert_eq!(resp.added[0].index, 0);
914    }
915
916    /// Oracle: EmailGetParams with all None serializes to empty object `{}`.
917    /// RFC 8621 §4.1.8 — omitted fields mean "use server defaults".
918    #[test]
919    fn email_get_params_default_serializes_to_empty_object() {
920        let params = EmailGetParams::default();
921        let v = serde_json::to_value(&params).expect("serialize EmailGetParams");
922        assert_eq!(v, serde_json::json!({}), "default must serialize to {{}}");
923    }
924
925    /// Oracle: EmailGetParams with all fields set serializes all camelCase keys.
926    /// Expected field names from RFC 8621 §4.1.8.
927    #[test]
928    fn email_get_params_all_fields_serializes_correctly() {
929        let params = EmailGetParams {
930            body_properties: Some(vec!["partId".into(), "type".into()]),
931            fetch_text_body_values: Some(true),
932            fetch_html_body_values: Some(false),
933            fetch_all_body_values: Some(true),
934            max_body_value_bytes: Some(1024),
935            extra: serde_json::Map::new(),
936        };
937        let v = serde_json::to_value(&params).expect("serialize");
938        assert_eq!(
939            v["bodyProperties"],
940            json!(["partId", "type"]),
941            "bodyProperties"
942        );
943        assert_eq!(v["fetchTextBodyValues"], json!(true));
944        // RFC 8621 §4.2 line 2327 — wire spelling is "fetchHTMLBodyValues"
945        // (HTML uppercase), NOT default camelCase "fetchHtmlBodyValues".
946        assert_eq!(v["fetchHTMLBodyValues"], json!(false));
947        assert!(
948            v.get("fetchHtmlBodyValues").is_none(),
949            "must NOT emit the lowercase-html wire key (RFC 8621 §4.2)"
950        );
951        assert_eq!(v["fetchAllBodyValues"], json!(true));
952        assert_eq!(v["maxBodyValueBytes"], json!(1024_u64));
953    }
954
955    /// Oracle: EmailCopyParams serializes fromAccountId and optional fields.
956    /// Expected field names from RFC 8621 §4.7 and RFC 8620 §5.4.
957    #[test]
958    fn email_copy_params_serializes_correctly() {
959        let params = EmailCopyParams {
960            from_account_id: "acct-src".into(),
961            on_success_destroy_original: Some(true),
962            destroy_from_if_in_state: Some("s99".into()),
963            extra: serde_json::Map::new(),
964        };
965        let v = serde_json::to_value(&params).expect("serialize");
966        assert_eq!(v["fromAccountId"], json!("acct-src"));
967        assert_eq!(v["onSuccessDestroyOriginal"], json!(true));
968        assert_eq!(v["destroyFromIfInState"], json!("s99"));
969    }
970
971    /// Oracle: EmailCopyParams with None optionals omits those keys.
972    #[test]
973    fn email_copy_params_omits_none_fields() {
974        let params = EmailCopyParams {
975            from_account_id: "acct-src".into(),
976            on_success_destroy_original: None,
977            destroy_from_if_in_state: None,
978            extra: serde_json::Map::new(),
979        };
980        let v = serde_json::to_value(&params).expect("serialize");
981        assert_eq!(v["fromAccountId"], json!("acct-src"));
982        assert!(
983            v.get("onSuccessDestroyOriginal").is_none() || v["onSuccessDestroyOriginal"].is_null(),
984            "onSuccessDestroyOriginal must be absent"
985        );
986    }
987
988    // ── Extras-preservation policy tests (JMAP-lbdy.9) ─────────────────
989    //
990    // For Serialize-only method-argument structs, the test constructs a
991    // struct with a vendor field in `extra` and asserts that the field
992    // flattens into the serialized JSON. For Deserialize-only method-
993    // response structs, the test deserialises JSON containing a vendor
994    // field and asserts the field is captured in `extra`. Both directions
995    // use synthetic `acmeCorp*` keys that are guaranteed not to appear in
996    // any RFC 8621 typed field — so the tests are independent of the
997    // crate under test.
998
999    /// `EmailGetParams.extra` flattens into serialized JSON.
1000    #[test]
1001    fn email_get_params_propagates_vendor_extras() {
1002        let mut params = EmailGetParams::default();
1003        params
1004            .extra
1005            .insert("acmeCorpInline".into(), json!("aggressive"));
1006        let v = serde_json::to_value(&params).expect("serialize EmailGetParams");
1007        assert_eq!(v["acmeCorpInline"], json!("aggressive"));
1008    }
1009
1010    /// `EmailCopyParams.extra` flattens into serialized JSON.
1011    #[test]
1012    fn email_copy_params_propagates_vendor_extras() {
1013        let mut extra = serde_json::Map::new();
1014        extra.insert("acmeCorpAudit".into(), json!(true));
1015        let params = EmailCopyParams {
1016            from_account_id: "acct-src".into(),
1017            on_success_destroy_original: None,
1018            destroy_from_if_in_state: None,
1019            extra,
1020        };
1021        let v = serde_json::to_value(&params).expect("serialize EmailCopyParams");
1022        assert_eq!(v["acmeCorpAudit"], json!(true));
1023    }
1024
1025    /// `MailboxSetParams.extra` flattens into serialized JSON.
1026    #[test]
1027    fn mailbox_set_params_propagates_vendor_extras() {
1028        let mut params = MailboxSetParams::default();
1029        params
1030            .extra
1031            .insert("acmeCorpCascade".into(), json!("strict"));
1032        let v = serde_json::to_value(&params).expect("serialize MailboxSetParams");
1033        assert_eq!(v["acmeCorpCascade"], json!("strict"));
1034    }
1035
1036    /// `EmailSubmissionSetParams.extra` flattens into serialized JSON.
1037    #[test]
1038    fn email_submission_set_params_propagates_vendor_extras() {
1039        let mut params = EmailSubmissionSetParams::default();
1040        params
1041            .extra
1042            .insert("acmeCorpQueue".into(), json!("priority"));
1043        let v = serde_json::to_value(&params).expect("serialize EmailSubmissionSetParams");
1044        assert_eq!(v["acmeCorpQueue"], json!("priority"));
1045    }
1046
1047    /// `EmailImportInput.extra` flattens into serialized JSON.
1048    #[test]
1049    fn email_import_input_propagates_vendor_extras() {
1050        let blob = Id::from("blob1");
1051        let mailboxes = [Id::from("mb1")];
1052        let mut extra = serde_json::Map::new();
1053        extra.insert("acmeCorpSource".into(), json!("mta-relay"));
1054        let input = EmailImportInput {
1055            blob_id: &blob,
1056            mailbox_ids: &mailboxes,
1057            keywords: None,
1058            received_at: None,
1059            extra,
1060        };
1061        let v = serde_json::to_value(&input).expect("serialize EmailImportInput");
1062        assert_eq!(v["acmeCorpSource"], json!("mta-relay"));
1063    }
1064
1065    /// `EmailParseParams.extra` flattens into serialized JSON.
1066    #[test]
1067    fn email_parse_params_propagates_vendor_extras() {
1068        let mut params = EmailParseParams::default();
1069        params.extra.insert("acmeCorpStrict".into(), json!(true));
1070        let v = serde_json::to_value(&params).expect("serialize EmailParseParams");
1071        assert_eq!(v["acmeCorpStrict"], json!(true));
1072    }
1073
1074    /// `EmailImportCreated.extra` captures unknown fields on deserialize.
1075    #[test]
1076    fn email_import_created_preserves_vendor_extras() {
1077        let raw = json!({
1078            "id": "M1",
1079            "blobId": "B1",
1080            "threadId": "T1",
1081            "size": 1024,
1082            "acmeCorpAntivirus": "clean"
1083        });
1084        let created: EmailImportCreated =
1085            serde_json::from_value(raw).expect("EmailImportCreated must deserialize");
1086        assert_eq!(
1087            created
1088                .extra
1089                .get("acmeCorpAntivirus")
1090                .and_then(|v| v.as_str()),
1091            Some("clean")
1092        );
1093    }
1094
1095    /// `EmailImportResponse.extra` captures unknown fields on deserialize.
1096    #[test]
1097    fn email_import_response_preserves_vendor_extras() {
1098        let raw = json!({
1099            "accountId": "acc1",
1100            "newState": "s2",
1101            "acmeCorpJobId": "job-42"
1102        });
1103        let resp: EmailImportResponse =
1104            serde_json::from_value(raw).expect("EmailImportResponse must deserialize");
1105        assert_eq!(
1106            resp.extra.get("acmeCorpJobId").and_then(|v| v.as_str()),
1107            Some("job-42")
1108        );
1109    }
1110
1111    /// `EmailParseResponse.extra` captures unknown fields on deserialize.
1112    #[test]
1113    fn email_parse_response_preserves_vendor_extras() {
1114        let raw = json!({
1115            "accountId": "acc1",
1116            "acmeCorpParser": "v3"
1117        });
1118        let resp: EmailParseResponse =
1119            serde_json::from_value(raw).expect("EmailParseResponse must deserialize");
1120        assert_eq!(
1121            resp.extra.get("acmeCorpParser").and_then(|v| v.as_str()),
1122            Some("v3")
1123        );
1124    }
1125}