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 {
246 conditions: vec![],
247 };
248 assert!(cond.evaluate(&newsletter_msg()));
249 }
250
251 #[test]
252 fn empty_or_is_false() {
253 let cond = Conditions::Or {
254 conditions: vec![],
255 };
256 assert!(!cond.evaluate(&newsletter_msg()));
257 }
258
259 #[test]
262 fn from_field_matches_email() {
263 let cond = FieldCondition::From {
264 pattern: StringMatch::Contains("substack".into()),
265 };
266 assert!(cond.evaluate(&newsletter_msg()));
267 }
268
269 #[test]
270 fn to_field_matches_any_recipient() {
271 let cond = FieldCondition::To {
272 pattern: StringMatch::Exact("user@example.com".into()),
273 };
274 assert!(cond.evaluate(&newsletter_msg()));
275 }
276
277 #[test]
278 fn subject_field_matches() {
279 let cond = FieldCondition::Subject {
280 pattern: StringMatch::Contains("Rust".into()),
281 };
282 assert!(cond.evaluate(&newsletter_msg()));
283 }
284
285 #[test]
286 fn has_label_checks_label_list() {
287 let cond = FieldCondition::HasLabel {
288 label: "newsletters".into(),
289 };
290 assert!(cond.evaluate(&newsletter_msg()));
291
292 let cond = FieldCondition::HasLabel {
293 label: "work".into(),
294 };
295 assert!(!cond.evaluate(&newsletter_msg()));
296 }
297
298 #[test]
299 fn size_conditions_work() {
300 let msg = newsletter_msg(); let cond = FieldCondition::SizeGreaterThan { bytes: 10000 };
303 assert!(cond.evaluate(&msg));
304
305 let cond = FieldCondition::SizeGreaterThan { bytes: 20000 };
306 assert!(!cond.evaluate(&msg));
307
308 let cond = FieldCondition::SizeLessThan { bytes: 20000 };
309 assert!(cond.evaluate(&msg));
310 }
311
312 #[test]
313 fn date_conditions_work() {
314 let msg = newsletter_msg(); let past = chrono::Utc::now() - chrono::Duration::hours(1);
316 let future = chrono::Utc::now() + chrono::Duration::hours(1);
317
318 let cond = FieldCondition::DateAfter { date: past };
319 assert!(cond.evaluate(&msg));
320
321 let cond = FieldCondition::DateBefore { date: future };
322 assert!(cond.evaluate(&msg));
323 }
324
325 #[test]
326 fn body_contains_searches_text() {
327 let cond = FieldCondition::BodyContains {
328 pattern: StringMatch::Contains("weekly Rust digest".into()),
329 };
330 assert!(cond.evaluate(&newsletter_msg()));
331 }
332
333 #[test]
334 fn body_contains_returns_false_when_no_body() {
335 let mut msg = newsletter_msg();
336 msg.body = None;
337 let cond = FieldCondition::BodyContains {
338 pattern: StringMatch::Contains("anything".into()),
339 };
340 assert!(!cond.evaluate(&msg));
341 }
342
343 #[test]
344 fn has_attachment_checks_flag() {
345 assert!(!FieldCondition::HasAttachment.evaluate(&newsletter_msg()));
346
347 let mut msg = newsletter_msg();
348 msg.has_attachment = true;
349 assert!(FieldCondition::HasAttachment.evaluate(&msg));
350 }
351
352 #[test]
355 fn conditions_roundtrip_through_json() {
356 let cond = Conditions::And {
357 conditions: vec![
358 Conditions::Field(FieldCondition::From {
359 pattern: StringMatch::Glob("*@substack.com".into()),
360 }),
361 Conditions::Not {
362 condition: Box::new(Conditions::Field(FieldCondition::IsStarred)),
363 },
364 ],
365 };
366 let json = serde_json::to_string(&cond).unwrap();
367 let parsed: Conditions = serde_json::from_str(&json).unwrap();
368 assert!(parsed.evaluate(&newsletter_msg()));
370 }
371}