1use std::sync::Arc;
7
8use regex::Regex;
9
10use crate::types::{
11 AddressPart, Condition, ConditionField, EnvelopePart, FilterRule, Filterable, LogicOp, Operator,
12};
13
14const REGEX_TIMEOUT_MS: u128 = 100;
16
17#[derive(Clone, Default)]
20pub struct RegexCache {
21 inner: Arc<std::sync::RwLock<std::collections::HashMap<String, Option<Regex>>>>,
22}
23
24impl RegexCache {
25 #[must_use]
27 pub fn get_or_compile(&self, pattern: &str) -> Option<Regex> {
28 if let Ok(cache) = self.inner.read() {
30 if let Some(entry) = cache.get(pattern) {
31 return entry.clone();
32 }
33 }
34 let compiled = Regex::new(pattern).ok();
36 if let Ok(mut cache) = self.inner.write() {
37 cache.insert(pattern.to_string(), compiled.clone());
38 }
39 compiled
40 }
41}
42
43#[must_use]
50pub fn field_value<'a, F: Filterable + ?Sized>(msg: &'a F, field: &ConditionField) -> &'a str {
51 match field {
52 ConditionField::From => msg.from(),
53 ConditionField::To => msg.to(),
54 ConditionField::Cc => msg.cc(),
55 ConditionField::Subject => msg.subject(),
56 ConditionField::Body => msg.body(),
57 ConditionField::Header(name) => msg.header(name).unwrap_or_default(),
58 ConditionField::HasAttachment => {
59 if msg.has_attachment() {
60 "true"
61 } else {
62 "false"
63 }
64 }
65 ConditionField::Envelope { part, .. } => match part {
66 EnvelopePart::From => msg.envelope_from().unwrap_or_default(),
67 EnvelopePart::To => msg.envelope_to().unwrap_or_default(),
68 },
69 }
70}
71
72#[must_use]
81pub fn evaluate_rule<F: Filterable + ?Sized>(
82 rule: &FilterRule,
83 msg: &F,
84 regex_cache: &RegexCache,
85) -> bool {
86 if !rule.enabled {
87 return false;
88 }
89 if rule.conditions.is_empty() {
90 return false;
91 }
92
93 let results: Vec<bool> = rule
94 .conditions
95 .iter()
96 .map(|c| evaluate_condition(c, msg, regex_cache))
97 .collect();
98
99 match rule.condition_logic {
100 LogicOp::And => results.iter().all(|&r| r),
101 LogicOp::Or => results.iter().any(|&r| r),
102 }
103}
104
105#[must_use]
115pub fn evaluate_condition<F: Filterable + ?Sized>(
116 condition: &Condition,
117 msg: &F,
118 regex_cache: &RegexCache,
119) -> bool {
120 let result = if let ConditionField::Envelope { part, address_part } = &condition.field {
121 let raw = match part {
122 EnvelopePart::From => msg.envelope_from(),
123 EnvelopePart::To => msg.envelope_to(),
124 };
125 match raw {
128 Some(value) => apply_operator(
129 &condition.operator,
130 extract_address_part(value, *address_part),
131 condition,
132 regex_cache,
133 ),
134 None => false,
135 }
136 } else {
137 let field_value = field_value(msg, &condition.field);
138 apply_operator(&condition.operator, field_value, condition, regex_cache)
139 };
140 if condition.negate { !result } else { result }
141}
142
143#[must_use]
148pub fn extract_address_part(address: &str, part: AddressPart) -> &str {
149 match part {
150 AddressPart::All => address,
151 AddressPart::Localpart => address.rsplit_once('@').map_or(address, |(lp, _)| lp),
152 AddressPart::Domain => address.rsplit_once('@').map_or("", |(_, d)| d),
153 }
154}
155
156fn apply_operator(
160 operator: &Operator,
161 value: &str,
162 condition: &Condition,
163 regex_cache: &RegexCache,
164) -> bool {
165 match operator {
166 Operator::Contains => value
167 .to_lowercase()
168 .contains(&condition.value.to_lowercase()),
169 Operator::Equals => value.eq_ignore_ascii_case(&condition.value),
170 Operator::Matches => glob_match(&condition.value, value),
171 Operator::Regex => evaluate_regex(&condition.value, value, regex_cache),
172 Operator::Exists => !value.is_empty(),
173 Operator::NumericEquals => ascii_numeric_eq(&condition.value, value),
174 }
175}
176
177#[must_use]
182pub fn ascii_numeric_eq(a: &str, b: &str) -> bool {
183 let valid = |s: &str| s.bytes().all(|c| c.is_ascii_digit());
184 if !valid(a) || !valid(b) {
185 return false;
186 }
187 if a.is_empty() || b.is_empty() {
188 return a.is_empty() && b.is_empty();
189 }
190 strip_leading_zeros(a) == strip_leading_zeros(b)
191}
192
193fn strip_leading_zeros(s: &str) -> &str {
194 let trimmed = s.trim_start_matches('0');
195 if trimmed.is_empty() { "0" } else { trimmed }
196}
197
198fn evaluate_regex(pattern: &str, input: &str, cache: &RegexCache) -> bool {
203 let Some(re) = cache.get_or_compile(pattern) else {
204 return false;
205 };
206
207 let start = std::time::Instant::now();
212 let matched = re.is_match(input);
213 if start.elapsed().as_millis() > REGEX_TIMEOUT_MS {
214 return false;
215 }
216 matched
217}
218
219#[must_use]
224pub fn glob_match(pattern: &str, input: &str) -> bool {
225 let pattern_lower = pattern.to_lowercase();
226 let input_lower = input.to_lowercase();
227 glob_match_inner(pattern_lower.as_bytes(), input_lower.as_bytes())
228}
229
230#[allow(clippy::similar_names)]
231fn glob_match_inner(pattern: &[u8], input: &[u8]) -> bool {
232 let mut pi = 0;
233 let mut ii = 0;
234 let mut star_pi = usize::MAX;
235 let mut star_ii = 0;
236
237 while ii < input.len() {
238 if pi < pattern.len() && pattern[pi] == b'*' {
239 star_pi = pi;
240 star_ii = ii;
241 pi += 1;
242 } else if pi < pattern.len() && (pattern[pi] == b'?' || pattern[pi] == input[ii]) {
243 pi += 1;
244 ii += 1;
245 } else if star_pi != usize::MAX {
246 pi = star_pi + 1;
247 star_ii += 1;
248 ii = star_ii;
249 } else {
250 return false;
251 }
252 }
253
254 while pi < pattern.len() && pattern[pi] == b'*' {
255 pi += 1;
256 }
257
258 pi == pattern.len()
259}
260
261pub fn sort_rules_by_priority(rules: &mut [FilterRule]) {
263 rules.sort_by_key(|r| r.priority);
264}
265
266#[derive(Default)]
273pub struct EvalContext<'a> {
274 pub seen_before: Option<&'a dyn Fn(&str) -> bool>,
283}
284
285impl<'a> EvalContext<'a> {
286 #[must_use]
288 pub fn with_seen_before(mut self, seen_before: &'a dyn Fn(&str) -> bool) -> Self {
289 self.seen_before = Some(seen_before);
290 self
291 }
292}
293
294#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
298pub enum EvalWarning {
299 #[error("notify method {method:?} has an unrecognized URI scheme")]
305 UnknownNotifyMethod {
306 method: String,
308 },
309 #[error("vacation action could not determine a reply-to sender")]
313 VacationNoSender,
314}
315
316#[derive(Clone, Debug, Default)]
319pub struct EvalOutcome {
320 pub rule_id: Option<String>,
322 pub plan: Vec<crate::actions::PlannedAction>,
324 pub warnings: Vec<EvalWarning>,
326}
327
328impl EvalOutcome {
329 #[must_use]
331 pub fn is_empty(&self) -> bool {
332 self.plan.is_empty() && self.warnings.is_empty()
333 }
334}
335
336#[must_use]
353pub fn evaluate_plan<F: Filterable + ?Sized>(
354 rules: &[FilterRule],
355 msg: &F,
356 ctx: &EvalContext<'_>,
357 regex_cache: &RegexCache,
358) -> EvalOutcome {
359 let mut outcome = EvalOutcome::default();
360 let Some(rule) = rules.iter().find(|r| evaluate_rule(r, msg, regex_cache)) else {
361 return outcome;
362 };
363 outcome.rule_id = Some(rule.id.clone());
364 for action in &rule.actions {
365 match action {
366 crate::types::Action::Vacation(vacation) => {
367 plan_vacation(vacation, msg, ctx, &mut outcome);
368 }
369 crate::types::Action::Notify(notify) => {
370 if !notify.has_known_scheme() {
371 outcome.warnings.push(EvalWarning::UnknownNotifyMethod {
372 method: notify.method.clone(),
373 });
374 }
375 outcome
376 .plan
377 .push(crate::actions::PlannedAction::from(action));
378 }
379 other => outcome
380 .plan
381 .push(crate::actions::PlannedAction::from(other)),
382 }
383 }
384 outcome
385}
386
387fn plan_vacation<F: Filterable + ?Sized>(
389 vacation: &crate::types::Vacation,
390 msg: &F,
391 ctx: &EvalContext<'_>,
392 outcome: &mut EvalOutcome,
393) {
394 let sender = msg
395 .envelope_from()
396 .filter(|s| !s.is_empty())
397 .map_or_else(|| msg.from().to_string(), str::to_string);
398 if sender.is_empty() {
399 outcome.warnings.push(EvalWarning::VacationNoSender);
400 return;
401 }
402 if ctx.seen_before.is_some_and(|seen| seen(&sender)) {
403 return;
405 }
406 let subject = vacation.subject.clone().unwrap_or_else(|| {
407 let original = msg.subject();
408 if original.is_empty() {
409 "Automated reply".to_string()
410 } else {
411 format!("Re: {original}")
412 }
413 });
414 outcome.plan.push(crate::actions::PlannedAction::Vacation(
415 crate::actions::VacationReply {
416 to: sender,
417 days: vacation.days,
418 subject,
419 from: vacation.from.clone(),
420 message: vacation.message.clone(),
421 },
422 ));
423}
424
425#[cfg(test)]
426mod tests {
427 #![allow(clippy::unwrap_used, clippy::expect_used)]
428
429 use super::*;
430 use crate::types::MailEnvelope;
431
432 fn make_rule(conditions: Vec<Condition>, logic: LogicOp) -> FilterRule {
433 FilterRule {
434 id: "test-rule-1".to_string(),
435 name: "Test Rule".to_string(),
436 enabled: true,
437 priority: 0,
438 conditions,
439 condition_logic: logic,
440 actions: vec![],
441 }
442 }
443
444 fn make_envelope() -> MailEnvelope {
445 MailEnvelope {
446 from: "alice@example.com".to_string(),
447 to: "bob@example.com".to_string(),
448 cc: String::new(),
449 subject: "Hello World".to_string(),
450 body: String::new(),
451 has_attachment: false,
452 envelope_from: None,
453 envelope_to: None,
454 headers: vec![],
455 }
456 }
457
458 #[test]
459 fn contains_operator_matches_case_insensitive() {
460 let rule = make_rule(
461 vec![Condition {
462 field: ConditionField::Subject,
463 operator: Operator::Contains,
464 value: "hello".to_string(),
465 negate: false,
466 }],
467 LogicOp::And,
468 );
469 let msg = make_envelope();
470 let cache = RegexCache::default();
471 assert!(evaluate_rule(&rule, &msg, &cache));
472 }
473
474 #[test]
475 fn contains_operator_no_match() {
476 let rule = make_rule(
477 vec![Condition {
478 field: ConditionField::Subject,
479 operator: Operator::Contains,
480 value: "nomatch".to_string(),
481 negate: false,
482 }],
483 LogicOp::And,
484 );
485 let msg = make_envelope();
486 let cache = RegexCache::default();
487 assert!(!evaluate_rule(&rule, &msg, &cache));
488 }
489
490 #[test]
491 fn negate_inverts_result() {
492 let rule = make_rule(
493 vec![Condition {
494 field: ConditionField::Subject,
495 operator: Operator::Contains,
496 value: "nomatch".to_string(),
497 negate: true,
498 }],
499 LogicOp::And,
500 );
501 let msg = make_envelope();
502 let cache = RegexCache::default();
503 assert!(evaluate_rule(&rule, &msg, &cache));
504 }
505
506 #[test]
507 fn and_logic_requires_all() {
508 let rule = make_rule(
509 vec![
510 Condition {
511 field: ConditionField::Subject,
512 operator: Operator::Contains,
513 value: "hello".to_string(),
514 negate: false,
515 },
516 Condition {
517 field: ConditionField::From,
518 operator: Operator::Contains,
519 value: "nomatch".to_string(),
520 negate: false,
521 },
522 ],
523 LogicOp::And,
524 );
525 let msg = make_envelope();
526 let cache = RegexCache::default();
527 assert!(!evaluate_rule(&rule, &msg, &cache));
528 }
529
530 #[test]
531 fn or_logic_requires_any() {
532 let rule = make_rule(
533 vec![
534 Condition {
535 field: ConditionField::Subject,
536 operator: Operator::Contains,
537 value: "nomatch".to_string(),
538 negate: false,
539 },
540 Condition {
541 field: ConditionField::From,
542 operator: Operator::Contains,
543 value: "alice".to_string(),
544 negate: false,
545 },
546 ],
547 LogicOp::Or,
548 );
549 let msg = make_envelope();
550 let cache = RegexCache::default();
551 assert!(evaluate_rule(&rule, &msg, &cache));
552 }
553
554 #[test]
555 fn regex_condition_matches() {
556 let rule = make_rule(
557 vec![Condition {
558 field: ConditionField::Subject,
559 operator: Operator::Regex,
560 value: r"(?i)hello\s+world".to_string(),
561 negate: false,
562 }],
563 LogicOp::And,
564 );
565 let msg = make_envelope();
566 let cache = RegexCache::default();
567 assert!(evaluate_rule(&rule, &msg, &cache));
568 }
569
570 #[test]
571 fn regex_invalid_pattern_returns_false() {
572 let rule = make_rule(
573 vec![Condition {
574 field: ConditionField::Subject,
575 operator: Operator::Regex,
576 value: "[invalid".to_string(),
577 negate: false,
578 }],
579 LogicOp::And,
580 );
581 let msg = make_envelope();
582 let cache = RegexCache::default();
583 assert!(!evaluate_rule(&rule, &msg, &cache));
584 }
585
586 #[test]
587 fn glob_match_basic() {
588 assert!(glob_match("hello*", "hello world"));
589 assert!(glob_match("*world", "hello world"));
590 assert!(glob_match("hello*world", "hello beautiful world"));
591 assert!(glob_match("h?llo", "hello"));
592 assert!(!glob_match("h?llo", "hllo"));
593 assert!(glob_match("*", "anything"));
594 }
595
596 #[test]
597 fn disabled_rule_never_matches() {
598 let mut rule = make_rule(
599 vec![Condition {
600 field: ConditionField::Subject,
601 operator: Operator::Exists,
602 value: String::new(),
603 negate: false,
604 }],
605 LogicOp::And,
606 );
607 rule.enabled = false;
608 let msg = make_envelope();
609 let cache = RegexCache::default();
610 assert!(!evaluate_rule(&rule, &msg, &cache));
611 }
612
613 #[test]
614 fn empty_conditions_never_match() {
615 let rule = make_rule(vec![], LogicOp::And);
616 let msg = make_envelope();
617 let cache = RegexCache::default();
618 assert!(!evaluate_rule(&rule, &msg, &cache));
619 }
620
621 #[test]
622 fn exists_operator() {
623 let rule = make_rule(
624 vec![Condition {
625 field: ConditionField::Subject,
626 operator: Operator::Exists,
627 value: String::new(),
628 negate: false,
629 }],
630 LogicOp::And,
631 );
632 let msg = make_envelope();
633 let cache = RegexCache::default();
634 assert!(evaluate_rule(&rule, &msg, &cache));
635 }
636
637 #[test]
638 fn body_condition() {
639 let rule = make_rule(
640 vec![Condition {
641 field: ConditionField::Body,
642 operator: Operator::Contains,
643 value: "test".to_string(),
644 negate: false,
645 }],
646 LogicOp::And,
647 );
648 let msg = MailEnvelope {
649 body: "this is a test body".to_string(),
650 ..make_envelope()
651 };
652 let cache = RegexCache::default();
653 assert!(evaluate_rule(&rule, &msg, &cache));
654 }
655
656 #[test]
657 fn has_attachment_condition() {
658 let rule = make_rule(
659 vec![Condition {
660 field: ConditionField::HasAttachment,
661 operator: Operator::Equals,
662 value: "true".to_string(),
663 negate: false,
664 }],
665 LogicOp::And,
666 );
667 let msg = MailEnvelope {
668 has_attachment: true,
669 ..make_envelope()
670 };
671 let cache = RegexCache::default();
672 assert!(evaluate_rule(&rule, &msg, &cache));
673 }
674
675 #[test]
676 fn header_condition() {
677 let rule = make_rule(
678 vec![Condition {
679 field: ConditionField::Header("X-Priority".to_string()),
680 operator: Operator::Equals,
681 value: "high".to_string(),
682 negate: false,
683 }],
684 LogicOp::And,
685 );
686 let msg = MailEnvelope {
687 headers: vec![("x-priority".to_string(), "high".to_string())],
688 ..make_envelope()
689 };
690 let cache = RegexCache::default();
691 assert!(evaluate_rule(&rule, &msg, &cache));
692 }
693
694 #[test]
695 fn regex_cache_reuses_compiled_pattern() {
696 let cache = RegexCache::default();
697 let r1 = cache.get_or_compile(r"\d+");
698 let r2 = cache.get_or_compile(r"\d+");
699 assert!(r1.is_some());
701 assert!(r2.is_some());
702 assert!(r1.unwrap().is_match("123"));
704 assert!(r2.unwrap().is_match("123"));
705 }
706
707 #[test]
708 fn rule_validation_rejects_invalid_regex() {
709 let mut rule = make_rule(
710 vec![Condition {
711 field: ConditionField::Subject,
712 operator: Operator::Regex,
713 value: "[invalid".to_string(),
714 negate: false,
715 }],
716 LogicOp::And,
717 );
718 assert!(matches!(
719 rule.validate(),
720 Err(crate::error::FilterError::InvalidRegex { .. })
721 ));
722 rule.conditions[0].operator = Operator::Contains;
723 rule.conditions[0].value = "ok".to_string();
724 assert!(rule.validate().is_ok());
725 rule.id = String::new();
726 assert!(matches!(
727 rule.validate(),
728 Err(crate::error::FilterError::EmptyRuleId)
729 ));
730 rule.id = "x".to_string();
731 rule.name = String::new();
732 assert!(matches!(
733 rule.validate(),
734 Err(crate::error::FilterError::EmptyRuleName)
735 ));
736 }
737
738 fn envelope_condition(part: EnvelopePart, part_kind: AddressPart, value: &str) -> Condition {
741 Condition {
742 field: ConditionField::Envelope {
743 part,
744 address_part: part_kind,
745 },
746 operator: Operator::Equals,
747 value: value.to_string(),
748 negate: false,
749 }
750 }
751
752 fn envelope_msg() -> MailEnvelope {
753 MailEnvelope {
754 from: "newsletter@lists.example.com".to_string(),
755 envelope_from: Some("bounce@sender.example.net".to_string()),
756 envelope_to: Some("you@corp.example".to_string()),
757 ..MailEnvelope::default()
758 }
759 }
760
761 #[test]
762 fn envelope_all_matches_full_address() {
763 let msg = envelope_msg();
764 let cache = RegexCache::default();
765 for (part, value) in [
766 (EnvelopePart::From, "bounce@sender.example.net"),
767 (EnvelopePart::To, "you@corp.example"),
768 ] {
769 let cond = envelope_condition(part, AddressPart::All, value);
770 assert!(evaluate_condition(&cond, &msg, &cache));
771 }
772 }
773
774 #[test]
775 fn envelope_localpart_and_domain() {
776 let msg = envelope_msg();
777 let cache = RegexCache::default();
778 let cond = envelope_condition(EnvelopePart::From, AddressPart::Localpart, "bounce");
779 assert!(evaluate_condition(&cond, &msg, &cache));
780 let cond = envelope_condition(
781 EnvelopePart::From,
782 AddressPart::Domain,
783 "sender.example.net",
784 );
785 assert!(evaluate_condition(&cond, &msg, &cache));
786 let cond = envelope_condition(EnvelopePart::From, AddressPart::Domain, "elsewhere");
787 assert!(!evaluate_condition(&cond, &msg, &cache));
788 }
789
790 #[test]
791 fn envelope_matching_is_case_insensitive() {
792 let msg = envelope_msg();
793 let cache = RegexCache::default();
794 let cond = envelope_condition(EnvelopePart::To, AddressPart::All, "YOU@CORP.EXAMPLE");
795 assert!(evaluate_condition(&cond, &msg, &cache));
796 }
797
798 #[test]
799 fn envelope_without_at_domain_is_empty() {
800 let msg = MailEnvelope {
801 envelope_from: Some("bare-address".to_string()),
802 ..MailEnvelope::default()
803 };
804 let cache = RegexCache::default();
805 let localpart =
806 envelope_condition(EnvelopePart::From, AddressPart::Localpart, "bare-address");
807 assert!(evaluate_condition(&localpart, &msg, &cache));
808 let domain = envelope_condition(EnvelopePart::From, AddressPart::Domain, "");
809 assert!(evaluate_condition(&domain, &msg, &cache));
810 let domain = envelope_condition(EnvelopePart::From, AddressPart::Domain, "example.com");
811 assert!(!evaluate_condition(&domain, &msg, &cache));
812 }
813
814 #[test]
815 fn envelope_missing_data_never_matches() {
816 let msg = MailEnvelope::default();
817 let cache = RegexCache::default();
818 let cond = envelope_condition(EnvelopePart::From, AddressPart::All, "anything");
819 assert!(!evaluate_condition(&cond, &msg, &cache));
820 let cond = Condition {
822 negate: true,
823 ..cond
824 };
825 assert!(evaluate_condition(&cond, &msg, &cache));
826 }
827
828 #[test]
829 fn envelope_supports_all_operators() {
830 let msg = envelope_msg();
831 let cache = RegexCache::default();
832 let base_field = ConditionField::Envelope {
833 part: EnvelopePart::To,
834 address_part: AddressPart::Domain,
835 };
836 let contains = Condition {
837 field: base_field.clone(),
838 operator: Operator::Contains,
839 value: "corp".to_string(),
840 negate: false,
841 };
842 assert!(evaluate_condition(&contains, &msg, &cache));
843 let glob = Condition {
844 field: base_field.clone(),
845 operator: Operator::Matches,
846 value: "*.example".to_string(),
847 negate: false,
848 };
849 assert!(evaluate_condition(&glob, &msg, &cache));
850 let exists = Condition {
851 field: base_field.clone(),
852 operator: Operator::Exists,
853 value: String::new(),
854 negate: false,
855 };
856 assert!(evaluate_condition(&exists, &msg, &cache));
857 let numeric = Condition {
858 field: base_field,
859 operator: Operator::NumericEquals,
860 value: "7".to_string(),
861 negate: false,
862 };
863 assert!(!evaluate_condition(&numeric, &msg, &cache));
864 }
865
866 #[test]
867 fn field_value_envelope_resolves_full_address() {
868 let msg = envelope_msg();
869 assert_eq!(
870 field_value(
871 &msg,
872 &ConditionField::Envelope {
873 part: EnvelopePart::From,
874 address_part: AddressPart::All,
875 }
876 ),
877 "bounce@sender.example.net"
878 );
879 }
880
881 #[test]
884 fn ascii_numeric_equality() {
885 assert!(ascii_numeric_eq("42", "42"));
886 assert!(ascii_numeric_eq("007", "7"));
887 assert!(ascii_numeric_eq("", ""));
888 assert!(!ascii_numeric_eq("42", "0421"));
889 assert!(!ascii_numeric_eq("", "0"));
890 assert!(!ascii_numeric_eq("12a", "12"));
891 assert!(!ascii_numeric_eq("12", "1 2"));
892 assert!(!ascii_numeric_eq("-1", "1"));
893 }
894
895 #[test]
896 fn numeric_equals_operator_on_header() {
897 let rule = make_rule(
898 vec![Condition {
899 field: ConditionField::Header("X-Spam-Score".to_string()),
900 operator: Operator::NumericEquals,
901 value: "10".to_string(),
902 negate: false,
903 }],
904 LogicOp::And,
905 );
906 let msg = MailEnvelope {
907 headers: vec![("X-Spam-Score".to_string(), "010".to_string())],
908 ..MailEnvelope::default()
909 };
910 assert!(evaluate_rule(&rule, &msg, &RegexCache::default()));
911 }
912
913 use crate::actions::{VacationReply, VacationTracker};
916 use crate::types::{Action, Notify, Vacation};
917
918 fn matching_rule(actions: Vec<Action>) -> FilterRule {
920 FilterRule {
921 actions,
922 ..make_rule(
923 vec![Condition {
924 field: ConditionField::Subject,
925 operator: Operator::Contains,
926 value: "hello".to_string(),
927 negate: false,
928 }],
929 LogicOp::And,
930 )
931 }
932 }
933
934 fn eval_envelope() -> MailEnvelope {
935 MailEnvelope {
936 from: "alice@example.com".to_string(),
937 envelope_from: Some("alice@bounce.example.com".to_string()),
938 to: "you@example.com".to_string(),
939 subject: "Hello".to_string(),
940 ..MailEnvelope::default()
941 }
942 }
943
944 #[test]
945 fn evaluate_plan_resolves_vacation_reply() {
946 let rule = matching_rule(vec![Action::Vacation(
947 Vacation::new("I am away until Monday.")
948 .with_subject("Away")
949 .with_days(3),
950 )]);
951 let msg = eval_envelope();
952 let outcome = evaluate_plan(
953 &[rule],
954 &msg,
955 &EvalContext::default(),
956 &RegexCache::default(),
957 );
958 assert_eq!(outcome.plan.len(), 1);
959 assert!(outcome.warnings.is_empty());
960 let crate::actions::PlannedAction::Vacation(reply) = &outcome.plan[0] else {
961 panic!("expected vacation outcome");
962 };
963 assert_eq!(
964 reply,
965 &VacationReply {
966 to: "alice@bounce.example.com".to_string(),
967 days: 3,
968 subject: "Away".to_string(),
969 from: None,
970 message: "I am away until Monday.".to_string(),
971 }
972 );
973 }
974
975 #[test]
976 fn vacation_subject_defaults_to_re_original() {
977 let rule = FilterRule {
978 actions: vec![Action::Vacation(Vacation::new("away"))],
979 ..matching_rule(vec![])
980 };
981 let outcome = evaluate_plan(
982 &[rule],
983 &eval_envelope(),
984 &EvalContext::default(),
985 &RegexCache::default(),
986 );
987 let crate::actions::PlannedAction::Vacation(reply) = &outcome.plan[0] else {
988 panic!("expected vacation outcome");
989 };
990 assert_eq!(reply.subject, "Re: Hello");
991 assert_eq!(reply.days, 7);
992 }
993
994 #[test]
995 fn vacation_prefers_envelope_sender_over_header_from() {
996 let rule = FilterRule {
997 actions: vec![Action::Vacation(Vacation::new("away"))],
998 ..matching_rule(vec![])
999 };
1000 let msg = MailEnvelope {
1001 from: "header-from@example.com".to_string(),
1002 envelope_from: Some("envelope-from@example.net".to_string()),
1003 ..eval_envelope()
1004 };
1005 let outcome = evaluate_plan(
1006 &[rule],
1007 &msg,
1008 &EvalContext::default(),
1009 &RegexCache::default(),
1010 );
1011 let crate::actions::PlannedAction::Vacation(reply) = &outcome.plan[0] else {
1012 panic!("expected vacation outcome");
1013 };
1014 assert_eq!(reply.to, "envelope-from@example.net");
1015 }
1016
1017 #[test]
1018 fn vacation_seen_before_hook_suppresses_reply() {
1019 let rule = FilterRule {
1020 actions: vec![Action::Vacation(Vacation::new("away"))],
1021 ..matching_rule(vec![])
1022 };
1023 let msg = eval_envelope();
1024 let seen = |sender: &str| sender == "alice@bounce.example.com";
1025 let ctx = EvalContext::default().with_seen_before(&seen);
1026 let outcome = evaluate_plan(&[rule], &msg, &ctx, &RegexCache::default());
1027 assert!(outcome.plan.is_empty());
1028 assert!(outcome.warnings.is_empty());
1029 }
1030
1031 #[test]
1032 fn vacation_tracker_dedups_across_evaluations() {
1033 let rule = FilterRule {
1034 actions: vec![Action::Vacation(Vacation::new("away"))],
1035 ..matching_rule(vec![])
1036 };
1037 let tracker = VacationTracker::new();
1038 let seen = |sender: &str| tracker.seen_before(sender, 7);
1039 let ctx = EvalContext::default().with_seen_before(&seen);
1040
1041 let first = evaluate_plan(
1042 std::slice::from_ref(&rule),
1043 &eval_envelope(),
1044 &ctx,
1045 &RegexCache::default(),
1046 );
1047 assert_eq!(first.plan.len(), 1);
1048 if let crate::actions::PlannedAction::Vacation(reply) = &first.plan[0] {
1049 tracker.record(&reply.to);
1050 }
1051
1052 let second = evaluate_plan(
1053 std::slice::from_ref(&rule),
1054 &eval_envelope(),
1055 &ctx,
1056 &RegexCache::default(),
1057 );
1058 assert!(second.plan.is_empty());
1059 }
1060
1061 #[test]
1062 fn vacation_without_sender_warns_and_is_omitted() {
1063 let rule = FilterRule {
1064 actions: vec![Action::Vacation(Vacation::new("away"))],
1065 ..matching_rule(vec![])
1066 };
1067 let msg = MailEnvelope {
1070 subject: "Hello".to_string(),
1071 ..MailEnvelope::default()
1072 };
1073 let outcome = evaluate_plan(
1074 &[rule],
1075 &msg,
1076 &EvalContext::default(),
1077 &RegexCache::default(),
1078 );
1079 assert!(outcome.plan.is_empty());
1080 assert_eq!(outcome.warnings, vec![EvalWarning::VacationNoSender]);
1081 }
1082
1083 #[test]
1084 fn notify_plans_method_and_message() {
1085 let rule = FilterRule {
1086 actions: vec![Action::Notify(Notify::new(
1087 "mailto:ops@example.com",
1088 "Payment received",
1089 ))],
1090 ..matching_rule(vec![])
1091 };
1092 let outcome = evaluate_plan(
1093 &[rule],
1094 &eval_envelope(),
1095 &EvalContext::default(),
1096 &RegexCache::default(),
1097 );
1098 assert!(outcome.warnings.is_empty());
1099 assert_eq!(
1100 outcome.plan,
1101 vec![crate::actions::PlannedAction::Notify {
1102 method: "mailto:ops@example.com".to_string(),
1103 message: "Payment received".to_string(),
1104 }]
1105 );
1106 }
1107
1108 #[test]
1109 fn unknown_notify_method_parses_but_warns() {
1110 let rule = FilterRule {
1111 actions: vec![
1112 Action::Notify(Notify::new("carrier-pigeon:perth", "hello")),
1113 Action::MarkRead,
1114 ],
1115 ..matching_rule(vec![])
1116 };
1117 let outcome = evaluate_plan(
1118 &[rule],
1119 &eval_envelope(),
1120 &EvalContext::default(),
1121 &RegexCache::default(),
1122 );
1123 assert_eq!(outcome.plan.len(), 2);
1125 assert_eq!(
1127 outcome.warnings,
1128 vec![EvalWarning::UnknownNotifyMethod {
1129 method: "carrier-pigeon:perth".to_string(),
1130 }]
1131 );
1132 }
1133
1134 #[test]
1135 fn notify_method_without_scheme_warns() {
1136 let rule = FilterRule {
1137 actions: vec![Action::Notify(Notify::new("no-scheme-here", ""))],
1138 ..matching_rule(vec![])
1139 };
1140 let outcome = evaluate_plan(
1141 &[rule],
1142 &eval_envelope(),
1143 &EvalContext::default(),
1144 &RegexCache::default(),
1145 );
1146 assert_eq!(outcome.plan.len(), 1);
1147 assert_eq!(
1148 outcome.warnings,
1149 vec![EvalWarning::UnknownNotifyMethod {
1150 method: "no-scheme-here".to_string(),
1151 }]
1152 );
1153 }
1154
1155 #[test]
1156 fn no_matching_rule_yields_empty_outcome() {
1157 let rule = FilterRule {
1158 actions: vec![Action::MarkRead],
1159 ..make_rule(
1160 vec![Condition {
1161 field: ConditionField::Subject,
1162 operator: Operator::Contains,
1163 value: "never".to_string(),
1164 negate: false,
1165 }],
1166 LogicOp::And,
1167 )
1168 };
1169 let outcome = evaluate_plan(
1170 &[rule],
1171 &eval_envelope(),
1172 &EvalContext::default(),
1173 &RegexCache::default(),
1174 );
1175 assert!(outcome.is_empty());
1176 assert_eq!(outcome.rule_id, None);
1177 }
1178}