1pub mod apply;
2pub mod edit;
3pub mod engine;
4
5use std::io;
6use std::path::Path;
7use std::time::Duration;
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, thiserror::Error)]
13pub enum RulesError {
14 #[error("rules.toml: {0}")]
15 Parse(String),
16 #[error("rule '{rule}': {reason}")]
17 Invalid { rule: String, reason: String },
18 #[error("no rule named '{0}'")]
19 NotFound(String),
20 #[error("rule '{0}' is disabled; approve or enable it first")]
21 Disabled(String),
22 #[error(transparent)]
23 Io(#[from] io::Error),
24 #[error(transparent)]
25 Store(#[from] crate::store::StoreError),
26}
27
28pub(crate) fn invalid(rule: &str, reason: impl Into<String>) -> RulesError {
29 RulesError::Invalid {
30 rule: rule.to_string(),
31 reason: reason.into(),
32 }
33}
34
35#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
36#[serde(deny_unknown_fields)]
37pub struct RuleFile {
38 #[serde(default)]
39 pub rules: Vec<Rule>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
43#[serde(deny_unknown_fields)]
44pub struct Rule {
45 pub name: String,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub account: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub folder: Option<String>,
53 #[serde(default = "default_true")]
55 pub enabled: bool,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub proposed_by: Option<String>,
59 #[serde(rename = "match")]
61 pub matches: Match,
62 pub actions: Vec<Action>,
64}
65
66impl Rule {
67 pub fn definition(&self) -> Result<String, RulesError> {
69 serde_json::to_string(&(&self.account, &self.folder, &self.matches, &self.actions)).map_err(
70 |e| RulesError::Invalid {
71 rule: self.name.clone(),
72 reason: e.to_string(),
73 },
74 )
75 }
76}
77
78#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
79#[serde(deny_unknown_fields)]
80pub struct Match {
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub from: Option<TextMatch>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub to: Option<TextMatch>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub cc: Option<TextMatch>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub subject: Option<TextMatch>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub body: Option<TextMatch>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub header: Option<OneOrMany<HeaderMatch>>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub older_than: Option<String>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub larger_than: Option<String>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub seen: Option<bool>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub flagged: Option<bool>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub to_me: Option<bool>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub alias: Option<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub tag: Option<String>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub none: Option<Vec<Match>>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub any: Option<Vec<Match>>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
130#[serde(untagged, expecting = "one value or a list of them")]
131pub enum OneOrMany<T> {
132 One(T),
133 Many(Vec<T>),
134}
135
136impl<T> OneOrMany<T> {
137 pub fn as_slice(&self) -> &[T] {
138 match self {
139 OneOrMany::One(one) => std::slice::from_ref(one),
140 OneOrMany::Many(many) => many,
141 }
142 }
143}
144
145#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
147#[serde(deny_unknown_fields)]
148pub struct TextMatch {
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub contains: Option<OneOrMany<String>>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub equals: Option<OneOrMany<String>>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub regex: Option<String>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
161#[serde(deny_unknown_fields)]
162pub struct HeaderMatch {
163 pub name: String,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub contains: Option<OneOrMany<String>>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub equals: Option<OneOrMany<String>>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub regex: Option<String>,
174}
175
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
177#[serde(rename_all = "snake_case")]
178pub enum Action {
179 Delete,
181 MarkRead,
183 Flag,
185 Archive,
187 Notify,
189 Silent,
191 Stop,
193 #[serde(skip)]
194 MarkUnread,
195 #[serde(skip)]
196 Unflag,
197 #[serde(skip)]
199 Trash,
200 #[serde(skip)]
202 Junk,
203 #[serde(skip)]
205 NotJunk,
206 #[serde(rename = "move")]
208 Move(String),
209 #[serde(rename = "tag")]
211 Tag(String),
212}
213
214impl Action {
215 pub fn command_label(&self) -> String {
217 match self {
218 Action::Trash => Action::Delete.label(),
219 _ => self.label(),
220 }
221 }
222
223 pub fn label(&self) -> String {
224 match self {
225 Action::Delete => "delete".into(),
226 Action::MarkRead => "mark_read".into(),
227 Action::Flag => "flag".into(),
228 Action::Archive => "archive".into(),
229 Action::Notify => "notify".into(),
230 Action::Silent => "silent".into(),
231 Action::Stop => "stop".into(),
232 Action::MarkUnread => "mark_unread".into(),
233 Action::Unflag => "unflag".into(),
234 Action::Trash => "trash".into(),
235 Action::Junk => "junk".into(),
236 Action::NotJunk => "not_junk".into(),
237 Action::Move(folder) => format!("move:{folder}"),
238 Action::Tag(keyword) => format!("tag:{keyword}"),
239 }
240 }
241}
242
243fn default_true() -> bool {
244 true
245}
246
247pub fn schema() -> String {
249 let schema = schemars::schema_for!(RuleFile);
250 #[allow(clippy::expect_used)]
253 let text = serde_json::to_string_pretty(&schema).expect("a schema serializes");
254 text + "\n"
255}
256
257pub fn parse(text: &str) -> Result<RuleFile, RulesError> {
258 toml::from_str(text).map_err(|e| RulesError::Parse(e.to_string()))
259}
260
261pub fn load(path: &Path) -> Result<RuleFile, RulesError> {
262 parse(&crate::paths::read_or_empty(path)?)
263}
264
265#[derive(Debug, Clone)]
266pub(crate) enum Matcher {
267 Contains(Vec<String>),
268 Equals(Vec<String>),
269 Regex(regex::Regex),
270}
271
272impl Matcher {
273 pub(crate) fn is_match(&self, text: &str) -> bool {
274 match self {
275 Matcher::Contains(needles) => {
276 let text = text.to_lowercase();
277 needles.iter().any(|needle| text.contains(needle))
278 }
279 Matcher::Equals(wanted) => {
280 let text = text.trim().to_lowercase();
281 wanted.contains(&text)
282 }
283 Matcher::Regex(re) => re.is_match(text),
284 }
285 }
286
287 pub(crate) fn is_address_match(&self, field: &str) -> bool {
290 match self {
291 Matcher::Equals(wanted) => {
292 self.is_match(field)
293 || crate::message::bare_addresses(field)
294 .iter()
295 .any(|address| wanted.contains(&address.to_lowercase()))
296 }
297 _ => self.is_match(field),
298 }
299 }
300
301 fn build(
302 rule: &str,
303 path: &str,
304 contains: &Option<OneOrMany<String>>,
305 equals: &Option<OneOrMany<String>>,
306 regex: &Option<String>,
307 ) -> Result<Matcher, RulesError> {
308 let values =
309 |list: &Option<OneOrMany<String>>| list.as_ref().map(|l| l.as_slice().to_vec());
310 let (contains, equals) = (values(contains), values(equals));
311 let empty_list = [&contains, &equals]
312 .into_iter()
313 .flatten()
314 .any(Vec::is_empty);
315 if empty_list
316 || [&contains, &equals]
317 .into_iter()
318 .flatten()
319 .flatten()
320 .chain(regex)
321 .any(|value| value.trim().is_empty())
322 {
323 return Err(invalid(rule, format!("{path} must not be empty")));
324 }
325 match (contains, equals, regex) {
326 (Some(c), None, None) => Ok(Matcher::Contains(
327 c.iter().map(|value| value.to_lowercase()).collect(),
328 )),
329 (None, Some(e), None) => Ok(Matcher::Equals(
330 e.iter().map(|value| value.trim().to_lowercase()).collect(),
331 )),
332 (None, None, Some(r)) => regex::Regex::new(r)
333 .map(Matcher::Regex)
334 .map_err(|e| invalid(rule, format!("{path}: invalid regex: {e}"))),
335 _ => Err(invalid(
336 rule,
337 format!("{path} needs exactly one of contains, equals, regex"),
338 )),
339 }
340 }
341}
342
343#[derive(Debug, Clone)]
344pub struct CompiledRule {
345 pub rule: Rule,
346 pub first_seen_at: i64,
347 pub(crate) conditions: Conditions,
348}
349
350#[derive(Debug, Clone, Default)]
352pub(crate) struct Conditions {
353 pub(crate) from: Option<Matcher>,
354 pub(crate) to: Option<Matcher>,
355 pub(crate) cc: Option<Matcher>,
356 pub(crate) subject: Option<Matcher>,
357 pub(crate) body: Option<Matcher>,
358 pub(crate) headers: Vec<(String, Matcher)>,
359 pub(crate) older_than: Option<Duration>,
360 pub(crate) larger_than: Option<u64>,
361 pub(crate) seen: Option<bool>,
362 pub(crate) flagged: Option<bool>,
363 pub(crate) to_me: Option<bool>,
364 pub(crate) alias: Option<regex::Regex>,
365 pub(crate) tag: Option<String>,
366 pub(crate) none: Vec<Conditions>,
367 pub(crate) any: Vec<Conditions>,
368}
369
370impl Conditions {
371 fn needs_body(&self) -> bool {
372 self.body.is_some()
373 || self.none.iter().any(Conditions::needs_body)
374 || self.any.iter().any(Conditions::needs_body)
375 }
376}
377
378impl CompiledRule {
379 pub fn folder(&self) -> &str {
380 self.rule.folder.as_deref().unwrap_or("INBOX")
381 }
382
383 pub fn applies_to_account(&self, account: &str) -> bool {
384 self.rule.account.as_deref().is_none_or(|a| a == account)
385 }
386
387 pub fn needs_body(&self) -> bool {
388 self.conditions.needs_body()
389 }
390}
391
392const MAX_NAME_CHARS: usize = 255;
394
395const MAX_DEPTH: usize = 8;
397
398pub fn enabled_rule(path: &Path, name: &str) -> Result<CompiledRule, RulesError> {
401 let rule = compile(&load(path)?)?
402 .into_iter()
403 .find(|r| r.rule.name == name)
404 .ok_or_else(|| RulesError::NotFound(name.to_string()))?;
405 if !rule.rule.enabled {
406 return Err(RulesError::Disabled(name.to_string()));
407 }
408 Ok(rule)
409}
410
411pub fn compile(file: &RuleFile) -> Result<Vec<CompiledRule>, RulesError> {
412 let mut names = std::collections::HashSet::new();
413 file.rules
414 .iter()
415 .map(|rule| compile_rule(rule, &mut names))
416 .collect()
417}
418
419fn compile_rule(
420 rule: &Rule,
421 names: &mut std::collections::HashSet<String>,
422) -> Result<CompiledRule, RulesError> {
423 if rule.name.trim().is_empty() {
424 return Err(invalid(&rule.name, "name must not be empty"));
425 }
426 if !names.insert(rule.name.clone()) {
427 return Err(invalid(&rule.name, "duplicate rule name"));
428 }
429 if rule.actions.is_empty() {
430 return Err(invalid(&rule.name, "actions must not be empty"));
431 }
432 let strings = [
433 ("name", Some(&rule.name)),
434 ("account", rule.account.as_ref()),
435 ("folder", rule.folder.as_ref()),
436 ]
437 .into_iter()
438 .filter_map(|(field, value)| Some((field, value?)))
439 .chain(rule.actions.iter().filter_map(|action| match action {
440 Action::Move(folder) => Some(("move folder", folder)),
441 _ => None,
442 }));
443 for (field, value) in strings {
444 if value.chars().any(char::is_control) {
445 return Err(invalid(
446 &rule.name,
447 format!("{field} must not contain control characters"),
448 ));
449 }
450 if value.trim().is_empty() {
451 return Err(invalid(&rule.name, format!("{field} must not be empty")));
452 }
453 if value.chars().count() > MAX_NAME_CHARS {
454 return Err(invalid(
455 &rule.name,
456 format!("{field} must be at most {MAX_NAME_CHARS} characters"),
457 ));
458 }
459 }
460 for action in &rule.actions {
461 if let Action::Tag(keyword) = action {
462 check_keyword(keyword)
463 .map_err(|reason| invalid(&rule.name, format!("tag {reason}")))?;
464 }
465 }
466 Ok(CompiledRule {
467 rule: rule.clone(),
468 first_seen_at: 0,
469 conditions: compile_match(&rule.name, &rule.matches, "match", 0)?,
470 })
471}
472
473fn compile_match(
474 rule: &str,
475 m: &Match,
476 path: &str,
477 depth: usize,
478) -> Result<Conditions, RulesError> {
479 if depth > MAX_DEPTH {
480 return Err(invalid(
481 rule,
482 format!("{path} is nested deeper than {MAX_DEPTH}"),
483 ));
484 }
485 let headers = m
486 .header
487 .as_ref()
488 .map(OneOrMany::as_slice)
489 .unwrap_or_default();
490 let has_condition = m.from.is_some()
491 || m.to.is_some()
492 || m.cc.is_some()
493 || m.subject.is_some()
494 || m.body.is_some()
495 || !headers.is_empty()
496 || m.older_than.is_some()
497 || m.larger_than.is_some()
498 || m.seen.is_some()
499 || m.flagged.is_some()
500 || m.to_me.is_some()
501 || m.alias.is_some()
502 || m.tag.is_some()
503 || m.none.is_some()
504 || m.any.is_some();
505 if !has_condition {
506 let reason = match &m.header {
507 Some(_) => format!("{path}.header must not be empty"),
508 None => format!("{path} needs at least one condition"),
509 };
510 return Err(invalid(rule, reason));
511 }
512 if m.alias
513 .as_ref()
514 .is_some_and(|alias| alias.trim().is_empty())
515 {
516 return Err(invalid(rule, format!("{path}.alias must not be empty")));
517 }
518 if let Some(tag) = &m.tag {
519 check_keyword(tag).map_err(|reason| invalid(rule, format!("{path}.tag {reason}")))?;
520 }
521 let text = |field: &str, t: &Option<TextMatch>| -> Result<Option<Matcher>, RulesError> {
522 t.as_ref()
523 .map(|t| {
524 Matcher::build(
525 rule,
526 &format!("{path}.{field}"),
527 &t.contains,
528 &t.equals,
529 &t.regex,
530 )
531 })
532 .transpose()
533 };
534 let headers = headers
535 .iter()
536 .map(|h| {
537 if h.name.trim().is_empty() {
538 return Err(invalid(
539 rule,
540 format!("{path}.header.name must not be empty"),
541 ));
542 }
543 Matcher::build(
544 rule,
545 &format!("{path}.header"),
546 &h.contains,
547 &h.equals,
548 &h.regex,
549 )
550 .map(|matcher| (h.name.clone(), matcher))
551 })
552 .collect::<Result<_, _>>()?;
553 let older_than = m
554 .older_than
555 .as_ref()
556 .map(|s| {
557 humantime::parse_duration(s)
558 .map_err(|e| invalid(rule, format!("{path}.older_than: {e}")))
559 })
560 .transpose()?;
561 let larger_than = m
562 .larger_than
563 .as_deref()
564 .map(|text| {
565 parse_size(text).ok_or_else(|| {
566 invalid(
567 rule,
568 format!(
569 "{path}.larger_than: expected a size such as 500KB or 5MB, got '{text}'"
570 ),
571 )
572 })
573 })
574 .transpose()?;
575 let alias = m
576 .alias
577 .as_ref()
578 .map(|pattern| {
579 regex::Regex::new(&crate::config::alias_regex(pattern))
580 .map_err(|e| invalid(rule, format!("{path}.alias: {e}")))
581 })
582 .transpose()?;
583 let branches = |key: &str, list: &Option<Vec<Match>>| -> Result<Vec<Conditions>, RulesError> {
584 match list.as_deref() {
585 None => Ok(Vec::new()),
586 Some([]) => Err(invalid(rule, format!("{path}.{key} must not be empty"))),
587 Some(entries) => entries
588 .iter()
589 .enumerate()
590 .map(|(i, entry)| {
591 compile_match(rule, entry, &format!("{path}.{key}[{i}]"), depth + 1)
592 })
593 .collect(),
594 }
595 };
596 let any = branches("any", &m.any)?;
597 let none = branches("none", &m.none)?;
598 Ok(Conditions {
599 from: text("from", &m.from)?,
600 to: text("to", &m.to)?,
601 cc: text("cc", &m.cc)?,
602 subject: text("subject", &m.subject)?,
603 body: text("body", &m.body)?,
604 headers,
605 older_than,
606 larger_than,
607 seen: m.seen,
608 flagged: m.flagged,
609 to_me: m.to_me,
610 alias,
611 tag: m.tag.clone(),
612 none,
613 any,
614 })
615}
616
617fn parse_size(text: &str) -> Option<u64> {
619 let text = text.trim();
620 let digits_end = text
621 .find(|c: char| !c.is_ascii_digit())
622 .unwrap_or(text.len());
623 let (number, unit) = text.split_at(digits_end);
624 let shift = match unit.trim().to_ascii_uppercase().as_str() {
625 "" | "B" => 0,
626 "KB" => 10,
627 "MB" => 20,
628 "GB" => 30,
629 _ => return None,
630 };
631 number.parse::<u64>().ok()?.checked_mul(1 << shift)
632}
633
634pub(crate) fn check_keyword(keyword: &str) -> Result<(), String> {
637 const ATOM_SPECIALS: &[char] = &['(', ')', '{', ' ', '%', '*', '"', '\\', ']'];
638 if keyword.is_empty() {
639 return Err("must not be empty".into());
640 }
641 if keyword.len() > MAX_NAME_CHARS {
642 return Err(format!("must be at most {MAX_NAME_CHARS} characters"));
643 }
644 if !keyword
645 .chars()
646 .all(|c| c.is_ascii_graphic() && !ATOM_SPECIALS.contains(&c))
647 {
648 return Err("must be printable ASCII without spaces, backslashes or ( ) { % * \" ]".into());
649 }
650 if keyword.eq_ignore_ascii_case(engine::RESTORED_KEYWORD) {
651 return Err("is reserved for restored mail".into());
652 }
653 Ok(())
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659
660 #[test]
661 fn schema_file_is_current() {
662 let path = concat!(
663 env!("CARGO_MANIFEST_DIR"),
664 "/../../docs/src/rules.schema.json"
665 );
666 if std::env::var_os("POSTVAK_BLESS").is_some() {
667 std::fs::write(path, schema()).unwrap();
668 }
669 assert_eq!(
670 std::fs::read_to_string(path).unwrap(),
671 schema(),
672 "docs/src/rules.schema.json is stale; run POSTVAK_BLESS=1 cargo test"
673 );
674 }
675
676 #[test]
677 fn schema_describes_rules_and_rejects_unknown_keys() {
678 let text = schema();
679 let schema: serde_json::Value = serde_json::from_str(&text).unwrap();
680 let rule = &schema["$defs"]["Rule"];
681 assert_eq!(rule["additionalProperties"], false);
682 assert!(rule["properties"]["match"].is_object());
683 assert!(
684 !text.contains("mark_unread"),
685 "CLI-only actions stay out of the schema"
686 );
687 }
688
689 pub(crate) const SAMPLE: &str = r#"
690[[rules]]
691name = "purge sign-in codes"
692account = "work"
693match.from = { regex = "no-?reply@" }
694match.subject = { regex = "(?i)sign.?in|verification code|magic link" }
695match.older_than = "1h"
696match.seen = true
697actions = ["delete"]
698
699[[rules]]
700name = "github to folder"
701match.header = { name = "List-Id", contains = "github.com" }
702actions = [{ move = "Lists/GitHub" }, "mark_read"]
703
704[[rules]]
705name = "shop alias"
706enabled = false
707proposed_by = "cli:test"
708match.alias = "*@shop.example.com"
709actions = [{ move = "Shopping" }, "notify"]
710"#;
711
712 #[test]
713 fn parses_sample() {
714 let file = parse(SAMPLE).unwrap();
715 assert_eq!(file.rules.len(), 3);
716 let github = &file.rules[1];
717 assert_eq!(
718 github.actions,
719 vec![Action::Move("Lists/GitHub".into()), Action::MarkRead]
720 );
721 assert_eq!(
722 github.matches.header.as_ref().unwrap().as_slice()[0].name,
723 "List-Id"
724 );
725 assert!(!file.rules[2].enabled);
726 assert_eq!(file.rules[2].proposed_by.as_deref(), Some("cli:test"));
727 }
728
729 #[test]
730 fn compile_sets_defaults() {
731 let compiled = compile(&parse(SAMPLE).unwrap()).unwrap();
732 assert_eq!(compiled[0].folder(), "INBOX");
733 assert!(compiled[0].applies_to_account("work"));
734 assert!(!compiled[0].applies_to_account("home"));
735 assert!(compiled[1].applies_to_account("anything"));
736 assert!(!compiled[0].needs_body());
737 assert_eq!(
738 compiled[0].conditions.older_than,
739 Some(Duration::from_secs(3600))
740 );
741 }
742
743 #[test]
744 fn rejects_bad_regex_with_rule_name() {
745 let bad = SAMPLE.replace("no-?reply@", "(unclosed");
746 match parse(&bad).and_then(|f| compile(&f)) {
747 Err(RulesError::Invalid { rule, reason }) => {
748 assert_eq!(rule, "purge sign-in codes");
749 assert!(reason.contains("regex"), "{reason}");
750 }
751 other => panic!("expected Invalid, got {other:?}"),
752 }
753 }
754
755 #[test]
756 fn rejects_text_match_without_exactly_one_operator() {
757 let two = SAMPLE.replace(
758 r#"{ regex = "no-?reply@" }"#,
759 r#"{ regex = "a", contains = "b" }"#,
760 );
761 assert!(matches!(
762 parse(&two).and_then(|f| compile(&f)),
763 Err(RulesError::Invalid { .. })
764 ));
765 let none = SAMPLE.replace(r#"{ regex = "no-?reply@" }"#, "{ }");
766 assert!(matches!(
767 parse(&none).and_then(|f| compile(&f)),
768 Err(RulesError::Invalid { .. })
769 ));
770 }
771
772 #[test]
773 fn rejects_rule_without_conditions_or_actions_or_unknown_action() {
774 let empty_match = "[[rules]]\nname = \"x\"\nmatch = {}\nactions = [\"delete\"]\n";
775 assert!(matches!(
776 parse(empty_match).and_then(|f| compile(&f)),
777 Err(RulesError::Invalid { .. })
778 ));
779 let no_actions = "[[rules]]\nname = \"x\"\nmatch.seen = true\nactions = []\n";
780 assert!(matches!(
781 parse(no_actions).and_then(|f| compile(&f)),
782 Err(RulesError::Invalid { .. })
783 ));
784 let unknown = "[[rules]]\nname = \"x\"\nmatch.seen = true\nactions = [\"explode\"]\n";
785 assert!(matches!(parse(unknown), Err(RulesError::Parse(_))));
786 }
787
788 #[test]
789 fn enabled_rule_finds_by_name_and_refuses_absent_or_disabled() {
790 let dir = tempfile::tempdir().unwrap();
791 let path = dir.path().join("rules.toml");
792 std::fs::write(&path, SAMPLE).unwrap();
793 let rule = enabled_rule(&path, "github to folder").unwrap();
794 assert_eq!(rule.rule.name, "github to folder");
795 let error = |name| enabled_rule(&path, name).err().map(|e| e.to_string());
796 assert_eq!(error("nope").as_deref(), Some("no rule named 'nope'"));
797 assert_eq!(
798 error("shop alias").as_deref(),
799 Some("rule 'shop alias' is disabled; approve or enable it first")
800 );
801 }
802
803 #[test]
804 fn rejects_bad_duration_and_duplicate_names() {
805 let bad = SAMPLE.replace("\"1h\"", "\"soon\"");
806 assert!(matches!(
807 parse(&bad).and_then(|f| compile(&f)),
808 Err(RulesError::Invalid { .. })
809 ));
810 let dup = SAMPLE.replace(
811 "name = \"github to folder\"",
812 "name = \"purge sign-in codes\"",
813 );
814 assert!(matches!(
815 parse(&dup).and_then(|f| compile(&f)),
816 Err(RulesError::Invalid { .. })
817 ));
818 }
819
820 #[test]
821 fn load_missing_file_is_empty() {
822 let dir = tempfile::tempdir().unwrap();
823 assert!(
824 load(&dir.path().join("rules.toml"))
825 .unwrap()
826 .rules
827 .is_empty()
828 );
829 }
830
831 #[test]
832 fn matcher_semantics() {
833 assert!(Matcher::Contains(vec!["github".into()]).is_match("Lists GitHub Dev"));
834 assert!(Matcher::Equals(vec!["a@b.c".into()]).is_match("A@B.C"));
835 assert!(!Matcher::Equals(vec!["a@b.c".into()]).is_match("xa@b.c"));
836 assert!(Matcher::Regex(regex::Regex::new("^no-?reply").unwrap()).is_match("noreply@x"));
837 }
838
839 #[test]
840 fn equals_ignores_case_beyond_ascii() {
841 let equals = |value: &str| {
842 let wanted = Some(OneOrMany::One(value.to_string()));
843 Matcher::build("r", "match.from", &None, &wanted, &None).unwrap()
844 };
845 assert!(equals("jürgen").is_match("JÜRGEN"));
846 assert!(equals("JÜRGEN").is_match("jürgen"));
847 assert!(equals("jürgen@x.de").is_address_match("Jürgen <JÜRGEN@x.de>"));
848 }
849
850 #[test]
851 fn rejects_unknown_top_level_key() {
852 let typo = "[[rule]]\nname = \"x\"\nmatch.seen = true\nactions = [\"flag\"]\n";
853 assert!(matches!(parse(typo), Err(RulesError::Parse(_))));
854 }
855
856 #[test]
857 fn rejects_empty_values() {
858 let rule = |matcher: &str, action: &str| {
859 format!("[[rules]]\nname = \"x\"\n{matcher}\nactions = [{action}]\n")
860 };
861 let cases = [
862 rule("match.subject = { contains = \"\" }", "\"delete\""),
863 rule("match.subject = { equals = \" \" }", "\"delete\""),
864 rule("match.subject = { regex = \"\" }", "\"delete\""),
865 rule(
866 "match.header = { name = \"\", contains = \"a\" }",
867 "\"delete\"",
868 ),
869 rule("match.seen = true", "{ move = \"\" }"),
870 rule("match.alias = \"\"", "\"delete\""),
871 ];
872 for text in cases {
873 assert!(
874 matches!(
875 parse(&text).and_then(|f| compile(&f)),
876 Err(RulesError::Invalid { .. })
877 ),
878 "{text}"
879 );
880 }
881 }
882
883 #[test]
884 fn rejects_blank_and_overlong_names_and_folders() {
885 let long = "a".repeat(MAX_NAME_CHARS + 1);
886 let rule = |extra: &str, name: &str, target: &str| {
887 format!(
888 "[[rules]]\nname = \"{name}\"\n{extra}match.seen = true\nactions = [{{ move = \"{target}\" }}]\n"
889 )
890 };
891 let cases = [
892 (
893 rule("folder = \"\"\n", "x", "Done"),
894 "folder must not be empty",
895 ),
896 (
897 rule("folder = \" \"\n", "x", "Done"),
898 "folder must not be empty",
899 ),
900 (
901 rule("account = \" \"\n", "x", "Done"),
902 "account must not be empty",
903 ),
904 (
905 rule(&format!("folder = \"{long}\"\n"), "x", "Done"),
906 "folder must be at most 255",
907 ),
908 (rule("", &long, "Done"), "name must be at most 255"),
909 (rule("", "x", &long), "move folder must be at most 255"),
910 ];
911 for (text, want) in cases {
912 match parse(&text).and_then(|f| compile(&f)) {
913 Err(RulesError::Invalid { reason, .. }) => {
914 assert!(reason.contains(want), "{reason} should contain {want}")
915 }
916 other => panic!("expected Invalid ({want}), got {other:?}"),
917 }
918 }
919 let at_limit = "a".repeat(MAX_NAME_CHARS);
921 compile(&parse(&rule("", &at_limit, &at_limit)).unwrap()).unwrap();
922 }
923
924 #[test]
925 fn rejects_control_characters_in_agent_strings() {
926 let cases = [
927 "[[rules]]\nname = \"x\"\nmatch.seen = true\nactions = [{ move = \"Codes\\u001b[2J\" }]\n",
928 "[[rules]]\nname = \"x\\u001b]0;pwned\"\nmatch.seen = true\nactions = [\"flag\"]\n",
929 ];
930 for text in cases {
931 match parse(text).and_then(|f| compile(&f)) {
932 Err(RulesError::Invalid { reason, .. }) => {
933 assert!(reason.contains("control characters"), "{reason}")
934 }
935 other => panic!("expected Invalid for {text}, got {other:?}"),
936 }
937 }
938 }
939
940 const NESTED: &str = r#"
941[[rules]]
942name = "tagged"
943match.from = { contains = "shop" }
944match.none = [{ subject = { contains = ["receipt", "invoice"] } }]
945match.any = [{ to_me = true }, { tag = "$label1" }]
946match.header = [{ name = "List-Id", contains = "shop" }, { name = "X-Mailer", contains = "bulk" }]
947actions = [{ tag = "$label4" }, { move = "Shop" }]
948"#;
949
950 #[test]
951 fn parses_none_any_tag_value_and_header_lists() {
952 let file = parse(NESTED).unwrap();
953 let rule = &file.rules[0];
954 assert_eq!(
955 rule.actions,
956 vec![Action::Tag("$label4".into()), Action::Move("Shop".into())]
957 );
958 assert_eq!(rule.matches.any.as_ref().unwrap().len(), 2);
959 assert_eq!(rule.matches.none.as_ref().unwrap().len(), 1);
960 assert_eq!(rule.matches.header.as_ref().unwrap().as_slice().len(), 2);
961 let compiled = compile(&file).unwrap();
962 assert_eq!(compiled[0].conditions.headers.len(), 2);
963 let again = parse(&toml::to_string(&file).unwrap()).unwrap();
964 assert_eq!(again.rules[0].actions, rule.actions);
965 assert_eq!(again.rules[0].matches.any.as_ref().unwrap().len(), 2);
966 assert_eq!(
967 toml::to_string(&again).unwrap(),
968 toml::to_string(&file).unwrap()
969 );
970 }
971
972 #[test]
973 fn single_header_table_still_parses() {
974 let compiled = compile(&parse(SAMPLE).unwrap()).unwrap();
975 assert_eq!(compiled[1].conditions.headers.len(), 1);
976 }
977
978 #[test]
979 fn nested_matches_are_validated_with_their_path() {
980 let rule =
981 |matcher: &str| format!("[[rules]]\nname = \"x\"\n{matcher}\nactions = [\"flag\"]\n");
982 let deep = format!(
983 "match.none = {}{}",
984 "[{ none = ".repeat(MAX_DEPTH),
985 "[{ seen = true }]".to_string() + &" }]".repeat(MAX_DEPTH)
986 );
987 let cases = [
988 (
989 rule("match.none = [{}]"),
990 "match.none[0] needs at least one condition",
991 ),
992 (rule("match.none = []"), "match.none must not be empty"),
993 (
994 rule("match.subject = { contains = [] }"),
995 "match.subject must not be empty",
996 ),
997 (
998 rule("match.subject = { contains = [\"a\", \" \"] }"),
999 "match.subject must not be empty",
1000 ),
1001 (rule("match.header = []"), "match.header must not be empty"),
1002 (rule("match.any = []"), "match.any must not be empty"),
1003 (
1004 rule("match.any = [{}]"),
1005 "match.any[0] needs at least one condition",
1006 ),
1007 (
1008 rule("match.none = [{ subject = { regex = \"(\" } }]"),
1009 "match.none[0].subject: invalid regex",
1010 ),
1011 (
1012 rule("match.any = [{ seen = true }, { from = { contains = \"\" } }]"),
1013 "match.any[1].from must not be empty",
1014 ),
1015 (rule(&deep), "nested deeper than"),
1016 ];
1017 for (text, want) in cases {
1018 match parse(&text).and_then(|f| compile(&f)) {
1019 Err(RulesError::Invalid { reason, .. }) => {
1020 assert!(reason.contains(want), "{reason} should contain {want}")
1021 }
1022 other => panic!("expected Invalid ({want}), got {other:?}"),
1023 }
1024 }
1025 }
1026
1027 #[test]
1028 fn tags_must_be_imap_keywords() {
1029 let action = |tag: &str| {
1030 format!(
1031 "[[rules]]\nname = \"x\"\nmatch.seen = true\nactions = [{{ tag = \"{tag}\" }}]\n"
1032 )
1033 };
1034 let matcher = |tag: &str| {
1035 format!("[[rules]]\nname = \"x\"\nmatch.tag = \"{tag}\"\nactions = [\"flag\"]\n")
1036 };
1037 for bad in [
1038 "",
1039 "two words",
1040 "\\\\Seen",
1041 "a)b",
1042 "x\\r\\nA1 LOGOUT",
1043 "naïve",
1044 "$PostvakRestored",
1045 "$postvakRESTORED",
1046 ] {
1047 for text in [action(bad), matcher(bad)] {
1048 assert!(
1049 matches!(
1050 parse(&text).and_then(|f| compile(&f)),
1051 Err(RulesError::Invalid { .. })
1052 ),
1053 "{text}"
1054 );
1055 }
1056 }
1057 for good in ["$label1", "Important", "$Junk", "todo-later"] {
1058 compile(&parse(&action(good)).unwrap()).unwrap();
1059 compile(&parse(&matcher(good)).unwrap()).unwrap();
1060 }
1061 }
1062
1063 #[test]
1064 fn larger_than_takes_a_size_with_a_unit() {
1065 let rule = |size: &str| {
1066 format!(
1067 "[[rules]]\nname = \"x\"\nmatch.larger_than = \"{size}\"\nactions = [\"flag\"]\n"
1068 )
1069 };
1070 let size =
1071 |text: &str| compile(&parse(&rule(text)).unwrap()).map(|c| c[0].conditions.larger_than);
1072 assert_eq!(size("500").unwrap(), Some(500));
1073 assert_eq!(size("500B").unwrap(), Some(500));
1074 assert_eq!(size("2kb").unwrap(), Some(2048));
1075 assert_eq!(size("5 MB").unwrap(), Some(5 << 20));
1076 assert_eq!(size("1GB").unwrap(), Some(1 << 30));
1077 for bad in ["", "MB", "5TB", "-1MB", "1.5MB", "99999999999999999999"] {
1078 match size(bad) {
1079 Err(RulesError::Invalid { reason, .. }) => {
1080 assert!(reason.contains("match.larger_than"), "{reason}")
1081 }
1082 other => panic!("expected Invalid for {bad:?}, got {other:?}"),
1083 }
1084 }
1085 }
1086
1087 #[test]
1088 fn flagged_and_stop_parse() {
1089 let text =
1090 "[[rules]]\nname = \"x\"\nmatch.flagged = true\nactions = [\"mark_read\", \"stop\"]\n";
1091 let compiled = compile(&parse(text).unwrap()).unwrap();
1092 assert_eq!(compiled[0].conditions.flagged, Some(true));
1093 assert_eq!(
1094 compiled[0].rule.actions,
1095 vec![Action::MarkRead, Action::Stop]
1096 );
1097 }
1098
1099 #[test]
1100 fn cli_only_actions_are_not_rule_actions() {
1101 for action in ["mark_unread", "trash", "unflag"] {
1102 let text =
1103 format!("[[rules]]\nname = \"x\"\nmatch.seen = true\nactions = [\"{action}\"]\n");
1104 assert!(parse(&text).is_err(), "{action}");
1105 }
1106 }
1107}