Skip to main content

io_jmap/rfc8621/email/
types.rs

1//! JMAP Email types (RFC 8621 §4).
2
3use alloc::{collections::BTreeMap, format, string::String, vec::Vec};
4
5use serde::{Deserialize, Serialize};
6
7/// A JMAP Email object (RFC 8621 §4.1).
8#[derive(Clone, Debug, Default, Serialize, Deserialize)]
9#[serde(rename_all = "camelCase")]
10pub struct JmapEmail {
11    pub id: Option<String>,
12    /// Blob ID for the raw RFC 5322 message.
13    pub blob_id: Option<String>,
14    pub thread_id: Option<String>,
15    /// `{ mailbox-id -> true }` for each mailbox containing the email.
16    pub mailbox_ids: Option<BTreeMap<String, bool>>,
17    /// `{ keyword -> true }`. Standard: `$seen`, `$flagged`, `$answered`,
18    /// `$draft`.
19    pub keywords: Option<BTreeMap<String, bool>>,
20    /// Size of the raw RFC 5322 message, in bytes.
21    pub size: Option<u64>,
22    /// RFC 3339 receive time.
23    pub received_at: Option<String>,
24    pub message_id: Option<Vec<String>>,
25    pub in_reply_to: Option<Vec<String>>,
26    pub references: Option<Vec<String>>,
27    pub sender: Option<Vec<JmapEmailAddress>>,
28    pub from: Option<Vec<JmapEmailAddress>>,
29    pub to: Option<Vec<JmapEmailAddress>>,
30    pub cc: Option<Vec<JmapEmailAddress>>,
31    pub bcc: Option<Vec<JmapEmailAddress>>,
32    pub reply_to: Option<Vec<JmapEmailAddress>>,
33    pub subject: Option<String>,
34    /// `Date` header as an RFC 3339 string.
35    pub sent_at: Option<String>,
36    pub body_structure: Option<JmapEmailBodyPart>,
37    /// `{ part-id -> body }` for text parts.
38    pub body_values: Option<BTreeMap<String, JmapEmailBodyValue>>,
39    pub text_body: Option<Vec<JmapEmailBodyPart>>,
40    pub html_body: Option<Vec<JmapEmailBodyPart>>,
41    pub attachments: Option<Vec<JmapEmailBodyPart>>,
42    pub has_attachment: Option<bool>,
43    /// Short plaintext preview (up to 256 chars).
44    pub preview: Option<String>,
45    /// Raw headers in order of appearance.
46    pub headers: Option<Vec<JmapEmailHeader>>,
47}
48
49/// An email address (name + email pair).
50#[derive(Clone, Debug, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct JmapEmailAddress {
53    pub name: Option<String>,
54    pub email: String,
55}
56
57/// A raw email header name-value pair.
58#[derive(Clone, Debug, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct JmapEmailHeader {
61    /// Field name, without trailing colon.
62    pub name: String,
63    /// Raw value, with leading whitespace preserved.
64    pub value: String,
65}
66
67/// A MIME body part descriptor.
68#[derive(Clone, Debug, Default, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct JmapEmailBodyPart {
71    pub part_id: Option<String>,
72    pub blob_id: Option<String>,
73    pub size: Option<u64>,
74    /// Filename from `Content-Disposition` or `Content-Type`.
75    pub name: Option<String>,
76    pub r#type: Option<String>,
77    pub charset: Option<String>,
78    /// `inline` or `attachment`.
79    pub disposition: Option<String>,
80    pub cid: Option<String>,
81    pub language: Option<Vec<String>>,
82    pub location: Option<String>,
83    /// Sub-parts (multipart only).
84    pub sub_parts: Option<Vec<JmapEmailBodyPart>>,
85    pub headers: Option<Vec<JmapEmailHeader>>,
86}
87
88/// The text content of a body part.
89#[derive(Clone, Debug, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct JmapEmailBodyValue {
92    pub value: String,
93    /// Charset or encoding problem during decode.
94    pub is_encoding_problem: bool,
95    /// Whether the value was truncated.
96    pub is_truncated: bool,
97}
98
99/// [`JmapEmail`] properties requestable in `Email/get` (RFC 8621 §4.1).
100#[derive(Clone, Debug, Serialize)]
101#[serde(rename_all = "camelCase")]
102pub enum JmapEmailProperty {
103    Id,
104    BlobId,
105    ThreadId,
106    MailboxIds,
107    Keywords,
108    Size,
109    ReceivedAt,
110    MessageId,
111    InReplyTo,
112    References,
113    Sender,
114    From,
115    To,
116    Cc,
117    Bcc,
118    ReplyTo,
119    Subject,
120    SentAt,
121    BodyStructure,
122    BodyValues,
123    TextBody,
124    HtmlBody,
125    Attachments,
126    HasAttachment,
127    Preview,
128    Headers,
129}
130
131/// Sort property for `Email/query` (RFC 8621 §4.4).
132#[derive(Clone, Debug, Serialize)]
133#[serde(rename_all = "camelCase")]
134pub enum JmapEmailSortProperty {
135    ReceivedAt,
136    SentAt,
137    Size,
138    From,
139    To,
140    Subject,
141    HasAttachment,
142    /// Sort by keyword presence on the email (requires `keyword` field).
143    Keyword,
144    /// Sort by whether all emails in the thread have a keyword
145    /// (requires `keyword` field).
146    AllInThreadHaveKeyword,
147    /// Sort by whether some emails in the thread have a keyword
148    /// (requires `keyword` field).
149    SomeInThreadHaveKeyword,
150}
151
152/// JmapFilter for `Email/query` (RFC 8621 §4.4).
153#[derive(Clone, Debug, Default, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct JmapEmailFilter {
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub in_mailbox: Option<String>,
158    /// Exclude messages in any of these mailbox IDs.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub in_mailbox_other_than: Option<Vec<String>>,
161    /// RFC 3339 upper bound.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub before: Option<String>,
164    /// RFC 3339 lower bound.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub after: Option<String>,
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub min_size: Option<u64>,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub max_size: Option<u64>,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub all_in_thread_have_keyword: Option<String>,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub some_in_thread_have_keyword: Option<String>,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub none_in_thread_have_keyword: Option<String>,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub has_keyword: Option<String>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub not_keyword: Option<String>,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub has_attachment: Option<bool>,
183    /// Full-text search query.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub text: Option<String>,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub from: Option<String>,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub to: Option<String>,
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub cc: Option<String>,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub bcc: Option<String>,
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub subject: Option<String>,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub body: Option<String>,
198}
199
200/// Comparator for `Email/query` sorting (RFC 8621 §4.4).
201#[derive(Clone, Debug, Serialize)]
202#[serde(rename_all = "camelCase")]
203pub struct JmapEmailComparator {
204    pub property: JmapEmailSortProperty,
205    /// Ascending if `None` or `Some(true)`.
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub is_ascending: Option<bool>,
208    /// String comparison collation.
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub collation: Option<String>,
211    /// Required when `property` is `Keyword`, `AllInThreadHaveKeyword`, or
212    /// `SomeInThreadHaveKeyword`.
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub keyword: Option<String>,
215}
216
217impl JmapEmailComparator {
218    /// Sort by `receivedAt` descending (newest first).
219    pub fn received_at_desc() -> Self {
220        Self {
221            property: JmapEmailSortProperty::ReceivedAt,
222            is_ascending: Some(false),
223            collation: None,
224            keyword: None,
225        }
226    }
227}
228
229/// A single operation in an `Email/set` update patch (RFC 8621 §4.7). Each
230/// variant serialises as a JSON Pointer entry in a flat patch object.
231#[derive(Clone, Debug)]
232pub enum JmapEmailPatchOp {
233    /// Set a keyword: `"keywords/<kw>": true`
234    SetKeyword(String),
235    /// Unset a keyword: `"keywords/<kw>": null`
236    UnsetKeyword(String),
237    /// Replace all keywords atomically: `"keywords": { ... }`
238    ReplaceKeywords(BTreeMap<String, bool>),
239    /// Add email to a mailbox: `"mailboxIds/<id>": true`
240    AddToMailbox(String),
241    /// Remove email from a mailbox: `"mailboxIds/<id>": null`
242    RemoveFromMailbox(String),
243    /// Replace mailbox membership atomically: `"mailboxIds": { ... }`
244    ReplaceMailboxIds(BTreeMap<String, bool>),
245}
246
247/// A set of patch operations applied to a single email in `Email/set`.
248///
249/// Serializes to a flat JSON Merge Patch object (RFC 7396).
250#[derive(Clone, Debug, Default)]
251pub struct JmapEmailPatch(pub Vec<JmapEmailPatchOp>);
252
253impl JmapEmailPatch {
254    pub fn set_keyword(mut self, keyword: impl Into<String>) -> Self {
255        self.0.push(JmapEmailPatchOp::SetKeyword(keyword.into()));
256        self
257    }
258
259    pub fn unset_keyword(mut self, keyword: impl Into<String>) -> Self {
260        self.0.push(JmapEmailPatchOp::UnsetKeyword(keyword.into()));
261        self
262    }
263
264    pub fn replace_keywords(mut self, keywords: BTreeMap<String, bool>) -> Self {
265        self.0.push(JmapEmailPatchOp::ReplaceKeywords(keywords));
266        self
267    }
268
269    pub fn add_to_mailbox(mut self, id: impl Into<String>) -> Self {
270        self.0.push(JmapEmailPatchOp::AddToMailbox(id.into()));
271        self
272    }
273
274    pub fn remove_from_mailbox(mut self, id: impl Into<String>) -> Self {
275        self.0.push(JmapEmailPatchOp::RemoveFromMailbox(id.into()));
276        self
277    }
278
279    pub fn replace_mailbox_ids(mut self, ids: BTreeMap<String, bool>) -> Self {
280        self.0.push(JmapEmailPatchOp::ReplaceMailboxIds(ids));
281        self
282    }
283}
284
285impl Serialize for JmapEmailPatch {
286    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
287        use serde::ser::SerializeMap;
288        let mut map = s.serialize_map(Some(self.0.len()))?;
289        for op in &self.0 {
290            match op {
291                JmapEmailPatchOp::SetKeyword(kw) => {
292                    map.serialize_entry(&format!("keywords/{kw}"), &true)?
293                }
294                JmapEmailPatchOp::UnsetKeyword(kw) => {
295                    map.serialize_entry(&format!("keywords/{kw}"), &Option::<bool>::None)?
296                }
297                JmapEmailPatchOp::ReplaceKeywords(kws) => map.serialize_entry("keywords", kws)?,
298                JmapEmailPatchOp::AddToMailbox(id) => {
299                    map.serialize_entry(&format!("mailboxIds/{id}"), &true)?
300                }
301                JmapEmailPatchOp::RemoveFromMailbox(id) => {
302                    map.serialize_entry(&format!("mailboxIds/{id}"), &Option::<bool>::None)?
303                }
304                JmapEmailPatchOp::ReplaceMailboxIds(ids) => {
305                    map.serialize_entry("mailboxIds", ids)?
306                }
307            }
308        }
309        map.end()
310    }
311}
312
313/// Arguments for importing a single RFC 5322 message via `Email/import`.
314#[derive(Clone, Debug, Serialize)]
315#[serde(rename_all = "camelCase")]
316pub struct JmapEmailImportArgs {
317    /// Blob ID of the RFC 5322 message.
318    pub blob_id: String,
319    /// `{ mailbox-id -> true }` for destination mailboxes.
320    pub mailbox_ids: BTreeMap<String, bool>,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub keywords: Option<BTreeMap<String, bool>>,
323    /// RFC 3339 override for `receivedAt`.
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub received_at: Option<String>,
326}
327
328/// Arguments for copying a single email between accounts via `Email/copy`.
329#[derive(Clone, Debug, Serialize)]
330#[serde(rename_all = "camelCase")]
331pub struct JmapEmailCopyArgs {
332    /// Source email ID.
333    pub id: String,
334    /// `{ mailbox-id -> true }` for destination mailboxes.
335    pub mailbox_ids: BTreeMap<String, bool>,
336    /// Keywords on the copy (replaces source keywords).
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub keywords: Option<BTreeMap<String, bool>>,
339    /// RFC 3339 override for the copy's `receivedAt`.
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub received_at: Option<String>,
342}
343
344/// Per-object error returned in `Email/set` responses (RFC 8621 §4.7).
345#[derive(Clone, Debug, Deserialize)]
346#[serde(tag = "type", rename_all = "camelCase")]
347pub enum JmapEmailSetItemError {
348    /// The email would exceed the server's keyword limit (RFC 8621 §4.7).
349    TooManyKeywords { description: Option<String> },
350    /// The email would be in too many mailboxes (RFC 8621 §4.7).
351    TooManyMailboxes { description: Option<String> },
352    /// One or more blob IDs in the email were not found (RFC 8621 §4.7).
353    BlobNotFound { description: Option<String> },
354    /// Standard set error (RFC 8620 §5.3): target id not found.
355    NotFound { description: Option<String> },
356    /// Standard set error (RFC 8620 §5.3): patch could not be applied.
357    InvalidPatch { description: Option<String> },
358    /// Standard set error (RFC 8620 §5.3): would destroy an object already
359    /// queued for destruction in the same request.
360    WillDestroy { description: Option<String> },
361    /// Standard set error (RFC 8620 §5.3): one or more properties were invalid.
362    InvalidProperties {
363        description: Option<String>,
364        #[serde(default)]
365        properties: Vec<String>,
366    },
367    /// Standard set error (RFC 8620 §5.3): tried to create/destroy a
368    /// server-managed singleton.
369    Singleton { description: Option<String> },
370    /// Catch-all for set errors not modelled above.
371    #[serde(other)]
372    Unknown,
373}
374
375/// Per-object error returned in `Email/import` responses (RFC 8621 §4.9).
376#[derive(Clone, Debug, Deserialize)]
377#[serde(tag = "type", rename_all = "camelCase")]
378pub enum JmapEmailImportItemError {
379    /// The message body was not a valid RFC 5322 message (RFC 8621 §4.9).
380    InvalidEmail { description: Option<String> },
381    /// Standard set error (RFC 8620 §5.3): target id not found.
382    NotFound { description: Option<String> },
383    /// Standard set error (RFC 8620 §5.3): one or more properties were invalid.
384    InvalidProperties {
385        description: Option<String>,
386        #[serde(default)]
387        properties: Vec<String>,
388    },
389    /// Catch-all for set errors not modelled above.
390    #[serde(other)]
391    Unknown,
392}
393
394/// Per-object error returned in `Email/copy` responses (RFC 8621 §4.10).
395#[derive(Clone, Debug, Deserialize)]
396#[serde(tag = "type", rename_all = "camelCase")]
397pub enum JmapEmailCopyItemError {
398    /// The email already exists in the destination account (RFC 8621 §4.10).
399    AlreadyExists { description: Option<String> },
400    /// Standard set error (RFC 8620 §5.3): target id not found.
401    NotFound { description: Option<String> },
402    /// Standard set error (RFC 8620 §5.3): one or more properties were invalid.
403    InvalidProperties {
404        description: Option<String>,
405        #[serde(default)]
406        properties: Vec<String>,
407    },
408    /// Catch-all for set errors not modelled above.
409    #[serde(other)]
410    Unknown,
411}