Skip to main content

sieve_kit/
types.rs

1//! Filter rule types: conditions, operators, actions, and the composite rule
2//! structure. These types are domain-only (no storage/async dependencies) so
3//! they can be evaluated synchronously against any [`Filterable`] message.
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::FilterError;
8
9/// The fields a filter engine needs from a message.
10///
11/// Implement this trait for your message type (or use the provided
12/// [`MailEnvelope`] struct) to evaluate rules against it.
13pub trait Filterable {
14    /// First From address email (empty if absent).
15    fn from(&self) -> &str;
16
17    /// First To address email (empty if absent).
18    fn to(&self) -> &str;
19
20    /// First Cc address email (empty if absent).
21    fn cc(&self) -> &str;
22
23    /// Subject header (empty if absent).
24    fn subject(&self) -> &str;
25
26    /// Body text (empty if absent).
27    fn body(&self) -> &str;
28
29    /// Whether the message has attachments.
30    fn has_attachment(&self) -> bool;
31
32    /// Value of a raw header by name (case-insensitive lookup), if present.
33    fn header(&self, name: &str) -> Option<&str> {
34        let _ = name;
35        None
36    }
37
38    /// Envelope sender (SMTP `MAIL FROM`), when the caller supplies envelope
39    /// data. Used by the [`envelope`](ConditionField::Envelope) test (RFC 5228
40    /// §5.1) and by [`vacation`](Action::Vacation) reply routing.
41    ///
42    /// Returns `None` when envelope data is unavailable; envelope conditions
43    /// then evaluate as non-matching.
44    fn envelope_from(&self) -> Option<&str> {
45        None
46    }
47
48    /// Envelope recipient (SMTP `RCPT TO`), when the caller supplies envelope
49    /// data. See [`envelope_from`](Self::envelope_from).
50    fn envelope_to(&self) -> Option<&str> {
51        None
52    }
53}
54
55/// A complete filter rule: conditions + actions + metadata.
56#[derive(Clone, Debug, Serialize, Deserialize)]
57pub struct FilterRule {
58    /// Stable identifier.
59    pub id: String,
60    /// Human-readable name.
61    pub name: String,
62    /// Whether the rule is active.
63    pub enabled: bool,
64    /// Lower = evaluated first.
65    pub priority: i32,
66    /// Conditions to evaluate.
67    pub conditions: Vec<Condition>,
68    /// How conditions are combined.
69    pub condition_logic: LogicOp,
70    /// Actions to execute when the rule matches.
71    pub actions: Vec<Action>,
72}
73
74impl FilterRule {
75    /// Validate the rule: non-empty `id` and `name`, and every
76    /// [`Operator::Regex`] condition must compile.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`FilterError`] on an empty id/name or an invalid regex
81    /// pattern.
82    pub fn validate(&self) -> Result<(), FilterError> {
83        if self.id.is_empty() {
84            return Err(FilterError::EmptyRuleId);
85        }
86        if self.name.is_empty() {
87            return Err(FilterError::EmptyRuleName);
88        }
89        for condition in &self.conditions {
90            if condition.operator == Operator::Regex {
91                regex::Regex::new(&condition.value).map_err(|source| {
92                    FilterError::InvalidRegex {
93                        pattern: condition.value.clone(),
94                        source,
95                    }
96                })?;
97            }
98        }
99        for action in &self.actions {
100            if let Action::Vacation(vacation) = action {
101                vacation.validate()?;
102            }
103        }
104        Ok(())
105    }
106}
107
108/// Boolean combinator for conditions.
109#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
110pub enum LogicOp {
111    /// All conditions must match.
112    And,
113    /// Any condition may match.
114    Or,
115}
116
117/// A single condition clause.
118#[derive(Clone, Debug, Serialize, Deserialize)]
119pub struct Condition {
120    /// Which message field to test.
121    pub field: ConditionField,
122    /// How to test it.
123    pub operator: Operator,
124    /// The comparison value (interpreted per operator).
125    pub value: String,
126    /// When `true`, invert the result.
127    pub negate: bool,
128}
129
130/// Message fields that conditions can target.
131#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
132pub enum ConditionField {
133    /// First From address email.
134    From,
135    /// First To address email.
136    To,
137    /// Any Cc address email.
138    Cc,
139    /// Subject header.
140    Subject,
141    /// Message body text.
142    Body,
143    /// A specific header by name.
144    Header(String),
145    /// Whether the message has attachments.
146    HasAttachment,
147    /// An SMTP envelope value (RFC 5228 §5.1 `envelope` test).
148    ///
149    /// Resolved from [`Filterable::envelope_from`] /
150    /// [`Filterable::envelope_to`]; when the caller does not supply envelope
151    /// data, the test evaluates as non-matching.
152    Envelope {
153        /// Which envelope value to test.
154        part: EnvelopePart,
155        /// Which portion of the address to compare.
156        address_part: AddressPart,
157    },
158}
159
160/// Which SMTP envelope value an [`envelope`](ConditionField::Envelope) test
161/// reads (RFC 5228 §5.1 string-list `"from"` / `"to"`).
162#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
163pub enum EnvelopePart {
164    /// Envelope sender (`MAIL FROM`), i.e. the return path.
165    From,
166    /// Envelope recipient (`RCPT TO`).
167    To,
168}
169
170/// Which portion of an address an [`envelope`](ConditionField::Envelope) test
171/// compares (RFC 5228 §5.1 `:all` / `:localpart` / `:domain`).
172#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
173pub enum AddressPart {
174    /// The whole address (default in RFC 5228).
175    All,
176    /// Everything before the last `@` (the whole value when there is no `@`).
177    Localpart,
178    /// Everything after the last `@` (empty when there is no `@`).
179    Domain,
180}
181
182/// Comparison operators.
183#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
184pub enum Operator {
185    /// Substring match (case-insensitive).
186    Contains,
187    /// Exact match (case-insensitive).
188    Equals,
189    /// Glob-style match (`*` and `?`).
190    Matches,
191    /// Regular expression (bounded: 100 ms post-check, complexity limit).
192    Regex,
193    /// Field exists and is non-empty.
194    Exists,
195    /// Numeric equality per the `i;ascii-numeric` comparator (RFC 4790):
196    /// both sides must be all-ASCII-digit strings (leading zeros ignored,
197    /// empty equals only empty); any non-numeric value never matches.
198    NumericEquals,
199}
200
201/// An IMAP message flag.
202#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
203pub enum Flag {
204    /// `\Seen` — message has been read.
205    Seen,
206    /// `\Answered` — message has been replied to.
207    Answered,
208    /// `\Flagged` — message is flagged/starred.
209    Flagged,
210    /// `\Deleted` — message is marked for deletion.
211    Deleted,
212    /// `\Draft` — message is a draft.
213    Draft,
214    /// A non-system keyword flag.
215    Keyword(String),
216}
217
218/// An action to perform when a rule matches.
219#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
220pub enum Action {
221    /// Move the message to a folder (`fileinto`).
222    MoveTo(String),
223    /// Copy the message to a folder (original stays).
224    CopyTo(String),
225    /// Add flags to the message (RFC 5232 `addflag`).
226    Flag(Vec<Flag>),
227    /// Remove flags from the message (RFC 5232 `removeflag`).
228    Unflag(Vec<Flag>),
229    /// Replace the message's flag set (RFC 5232 `setflag`).
230    SetFlags(Vec<Flag>),
231    /// Mark as read (adds `\Seen`).
232    MarkRead,
233    /// Delete the message (move to trash, `discard` — host decides).
234    Delete,
235    /// Forward to an email address (`redirect`).
236    Forward(String),
237    /// Send an automated reply, at most once per sender per period
238    /// (`vacation`, RFC 5230). Evaluation produces a
239    /// [`PlannedAction::Vacation`](crate::actions::PlannedAction::Vacation)
240    /// outcome; actual SMTP sending is the host's responsibility.
241    Vacation(Vacation),
242    /// Emit a notification to an external method (`notify`, RFC 5436).
243    /// Evaluation produces a
244    /// [`PlannedAction::Notify`](crate::actions::PlannedAction::Notify)
245    /// outcome; actual delivery is the host's responsibility.
246    Notify(Notify),
247}
248
249/// Configuration for the [`vacation`](Action::Vacation) action (RFC 5230).
250///
251/// `days` defaults to 7 (the RFC default for an omitted `:days`). The
252/// engine resolves the reply-to sender from envelope data at evaluation
253/// time and applies respond-once-per-sender-per-period semantics via the
254/// caller-supplied dedup hook (see
255/// [`EvalContext`](crate::eval::EvalContext)); the outcome carries enough
256/// information (`to`, `days`) for hosts that prefer to dedup themselves.
257#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
258pub struct Vacation {
259    /// Minimum days between responses to the same sender (`:days`).
260    pub days: u32,
261    /// Override subject for the reply (`:subject`); when unset the engine
262    /// uses `Re: <original subject>`.
263    pub subject: Option<String>,
264    /// Override From address for the reply (`:from`).
265    pub from: Option<String>,
266    /// Response body text (the positional `message` argument).
267    pub message: String,
268}
269
270impl Vacation {
271    /// Create a vacation action with the RFC-default 7-day respond period.
272    #[must_use]
273    pub fn new(message: impl Into<String>) -> Self {
274        Self {
275            days: 7,
276            subject: None,
277            from: None,
278            message: message.into(),
279        }
280    }
281
282    /// Set the minimum respond period in days (`:days`).
283    #[must_use]
284    pub fn with_days(mut self, days: u32) -> Self {
285        self.days = days;
286        self
287    }
288
289    /// Set the reply subject override (`:subject`).
290    #[must_use]
291    pub fn with_subject(mut self, subject: impl Into<String>) -> Self {
292        self.subject = Some(subject.into());
293        self
294    }
295
296    /// Set the reply From address override (`:from`).
297    #[must_use]
298    pub fn with_from(mut self, from: impl Into<String>) -> Self {
299        self.from = Some(from.into());
300        self
301    }
302
303    /// Validate the configuration: non-empty message and `days >= 1`.
304    ///
305    /// # Errors
306    ///
307    /// Returns [`FilterError::InvalidVacation`] when the message body is
308    /// empty or the period is zero.
309    pub fn validate(&self) -> Result<(), FilterError> {
310        if self.message.is_empty() {
311            return Err(FilterError::InvalidVacation {
312                reason: "message must not be empty".to_string(),
313            });
314        }
315        if self.days == 0 {
316            return Err(FilterError::InvalidVacation {
317                reason: "days must be at least 1".to_string(),
318            });
319        }
320        Ok(())
321    }
322}
323
324/// Configuration for the [`notify`](Action::Notify) action (RFC 5436).
325///
326/// `method` is a URI such as `"mailto:ops@example.com"` or
327/// `"xmpp:user@host"`. Any string is accepted (construction never fails);
328/// evaluation emits an
329/// [`EvalWarning::UnknownNotifyMethod`](crate::eval::EvalWarning::UnknownNotifyMethod)
330/// when the URI scheme is not one of the recognized notification schemes.
331#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
332pub struct Notify {
333    /// Notification method URI (`:method`).
334    pub method: String,
335    /// Human-readable message body (`:message`); empty means unset.
336    pub message: String,
337}
338
339/// URI schemes recognized as notification methods during evaluation.
340/// `mailto` is defined by RFC 5436; the others are common extension
341/// schemes. Unknown schemes still parse — evaluation just warns.
342pub const KNOWN_NOTIFY_SCHEMES: [&str; 6] = ["mailto", "xmpp", "sms", "tel", "http", "https"];
343
344impl Notify {
345    /// Create a notification action for the given method URI.
346    #[must_use]
347    pub fn new(method: impl Into<String>, message: impl Into<String>) -> Self {
348        Self {
349            method: method.into(),
350            message: message.into(),
351        }
352    }
353
354    /// The URI scheme of [`method`](Self::method) (lowercased), i.e. the
355    /// portion before the first `:`, or `None` when the method has no
356    /// scheme.
357    #[must_use]
358    pub fn scheme(&self) -> Option<String> {
359        let (scheme, _) = self.method.split_once(':')?;
360        let scheme = scheme.to_ascii_lowercase();
361        if scheme.is_empty() {
362            None
363        } else {
364            Some(scheme)
365        }
366    }
367
368    /// Whether the method URI uses a scheme in [`KNOWN_NOTIFY_SCHEMES`].
369    #[must_use]
370    pub fn has_known_scheme(&self) -> bool {
371        self.scheme()
372            .is_some_and(|s| KNOWN_NOTIFY_SCHEMES.contains(&s.as_str()))
373    }
374}
375
376/// Extracted field values from a message for condition evaluation.
377///
378/// A concrete [`Filterable`]: build one from your message, or evaluate
379/// directly against your own [`Filterable`] implementation.
380#[derive(Clone, Debug, Default)]
381pub struct FieldValues<'a> {
382    /// From address email (first).
383    pub from: &'a str,
384    /// To address email (first).
385    pub to: &'a str,
386    /// Cc address email (first, if any).
387    pub cc: &'a str,
388    /// Subject.
389    pub subject: &'a str,
390    /// Body text.
391    pub body: &'a str,
392    /// Whether the message has attachments.
393    pub has_attachment: bool,
394    /// Raw headers (name -> value) for `Header` conditions.
395    pub headers: Vec<(&'a str, &'a str)>,
396    /// Envelope sender (SMTP `MAIL FROM`) for `Envelope` conditions and
397    /// vacation reply routing; `None` when the caller has no envelope data.
398    pub envelope_from: Option<&'a str>,
399    /// Envelope recipient (SMTP `RCPT TO`); `None` when the caller has no
400    /// envelope data.
401    pub envelope_to: Option<&'a str>,
402}
403
404impl Filterable for FieldValues<'_> {
405    fn from(&self) -> &str {
406        self.from
407    }
408
409    fn to(&self) -> &str {
410        self.to
411    }
412
413    fn cc(&self) -> &str {
414        self.cc
415    }
416
417    fn subject(&self) -> &str {
418        self.subject
419    }
420
421    fn body(&self) -> &str {
422        self.body
423    }
424
425    fn has_attachment(&self) -> bool {
426        self.has_attachment
427    }
428
429    fn header(&self, name: &str) -> Option<&str> {
430        self.headers
431            .iter()
432            .find(|(k, _)| k.eq_ignore_ascii_case(name))
433            .map(|(_, v)| *v)
434    }
435
436    fn envelope_from(&self) -> Option<&str> {
437        self.envelope_from
438    }
439
440    fn envelope_to(&self) -> Option<&str> {
441        self.envelope_to
442    }
443}
444
445/// A simple envelope of the fields a filter engine needs.
446///
447/// A ready-made [`Filterable`] implementation for callers that do not want to
448/// implement the trait for their own message type.
449#[derive(Clone, Debug, Default, Serialize, Deserialize)]
450pub struct MailEnvelope {
451    /// First From address email.
452    pub from: String,
453    /// First To address email.
454    pub to: String,
455    /// First Cc address email.
456    pub cc: String,
457    /// Subject header.
458    pub subject: String,
459    /// Body text.
460    pub body: String,
461    /// Whether the message has attachments.
462    pub has_attachment: bool,
463    /// Raw headers (name -> value) for `Header` conditions.
464    pub headers: Vec<(String, String)>,
465    /// Envelope sender (SMTP `MAIL FROM`) for `Envelope` conditions and
466    /// vacation reply routing; `None` when not supplied.
467    pub envelope_from: Option<String>,
468    /// Envelope recipient (SMTP `RCPT TO`); `None` when not supplied.
469    pub envelope_to: Option<String>,
470}
471
472impl Filterable for MailEnvelope {
473    fn from(&self) -> &str {
474        &self.from
475    }
476
477    fn to(&self) -> &str {
478        &self.to
479    }
480
481    fn cc(&self) -> &str {
482        &self.cc
483    }
484
485    fn subject(&self) -> &str {
486        &self.subject
487    }
488
489    fn body(&self) -> &str {
490        &self.body
491    }
492
493    fn has_attachment(&self) -> bool {
494        self.has_attachment
495    }
496
497    fn header(&self, name: &str) -> Option<&str> {
498        self.headers
499            .iter()
500            .find(|(k, _)| k.eq_ignore_ascii_case(name))
501            .map(|(_, v)| v.as_str())
502    }
503
504    fn envelope_from(&self) -> Option<&str> {
505        self.envelope_from.as_deref()
506    }
507
508    fn envelope_to(&self) -> Option<&str> {
509        self.envelope_to.as_deref()
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    #![allow(clippy::unwrap_used, clippy::expect_used)]
516
517    use super::*;
518
519    #[test]
520    fn vacation_validate_rejects_empty_message() {
521        let vacation = Vacation::new("");
522        assert!(matches!(
523            vacation.validate(),
524            Err(FilterError::InvalidVacation { reason }) if reason.contains("message")
525        ));
526        assert!(Vacation::new("body").validate().is_ok());
527    }
528
529    #[test]
530    fn vacation_validate_rejects_zero_days() {
531        let vacation = Vacation::new("body").with_days(0);
532        assert!(matches!(
533            vacation.validate(),
534            Err(FilterError::InvalidVacation { reason }) if reason.contains("days")
535        ));
536        assert!(vacation.with_days(1).validate().is_ok());
537    }
538
539    #[test]
540    fn vacation_builders_set_fields_and_defaults() {
541        let vacation = Vacation::new("away")
542            .with_days(3)
543            .with_subject("OOO")
544            .with_from("me@example.com");
545        assert_eq!(vacation.days, 3);
546        assert_eq!(vacation.subject.as_deref(), Some("OOO"));
547        assert_eq!(vacation.from.as_deref(), Some("me@example.com"));
548        assert_eq!(vacation.message, "away");
549        // RFC 5230 default when :days is omitted.
550        assert_eq!(Vacation::new("x").days, 7);
551    }
552
553    #[test]
554    fn rule_validate_checks_vacation_actions() {
555        let rule = FilterRule {
556            id: "r".into(),
557            name: "r".into(),
558            enabled: true,
559            priority: 0,
560            conditions: vec![],
561            condition_logic: LogicOp::And,
562            actions: vec![Action::Vacation(Vacation::new(""))],
563        };
564        assert!(matches!(
565            rule.validate(),
566            Err(FilterError::InvalidVacation { .. })
567        ));
568    }
569
570    #[test]
571    fn notify_scheme_extraction() {
572        assert_eq!(
573            Notify::new("MAILTO:x@y", "").scheme().as_deref(),
574            Some("mailto")
575        );
576        assert_eq!(
577            Notify::new("xmpp:user@host", "").scheme().as_deref(),
578            Some("xmpp")
579        );
580        assert_eq!(Notify::new("no-scheme", "").scheme(), None);
581        assert_eq!(Notify::new(":empty-scheme", "").scheme(), None);
582        assert!(Notify::new("mailto:x@y", "").has_known_scheme());
583        assert!(Notify::new("https://hook.example", "").has_known_scheme());
584        assert!(!Notify::new("carrier-pigeon:perth", "").has_known_scheme());
585    }
586
587    #[test]
588    fn filterable_envelope_defaults_to_none() {
589        #[allow(dead_code)]
590        struct Bare;
591        impl Filterable for Bare {
592            fn from(&self) -> &str {
593                ""
594            }
595            fn to(&self) -> &str {
596                ""
597            }
598            fn cc(&self) -> &str {
599                ""
600            }
601            fn subject(&self) -> &str {
602                ""
603            }
604            fn body(&self) -> &str {
605                ""
606            }
607            fn has_attachment(&self) -> bool {
608                false
609            }
610        }
611        let bare = Bare;
612        assert_eq!(bare.envelope_from(), None);
613        assert_eq!(bare.envelope_to(), None);
614    }
615}