1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
6#[serde(tag = "type", rename_all = "snake_case")]
7pub enum Conditions {
8 And { conditions: Vec<Conditions> },
9 Or { conditions: Vec<Conditions> },
10 Not { condition: Box<Conditions> },
11 Field(FieldCondition),
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(tag = "field", rename_all = "snake_case")]
17pub enum FieldCondition {
18 From { pattern: StringMatch },
19 To { pattern: StringMatch },
20 Subject { pattern: StringMatch },
21 HasLabel { label: String },
22 HasAttachment,
23 SizeGreaterThan { bytes: u64 },
24 SizeLessThan { bytes: u64 },
25 DateAfter { date: DateTime<Utc> },
26 DateBefore { date: DateTime<Utc> },
27 IsUnread,
28 IsStarred,
29 HasUnsubscribe,
30 BodyContains { pattern: StringMatch },
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(tag = "type", content = "value", rename_all = "snake_case")]
36pub enum StringMatch {
37 Exact(String),
38 Contains(String),
39 Regex(String),
40 Glob(String),
41}
42
43pub trait MessageView {
47 fn sender_email(&self) -> &str;
48 fn to_emails(&self) -> &[String];
49 fn subject(&self) -> &str;
50 fn labels(&self) -> &[String];
51 fn has_attachment(&self) -> bool;
52 fn size_bytes(&self) -> u64;
53 fn date(&self) -> DateTime<Utc>;
54 fn is_unread(&self) -> bool;
55 fn is_starred(&self) -> bool;
56 fn has_unsubscribe(&self) -> bool;
57 fn body_text(&self) -> Option<&str>;
58}
59
60impl StringMatch {
61 pub fn matches(&self, haystack: &str) -> bool {
63 match self {
64 StringMatch::Exact(s) => haystack == s,
65 StringMatch::Contains(s) => haystack.to_lowercase().contains(&s.to_lowercase()),
66 StringMatch::Regex(pattern) => regex::Regex::new(pattern)
67 .map(|re| re.is_match(haystack))
68 .unwrap_or(false),
69 StringMatch::Glob(pattern) => glob_match::glob_match(pattern, haystack),
70 }
71 }
72}
73
74impl Conditions {
75 pub fn evaluate(&self, msg: &dyn MessageView) -> bool {
77 match self {
78 Conditions::And { conditions } => conditions.iter().all(|c| c.evaluate(msg)),
79 Conditions::Or { conditions } => conditions.iter().any(|c| c.evaluate(msg)),
80 Conditions::Not { condition } => !condition.evaluate(msg),
81 Conditions::Field(field) => field.evaluate(msg),
82 }
83 }
84}
85
86impl FieldCondition {
87 pub fn evaluate(&self, msg: &dyn MessageView) -> bool {
88 match self {
89 FieldCondition::From { pattern } => pattern.matches(msg.sender_email()),
90 FieldCondition::To { pattern } => msg.to_emails().iter().any(|e| pattern.matches(e)),
91 FieldCondition::Subject { pattern } => pattern.matches(msg.subject()),
92 FieldCondition::HasLabel { label } => msg.labels().iter().any(|l| l == label),
93 FieldCondition::HasAttachment => msg.has_attachment(),
94 FieldCondition::SizeGreaterThan { bytes } => msg.size_bytes() > *bytes,
95 FieldCondition::SizeLessThan { bytes } => msg.size_bytes() < *bytes,
96 FieldCondition::DateAfter { date } => msg.date() > *date,
97 FieldCondition::DateBefore { date } => msg.date() < *date,
98 FieldCondition::IsUnread => msg.is_unread(),
99 FieldCondition::IsStarred => msg.is_starred(),
100 FieldCondition::HasUnsubscribe => msg.has_unsubscribe(),
101 FieldCondition::BodyContains { pattern } => {
102 msg.body_text().is_some_and(|body| pattern.matches(body))
103 }
104 }
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use crate::tests::*;
112
113 #[test]
116 fn exact_match_requires_full_equality() {
117 let m = StringMatch::Exact("alice@example.com".into());
118 assert!(m.matches("alice@example.com"));
119 assert!(!m.matches("ALICE@example.com"));
120 assert!(!m.matches("alice@example.com "));
121 }
122
123 #[test]
124 fn contains_match_is_case_insensitive() {
125 let m = StringMatch::Contains("invoice".into());
126 assert!(m.matches("Re: Invoice #2847"));
127 assert!(m.matches("INVOICE attached"));
128 assert!(m.matches("Your invoice is ready"));
129 assert!(!m.matches("Receipt attached"));
130 }
131
132 #[test]
133 fn contains_match_handles_unicode() {
134 let m = StringMatch::Contains("café".into());
135 assert!(m.matches("Meeting at Café Luna"));
136 }
137
138 #[test]
139 fn glob_match_with_wildcards() {
140 let m = StringMatch::Glob("*@substack.com".into());
141 assert!(m.matches("newsletter@substack.com"));
142 assert!(m.matches("alice@substack.com"));
143 assert!(!m.matches("newsletter@gmail.com"));
144 }
145
146 #[test]
147 fn glob_match_with_question_mark() {
148 let m = StringMatch::Glob("user?@example.com".into());
149 assert!(m.matches("user1@example.com"));
150 assert!(m.matches("userA@example.com"));
151 assert!(!m.matches("user12@example.com"));
152 }
153
154 #[test]
155 fn regex_match_complex_pattern() {
156 let m = StringMatch::Regex(r"(?i)invoice\s*#\d+".into());
157 assert!(m.matches("Re: Invoice #2847"));
158 assert!(m.matches("invoice#100"));
159 assert!(!m.matches("Receipt attached"));
160 }
161
162 #[test]
163 fn regex_invalid_pattern_returns_false() {
164 let m = StringMatch::Regex(r"[invalid".into());
165 assert!(!m.matches("anything"));
166 }
167
168 #[test]
171 fn and_condition_requires_all_true() {
172 let cond = Conditions::And {
173 conditions: vec![
174 Conditions::Field(FieldCondition::HasLabel {
175 label: "newsletters".into(),
176 }),
177 Conditions::Field(FieldCondition::HasUnsubscribe),
178 ],
179 };
180 assert!(cond.evaluate(&newsletter_msg()));
181 }
182
183 #[test]
184 fn and_condition_short_circuits_on_false() {
185 let cond = Conditions::And {
186 conditions: vec![
187 Conditions::Field(FieldCondition::IsStarred),
188 Conditions::Field(FieldCondition::HasUnsubscribe),
189 ],
190 };
191 assert!(!cond.evaluate(&newsletter_msg()));
192 }
193
194 #[test]
195 fn or_condition_succeeds_on_any_true() {
196 let cond = Conditions::Or {
197 conditions: vec![
198 Conditions::Field(FieldCondition::IsStarred),
199 Conditions::Field(FieldCondition::HasUnsubscribe),
200 ],
201 };
202 assert!(cond.evaluate(&newsletter_msg()));
203 }
204
205 #[test]
206 fn or_condition_fails_when_all_false() {
207 let cond = Conditions::Or {
208 conditions: vec![
209 Conditions::Field(FieldCondition::IsStarred),
210 Conditions::Field(FieldCondition::HasAttachment),
211 ],
212 };
213 assert!(!cond.evaluate(&newsletter_msg()));
214 }
215
216 #[test]
217 fn not_condition_inverts() {
218 let cond = Conditions::Not {
219 condition: Box::new(Conditions::Field(FieldCondition::IsStarred)),
220 };
221 assert!(cond.evaluate(&newsletter_msg())); }
223
224 #[test]
225 fn nested_conditions_evaluate_correctly() {
226 let cond = Conditions::Or {
228 conditions: vec![
229 Conditions::And {
230 conditions: vec![
231 Conditions::Field(FieldCondition::From {
232 pattern: StringMatch::Glob("*@substack.com".into()),
233 }),
234 Conditions::Field(FieldCondition::HasUnsubscribe),
235 ],
236 },
237 Conditions::Field(FieldCondition::IsStarred),
238 ],
239 };
240 assert!(cond.evaluate(&newsletter_msg()));
241 }
242
243 #[test]
244 fn empty_and_is_vacuously_true() {
245 let cond = Conditions::And { conditions: vec![] };
246 assert!(cond.evaluate(&newsletter_msg()));
247 }
248
249 #[test]
250 fn empty_or_is_false() {
251 let cond = Conditions::Or { conditions: vec![] };
252 assert!(!cond.evaluate(&newsletter_msg()));
253 }
254
255 #[test]
258 fn from_field_matches_email() {
259 let cond = FieldCondition::From {
260 pattern: StringMatch::Contains("substack".into()),
261 };
262 assert!(cond.evaluate(&newsletter_msg()));
263 }
264
265 #[test]
266 fn to_field_matches_any_recipient() {
267 let cond = FieldCondition::To {
268 pattern: StringMatch::Exact("user@example.com".into()),
269 };
270 assert!(cond.evaluate(&newsletter_msg()));
271 }
272
273 #[test]
274 fn subject_field_matches() {
275 let cond = FieldCondition::Subject {
276 pattern: StringMatch::Contains("Rust".into()),
277 };
278 assert!(cond.evaluate(&newsletter_msg()));
279 }
280
281 #[test]
282 fn has_label_checks_label_list() {
283 let cond = FieldCondition::HasLabel {
284 label: "newsletters".into(),
285 };
286 assert!(cond.evaluate(&newsletter_msg()));
287
288 let cond = FieldCondition::HasLabel {
289 label: "work".into(),
290 };
291 assert!(!cond.evaluate(&newsletter_msg()));
292 }
293
294 #[test]
295 fn size_conditions_work() {
296 let msg = newsletter_msg(); let cond = FieldCondition::SizeGreaterThan { bytes: 10000 };
299 assert!(cond.evaluate(&msg));
300
301 let cond = FieldCondition::SizeGreaterThan { bytes: 20000 };
302 assert!(!cond.evaluate(&msg));
303
304 let cond = FieldCondition::SizeLessThan { bytes: 20000 };
305 assert!(cond.evaluate(&msg));
306 }
307
308 #[test]
309 fn date_conditions_work() {
310 let msg = newsletter_msg(); let past = chrono::Utc::now() - chrono::Duration::hours(1);
312 let future = chrono::Utc::now() + chrono::Duration::hours(1);
313
314 let cond = FieldCondition::DateAfter { date: past };
315 assert!(cond.evaluate(&msg));
316
317 let cond = FieldCondition::DateBefore { date: future };
318 assert!(cond.evaluate(&msg));
319 }
320
321 #[test]
322 fn body_contains_searches_text() {
323 let cond = FieldCondition::BodyContains {
324 pattern: StringMatch::Contains("weekly Rust digest".into()),
325 };
326 assert!(cond.evaluate(&newsletter_msg()));
327 }
328
329 #[test]
330 fn body_contains_returns_false_when_no_body() {
331 let mut msg = newsletter_msg();
332 msg.body = None;
333 let cond = FieldCondition::BodyContains {
334 pattern: StringMatch::Contains("anything".into()),
335 };
336 assert!(!cond.evaluate(&msg));
337 }
338
339 #[test]
340 fn has_attachment_checks_flag() {
341 assert!(!FieldCondition::HasAttachment.evaluate(&newsletter_msg()));
342
343 let mut msg = newsletter_msg();
344 msg.has_attachment = true;
345 assert!(FieldCondition::HasAttachment.evaluate(&msg));
346 }
347
348 #[test]
351 fn conditions_roundtrip_through_json() {
352 let cond = Conditions::And {
353 conditions: vec![
354 Conditions::Field(FieldCondition::From {
355 pattern: StringMatch::Glob("*@substack.com".into()),
356 }),
357 Conditions::Not {
358 condition: Box::new(Conditions::Field(FieldCondition::IsStarred)),
359 },
360 ],
361 };
362 let json = serde_json::to_string(&cond).unwrap();
363 let parsed: Conditions = serde_json::from_str(&json).unwrap();
364 assert!(parsed.evaluate(&newsletter_msg()));
366 }
367}