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(¶ms).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(¶ms).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(¶ms).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(¶ms).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(¶ms).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(¶ms).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(¶ms).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(¶ms).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(¶ms).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}