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}
34
35impl EmailAddress {
36 /// Construct an [`EmailAddress`] with no display name.
37 pub fn new(email: impl Into<String>) -> Self {
38 Self {
39 name: None,
40 email: email.into(),
41 }
42 }
43}
44
45/// A named group of email addresses (RFC 8621 §4.1.2.4).
46///
47/// Preserves RFC 5322 group structure. Consecutive mailboxes not part of
48/// a named group are collected under an `EmailAddressGroup` with `name: null`.
49#[non_exhaustive]
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct EmailAddressGroup {
53 /// The decoded display-name of the group, or `null` for ungrouped mailboxes.
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub name: Option<String>,
56 /// The mailboxes that belong to this group.
57 pub addresses: Vec<EmailAddress>,
58}
59
60impl EmailAddressGroup {
61 /// Construct an [`EmailAddressGroup`] with no group name.
62 pub fn new(addresses: Vec<EmailAddress>) -> Self {
63 Self {
64 name: None,
65 addresses,
66 }
67 }
68}
69
70/// A single RFC 5322 header field (RFC 8621 §4.1.3).
71///
72/// The `name` retains original capitalisation; `value` is the raw field value.
73#[non_exhaustive]
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "camelCase")]
76pub struct EmailHeader {
77 /// The header field name (e.g. `"Content-Type"`), case-preserved.
78 pub name: String,
79 /// The header field value in Raw form.
80 pub value: String,
81}
82
83impl EmailHeader {
84 /// Construct an [`EmailHeader`] from its name and value.
85 pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
86 Self {
87 name: name.into(),
88 value: value.into(),
89 }
90 }
91}
92
93/// The decoded text content of one body part (RFC 8621 §4.1.4).
94///
95/// Returned inside the `bodyValues` map of an Email object.
96#[non_exhaustive]
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "camelCase")]
99pub struct EmailBodyValue {
100 /// The decoded text content of the part.
101 pub value: String,
102 /// `true` if charset decoding or content-transfer-encoding decoding
103 /// encountered errors (RFC 8621 §4.1.4).
104 ///
105 /// Always present in serialized output (no `skip_serializing_if`); RFC 8621 §4.1.4
106 /// requires both flags in the `bodyValues` map. `#[serde(default)]` handles
107 /// deserialization when absent (treated as `false`).
108 #[serde(default)]
109 pub is_encoding_problem: bool,
110 /// `true` if `value` was truncated due to a `maxBodyValueBytes` limit
111 /// (RFC 8621 §4.1.4).
112 ///
113 /// Always present in serialized output; same rationale as `is_encoding_problem`.
114 #[serde(default)]
115 pub is_truncated: bool,
116}
117
118impl EmailBodyValue {
119 /// Construct an [`EmailBodyValue`] with the given text content.
120 ///
121 /// `is_encoding_problem` and `is_truncated` default to `false`.
122 pub fn new(value: impl Into<String>) -> Self {
123 Self {
124 value: value.into(),
125 is_encoding_problem: false,
126 is_truncated: false,
127 }
128 }
129}
130
131/// One MIME body part within an Email (RFC 8621 §4.1.4).
132///
133/// The `sub_parts` field is recursive: multipart bodies nest further
134/// `EmailBodyPart` values.
135#[non_exhaustive]
136#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct EmailBodyPart {
139 /// Uniquely identifies this part within the Email (null for multipart/*).
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub part_id: Option<String>,
142 /// Blob id of the decoded part content (null for multipart/*).
143 #[serde(skip_serializing_if = "Option::is_none")]
144 pub blob_id: Option<Id>,
145 /// Size in octets of the decoded content.
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub size: Option<u64>,
148 /// All header fields of the part in Raw form, in order.
149 #[serde(default, skip_serializing_if = "Vec::is_empty")]
150 pub headers: Vec<EmailHeader>,
151 /// Decoded filename from Content-Disposition or Content-Type parameters.
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub name: Option<String>,
154 /// MIME content type (e.g. `"text/plain"`).
155 // `type` is a Rust keyword; the trailing underscore is the conventional escape.
156 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
157 pub type_: Option<String>,
158 /// Charset parameter of the Content-Type header field.
159 #[serde(skip_serializing_if = "Option::is_none")]
160 pub charset: Option<String>,
161 /// Value of the Content-Disposition header field (parameters stripped).
162 #[serde(skip_serializing_if = "Option::is_none")]
163 pub disposition: Option<String>,
164 /// Content-Id value with CFWS and angle brackets removed.
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub cid: Option<String>,
167 /// Language tags from the Content-Language header field.
168 #[serde(skip_serializing_if = "Option::is_none")]
169 pub language: Option<Vec<String>>,
170 /// URI from the Content-Location header field.
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub location: Option<String>,
173 /// Child parts when `type_` is `"multipart/*"`.
174 #[serde(skip_serializing_if = "Option::is_none")]
175 pub sub_parts: Option<Vec<EmailBodyPart>>,
176}
177
178/// An Email object (RFC 8621 §4.1).
179///
180/// Combines metadata (§4.1.1), parsed header convenience properties (§4.1.3),
181/// and body fields (§4.1.4).
182///
183/// # Full vs partial responses
184///
185/// This type is designed for **full `Email/get` responses** where all metadata
186/// properties are present. The metadata fields `blob_id`, `thread_id`,
187/// `mailbox_ids`, `size`, and `received_at` are required (non-`Option`);
188/// deserialization fails if any of them is absent from the JSON.
189///
190/// RFC 8621 §4.5 allows clients to request only a subset of properties. If
191/// a partial response omits any required metadata field, `serde_json::from_str`
192/// will return a "missing field" error. For partial-property responses,
193/// deserialize into `serde_json::Value` first or define a narrower type with
194/// all fields `Option`.
195///
196/// Header convenience properties (§4.1.3) and body fields (§4.1.4) are all
197/// `Option`; they deserialize as `None` when not included in the response.
198///
199/// # Serialization caveat for server implementors
200///
201/// Several collection fields (`keywords`, `body_values`, `text_body`,
202/// `html_body`, `attachments`, `headers`) use
203/// `#[serde(skip_serializing_if = "…::is_empty")]`. This is correct for
204/// partial responses — a property not in the client's `properties` list MUST be
205/// absent from the response. However, RFC 8621 §4.1.1 defines `keywords` with
206/// `default: {}`, meaning a server MUST include `"keywords":{}` in the response
207/// when the property was requested and the email has no keywords.
208///
209/// **Do not rely on `serde_json::to_value(email)` to produce RFC-compliant JSON
210/// for full-object responses.** Server code in `jmap-mail-server` must
211/// explicitly populate any collection fields that are in the requested
212/// `properties` set before serialization, or use a custom serializer that
213/// includes them.
214#[non_exhaustive]
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "camelCase")]
217pub struct Email {
218 // --- Metadata (§4.1.1) ---
219 /// The JMAP object id of this Email.
220 pub id: Id,
221 /// Blob id of the raw RFC 5322 message octets.
222 pub blob_id: Id,
223 /// Id of the Thread this Email belongs to.
224 pub thread_id: Id,
225 /// Set of Mailbox ids this Email belongs to.
226 ///
227 /// Represented as `HashMap<Id, bool>` because the JMAP wire format uses a JSON object
228 /// with boolean values (RFC 8621 §4.1.1). Values are always `true` in full-object
229 /// responses; the map shape is also used in PatchObject updates (RFC 8620 §5.3) where
230 /// a `null` value removes an entry.
231 pub mailbox_ids: HashMap<Id, bool>,
232 /// Keywords applied to this Email.
233 ///
234 /// Same JSON object shape as `mailbox_ids` (string keys, boolean values) — JMAP wire
235 /// format requirement. Keys are [`Keyword`] values (not JMAP `Id`s); system keywords
236 /// start with `$` which is not valid inside a JMAP `Id` (RFC 8620 §1.2).
237 /// Values are always `true` in full-object responses (RFC 8621 §4.1.1).
238 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
239 pub keywords: HashMap<Keyword, bool>,
240 /// Size in octets of the raw RFC 5322 message.
241 pub size: u64,
242 /// Date the Email was received by the message store.
243 pub received_at: UTCDate,
244
245 // --- Parsed header convenience properties (§4.1.3) ---
246 /// Value of the Message-ID header field as a list of message ids.
247 #[serde(skip_serializing_if = "Option::is_none")]
248 pub message_id: Option<Vec<String>>,
249 /// Value of the In-Reply-To header field as a list of message ids.
250 #[serde(skip_serializing_if = "Option::is_none")]
251 pub in_reply_to: Option<Vec<String>>,
252 /// Value of the References header field as a list of message ids.
253 #[serde(skip_serializing_if = "Option::is_none")]
254 pub references: Option<Vec<String>>,
255 /// Parsed addresses from the Sender header field.
256 #[serde(skip_serializing_if = "Option::is_none")]
257 pub sender: Option<Vec<EmailAddress>>,
258 /// Parsed addresses from the From header field.
259 #[serde(skip_serializing_if = "Option::is_none")]
260 pub from: Option<Vec<EmailAddress>>,
261 /// Parsed addresses from the To header field.
262 #[serde(skip_serializing_if = "Option::is_none")]
263 pub to: Option<Vec<EmailAddress>>,
264 /// Parsed addresses from the Cc header field.
265 #[serde(skip_serializing_if = "Option::is_none")]
266 pub cc: Option<Vec<EmailAddress>>,
267 /// Parsed addresses from the Bcc header field.
268 #[serde(skip_serializing_if = "Option::is_none")]
269 pub bcc: Option<Vec<EmailAddress>>,
270 /// Parsed addresses from the Reply-To header field.
271 #[serde(skip_serializing_if = "Option::is_none")]
272 pub reply_to: Option<Vec<EmailAddress>>,
273 /// Decoded text value of the Subject header field.
274 #[serde(skip_serializing_if = "Option::is_none")]
275 pub subject: Option<String>,
276 /// Parsed value of the Date header field (RFC 8621 §4.1.3).
277 ///
278 /// Type `Date` (any RFC 3339 timezone offset) per the RFC. Email Date
279 /// headers commonly carry non-UTC offsets such as `"+10:00"`.
280 #[serde(skip_serializing_if = "Option::is_none")]
281 pub sent_at: Option<Date>,
282
283 // --- Raw headers (§4.1.3) ---
284 /// All header fields of the message in Raw form, in order.
285 #[serde(default, skip_serializing_if = "Vec::is_empty")]
286 pub headers: Vec<EmailHeader>,
287
288 // --- Body fields (§4.1.4) ---
289 /// Map from partId to decoded text content for text body parts.
290 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
291 pub body_values: HashMap<String, EmailBodyValue>,
292 /// Text body parts to display, preferring text/plain.
293 #[serde(default, skip_serializing_if = "Vec::is_empty")]
294 pub text_body: Vec<EmailBodyPart>,
295 /// HTML body parts to display, preferring text/html.
296 #[serde(default, skip_serializing_if = "Vec::is_empty")]
297 pub html_body: Vec<EmailBodyPart>,
298 /// All attachment parts (depth-first, excluding subParts).
299 #[serde(default, skip_serializing_if = "Vec::is_empty")]
300 pub attachments: Vec<EmailBodyPart>,
301 /// Full MIME body structure of the message.
302 #[serde(skip_serializing_if = "Option::is_none")]
303 pub body_structure: Option<EmailBodyPart>,
304 /// True if there is at least one downloadable attachment.
305 #[serde(default)]
306 pub has_attachment: bool,
307 /// Short plaintext preview of the message body (≤256 characters).
308 #[serde(skip_serializing_if = "Option::is_none")]
309 pub preview: Option<String>,
310}
311
312impl Email {
313 /// Construct an [`Email`] from its six required metadata fields.
314 ///
315 /// All parsed-header and body fields default to `None` / empty.
316 pub fn new(
317 id: Id,
318 blob_id: Id,
319 thread_id: Id,
320 mailbox_ids: HashMap<Id, bool>,
321 size: u64,
322 received_at: UTCDate,
323 ) -> Self {
324 Self {
325 id,
326 blob_id,
327 thread_id,
328 mailbox_ids,
329 keywords: HashMap::new(),
330 size,
331 received_at,
332 message_id: None,
333 in_reply_to: None,
334 references: None,
335 sender: None,
336 from: None,
337 to: None,
338 cc: None,
339 bcc: None,
340 reply_to: None,
341 subject: None,
342 sent_at: None,
343 headers: Vec::new(),
344 body_values: HashMap::new(),
345 text_body: Vec::new(),
346 html_body: Vec::new(),
347 attachments: Vec::new(),
348 body_structure: None,
349 has_attachment: false,
350 preview: None,
351 }
352 }
353}