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
6pub 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 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}