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}