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}