1use crate::eval::{RegexCache, evaluate_rule};
6use crate::types::{Action, FilterRule, Filterable, Flag};
7
8#[derive(Clone, Debug)]
10pub struct FilterMatch {
11 pub message_id: String,
13 pub actions: Vec<Action>,
15}
16
17impl FilterMatch {
18 #[must_use]
20 pub fn new(message_id: impl Into<String>, actions: Vec<Action>) -> Self {
21 Self {
22 message_id: message_id.into(),
23 actions,
24 }
25 }
26}
27
28#[derive(Clone, Debug, PartialEq, Eq)]
30pub enum PlannedAction {
31 Move {
33 to: String,
35 },
36 Copy {
38 to: String,
40 },
41 AddFlags {
43 flags: Vec<crate::types::Flag>,
45 },
46 RemoveFlags {
48 flags: Vec<crate::types::Flag>,
50 },
51 SetFlags {
53 flags: Vec<crate::types::Flag>,
55 },
56 MarkRead,
58 Delete,
60 Forward {
62 to: String,
64 },
65 Vacation(VacationReply),
69 Notify {
72 method: String,
74 message: String,
76 },
77}
78
79#[derive(Clone, Debug, PartialEq, Eq)]
83pub struct VacationReply {
84 pub to: String,
89 pub days: u32,
91 pub subject: String,
94 pub from: Option<String>,
96 pub message: String,
98}
99
100impl From<&Action> for PlannedAction {
101 fn from(action: &Action) -> Self {
102 match action {
103 Action::MoveTo(to) => Self::Move { to: to.clone() },
104 Action::CopyTo(to) => Self::Copy { to: to.clone() },
105 Action::Flag(flags) => Self::AddFlags {
106 flags: flags.clone(),
107 },
108 Action::Unflag(flags) => Self::RemoveFlags {
109 flags: flags.clone(),
110 },
111 Action::SetFlags(flags) => Self::SetFlags {
112 flags: flags.clone(),
113 },
114 Action::MarkRead => Self::MarkRead,
115 Action::Delete => Self::Delete,
116 Action::Forward(addr) => Self::Forward { to: addr.clone() },
117 Action::Vacation(vacation) => Self::Vacation(VacationReply {
118 to: String::new(),
121 days: vacation.days,
122 subject: vacation.subject.clone().unwrap_or_default(),
123 from: vacation.from.clone(),
124 message: vacation.message.clone(),
125 }),
126 Action::Notify(notify) => Self::Notify {
127 method: notify.method.clone(),
128 message: notify.message.clone(),
129 },
130 }
131 }
132}
133
134#[must_use]
140pub fn apply_flag_plan(current: &[Flag], plan: &[PlannedAction]) -> Vec<Flag> {
141 let mut flags: Vec<Flag> = current.to_vec();
142 for action in plan {
143 match action {
144 PlannedAction::AddFlags { flags: add } => {
145 for flag in add {
146 if !flags.contains(flag) {
147 flags.push(flag.clone());
148 }
149 }
150 }
151 PlannedAction::RemoveFlags { flags: remove } => {
152 flags.retain(|f| !remove.contains(f));
153 }
154 PlannedAction::SetFlags { flags: set } => {
155 flags = set.clone();
156 }
157 _ => {}
158 }
159 }
160 flags
161}
162
163#[derive(Default)]
179pub struct VacationTracker {
180 entries: std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>>,
181}
182
183impl VacationTracker {
184 #[must_use]
186 pub fn new() -> Self {
187 Self::default()
188 }
189
190 #[must_use]
193 pub fn seen_before(&self, sender: &str, days: u32) -> bool {
194 let Ok(entries) = self.entries.lock() else {
195 return false;
196 };
197 entries.get(sender).is_some_and(|recorded| {
198 recorded.elapsed() < std::time::Duration::from_secs(u64::from(days) * 86_400)
199 })
200 }
201
202 pub fn record(&self, sender: &str) {
204 self.record_at(sender, std::time::Instant::now());
205 }
206
207 pub fn record_at(&self, sender: &str, at: std::time::Instant) {
210 if let Ok(mut entries) = self.entries.lock() {
211 entries.insert(sender.to_string(), at);
212 }
213 }
214}
215
216#[must_use]
220pub fn build_action_plan(actions: &[Action]) -> Vec<PlannedAction> {
221 actions.iter().map(PlannedAction::from).collect()
222}
223
224#[must_use]
230pub fn collect_matches<F: Filterable + ?Sized>(
231 rules: &[FilterRule],
232 msg: &F,
233 regex_cache: &RegexCache,
234) -> Vec<Action> {
235 for rule in rules {
236 if evaluate_rule(rule, msg, regex_cache) {
237 return rule.actions.clone();
238 }
239 }
240 Vec::new()
241}
242
243#[cfg(test)]
244mod tests {
245 #![allow(clippy::unwrap_used, clippy::expect_used)]
246
247 use super::*;
248 use crate::types::{Condition, ConditionField, LogicOp, MailEnvelope, Operator};
249
250 fn test_rule(actions: Vec<Action>) -> FilterRule {
251 FilterRule {
252 id: "test".to_string(),
253 name: "Test".to_string(),
254 enabled: true,
255 priority: 0,
256 conditions: vec![Condition {
257 field: ConditionField::Subject,
258 operator: Operator::Contains,
259 value: "hello".to_string(),
260 negate: false,
261 }],
262 condition_logic: LogicOp::And,
263 actions,
264 }
265 }
266
267 fn make_envelope() -> MailEnvelope {
268 MailEnvelope {
269 subject: "Hello World".to_string(),
270 ..MailEnvelope::default()
271 }
272 }
273
274 #[test]
275 fn collect_matches_returns_first_rule_actions() {
276 let rules = vec![
277 test_rule(vec![Action::MarkRead]),
278 test_rule(vec![Action::Delete]),
279 ];
280 let msg = make_envelope();
281 let cache = RegexCache::default();
282 let matches = collect_matches(&rules, &msg, &cache);
283 assert_eq!(matches.len(), 1);
284 assert_eq!(matches[0], Action::MarkRead);
285 }
286
287 #[test]
288 fn collect_matches_returns_empty_when_no_match() {
289 let rules = vec![test_rule(vec![Action::MarkRead])];
290 let msg = MailEnvelope {
291 subject: "no match here".to_string(),
292 ..MailEnvelope::default()
293 };
294 let cache = RegexCache::default();
295 let matches = collect_matches(&rules, &msg, &cache);
296 assert!(matches.is_empty());
297 }
298
299 #[test]
300 fn build_action_plan_translates_all_variants() {
301 let actions = vec![
302 Action::MoveTo("Archive".to_string()),
303 Action::CopyTo("Keep".to_string()),
304 Action::Flag(vec![crate::types::Flag::Flagged]),
305 Action::MarkRead,
306 Action::Delete,
307 Action::Forward("a@b.com".to_string()),
308 ];
309 let plan = build_action_plan(&actions);
310 assert_eq!(plan.len(), 6);
311 assert_eq!(
312 plan[0],
313 PlannedAction::Move {
314 to: "Archive".to_string()
315 }
316 );
317 assert_eq!(
318 plan[1],
319 PlannedAction::Copy {
320 to: "Keep".to_string()
321 }
322 );
323 assert_eq!(
324 plan[2],
325 PlannedAction::AddFlags {
326 flags: vec![crate::types::Flag::Flagged]
327 }
328 );
329 assert_eq!(plan[3], PlannedAction::MarkRead);
330 assert_eq!(plan[4], PlannedAction::Delete);
331 assert_eq!(
332 plan[5],
333 PlannedAction::Forward {
334 to: "a@b.com".to_string()
335 }
336 );
337 }
338
339 #[test]
340 fn filter_match_new_accepts_any_id() {
341 let m = FilterMatch::new("msg-42", vec![Action::MarkRead]);
342 assert_eq!(m.message_id, "msg-42");
343 assert_eq!(m.actions, vec![Action::MarkRead]);
344 }
345
346 #[test]
347 fn build_action_plan_translates_flag_mutation_variants() {
348 let actions = vec![
349 Action::Flag(vec![Flag::Flagged]),
350 Action::Unflag(vec![Flag::Seen]),
351 Action::SetFlags(vec![Flag::Answered]),
352 ];
353 let plan = build_action_plan(&actions);
354 assert_eq!(
355 plan,
356 vec![
357 PlannedAction::AddFlags {
358 flags: vec![Flag::Flagged]
359 },
360 PlannedAction::RemoveFlags {
361 flags: vec![Flag::Seen]
362 },
363 PlannedAction::SetFlags {
364 flags: vec![Flag::Answered]
365 },
366 ]
367 );
368 }
369
370 #[test]
371 fn build_action_plan_translates_vacation_and_notify() {
372 let plan = build_action_plan(&[
373 Action::Vacation(
374 crate::types::Vacation::new("away")
375 .with_days(2)
376 .with_from("me@example.com"),
377 ),
378 Action::Notify(crate::types::Notify::new("mailto:x@y", "ping")),
379 ]);
380 assert_eq!(
381 plan,
382 vec![
383 PlannedAction::Vacation(VacationReply {
384 to: String::new(),
387 days: 2,
388 subject: String::new(),
389 from: Some("me@example.com".to_string()),
390 message: "away".to_string(),
391 }),
392 PlannedAction::Notify {
393 method: "mailto:x@y".to_string(),
394 message: "ping".to_string(),
395 },
396 ]
397 );
398 }
399
400 #[test]
401 fn apply_flag_plan_adds_without_duplicates() {
402 let current = vec![Flag::Seen];
403 let plan = build_action_plan(&[Action::Flag(vec![Flag::Seen, Flag::Flagged])]);
404 let flags = apply_flag_plan(¤t, &plan);
405 assert_eq!(flags, vec![Flag::Seen, Flag::Flagged]);
406 }
407
408 #[test]
409 fn apply_flag_plan_removes_listed_flags_only() {
410 let current = vec![Flag::Seen, Flag::Flagged, Flag::Keyword("work".into())];
411 let plan = build_action_plan(&[Action::Unflag(vec![
412 Flag::Seen,
413 Flag::Keyword("nope".into()),
414 ])]);
415 let flags = apply_flag_plan(¤t, &plan);
416 assert_eq!(flags, vec![Flag::Flagged, Flag::Keyword("work".into())]);
417 }
418
419 #[test]
420 fn apply_flag_plan_set_replaces_whole_set() {
421 let current = vec![Flag::Seen, Flag::Flagged];
422 let plan = build_action_plan(&[Action::SetFlags(vec![Flag::Draft])]);
423 let flags = apply_flag_plan(¤t, &plan);
424 assert_eq!(flags, vec![Flag::Draft]);
425 }
426
427 #[test]
428 fn apply_flag_plan_folds_in_order() {
429 let current = vec![];
430 let plan = build_action_plan(&[
431 Action::Flag(vec![Flag::Flagged]),
432 Action::Flag(vec![Flag::Seen]),
433 Action::Unflag(vec![Flag::Flagged]),
434 Action::SetFlags(vec![Flag::Answered, Flag::Draft]),
435 ]);
436 let flags = apply_flag_plan(¤t, &plan);
437 assert_eq!(flags, vec![Flag::Answered, Flag::Draft]);
438 }
439
440 #[test]
441 fn apply_flag_plan_ignores_non_flag_actions() {
442 let current = vec![Flag::Seen];
443 let plan = build_action_plan(&[Action::MoveTo("Archive".into()), Action::MarkRead]);
444 let flags = apply_flag_plan(¤t, &plan);
445 assert_eq!(flags, vec![Flag::Seen]);
446 }
447
448 #[test]
449 fn vacation_tracker_responds_once_within_period() {
450 let tracker = VacationTracker::new();
451 assert!(!tracker.seen_before("a@b.c", 7));
452 tracker.record("a@b.c");
453 assert!(tracker.seen_before("a@b.c", 7));
454 assert!(!tracker.seen_before("other@b.c", 7));
455 let now = std::time::Instant::now();
457 tracker.record_at("old@b.c", now - std::time::Duration::from_secs(7 * 86_400));
458 assert!(!tracker.seen_before("old@b.c", 7));
459 tracker.record_at(
460 "recent@b.c",
461 now - std::time::Duration::from_secs(6 * 86_400),
462 );
463 assert!(tracker.seen_before("recent@b.c", 7));
464 }
465}