Skip to main content

postvak_core/rules/
mod.rs

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    /// Unique name; renaming a rule restarts its clock
46    pub name: String,
47    /// Only for this account; default all accounts
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub account: Option<String>,
50    /// The folder the rule watches; default INBOX
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub folder: Option<String>,
53    /// Disabled rules are skipped; proposals start disabled
54    #[serde(default = "default_true")]
55    pub enabled: bool,
56    /// Who proposed the rule; set by `rules propose`
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub proposed_by: Option<String>,
59    /// Conditions that must all hold; at least one
60    #[serde(rename = "match")]
61    pub matches: Match,
62    /// What to do; at least one
63    pub actions: Vec<Action>,
64}
65
66impl Rule {
67    /// What the rule does, as JSON: editing any of it restarts the rule's clock, so a widened rule never acts on older mail.
68    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    /// The From header
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub from: Option<TextMatch>,
84    /// The To header
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub to: Option<TextMatch>,
87    /// The Cc header
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub cc: Option<TextMatch>,
90    /// The subject
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub subject: Option<TextMatch>,
93    /// The plain-text body; HTML mail is converted
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub body: Option<TextMatch>,
96    /// Any header, by name; a list must all match
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub header: Option<OneOrMany<HeaderMatch>>,
99    /// Arrived at least this long ago, e.g. 30m, 1h, 2days
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub older_than: Option<String>,
102    /// Larger than this size, e.g. 500KB, 5MB; a bare number is bytes and 1KB is 1024 bytes
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub larger_than: Option<String>,
105    /// Read (true) or unread (false)
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub seen: Option<bool>,
108    /// Flagged (true) or not (false)
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub flagged: Option<bool>,
111    /// To, Cc or Delivered-To holds your address or an alias
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub to_me: Option<bool>,
114    /// Sent to this alias; * is a wildcard
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub alias: Option<String>,
117    /// Carries this IMAP keyword, which Thunderbird shows as a tag (e.g. $label1); case-insensitive
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub tag: Option<String>,
120    /// Holds when no entry holds; an entry holds when all its conditions do
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub none: Option<Vec<Match>>,
123    /// Holds when at least one of these holds
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub any: Option<Vec<Match>>,
126}
127
128/// One value, or a list of them
129#[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/// Exactly one of contains, equals, regex
146#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
147#[serde(deny_unknown_fields)]
148pub struct TextMatch {
149    /// Case-insensitive substring; a list matches any of them
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub contains: Option<OneOrMany<String>>,
152    /// The whole value, case-insensitive; on from, to and cc also any single address; a list matches any of them
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub equals: Option<OneOrMany<String>>,
155    /// Rust regex syntax; (?i) makes it case-insensitive
156    #[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    /// Header name, e.g. List-Id
164    pub name: String,
165    /// Case-insensitive substring; a list matches any of them
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub contains: Option<OneOrMany<String>>,
168    /// The whole value, case-insensitive; on from, to and cc also any single address; a list matches any of them
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub equals: Option<OneOrMany<String>>,
171    /// Rust regex syntax; (?i) makes it case-insensitive
172    #[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    /// Back up as .eml locally, then remove from the server; stops later rules
180    Delete,
181    /// Set \Seen
182    MarkRead,
183    /// Set \Flagged
184    Flag,
185    /// Move to the server's Archive folder
186    Archive,
187    /// Notify even when the message was moved
188    Notify,
189    /// Never notify
190    Silent,
191    /// Later rules don't run for this message
192    Stop,
193    #[serde(skip)]
194    MarkUnread,
195    #[serde(skip)]
196    Unflag,
197    /// A user delete: to the Trash folder, or deleted with a backup when there is none or the message is already in it.
198    #[serde(skip)]
199    Trash,
200    /// A user report: tagged $Junk, which trains server spam filters, and moved to the Junk folder.
201    #[serde(skip)]
202    Junk,
203    /// Undoes a wrong `Junk`: tagged $NotJunk and moved to INBOX.
204    #[serde(skip)]
205    NotJunk,
206    /// Move to this folder, creating it if needed
207    #[serde(rename = "move")]
208    Move(String),
209    /// Add this IMAP keyword, which Thunderbird shows as a tag (e.g. $label1)
210    #[serde(rename = "tag")]
211    Tag(String),
212}
213
214impl Action {
215    /// The label of the command a user gave for this action: `delete` sends `Trash` outside the Trash folder.
216    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
247/// JSON Schema of rules.toml; a proposal for `rules propose` is one entry of `rules`.
248pub fn schema() -> String {
249    let schema = schemars::schema_for!(RuleFile);
250    // Schema's own Serialize puts `$schema` and `title` first, unlike `{:#}` on its value. Serializing an
251    // in-memory schema to a String can't fail: no I/O, and every map key is a string.
252    #[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    /// For from, to and cc: `equals` also accepts any single bare address, so a display name or a second recipient
288    /// does not stop the match.
289    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/// A compiled `Match`: every set condition must hold.
351#[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
392/// Longest rule name, account or folder; agents propose rules, so no field may grow without bound.
393const MAX_NAME_CHARS: usize = 255;
394
395/// Deepest nesting of `not` and `any`; Thunderbird's filters need two levels at most.
396const MAX_DEPTH: usize = 8;
397
398/// The compiled rule called `name`, refused when absent or disabled. It keeps `first_seen_at` 0, so it also covers mail
399/// that predates the rule.
400pub 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
617/// Bytes in `500`, `500B`, `2KB`, `5 MB` or `1GB`, ignoring case; units are powers of 1024, as in Thunderbird.
618fn 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
634/// An IMAP keyword is an atom (RFC 3501): printable ASCII without specials. It goes into the STORE command verbatim, so
635/// this check is what keeps a proposed tag from injecting IMAP syntax.
636pub(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        // Exactly at the limit is fine.
920        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}