use jmap_metadata_types::MetadataTextMatch;
use jmap_types::{Id, impl_string_enum};
use serde::{Deserialize, Serialize};
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum MailboxRole {
Inbox,
Trash,
Sent,
Drafts,
Junk,
Archive,
Flagged,
Important,
All,
Other(String),
}
impl_string_enum!(MailboxRole, "a JMAP Mailbox role string",
"inbox" => Inbox,
"trash" => Trash,
"sent" => Sent,
"drafts" => Drafts,
"junk" => Junk,
"archive" => Archive,
"flagged" => Flagged,
"important" => Important,
"all" => All,
);
impl MailboxRole {
pub fn to_wire_str(&self) -> &str {
match self {
Self::Inbox => "inbox",
Self::Trash => "trash",
Self::Sent => "sent",
Self::Drafts => "drafts",
Self::Junk => "junk",
Self::Archive => "archive",
Self::Flagged => "flagged",
Self::Important => "important",
Self::All => "all",
Self::Other(s) => s.as_str(),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MailboxRights {
pub may_read_items: bool,
pub may_add_items: bool,
pub may_remove_items: bool,
pub may_set_seen: bool,
pub may_set_keywords: bool,
pub may_create_child: bool,
pub may_rename: bool,
pub may_delete: bool,
pub may_submit: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub may_set_metadata: Option<bool>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Mailbox {
pub id: Id,
pub name: String,
pub parent_id: Option<Id>,
pub role: Option<MailboxRole>,
#[serde(default)]
pub sort_order: u32,
pub total_emails: u32,
pub unread_emails: u32,
pub total_threads: u32,
pub unread_threads: u32,
pub my_rights: MailboxRights,
pub is_subscribed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub private_metadata: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl Mailbox {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: Id,
name: impl Into<String>,
sort_order: u32,
total_emails: u32,
unread_emails: u32,
total_threads: u32,
unread_threads: u32,
my_rights: MailboxRights,
is_subscribed: bool,
) -> Self {
Self {
id,
name: name.into(),
sort_order,
total_emails,
unread_emails,
total_threads,
unread_threads,
my_rights,
is_subscribed,
parent_id: None,
role: None,
metadata: None,
private_metadata: None,
extra: serde_json::Map::new(),
}
}
}
fn present<'de, D, T>(d: D) -> Result<Option<Option<T>>, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::Deserialize<'de>,
{
serde::Deserialize::deserialize(d).map(Some)
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct MailboxFilterCondition {
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub parent_id: Option<Option<Id>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub role: Option<Option<MailboxRole>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_any_role: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_subscribed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata_exists: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub private_metadata_exists: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata_text_contains: Option<MetadataTextMatch>,
#[serde(skip_serializing_if = "Option::is_none")]
pub private_metadata_text_contains: Option<MetadataTextMatch>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata_text_equals: Option<MetadataTextMatch>,
#[serde(skip_serializing_if = "Option::is_none")]
pub private_metadata_text_equals: Option<MetadataTextMatch>,
}
impl MailboxFilterCondition {
pub fn has_metadata_condition(&self) -> bool {
self.metadata_exists.is_some()
|| self.private_metadata_exists.is_some()
|| self.metadata_text_contains.is_some()
|| self.private_metadata_text_contains.is_some()
|| self.metadata_text_equals.is_some()
|| self.private_metadata_text_equals.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn mailbox_rights_preserves_vendor_extras() {
let raw = json!({
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": false,
"maySetSeen": true,
"maySetKeywords": false,
"mayCreateChild": false,
"mayRename": false,
"mayDelete": false,
"maySubmit": false,
"acmeCorpMayPin": true
});
let rights: MailboxRights = serde_json::from_value(raw).unwrap();
assert_eq!(
rights.extra.get("acmeCorpMayPin").and_then(|v| v.as_bool()),
Some(true)
);
let back = serde_json::to_value(&rights).unwrap();
assert_eq!(back["acmeCorpMayPin"], true);
}
#[test]
fn mailbox_preserves_vendor_extras() {
let raw = json!({
"id": "m1",
"name": "Inbox",
"sortOrder": 0,
"totalEmails": 0,
"unreadEmails": 0,
"totalThreads": 0,
"unreadThreads": 0,
"myRights": {
"mayReadItems": true, "mayAddItems": true, "mayRemoveItems": true,
"maySetSeen": true, "maySetKeywords": true, "mayCreateChild": true,
"mayRename": true, "mayDelete": false, "maySubmit": true
},
"isSubscribed": true,
"acmeCorpColor": "#ff0000"
});
let mbox: Mailbox = serde_json::from_value(raw).unwrap();
assert_eq!(
mbox.extra.get("acmeCorpColor").and_then(|v| v.as_str()),
Some("#ff0000")
);
let back = serde_json::to_value(&mbox).unwrap();
assert_eq!(back["acmeCorpColor"], "#ff0000");
}
#[test]
fn mailbox_filter_parent_id_absent_round_trips() {
let cond: MailboxFilterCondition = serde_json::from_value(json!({})).unwrap();
assert!(cond.parent_id.is_none(), "absent must deserialize as None");
let back = serde_json::to_value(&cond).unwrap();
assert!(
back.get("parentId").is_none(),
"absent parentId must not appear in serialized output"
);
}
#[test]
fn mailbox_filter_parent_id_null_round_trips() {
let cond: MailboxFilterCondition =
serde_json::from_value(json!({"parentId": null})).unwrap();
assert_eq!(
cond.parent_id,
Some(None),
"explicit null must deserialize as Some(None)"
);
let back = serde_json::to_value(&cond).unwrap();
assert_eq!(
back["parentId"],
serde_json::Value::Null,
"Some(None) must serialize back as null"
);
}
#[test]
fn mailbox_filter_parent_id_string_round_trips() {
let cond: MailboxFilterCondition =
serde_json::from_value(json!({"parentId": "mbox-42"})).unwrap();
assert_eq!(cond.parent_id, Some(Some(Id::from("mbox-42"))));
let back = serde_json::to_value(&cond).unwrap();
assert_eq!(back["parentId"], "mbox-42");
}
#[test]
fn mailbox_filter_parent_id_rejects_non_spec_shapes() {
for (bad, label) in [
(json!({"parentId": 42}), "number"),
(json!({"parentId": true}), "boolean"),
(json!({"parentId": ["m1"]}), "array"),
(json!({"parentId": {"id": "m1"}}), "object"),
] {
assert!(
serde_json::from_value::<MailboxFilterCondition>(bad).is_err(),
"{label} must be rejected"
);
}
}
#[test]
fn mailbox_filter_role_null_round_trips() {
let cond: MailboxFilterCondition = serde_json::from_value(json!({"role": null})).unwrap();
assert_eq!(cond.role, Some(None));
assert_eq!(serde_json::to_value(&cond).unwrap(), json!({"role": null}));
let absent: MailboxFilterCondition = serde_json::from_value(json!({})).unwrap();
assert_eq!(absent.role, None);
assert_eq!(serde_json::to_value(&absent).unwrap(), json!({}));
}
#[test]
fn mailbox_filter_role_value_is_typed() {
let cond: MailboxFilterCondition =
serde_json::from_value(json!({"role": "trash"})).unwrap();
assert_eq!(cond.role, Some(Some(MailboxRole::Trash)));
assert_eq!(
serde_json::to_value(&cond).unwrap(),
json!({"role": "trash"})
);
assert!(serde_json::from_value::<MailboxFilterCondition>(json!({"role": 7})).is_err());
}
#[test]
fn mailbox_filter_metadata_condition_in_tree() {
let wire = json!({
"operator": "NOT",
"conditions": [
{ "metadataTextEquals": { "path": "ns/tag", "value": "x" } }
]
});
let filter: crate::MailboxFilter = serde_json::from_value(wire.clone()).unwrap();
let jmap_types::Filter::Operator(op) = &filter else {
panic!("expected an operator, got {filter:?}");
};
let jmap_types::Filter::Condition(leaf) = &op.conditions[0] else {
panic!("expected a condition");
};
assert_eq!(
leaf.metadata_text_equals,
Some(MetadataTextMatch::new("ns/tag", "x"))
);
assert!(leaf.has_metadata_condition());
assert!(!MailboxFilterCondition::default().has_metadata_condition());
assert_eq!(serde_json::to_value(&filter).unwrap(), wire);
assert!(
serde_json::from_value::<MailboxFilterCondition>(json!({"metadataEquals": {}}))
.is_err()
);
}
#[test]
fn mailbox_metadata_properties() {
let mut raw = json!({
"id": "m1",
"name": "Inbox",
"parentId": null,
"role": null,
"totalEmails": 0,
"unreadEmails": 0,
"totalThreads": 0,
"unreadThreads": 0,
"myRights": {
"mayReadItems": true, "mayAddItems": true, "mayRemoveItems": true,
"maySetSeen": true, "maySetKeywords": true, "mayCreateChild": true,
"mayRename": true, "mayDelete": true, "maySubmit": true
},
"isSubscribed": true,
"metadata": {"acme.example.com": {"color": "red"}},
"privateMetadata": {"acme.example.com": {"memo": "mine"}}
});
let mb: Mailbox = serde_json::from_value(raw.clone()).unwrap();
assert_eq!(
mb.metadata
.as_ref()
.map(|m| &m["acme.example.com"]["color"]),
Some(&json!("red"))
);
assert_eq!(
mb.private_metadata
.as_ref()
.map(|m| &m["acme.example.com"]["memo"]),
Some(&json!("mine"))
);
assert!(mb.extra.is_empty());
assert_eq!(
serde_json::to_value(&mb).unwrap()["metadata"],
raw["metadata"]
);
raw["metadata"] = json!(null);
let mb: Mailbox = serde_json::from_value(raw.clone()).unwrap();
assert_eq!(mb.metadata, None);
assert!(serde_json::to_value(&mb).unwrap().get("metadata").is_none());
raw["metadata"] = json!("red");
assert!(serde_json::from_value::<Mailbox>(raw).is_err());
}
}