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}