Skip to main content

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; `None`
129    /// serializes as `null`.
130    pub parent_id: Option<Id>,
131    /// Well-known role identifying the Mailbox's common purpose; `None`
132    /// serializes as `null`.
133    pub role: Option<MailboxRole>,
134    /// Client UI sort position; lower values sort first among siblings.
135    /// Defaults to `0`.
136    #[serde(default)]
137    pub sort_order: u32,
138    /// Total number of Emails in this Mailbox (server-set).
139    pub total_emails: u32,
140    /// Number of Emails without `$seen` or `$draft` (server-set).
141    pub unread_emails: u32,
142    /// Number of Threads with at least one Email in this Mailbox (server-set).
143    pub total_threads: u32,
144    /// Number of unread Threads in this Mailbox (server-set).
145    pub unread_threads: u32,
146    /// ACL rights the authenticated user has on this Mailbox (server-set).
147    pub my_rights: MailboxRights,
148    /// Whether the user has subscribed to this Mailbox.
149    pub is_subscribed: bool,
150    /// Catch-all for vendor / site / private extension fields not covered
151    /// by the typed fields above. Preserves unknown fields across
152    /// deserialize/serialize round-trip per workspace extras-preservation
153    /// policy (see workspace AGENTS.md).
154    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
155    pub extra: serde_json::Map<String, serde_json::Value>,
156}
157
158impl Mailbox {
159    /// Construct a [`Mailbox`] from its required fields.
160    ///
161    /// `parent_id` and `role` default to `None`.
162    // Nine arguments because Mailbox has nine required RFC 8621 properties; all
163    // are needed for construction since #[non_exhaustive] prevents struct
164    // literals outside this crate.
165    #[allow(clippy::too_many_arguments)]
166    pub fn new(
167        id: Id,
168        name: impl Into<String>,
169        sort_order: u32,
170        total_emails: u32,
171        unread_emails: u32,
172        total_threads: u32,
173        unread_threads: u32,
174        my_rights: MailboxRights,
175        is_subscribed: bool,
176    ) -> Self {
177        Self {
178            id,
179            name: name.into(),
180            sort_order,
181            total_emails,
182            unread_emails,
183            total_threads,
184            unread_threads,
185            my_rights,
186            is_subscribed,
187            parent_id: None,
188            role: None,
189            extra: serde_json::Map::new(),
190        }
191    }
192}
193
194/// Deserialize `parent_id` so that an explicit JSON `null` is preserved as
195/// `Some(Value::Null)` instead of being collapsed to `None`, while
196/// rejecting wire values that RFC 8621 §2.3 does not permit (numbers,
197/// booleans, arrays, nested objects).
198///
199/// `#[serde(default)]` on the field handles the absent case (produces `None`
200/// without calling this function). When the field is present, serde calls
201/// this function with the value, which always wraps a spec-valid result
202/// in `Some(...)`. This is the only way to distinguish `{}` from
203/// `{"parentId": null}` for a field of type `Option<T>` — serde's
204/// default `Option<T>` Deserialize impl treats both as `None`.
205///
206/// Per RFC 8621 §2.3 the only valid `parentId` wire shapes are JSON
207/// `null` and a JSON string (Mailbox `Id`). Anything else is a
208/// non-conformant peer; rejecting at the deserialize layer surfaces
209/// the error to the caller instead of silently round-tripping
210/// wire-invalid data.
211fn deserialize_parent_id_three_way<'de, D>(
212    deserializer: D,
213) -> Result<Option<serde_json::Value>, D::Error>
214where
215    D: serde::Deserializer<'de>,
216{
217    use serde::Deserialize;
218    let v = serde_json::Value::deserialize(deserializer)?;
219    match &v {
220        serde_json::Value::Null | serde_json::Value::String(_) => Ok(Some(v)),
221        other => Err(serde::de::Error::custom(format!(
222            "parentId must be null or a string (Mailbox Id) per RFC 8621 §2.3; got {}",
223            match other {
224                serde_json::Value::Bool(_) => "boolean",
225                serde_json::Value::Number(_) => "number",
226                serde_json::Value::Array(_) => "array",
227                serde_json::Value::Object(_) => "object",
228                // Null and String already returned above.
229                _ => "unexpected value",
230            }
231        ))),
232    }
233}
234
235/// Filter condition for `Mailbox/query` (RFC 8621 §2.3).
236///
237/// All fields are optional; a condition with no fields set matches every Mailbox.
238///
239/// ## `parentId` semantics
240///
241/// The `parentId` field has three distinct states that must be preserved:
242/// - **absent** (`None`) — do not filter by parent; return mailboxes at any level.
243/// - **`null`** (`Some(serde_json::Value::Null)`) — return only top-level mailboxes
244///   (those with no parent).
245/// - **`"<id>"`** (`Some(serde_json::Value::String(...))`) — return only mailboxes
246///   whose `parentId` equals the given `Id`.
247///
248/// The combination of `#[serde(default, deserialize_with = ...)]` preserves
249/// this three-way distinction. Without the custom deserializer, serde's
250/// default `Option<T>` Deserialize impl would collapse a JSON `null` to
251/// `None`, making `null` and absent indistinguishable.
252#[non_exhaustive]
253#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
254#[serde(rename_all = "camelCase")]
255pub struct MailboxFilterCondition {
256    /// See type-level docs for three-way semantics.
257    #[serde(
258        default,
259        deserialize_with = "deserialize_parent_id_three_way",
260        skip_serializing_if = "Option::is_none"
261    )]
262    pub parent_id: Option<serde_json::Value>,
263
264    /// Mailbox name must contain this string (case-sensitive substring match).
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub name: Option<String>,
267
268    /// Mailbox role must equal this string (e.g. `"inbox"`, `"trash"`).
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub role: Option<String>,
271
272    /// If `true`, only mailboxes with a non-null role are returned.
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub has_any_role: Option<bool>,
275
276    /// If `true`, only subscribed mailboxes are returned.
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub is_subscribed: Option<bool>,
279}
280
281/// The wire-format field names accepted by [`MailboxFilterCondition`]
282/// (RFC 8621 §2.3). Server crates use this to pre-validate
283/// `Mailbox/query.filter` against unknown keys per RFC 8620 §5.5
284/// (return `unsupportedFilter`) before deserialising into the typed
285/// struct. The drift-check unit test in this module asserts that the
286/// list matches every field actually serialised by the struct, so a
287/// new field on `MailboxFilterCondition` cannot quietly diverge from
288/// this list — `cargo test -p jmap-mail-types` will fail.
289pub const MAILBOX_FILTER_CONDITION_KEYS: &[&str] =
290    &["parentId", "name", "role", "hasAnyRole", "isSubscribed"];
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use serde_json::json;
296
297    // ── Extras-preservation policy tests (JMAP-lbdy.2) ───────────────────
298
299    /// `MailboxRights.extra` captures vendor fields and preserves them.
300    #[test]
301    fn mailbox_rights_preserves_vendor_extras() {
302        let raw = json!({
303            "mayReadItems": true,
304            "mayAddItems": true,
305            "mayRemoveItems": false,
306            "maySetSeen": true,
307            "maySetKeywords": false,
308            "mayCreateChild": false,
309            "mayRename": false,
310            "mayDelete": false,
311            "maySubmit": false,
312            "acmeCorpMayPin": true
313        });
314        let rights: MailboxRights = serde_json::from_value(raw).unwrap();
315        assert_eq!(
316            rights.extra.get("acmeCorpMayPin").and_then(|v| v.as_bool()),
317            Some(true)
318        );
319        let back = serde_json::to_value(&rights).unwrap();
320        assert_eq!(back["acmeCorpMayPin"], true);
321    }
322
323    /// `Mailbox.extra` captures vendor fields and preserves them.
324    #[test]
325    fn mailbox_preserves_vendor_extras() {
326        let raw = json!({
327            "id": "m1",
328            "name": "Inbox",
329            "sortOrder": 0,
330            "totalEmails": 0,
331            "unreadEmails": 0,
332            "totalThreads": 0,
333            "unreadThreads": 0,
334            "myRights": {
335                "mayReadItems": true, "mayAddItems": true, "mayRemoveItems": true,
336                "maySetSeen": true, "maySetKeywords": true, "mayCreateChild": true,
337                "mayRename": true, "mayDelete": false, "maySubmit": true
338            },
339            "isSubscribed": true,
340            "acmeCorpColor": "#ff0000"
341        });
342        let mbox: Mailbox = serde_json::from_value(raw).unwrap();
343        assert_eq!(
344            mbox.extra.get("acmeCorpColor").and_then(|v| v.as_str()),
345            Some("#ff0000")
346        );
347        let back = serde_json::to_value(&mbox).unwrap();
348        assert_eq!(back["acmeCorpColor"], "#ff0000");
349    }
350
351    // ── parentId three-way semantics (RFC 8621 §2.3) ────────────────────
352    //
353    // The Mailbox/query filter distinguishes three states for `parentId`:
354    // absent (no filter), explicit JSON null (top-level only), explicit string
355    // (specific parent). The default `Option<T>` Deserialize impl collapses
356    // `null` to `None`, so `MailboxFilterCondition.parent_id` uses a custom
357    // deserializer to preserve `Some(Value::Null)`. These tests lock that
358    // behavior.
359
360    /// Oracle: absent `parentId` deserializes as `None` and serializes back to
361    /// an object without the field.
362    #[test]
363    fn mailbox_filter_parent_id_absent_round_trips() {
364        let cond: MailboxFilterCondition = serde_json::from_value(json!({})).unwrap();
365        assert!(cond.parent_id.is_none(), "absent must deserialize as None");
366        let back = serde_json::to_value(&cond).unwrap();
367        assert!(
368            back.get("parentId").is_none(),
369            "absent parentId must not appear in serialized output"
370        );
371    }
372
373    /// Oracle: explicit JSON `null` for `parentId` deserializes as
374    /// `Some(Value::Null)` (NOT `None`) and serializes back to `null`.
375    /// This is the wire-level signal for "top-level mailboxes only".
376    #[test]
377    fn mailbox_filter_parent_id_null_round_trips() {
378        let cond: MailboxFilterCondition =
379            serde_json::from_value(json!({"parentId": null})).unwrap();
380        assert!(
381            matches!(cond.parent_id, Some(serde_json::Value::Null)),
382            "explicit null must deserialize as Some(Value::Null), got {:?}",
383            cond.parent_id
384        );
385        let back = serde_json::to_value(&cond).unwrap();
386        assert_eq!(
387            back["parentId"],
388            serde_json::Value::Null,
389            "Some(Value::Null) must serialize back as null"
390        );
391    }
392
393    /// Oracle: explicit string `parentId` deserializes as
394    /// `Some(Value::String(...))` and serializes back to the same string.
395    #[test]
396    fn mailbox_filter_parent_id_string_round_trips() {
397        let cond: MailboxFilterCondition =
398            serde_json::from_value(json!({"parentId": "mbox-42"})).unwrap();
399        match cond.parent_id {
400            Some(serde_json::Value::String(ref s)) => assert_eq!(s, "mbox-42"),
401            other => panic!("expected Some(String), got {other:?}"),
402        }
403        let back = serde_json::to_value(&cond).unwrap();
404        assert_eq!(back["parentId"], "mbox-42");
405    }
406
407    /// RFC 8621 §2.3 admits only JSON `null` and a JSON string for
408    /// `parentId`. Numbers, booleans, arrays, and nested objects MUST
409    /// be rejected at deserialize time rather than silently round-
410    /// tripped as opaque `serde_json::Value` blobs.
411    ///
412    /// Independent oracle: the spec text itself. Each rejected case
413    /// asserts the error message references the spec section, so a
414    /// future shape change that loosened the deserializer would be
415    /// caught.
416    #[test]
417    fn mailbox_filter_parent_id_rejects_non_spec_shapes() {
418        for (bad, label) in [
419            (json!({"parentId": 42}), "number"),
420            (json!({"parentId": true}), "boolean"),
421            (json!({"parentId": ["m1"]}), "array"),
422            (json!({"parentId": {"id": "m1"}}), "object"),
423        ] {
424            let err = serde_json::from_value::<MailboxFilterCondition>(bad)
425                .expect_err(&format!("{label} must be rejected"));
426            let msg = err.to_string();
427            assert!(
428                msg.contains("parentId must be null or a string"),
429                "{label} rejection must cite the spec rule; got: {msg}"
430            );
431        }
432    }
433
434    /// Oracle: [`MAILBOX_FILTER_CONDITION_KEYS`] is a single source of truth
435    /// for the wire-format keys of [`MailboxFilterCondition`]. Mirror the
436    /// const against the actual serialised field names, derived by
437    /// fully-populating the struct and asking serde for the JSON keys. A
438    /// future contributor who adds a new field but forgets to update the
439    /// const trips this test (`cargo test -p jmap-mail-types`), avoiding
440    /// the silent server-side drift documented in `bd:JMAP-j7pa.3`.
441    ///
442    /// Independent oracle: the JSON object produced by `serde_json::to_value`
443    /// over a hand-built struct value. The struct is owned by this crate;
444    /// the const is owned by this crate; the test compares one against the
445    /// other at compile-equivalent time. No JMAP-server code is involved.
446    #[test]
447    fn mailbox_filter_condition_keys_matches_struct_fields() {
448        use std::collections::BTreeSet;
449
450        // Every field gets a non-None value so every key is serialised.
451        let mut cond = MailboxFilterCondition {
452            parent_id: Some(serde_json::Value::String("any-id".into())),
453            name: Some("inbox".into()),
454            role: Some("inbox".into()),
455            has_any_role: Some(true),
456            is_subscribed: Some(true),
457        };
458        // Touch the `cond` binding via serialisation; the let-binding is
459        // intentionally mutable to force this test to be updated alongside
460        // any future field addition that lands without an obvious default.
461        let _ = &mut cond;
462
463        let value = serde_json::to_value(&cond).expect("serialisation must succeed");
464        let serialised_keys: BTreeSet<&str> = value
465            .as_object()
466            .expect("filter condition must serialise as an object")
467            .keys()
468            .map(String::as_str)
469            .collect();
470
471        let declared_keys: BTreeSet<&str> = MAILBOX_FILTER_CONDITION_KEYS.iter().copied().collect();
472
473        assert_eq!(
474            declared_keys, serialised_keys,
475            "MAILBOX_FILTER_CONDITION_KEYS must match every serialised field of MailboxFilterCondition; \
476             declared={declared_keys:?}, serialised={serialised_keys:?}. \
477             If you added a field to MailboxFilterCondition, add its camelCase wire name to the const."
478        );
479    }
480}