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}