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}