use serde::{Deserialize, Serialize};
use crate::error::FilterError;
pub trait Filterable {
fn from(&self) -> &str;
fn to(&self) -> &str;
fn cc(&self) -> &str;
fn subject(&self) -> &str;
fn body(&self) -> &str;
fn has_attachment(&self) -> bool;
fn header(&self, name: &str) -> Option<&str> {
let _ = name;
None
}
fn envelope_from(&self) -> Option<&str> {
None
}
fn envelope_to(&self) -> Option<&str> {
None
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FilterRule {
pub id: String,
pub name: String,
pub enabled: bool,
pub priority: i32,
pub conditions: Vec<Condition>,
pub condition_logic: LogicOp,
pub actions: Vec<Action>,
}
impl FilterRule {
pub fn validate(&self) -> Result<(), FilterError> {
if self.id.is_empty() {
return Err(FilterError::EmptyRuleId);
}
if self.name.is_empty() {
return Err(FilterError::EmptyRuleName);
}
for condition in &self.conditions {
if condition.operator == Operator::Regex {
regex::Regex::new(&condition.value).map_err(|source| {
FilterError::InvalidRegex {
pattern: condition.value.clone(),
source,
}
})?;
}
}
for action in &self.actions {
if let Action::Vacation(vacation) = action {
vacation.validate()?;
}
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum LogicOp {
And,
Or,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Condition {
pub field: ConditionField,
pub operator: Operator,
pub value: String,
pub negate: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConditionField {
From,
To,
Cc,
Subject,
Body,
Header(String),
HasAttachment,
Envelope {
part: EnvelopePart,
address_part: AddressPart,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum EnvelopePart {
From,
To,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AddressPart {
All,
Localpart,
Domain,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Operator {
Contains,
Equals,
Matches,
Regex,
Exists,
NumericEquals,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Flag {
Seen,
Answered,
Flagged,
Deleted,
Draft,
Keyword(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Action {
MoveTo(String),
CopyTo(String),
Flag(Vec<Flag>),
Unflag(Vec<Flag>),
SetFlags(Vec<Flag>),
MarkRead,
Delete,
Forward(String),
Vacation(Vacation),
Notify(Notify),
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Vacation {
pub days: u32,
pub subject: Option<String>,
pub from: Option<String>,
pub message: String,
}
impl Vacation {
#[must_use]
pub fn new(message: impl Into<String>) -> Self {
Self {
days: 7,
subject: None,
from: None,
message: message.into(),
}
}
#[must_use]
pub fn with_days(mut self, days: u32) -> Self {
self.days = days;
self
}
#[must_use]
pub fn with_subject(mut self, subject: impl Into<String>) -> Self {
self.subject = Some(subject.into());
self
}
#[must_use]
pub fn with_from(mut self, from: impl Into<String>) -> Self {
self.from = Some(from.into());
self
}
pub fn validate(&self) -> Result<(), FilterError> {
if self.message.is_empty() {
return Err(FilterError::InvalidVacation {
reason: "message must not be empty".to_string(),
});
}
if self.days == 0 {
return Err(FilterError::InvalidVacation {
reason: "days must be at least 1".to_string(),
});
}
Ok(())
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Notify {
pub method: String,
pub message: String,
}
pub const KNOWN_NOTIFY_SCHEMES: [&str; 6] = ["mailto", "xmpp", "sms", "tel", "http", "https"];
impl Notify {
#[must_use]
pub fn new(method: impl Into<String>, message: impl Into<String>) -> Self {
Self {
method: method.into(),
message: message.into(),
}
}
#[must_use]
pub fn scheme(&self) -> Option<String> {
let (scheme, _) = self.method.split_once(':')?;
let scheme = scheme.to_ascii_lowercase();
if scheme.is_empty() {
None
} else {
Some(scheme)
}
}
#[must_use]
pub fn has_known_scheme(&self) -> bool {
self.scheme()
.is_some_and(|s| KNOWN_NOTIFY_SCHEMES.contains(&s.as_str()))
}
}
#[derive(Clone, Debug, Default)]
pub struct FieldValues<'a> {
pub from: &'a str,
pub to: &'a str,
pub cc: &'a str,
pub subject: &'a str,
pub body: &'a str,
pub has_attachment: bool,
pub headers: Vec<(&'a str, &'a str)>,
pub envelope_from: Option<&'a str>,
pub envelope_to: Option<&'a str>,
}
impl Filterable for FieldValues<'_> {
fn from(&self) -> &str {
self.from
}
fn to(&self) -> &str {
self.to
}
fn cc(&self) -> &str {
self.cc
}
fn subject(&self) -> &str {
self.subject
}
fn body(&self) -> &str {
self.body
}
fn has_attachment(&self) -> bool {
self.has_attachment
}
fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| *v)
}
fn envelope_from(&self) -> Option<&str> {
self.envelope_from
}
fn envelope_to(&self) -> Option<&str> {
self.envelope_to
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct MailEnvelope {
pub from: String,
pub to: String,
pub cc: String,
pub subject: String,
pub body: String,
pub has_attachment: bool,
pub headers: Vec<(String, String)>,
pub envelope_from: Option<String>,
pub envelope_to: Option<String>,
}
impl Filterable for MailEnvelope {
fn from(&self) -> &str {
&self.from
}
fn to(&self) -> &str {
&self.to
}
fn cc(&self) -> &str {
&self.cc
}
fn subject(&self) -> &str {
&self.subject
}
fn body(&self) -> &str {
&self.body
}
fn has_attachment(&self) -> bool {
self.has_attachment
}
fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
fn envelope_from(&self) -> Option<&str> {
self.envelope_from.as_deref()
}
fn envelope_to(&self) -> Option<&str> {
self.envelope_to.as_deref()
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
#[test]
fn vacation_validate_rejects_empty_message() {
let vacation = Vacation::new("");
assert!(matches!(
vacation.validate(),
Err(FilterError::InvalidVacation { reason }) if reason.contains("message")
));
assert!(Vacation::new("body").validate().is_ok());
}
#[test]
fn vacation_validate_rejects_zero_days() {
let vacation = Vacation::new("body").with_days(0);
assert!(matches!(
vacation.validate(),
Err(FilterError::InvalidVacation { reason }) if reason.contains("days")
));
assert!(vacation.with_days(1).validate().is_ok());
}
#[test]
fn vacation_builders_set_fields_and_defaults() {
let vacation = Vacation::new("away")
.with_days(3)
.with_subject("OOO")
.with_from("me@example.com");
assert_eq!(vacation.days, 3);
assert_eq!(vacation.subject.as_deref(), Some("OOO"));
assert_eq!(vacation.from.as_deref(), Some("me@example.com"));
assert_eq!(vacation.message, "away");
assert_eq!(Vacation::new("x").days, 7);
}
#[test]
fn rule_validate_checks_vacation_actions() {
let rule = FilterRule {
id: "r".into(),
name: "r".into(),
enabled: true,
priority: 0,
conditions: vec![],
condition_logic: LogicOp::And,
actions: vec![Action::Vacation(Vacation::new(""))],
};
assert!(matches!(
rule.validate(),
Err(FilterError::InvalidVacation { .. })
));
}
#[test]
fn notify_scheme_extraction() {
assert_eq!(
Notify::new("MAILTO:x@y", "").scheme().as_deref(),
Some("mailto")
);
assert_eq!(
Notify::new("xmpp:user@host", "").scheme().as_deref(),
Some("xmpp")
);
assert_eq!(Notify::new("no-scheme", "").scheme(), None);
assert_eq!(Notify::new(":empty-scheme", "").scheme(), None);
assert!(Notify::new("mailto:x@y", "").has_known_scheme());
assert!(Notify::new("https://hook.example", "").has_known_scheme());
assert!(!Notify::new("carrier-pigeon:perth", "").has_known_scheme());
}
#[test]
fn filterable_envelope_defaults_to_none() {
#[allow(dead_code)]
struct Bare;
impl Filterable for Bare {
fn from(&self) -> &str {
""
}
fn to(&self) -> &str {
""
}
fn cc(&self) -> &str {
""
}
fn subject(&self) -> &str {
""
}
fn body(&self) -> &str {
""
}
fn has_attachment(&self) -> bool {
false
}
}
let bare = Bare;
assert_eq!(bare.envelope_from(), None);
assert_eq!(bare.envelope_to(), None);
}
}