use jmap_types::{Id, UTCDate};
use serde::{Deserialize, Serialize};
use crate::keyword::Keyword;
pub use jmap_types::query::{Filter, FilterOperator, Operator};
pub type EmailFilter = Filter<EmailFilterCondition>;
pub type EmailSubmissionFilter = Filter<crate::submission::EmailSubmissionFilterCondition>;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
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>>,
}
#[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,
}
}
}
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 {
if 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 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_become_empty_condition() {
let json = r#"{"unknownField":"value"}"#;
let f: EmailFilter = serde_json::from_str(json).expect("must parse as empty Condition");
match &f {
Filter::Condition(c) => {
assert_eq!(
*c,
EmailFilterCondition::default(),
"unknown fields yield all-None"
);
}
other => panic!("expected empty Condition, got {other:?}"),
}
}
#[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"}"#
);
}
}