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::BTreeMap;
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 `BTreeMap<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: BTreeMap<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)]
273    pub keywords: BTreeMap<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 = "BTreeMap::is_empty")]
325    pub body_values: BTreeMap<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
345    // --- draft-ietf-jmap-metadata-04 §2 ---
346    /// Shared metadata, keyed by namespace identifier
347    /// (draft-ietf-jmap-metadata-04 §2). Each value is a JSON object (§2.1).
348    /// `None` when absent, which -04 §2 treats as `{}`; in an update a
349    /// `null` value "is equivalent to {}" (-04 §3.2) and also decodes as
350    /// `None`.
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
353    /// The authenticated user's own metadata, keyed by namespace
354    /// identifier (draft-ietf-jmap-metadata-04 §2). Same shape and `None`
355    /// handling as `metadata`.
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub private_metadata: Option<serde_json::Map<String, serde_json::Value>>,
358    /// Catch-all for vendor / site / private extension fields not covered
359    /// by the typed fields above. Preserves unknown fields across
360    /// deserialize/serialize round-trip per workspace extras-preservation
361    /// policy (see workspace AGENTS.md).
362    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
363    pub extra: serde_json::Map<String, serde_json::Value>,
364}
365
366impl Email {
367    /// Construct an [`Email`] from its six required metadata fields.
368    ///
369    /// All parsed-header and body fields default to `None` / empty.
370    pub fn new(
371        id: Id,
372        blob_id: Id,
373        thread_id: Id,
374        mailbox_ids: BTreeMap<Id, bool>,
375        size: u64,
376        received_at: UTCDate,
377    ) -> Self {
378        Self {
379            id,
380            blob_id,
381            thread_id,
382            mailbox_ids,
383            keywords: BTreeMap::new(),
384            size,
385            received_at,
386            message_id: None,
387            in_reply_to: None,
388            references: None,
389            sender: None,
390            from: None,
391            to: None,
392            cc: None,
393            bcc: None,
394            reply_to: None,
395            subject: None,
396            sent_at: None,
397            headers: Vec::new(),
398            body_values: BTreeMap::new(),
399            text_body: Vec::new(),
400            html_body: Vec::new(),
401            attachments: Vec::new(),
402            body_structure: None,
403            has_attachment: false,
404            preview: None,
405            metadata: None,
406            private_metadata: None,
407            extra: serde_json::Map::new(),
408        }
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use serde_json::json;
416
417    // ── Extras-preservation policy tests (JMAP-lbdy.2) ───────────────────
418    //
419    // One round-trip preservation test per migrated type. Each test
420    // asserts that an unknown vendor / site / private-extension field
421    // survives deserialize/serialize unchanged. Per workspace
422    // AGENTS.md "Extras-preservation policy for vendor/site fields".
423
424    /// `EmailAddress.extra` captures vendor fields and preserves them.
425    #[test]
426    fn email_address_preserves_vendor_extras() {
427        let raw = json!({
428            "name": "Alice",
429            "email": "alice@example.com",
430            "acmeCorpVerified": true
431        });
432        let addr: EmailAddress = serde_json::from_value(raw).unwrap();
433        assert_eq!(
434            addr.extra.get("acmeCorpVerified").and_then(|v| v.as_bool()),
435            Some(true)
436        );
437        let back = serde_json::to_value(&addr).unwrap();
438        assert_eq!(back["acmeCorpVerified"], true);
439    }
440
441    /// `EmailAddressGroup.extra` captures vendor fields and preserves them.
442    #[test]
443    fn email_address_group_preserves_vendor_extras() {
444        let raw = json!({
445            "name": "Engineering",
446            "addresses": [],
447            "acmeCorpDistributionId": "dl-eng"
448        });
449        let grp: EmailAddressGroup = serde_json::from_value(raw).unwrap();
450        assert_eq!(
451            grp.extra
452                .get("acmeCorpDistributionId")
453                .and_then(|v| v.as_str()),
454            Some("dl-eng")
455        );
456        let back = serde_json::to_value(&grp).unwrap();
457        assert_eq!(back["acmeCorpDistributionId"], "dl-eng");
458    }
459
460    /// `EmailHeader.extra` captures vendor fields and preserves them.
461    #[test]
462    fn email_header_preserves_vendor_extras() {
463        let raw = json!({
464            "name": "X-Custom",
465            "value": "v",
466            "acmeCorpOrigin": "edge-1"
467        });
468        let hdr: EmailHeader = serde_json::from_value(raw).unwrap();
469        assert_eq!(
470            hdr.extra.get("acmeCorpOrigin").and_then(|v| v.as_str()),
471            Some("edge-1")
472        );
473        let back = serde_json::to_value(&hdr).unwrap();
474        assert_eq!(back["acmeCorpOrigin"], "edge-1");
475    }
476
477    /// `EmailBodyValue.extra` captures vendor fields and preserves them.
478    #[test]
479    fn email_body_value_preserves_vendor_extras() {
480        let raw = json!({
481            "value": "hello",
482            "isEncodingProblem": false,
483            "isTruncated": false,
484            "acmeCorpScanResult": "clean"
485        });
486        let bv: EmailBodyValue = serde_json::from_value(raw).unwrap();
487        assert_eq!(
488            bv.extra.get("acmeCorpScanResult").and_then(|v| v.as_str()),
489            Some("clean")
490        );
491        let back = serde_json::to_value(&bv).unwrap();
492        assert_eq!(back["acmeCorpScanResult"], "clean");
493    }
494
495    /// `EmailBodyPart.extra` captures vendor fields and preserves them.
496    #[test]
497    fn email_body_part_preserves_vendor_extras() {
498        let raw = json!({
499            "partId": "1",
500            "blobId": "b1",
501            "size": 42,
502            "type": "text/plain",
503            "acmeCorpChecksum": "sha256:deadbeef"
504        });
505        let part: EmailBodyPart = serde_json::from_value(raw).unwrap();
506        assert_eq!(
507            part.extra.get("acmeCorpChecksum").and_then(|v| v.as_str()),
508            Some("sha256:deadbeef")
509        );
510        let back = serde_json::to_value(&part).unwrap();
511        assert_eq!(back["acmeCorpChecksum"], "sha256:deadbeef");
512    }
513
514    /// `Email.extra` captures vendor fields and preserves them across
515    /// deserialize/serialize round-trip.
516    #[test]
517    fn email_preserves_vendor_extras() {
518        let raw = json!({
519            "id": "e1",
520            "blobId": "b1",
521            "threadId": "t1",
522            "mailboxIds": {"m1": true},
523            "size": 1024,
524            "receivedAt": "2024-06-01T00:00:00Z",
525            "acmeCorpClassification": {"label": "internal", "score": 0.9}
526        });
527        let email: Email = serde_json::from_value(raw).unwrap();
528        assert_eq!(
529            email
530                .extra
531                .get("acmeCorpClassification")
532                .and_then(|v| v["label"].as_str()),
533            Some("internal")
534        );
535        let back = serde_json::to_value(&email).unwrap();
536        assert_eq!(back["acmeCorpClassification"]["score"], 0.9);
537    }
538
539    /// Empty extras must NOT serialize as a key on the wire — wire shape
540    /// is byte-identical to the pre-migration form when no vendor fields
541    /// are present.
542    #[test]
543    fn email_address_empty_extras_omitted_from_wire() {
544        let addr = EmailAddress::new("a@b");
545        let serialized = serde_json::to_value(&addr).unwrap();
546        let obj = serialized.as_object().expect("must be object");
547        // Only "email" — name is None and is skip_serializing_if; extra is empty.
548        assert_eq!(
549            obj.len(),
550            1,
551            "empty extras must not add wire keys; got {serialized}"
552        );
553        assert!(obj.contains_key("email"));
554    }
555
556    /// Regression test locking in the wire-key-collision contract.
557    ///
558    /// Workspace AGENTS.md ("Extras-preservation policy" → "Caller
559    /// contract — wire-key collisions") documents that a caller MUST
560    /// NOT insert a key into `extra` whose name matches a typed field
561    /// on the same struct. This test asserts the observable behavior
562    /// that justifies the doc warning:
563    ///
564    /// 1. Serialize succeeds and emits both the typed field and the
565    ///    `extra` entry (no dedup by serde-flatten).
566    /// 2. Deserialize on the same bytes rejects with `duplicate field`.
567    ///
568    /// If a future serde or serde_json release changes either behavior
569    /// (e.g. silently drops the typed field, silently drops the flatten
570    /// entry, accepts duplicate keys), this test will fail loudly so
571    /// the workspace policy can be re-evaluated rather than silently
572    /// drift.
573    ///
574    /// Independent oracle: the expected JSON shape (two `"email"`
575    /// keys) is constructed by string concatenation, not by the code
576    /// under test.
577    #[test]
578    fn extra_collision_with_typed_field_round_trip_fails() {
579        let mut addr = EmailAddress::new("real@example.com");
580        addr.extra.insert(
581            "email".into(),
582            serde_json::Value::from("override@example.com"),
583        );
584
585        // (1) Serialize succeeds and emits both keys.
586        let serialised = serde_json::to_string(&addr).expect("serialize must succeed");
587        // Independent oracle: the bytes must contain two "email" keys.
588        let occurrences = serialised.matches("\"email\":").count();
589        assert_eq!(
590            occurrences, 2,
591            "wire output must contain two duplicate keys; got {serialised}"
592        );
593
594        // (2) Deserialize on the same bytes must reject with duplicate
595        // field. RFC 8259 §4 calls duplicate keys "unpredictable" and
596        // the workspace's strict-deserialize stance rejects them.
597        let err = serde_json::from_str::<EmailAddress>(&serialised)
598            .expect_err("deserialize must reject duplicate-key wire form");
599        assert!(
600            err.to_string().contains("duplicate field"),
601            "error must mention duplicate field; got: {err}"
602        );
603    }
604
605    /// Stress test for the workspace extras-preservation contract.
606    ///
607    /// The per-type `*_preserves_vendor_extras` tests above each insert one
608    /// scalar/shallow vendor field. This test exercises three round-trip
609    /// paths that the per-type tests do not:
610    ///
611    /// 1. **Multiple vendor fields on the same object** — if serde-flatten
612    ///    plus Map had a single-key happy-path bias, a two-key bug would
613    ///    slip past the per-type tests.
614    /// 2. **Nested-object + nested-array extras** — exercises
615    ///    `serde_json::Value`'s recursive round-trip beyond one level of
616    ///    depth.
617    /// 3. **Byte-level string round-trip** — every per-type test uses
618    ///    `from_value` / `to_value`, which skips the tokenizer. This test
619    ///    goes through `to_string` → `from_str` → `to_string` and asserts
620    ///    every vendor field key is preserved on both serialisations.
621    ///
622    /// `Email` is the canonical extension-types template (workspace
623    /// AGENTS.md "Canonical Templates"), so one comprehensive test here
624    /// covers every cookie-cut sibling that follows the same pattern.
625    ///
626    /// Independent oracle: hand-written JSON; assertions compare keys
627    /// and shape, not byte equality (`extra` is a `serde_json::Map`, whose
628    /// key order depends on the `serde_json/preserve_order` feature, but
629    /// every key MUST be retained).
630    #[test]
631    fn email_extras_multi_field_nested_and_string_roundtrip() {
632        let raw_str = r#"{
633            "id": "e1",
634            "blobId": "b1",
635            "threadId": "t1",
636            "mailboxIds": {"m1": true},
637            "size": 1024,
638            "receivedAt": "2024-06-01T00:00:00Z",
639            "acmeCorpFoo": "bar",
640            "siteHint": "high-priority",
641            "acmeCorpNested": {
642                "version": 2,
643                "signed": [
644                    {"by": "alice", "at": "2024-06-01T00:00:00Z"},
645                    {"by": "bob", "at": "2024-06-01T00:01:00Z"}
646                ],
647                "tags": ["x", "y", "z"]
648            }
649        }"#;
650
651        // Gap 3: byte-level from_str (not from_value) — exercises the
652        // streaming tokenizer + serde-flatten's interaction with it.
653        let email: Email =
654            serde_json::from_str(raw_str).expect("from_str must accept the wire form");
655
656        // Gap 1: every vendor key must survive deserialize.
657        assert!(
658            email.extra.contains_key("acmeCorpFoo"),
659            "scalar vendor field lost"
660        );
661        assert!(
662            email.extra.contains_key("siteHint"),
663            "second scalar vendor field lost"
664        );
665        assert!(
666            email.extra.contains_key("acmeCorpNested"),
667            "nested vendor field lost"
668        );
669        assert_eq!(
670            email.extra.len(),
671            3,
672            "vendor key count must be exactly three; got {:?}",
673            email.extra.keys().collect::<Vec<_>>()
674        );
675
676        // Gap 2: nested object structure must be preserved verbatim.
677        let nested = email
678            .extra
679            .get("acmeCorpNested")
680            .expect("acmeCorpNested key present");
681        assert_eq!(nested["version"], 2);
682        assert_eq!(nested["signed"][0]["by"], "alice");
683        assert_eq!(nested["signed"][1]["by"], "bob");
684        assert_eq!(nested["tags"][2], "z");
685
686        // Gap 3 (cont.): byte-level to_string → from_str → re-parse must
687        // preserve every vendor key. We do not assert byte equality on
688        // the serialised string (`extra` key order is feature-dependent),
689        // but every vendor key MUST be present on the second parse and
690        // the nested structure MUST round-trip intact.
691        let serialised = serde_json::to_string(&email).expect("to_string must succeed");
692        let reparsed: Email =
693            serde_json::from_str(&serialised).expect("from_str must re-accept own output");
694        assert_eq!(reparsed.extra.len(), 3);
695        assert_eq!(
696            reparsed.extra.get("acmeCorpFoo").and_then(|v| v.as_str()),
697            Some("bar")
698        );
699        assert_eq!(
700            reparsed.extra.get("siteHint").and_then(|v| v.as_str()),
701            Some("high-priority")
702        );
703        let nested2 = reparsed.extra.get("acmeCorpNested").expect("present");
704        assert_eq!(nested2["signed"][0]["by"], "alice");
705        assert_eq!(nested2["tags"][2], "z");
706    }
707
708    /// Oracle: workspace map-ordering policy (bd:JMAP-pdux) — wire-format
709    /// maps serialize in sorted key order. Expected bytes are hand-written.
710    #[test]
711    fn email_maps_serialize_in_sorted_key_order() {
712        let mut mailbox_ids = BTreeMap::new();
713        mailbox_ids.insert(Id::from("mb2"), true);
714        mailbox_ids.insert(Id::from("mb1"), true);
715        let received_at: jmap_types::UTCDate =
716            serde_json::from_str(r#""2024-01-01T00:00:00Z""#).expect("UTCDate");
717        let mut email = Email::new(
718            Id::from("M1"),
719            Id::from("G1"),
720            Id::from("T1"),
721            mailbox_ids,
722            1,
723            received_at,
724        );
725        email.keywords.insert(Keyword::from("$seen"), true);
726        email.keywords.insert(Keyword::from("$draft"), true);
727        let j = serde_json::to_string(&email).expect("serialize");
728        assert!(
729            j.contains(r#""mailboxIds":{"mb1":true,"mb2":true}"#),
730            "mailboxIds keys must be sorted: {j}"
731        );
732        assert!(
733            j.contains(r#""keywords":{"$draft":true,"$seen":true}"#),
734            "keywords keys must be sorted: {j}"
735        );
736    }
737
738    /// Oracle: -04 §2 adds `metadata` and `privateMetadata` (objects keyed
739    /// by namespace) to Email; hand-written JSON. The values land in the
740    /// typed fields, not `extra`, and round-trip. -04 §3.2: in an update
741    /// "null is equivalent to {}", so `null` decodes as absent.
742    #[test]
743    fn email_metadata_properties() {
744        let mut raw = json!({
745            "id": "e1",
746            "blobId": "b1",
747            "threadId": "t1",
748            "mailboxIds": {"m1": true},
749            "size": 10,
750            "receivedAt": "2026-01-01T00:00:00Z",
751            "metadata": {"acme.example.com": {"memo": "follow up"}},
752            "privateMetadata": {"acme.example.com": {"flag": true}}
753        });
754        let email: Email = serde_json::from_value(raw.clone()).unwrap();
755        assert_eq!(
756            email
757                .metadata
758                .as_ref()
759                .map(|m| &m["acme.example.com"]["memo"]),
760            Some(&json!("follow up"))
761        );
762        assert_eq!(
763            email
764                .private_metadata
765                .as_ref()
766                .map(|m| &m["acme.example.com"]["flag"]),
767            Some(&json!(true))
768        );
769        assert!(email.extra.is_empty());
770        let back = serde_json::to_value(&email).unwrap();
771        assert_eq!(back["metadata"], raw["metadata"]);
772        assert_eq!(back["privateMetadata"], raw["privateMetadata"]);
773
774        raw["privateMetadata"] = json!(null);
775        let email: Email = serde_json::from_value(raw.clone()).unwrap();
776        assert_eq!(email.private_metadata, None);
777
778        raw["metadata"] = json!([1]);
779        assert!(serde_json::from_value::<Email>(raw).is_err());
780    }
781}