Skip to main content

sieve_kit/
eval.rs

1//! Rule evaluation engine. Synchronous, bounded, no I/O.
2//!
3//! Regex evaluation is checked against a 100 ms budget after matching.
4//! Invalid regex patterns are treated as non-matching (never panic).
5
6use std::sync::Arc;
7
8use regex::Regex;
9
10use crate::types::{
11    AddressPart, Condition, ConditionField, EnvelopePart, FilterRule, Filterable, LogicOp, Operator,
12};
13
14/// Maximum time allowed for a single regex match (milliseconds).
15const REGEX_TIMEOUT_MS: u128 = 100;
16
17/// Cache of compiled regex patterns keyed by the raw pattern string.
18/// Prevents re-compilation on every evaluation pass.
19#[derive(Clone, Default)]
20pub struct RegexCache {
21    inner: Arc<std::sync::RwLock<std::collections::HashMap<String, Option<Regex>>>>,
22}
23
24impl RegexCache {
25    /// Returns a compiled regex, or `None` if the pattern is invalid.
26    #[must_use]
27    pub fn get_or_compile(&self, pattern: &str) -> Option<Regex> {
28        // Fast path: already compiled.
29        if let Ok(cache) = self.inner.read() {
30            if let Some(entry) = cache.get(pattern) {
31                return entry.clone();
32            }
33        }
34        // Slow path: compile and insert.
35        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/// Resolve the string value of a condition field on a message.
44///
45/// `HasAttachment` resolves to `"true"`/`"false"`; missing fields resolve to
46/// the empty string. For [`ConditionField::Envelope`] this is the *full*
47/// envelope address (`:all` semantics); use
48/// [`evaluate_condition`] to apply `:localpart`/`:domain` extraction.
49#[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/// Evaluate a filter rule against a message.
73///
74/// Returns `true` when the rule matches (all/any conditions satisfied).
75/// Disabled rules and rules without conditions always return `false`.
76///
77/// # Panics
78///
79/// Never panics. Invalid regex patterns are treated as non-matching.
80#[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/// Evaluate a single condition against a message.
106///
107/// Envelope conditions (RFC 5228 §5.1) evaluate as non-matching when the
108/// message supplies no envelope data; `:localpart`/`:domain` select the
109/// portion of the address compared.
110///
111/// # Panics
112///
113/// Never panics.
114#[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        // Missing envelope data: the test does not match (a negated test
126        // then matches, per `not` semantics).
127        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/// Extract the portion of an address selected by an
144/// [`AddressPart`]. `:localpart` is everything before the last `@` (the
145/// whole value when there is no `@`); `:domain` is everything after the
146/// last `@` (empty when there is no `@`).
147#[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
156/// Apply a comparison operator to a resolved field value. Shared by the
157/// plain and envelope code paths so operator semantics (the `:comparator`
158/// plumbing) stay identical across `address`/`header`/`envelope` tests.
159fn 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/// `i;ascii-numeric` equality (RFC 4790): both strings must consist solely
178/// of ASCII digits and be numerically equal (leading zeros ignored). The
179/// empty string compares equal only to the empty string; any non-numeric
180/// operand never matches.
181#[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
198/// Evaluate a regex condition with a bounded budget.
199///
200/// Returns `false` on invalid patterns or when the match exceeds the time
201/// budget (never panics).
202fn 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    // The regex crate is linear-time (no catastrophic backtracking) and has
208    // no built-in timeout. Wall-clock measurement is a post-hoc safety check:
209    // a match that ran over budget is reported as non-matching so callers can
210    // treat pathological rules conservatively.
211    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/// Simple glob matching supporting `*` (any chars) and `?` (single char).
220///
221/// `*` and `?` are the only special characters; backslash escapes them.
222/// Matching is case-insensitive.
223#[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
261/// Sort rules by priority (ascending = highest priority first).
262pub fn sort_rules_by_priority(rules: &mut [FilterRule]) {
263    rules.sort_by_key(|r| r.priority);
264}
265
266/// Caller-supplied runtime context for [`evaluate_plan`].
267///
268/// Evaluation stays synchronous and I/O-free: instead of the engine touching
269/// storage, callers hand in an optional `seen_before` predicate that decides
270/// whether an automated reply was already sent to a sender within its
271/// respond period.
272#[derive(Default)]
273pub struct EvalContext<'a> {
274    /// Respond-once hook for the
275    /// [`vacation`](crate::types::Action::Vacation) action (RFC 5230):
276    /// return `true` when a reply was already sent to this sender within
277    /// the configured period. When `None`, no dedup is applied and the
278    /// outcome always carries the reply (hosts can dedup themselves using
279    /// the `to`/`days` fields of
280    /// [`VacationReply`](crate::actions::VacationReply), or use the
281    /// ready-made [`VacationTracker`](crate::actions::VacationTracker)).
282    pub seen_before: Option<&'a dyn Fn(&str) -> bool>,
283}
284
285impl<'a> EvalContext<'a> {
286    /// Attach a respond-once dedup predicate.
287    #[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/// A non-fatal observation made during [`evaluate_plan`]. Evaluation never
295/// fails: misconfigured or exotic actions are reported as warnings while
296/// the rest of the plan still executes.
297#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
298pub enum EvalWarning {
299    /// A [`notify`](crate::types::Action::Notify) method URI uses a scheme
300    /// outside the recognized set
301    /// ([`KNOWN_NOTIFY_SCHEMES`](crate::types::KNOWN_NOTIFY_SCHEMES)). The
302    /// notification outcome is still produced — the host decides whether to
303    /// attempt delivery.
304    #[error("notify method {method:?} has an unrecognized URI scheme")]
305    UnknownNotifyMethod {
306        /// The offending method URI.
307        method: String,
308    },
309    /// A [`vacation`](crate::types::Action::Vacation) reply could not be
310    /// routed: neither envelope sender nor header From yielded a usable
311    /// address. The reply is omitted from the plan.
312    #[error("vacation action could not determine a reply-to sender")]
313    VacationNoSender,
314}
315
316/// The result of evaluating a rule set with [`evaluate_plan`]: the matched
317/// rule (if any), the planned outcomes, and any warnings.
318#[derive(Clone, Debug, Default)]
319pub struct EvalOutcome {
320    /// Id of the first matching rule, or `None` when no rule matched.
321    pub rule_id: Option<String>,
322    /// Outcomes to execute, in rule action order.
323    pub plan: Vec<crate::actions::PlannedAction>,
324    /// Non-fatal observations (unknown notify schemes, unroutable replies).
325    pub warnings: Vec<EvalWarning>,
326}
327
328impl EvalOutcome {
329    /// Whether the outcome set is empty (no rule matched and no warnings).
330    #[must_use]
331    pub fn is_empty(&self) -> bool {
332        self.plan.is_empty() && self.warnings.is_empty()
333    }
334}
335
336/// Evaluate rules against a message and resolve them into execution-ready
337/// outcomes.
338///
339/// Like [`collect_matches`](crate::actions::collect_matches) this is
340/// first-match-wins, but the returned plan additionally resolves the
341/// actions that need runtime context:
342///
343/// - [`vacation`](crate::types::Action::Vacation): the reply-to sender is
344///   taken from envelope data (falling back to the header From address);
345///   the subject defaults to `Re: <original subject>`; a reply already sent
346///   (per [`EvalContext::seen_before`]) suppresses the outcome, and an
347///   unroutable reply emits [`EvalWarning::VacationNoSender`].
348/// - [`notify`](crate::types::Action::Notify): unrecognized method schemes
349///   emit [`EvalWarning::UnknownNotifyMethod`].
350///
351/// Pure and synchronous: no I/O, never panics.
352#[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
387/// Resolve a vacation action into a (possibly suppressed) reply outcome.
388fn 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        // Respond-once-per-sender-per-period: suppress silently.
404        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        // Both calls should succeed and return equivalent patterns.
700        assert!(r1.is_some());
701        assert!(r2.is_some());
702        // Verify both patterns match the same input.
703        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    // ---- envelope test (RFC 5228 §5.1) ---------------------------------
739
740    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        // Negation inverts: `not envelope` matches when data is missing.
821        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    // ---- i;ascii-numeric comparator ------------------------------------
882
883    #[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    // ---- evaluate_plan: vacation, notify, warnings ----------------------
914
915    use crate::actions::{VacationReply, VacationTracker};
916    use crate::types::{Action, Notify, Vacation};
917
918    /// A rule that always matches [`eval_envelope`] (subject "Hello").
919    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        // Matches on subject but supplies neither envelope sender nor a
1068        // header From address to reply to.
1069        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        // The notification outcome is still produced...
1124        assert_eq!(outcome.plan.len(), 2);
1125        // ...alongside a warning about the unrecognized scheme.
1126        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}