Skip to main content

sieve_kit/
types.rs

1//! Filter rule types: conditions, operators, actions, and the composite rule
2//! structure. These types are domain-only (no storage/async dependencies) so
3//! they can be evaluated synchronously against any [`Filterable`] message.
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::FilterError;
8
9/// The fields a filter engine needs from a message.
10///
11/// Implement this trait for your message type (or use the provided
12/// [`MailEnvelope`] struct) to evaluate rules against it.
13pub trait Filterable {
14    /// First From address email (empty if absent).
15    fn from(&self) -> &str;
16
17    /// First To address email (empty if absent).
18    fn to(&self) -> &str;
19
20    /// First Cc address email (empty if absent).
21    fn cc(&self) -> &str;
22
23    /// Subject header (empty if absent).
24    fn subject(&self) -> &str;
25
26    /// Body text (empty if absent).
27    fn body(&self) -> &str;
28
29    /// Whether the message has attachments.
30    fn has_attachment(&self) -> bool;
31
32    /// Value of a raw header by name (case-insensitive lookup), if present.
33    fn header(&self, name: &str) -> Option<&str> {
34        let _ = name;
35        None
36    }
37}
38
39/// A complete filter rule: conditions + actions + metadata.
40#[derive(Clone, Debug, Serialize, Deserialize)]
41pub struct FilterRule {
42    /// Stable identifier.
43    pub id: String,
44    /// Human-readable name.
45    pub name: String,
46    /// Whether the rule is active.
47    pub enabled: bool,
48    /// Lower = evaluated first.
49    pub priority: i32,
50    /// Conditions to evaluate.
51    pub conditions: Vec<Condition>,
52    /// How conditions are combined.
53    pub condition_logic: LogicOp,
54    /// Actions to execute when the rule matches.
55    pub actions: Vec<Action>,
56}
57
58impl FilterRule {
59    /// Validate the rule: non-empty `id` and `name`, and every
60    /// [`Operator::Regex`] condition must compile.
61    ///
62    /// # Errors
63    ///
64    /// Returns [`FilterError`] on an empty id/name or an invalid regex
65    /// pattern.
66    pub fn validate(&self) -> Result<(), FilterError> {
67        if self.id.is_empty() {
68            return Err(FilterError::EmptyRuleId);
69        }
70        if self.name.is_empty() {
71            return Err(FilterError::EmptyRuleName);
72        }
73        for condition in &self.conditions {
74            if condition.operator == Operator::Regex {
75                regex::Regex::new(&condition.value).map_err(|source| FilterError::InvalidRegex {
76                    pattern: condition.value.clone(),
77                    source,
78                })?;
79            }
80        }
81        Ok(())
82    }
83}
84
85/// Boolean combinator for conditions.
86#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
87pub enum LogicOp {
88    /// All conditions must match.
89    And,
90    /// Any condition may match.
91    Or,
92}
93
94/// A single condition clause.
95#[derive(Clone, Debug, Serialize, Deserialize)]
96pub struct Condition {
97    /// Which message field to test.
98    pub field: ConditionField,
99    /// How to test it.
100    pub operator: Operator,
101    /// The comparison value (interpreted per operator).
102    pub value: String,
103    /// When `true`, invert the result.
104    pub negate: bool,
105}
106
107/// Message fields that conditions can target.
108#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
109pub enum ConditionField {
110    /// First From address email.
111    From,
112    /// First To address email.
113    To,
114    /// Any Cc address email.
115    Cc,
116    /// Subject header.
117    Subject,
118    /// Message body text.
119    Body,
120    /// A specific header by name.
121    Header(String),
122    /// Whether the message has attachments.
123    HasAttachment,
124}
125
126/// Comparison operators.
127#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
128pub enum Operator {
129    /// Substring match (case-insensitive).
130    Contains,
131    /// Exact match (case-insensitive).
132    Equals,
133    /// Glob-style match (`*` and `?`).
134    Matches,
135    /// Regular expression (bounded: 100 ms post-check, complexity limit).
136    Regex,
137    /// Field exists and is non-empty.
138    Exists,
139}
140
141/// An IMAP message flag.
142#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
143pub enum Flag {
144    /// `\Seen` — message has been read.
145    Seen,
146    /// `\Answered` — message has been replied to.
147    Answered,
148    /// `\Flagged` — message is flagged/starred.
149    Flagged,
150    /// `\Deleted` — message is marked for deletion.
151    Deleted,
152    /// `\Draft` — message is a draft.
153    Draft,
154    /// A non-system keyword flag.
155    Keyword(String),
156}
157
158/// An action to perform when a rule matches.
159#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
160pub enum Action {
161    /// Move the message to a folder.
162    MoveTo(String),
163    /// Copy the message to a folder (original stays).
164    CopyTo(String),
165    /// Apply flags.
166    Flag(Vec<Flag>),
167    /// Mark as read (adds `\Seen`).
168    MarkRead,
169    /// Delete the message (move to trash).
170    Delete,
171    /// Forward to an email address.
172    Forward(String),
173}
174
175/// Extracted field values from a message for condition evaluation.
176///
177/// A concrete [`Filterable`]: build one from your message, or evaluate
178/// directly against your own [`Filterable`] implementation.
179#[derive(Clone, Debug, Default)]
180pub struct FieldValues<'a> {
181    /// From address email (first).
182    pub from: &'a str,
183    /// To address email (first).
184    pub to: &'a str,
185    /// Cc address email (first, if any).
186    pub cc: &'a str,
187    /// Subject.
188    pub subject: &'a str,
189    /// Body text.
190    pub body: &'a str,
191    /// Whether the message has attachments.
192    pub has_attachment: bool,
193    /// Raw headers (name -> value) for `Header` conditions.
194    pub headers: Vec<(&'a str, &'a str)>,
195}
196
197impl Filterable for FieldValues<'_> {
198    fn from(&self) -> &str {
199        self.from
200    }
201
202    fn to(&self) -> &str {
203        self.to
204    }
205
206    fn cc(&self) -> &str {
207        self.cc
208    }
209
210    fn subject(&self) -> &str {
211        self.subject
212    }
213
214    fn body(&self) -> &str {
215        self.body
216    }
217
218    fn has_attachment(&self) -> bool {
219        self.has_attachment
220    }
221
222    fn header(&self, name: &str) -> Option<&str> {
223        self.headers
224            .iter()
225            .find(|(k, _)| k.eq_ignore_ascii_case(name))
226            .map(|(_, v)| *v)
227    }
228}
229
230/// A simple envelope of the fields a filter engine needs.
231///
232/// A ready-made [`Filterable`] implementation for callers that do not want to
233/// implement the trait for their own message type.
234#[derive(Clone, Debug, Default, Serialize, Deserialize)]
235pub struct MailEnvelope {
236    /// First From address email.
237    pub from: String,
238    /// First To address email.
239    pub to: String,
240    /// First Cc address email.
241    pub cc: String,
242    /// Subject header.
243    pub subject: String,
244    /// Body text.
245    pub body: String,
246    /// Whether the message has attachments.
247    pub has_attachment: bool,
248    /// Raw headers (name -> value) for `Header` conditions.
249    pub headers: Vec<(String, String)>,
250}
251
252impl Filterable for MailEnvelope {
253    fn from(&self) -> &str {
254        &self.from
255    }
256
257    fn to(&self) -> &str {
258        &self.to
259    }
260
261    fn cc(&self) -> &str {
262        &self.cc
263    }
264
265    fn subject(&self) -> &str {
266        &self.subject
267    }
268
269    fn body(&self) -> &str {
270        &self.body
271    }
272
273    fn has_attachment(&self) -> bool {
274        self.has_attachment
275    }
276
277    fn header(&self, name: &str) -> Option<&str> {
278        self.headers
279            .iter()
280            .find(|(k, _)| k.eq_ignore_ascii_case(name))
281            .map(|(_, v)| v.as_str())
282    }
283}