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, 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    #[serde(skip_serializing_if = "Option::is_none")]
51    pub fetch_html_body_values: Option<bool>,
52    /// If `true`, inline values for all body parts (RFC 8621 §4.1.8).
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub fetch_all_body_values: Option<bool>,
55    /// Truncate body values to at most this many bytes (RFC 8621 §4.1.8).
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub max_body_value_bytes: Option<u64>,
58    /// Catch-all for vendor / site / private extension fields not covered
59    /// by the typed fields above. Preserves unknown fields across
60    /// deserialize/serialize round-trip per workspace extras-preservation
61    /// policy (see workspace AGENTS.md).
62    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
63    pub extra: serde_json::Map<String, serde_json::Value>,
64}
65
66/// Extra args for Email/copy (RFC 8621 §4.7).
67#[derive(Debug, serde::Serialize)]
68#[serde(rename_all = "camelCase")]
69pub struct EmailCopyParams {
70    /// The account to copy from (RFC 8621 §4.7).
71    pub from_account_id: Id,
72    /// If `true`, destroy originals after successful copy (RFC 8620 §5.4).
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub on_success_destroy_original: Option<bool>,
75    /// If-in-state guard for the source account destroy step (RFC 8620 §5.4).
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub destroy_from_if_in_state: Option<jmap_types::State>,
78    /// Catch-all for vendor / site / private extension fields not covered
79    /// by the typed fields above. Preserves unknown fields across
80    /// deserialize/serialize round-trip per workspace extras-preservation
81    /// policy (see workspace AGENTS.md).
82    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
83    pub extra: serde_json::Map<String, serde_json::Value>,
84}
85
86/// Extra args for Mailbox/set (RFC 8621 §2.5).
87#[derive(Debug, Default, serde::Serialize)]
88#[serde(rename_all = "camelCase")]
89pub struct MailboxSetParams {
90    /// If `true`, destroy all emails in the mailbox when the mailbox itself is
91    /// destroyed (RFC 8621 §2.5).
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub on_destroy_remove_emails: Option<bool>,
94    /// Catch-all for vendor / site / private extension fields not covered
95    /// by the typed fields above. Preserves unknown fields across
96    /// deserialize/serialize round-trip per workspace extras-preservation
97    /// policy (see workspace AGENTS.md).
98    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
99    pub extra: serde_json::Map<String, serde_json::Value>,
100}
101
102/// Extra args for EmailSubmission/set (RFC 8621 §7.5).
103///
104/// These two fields are method-level arguments on `EmailSubmission/set` (not
105/// nested inside a create/update object). They let the caller atomically
106/// modify or destroy related Email objects when a submission is created
107/// successfully, without a separate round-trip.
108///
109/// Example use case: remove the `$draft` keyword from the email after
110/// submission succeeds.
111#[derive(Debug, Default, serde::Serialize)]
112#[serde(rename_all = "camelCase")]
113pub struct EmailSubmissionSetParams {
114    /// Map of creation key → [`jmap_types::PatchObject`] to apply to the
115    /// associated Email if the submission is created successfully
116    /// (RFC 8621 §7.5).
117    ///
118    /// Keys that start with `"#"` are result references to creation keys in
119    /// the same `create` map. Wire format is unchanged from a plain JSON
120    /// object because `PatchObject` is `#[serde(transparent)]`.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub on_success_update_email: Option<HashMap<String, jmap_types::PatchObject>>,
123
124    /// Email IDs (or `#`-prefixed creation keys) to destroy if the submission
125    /// is created successfully (RFC 8621 §7.5).
126    ///
127    /// Typically used to destroy the draft email after successful submission.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub on_success_destroy_email: Option<Vec<String>>,
130
131    /// Catch-all for vendor / site / private extension fields not covered
132    /// by the typed fields above. Preserves unknown fields across
133    /// deserialize/serialize round-trip per workspace extras-preservation
134    /// policy (see workspace AGENTS.md).
135    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
136    pub extra: serde_json::Map<String, serde_json::Value>,
137}
138
139/// Per-creation EmailImport object for [`Email/import`](super::SessionClient::email_import)
140/// (RFC 8621 §4.8).
141///
142/// The raw [RFC 5322] message must already have been uploaded as a blob; the
143/// caller passes that blob's id here. At least one mailbox id MUST be
144/// supplied; the empty case is rejected by `email_import` as `InvalidArgument`.
145///
146/// [RFC 5322]: https://www.rfc-editor.org/rfc/rfc5322
147#[derive(Debug, serde::Serialize)]
148#[serde(rename_all = "camelCase")]
149pub struct EmailImportInput<'a> {
150    /// Blob id of the uploaded raw RFC 5322 message.
151    pub blob_id: &'a Id,
152    /// Mailbox ids the new Email is assigned to. Wire shape is `{id: true}`;
153    /// callers supply the set as a slice. At least one id is required (RFC 8621 §4.8).
154    #[serde(serialize_with = "ser_mailbox_id_set")]
155    pub mailbox_ids: &'a [Id],
156    /// Keywords to apply to the Email. Wire shape is `{keyword: true}`.
157    /// Defaults to an empty map per RFC 8621 §4.8 when `None`.
158    #[serde(
159        skip_serializing_if = "Option::is_none",
160        serialize_with = "ser_opt_keyword_set"
161    )]
162    pub keywords: Option<&'a [&'a str]>,
163    /// `receivedAt` to set on the imported Email. Defaults to the most recent
164    /// `Received` header or import time per RFC 8621 §4.8 when `None`.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub received_at: Option<&'a jmap_types::UTCDate>,
167    /// Catch-all for vendor / site / private extension fields not covered
168    /// by the typed fields above. Preserves unknown fields across
169    /// deserialize/serialize round-trip per workspace extras-preservation
170    /// policy (see workspace AGENTS.md).
171    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
172    pub extra: serde_json::Map<String, serde_json::Value>,
173}
174
175fn ser_mailbox_id_set<S: serde::Serializer>(ids: &&[Id], s: S) -> Result<S::Ok, S::Error> {
176    use serde::ser::SerializeMap;
177    let mut m = s.serialize_map(Some(ids.len()))?;
178    for id in *ids {
179        m.serialize_entry(id.as_ref(), &true)?;
180    }
181    m.end()
182}
183
184fn ser_opt_keyword_set<S: serde::Serializer>(
185    kws: &Option<&[&str]>,
186    s: S,
187) -> Result<S::Ok, S::Error> {
188    use serde::ser::SerializeMap;
189    let kws = kws.expect("skip_serializing_if guarantees Some");
190    let mut m = s.serialize_map(Some(kws.len()))?;
191    for k in kws {
192        m.serialize_entry(k, &true)?;
193    }
194    m.end()
195}
196
197/// Per-creation success entry in an [`EmailImportResponse`] (RFC 8621 §4.8).
198///
199/// The server reports the new Email's `id`, `blobId` (may differ from the
200/// caller-supplied blob id if the server normalised the message), `threadId`,
201/// and `size` for each successfully imported message.
202#[non_exhaustive]
203#[derive(Debug, Clone, serde::Deserialize)]
204#[serde(rename_all = "camelCase")]
205pub struct EmailImportCreated {
206    /// Server-assigned Email id.
207    pub id: Id,
208    /// Blob id of the canonical raw message (may differ from the input blob id).
209    pub blob_id: Id,
210    /// Server-assigned Thread id this Email belongs to.
211    pub thread_id: Id,
212    /// Size of the canonical raw message in bytes.
213    pub size: u64,
214    /// Catch-all for vendor / site / private extension fields not covered
215    /// by the typed fields above. Preserves unknown fields across
216    /// deserialize/serialize round-trip per workspace extras-preservation
217    /// policy (see workspace AGENTS.md).
218    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
219    pub extra: serde_json::Map<String, serde_json::Value>,
220}
221
222/// Response to [`Email/import`](super::SessionClient::email_import) (RFC 8621 §4.8).
223#[non_exhaustive]
224#[derive(Debug, Clone, serde::Deserialize)]
225#[serde(rename_all = "camelCase")]
226pub struct EmailImportResponse {
227    /// The account this response refers to.
228    pub account_id: Id,
229    /// State token before the import, or `null` if the server cannot supply one.
230    #[serde(default)]
231    pub old_state: Option<State>,
232    /// State token after the import.
233    pub new_state: State,
234    /// Successfully imported Emails keyed by creation id.
235    #[serde(default)]
236    pub created: Option<HashMap<String, EmailImportCreated>>,
237    /// Failures keyed by creation id (RFC 8621 §4.8 errors include
238    /// `alreadyExists`, `invalidProperties`, `overQuota`, `invalidEmail`).
239    #[serde(default)]
240    pub not_created: Option<HashMap<String, SetError>>,
241    /// Catch-all for vendor / site / private extension fields not covered
242    /// by the typed fields above. Preserves unknown fields across
243    /// deserialize/serialize round-trip per workspace extras-preservation
244    /// policy (see workspace AGENTS.md).
245    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
246    pub extra: serde_json::Map<String, serde_json::Value>,
247}
248
249/// Extra args for [`Email/parse`](super::SessionClient::email_parse) (RFC 8621 §4.9).
250///
251/// Mirrors the body-fetch options of [`EmailGetParams`] plus a `properties`
252/// override. All fields are optional; absent fields use server defaults.
253#[derive(Debug, Default, serde::Serialize)]
254#[serde(rename_all = "camelCase")]
255pub struct EmailParseParams {
256    /// Override the set of Email properties returned per parsed message
257    /// (RFC 8621 §4.9). When `None`, the server returns the default set
258    /// documented in the spec.
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub properties: Option<Vec<String>>,
261    /// Override the set of body-part properties returned (RFC 8621 §4.9).
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub body_properties: Option<Vec<String>>,
264    /// If `true`, inline values for text/plain body parts (RFC 8621 §4.9).
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub fetch_text_body_values: Option<bool>,
267    /// If `true`, inline values for text/html body parts (RFC 8621 §4.9).
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub fetch_html_body_values: Option<bool>,
270    /// If `true`, inline values for all body parts (RFC 8621 §4.9).
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub fetch_all_body_values: Option<bool>,
273    /// Truncate body values to at most this many bytes (RFC 8621 §4.9).
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub max_body_value_bytes: Option<u64>,
276    /// Catch-all for vendor / site / private extension fields not covered
277    /// by the typed fields above. Preserves unknown fields across
278    /// deserialize/serialize round-trip per workspace extras-preservation
279    /// policy (see workspace AGENTS.md).
280    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
281    pub extra: serde_json::Map<String, serde_json::Value>,
282}
283
284/// Response to [`Email/parse`](super::SessionClient::email_parse) (RFC 8621 §4.9).
285///
286/// Per RFC 8621 §4.9, parsed Email objects have `id`, `mailboxIds`,
287/// `keywords`, and `receivedAt` set to `null`; callers should not rely on
288/// those fields being populated.
289#[non_exhaustive]
290#[derive(Debug, Clone, serde::Deserialize)]
291#[serde(rename_all = "camelCase")]
292pub struct EmailParseResponse {
293    /// The account this response refers to.
294    pub account_id: Id,
295    /// Parsed Emails keyed by source blob id.
296    #[serde(default)]
297    pub parsed: Option<HashMap<Id, jmap_mail_types::Email>>,
298    /// Blob ids whose contents were not parseable as RFC 5322 messages.
299    #[serde(default)]
300    pub not_parsable: Option<Vec<Id>>,
301    /// Blob ids that could not be found in the account's blob store.
302    #[serde(default)]
303    pub not_found: Option<Vec<Id>>,
304    /// Catch-all for vendor / site / private extension fields not covered
305    /// by the typed fields above. Preserves unknown fields across
306    /// deserialize/serialize round-trip per workspace extras-preservation
307    /// policy (see workspace AGENTS.md).
308    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
309    pub extra: serde_json::Map<String, serde_json::Value>,
310}
311
312// ---------------------------------------------------------------------------
313// Constants
314// ---------------------------------------------------------------------------
315
316/// The call-id embedded in every single-method JMAP request produced by
317/// [`build_request`]. Pass directly to `jmap_base_client::extract_response`.
318pub(crate) const CALL_ID: &str = "r1";
319
320/// Capability URIs for JMAP Mail method calls (RFC 8621).
321pub(crate) const USING_MAIL: &[&str] = &["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"];
322
323// ---------------------------------------------------------------------------
324// build_request helper
325// ---------------------------------------------------------------------------
326
327/// Build a single-method JMAP request.
328///
329/// `using` is the complete `using` array for the request (RFC 8620 §3.3).
330/// Use the pre-defined constant [`USING_MAIL`] for standard calls.
331///
332/// The embedded call-id is [`CALL_ID`]; pass it directly to
333/// `jmap_base_client::extract_response`.
334pub(crate) fn build_request(
335    method: &str,
336    args: serde_json::Value,
337    using: &[&str],
338) -> jmap_types::JmapRequest {
339    let using_vec: Vec<String> = using.iter().map(|&s| s.to_owned()).collect();
340    let invocation: jmap_types::Invocation = (method.to_owned(), args, CALL_ID.to_owned());
341    jmap_types::JmapRequest::new(using_vec, vec![invocation], None)
342}
343
344// ---------------------------------------------------------------------------
345// SessionClient — session-bound client
346// ---------------------------------------------------------------------------
347
348/// A `JmapClient` bound to a JMAP session.
349///
350/// Obtain via [`JmapMailExt::with_mail_session`](crate::JmapMailExt::with_mail_session).
351/// All JMAP Mail methods are available on this type without needing to pass
352/// `&Session` on every call.
353///
354/// # Session lifecycle
355///
356/// `SessionClient` captures the `Session` at construction time. After
357/// re-fetching the session via `JmapClient::fetch_session`, construct a new
358/// `SessionClient` with the updated session. Reusing a stale `SessionClient`
359/// after session expiry will result in `unknownAccount` or similar errors
360/// from the server.
361///
362/// `Clone` is derived because `JmapClient` is itself cheap-to-clone (it
363/// already implements `Clone` and `with_mail_session` clones one
364/// internally), enabling parallel-task fan-out with one bound session.
365///
366/// `Debug` is implemented manually to redact the inner `JmapClient` (which
367/// holds an HTTP client and is intentionally not `Debug` in
368/// `jmap-base-client`); only the `Session` is shown. This lets callers
369/// embed a `SessionClient` in a `#[derive(Debug)]` struct without manual
370/// impls of their own.
371#[non_exhaustive]
372#[derive(Clone)]
373pub struct SessionClient {
374    pub(crate) client: jmap_base_client::JmapClient,
375    pub(crate) session: jmap_base_client::Session,
376}
377
378impl std::fmt::Debug for SessionClient {
379    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
380        f.debug_struct("SessionClient")
381            // The inner JmapClient is not Debug — show a placeholder so
382            // callers know it is present without leaking HTTP-client
383            // internals.
384            .field("client", &"<JmapClient>")
385            .field("session", &self.session)
386            .finish()
387    }
388}
389
390impl SessionClient {
391    /// Extract `(api_url, mail_account_id)` from the bound session.
392    ///
393    /// Returns `Err(InvalidSession)` if there is no primary account for
394    /// `urn:ietf:params:jmap:mail`.
395    pub(crate) fn session_parts(&self) -> Result<(&str, &str), jmap_base_client::ClientError> {
396        let api_url = self.session.api_url.as_str();
397        let account_id = self
398            .session
399            .primary_account_id("urn:ietf:params:jmap:mail")
400            .ok_or_else(|| {
401                jmap_base_client::ClientError::InvalidSession(
402                    "no primary account for urn:ietf:params:jmap:mail".into(),
403                )
404            })?;
405        Ok((api_url, account_id))
406    }
407
408    /// Forward a JMAP request to the underlying HTTP client.
409    pub(crate) async fn call_internal(
410        &self,
411        api_url: &str,
412        req: &jmap_types::JmapRequest,
413    ) -> Result<jmap_types::JmapResponse, jmap_base_client::ClientError> {
414        self.client.call(api_url, req).await
415    }
416}
417
418// ---------------------------------------------------------------------------
419// Tests
420// ---------------------------------------------------------------------------
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use serde_json::json;
426
427    /// Oracle: build_request produces the correct method name.
428    /// Expected: invocation[0] == method name, invocation[2] == CALL_ID.
429    /// The expected values are literals from the code spec, not derived from
430    /// the function under test.
431    #[test]
432    fn build_request_method_name_and_call_id() {
433        let req = build_request(
434            "Email/get",
435            json!({"accountId": "acc1", "ids": null}),
436            USING_MAIL,
437        );
438        let v = serde_json::to_value(&req).expect("serialize JmapRequest");
439
440        let calls = v["methodCalls"]
441            .as_array()
442            .expect("methodCalls must be array");
443        assert_eq!(calls.len(), 1, "must have exactly 1 method call");
444        assert_eq!(calls[0][0], json!("Email/get"), "method name must match");
445        assert_eq!(calls[0][2], json!("r1"), "call_id must be CALL_ID constant");
446    }
447
448    /// Oracle: USING_MAIL contains exactly the two RFC 8621 capability URIs.
449    /// Expected values are taken directly from RFC 8621 §1.3.
450    #[test]
451    fn using_mail_contains_correct_uris() {
452        let req = build_request("Email/get", json!({}), USING_MAIL);
453        let v = serde_json::to_value(&req).expect("serialize");
454        let using = v["using"].as_array().expect("using must be array");
455        assert_eq!(using.len(), 2);
456        assert!(
457            using.contains(&json!("urn:ietf:params:jmap:core")),
458            "must include jmap:core"
459        );
460        assert!(
461            using.contains(&json!("urn:ietf:params:jmap:mail")),
462            "must include jmap:mail"
463        );
464    }
465
466    /// Oracle: session_parts returns InvalidSession when no primary account
467    /// for mail capability. Expected error kind from base client AGENTS.md.
468    #[test]
469    fn session_parts_err_no_primary_account() {
470        let session_json = json!({
471            "capabilities": {},
472            "accounts": {},
473            "primaryAccounts": {},
474            "username": "user@example.com",
475            "apiUrl": "https://jmap.example.com/api/",
476            "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
477            "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
478            "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
479            "state": "s1"
480        });
481        let session: jmap_base_client::Session =
482            serde_json::from_value(session_json).expect("session must deserialize");
483
484        let result = session.primary_account_id("urn:ietf:params:jmap:mail");
485        assert!(
486            result.is_none(),
487            "must return None when mail capability is not in primaryAccounts"
488        );
489    }
490
491    /// Oracle: GetResponse<T> deserializes from RFC 8620 §5.1 shape.
492    /// The JSON shape is taken from RFC 8620 §5.1, not from the code.
493    #[test]
494    fn get_response_deserializes() {
495        let json = json!({
496            "accountId": "acc1",
497            "state": "s42",
498            "list": [],
499            "notFound": ["missing1"]
500        });
501        let resp: GetResponse<serde_json::Value> =
502            serde_json::from_value(json).expect("GetResponse must deserialize");
503        assert_eq!(resp.account_id, "acc1");
504        assert_eq!(resp.state, "s42");
505        assert!(resp.list.is_empty());
506        assert_eq!(
507            resp.not_found.as_deref(),
508            Some(["missing1".into()].as_slice())
509        );
510    }
511
512    /// Oracle: ChangesResponse deserializes from RFC 8620 §5.2 shape.
513    #[test]
514    fn changes_response_deserializes() {
515        let json = json!({
516            "accountId": "acc1",
517            "oldState": "s10",
518            "newState": "s11",
519            "hasMoreChanges": false,
520            "created": ["id1"],
521            "updated": ["id2"],
522            "destroyed": []
523        });
524        let resp: ChangesResponse =
525            serde_json::from_value(json).expect("ChangesResponse must deserialize");
526        assert_eq!(resp.old_state, "s10");
527        assert_eq!(resp.new_state, "s11");
528        assert!(!resp.has_more_changes);
529    }
530
531    /// Oracle: SetResponse deserializes from RFC 8620 §5.3 shape.
532    #[test]
533    fn set_response_deserializes() {
534        let json = json!({
535            "accountId": "acc1",
536            "oldState": "s10",
537            "newState": "s11",
538            "created": null,
539            "updated": null,
540            "destroyed": ["id1"],
541            "notCreated": null,
542            "notUpdated": null,
543            "notDestroyed": null
544        });
545        let resp: SetResponse = serde_json::from_value(json).expect("SetResponse must deserialize");
546        assert_eq!(resp.new_state, "s11");
547        assert_eq!(resp.destroyed.as_deref(), Some(["id1".into()].as_slice()));
548    }
549
550    /// Oracle: SetResponse<T>.updated must accept null values per RFC 8620
551    /// §5.3 wire type "Id[Foo|null]|null" (rfc8620.txt line 2043).
552    ///
553    /// The server returns null for a successfully updated object when the
554    /// patch was applied verbatim with no server-set property deltas to
555    /// report. A typed SetResponse<Email> must deserialize this shape rather
556    /// than failing because `null` cannot become Email.
557    ///
558    /// Independent oracle: hand-written JSON fixture mirroring the spec
559    /// wire shape directly — not generated by any code in this crate.
560    #[test]
561    fn set_response_updated_accepts_null_values() {
562        let json = json!({
563            "accountId": "acc1",
564            "oldState": "s1",
565            "newState": "s2",
566            "updated": {
567                "M1": null,
568                "M2": null
569            }
570        });
571        let resp: SetResponse<jmap_mail_types::Email> = serde_json::from_value(json)
572            .expect("SetResponse must accept Id[Foo|null] per RFC 8620 §5.3");
573        let updated = resp.updated.expect("updated must be Some");
574        assert_eq!(updated.len(), 2, "two ids in updated map");
575        assert!(
576            updated
577                .get(&Id::from("M1"))
578                .expect("M1 key present")
579                .is_none(),
580            "M1 value must be None (null)"
581        );
582        assert!(
583            updated
584                .get(&Id::from("M2"))
585                .expect("M2 key present")
586                .is_none(),
587            "M2 value must be None (null)"
588        );
589    }
590
591    /// Oracle: SetResponse<T>.updated also accepts non-null Foo values per
592    /// RFC 8620 §5.3 — the union "Id[Foo|null]" must round-trip both arms.
593    /// Server returns a Foo object when server-set or computed properties
594    /// changed beyond what the client patched (rfc8620.txt lines 2048-2051).
595    #[test]
596    fn set_response_updated_accepts_object_values() {
597        let json = json!({
598            "accountId": "acc1",
599            "oldState": "s1",
600            "newState": "s2",
601            "updated": {
602                "M1": { "id": "M1", "subject": "Hello" }
603            }
604        });
605        let resp: SetResponse<serde_json::Value> = serde_json::from_value(json)
606            .expect("SetResponse must accept Id[Foo] per RFC 8620 §5.3");
607        let updated = resp.updated.expect("updated must be Some");
608        let m1 = updated
609            .get(&Id::from("M1"))
610            .expect("M1 key present")
611            .as_ref()
612            .expect("M1 value must be Some when server reports deltas");
613        assert_eq!(m1["subject"], json!("Hello"));
614    }
615
616    /// Oracle: QueryChangesResponse deserializes from RFC 8620 §5.6 shape.
617    #[test]
618    fn query_changes_response_deserializes() {
619        let json = json!({
620            "accountId": "acc1",
621            "oldQueryState": "qs1",
622            "newQueryState": "qs2",
623            "total": 5,
624            "removed": ["id3"],
625            "added": [{"id": "id4", "index": 0}]
626        });
627        let resp: QueryChangesResponse =
628            serde_json::from_value(json).expect("QueryChangesResponse must deserialize");
629        assert_eq!(resp.old_query_state, "qs1");
630        assert_eq!(resp.new_query_state, "qs2");
631        assert_eq!(resp.total, Some(5));
632        assert_eq!(resp.removed.len(), 1);
633        assert_eq!(resp.added.len(), 1);
634        assert_eq!(resp.added[0].index, 0);
635    }
636
637    /// Oracle: EmailGetParams with all None serializes to empty object `{}`.
638    /// RFC 8621 §4.1.8 — omitted fields mean "use server defaults".
639    #[test]
640    fn email_get_params_default_serializes_to_empty_object() {
641        let params = EmailGetParams::default();
642        let v = serde_json::to_value(&params).expect("serialize EmailGetParams");
643        assert_eq!(v, serde_json::json!({}), "default must serialize to {{}}");
644    }
645
646    /// Oracle: EmailGetParams with all fields set serializes all camelCase keys.
647    /// Expected field names from RFC 8621 §4.1.8.
648    #[test]
649    fn email_get_params_all_fields_serializes_correctly() {
650        let params = EmailGetParams {
651            body_properties: Some(vec!["partId".into(), "type".into()]),
652            fetch_text_body_values: Some(true),
653            fetch_html_body_values: Some(false),
654            fetch_all_body_values: Some(true),
655            max_body_value_bytes: Some(1024),
656            extra: serde_json::Map::new(),
657        };
658        let v = serde_json::to_value(&params).expect("serialize");
659        assert_eq!(
660            v["bodyProperties"],
661            json!(["partId", "type"]),
662            "bodyProperties"
663        );
664        assert_eq!(v["fetchTextBodyValues"], json!(true));
665        assert_eq!(v["fetchHtmlBodyValues"], json!(false));
666        assert_eq!(v["fetchAllBodyValues"], json!(true));
667        assert_eq!(v["maxBodyValueBytes"], json!(1024_u64));
668    }
669
670    /// Oracle: EmailCopyParams serializes fromAccountId and optional fields.
671    /// Expected field names from RFC 8621 §4.7 and RFC 8620 §5.4.
672    #[test]
673    fn email_copy_params_serializes_correctly() {
674        let params = EmailCopyParams {
675            from_account_id: "acct-src".into(),
676            on_success_destroy_original: Some(true),
677            destroy_from_if_in_state: Some("s99".into()),
678            extra: serde_json::Map::new(),
679        };
680        let v = serde_json::to_value(&params).expect("serialize");
681        assert_eq!(v["fromAccountId"], json!("acct-src"));
682        assert_eq!(v["onSuccessDestroyOriginal"], json!(true));
683        assert_eq!(v["destroyFromIfInState"], json!("s99"));
684    }
685
686    /// Oracle: EmailCopyParams with None optionals omits those keys.
687    #[test]
688    fn email_copy_params_omits_none_fields() {
689        let params = EmailCopyParams {
690            from_account_id: "acct-src".into(),
691            on_success_destroy_original: None,
692            destroy_from_if_in_state: None,
693            extra: serde_json::Map::new(),
694        };
695        let v = serde_json::to_value(&params).expect("serialize");
696        assert_eq!(v["fromAccountId"], json!("acct-src"));
697        assert!(
698            v.get("onSuccessDestroyOriginal").is_none() || v["onSuccessDestroyOriginal"].is_null(),
699            "onSuccessDestroyOriginal must be absent"
700        );
701    }
702
703    // ── Extras-preservation policy tests (JMAP-lbdy.9) ─────────────────
704    //
705    // For Serialize-only method-argument structs, the test constructs a
706    // struct with a vendor field in `extra` and asserts that the field
707    // flattens into the serialized JSON. For Deserialize-only method-
708    // response structs, the test deserialises JSON containing a vendor
709    // field and asserts the field is captured in `extra`. Both directions
710    // use synthetic `acmeCorp*` keys that are guaranteed not to appear in
711    // any RFC 8621 typed field — so the tests are independent of the
712    // crate under test.
713
714    /// `EmailGetParams.extra` flattens into serialized JSON.
715    #[test]
716    fn email_get_params_propagates_vendor_extras() {
717        let mut params = EmailGetParams::default();
718        params
719            .extra
720            .insert("acmeCorpInline".into(), json!("aggressive"));
721        let v = serde_json::to_value(&params).expect("serialize EmailGetParams");
722        assert_eq!(v["acmeCorpInline"], json!("aggressive"));
723    }
724
725    /// `EmailCopyParams.extra` flattens into serialized JSON.
726    #[test]
727    fn email_copy_params_propagates_vendor_extras() {
728        let mut extra = serde_json::Map::new();
729        extra.insert("acmeCorpAudit".into(), json!(true));
730        let params = EmailCopyParams {
731            from_account_id: "acct-src".into(),
732            on_success_destroy_original: None,
733            destroy_from_if_in_state: None,
734            extra,
735        };
736        let v = serde_json::to_value(&params).expect("serialize EmailCopyParams");
737        assert_eq!(v["acmeCorpAudit"], json!(true));
738    }
739
740    /// `MailboxSetParams.extra` flattens into serialized JSON.
741    #[test]
742    fn mailbox_set_params_propagates_vendor_extras() {
743        let mut params = MailboxSetParams::default();
744        params
745            .extra
746            .insert("acmeCorpCascade".into(), json!("strict"));
747        let v = serde_json::to_value(&params).expect("serialize MailboxSetParams");
748        assert_eq!(v["acmeCorpCascade"], json!("strict"));
749    }
750
751    /// `EmailSubmissionSetParams.extra` flattens into serialized JSON.
752    #[test]
753    fn email_submission_set_params_propagates_vendor_extras() {
754        let mut params = EmailSubmissionSetParams::default();
755        params
756            .extra
757            .insert("acmeCorpQueue".into(), json!("priority"));
758        let v = serde_json::to_value(&params).expect("serialize EmailSubmissionSetParams");
759        assert_eq!(v["acmeCorpQueue"], json!("priority"));
760    }
761
762    /// `EmailImportInput.extra` flattens into serialized JSON.
763    #[test]
764    fn email_import_input_propagates_vendor_extras() {
765        let blob = Id::from("blob1");
766        let mailboxes = [Id::from("mb1")];
767        let mut extra = serde_json::Map::new();
768        extra.insert("acmeCorpSource".into(), json!("mta-relay"));
769        let input = EmailImportInput {
770            blob_id: &blob,
771            mailbox_ids: &mailboxes,
772            keywords: None,
773            received_at: None,
774            extra,
775        };
776        let v = serde_json::to_value(&input).expect("serialize EmailImportInput");
777        assert_eq!(v["acmeCorpSource"], json!("mta-relay"));
778    }
779
780    /// `EmailParseParams.extra` flattens into serialized JSON.
781    #[test]
782    fn email_parse_params_propagates_vendor_extras() {
783        let mut params = EmailParseParams::default();
784        params.extra.insert("acmeCorpStrict".into(), json!(true));
785        let v = serde_json::to_value(&params).expect("serialize EmailParseParams");
786        assert_eq!(v["acmeCorpStrict"], json!(true));
787    }
788
789    /// `EmailImportCreated.extra` captures unknown fields on deserialize.
790    #[test]
791    fn email_import_created_preserves_vendor_extras() {
792        let raw = json!({
793            "id": "M1",
794            "blobId": "B1",
795            "threadId": "T1",
796            "size": 1024,
797            "acmeCorpAntivirus": "clean"
798        });
799        let created: EmailImportCreated =
800            serde_json::from_value(raw).expect("EmailImportCreated must deserialize");
801        assert_eq!(
802            created
803                .extra
804                .get("acmeCorpAntivirus")
805                .and_then(|v| v.as_str()),
806            Some("clean")
807        );
808    }
809
810    /// `EmailImportResponse.extra` captures unknown fields on deserialize.
811    #[test]
812    fn email_import_response_preserves_vendor_extras() {
813        let raw = json!({
814            "accountId": "acc1",
815            "newState": "s2",
816            "acmeCorpJobId": "job-42"
817        });
818        let resp: EmailImportResponse =
819            serde_json::from_value(raw).expect("EmailImportResponse must deserialize");
820        assert_eq!(
821            resp.extra.get("acmeCorpJobId").and_then(|v| v.as_str()),
822            Some("job-42")
823        );
824    }
825
826    /// `EmailParseResponse.extra` captures unknown fields on deserialize.
827    #[test]
828    fn email_parse_response_preserves_vendor_extras() {
829        let raw = json!({
830            "accountId": "acc1",
831            "acmeCorpParser": "v3"
832        });
833        let resp: EmailParseResponse =
834            serde_json::from_value(raw).expect("EmailParseResponse must deserialize");
835        assert_eq!(
836            resp.extra.get("acmeCorpParser").and_then(|v| v.as_str()),
837            Some("v3")
838        );
839    }
840}