Skip to main content

postvak_core/rules/
engine.rs

1use crate::config::Identity;
2use crate::message::{bare_addresses, header_text};
3use crate::rules::{Action, CompiledRule, Conditions};
4use crate::store::{Message, has_flag_ignore_case};
5
6/// Set by `trash restore`; rules never act on, or notify about, mail carrying it.
7pub const RESTORED_KEYWORD: &str = "$PostvakRestored";
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Mode {
11    Normal,
12    ApplyExisting,
13}
14
15pub struct Context<'a> {
16    pub account: &'a str,
17    pub identity: &'a Identity,
18    pub now: i64,
19    pub mode: Mode,
20    pub notify_default: bool,
21}
22
23#[derive(Debug, Clone, PartialEq)]
24pub struct PlannedAction {
25    pub rule: String,
26    pub action: Action,
27}
28
29#[derive(Debug, Clone, PartialEq, Default)]
30pub struct Plan {
31    pub actions: Vec<PlannedAction>,
32    pub notify: bool,
33}
34
35pub fn folder_needs_body(rules: &[CompiledRule], account: &str, folder: &str) -> bool {
36    rules.iter().any(|r| {
37        r.rule.enabled && r.applies_to_account(account) && r.folder() == folder && r.needs_body()
38    })
39}
40
41pub fn evaluate(rules: &[CompiledRule], msg: &Message, ctx: &Context) -> Plan {
42    if has_flag_ignore_case(&msg.flags, RESTORED_KEYWORD) {
43        return Plan::default();
44    }
45    let mut plan = Plan::default();
46    let mut explicit_notify: Option<bool> = None;
47    for rule in rules {
48        if !rule.rule.enabled
49            || !rule.applies_to_account(ctx.account)
50            || rule.folder() != msg.folder
51        {
52            continue;
53        }
54        if ctx.mode == Mode::Normal && msg.internaldate < rule.first_seen_at {
55            continue;
56        }
57        // An unknown body is neither a match nor a mismatch, so `none` cannot turn a failed fetch into a hit.
58        if rule.needs_body() && msg.body_text.is_none() {
59            continue;
60        }
61        if !matches(&rule.conditions, msg, ctx) {
62            continue;
63        }
64        let mut stop = false;
65        for action in &rule.rule.actions {
66            match action {
67                Action::Notify => explicit_notify = Some(true),
68                Action::Silent => explicit_notify = Some(false),
69                Action::Stop => stop = true,
70                _ => plan.actions.push(PlannedAction {
71                    rule: rule.rule.name.clone(),
72                    action: action.clone(),
73                }),
74            }
75            if *action == Action::Delete {
76                stop = true;
77            }
78        }
79        if stop {
80            break;
81        }
82    }
83    let deleted = plan.actions.iter().any(|a| a.action == Action::Delete);
84    plan.notify = !deleted
85        && match explicit_notify {
86            Some(explicit) => explicit,
87            None => {
88                ctx.notify_default
89                    && !plan.actions.iter().any(|a| {
90                        matches!(a.action, Action::Delete | Action::Move(_) | Action::Archive)
91                    })
92            }
93        };
94    plan
95}
96
97fn field(value: &Option<String>) -> &str {
98    value.as_deref().unwrap_or("")
99}
100
101fn matches(c: &Conditions, msg: &Message, ctx: &Context) -> bool {
102    if let Some(m) = &c.from
103        && !m.is_address_match(field(&msg.from_addr))
104    {
105        return false;
106    }
107    if let Some(m) = &c.to
108        && !m.is_address_match(field(&msg.to_addr))
109    {
110        return false;
111    }
112    if let Some(m) = &c.cc
113        && !m.is_address_match(field(&msg.cc_addr))
114    {
115        return false;
116    }
117    if let Some(m) = &c.subject
118        && !m.is_match(field(&msg.subject))
119    {
120        return false;
121    }
122    if let Some(m) = &c.body
123        && !m.is_match(field(&msg.body_text))
124    {
125        return false;
126    }
127    for (name, m) in &c.headers {
128        match header_text(&msg.headers, name) {
129            Some(value) if m.is_match(&value) => {}
130            _ => return false,
131        }
132    }
133    if let Some(min_age) = c.older_than
134        && ctx.now - msg.internaldate < i64::try_from(min_age.as_secs()).unwrap_or(i64::MAX)
135    {
136        return false;
137    }
138    if let Some(min_size) = c.larger_than
139        && msg.size.is_none_or(|size| u64::from(size) <= min_size)
140    {
141        return false;
142    }
143    if let Some(seen) = c.seen
144        && msg.is_seen() != seen
145    {
146        return false;
147    }
148    if let Some(flagged) = c.flagged
149        && msg.is_flagged() != flagged
150    {
151        return false;
152    }
153    if let Some(tag) = &c.tag
154        && !has_flag_ignore_case(&msg.flags, tag)
155    {
156        return false;
157    }
158    if c.to_me.is_some() || c.alias.is_some() {
159        let recipients: Vec<String> = [&msg.to_addr, &msg.cc_addr, &msg.delivered_to]
160            .into_iter()
161            .flat_map(|f| bare_addresses(field(f)))
162            .collect();
163        if let Some(to_me) = c.to_me
164            && recipients.iter().any(|r| ctx.identity.is_me(r)) != to_me
165        {
166            return false;
167        }
168        if let Some(alias) = &c.alias
169            && !recipients.iter().any(|r| alias.is_match(r))
170        {
171            return false;
172        }
173    }
174    if c.none.iter().any(|entry| matches(entry, msg, ctx)) {
175        return false;
176    }
177    c.any.is_empty() || c.any.iter().any(|branch| matches(branch, msg, ctx))
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::config::{AccountConfig, PasswordSource};
184    use crate::rules::{compile, parse};
185
186    const HOUR: i64 = 3600;
187
188    fn identity() -> Identity {
189        let password = PasswordSource::Keyring { keyring: true };
190        AccountConfig {
191            aliases: vec!["*@shop.example.com".into()],
192            ..AccountConfig::new("work", "h", "pieter@example.com", password)
193        }
194        .identity()
195        .unwrap()
196    }
197
198    fn rules(toml: &str, first_seen_at: i64) -> Vec<CompiledRule> {
199        let mut compiled = compile(&parse(toml).unwrap()).unwrap();
200        for r in &mut compiled {
201            r.first_seen_at = first_seen_at;
202        }
203        compiled
204    }
205
206    fn msg(from: &str, to: &str, subject: &str, internaldate: i64, seen: bool) -> Message {
207        let list_id = if from.contains("github") {
208            "List-Id: dev <dev.github.com>\r\n"
209        } else {
210            ""
211        };
212        let headers = format!("From: {from}\r\nTo: {to}\r\nSubject: {subject}\r\n{list_id}\r\n");
213        Message {
214            folder: "INBOX".into(),
215            uid: 1,
216            message_id: Some("m1@x".into()),
217            from_addr: Some(from.into()),
218            to_addr: Some(to.into()),
219            thread_id: "m1@x".into(),
220            subject: Some(subject.into()),
221            date: Some(internaldate),
222            internaldate,
223            flags: if seen { "\\Seen".into() } else { String::new() },
224            headers: headers.into_bytes(),
225            ..Default::default()
226        }
227    }
228
229    fn ctx<'a>(id: &'a Identity, now: i64) -> Context<'a> {
230        Context {
231            account: "work",
232            identity: id,
233            now,
234            mode: Mode::Normal,
235            notify_default: true,
236        }
237    }
238
239    const PURGE: &str = r#"
240[[rules]]
241name = "purge"
242match.from = { regex = "no-?reply@" }
243match.subject = { regex = "(?i)sign.?in|verification code" }
244match.older_than = "1h"
245match.seen = true
246actions = ["delete"]
247"#;
248
249    #[test]
250    fn restored_mail_is_left_alone() {
251        let id = identity();
252        let rules = rules(
253            "[[rules]]\nname = \"codes\"\nmatch.subject = { contains = \"code\" }\nactions = [\"delete\"]\n",
254            0,
255        );
256        let mut m = msg("noreply@x.com", "me@example.com", "your code", 100, true);
257        assert!(!evaluate(&rules, &m, &ctx(&id, 200)).actions.is_empty());
258        m.flags = format!("\\Seen {RESTORED_KEYWORD}");
259        assert_eq!(evaluate(&rules, &m, &ctx(&id, 200)), Plan::default());
260    }
261
262    #[test]
263    fn sign_in_code_deleted_only_when_old_and_seen() {
264        let id = identity();
265        let rules = rules(PURGE, 0);
266        let now = 10 * HOUR;
267        let fresh = msg(
268            "noreply@login.example",
269            "pieter@example.com",
270            "Your sign-in code",
271            now - 600,
272            true,
273        );
274        assert!(evaluate(&rules, &fresh, &ctx(&id, now)).actions.is_empty());
275        let old_unseen = msg(
276            "noreply@login.example",
277            "pieter@example.com",
278            "Your sign-in code",
279            now - 2 * HOUR,
280            false,
281        );
282        assert!(
283            evaluate(&rules, &old_unseen, &ctx(&id, now))
284                .actions
285                .is_empty()
286        );
287        let old_seen = msg(
288            "noreply@login.example",
289            "pieter@example.com",
290            "Your sign-in code",
291            now - 2 * HOUR,
292            true,
293        );
294        let plan = evaluate(&rules, &old_seen, &ctx(&id, now));
295        assert_eq!(
296            plan.actions,
297            vec![PlannedAction {
298                rule: "purge".into(),
299                action: Action::Delete
300            }]
301        );
302        assert!(!plan.notify, "deleted mail is silent");
303    }
304
305    #[test]
306    fn rule_ignores_mail_older_than_its_first_seen_unless_apply_existing() {
307        let id = identity();
308        let rules = rules(PURGE, 5 * HOUR);
309        let now = 10 * HOUR;
310        let before_rule = msg("noreply@x", "pieter@example.com", "sign in", 4 * HOUR, true);
311        assert!(
312            evaluate(&rules, &before_rule, &ctx(&id, now))
313                .actions
314                .is_empty()
315        );
316        let mut c = ctx(&id, now);
317        c.mode = Mode::ApplyExisting;
318        assert_eq!(evaluate(&rules, &before_rule, &c).actions.len(), 1);
319    }
320
321    #[test]
322    fn equals_on_address_fields_compares_bare_addresses() {
323        let id = identity();
324        let from_rule = rules(
325            "[[rules]]\nname = \"gh\"\nmatch.from = { equals = \" NoReply@GitHub.com \" }\nactions = [\"flag\"]\n",
326            0,
327        );
328        let github = msg(
329            "GitHub <noreply@github.com>",
330            "pieter@example.com",
331            "PR",
332            100,
333            false,
334        );
335        assert_eq!(
336            evaluate(&from_rule, &github, &ctx(&id, 200)).actions.len(),
337            1
338        );
339        let to_rule = rules(
340            "[[rules]]\nname = \"second\"\nmatch.to = { equals = \"b@x\" }\nactions = [\"flag\"]\n",
341            0,
342        );
343        let two = msg("a@y", "A <a@x>, B <b@x>", "hi", 100, false);
344        assert_eq!(evaluate(&to_rule, &two, &ctx(&id, 200)).actions.len(), 1);
345        let subject_rule = rules(
346            "[[rules]]\nname = \"s\"\nmatch.subject = { equals = \"pr\" }\nactions = [\"flag\"]\n",
347            0,
348        );
349        assert_eq!(
350            evaluate(&subject_rule, &github, &ctx(&id, 200))
351                .actions
352                .len(),
353            1
354        );
355        let other = msg(
356            "noreply@github.com.evil",
357            "pieter@example.com",
358            "PR",
359            100,
360            false,
361        );
362        assert!(
363            evaluate(&from_rule, &other, &ctx(&id, 200))
364                .actions
365                .is_empty()
366        );
367    }
368
369    #[test]
370    fn move_and_mark_read_chain_and_header_match() {
371        let id = identity();
372        let toml = r#"
373[[rules]]
374name = "github"
375match.header = { name = "List-Id", contains = "github.com" }
376actions = [{ move = "Lists/GitHub" }, "mark_read"]
377"#;
378        let plan = evaluate(
379            &rules(toml, 0),
380            &msg("bot@github.com", "pieter@example.com", "PR", 100, false),
381            &ctx(&id, 200),
382        );
383        assert_eq!(
384            plan.actions
385                .iter()
386                .map(|a| a.action.clone())
387                .collect::<Vec<_>>(),
388            vec![Action::Move("Lists/GitHub".into()), Action::MarkRead]
389        );
390        assert!(!plan.notify, "moved mail is silent by default");
391    }
392
393    #[test]
394    fn delete_stops_the_chain() {
395        let id = identity();
396        let toml = r#"
397[[rules]]
398name = "first"
399match.subject = { contains = "spam" }
400actions = ["delete"]
401
402[[rules]]
403name = "second"
404match.subject = { contains = "spam" }
405actions = ["flag"]
406"#;
407        let plan = evaluate(
408            &rules(toml, 0),
409            &msg("a@x", "pieter@example.com", "SPAM offer", 100, false),
410            &ctx(&id, 200),
411        );
412        assert_eq!(plan.actions.len(), 1);
413        assert_eq!(plan.actions[0].rule, "first");
414    }
415
416    #[test]
417    fn account_and_folder_scoping() {
418        let id = identity();
419        let toml = r#"
420[[rules]]
421name = "other account"
422account = "home"
423match.seen = false
424actions = ["flag"]
425
426[[rules]]
427name = "other folder"
428folder = "Archive"
429match.seen = false
430actions = ["flag"]
431"#;
432        let plan = evaluate(
433            &rules(toml, 0),
434            &msg("a@x", "pieter@example.com", "s", 100, false),
435            &ctx(&id, 200),
436        );
437        assert!(plan.actions.is_empty());
438    }
439
440    #[test]
441    fn to_me_and_alias() {
442        let id = identity();
443        let toml = r#"
444[[rules]]
445name = "lists"
446match.to_me = false
447actions = [{ move = "Lists" }]
448
449[[rules]]
450name = "shop"
451match.alias = "*@shop.example.com"
452actions = [{ move = "Shopping" }]
453"#;
454        let r = rules(toml, 0);
455        let direct = msg("a@x", "Pieter <pieter@example.com>", "s", 100, false);
456        assert!(evaluate(&r, &direct, &ctx(&id, 200)).actions.is_empty());
457        let list = msg("a@x", "dev@lists.example", "s", 100, false);
458        assert_eq!(evaluate(&r, &list, &ctx(&id, 200)).actions[0].rule, "lists");
459        let shop = msg("a@x", "orders@shop.example.com", "s", 100, false);
460        let plan = evaluate(&r, &shop, &ctx(&id, 200));
461        assert_eq!(
462            plan.actions
463                .iter()
464                .map(|a| a.rule.as_str())
465                .collect::<Vec<_>>(),
466            vec!["shop"]
467        );
468    }
469
470    #[test]
471    fn body_rule_needs_body_and_matches_when_present() {
472        let id = identity();
473        let toml = r#"
474[[rules]]
475name = "unsubscribe"
476match.body = { contains = "unsubscribe" }
477actions = ["flag"]
478"#;
479        let r = rules(toml, 0);
480        assert!(folder_needs_body(&r, "work", "INBOX"));
481        assert!(!folder_needs_body(&r, "work", "Sent"));
482        let mut m = msg("a@x", "pieter@example.com", "s", 100, false);
483        assert!(evaluate(&r, &m, &ctx(&id, 200)).actions.is_empty());
484        m.body_text = Some("Click here to UNSUBSCRIBE".into());
485        assert_eq!(evaluate(&r, &m, &ctx(&id, 200)).actions.len(), 1);
486    }
487
488    #[test]
489    fn notification_policy() {
490        let id = identity();
491        let toml = r#"
492[[rules]]
493name = "github"
494match.header = { name = "List-Id", contains = "github.com" }
495actions = [{ move = "Lists/GitHub" }, "notify"]
496
497[[rules]]
498name = "quiet"
499match.subject = { contains = "newsletter" }
500actions = ["silent"]
501"#;
502        let r = rules(toml, 0);
503        let untouched = msg("a@x", "pieter@example.com", "hello", 100, false);
504        assert!(evaluate(&r, &untouched, &ctx(&id, 200)).notify);
505        let mut off = ctx(&id, 200);
506        off.notify_default = false;
507        assert!(!evaluate(&r, &untouched, &off).notify);
508        let moved_but_notify = msg("bot@github.com", "pieter@example.com", "PR", 100, false);
509        assert!(evaluate(&r, &moved_but_notify, &ctx(&id, 200)).notify);
510        let quiet = msg("a@x", "pieter@example.com", "Weekly newsletter", 100, false);
511        assert!(!evaluate(&r, &quiet, &ctx(&id, 200)).notify);
512    }
513
514    #[test]
515    fn deleted_mail_never_notifies() {
516        let id = identity();
517        let toml = r#"
518[[rules]]
519name = "loud"
520match.subject = { contains = "x" }
521actions = ["notify"]
522
523[[rules]]
524name = "purge"
525match.subject = { contains = "x" }
526actions = ["delete"]
527"#;
528        let plan = evaluate(
529            &rules(toml, 0),
530            &msg("a@x", "pieter@example.com", "x", 100, false),
531            &ctx(&id, 200),
532        );
533        assert!(!plan.notify);
534        assert_eq!(plan.actions.len(), 1);
535        assert_eq!(plan.actions[0].action, Action::Delete);
536    }
537
538    #[test]
539    fn huge_older_than_never_matches() {
540        let id = identity();
541        let toml = "[[rules]]\nname = \"never\"\nmatch.older_than = \"300000000000y\"\nactions = [\"delete\"]\n";
542        let m = msg("a@x", "pieter@example.com", "s", 0, true);
543        assert!(
544            evaluate(&rules(toml, 0), &m, &ctx(&id, 10 * HOUR))
545                .actions
546                .is_empty()
547        );
548    }
549
550    #[test]
551    fn to_me_via_delivered_to_and_cc() {
552        let id = identity();
553        let toml = r#"
554[[rules]]
555name = "lists"
556match.to_me = false
557actions = [{ move = "Lists" }]
558
559[[rules]]
560name = "shop"
561match.alias = "orders@shop.example.com"
562actions = [{ move = "Shopping" }]
563"#;
564        let r = rules(toml, 0);
565        let mut delivered = msg("a@x", "dev@lists.example", "s", 100, false);
566        delivered.delivered_to = Some("pieter@example.com".into());
567        assert!(evaluate(&r, &delivered, &ctx(&id, 200)).actions.is_empty());
568        let mut cc = msg("a@x", "pieter@example.com", "s", 100, false);
569        cc.cc_addr = Some("Shop <orders@shop.example.com>".into());
570        let plan = evaluate(&r, &cc, &ctx(&id, 200));
571        assert_eq!(
572            plan.actions
573                .iter()
574                .map(|a| a.rule.as_str())
575                .collect::<Vec<_>>(),
576            vec!["shop"]
577        );
578    }
579
580    #[test]
581    fn older_than_boundary_is_inclusive() {
582        let id = identity();
583        let toml = "[[rules]]\nname = \"age\"\nmatch.older_than = \"1h\"\nactions = [\"flag\"]\n";
584        let r = rules(toml, 0);
585        let exact = msg("a@x", "pieter@example.com", "s", 1000, false);
586        assert_eq!(
587            evaluate(&r, &exact, &ctx(&id, 1000 + HOUR)).actions.len(),
588            1
589        );
590        assert!(
591            evaluate(&r, &exact, &ctx(&id, 1000 + HOUR - 1))
592                .actions
593                .is_empty()
594        );
595    }
596
597    fn fires(toml: &str, m: &Message) -> bool {
598        let id = identity();
599        !evaluate(&rules(toml, 0), m, &ctx(&id, 200))
600            .actions
601            .is_empty()
602    }
603
604    #[test]
605    fn none_excludes_mail_any_entry_matches() {
606        let toml = r#"
607[[rules]]
608name = "shop but no receipts"
609match.from = { contains = "shop" }
610match.none = [{ subject = { contains = "receipt" } }, { from = { contains = "billing" } }]
611actions = ["flag"]
612"#;
613        assert!(fires(toml, &msg("shop@x", "p@x", "Sale", 100, false)));
614        assert!(!fires(
615            toml,
616            &msg("shop@x", "p@x", "Your receipt", 100, false)
617        ));
618        assert!(!fires(
619            toml,
620            &msg("billing@shop.x", "p@x", "Sale", 100, false)
621        ));
622        assert!(!fires(toml, &msg("bank@x", "p@x", "Sale", 100, false)));
623    }
624
625    #[test]
626    fn a_none_entry_excludes_only_when_all_its_conditions_hold() {
627        let toml = r#"
628[[rules]]
629name = "x"
630match.seen = false
631match.none = [{ from = { contains = "alice" }, subject = { contains = "lunch" } }]
632actions = ["flag"]
633"#;
634        assert!(!fires(toml, &msg("alice@x", "p@x", "lunch?", 100, false)));
635        assert!(fires(toml, &msg("alice@x", "p@x", "report", 100, false)));
636        assert!(fires(toml, &msg("bob@x", "p@x", "lunch?", 100, false)));
637    }
638
639    #[test]
640    fn a_list_of_values_matches_any_of_them() {
641        let toml = r#"
642[[rules]]
643name = "x"
644match.subject = { contains = ["receipt", "INVOICE"] }
645match.from = { equals = ["a@x", "b@x"] }
646actions = ["flag"]
647"#;
648        assert!(fires(
649            toml,
650            &msg("A <a@x>", "p@x", "Your invoice", 100, false)
651        ));
652        assert!(fires(toml, &msg("b@x", "p@x", "receipt 12", 100, false)));
653        assert!(!fires(toml, &msg("c@x", "p@x", "receipt 12", 100, false)));
654        assert!(!fires(toml, &msg("a@x", "p@x", "hello", 100, false)));
655    }
656
657    #[test]
658    fn any_needs_one_branch_and_the_rest_still_holds() {
659        let toml = r#"
660[[rules]]
661name = "either"
662match.seen = false
663match.any = [{ from = { contains = "alice" } }, { subject = { contains = "urgent" } }]
664actions = ["flag"]
665"#;
666        assert!(fires(toml, &msg("alice@x", "p@x", "hi", 100, false)));
667        assert!(fires(toml, &msg("bob@x", "p@x", "URGENT", 100, false)));
668        assert!(!fires(toml, &msg("bob@x", "p@x", "hi", 100, false)));
669        assert!(!fires(toml, &msg("alice@x", "p@x", "hi", 100, true)));
670    }
671
672    #[test]
673    fn every_listed_header_must_match() {
674        let toml = r#"
675[[rules]]
676name = "both"
677match.header = [{ name = "List-Id", contains = "github.com" }, { name = "Subject", contains = "PR" }]
678actions = ["flag"]
679"#;
680        assert!(fires(
681            toml,
682            &msg("bot@github.com", "p@x", "PR 1", 100, false)
683        ));
684        assert!(!fires(
685            toml,
686            &msg("bot@github.com", "p@x", "Issue", 100, false)
687        ));
688        assert!(!fires(toml, &msg("a@x", "p@x", "PR 1", 100, false)));
689    }
690
691    #[test]
692    fn tag_matches_keywords_ignoring_case() {
693        let toml = "[[rules]]\nname = \"t\"\nmatch.tag = \"$label1\"\nactions = [\"flag\"]\n";
694        let mut m = msg("a@x", "p@x", "s", 100, false);
695        assert!(!fires(toml, &m));
696        m.flags = "\\Seen $Label1".into();
697        assert!(fires(toml, &m));
698        m.flags = "$label10".into();
699        assert!(!fires(toml, &m));
700    }
701
702    #[test]
703    fn rule_needing_a_missing_body_never_fires_even_negated() {
704        let toml = "[[rules]]\nname = \"b\"\nmatch.none = [{ body = { contains = \"keep\" } }]\nactions = [\"delete\"]\n";
705        let r = rules(toml, 0);
706        assert!(folder_needs_body(&r, "work", "INBOX"));
707        let mut m = msg("a@x", "p@x", "s", 100, false);
708        assert!(!fires(toml, &m));
709        m.body_text = Some("please keep this".into());
710        assert!(!fires(toml, &m));
711        m.body_text = Some("junk".into());
712        assert!(fires(toml, &m));
713    }
714
715    #[test]
716    fn flagged_matches_the_flagged_state() {
717        let toml = "[[rules]]\nname = \"f\"\nmatch.flagged = true\nactions = [\"mark_read\"]\n";
718        let mut m = msg("a@x", "p@x", "s", 100, false);
719        assert!(!fires(toml, &m));
720        m.flags = "\\Flagged".into();
721        assert!(fires(toml, &m));
722        let unflagged = toml.replace("flagged = true", "flagged = false");
723        assert!(!fires(&unflagged, &m));
724    }
725
726    #[test]
727    fn larger_than_compares_the_message_size() {
728        let toml = "[[rules]]\nname = \"big\"\nmatch.larger_than = \"1KB\"\nactions = [\"flag\"]\n";
729        let mut m = msg("a@x", "p@x", "s", 100, false);
730        assert!(!fires(toml, &m), "an unknown size is not large");
731        m.size = Some(1024);
732        assert!(!fires(toml, &m));
733        m.size = Some(1025);
734        assert!(fires(toml, &m));
735    }
736
737    #[test]
738    fn stop_keeps_later_rules_from_acting() {
739        let id = identity();
740        let toml = r#"
741[[rules]]
742name = "first"
743match.subject = { contains = "x" }
744actions = [{ tag = "$label1" }, "stop"]
745
746[[rules]]
747name = "second"
748match.subject = { contains = "x" }
749actions = [{ move = "Later" }]
750"#;
751        let plan = evaluate(
752            &rules(toml, 0),
753            &msg("a@x", "p@x", "x", 100, false),
754            &ctx(&id, 200),
755        );
756        assert_eq!(
757            plan.actions,
758            vec![PlannedAction {
759                rule: "first".into(),
760                action: Action::Tag("$label1".into())
761            }]
762        );
763        assert!(plan.notify);
764    }
765
766    #[test]
767    fn header_matches_the_decoded_value() {
768        let toml = "[[rules]]\nname = \"h\"\nmatch.header = { name = \"X-Team\", contains = \"grüße\" }\nactions = [\"flag\"]\n";
769        let mut m = msg("a@x", "p@x", "s", 100, false);
770        m.headers = b"X-Team: =?UTF-8?Q?Gr=C3=BC=C3=9Fe?=\r\n\r\n".to_vec();
771        assert!(fires(toml, &m));
772    }
773
774    #[test]
775    fn disabled_rules_are_skipped() {
776        let id = identity();
777        let toml = "[[rules]]\nname = \"off\"\nenabled = false\nmatch.seen = false\nactions = [\"flag\"]\n";
778        assert!(
779            evaluate(
780                &rules(toml, 0),
781                &msg("a@x", "p@x", "s", 100, false),
782                &ctx(&id, 200)
783            )
784            .actions
785            .is_empty()
786        );
787    }
788}