Skip to main content

sieve_kit/
actions.rs

1//! Action definitions and execution helpers. Actions are value types that
2//! describe what to do; the host application translates them into its own
3//! mail operations. Evaluation and planning here are pure: no I/O.
4
5use crate::eval::{RegexCache, evaluate_rule};
6use crate::types::{Action, FilterRule, Filterable, Flag};
7
8/// The result of evaluating a rule: which message and what actions to apply.
9#[derive(Clone, Debug)]
10pub struct FilterMatch {
11    /// The matched message identifier.
12    pub message_id: String,
13    /// Actions to execute, in rule order.
14    pub actions: Vec<Action>,
15}
16
17impl FilterMatch {
18    /// Create a match for the given message id.
19    #[must_use]
20    pub fn new(message_id: impl Into<String>, actions: Vec<Action>) -> Self {
21        Self {
22            message_id: message_id.into(),
23            actions,
24        }
25    }
26}
27
28/// A single planned action ready for execution by the host application.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub enum PlannedAction {
31    /// Move to folder.
32    Move {
33        /// Destination folder.
34        to: String,
35    },
36    /// Copy to folder.
37    Copy {
38        /// Destination folder.
39        to: String,
40    },
41    /// Add flags (RFC 5232 `addflag`).
42    AddFlags {
43        /// Flags to add.
44        flags: Vec<crate::types::Flag>,
45    },
46    /// Remove flags (RFC 5232 `removeflag`).
47    RemoveFlags {
48        /// Flags to remove.
49        flags: Vec<crate::types::Flag>,
50    },
51    /// Replace the flag set (RFC 5232 `setflag`).
52    SetFlags {
53        /// The new flag set.
54        flags: Vec<crate::types::Flag>,
55    },
56    /// Mark as read.
57    MarkRead,
58    /// Delete (move to trash).
59    Delete,
60    /// Forward to address.
61    Forward {
62        /// Recipient email.
63        to: String,
64    },
65    /// Send an automated reply (RFC 5230 `vacation`). The engine *evaluates*
66    /// the reply — dedup, routing, default subject — but never sends: handing
67    /// it to an SMTP transport is the host's responsibility.
68    Vacation(VacationReply),
69    /// Emit a notification to an external method (RFC 5436 `notify`).
70    /// Delivery is the host's responsibility.
71    Notify {
72        /// Notification method URI (e.g. `mailto:ops@example.com`).
73        method: String,
74        /// Message body (`:message`).
75        message: String,
76    },
77}
78
79/// An evaluated automated reply (RFC 5230 `vacation`), ready for the host's
80/// SMTP layer. The engine computes routing and defaults; the host sends it
81/// and tracks the respond period (see [`VacationTracker`]).
82#[derive(Clone, Debug, PartialEq, Eq)]
83pub struct VacationReply {
84    /// Address to reply to: the envelope sender, falling back to the header
85    /// From address. Unfilled (empty) when the outcome was built by
86    /// [`build_action_plan`] instead of
87    /// [`evaluate_plan`](crate::eval::evaluate_plan).
88    pub to: String,
89    /// Minimum days before another reply to the same sender (`:days`).
90    pub days: u32,
91    /// Resolved subject: the configured `:subject`, else
92    /// `Re: <original subject>`, else `Automated reply`.
93    pub subject: String,
94    /// Configured `:from` override, if any.
95    pub from: Option<String>,
96    /// Response body text.
97    pub message: String,
98}
99
100impl From<&Action> for PlannedAction {
101    fn from(action: &Action) -> Self {
102        match action {
103            Action::MoveTo(to) => Self::Move { to: to.clone() },
104            Action::CopyTo(to) => Self::Copy { to: to.clone() },
105            Action::Flag(flags) => Self::AddFlags {
106                flags: flags.clone(),
107            },
108            Action::Unflag(flags) => Self::RemoveFlags {
109                flags: flags.clone(),
110            },
111            Action::SetFlags(flags) => Self::SetFlags {
112                flags: flags.clone(),
113            },
114            Action::MarkRead => Self::MarkRead,
115            Action::Delete => Self::Delete,
116            Action::Forward(addr) => Self::Forward { to: addr.clone() },
117            Action::Vacation(vacation) => Self::Vacation(VacationReply {
118                // Recipient and default subject are runtime-resolved by
119                // `evaluate_plan`; a pure translation leaves them unfilled.
120                to: String::new(),
121                days: vacation.days,
122                subject: vacation.subject.clone().unwrap_or_default(),
123                from: vacation.from.clone(),
124                message: vacation.message.clone(),
125            }),
126            Action::Notify(notify) => Self::Notify {
127                method: notify.method.clone(),
128                message: notify.message.clone(),
129            },
130        }
131    }
132}
133
134/// Fold flag mutations (RFC 5232 `addflag`/`removeflag`/`setflag`) onto a
135/// current flag set, in plan order. `AddFlags` appends without duplicates,
136/// `RemoveFlags` drops every listed flag, `SetFlags` replaces the set.
137/// [`MarkRead`](PlannedAction::MarkRead) is a host-level concern and is
138/// ignored here. Keywords compare exactly (hosts may normalize case).
139#[must_use]
140pub fn apply_flag_plan(current: &[Flag], plan: &[PlannedAction]) -> Vec<Flag> {
141    let mut flags: Vec<Flag> = current.to_vec();
142    for action in plan {
143        match action {
144            PlannedAction::AddFlags { flags: add } => {
145                for flag in add {
146                    if !flags.contains(flag) {
147                        flags.push(flag.clone());
148                    }
149                }
150            }
151            PlannedAction::RemoveFlags { flags: remove } => {
152                flags.retain(|f| !remove.contains(f));
153            }
154            PlannedAction::SetFlags { flags: set } => {
155                flags = set.clone();
156            }
157            _ => {}
158        }
159    }
160    flags
161}
162
163/// In-memory ledger for vacation respond-once-per-sender-per-period
164/// semantics (RFC 5230). Record a reply when one is sent; query with
165/// [`seen_before`](Self::seen_before) to decide whether another is due.
166/// Thread-safe; usable directly as an
167/// [`EvalContext::seen_before`](crate::eval::EvalContext) predicate.
168///
169/// ```
170/// use sieve_kit::actions::VacationTracker;
171///
172/// let tracker = VacationTracker::new();
173/// assert!(!tracker.seen_before("alice@example.com", 7));
174/// tracker.record("alice@example.com");
175/// assert!(tracker.seen_before("alice@example.com", 7));
176/// assert!(!tracker.seen_before("bob@example.com", 7));
177/// ```
178#[derive(Default)]
179pub struct VacationTracker {
180    entries: std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>>,
181}
182
183impl VacationTracker {
184    /// Create an empty tracker.
185    #[must_use]
186    pub fn new() -> Self {
187        Self::default()
188    }
189
190    /// Whether a reply was recorded for `sender` less than `days` days ago.
191    /// Poisoned state is treated as "nothing recorded" (never panics).
192    #[must_use]
193    pub fn seen_before(&self, sender: &str, days: u32) -> bool {
194        let Ok(entries) = self.entries.lock() else {
195            return false;
196        };
197        entries.get(sender).is_some_and(|recorded| {
198            recorded.elapsed() < std::time::Duration::from_secs(u64::from(days) * 86_400)
199        })
200    }
201
202    /// Record that a reply was sent to `sender`, effective now.
203    pub fn record(&self, sender: &str) {
204        self.record_at(sender, std::time::Instant::now());
205    }
206
207    /// Record a reply with an explicit timestamp — the injection point for
208    /// tests and clock-control.
209    pub fn record_at(&self, sender: &str, at: std::time::Instant) {
210        if let Ok(mut entries) = self.entries.lock() {
211            entries.insert(sender.to_string(), at);
212        }
213    }
214}
215
216/// Translate a list of actions into a plan that the host can execute.
217///
218/// This is a pure function: it collects the actions without performing I/O.
219#[must_use]
220pub fn build_action_plan(actions: &[Action]) -> Vec<PlannedAction> {
221    actions.iter().map(PlannedAction::from).collect()
222}
223
224/// Collect the actions from the first rule matching a message (in the given
225/// rule order — use [`sort_rules_by_priority`](crate::eval::sort_rules_by_priority)
226/// to order by priority first).
227///
228/// If no rule matches, returns an empty vec.
229#[must_use]
230pub fn collect_matches<F: Filterable + ?Sized>(
231    rules: &[FilterRule],
232    msg: &F,
233    regex_cache: &RegexCache,
234) -> Vec<Action> {
235    for rule in rules {
236        if evaluate_rule(rule, msg, regex_cache) {
237            return rule.actions.clone();
238        }
239    }
240    Vec::new()
241}
242
243#[cfg(test)]
244mod tests {
245    #![allow(clippy::unwrap_used, clippy::expect_used)]
246
247    use super::*;
248    use crate::types::{Condition, ConditionField, LogicOp, MailEnvelope, Operator};
249
250    fn test_rule(actions: Vec<Action>) -> FilterRule {
251        FilterRule {
252            id: "test".to_string(),
253            name: "Test".to_string(),
254            enabled: true,
255            priority: 0,
256            conditions: vec![Condition {
257                field: ConditionField::Subject,
258                operator: Operator::Contains,
259                value: "hello".to_string(),
260                negate: false,
261            }],
262            condition_logic: LogicOp::And,
263            actions,
264        }
265    }
266
267    fn make_envelope() -> MailEnvelope {
268        MailEnvelope {
269            subject: "Hello World".to_string(),
270            ..MailEnvelope::default()
271        }
272    }
273
274    #[test]
275    fn collect_matches_returns_first_rule_actions() {
276        let rules = vec![
277            test_rule(vec![Action::MarkRead]),
278            test_rule(vec![Action::Delete]),
279        ];
280        let msg = make_envelope();
281        let cache = RegexCache::default();
282        let matches = collect_matches(&rules, &msg, &cache);
283        assert_eq!(matches.len(), 1);
284        assert_eq!(matches[0], Action::MarkRead);
285    }
286
287    #[test]
288    fn collect_matches_returns_empty_when_no_match() {
289        let rules = vec![test_rule(vec![Action::MarkRead])];
290        let msg = MailEnvelope {
291            subject: "no match here".to_string(),
292            ..MailEnvelope::default()
293        };
294        let cache = RegexCache::default();
295        let matches = collect_matches(&rules, &msg, &cache);
296        assert!(matches.is_empty());
297    }
298
299    #[test]
300    fn build_action_plan_translates_all_variants() {
301        let actions = vec![
302            Action::MoveTo("Archive".to_string()),
303            Action::CopyTo("Keep".to_string()),
304            Action::Flag(vec![crate::types::Flag::Flagged]),
305            Action::MarkRead,
306            Action::Delete,
307            Action::Forward("a@b.com".to_string()),
308        ];
309        let plan = build_action_plan(&actions);
310        assert_eq!(plan.len(), 6);
311        assert_eq!(
312            plan[0],
313            PlannedAction::Move {
314                to: "Archive".to_string()
315            }
316        );
317        assert_eq!(
318            plan[1],
319            PlannedAction::Copy {
320                to: "Keep".to_string()
321            }
322        );
323        assert_eq!(
324            plan[2],
325            PlannedAction::AddFlags {
326                flags: vec![crate::types::Flag::Flagged]
327            }
328        );
329        assert_eq!(plan[3], PlannedAction::MarkRead);
330        assert_eq!(plan[4], PlannedAction::Delete);
331        assert_eq!(
332            plan[5],
333            PlannedAction::Forward {
334                to: "a@b.com".to_string()
335            }
336        );
337    }
338
339    #[test]
340    fn filter_match_new_accepts_any_id() {
341        let m = FilterMatch::new("msg-42", vec![Action::MarkRead]);
342        assert_eq!(m.message_id, "msg-42");
343        assert_eq!(m.actions, vec![Action::MarkRead]);
344    }
345
346    #[test]
347    fn build_action_plan_translates_flag_mutation_variants() {
348        let actions = vec![
349            Action::Flag(vec![Flag::Flagged]),
350            Action::Unflag(vec![Flag::Seen]),
351            Action::SetFlags(vec![Flag::Answered]),
352        ];
353        let plan = build_action_plan(&actions);
354        assert_eq!(
355            plan,
356            vec![
357                PlannedAction::AddFlags {
358                    flags: vec![Flag::Flagged]
359                },
360                PlannedAction::RemoveFlags {
361                    flags: vec![Flag::Seen]
362                },
363                PlannedAction::SetFlags {
364                    flags: vec![Flag::Answered]
365                },
366            ]
367        );
368    }
369
370    #[test]
371    fn build_action_plan_translates_vacation_and_notify() {
372        let plan = build_action_plan(&[
373            Action::Vacation(
374                crate::types::Vacation::new("away")
375                    .with_days(2)
376                    .with_from("me@example.com"),
377            ),
378            Action::Notify(crate::types::Notify::new("mailto:x@y", "ping")),
379        ]);
380        assert_eq!(
381            plan,
382            vec![
383                PlannedAction::Vacation(VacationReply {
384                    // Pure translation leaves the recipient unfilled;
385                    // `evaluate_plan` resolves it from envelope data.
386                    to: String::new(),
387                    days: 2,
388                    subject: String::new(),
389                    from: Some("me@example.com".to_string()),
390                    message: "away".to_string(),
391                }),
392                PlannedAction::Notify {
393                    method: "mailto:x@y".to_string(),
394                    message: "ping".to_string(),
395                },
396            ]
397        );
398    }
399
400    #[test]
401    fn apply_flag_plan_adds_without_duplicates() {
402        let current = vec![Flag::Seen];
403        let plan = build_action_plan(&[Action::Flag(vec![Flag::Seen, Flag::Flagged])]);
404        let flags = apply_flag_plan(&current, &plan);
405        assert_eq!(flags, vec![Flag::Seen, Flag::Flagged]);
406    }
407
408    #[test]
409    fn apply_flag_plan_removes_listed_flags_only() {
410        let current = vec![Flag::Seen, Flag::Flagged, Flag::Keyword("work".into())];
411        let plan = build_action_plan(&[Action::Unflag(vec![
412            Flag::Seen,
413            Flag::Keyword("nope".into()),
414        ])]);
415        let flags = apply_flag_plan(&current, &plan);
416        assert_eq!(flags, vec![Flag::Flagged, Flag::Keyword("work".into())]);
417    }
418
419    #[test]
420    fn apply_flag_plan_set_replaces_whole_set() {
421        let current = vec![Flag::Seen, Flag::Flagged];
422        let plan = build_action_plan(&[Action::SetFlags(vec![Flag::Draft])]);
423        let flags = apply_flag_plan(&current, &plan);
424        assert_eq!(flags, vec![Flag::Draft]);
425    }
426
427    #[test]
428    fn apply_flag_plan_folds_in_order() {
429        let current = vec![];
430        let plan = build_action_plan(&[
431            Action::Flag(vec![Flag::Flagged]),
432            Action::Flag(vec![Flag::Seen]),
433            Action::Unflag(vec![Flag::Flagged]),
434            Action::SetFlags(vec![Flag::Answered, Flag::Draft]),
435        ]);
436        let flags = apply_flag_plan(&current, &plan);
437        assert_eq!(flags, vec![Flag::Answered, Flag::Draft]);
438    }
439
440    #[test]
441    fn apply_flag_plan_ignores_non_flag_actions() {
442        let current = vec![Flag::Seen];
443        let plan = build_action_plan(&[Action::MoveTo("Archive".into()), Action::MarkRead]);
444        let flags = apply_flag_plan(&current, &plan);
445        assert_eq!(flags, vec![Flag::Seen]);
446    }
447
448    #[test]
449    fn vacation_tracker_responds_once_within_period() {
450        let tracker = VacationTracker::new();
451        assert!(!tracker.seen_before("a@b.c", 7));
452        tracker.record("a@b.c");
453        assert!(tracker.seen_before("a@b.c", 7));
454        assert!(!tracker.seen_before("other@b.c", 7));
455        // Period boundary: 7 days exactly is due again, 6 days is not.
456        let now = std::time::Instant::now();
457        tracker.record_at("old@b.c", now - std::time::Duration::from_secs(7 * 86_400));
458        assert!(!tracker.seen_before("old@b.c", 7));
459        tracker.record_at(
460            "recent@b.c",
461            now - std::time::Duration::from_secs(6 * 86_400),
462        );
463        assert!(tracker.seen_before("recent@b.c", 7));
464    }
465}