use jmap_metadata_types::MetadataTextMatch;
use jmap_types::{Id, UTCDate, impl_string_enum};
use serde::{Deserialize, Serialize};
use crate::keyword::Keyword;
pub use jmap_types::{Filter, FilterOperator, Operator};
pub type EmailFilter = Filter<EmailFilterCondition>;
pub type MailboxFilter = Filter<crate::mailbox::MailboxFilterCondition>;
pub type EmailSubmissionFilter = Filter<crate::submission::EmailSubmissionFilterCondition>;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct EmailFilterCondition {
#[serde(skip_serializing_if = "Option::is_none")]
pub in_mailbox: Option<Id>,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_mailbox_other_than: Option<Vec<Id>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub before: Option<UTCDate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub after: Option<UTCDate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub all_in_thread_have_keyword: Option<Keyword>,
#[serde(skip_serializing_if = "Option::is_none")]
pub some_in_thread_have_keyword: Option<Keyword>,
#[serde(skip_serializing_if = "Option::is_none")]
pub none_in_thread_have_keyword: Option<Keyword>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_keyword: Option<Keyword>,
#[serde(skip_serializing_if = "Option::is_none")]
pub not_keyword: Option<Keyword>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_attachment: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bcc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_header"
)]
pub header: Option<Vec<String>>,
#[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 EmailFilterCondition {
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()
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ComparatorProperty {
ReceivedAt,
Size,
From,
To,
Subject,
SentAt,
HasKeyword,
AllInThreadHaveKeyword,
SomeInThreadHaveKeyword,
Other(String),
}
impl_string_enum!(ComparatorProperty, "an Email comparator property string",
"receivedAt" => ReceivedAt,
"size" => Size,
"from" => From,
"to" => To,
"subject" => Subject,
"sentAt" => SentAt,
"hasKeyword" => HasKeyword,
"allInThreadHaveKeyword" => AllInThreadHaveKeyword,
"someInThreadHaveKeyword" => SomeInThreadHaveKeyword,
);
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailComparator {
pub property: ComparatorProperty,
#[serde(default = "bool_true", skip_serializing_if = "is_true")]
pub is_ascending: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub collation: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keyword: Option<Keyword>,
}
impl EmailComparator {
pub fn new(property: ComparatorProperty) -> Self {
Self {
property,
is_ascending: true,
collation: None,
keyword: None,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum MailboxComparatorProperty {
SortOrder,
Name,
Other(String),
}
impl_string_enum!(MailboxComparatorProperty, "a Mailbox sort property string",
"sortOrder" => SortOrder,
"name" => Name,
);
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MailboxComparator {
pub property: MailboxComparatorProperty,
#[serde(default = "bool_true", skip_serializing_if = "is_true")]
pub is_ascending: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub collation: Option<String>,
}
impl MailboxComparator {
pub fn new(property: MailboxComparatorProperty) -> Self {
Self {
property,
is_ascending: true,
collation: None,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum EmailSubmissionComparatorProperty {
EmailId,
ThreadId,
SentAt,
Other(String),
}
impl_string_enum!(EmailSubmissionComparatorProperty, "an EmailSubmission sort property string",
"emailId" => EmailId,
"threadId" => ThreadId,
"sentAt" => SentAt,
);
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailSubmissionComparator {
pub property: EmailSubmissionComparatorProperty,
#[serde(default = "bool_true", skip_serializing_if = "is_true")]
pub is_ascending: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub collation: Option<String>,
}
impl EmailSubmissionComparator {
pub fn new(property: EmailSubmissionComparatorProperty) -> Self {
Self {
property,
is_ascending: true,
collation: None,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum SieveScriptComparatorProperty {
Name,
IsActive,
Other(String),
}
impl_string_enum!(SieveScriptComparatorProperty, "a SieveScript sort property string",
"name" => Name,
"isActive" => IsActive,
);
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SieveScriptComparator {
pub property: SieveScriptComparatorProperty,
#[serde(default = "bool_true", skip_serializing_if = "is_true")]
pub is_ascending: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub collation: Option<String>,
}
impl SieveScriptComparator {
pub fn new(property: SieveScriptComparatorProperty) -> Self {
Self {
property,
is_ascending: true,
collation: None,
}
}
}
fn bool_true() -> bool {
true
}
fn is_true(b: &bool) -> bool {
*b
}
fn deserialize_header<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let v: Option<Vec<String>> = Option::deserialize(deserializer)?;
if let Some(ref h) = v
&& (h.is_empty() || h.len() > 2)
{
return Err(serde::de::Error::custom(format!(
"header must have 1 or 2 elements (RFC 8621 §4.4.1), got {}",
h.len()
)));
}
Ok(v)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filter_condition_in_mailbox() {
let json = r#"{"inMailbox":"fb666a55"}"#;
let f: EmailFilter = serde_json::from_str(json).expect("must parse");
match &f {
Filter::Condition(c) => {
assert_eq!(
c.in_mailbox.as_ref().map(|id| id.as_ref()),
Some("fb666a55")
);
}
other => panic!("expected Condition, got {other:?}"),
}
let back = serde_json::to_string(&f).expect("serialize");
assert_eq!(back, json);
}
#[test]
fn filter_operator_or_two_keywords() {
let json =
r#"{"operator":"OR","conditions":[{"hasKeyword":"music"},{"hasKeyword":"video"}]}"#;
let f: EmailFilter = serde_json::from_str(json).expect("must parse");
match &f {
Filter::Operator(op) => {
assert_eq!(op.operator, Operator::Or);
assert_eq!(op.conditions.len(), 2);
match &op.conditions[0] {
Filter::Condition(c) => {
assert_eq!(c.has_keyword.as_deref(), Some("music"))
}
other => panic!("expected Condition, got {other:?}"),
}
match &op.conditions[1] {
Filter::Condition(c) => {
assert_eq!(c.has_keyword.as_deref(), Some("video"))
}
other => panic!("expected Condition, got {other:?}"),
}
}
other => panic!("expected Operator, got {other:?}"),
}
let back = serde_json::to_string(&f).expect("serialize");
assert_eq!(back, json);
}
#[test]
fn nested_and_or_roundtrip() {
let filter = EmailFilter::Operator(FilterOperator::new(
Operator::And,
vec![
EmailFilter::Condition(EmailFilterCondition {
in_mailbox: Some(Id::from("inbox-id")),
..Default::default()
}),
EmailFilter::Operator(FilterOperator::new(
Operator::Or,
vec![
EmailFilter::Condition(EmailFilterCondition {
has_keyword: Some(Keyword::from("$flagged")),
..Default::default()
}),
EmailFilter::Condition(EmailFilterCondition {
has_keyword: Some(Keyword::from("$answered")),
..Default::default()
}),
],
)),
],
));
let json = serde_json::to_string(&filter).expect("serialize");
let back: EmailFilter = serde_json::from_str(&json).expect("deserialize");
assert_eq!(filter, back);
}
#[test]
fn empty_condition_serializes_to_empty_object() {
let c = EmailFilterCondition::default();
let json = serde_json::to_string(&c).expect("serialize");
assert_eq!(json, "{}");
}
#[test]
fn all_scalar_fields_roundtrip() {
let c = EmailFilterCondition {
in_mailbox: Some(Id::from("mb1")),
min_size: Some(1024),
max_size: Some(65536),
all_in_thread_have_keyword: Some(Keyword::from("$seen")),
some_in_thread_have_keyword: Some(Keyword::from("$flagged")),
none_in_thread_have_keyword: Some(Keyword::from("$draft")),
has_keyword: Some(Keyword::from("$answered")),
not_keyword: Some(Keyword::from("$junk")),
has_attachment: Some(true),
text: Some("hello".to_owned()),
from: Some("alice@example.com".to_owned()),
to: Some("bob@example.com".to_owned()),
cc: Some("carol@example.com".to_owned()),
bcc: Some("dave@example.com".to_owned()),
subject: Some("Meeting".to_owned()),
body: Some("agenda".to_owned()),
..Default::default()
};
let json = serde_json::to_string(&c).expect("serialize");
let back: EmailFilterCondition = serde_json::from_str(&json).expect("deserialize");
assert_eq!(c, back);
}
#[test]
fn header_one_element_accepted() {
let json = r#"{"header":["X-Spam-Status"]}"#;
let c: EmailFilterCondition = serde_json::from_str(json).expect("must parse");
let h = c.header.as_ref().expect("header must be present");
assert_eq!(h.len(), 1);
assert_eq!(h[0], "X-Spam-Status");
}
#[test]
fn header_two_elements_accepted() {
let json = r#"{"header":["X-Spam-Status","Yes"]}"#;
let c: EmailFilterCondition = serde_json::from_str(json).expect("must parse");
let h = c.header.as_ref().expect("header must be present");
assert_eq!(h.len(), 2);
assert_eq!(h[0], "X-Spam-Status");
assert_eq!(h[1], "Yes");
}
#[test]
fn header_zero_elements_rejected() {
let json = r#"{"header":[]}"#;
let err = serde_json::from_str::<EmailFilterCondition>(json)
.expect_err("0-element header must fail");
let msg = err.to_string();
assert!(
msg.contains("header must have 1 or 2 elements"),
"unexpected error: {msg}"
);
}
#[test]
fn header_three_elements_rejected() {
let json = r#"{"header":["X-Foo","bar","extra"]}"#;
let err = serde_json::from_str::<EmailFilterCondition>(json)
.expect_err("3-element header must fail");
let msg = err.to_string();
assert!(
msg.contains("header must have 1 or 2 elements"),
"unexpected error: {msg}"
);
}
#[test]
fn date_fields_roundtrip() {
let json = r#"{"before":"2024-01-15T12:00:00Z","after":"2024-01-01T00:00:00Z"}"#;
let c: EmailFilterCondition = serde_json::from_str(json).expect("must parse");
let back = serde_json::to_string(&c).expect("serialize");
assert_eq!(back, json);
}
#[test]
fn in_mailbox_other_than_roundtrip() {
let json = r#"{"inMailboxOtherThan":["trash-id","spam-id"]}"#;
let c: EmailFilterCondition = serde_json::from_str(json).expect("must parse");
let ids: Vec<&str> = c
.in_mailbox_other_than
.as_ref()
.unwrap()
.iter()
.map(|id| id.as_ref())
.collect();
assert_eq!(ids, ["trash-id", "spam-id"]);
let back = serde_json::to_string(&c).expect("serialize");
assert_eq!(back, json);
}
#[test]
fn mailbox_comparator_rfc8621_properties() {
let c: MailboxComparator =
serde_json::from_str(r#"{"property":"sortOrder"}"#).expect("must parse");
assert_eq!(c.property, MailboxComparatorProperty::SortOrder);
assert!(c.is_ascending);
let c: MailboxComparator =
serde_json::from_str(r#"{"property":"name","isAscending":false}"#).expect("must parse");
assert_eq!(c.property, MailboxComparatorProperty::Name);
assert!(!c.is_ascending);
assert_eq!(
serde_json::to_value(&c).unwrap(),
serde_json::json!({"property":"name","isAscending":false})
);
}
#[test]
fn mailbox_comparator_unknown_property_round_trips() {
let json = serde_json::json!({"property":"totalEmails"});
let c: MailboxComparator = serde_json::from_value(json.clone()).expect("must parse");
assert_eq!(
c.property,
MailboxComparatorProperty::Other("totalEmails".to_owned())
);
assert_eq!(serde_json::to_value(&c).unwrap(), json);
}
#[test]
fn email_submission_comparator_rfc8621_properties() {
for (wire, want) in [
("emailId", EmailSubmissionComparatorProperty::EmailId),
("threadId", EmailSubmissionComparatorProperty::ThreadId),
("sentAt", EmailSubmissionComparatorProperty::SentAt),
] {
let json = serde_json::json!({ "property": wire });
let c: EmailSubmissionComparator =
serde_json::from_value(json.clone()).expect("must parse");
assert_eq!(c.property, want);
assert!(c.is_ascending);
assert_eq!(serde_json::to_value(&c).unwrap(), json);
}
let c: EmailSubmissionComparator =
serde_json::from_str(r#"{"property":"undoStatus"}"#).expect("must parse");
assert_eq!(
c.property,
EmailSubmissionComparatorProperty::Other("undoStatus".to_owned())
);
}
#[test]
fn comparator_example_from_rfc() {
let json0 =
r#"{"property":"someInThreadHaveKeyword","keyword":"$flagged","isAscending":false}"#;
let c0: EmailComparator = serde_json::from_str(json0).expect("must parse");
assert_eq!(c0.property, ComparatorProperty::SomeInThreadHaveKeyword);
assert_eq!(c0.keyword.as_deref(), Some("$flagged"));
assert!(!c0.is_ascending);
let json1 = r#"{"property":"subject","collation":"i;ascii-casemap"}"#;
let c1: EmailComparator = serde_json::from_str(json1).expect("must parse");
assert_eq!(c1.property, ComparatorProperty::Subject);
assert_eq!(c1.collation.as_deref(), Some("i;ascii-casemap"));
assert!(c1.is_ascending);
let back1 = serde_json::to_string(&c1).expect("serialize");
assert_eq!(back1, json1);
let json2 = r#"{"property":"receivedAt","isAscending":false}"#;
let c2: EmailComparator = serde_json::from_str(json2).expect("must parse");
assert_eq!(c2.property, ComparatorProperty::ReceivedAt);
assert!(!c2.is_ascending);
let back2 = serde_json::to_string(&c2).expect("serialize");
assert_eq!(back2, json2);
}
#[test]
fn comparator_is_ascending_default_and_skip() {
let json = r#"{"property":"receivedAt"}"#;
let c: EmailComparator = serde_json::from_str(json).expect("must parse");
assert!(c.is_ascending, "default must be true");
let back = serde_json::to_string(&c).expect("serialize");
assert_eq!(back, json, "isAscending:true must be omitted");
}
#[test]
fn filter_unknown_fields_are_rejected() {
for raw in [
r#"{"unknownField":"value"}"#,
r#"{"in_mailbox":"inbox"}"#,
r#"{"inMailbox":"inbox","unknownField":1}"#,
r#"{"operator":"AND","conditions":[{"operator":"NOT","conditions":[{"in_mailbox":"x"}]}]}"#,
r#"{"operator":"AND","conditions":[],"inMailbox":"x"}"#,
] {
assert!(
serde_json::from_str::<EmailFilter>(raw).is_err(),
"{raw} must be rejected"
);
}
let all_known = r#"{"inMailbox":"m1","inMailboxOtherThan":["m2"],"before":"2024-01-01T00:00:00Z","after":"2023-01-01T00:00:00Z","minSize":1,"maxSize":2,"allInThreadHaveKeyword":"$seen","someInThreadHaveKeyword":"$seen","noneInThreadHaveKeyword":"$seen","hasKeyword":"$seen","notKeyword":"$draft","hasAttachment":true,"text":"t","from":"f","to":"t","cc":"c","bcc":"b","subject":"s","body":"b","header":["X-A","v"]}"#;
let f: EmailFilter = serde_json::from_str(all_known).expect("all §4.4.1 properties decode");
assert!(matches!(f, Filter::Condition(_)));
}
#[test]
fn filter_empty_object_becomes_empty_condition() {
let json = "{}";
let f: EmailFilter = serde_json::from_str(json).expect("must parse");
assert!(
matches!(&f, Filter::Condition(c) if c == &EmailFilterCondition::default()),
"empty object must yield all-None Condition"
);
}
#[test]
fn comparator_new_constructor() {
let cmp = EmailComparator::new(ComparatorProperty::ReceivedAt);
assert_eq!(cmp.property, ComparatorProperty::ReceivedAt);
assert!(cmp.is_ascending);
assert!(cmp.collation.is_none());
assert!(cmp.keyword.is_none());
let json = serde_json::to_string(&cmp).expect("serialize");
assert_eq!(json, r#"{"property":"receivedAt"}"#);
}
#[test]
fn comparator_new_with_mutation() {
let mut cmp = EmailComparator::new(ComparatorProperty::HasKeyword);
cmp.keyword = Some(Keyword::from("$flagged"));
cmp.is_ascending = false;
let json = serde_json::to_string(&cmp).expect("serialize");
assert_eq!(
json,
r#"{"property":"hasKeyword","isAscending":false,"keyword":"$flagged"}"#
);
}
#[test]
fn metadata_conditions_survive_operator_tree() {
let wire = serde_json::json!({
"operator": "AND",
"conditions": [
{ "inMailbox": "MB-inbox" },
{ "privateMetadataExists": "acme.example.com/memo" },
{
"privateMetadataTextContains": {
"path": "acme.example.com/memo",
"value": "follow up"
}
}
]
});
let filter: EmailFilter = serde_json::from_value(wire.clone()).expect("decode");
let Filter::Operator(op) = &filter else {
panic!("expected an operator, got {filter:?}");
};
let leaves: Vec<&EmailFilterCondition> = op
.conditions
.iter()
.map(|f| match f {
Filter::Condition(c) => c,
other => panic!("expected a condition, got {other:?}"),
})
.collect();
assert!(!leaves[0].has_metadata_condition());
assert_eq!(
leaves[1].private_metadata_exists.as_deref(),
Some("acme.example.com/memo")
);
assert!(leaves[1].has_metadata_condition());
assert_eq!(
leaves[2].private_metadata_text_contains,
Some(MetadataTextMatch::new("acme.example.com/memo", "follow up"))
);
assert_eq!(serde_json::to_value(&filter).expect("encode"), wire);
}
#[test]
fn metadata_condition_names_decode_and_typos_reject() {
let m = serde_json::json!({"path": "ns/k", "value": "v"});
for (name, value) in [
("metadataExists", serde_json::json!("ns")),
("privateMetadataExists", serde_json::json!("ns")),
("metadataTextContains", m.clone()),
("privateMetadataTextContains", m.clone()),
("metadataTextEquals", m.clone()),
("privateMetadataTextEquals", m.clone()),
] {
let c: EmailFilterCondition =
serde_json::from_value(serde_json::json!({ name: value })).expect(name);
assert!(c.has_metadata_condition(), "{name}");
}
assert!(
serde_json::from_value::<EmailFilterCondition>(serde_json::json!({
"metadataExist": "ns"
}))
.is_err()
);
}
}