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}