jmap_mail_types/mailbox.rs
1//! RFC 8621 §2 Mailbox object and its component types.
2//!
3//! Provides [`Mailbox`], [`MailboxRole`], and [`MailboxRights`].
4//! Mailboxes are the folder containers for [`crate::Email`] objects.
5
6use jmap_types::{impl_string_enum, Id};
7use serde::{Deserialize, Serialize};
8
9/// The role of a Mailbox, identifying its common purpose (RFC 8621 §2).
10///
11/// Values correspond to IMAP Mailbox Name Attributes (RFC 8457), converted to
12/// lowercase. An account is not required to have Mailboxes with any particular
13/// role, and at most one Mailbox per account may hold each role.
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15#[non_exhaustive]
16pub enum MailboxRole {
17 /// The primary inbox for new incoming messages.
18 Inbox,
19 /// Deleted messages.
20 Trash,
21 /// Sent messages.
22 Sent,
23 /// Unsent draft messages.
24 Drafts,
25 /// Messages identified as likely spam.
26 Junk,
27 /// Archived messages.
28 Archive,
29 /// Messages flagged for follow-up.
30 Flagged,
31 /// Messages considered important.
32 Important,
33 /// Virtual mailbox containing all messages.
34 All,
35 /// Any role string not recognized by this implementation.
36 ///
37 /// RFC 8621 §2: "An unrecognized role SHOULD be treated as if no role were set."
38 ///
39 /// The inner string retains the original value received from the server, so
40 /// this variant round-trips correctly. When sending a `Mailbox/set` request
41 /// for a mailbox whose role came from the server, it is safe to echo the role
42 /// back — or omit it by setting `role` to `None`.
43 Other(String),
44}
45
46impl_string_enum!(MailboxRole, "a JMAP Mailbox role string",
47 "inbox" => Inbox,
48 "trash" => Trash,
49 "sent" => Sent,
50 "drafts" => Drafts,
51 "junk" => Junk,
52 "archive" => Archive,
53 "flagged" => Flagged,
54 "important" => Important,
55 "all" => All,
56);
57
58impl MailboxRole {
59 /// Return the RFC 8621 wire-format string for this role.
60 ///
61 /// Because this method is defined inside the crate that owns `MailboxRole`,
62 /// the match is exhaustive even though the enum is `#[non_exhaustive]`.
63 /// Adding a new variant without updating this method is a compile error.
64 pub fn to_wire_str(&self) -> &str {
65 match self {
66 Self::Inbox => "inbox",
67 Self::Trash => "trash",
68 Self::Sent => "sent",
69 Self::Drafts => "drafts",
70 Self::Junk => "junk",
71 Self::Archive => "archive",
72 Self::Flagged => "flagged",
73 Self::Important => "important",
74 Self::All => "all",
75 Self::Other(s) => s.as_str(),
76 }
77 }
78}
79
80/// Access control rights the authenticated user holds for a Mailbox (RFC 8621 §2).
81///
82/// Backwards compatible with IMAP ACLs (RFC 4314).
83///
84/// `Default` produces all-false (no access), which is the most restrictive valid value
85/// and a safe starting point when constructing rights in tests or server code.
86#[non_exhaustive]
87#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub struct MailboxRights {
90 /// User may use this Mailbox in Email/query filters and read its emails.
91 pub may_read_items: bool,
92 /// User may add mail to this Mailbox.
93 pub may_add_items: bool,
94 /// User may remove mail from this Mailbox.
95 pub may_remove_items: bool,
96 /// User may add or remove the `$seen` keyword on emails in this Mailbox.
97 pub may_set_seen: bool,
98 /// User may add or remove keywords other than `$seen` on emails.
99 pub may_set_keywords: bool,
100 /// User may create a child Mailbox under this one.
101 pub may_create_child: bool,
102 /// User may rename this Mailbox or move it under another parent.
103 pub may_rename: bool,
104 /// User may delete this Mailbox.
105 pub may_delete: bool,
106 /// Messages may be submitted directly to this Mailbox.
107 pub may_submit: bool,
108 /// Catch-all for vendor / site / private extension fields not covered
109 /// by the typed fields above. Preserves unknown fields across
110 /// deserialize/serialize round-trip per workspace extras-preservation
111 /// policy (see workspace AGENTS.md).
112 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
113 pub extra: serde_json::Map<String, serde_json::Value>,
114}
115
116/// A JMAP Mailbox object (RFC 8621 §2).
117///
118/// Mailboxes are the containers for Email objects. Each Email must belong to
119/// at least one Mailbox. Mailboxes form an acyclic forest via `parent_id`.
120#[non_exhaustive]
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct Mailbox {
124 /// Server-assigned immutable identifier.
125 pub id: Id,
126 /// User-visible name for this Mailbox.
127 pub name: String,
128 /// Id of the parent Mailbox, or `None` if top-level.
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub parent_id: Option<Id>,
131 /// Well-known role identifying the Mailbox's common purpose.
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub role: Option<MailboxRole>,
134 /// Client UI sort position; lower values sort first among siblings.
135 pub sort_order: u32,
136 /// Total number of Emails in this Mailbox (server-set).
137 pub total_emails: u32,
138 /// Number of Emails without `$seen` or `$draft` (server-set).
139 pub unread_emails: u32,
140 /// Number of Threads with at least one Email in this Mailbox (server-set).
141 pub total_threads: u32,
142 /// Number of unread Threads in this Mailbox (server-set).
143 pub unread_threads: u32,
144 /// ACL rights the authenticated user has on this Mailbox (server-set).
145 pub my_rights: MailboxRights,
146 /// Whether the user has subscribed to this Mailbox.
147 pub is_subscribed: bool,
148 /// Catch-all for vendor / site / private extension fields not covered
149 /// by the typed fields above. Preserves unknown fields across
150 /// deserialize/serialize round-trip per workspace extras-preservation
151 /// policy (see workspace AGENTS.md).
152 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
153 pub extra: serde_json::Map<String, serde_json::Value>,
154}
155
156impl Mailbox {
157 /// Construct a [`Mailbox`] from its required fields.
158 ///
159 /// `parent_id` and `role` default to `None`.
160 // Nine arguments because Mailbox has nine required RFC 8621 properties; all
161 // are needed for construction since #[non_exhaustive] prevents struct
162 // literals outside this crate.
163 #[allow(clippy::too_many_arguments)]
164 pub fn new(
165 id: Id,
166 name: impl Into<String>,
167 sort_order: u32,
168 total_emails: u32,
169 unread_emails: u32,
170 total_threads: u32,
171 unread_threads: u32,
172 my_rights: MailboxRights,
173 is_subscribed: bool,
174 ) -> Self {
175 Self {
176 id,
177 name: name.into(),
178 sort_order,
179 total_emails,
180 unread_emails,
181 total_threads,
182 unread_threads,
183 my_rights,
184 is_subscribed,
185 parent_id: None,
186 role: None,
187 extra: serde_json::Map::new(),
188 }
189 }
190}
191
192/// Deserialize `parent_id` so that an explicit JSON `null` is preserved as
193/// `Some(Value::Null)` instead of being collapsed to `None`.
194///
195/// `#[serde(default)]` on the field handles the absent case (produces `None`
196/// without calling this function). When the field is present, serde calls
197/// this function with the value, which always wraps the result in `Some(...)`.
198/// This is the only way to distinguish `{}` from `{"parentId": null}` for a
199/// field of type `Option<T>` — serde's default `Option<T>` Deserialize impl
200/// treats both as `None`.
201fn deserialize_parent_id_three_way<'de, D>(
202 deserializer: D,
203) -> Result<Option<serde_json::Value>, D::Error>
204where
205 D: serde::Deserializer<'de>,
206{
207 use serde::Deserialize;
208 serde_json::Value::deserialize(deserializer).map(Some)
209}
210
211/// Filter condition for `Mailbox/query` (RFC 8621 §2.3).
212///
213/// All fields are optional; a condition with no fields set matches every Mailbox.
214///
215/// ## `parentId` semantics
216///
217/// The `parentId` field has three distinct states that must be preserved:
218/// - **absent** (`None`) — do not filter by parent; return mailboxes at any level.
219/// - **`null`** (`Some(serde_json::Value::Null)`) — return only top-level mailboxes
220/// (those with no parent).
221/// - **`"<id>"`** (`Some(serde_json::Value::String(...))`) — return only mailboxes
222/// whose `parentId` equals the given `Id`.
223///
224/// The combination of `#[serde(default, deserialize_with = ...)]` preserves
225/// this three-way distinction. Without the custom deserializer, serde's
226/// default `Option<T>` Deserialize impl would collapse a JSON `null` to
227/// `None`, making `null` and absent indistinguishable.
228#[non_exhaustive]
229#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
230#[serde(rename_all = "camelCase")]
231pub struct MailboxFilterCondition {
232 /// See type-level docs for three-way semantics.
233 #[serde(
234 default,
235 deserialize_with = "deserialize_parent_id_three_way",
236 skip_serializing_if = "Option::is_none"
237 )]
238 pub parent_id: Option<serde_json::Value>,
239
240 /// Mailbox name must contain this string (case-sensitive substring match).
241 #[serde(skip_serializing_if = "Option::is_none")]
242 pub name: Option<String>,
243
244 /// Mailbox role must equal this string (e.g. `"inbox"`, `"trash"`).
245 #[serde(skip_serializing_if = "Option::is_none")]
246 pub role: Option<String>,
247
248 /// If `true`, only mailboxes with a non-null role are returned.
249 #[serde(skip_serializing_if = "Option::is_none")]
250 pub has_any_role: Option<bool>,
251
252 /// If `true`, only subscribed mailboxes are returned.
253 #[serde(skip_serializing_if = "Option::is_none")]
254 pub is_subscribed: Option<bool>,
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use serde_json::json;
261
262 // ── Extras-preservation policy tests (JMAP-lbdy.2) ───────────────────
263
264 /// `MailboxRights.extra` captures vendor fields and preserves them.
265 #[test]
266 fn mailbox_rights_preserves_vendor_extras() {
267 let raw = json!({
268 "mayReadItems": true,
269 "mayAddItems": true,
270 "mayRemoveItems": false,
271 "maySetSeen": true,
272 "maySetKeywords": false,
273 "mayCreateChild": false,
274 "mayRename": false,
275 "mayDelete": false,
276 "maySubmit": false,
277 "acmeCorpMayPin": true
278 });
279 let rights: MailboxRights = serde_json::from_value(raw).unwrap();
280 assert_eq!(
281 rights.extra.get("acmeCorpMayPin").and_then(|v| v.as_bool()),
282 Some(true)
283 );
284 let back = serde_json::to_value(&rights).unwrap();
285 assert_eq!(back["acmeCorpMayPin"], true);
286 }
287
288 /// `Mailbox.extra` captures vendor fields and preserves them.
289 #[test]
290 fn mailbox_preserves_vendor_extras() {
291 let raw = json!({
292 "id": "m1",
293 "name": "Inbox",
294 "sortOrder": 0,
295 "totalEmails": 0,
296 "unreadEmails": 0,
297 "totalThreads": 0,
298 "unreadThreads": 0,
299 "myRights": {
300 "mayReadItems": true, "mayAddItems": true, "mayRemoveItems": true,
301 "maySetSeen": true, "maySetKeywords": true, "mayCreateChild": true,
302 "mayRename": true, "mayDelete": false, "maySubmit": true
303 },
304 "isSubscribed": true,
305 "acmeCorpColor": "#ff0000"
306 });
307 let mbox: Mailbox = serde_json::from_value(raw).unwrap();
308 assert_eq!(
309 mbox.extra.get("acmeCorpColor").and_then(|v| v.as_str()),
310 Some("#ff0000")
311 );
312 let back = serde_json::to_value(&mbox).unwrap();
313 assert_eq!(back["acmeCorpColor"], "#ff0000");
314 }
315
316 // ── parentId three-way semantics (RFC 8621 §2.3) ────────────────────
317 //
318 // The Mailbox/query filter distinguishes three states for `parentId`:
319 // absent (no filter), explicit JSON null (top-level only), explicit string
320 // (specific parent). The default `Option<T>` Deserialize impl collapses
321 // `null` to `None`, so `MailboxFilterCondition.parent_id` uses a custom
322 // deserializer to preserve `Some(Value::Null)`. These tests lock that
323 // behavior.
324
325 /// Oracle: absent `parentId` deserializes as `None` and serializes back to
326 /// an object without the field.
327 #[test]
328 fn mailbox_filter_parent_id_absent_round_trips() {
329 let cond: MailboxFilterCondition = serde_json::from_value(json!({})).unwrap();
330 assert!(cond.parent_id.is_none(), "absent must deserialize as None");
331 let back = serde_json::to_value(&cond).unwrap();
332 assert!(
333 back.get("parentId").is_none(),
334 "absent parentId must not appear in serialized output"
335 );
336 }
337
338 /// Oracle: explicit JSON `null` for `parentId` deserializes as
339 /// `Some(Value::Null)` (NOT `None`) and serializes back to `null`.
340 /// This is the wire-level signal for "top-level mailboxes only".
341 #[test]
342 fn mailbox_filter_parent_id_null_round_trips() {
343 let cond: MailboxFilterCondition =
344 serde_json::from_value(json!({"parentId": null})).unwrap();
345 assert!(
346 matches!(cond.parent_id, Some(serde_json::Value::Null)),
347 "explicit null must deserialize as Some(Value::Null), got {:?}",
348 cond.parent_id
349 );
350 let back = serde_json::to_value(&cond).unwrap();
351 assert_eq!(
352 back["parentId"],
353 serde_json::Value::Null,
354 "Some(Value::Null) must serialize back as null"
355 );
356 }
357
358 /// Oracle: explicit string `parentId` deserializes as
359 /// `Some(Value::String(...))` and serializes back to the same string.
360 #[test]
361 fn mailbox_filter_parent_id_string_round_trips() {
362 let cond: MailboxFilterCondition =
363 serde_json::from_value(json!({"parentId": "mbox-42"})).unwrap();
364 match cond.parent_id {
365 Some(serde_json::Value::String(ref s)) => assert_eq!(s, "mbox-42"),
366 other => panic!("expected Some(String), got {other:?}"),
367 }
368 let back = serde_json::to_value(&cond).unwrap();
369 assert_eq!(back["parentId"], "mbox-42");
370 }
371}