Skip to main content

jmap_mail_types/
email.rs

1//! RFC 8621 §4 Email object and its component types.
2//!
3//! Provides [`Email`], [`EmailAddress`], [`EmailAddressGroup`], [`EmailHeader`],
4//! [`EmailBodyPart`], and [`EmailBodyValue`].  These are the types used in
5//! `Email/get` responses and `Email/set` requests.
6//!
7//! See [`Email`] for notes on full vs partial responses.
8
9use std::collections::HashMap;
10
11use jmap_types::{Date, Id, UTCDate};
12use serde::{Deserialize, Serialize};
13
14use crate::keyword::Keyword;
15
16/// A parsed email address (RFC 8621 §4.1.2.3).
17///
18/// Represents one address entry from an RFC 5322 address-list.
19/// The `email` field contains the "addr-spec"; `name` contains the
20/// decoded display-name, or `null` if absent.
21///
22/// In RFC 5322 terminology this is a "mailbox" (an addr-spec with optional
23/// display-name), distinct from the JMAP [`Mailbox`](crate::Mailbox) folder type.
24#[non_exhaustive]
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct EmailAddress {
28    /// The decoded display-name of the mailbox, or `null` if absent.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub name: Option<String>,
31    /// The addr-spec of the mailbox (e.g. `"user@example.com"`).
32    pub email: String,
33    /// Catch-all for vendor / site / private extension fields not covered
34    /// by the typed fields above. Preserves unknown fields across
35    /// deserialize/serialize round-trip per workspace extras-preservation
36    /// policy (see workspace AGENTS.md).
37    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
38    pub extra: serde_json::Map<String, serde_json::Value>,
39}
40
41impl EmailAddress {
42    /// Construct an [`EmailAddress`] with no display name.
43    pub fn new(email: impl Into<String>) -> Self {
44        Self {
45            name: None,
46            email: email.into(),
47            extra: serde_json::Map::new(),
48        }
49    }
50}
51
52/// A named group of email addresses (RFC 8621 §4.1.2.4).
53///
54/// Preserves RFC 5322 group structure. Consecutive mailboxes not part of
55/// a named group are collected under an `EmailAddressGroup` with `name: null`.
56#[non_exhaustive]
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct EmailAddressGroup {
60    /// The decoded display-name of the group, or `null` for ungrouped mailboxes.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub name: Option<String>,
63    /// The mailboxes that belong to this group.
64    pub addresses: Vec<EmailAddress>,
65    /// Catch-all for vendor / site / private extension fields not covered
66    /// by the typed fields above. Preserves unknown fields across
67    /// deserialize/serialize round-trip per workspace extras-preservation
68    /// policy (see workspace AGENTS.md).
69    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
70    pub extra: serde_json::Map<String, serde_json::Value>,
71}
72
73impl EmailAddressGroup {
74    /// Construct an [`EmailAddressGroup`] with no group name.
75    pub fn new(addresses: Vec<EmailAddress>) -> Self {
76        Self {
77            name: None,
78            addresses,
79            extra: serde_json::Map::new(),
80        }
81    }
82}
83
84/// A single RFC 5322 header field (RFC 8621 §4.1.3).
85///
86/// The `name` retains original capitalisation; `value` is the raw field value.
87#[non_exhaustive]
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct EmailHeader {
91    /// The header field name (e.g. `"Content-Type"`), case-preserved.
92    pub name: String,
93    /// The header field value in Raw form.
94    pub value: String,
95    /// Catch-all for vendor / site / private extension fields not covered
96    /// by the typed fields above. Preserves unknown fields across
97    /// deserialize/serialize round-trip per workspace extras-preservation
98    /// policy (see workspace AGENTS.md).
99    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
100    pub extra: serde_json::Map<String, serde_json::Value>,
101}
102
103impl EmailHeader {
104    /// Construct an [`EmailHeader`] from its name and value.
105    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
106        Self {
107            name: name.into(),
108            value: value.into(),
109            extra: serde_json::Map::new(),
110        }
111    }
112}
113
114/// The decoded text content of one body part (RFC 8621 §4.1.4).
115///
116/// Returned inside the `bodyValues` map of an Email object.
117#[non_exhaustive]
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct EmailBodyValue {
121    /// The decoded text content of the part.
122    pub value: String,
123    /// `true` if charset decoding or content-transfer-encoding decoding
124    /// encountered errors (RFC 8621 §4.1.4).
125    ///
126    /// Always present in serialized output (no `skip_serializing_if`); RFC 8621 §4.1.4
127    /// requires both flags in the `bodyValues` map.  `#[serde(default)]` handles
128    /// deserialization when absent (treated as `false`).
129    #[serde(default)]
130    pub is_encoding_problem: bool,
131    /// `true` if `value` was truncated due to a `maxBodyValueBytes` limit
132    /// (RFC 8621 §4.1.4).
133    ///
134    /// Always present in serialized output; same rationale as `is_encoding_problem`.
135    #[serde(default)]
136    pub is_truncated: bool,
137    /// Catch-all for vendor / site / private extension fields not covered
138    /// by the typed fields above. Preserves unknown fields across
139    /// deserialize/serialize round-trip per workspace extras-preservation
140    /// policy (see workspace AGENTS.md).
141    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
142    pub extra: serde_json::Map<String, serde_json::Value>,
143}
144
145impl EmailBodyValue {
146    /// Construct an [`EmailBodyValue`] with the given text content.
147    ///
148    /// `is_encoding_problem` and `is_truncated` default to `false`.
149    pub fn new(value: impl Into<String>) -> Self {
150        Self {
151            value: value.into(),
152            is_encoding_problem: false,
153            is_truncated: false,
154            extra: serde_json::Map::new(),
155        }
156    }
157}
158
159/// One MIME body part within an Email (RFC 8621 §4.1.4).
160///
161/// The `sub_parts` field is recursive: multipart bodies nest further
162/// `EmailBodyPart` values.
163#[non_exhaustive]
164#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
165#[serde(rename_all = "camelCase")]
166pub struct EmailBodyPart {
167    /// Uniquely identifies this part within the Email (null for multipart/*).
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub part_id: Option<String>,
170    /// Blob id of the decoded part content (null for multipart/*).
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub blob_id: Option<Id>,
173    /// Size in octets of the decoded content.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub size: Option<u64>,
176    /// All header fields of the part in Raw form, in order.
177    #[serde(default, skip_serializing_if = "Vec::is_empty")]
178    pub headers: Vec<EmailHeader>,
179    /// Decoded filename from Content-Disposition or Content-Type parameters.
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub name: Option<String>,
182    /// MIME content type (e.g. `"text/plain"`).
183    // `type` is a Rust keyword; the trailing underscore is the conventional escape.
184    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
185    pub type_: Option<String>,
186    /// Charset parameter of the Content-Type header field.
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub charset: Option<String>,
189    /// Value of the Content-Disposition header field (parameters stripped).
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub disposition: Option<String>,
192    /// Content-Id value with CFWS and angle brackets removed.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub cid: Option<String>,
195    /// Language tags from the Content-Language header field.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub language: Option<Vec<String>>,
198    /// URI from the Content-Location header field.
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub location: Option<String>,
201    /// Child parts when `type_` is `"multipart/*"`.
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub sub_parts: Option<Vec<EmailBodyPart>>,
204    /// Catch-all for vendor / site / private extension fields not covered
205    /// by the typed fields above. Preserves unknown fields across
206    /// deserialize/serialize round-trip per workspace extras-preservation
207    /// policy (see workspace AGENTS.md).
208    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
209    pub extra: serde_json::Map<String, serde_json::Value>,
210}
211
212/// An Email object (RFC 8621 §4.1).
213///
214/// Combines metadata (§4.1.1), parsed header convenience properties (§4.1.3),
215/// and body fields (§4.1.4).
216///
217/// # Full vs partial responses
218///
219/// This type is designed for **full `Email/get` responses** where all metadata
220/// properties are present.  The metadata fields `blob_id`, `thread_id`,
221/// `mailbox_ids`, `size`, and `received_at` are required (non-`Option`);
222/// deserialization fails if any of them is absent from the JSON.
223///
224/// RFC 8621 §4.5 allows clients to request only a subset of properties.  If
225/// a partial response omits any required metadata field, `serde_json::from_str`
226/// will return a "missing field" error.  For partial-property responses,
227/// deserialize into `serde_json::Value` first or define a narrower type with
228/// all fields `Option`.
229///
230/// Header convenience properties (§4.1.3) and body fields (§4.1.4) are all
231/// `Option`; they deserialize as `None` when not included in the response.
232///
233/// # Serialization caveat for server implementors
234///
235/// Several collection fields (`keywords`, `body_values`, `text_body`,
236/// `html_body`, `attachments`, `headers`) use
237/// `#[serde(skip_serializing_if = "…::is_empty")]`.  This is correct for
238/// partial responses — a property not in the client's `properties` list MUST be
239/// absent from the response.  However, RFC 8621 §4.1.1 defines `keywords` with
240/// `default: {}`, meaning a server MUST include `"keywords":{}` in the response
241/// when the property was requested and the email has no keywords.
242///
243/// **Do not rely on `serde_json::to_value(email)` to produce RFC-compliant JSON
244/// for full-object responses.**  Server code in `jmap-mail-server` must
245/// explicitly populate any collection fields that are in the requested
246/// `properties` set before serialization, or use a custom serializer that
247/// includes them.
248#[non_exhaustive]
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "camelCase")]
251pub struct Email {
252    // --- Metadata (§4.1.1) ---
253    /// The JMAP object id of this Email.
254    pub id: Id,
255    /// Blob id of the raw RFC 5322 message octets.
256    pub blob_id: Id,
257    /// Id of the Thread this Email belongs to.
258    pub thread_id: Id,
259    /// Set of Mailbox ids this Email belongs to.
260    ///
261    /// Represented as `HashMap<Id, bool>` because the JMAP wire format uses a JSON object
262    /// with boolean values (RFC 8621 §4.1.1).  Values are always `true` in full-object
263    /// responses; the map shape is also used in PatchObject updates (RFC 8620 §5.3) where
264    /// a `null` value removes an entry.
265    pub mailbox_ids: HashMap<Id, bool>,
266    /// Keywords applied to this Email.
267    ///
268    /// Same JSON object shape as `mailbox_ids` (string keys, boolean values) — JMAP wire
269    /// format requirement.  Keys are [`Keyword`] values (not JMAP `Id`s); system keywords
270    /// start with `$` which is not valid inside a JMAP `Id` (RFC 8620 §1.2).
271    /// Values are always `true` in full-object responses (RFC 8621 §4.1.1).
272    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
273    pub keywords: HashMap<Keyword, bool>,
274    /// Size in octets of the raw RFC 5322 message.
275    pub size: u64,
276    /// Date the Email was received by the message store.
277    pub received_at: UTCDate,
278
279    // --- Parsed header convenience properties (§4.1.3) ---
280    /// Value of the Message-ID header field as a list of message ids.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub message_id: Option<Vec<String>>,
283    /// Value of the In-Reply-To header field as a list of message ids.
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub in_reply_to: Option<Vec<String>>,
286    /// Value of the References header field as a list of message ids.
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub references: Option<Vec<String>>,
289    /// Parsed addresses from the Sender header field.
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub sender: Option<Vec<EmailAddress>>,
292    /// Parsed addresses from the From header field.
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub from: Option<Vec<EmailAddress>>,
295    /// Parsed addresses from the To header field.
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub to: Option<Vec<EmailAddress>>,
298    /// Parsed addresses from the Cc header field.
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub cc: Option<Vec<EmailAddress>>,
301    /// Parsed addresses from the Bcc header field.
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub bcc: Option<Vec<EmailAddress>>,
304    /// Parsed addresses from the Reply-To header field.
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub reply_to: Option<Vec<EmailAddress>>,
307    /// Decoded text value of the Subject header field.
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub subject: Option<String>,
310    /// Parsed value of the Date header field (RFC 8621 §4.1.3).
311    ///
312    /// Type `Date` (any RFC 3339 timezone offset) per the RFC.  Email Date
313    /// headers commonly carry non-UTC offsets such as `"+10:00"`.
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub sent_at: Option<Date>,
316
317    // --- Raw headers (§4.1.3) ---
318    /// All header fields of the message in Raw form, in order.
319    #[serde(default, skip_serializing_if = "Vec::is_empty")]
320    pub headers: Vec<EmailHeader>,
321
322    // --- Body fields (§4.1.4) ---
323    /// Map from partId to decoded text content for text body parts.
324    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
325    pub body_values: HashMap<String, EmailBodyValue>,
326    /// Text body parts to display, preferring text/plain.
327    #[serde(default, skip_serializing_if = "Vec::is_empty")]
328    pub text_body: Vec<EmailBodyPart>,
329    /// HTML body parts to display, preferring text/html.
330    #[serde(default, skip_serializing_if = "Vec::is_empty")]
331    pub html_body: Vec<EmailBodyPart>,
332    /// All attachment parts (depth-first, excluding subParts).
333    #[serde(default, skip_serializing_if = "Vec::is_empty")]
334    pub attachments: Vec<EmailBodyPart>,
335    /// Full MIME body structure of the message.
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub body_structure: Option<EmailBodyPart>,
338    /// True if there is at least one downloadable attachment.
339    #[serde(default)]
340    pub has_attachment: bool,
341    /// Short plaintext preview of the message body (≤256 characters).
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub preview: Option<String>,
344    /// Catch-all for vendor / site / private extension fields not covered
345    /// by the typed fields above. Preserves unknown fields across
346    /// deserialize/serialize round-trip per workspace extras-preservation
347    /// policy (see workspace AGENTS.md).
348    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
349    pub extra: serde_json::Map<String, serde_json::Value>,
350}
351
352impl Email {
353    /// Construct an [`Email`] from its six required metadata fields.
354    ///
355    /// All parsed-header and body fields default to `None` / empty.
356    pub fn new(
357        id: Id,
358        blob_id: Id,
359        thread_id: Id,
360        mailbox_ids: HashMap<Id, bool>,
361        size: u64,
362        received_at: UTCDate,
363    ) -> Self {
364        Self {
365            id,
366            blob_id,
367            thread_id,
368            mailbox_ids,
369            keywords: HashMap::new(),
370            size,
371            received_at,
372            message_id: None,
373            in_reply_to: None,
374            references: None,
375            sender: None,
376            from: None,
377            to: None,
378            cc: None,
379            bcc: None,
380            reply_to: None,
381            subject: None,
382            sent_at: None,
383            headers: Vec::new(),
384            body_values: HashMap::new(),
385            text_body: Vec::new(),
386            html_body: Vec::new(),
387            attachments: Vec::new(),
388            body_structure: None,
389            has_attachment: false,
390            preview: None,
391            extra: serde_json::Map::new(),
392        }
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use serde_json::json;
400
401    // ── Extras-preservation policy tests (JMAP-lbdy.2) ───────────────────
402    //
403    // One round-trip preservation test per migrated type. Each test
404    // asserts that an unknown vendor / site / private-extension field
405    // survives deserialize/serialize unchanged. Per workspace
406    // AGENTS.md "Extras-preservation policy for vendor/site fields".
407
408    /// `EmailAddress.extra` captures vendor fields and preserves them.
409    #[test]
410    fn email_address_preserves_vendor_extras() {
411        let raw = json!({
412            "name": "Alice",
413            "email": "alice@example.com",
414            "acmeCorpVerified": true
415        });
416        let addr: EmailAddress = serde_json::from_value(raw).unwrap();
417        assert_eq!(
418            addr.extra.get("acmeCorpVerified").and_then(|v| v.as_bool()),
419            Some(true)
420        );
421        let back = serde_json::to_value(&addr).unwrap();
422        assert_eq!(back["acmeCorpVerified"], true);
423    }
424
425    /// `EmailAddressGroup.extra` captures vendor fields and preserves them.
426    #[test]
427    fn email_address_group_preserves_vendor_extras() {
428        let raw = json!({
429            "name": "Engineering",
430            "addresses": [],
431            "acmeCorpDistributionId": "dl-eng"
432        });
433        let grp: EmailAddressGroup = serde_json::from_value(raw).unwrap();
434        assert_eq!(
435            grp.extra
436                .get("acmeCorpDistributionId")
437                .and_then(|v| v.as_str()),
438            Some("dl-eng")
439        );
440        let back = serde_json::to_value(&grp).unwrap();
441        assert_eq!(back["acmeCorpDistributionId"], "dl-eng");
442    }
443
444    /// `EmailHeader.extra` captures vendor fields and preserves them.
445    #[test]
446    fn email_header_preserves_vendor_extras() {
447        let raw = json!({
448            "name": "X-Custom",
449            "value": "v",
450            "acmeCorpOrigin": "edge-1"
451        });
452        let hdr: EmailHeader = serde_json::from_value(raw).unwrap();
453        assert_eq!(
454            hdr.extra.get("acmeCorpOrigin").and_then(|v| v.as_str()),
455            Some("edge-1")
456        );
457        let back = serde_json::to_value(&hdr).unwrap();
458        assert_eq!(back["acmeCorpOrigin"], "edge-1");
459    }
460
461    /// `EmailBodyValue.extra` captures vendor fields and preserves them.
462    #[test]
463    fn email_body_value_preserves_vendor_extras() {
464        let raw = json!({
465            "value": "hello",
466            "isEncodingProblem": false,
467            "isTruncated": false,
468            "acmeCorpScanResult": "clean"
469        });
470        let bv: EmailBodyValue = serde_json::from_value(raw).unwrap();
471        assert_eq!(
472            bv.extra.get("acmeCorpScanResult").and_then(|v| v.as_str()),
473            Some("clean")
474        );
475        let back = serde_json::to_value(&bv).unwrap();
476        assert_eq!(back["acmeCorpScanResult"], "clean");
477    }
478
479    /// `EmailBodyPart.extra` captures vendor fields and preserves them.
480    #[test]
481    fn email_body_part_preserves_vendor_extras() {
482        let raw = json!({
483            "partId": "1",
484            "blobId": "b1",
485            "size": 42,
486            "type": "text/plain",
487            "acmeCorpChecksum": "sha256:deadbeef"
488        });
489        let part: EmailBodyPart = serde_json::from_value(raw).unwrap();
490        assert_eq!(
491            part.extra.get("acmeCorpChecksum").and_then(|v| v.as_str()),
492            Some("sha256:deadbeef")
493        );
494        let back = serde_json::to_value(&part).unwrap();
495        assert_eq!(back["acmeCorpChecksum"], "sha256:deadbeef");
496    }
497
498    /// `Email.extra` captures vendor fields and preserves them across
499    /// deserialize/serialize round-trip.
500    #[test]
501    fn email_preserves_vendor_extras() {
502        let raw = json!({
503            "id": "e1",
504            "blobId": "b1",
505            "threadId": "t1",
506            "mailboxIds": {"m1": true},
507            "size": 1024,
508            "receivedAt": "2024-06-01T00:00:00Z",
509            "acmeCorpClassification": {"label": "internal", "score": 0.9}
510        });
511        let email: Email = serde_json::from_value(raw).unwrap();
512        assert_eq!(
513            email
514                .extra
515                .get("acmeCorpClassification")
516                .and_then(|v| v["label"].as_str()),
517            Some("internal")
518        );
519        let back = serde_json::to_value(&email).unwrap();
520        assert_eq!(back["acmeCorpClassification"]["score"], 0.9);
521    }
522
523    /// Empty extras must NOT serialize as a key on the wire — wire shape
524    /// is byte-identical to the pre-migration form when no vendor fields
525    /// are present.
526    #[test]
527    fn email_address_empty_extras_omitted_from_wire() {
528        let addr = EmailAddress::new("a@b");
529        let serialized = serde_json::to_value(&addr).unwrap();
530        let obj = serialized.as_object().expect("must be object");
531        // Only "email" — name is None and is skip_serializing_if; extra is empty.
532        assert_eq!(
533            obj.len(),
534            1,
535            "empty extras must not add wire keys; got {serialized}"
536        );
537        assert!(obj.contains_key("email"));
538    }
539
540    /// Regression test locking in the wire-key-collision contract.
541    ///
542    /// Workspace AGENTS.md ("Extras-preservation policy" → "Caller
543    /// contract — wire-key collisions") documents that a caller MUST
544    /// NOT insert a key into `extra` whose name matches a typed field
545    /// on the same struct. This test asserts the observable behavior
546    /// that justifies the doc warning:
547    ///
548    /// 1. Serialize succeeds and emits both the typed field and the
549    ///    `extra` entry (no dedup by serde-flatten).
550    /// 2. Deserialize on the same bytes rejects with `duplicate field`.
551    ///
552    /// If a future serde or serde_json release changes either behavior
553    /// (e.g. silently drops the typed field, silently drops the flatten
554    /// entry, accepts duplicate keys), this test will fail loudly so
555    /// the workspace policy can be re-evaluated rather than silently
556    /// drift.
557    ///
558    /// Independent oracle: the expected JSON shape (two `"email"`
559    /// keys) is constructed by string concatenation, not by the code
560    /// under test.
561    #[test]
562    fn extra_collision_with_typed_field_round_trip_fails() {
563        let mut addr = EmailAddress::new("real@example.com");
564        addr.extra.insert(
565            "email".into(),
566            serde_json::Value::from("override@example.com"),
567        );
568
569        // (1) Serialize succeeds and emits both keys.
570        let serialised = serde_json::to_string(&addr).expect("serialize must succeed");
571        // Independent oracle: the bytes must contain two "email" keys.
572        let occurrences = serialised.matches("\"email\":").count();
573        assert_eq!(
574            occurrences, 2,
575            "wire output must contain two duplicate keys; got {serialised}"
576        );
577
578        // (2) Deserialize on the same bytes must reject with duplicate
579        // field. RFC 8259 §4 calls duplicate keys "unpredictable" and
580        // the workspace's strict-deserialize stance rejects them.
581        let err = serde_json::from_str::<EmailAddress>(&serialised)
582            .expect_err("deserialize must reject duplicate-key wire form");
583        assert!(
584            err.to_string().contains("duplicate field"),
585            "error must mention duplicate field; got: {err}"
586        );
587    }
588
589    /// Stress test for the workspace extras-preservation contract.
590    ///
591    /// The per-type `*_preserves_vendor_extras` tests above each insert one
592    /// scalar/shallow vendor field. This test exercises three round-trip
593    /// paths that the per-type tests do not:
594    ///
595    /// 1. **Multiple vendor fields on the same object** — if serde-flatten
596    ///    + Map had a single-key happy-path bias, a two-key bug would slip
597    ///    past the per-type tests.
598    /// 2. **Nested-object + nested-array extras** — exercises
599    ///    `serde_json::Value`'s recursive round-trip beyond one level of
600    ///    depth.
601    /// 3. **Byte-level string round-trip** — every per-type test uses
602    ///    `from_value` / `to_value`, which skips the tokenizer. This test
603    ///    goes through `to_string` → `from_str` → `to_string` and asserts
604    ///    every vendor field key is preserved on both serialisations.
605    ///
606    /// `Email` is the canonical extension-types template (workspace
607    /// AGENTS.md "Canonical Templates"), so one comprehensive test here
608    /// covers every cookie-cut sibling that follows the same pattern.
609    ///
610    /// Independent oracle: hand-written JSON; assertions compare keys
611    /// and shape, not byte equality (object key order is not guaranteed
612    /// to be preserved across HashMap iteration, but every key MUST be
613    /// retained).
614    #[test]
615    fn email_extras_multi_field_nested_and_string_roundtrip() {
616        let raw_str = r#"{
617            "id": "e1",
618            "blobId": "b1",
619            "threadId": "t1",
620            "mailboxIds": {"m1": true},
621            "size": 1024,
622            "receivedAt": "2024-06-01T00:00:00Z",
623            "acmeCorpFoo": "bar",
624            "siteHint": "high-priority",
625            "acmeCorpNested": {
626                "version": 2,
627                "signed": [
628                    {"by": "alice", "at": "2024-06-01T00:00:00Z"},
629                    {"by": "bob", "at": "2024-06-01T00:01:00Z"}
630                ],
631                "tags": ["x", "y", "z"]
632            }
633        }"#;
634
635        // Gap 3: byte-level from_str (not from_value) — exercises the
636        // streaming tokenizer + serde-flatten's interaction with it.
637        let email: Email =
638            serde_json::from_str(raw_str).expect("from_str must accept the wire form");
639
640        // Gap 1: every vendor key must survive deserialize.
641        assert!(
642            email.extra.contains_key("acmeCorpFoo"),
643            "scalar vendor field lost"
644        );
645        assert!(
646            email.extra.contains_key("siteHint"),
647            "second scalar vendor field lost"
648        );
649        assert!(
650            email.extra.contains_key("acmeCorpNested"),
651            "nested vendor field lost"
652        );
653        assert_eq!(
654            email.extra.len(),
655            3,
656            "vendor key count must be exactly three; got {:?}",
657            email.extra.keys().collect::<Vec<_>>()
658        );
659
660        // Gap 2: nested object structure must be preserved verbatim.
661        let nested = email
662            .extra
663            .get("acmeCorpNested")
664            .expect("acmeCorpNested key present");
665        assert_eq!(nested["version"], 2);
666        assert_eq!(nested["signed"][0]["by"], "alice");
667        assert_eq!(nested["signed"][1]["by"], "bob");
668        assert_eq!(nested["tags"][2], "z");
669
670        // Gap 3 (cont.): byte-level to_string → from_str → re-parse must
671        // preserve every vendor key. We do not assert byte equality on
672        // the serialised string (HashMap iteration order is not stable),
673        // but every vendor key MUST be present on the second parse and
674        // the nested structure MUST round-trip intact.
675        let serialised = serde_json::to_string(&email).expect("to_string must succeed");
676        let reparsed: Email =
677            serde_json::from_str(&serialised).expect("from_str must re-accept own output");
678        assert_eq!(reparsed.extra.len(), 3);
679        assert_eq!(
680            reparsed.extra.get("acmeCorpFoo").and_then(|v| v.as_str()),
681            Some("bar")
682        );
683        assert_eq!(
684            reparsed.extra.get("siteHint").and_then(|v| v.as_str()),
685            Some("high-priority")
686        );
687        let nested2 = reparsed.extra.get("acmeCorpNested").expect("present");
688        assert_eq!(nested2["signed"][0]["by"], "alice");
689        assert_eq!(nested2["tags"][2], "z");
690    }
691}