Skip to main content

jmap_mail_types/
email.rs

1//! RFC 8621 §4 Email object and its component types.
2//!
3//! Provides [`Email`], [`EmailAddress`], [`EmailAddressGroup`], [`EmailHeader`],
4//! [`EmailBodyPart`], and [`EmailBodyValue`].  These are the types used in
5//! `Email/get` responses and `Email/set` requests.
6//!
7//! See [`Email`] for notes on full vs partial responses.
8
9use std::collections::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}