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_metadata_types::MetadataTextMatch;
7use jmap_types::{Id, impl_string_enum};
8use serde::{Deserialize, Serialize};
9
10/// The role of a Mailbox, identifying its common purpose (RFC 8621 §2).
11///
12/// Values correspond to IMAP Mailbox Name Attributes (RFC 8457), converted to
13/// lowercase.  An account is not required to have Mailboxes with any particular
14/// role, and at most one Mailbox per account may hold each role.
15#[non_exhaustive]
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub enum MailboxRole {
18    /// The primary inbox for new incoming messages.
19    Inbox,
20    /// Deleted messages.
21    Trash,
22    /// Sent messages.
23    Sent,
24    /// Unsent draft messages.
25    Drafts,
26    /// Messages identified as likely spam.
27    Junk,
28    /// Archived messages.
29    Archive,
30    /// Messages flagged for follow-up.
31    Flagged,
32    /// Messages considered important.
33    Important,
34    /// Virtual mailbox containing all messages.
35    All,
36    /// Any role string not recognized by this implementation.
37    ///
38    /// RFC 8621 §2: "An unrecognized role SHOULD be treated as if no role were set."
39    ///
40    /// The inner string retains the original value received from the server, so
41    /// this variant round-trips correctly.  When sending a `Mailbox/set` request
42    /// for a mailbox whose role came from the server, it is safe to echo the role
43    /// back — or omit it by setting `role` to `None`.
44    Other(String),
45}
46
47impl_string_enum!(MailboxRole, "a JMAP Mailbox role string",
48    "inbox"     => Inbox,
49    "trash"     => Trash,
50    "sent"      => Sent,
51    "drafts"    => Drafts,
52    "junk"      => Junk,
53    "archive"   => Archive,
54    "flagged"   => Flagged,
55    "important" => Important,
56    "all"       => All,
57);
58
59impl MailboxRole {
60    /// Return the RFC 8621 wire-format string for this role.
61    ///
62    /// Because this method is defined inside the crate that owns `MailboxRole`,
63    /// the match is exhaustive even though the enum is `#[non_exhaustive]`.
64    /// Adding a new variant without updating this method is a compile error.
65    pub fn to_wire_str(&self) -> &str {
66        match self {
67            Self::Inbox => "inbox",
68            Self::Trash => "trash",
69            Self::Sent => "sent",
70            Self::Drafts => "drafts",
71            Self::Junk => "junk",
72            Self::Archive => "archive",
73            Self::Flagged => "flagged",
74            Self::Important => "important",
75            Self::All => "all",
76            Self::Other(s) => s.as_str(),
77        }
78    }
79}
80
81/// Access control rights the authenticated user holds for a Mailbox (RFC 8621 §2).
82///
83/// Backwards compatible with IMAP ACLs (RFC 4314).
84///
85/// `Default` produces all-false (no access), which is the most restrictive valid value
86/// and a safe starting point when constructing rights in tests or server code.
87#[non_exhaustive]
88#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct MailboxRights {
91    /// User may use this Mailbox in Email/query filters and read its emails.
92    pub may_read_items: bool,
93    /// User may add mail to this Mailbox.
94    pub may_add_items: bool,
95    /// User may remove mail from this Mailbox.
96    pub may_remove_items: bool,
97    /// User may add or remove the `$seen` keyword on emails in this Mailbox.
98    pub may_set_seen: bool,
99    /// User may add or remove keywords other than `$seen` on emails.
100    pub may_set_keywords: bool,
101    /// User may create a child Mailbox under this one.
102    pub may_create_child: bool,
103    /// User may rename this Mailbox or move it under another parent.
104    pub may_rename: bool,
105    /// User may delete this Mailbox.
106    pub may_delete: bool,
107    /// Messages may be submitted directly to this Mailbox.
108    pub may_submit: bool,
109    /// User may modify the shared metadata of this Mailbox and of the Emails
110    /// in it (draft-ietf-jmap-metadata-04 §4.1; IMAP ACL "n" right, RFC 5257).
111    /// `None` when the server omits it: the right is present only when the
112    /// metadata capability lists this type or a type it contains (§4.1).
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub may_set_metadata: Option<bool>,
115    /// Catch-all for vendor / site / private extension fields not covered
116    /// by the typed fields above. Preserves unknown fields across
117    /// deserialize/serialize round-trip per workspace extras-preservation
118    /// policy (see workspace AGENTS.md).
119    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
120    pub extra: serde_json::Map<String, serde_json::Value>,
121}
122
123/// A JMAP Mailbox object (RFC 8621 §2).
124///
125/// Mailboxes are the containers for Email objects.  Each Email must belong to
126/// at least one Mailbox.  Mailboxes form an acyclic forest via `parent_id`.
127#[non_exhaustive]
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "camelCase")]
130pub struct Mailbox {
131    /// Server-assigned immutable identifier.
132    pub id: Id,
133    /// User-visible name for this Mailbox.
134    pub name: String,
135    /// Id of the parent Mailbox, or `None` if top-level; `None`
136    /// serializes as `null`.
137    pub parent_id: Option<Id>,
138    /// Well-known role identifying the Mailbox's common purpose; `None`
139    /// serializes as `null`.
140    pub role: Option<MailboxRole>,
141    /// Client UI sort position; lower values sort first among siblings.
142    /// Defaults to `0`.
143    #[serde(default)]
144    pub sort_order: u32,
145    /// Total number of Emails in this Mailbox (server-set).
146    pub total_emails: u32,
147    /// Number of Emails without `$seen` or `$draft` (server-set).
148    pub unread_emails: u32,
149    /// Number of Threads with at least one Email in this Mailbox (server-set).
150    pub total_threads: u32,
151    /// Number of unread Threads in this Mailbox (server-set).
152    pub unread_threads: u32,
153    /// ACL rights the authenticated user has on this Mailbox (server-set).
154    pub my_rights: MailboxRights,
155    /// Whether the user has subscribed to this Mailbox.
156    pub is_subscribed: bool,
157    /// Shared metadata, keyed by namespace identifier
158    /// (draft-ietf-jmap-metadata-04 §2). Each value is a JSON object (§2.1).
159    /// `None` when absent, which -04 §2 treats as `{}`; in an update a
160    /// `null` value "is equivalent to {}" (-04 §3.2) and also decodes as
161    /// `None`.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
164    /// The authenticated user's own metadata, keyed by namespace
165    /// identifier (draft-ietf-jmap-metadata-04 §2). Same shape and `None`
166    /// handling as `metadata`.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub private_metadata: Option<serde_json::Map<String, serde_json::Value>>,
169    /// Catch-all for vendor / site / private extension fields not covered
170    /// by the typed fields above. Preserves unknown fields across
171    /// deserialize/serialize round-trip per workspace extras-preservation
172    /// policy (see workspace AGENTS.md).
173    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
174    pub extra: serde_json::Map<String, serde_json::Value>,
175}
176
177impl Mailbox {
178    /// Construct a [`Mailbox`] from its required fields.
179    ///
180    /// `parent_id` and `role` default to `None`.
181    // Nine arguments because Mailbox has nine required RFC 8621 properties; all
182    // are needed for construction since #[non_exhaustive] prevents struct
183    // literals outside this crate.
184    #[allow(clippy::too_many_arguments)]
185    pub fn new(
186        id: Id,
187        name: impl Into<String>,
188        sort_order: u32,
189        total_emails: u32,
190        unread_emails: u32,
191        total_threads: u32,
192        unread_threads: u32,
193        my_rights: MailboxRights,
194        is_subscribed: bool,
195    ) -> Self {
196        Self {
197            id,
198            name: name.into(),
199            sort_order,
200            total_emails,
201            unread_emails,
202            total_threads,
203            unread_threads,
204            my_rights,
205            is_subscribed,
206            parent_id: None,
207            role: None,
208            metadata: None,
209            private_metadata: None,
210            extra: serde_json::Map::new(),
211        }
212    }
213}
214
215/// Deserialize a filter property that may be absent, `null` or a value into
216/// `Option<Option<T>>`: absent is `None` (via `#[serde(default)]`), `null`
217/// is `Some(None)` and a value is `Some(Some(v))`. Serde's own
218/// `Option<T>` impl collapses `null` into absent, which would lose the
219/// RFC 8621 §2.3 meaning of `null`. Paired with
220/// `skip_serializing_if = "Option::is_none"`, the three states round-trip.
221fn present<'de, D, T>(d: D) -> Result<Option<Option<T>>, D::Error>
222where
223    D: serde::Deserializer<'de>,
224    T: serde::Deserialize<'de>,
225{
226    serde::Deserialize::deserialize(d).map(Some)
227}
228
229/// Filter condition for `Mailbox/query` (RFC 8621 §2.3).
230///
231/// All fields are optional; a condition with no fields set matches every Mailbox.
232///
233/// ## Nullable properties
234///
235/// `parentId` (`Id|null`) and `role` (`String|null`) each have three
236/// distinct states, held as `Option<Option<T>>`:
237/// - **absent** (`None`) — do not filter on the property.
238/// - **`null`** (`Some(None)`) — only mailboxes whose property is `null`:
239///   top-level mailboxes for `parentId`, role-less ones for `role`.
240/// - **a value** (`Some(Some(v))`) — only mailboxes whose property equals `v`.
241#[non_exhaustive]
242#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
243#[serde(rename_all = "camelCase", deny_unknown_fields)]
244pub struct MailboxFilterCondition {
245    /// The `parentId` the Mailbox must have; see the type-level docs for
246    /// the three states.
247    #[serde(
248        default,
249        deserialize_with = "present",
250        skip_serializing_if = "Option::is_none"
251    )]
252    pub parent_id: Option<Option<Id>>,
253
254    /// Mailbox name must contain this string (case-sensitive substring match).
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub name: Option<String>,
257
258    /// "The Mailbox role must match the given value exactly"; see the
259    /// type-level docs for the three states.
260    #[serde(
261        default,
262        deserialize_with = "present",
263        skip_serializing_if = "Option::is_none"
264    )]
265    pub role: Option<Option<MailboxRole>>,
266
267    /// If `true`, only mailboxes with a non-null role are returned.
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub has_any_role: Option<bool>,
270
271    /// If `true`, only subscribed mailboxes are returned.
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub is_subscribed: Option<bool>,
274
275    /// draft-ietf-jmap-metadata-04 §3.6 `metadataExists`: a value must be
276    /// present at this path in the object's `metadata`. The path is a
277    /// namespace identifier, optionally followed by an RFC 6901 JSON
278    /// Pointer into the namespace value.
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub metadata_exists: Option<String>,
281
282    /// -04 §3.6 `privateMetadataExists`: as `metadata_exists`, against the
283    /// authenticated user's `privateMetadata`.
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub private_metadata_exists: Option<String>,
286
287    /// -04 §3.6 `metadataTextContains`: substring match under the
288    /// `i;unicode-casemap` collation on the `metadata` string at a path.
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub metadata_text_contains: Option<MetadataTextMatch>,
291
292    /// -04 §3.6 `privateMetadataTextContains`: as
293    /// `metadata_text_contains`, against `privateMetadata`.
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub private_metadata_text_contains: Option<MetadataTextMatch>,
296
297    /// -04 §3.6 `metadataTextEquals`: exact code-point match on the
298    /// `metadata` string at a path.
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub metadata_text_equals: Option<MetadataTextMatch>,
301
302    /// -04 §3.6 `privateMetadataTextEquals`: as `metadata_text_equals`,
303    /// against `privateMetadata`.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub private_metadata_text_equals: Option<MetadataTextMatch>,
306}
307
308impl MailboxFilterCondition {
309    /// `true` if any of the six draft-ietf-jmap-metadata-04 §3.6
310    /// `metadata*` / `privateMetadata*` fields is set.
311    pub fn has_metadata_condition(&self) -> bool {
312        self.metadata_exists.is_some()
313            || self.private_metadata_exists.is_some()
314            || self.metadata_text_contains.is_some()
315            || self.private_metadata_text_contains.is_some()
316            || self.metadata_text_equals.is_some()
317            || self.private_metadata_text_equals.is_some()
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use serde_json::json;
325
326    // ── Extras-preservation policy tests (JMAP-lbdy.2) ───────────────────
327
328    /// `MailboxRights.extra` captures vendor fields and preserves them.
329    #[test]
330    fn mailbox_rights_preserves_vendor_extras() {
331        let raw = json!({
332            "mayReadItems": true,
333            "mayAddItems": true,
334            "mayRemoveItems": false,
335            "maySetSeen": true,
336            "maySetKeywords": false,
337            "mayCreateChild": false,
338            "mayRename": false,
339            "mayDelete": false,
340            "maySubmit": false,
341            "acmeCorpMayPin": true
342        });
343        let rights: MailboxRights = serde_json::from_value(raw).unwrap();
344        assert_eq!(
345            rights.extra.get("acmeCorpMayPin").and_then(|v| v.as_bool()),
346            Some(true)
347        );
348        let back = serde_json::to_value(&rights).unwrap();
349        assert_eq!(back["acmeCorpMayPin"], true);
350    }
351
352    /// `Mailbox.extra` captures vendor fields and preserves them.
353    #[test]
354    fn mailbox_preserves_vendor_extras() {
355        let raw = json!({
356            "id": "m1",
357            "name": "Inbox",
358            "sortOrder": 0,
359            "totalEmails": 0,
360            "unreadEmails": 0,
361            "totalThreads": 0,
362            "unreadThreads": 0,
363            "myRights": {
364                "mayReadItems": true, "mayAddItems": true, "mayRemoveItems": true,
365                "maySetSeen": true, "maySetKeywords": true, "mayCreateChild": true,
366                "mayRename": true, "mayDelete": false, "maySubmit": true
367            },
368            "isSubscribed": true,
369            "acmeCorpColor": "#ff0000"
370        });
371        let mbox: Mailbox = serde_json::from_value(raw).unwrap();
372        assert_eq!(
373            mbox.extra.get("acmeCorpColor").and_then(|v| v.as_str()),
374            Some("#ff0000")
375        );
376        let back = serde_json::to_value(&mbox).unwrap();
377        assert_eq!(back["acmeCorpColor"], "#ff0000");
378    }
379
380    // ── parentId three-way semantics (RFC 8621 §2.3) ────────────────────
381    //
382    // The Mailbox/query filter distinguishes three states for `parentId`:
383    // absent (no filter), explicit JSON null (top-level only), explicit string
384    // (specific parent). The default `Option<T>` Deserialize impl collapses
385    // `null` to `None`, so `MailboxFilterCondition.parent_id` uses a custom
386    // deserializer to preserve `Some(None)`. These tests lock that
387    // behavior.
388
389    /// Oracle: absent `parentId` deserializes as `None` and serializes back to
390    /// an object without the field.
391    #[test]
392    fn mailbox_filter_parent_id_absent_round_trips() {
393        let cond: MailboxFilterCondition = serde_json::from_value(json!({})).unwrap();
394        assert!(cond.parent_id.is_none(), "absent must deserialize as None");
395        let back = serde_json::to_value(&cond).unwrap();
396        assert!(
397            back.get("parentId").is_none(),
398            "absent parentId must not appear in serialized output"
399        );
400    }
401
402    /// Oracle: explicit JSON `null` for `parentId` deserializes as
403    /// `Some(None)` (NOT `None`) and serializes back to `null`.
404    /// This is the wire-level signal for "top-level mailboxes only".
405    #[test]
406    fn mailbox_filter_parent_id_null_round_trips() {
407        let cond: MailboxFilterCondition =
408            serde_json::from_value(json!({"parentId": null})).unwrap();
409        assert_eq!(
410            cond.parent_id,
411            Some(None),
412            "explicit null must deserialize as Some(None)"
413        );
414        let back = serde_json::to_value(&cond).unwrap();
415        assert_eq!(
416            back["parentId"],
417            serde_json::Value::Null,
418            "Some(None) must serialize back as null"
419        );
420    }
421
422    /// Oracle: explicit string `parentId` deserializes as
423    /// `Some(Some(id))` and serializes back to the same string.
424    #[test]
425    fn mailbox_filter_parent_id_string_round_trips() {
426        let cond: MailboxFilterCondition =
427            serde_json::from_value(json!({"parentId": "mbox-42"})).unwrap();
428        assert_eq!(cond.parent_id, Some(Some(Id::from("mbox-42"))));
429        let back = serde_json::to_value(&cond).unwrap();
430        assert_eq!(back["parentId"], "mbox-42");
431    }
432
433    /// RFC 8621 §2.3 admits only JSON `null` and a JSON string for
434    /// `parentId`. Numbers, booleans, arrays, and nested objects MUST
435    /// be rejected at deserialize time.
436    ///
437    /// Independent oracle: the spec text itself (`parentId: Id|null`).
438    #[test]
439    fn mailbox_filter_parent_id_rejects_non_spec_shapes() {
440        for (bad, label) in [
441            (json!({"parentId": 42}), "number"),
442            (json!({"parentId": true}), "boolean"),
443            (json!({"parentId": ["m1"]}), "array"),
444            (json!({"parentId": {"id": "m1"}}), "object"),
445        ] {
446            assert!(
447                serde_json::from_value::<MailboxFilterCondition>(bad).is_err(),
448                "{label} must be rejected"
449            );
450        }
451    }
452    /// Oracle: RFC 8621 §2.3 types the filter `role` as `String|null`
453    /// ("The Mailbox role must match the given value exactly"), so an
454    /// explicit `null` (role-less mailboxes) is distinct from absent and
455    /// round-trips as `null`.
456    #[test]
457    fn mailbox_filter_role_null_round_trips() {
458        let cond: MailboxFilterCondition = serde_json::from_value(json!({"role": null})).unwrap();
459        assert_eq!(cond.role, Some(None));
460        assert_eq!(serde_json::to_value(&cond).unwrap(), json!({"role": null}));
461        let absent: MailboxFilterCondition = serde_json::from_value(json!({})).unwrap();
462        assert_eq!(absent.role, None);
463        assert_eq!(serde_json::to_value(&absent).unwrap(), json!({}));
464    }
465
466    /// Oracle: RFC 8621 §2 role registry value `"trash"`; a filter `role`
467    /// string deserializes into the typed [`MailboxRole`] and serializes
468    /// back to the same string, and a non-string is rejected
469    /// (`String|null`, RFC 8621 §2.3).
470    #[test]
471    fn mailbox_filter_role_value_is_typed() {
472        let cond: MailboxFilterCondition =
473            serde_json::from_value(json!({"role": "trash"})).unwrap();
474        assert_eq!(cond.role, Some(Some(MailboxRole::Trash)));
475        assert_eq!(
476            serde_json::to_value(&cond).unwrap(),
477            json!({"role": "trash"})
478        );
479        assert!(serde_json::from_value::<MailboxFilterCondition>(json!({"role": 7})).is_err());
480    }
481
482    /// Oracle: -04 §3.6 extends the Mailbox/query FilterCondition with
483    /// `metadataTextEquals`; hand-written leaf inside a `NOT` operator.
484    /// A misspelt name is still rejected.
485    #[test]
486    fn mailbox_filter_metadata_condition_in_tree() {
487        let wire = json!({
488            "operator": "NOT",
489            "conditions": [
490                { "metadataTextEquals": { "path": "ns/tag", "value": "x" } }
491            ]
492        });
493        let filter: crate::MailboxFilter = serde_json::from_value(wire.clone()).unwrap();
494        let jmap_types::Filter::Operator(op) = &filter else {
495            panic!("expected an operator, got {filter:?}");
496        };
497        let jmap_types::Filter::Condition(leaf) = &op.conditions[0] else {
498            panic!("expected a condition");
499        };
500        assert_eq!(
501            leaf.metadata_text_equals,
502            Some(MetadataTextMatch::new("ns/tag", "x"))
503        );
504        assert!(leaf.has_metadata_condition());
505        assert!(!MailboxFilterCondition::default().has_metadata_condition());
506        assert_eq!(serde_json::to_value(&filter).unwrap(), wire);
507        assert!(
508            serde_json::from_value::<MailboxFilterCondition>(json!({"metadataEquals": {}}))
509                .is_err()
510        );
511    }
512
513    /// Oracle: -04 §2 adds `metadata` and `privateMetadata` (objects keyed
514    /// by namespace) to each listed type; hand-written Mailbox JSON. The
515    /// values land in the typed fields, not `extra`. -04 §3.2: in an update
516    /// "null is equivalent to {}", so `null` decodes as absent; a
517    /// non-object value is rejected.
518    #[test]
519    fn mailbox_metadata_properties() {
520        let mut raw = json!({
521            "id": "m1",
522            "name": "Inbox",
523            "parentId": null,
524            "role": null,
525            "totalEmails": 0,
526            "unreadEmails": 0,
527            "totalThreads": 0,
528            "unreadThreads": 0,
529            "myRights": {
530                "mayReadItems": true, "mayAddItems": true, "mayRemoveItems": true,
531                "maySetSeen": true, "maySetKeywords": true, "mayCreateChild": true,
532                "mayRename": true, "mayDelete": true, "maySubmit": true
533            },
534            "isSubscribed": true,
535            "metadata": {"acme.example.com": {"color": "red"}},
536            "privateMetadata": {"acme.example.com": {"memo": "mine"}}
537        });
538        let mb: Mailbox = serde_json::from_value(raw.clone()).unwrap();
539        assert_eq!(
540            mb.metadata
541                .as_ref()
542                .map(|m| &m["acme.example.com"]["color"]),
543            Some(&json!("red"))
544        );
545        assert_eq!(
546            mb.private_metadata
547                .as_ref()
548                .map(|m| &m["acme.example.com"]["memo"]),
549            Some(&json!("mine"))
550        );
551        assert!(mb.extra.is_empty());
552        assert_eq!(
553            serde_json::to_value(&mb).unwrap()["metadata"],
554            raw["metadata"]
555        );
556
557        raw["metadata"] = json!(null);
558        let mb: Mailbox = serde_json::from_value(raw.clone()).unwrap();
559        assert_eq!(mb.metadata, None);
560        assert!(serde_json::to_value(&mb).unwrap().get("metadata").is_none());
561
562        raw["metadata"] = json!("red");
563        assert!(serde_json::from_value::<Mailbox>(raw).is_err());
564    }
565}