Skip to main content

mailrs_sieve/
lib.rs

1//! Clean delivery-action wrapper over Stalwart's [`sieve`] crate
2//! (RFC 5228 Sieve email filtering language + the common
3//! extensions: `fileinto`, `vacation`, `reject`, `redirect`, …).
4//!
5//! Stalwart's `sieve` crate exposes a low-level event-loop API
6//! (`Runtime::filter(msg).run(input)` returning a stream of
7//! [`sieve::Event`] variants the caller has to translate into
8//! their own delivery actions). That's powerful but every consumer
9//! ends up writing the same translation layer. This crate IS that
10//! translation layer, plus the small extras a real MTA needs:
11//!
12//! - `created_messages` tracking so `vacation` / `notify` reply
13//!   bodies are matched to their `SendMessage` events
14//! - Envelope (`From` / `To`) injection for `:envelope` tests and
15//!   vacation auto-reply addressing
16//! - One [`SieveAction`] enum the caller pattern-matches against
17//!   in its delivery loop
18//!
19//! Pure compile + evaluate: no I/O, no async. Plug into any
20//! script-storage layer (PG, file, in-memory) and any delivery
21//! backend (Maildir, IMAP, JMAP).
22//!
23//! ## Quick start
24//!
25//! ```no_run
26//! use mailrs_sieve::{compile_sieve, evaluate_sieve_with_envelope, SieveAction};
27//!
28//! let script = "fileinto \"INBOX/spam\";";
29//! let compiled = compile_sieve(script).unwrap();
30//! let message = b"From: a@b.c\r\nSubject: t\r\n\r\nbody";
31//! let actions = evaluate_sieve_with_envelope(
32//!     &compiled, message, Some("a@b.c"), Some("me@d.e")
33//! );
34//! assert!(matches!(actions[0], SieveAction::FileInto(ref f) if f == "INBOX/spam"));
35//! ```
36
37#![deny(missing_docs)]
38#![deny(rustdoc::broken_intra_doc_links)]
39
40use std::collections::HashMap;
41use std::sync::Arc;
42
43use sieve::{Compiler, Event, Input, Recipient, Runtime, Sieve as CompiledSieve};
44
45/// One delivery decision produced by [`evaluate_sieve_with_envelope`].
46/// The caller's delivery loop pattern-matches and executes the
47/// corresponding storage / forwarding / DSN action.
48#[derive(Debug, Clone)]
49pub enum SieveAction {
50    /// Default action: deliver to the user's inbox. Emitted when
51    /// the script ran to completion without setting any explicit
52    /// action (RFC 5228 §2.10.5 implicit keep).
53    Keep,
54    /// `fileinto "<folder>"` — deliver into the named folder.
55    /// The string is verbatim from the script; caller maps to its
56    /// own folder identifier (Maildir path, IMAP mailbox, …).
57    FileInto(String),
58    /// `discard` — silently drop.
59    Discard,
60    /// `redirect "<addr>"` — forward to another address.
61    /// Caller's outbound MTA queues the forward.
62    Redirect(String),
63    /// `reject "<reason>"` — DSN-reject back to the sender per
64    /// RFC 5429 §2.1. String is the reason text.
65    Reject(String),
66    /// Vacation auto-reply emitted by the `vacation` extension
67    /// (RFC 5230). `(recipient, full RFC 5322 message body)` —
68    /// caller SMTP-submits the body to the recipient.
69    Vacation(String, Vec<u8>),
70}
71
72/// compile a Sieve script, returning the compiled form
73pub fn compile_sieve(script: &str) -> Result<Arc<CompiledSieve>, String> {
74    let compiler = Compiler::new();
75    compiler
76        .compile(script.as_bytes())
77        .map(Arc::new)
78        .map_err(|e| format!("{e}"))
79}
80
81/// evaluate a compiled Sieve script against a message
82#[allow(dead_code)]
83pub fn evaluate_sieve(compiled: &Arc<CompiledSieve>, message: &[u8]) -> Vec<SieveAction> {
84    evaluate_sieve_with_envelope(compiled, message, None, None)
85}
86
87/// evaluate a compiled Sieve script against a message with envelope information
88/// for vacation/auto-reply support
89pub fn evaluate_sieve_with_envelope(
90    compiled: &Arc<CompiledSieve>,
91    message: &[u8],
92    envelope_from: Option<&str>,
93    envelope_to: Option<&str>,
94) -> Vec<SieveAction> {
95    let runtime = Runtime::new();
96    let mut ctx = runtime.filter(message);
97    if let Some(from) = envelope_from {
98        ctx.set_envelope(sieve::Envelope::From, from);
99    }
100    if let Some(to) = envelope_to {
101        ctx.set_envelope(sieve::Envelope::To, to);
102    }
103
104    let input = Input::Script {
105        name: sieve::Script::Personal("main".into()),
106        script: compiled.clone(),
107    };
108
109    let mut actions = Vec::new();
110    // track messages created by vacation/notify actions
111    let mut created_messages: HashMap<usize, Vec<u8>> = HashMap::new();
112    let mut result = ctx.run(input);
113
114    loop {
115        match result {
116            Some(Ok(Event::Keep { .. })) => {
117                actions.push(SieveAction::Keep);
118                break;
119            }
120            Some(Ok(Event::Discard)) => {
121                actions.push(SieveAction::Discard);
122                break;
123            }
124            Some(Ok(Event::FileInto { folder, .. })) => {
125                actions.push(SieveAction::FileInto(folder));
126                result = ctx.run(Input::True);
127            }
128            Some(Ok(Event::SendMessage {
129                recipient,
130                message_id,
131                ..
132            })) => {
133                let addr = match recipient {
134                    Recipient::Address(a) => a,
135                    Recipient::List(l) => l,
136                    Recipient::Group(g) => g.into_iter().next().unwrap_or_default(),
137                };
138                // if the message_id references a created message (vacation/notify),
139                // emit Vacation with the generated reply body
140                if let Some(body) = created_messages.remove(&message_id) {
141                    actions.push(SieveAction::Vacation(addr, body));
142                } else {
143                    actions.push(SieveAction::Redirect(addr));
144                }
145                result = ctx.run(Input::True);
146            }
147            Some(Ok(Event::Reject { reason, .. })) => {
148                actions.push(SieveAction::Reject(reason));
149                break;
150            }
151            Some(Ok(Event::CreatedMessage {
152                message_id,
153                message,
154            })) => {
155                created_messages.insert(message_id, message);
156                result = ctx.run(Input::True);
157            }
158            Some(Ok(Event::ListContains { .. })) => {
159                result = ctx.run(Input::False);
160            }
161            Some(Ok(Event::DuplicateId { .. })) => {
162                result = ctx.run(Input::False);
163            }
164            Some(Ok(Event::MailboxExists { .. })) => {
165                result = ctx.run(Input::True);
166            }
167            Some(Ok(Event::IncludeScript { .. })) => {
168                result = ctx.run(Input::False);
169            }
170            Some(Ok(Event::SetEnvelope { .. })) => {
171                result = ctx.run(Input::True);
172            }
173            Some(Ok(Event::Function { .. })) => {
174                result = ctx.run(Input::True);
175            }
176            Some(Ok(_)) => {
177                result = ctx.run(Input::True);
178            }
179            Some(Err(_)) => break,
180            None => break,
181        }
182    }
183
184    if actions.is_empty() {
185        actions.push(SieveAction::Keep);
186    }
187
188    actions
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    const MSG: &[u8] = b"From: sender@example.com\r\nTo: rcpt@example.com\r\nSubject: Test\r\nDate: Mon, 01 Jan 2024 00:00:00 +0000\r\n\r\nHello world";
196
197    #[test]
198    fn compile_valid_keep_script() {
199        assert!(compile_sieve("require \"fileinto\";\nkeep;").is_ok());
200    }
201
202    #[test]
203    fn compile_invalid_syntax() {
204        assert!(compile_sieve("this is not valid sieve {{{").is_err());
205    }
206
207    #[test]
208    fn default_keep_empty_body() {
209        let compiled = compile_sieve("require \"fileinto\";").unwrap();
210        let actions = evaluate_sieve(&compiled, MSG);
211        assert_eq!(actions.len(), 1);
212        assert!(matches!(actions[0], SieveAction::Keep));
213    }
214
215    #[test]
216    fn fileinto_junk() {
217        let compiled = compile_sieve("require \"fileinto\";\nfileinto \"Junk\";").unwrap();
218        let actions = evaluate_sieve(&compiled, MSG);
219        assert!(actions
220            .iter()
221            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Junk")));
222    }
223
224    #[test]
225    fn discard_action() {
226        let compiled = compile_sieve("discard;").unwrap();
227        let actions = evaluate_sieve(&compiled, MSG);
228        assert_eq!(actions.len(), 1);
229        assert!(matches!(actions[0], SieveAction::Discard));
230    }
231
232    #[test]
233    fn reject_action() {
234        let compiled = compile_sieve("require \"reject\";\nreject \"Go away\";").unwrap();
235        let actions = evaluate_sieve(&compiled, MSG);
236        assert_eq!(actions.len(), 1);
237        assert!(matches!(actions[0], SieveAction::Reject(ref r) if r == "Go away"));
238    }
239
240    #[test]
241    fn redirect_action() {
242        let compiled = compile_sieve("redirect \"fwd@example.com\";").unwrap();
243        let actions = evaluate_sieve(&compiled, MSG);
244        assert!(actions
245            .iter()
246            .any(|a| matches!(a, SieveAction::Redirect(addr) if addr == "fwd@example.com")));
247    }
248
249    #[test]
250    fn header_contains_match() {
251        let script = r#"
252            require "fileinto";
253            if header :contains "Subject" "Test" {
254                fileinto "Matched";
255            }
256        "#;
257        let compiled = compile_sieve(script).unwrap();
258        let actions = evaluate_sieve(&compiled, MSG);
259        assert!(actions
260            .iter()
261            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Matched")));
262    }
263
264    #[test]
265    fn header_contains_no_match() {
266        let script = r#"
267            require "fileinto";
268            if header :contains "Subject" "spam" {
269                fileinto "Junk";
270            }
271        "#;
272        let compiled = compile_sieve(script).unwrap();
273        let actions = evaluate_sieve(&compiled, MSG);
274        assert_eq!(actions.len(), 1);
275        assert!(matches!(actions[0], SieveAction::Keep));
276    }
277
278    #[test]
279    fn implicit_keep_no_actions() {
280        let compiled = compile_sieve("if false { discard; }").unwrap();
281        let actions = evaluate_sieve(&compiled, MSG);
282        assert!(actions.iter().any(|a| matches!(a, SieveAction::Keep)));
283    }
284
285    #[test]
286    fn if_else_chain() {
287        let script = r#"
288            require "fileinto";
289            if header :contains "Subject" "nope" {
290                fileinto "A";
291            } elsif header :contains "Subject" "Test" {
292                fileinto "B";
293            } else {
294                fileinto "C";
295            }
296        "#;
297        let compiled = compile_sieve(script).unwrap();
298        let actions = evaluate_sieve(&compiled, MSG);
299        assert!(actions
300            .iter()
301            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "B")));
302    }
303
304    #[test]
305    fn multi_action_fileinto_keep() {
306        let script = r#"
307            require "fileinto";
308            fileinto "Archive";
309            keep;
310        "#;
311        let compiled = compile_sieve(script).unwrap();
312        let actions = evaluate_sieve(&compiled, MSG);
313        assert!(actions.len() >= 2);
314        assert!(actions
315            .iter()
316            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Archive")));
317        assert!(actions.iter().any(|a| matches!(a, SieveAction::Keep)));
318    }
319
320    #[test]
321    fn empty_script() {
322        let compiled = compile_sieve("");
323        // empty script either compiles to implicit keep or fails; both are acceptable
324        if let Ok(compiled) = compiled {
325            let actions = evaluate_sieve(&compiled, MSG);
326            assert!(!actions.is_empty());
327            assert!(actions.iter().any(|a| matches!(a, SieveAction::Keep)));
328        }
329    }
330
331    #[test]
332    fn whitespace_only_script() {
333        let compiled = compile_sieve("   \n\n  ");
334        if let Ok(compiled) = compiled {
335            let actions = evaluate_sieve(&compiled, MSG);
336            assert!(actions.iter().any(|a| matches!(a, SieveAction::Keep)));
337        }
338    }
339
340    #[test]
341    fn size_over_match() {
342        // MSG is small (~100 bytes), so size :over 10 should match
343        let script = r#"
344            require "fileinto";
345            if size :over 10 {
346                fileinto "Big";
347            }
348        "#;
349        let compiled = compile_sieve(script).unwrap();
350        let actions = evaluate_sieve(&compiled, MSG);
351        assert!(actions
352            .iter()
353            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Big")));
354    }
355
356    #[test]
357    fn size_over_no_match() {
358        // MSG is small, so size :over 1M should not match
359        let script = r#"
360            require "fileinto";
361            if size :over 1M {
362                fileinto "Huge";
363            }
364        "#;
365        let compiled = compile_sieve(script).unwrap();
366        let actions = evaluate_sieve(&compiled, MSG);
367        assert!(actions.iter().any(|a| matches!(a, SieveAction::Keep)));
368        assert!(!actions
369            .iter()
370            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Huge")));
371    }
372
373    #[test]
374    fn size_under_match() {
375        let script = r#"
376            require "fileinto";
377            if size :under 1M {
378                fileinto "Small";
379            }
380        "#;
381        let compiled = compile_sieve(script).unwrap();
382        let actions = evaluate_sieve(&compiled, MSG);
383        assert!(actions
384            .iter()
385            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Small")));
386    }
387
388    #[test]
389    fn header_is_exact_match() {
390        let script = r#"
391            require "fileinto";
392            if header :is "Subject" "Test" {
393                fileinto "Exact";
394            }
395        "#;
396        let compiled = compile_sieve(script).unwrap();
397        let actions = evaluate_sieve(&compiled, MSG);
398        assert!(actions
399            .iter()
400            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Exact")));
401    }
402
403    #[test]
404    fn header_is_no_match() {
405        let script = r#"
406            require "fileinto";
407            if header :is "Subject" "Test message" {
408                fileinto "Exact";
409            }
410        "#;
411        let compiled = compile_sieve(script).unwrap();
412        let actions = evaluate_sieve(&compiled, MSG);
413        assert!(!actions
414            .iter()
415            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Exact")));
416    }
417
418    #[test]
419    fn header_matches_wildcard() {
420        let script = r#"
421            require "fileinto";
422            if header :matches "Subject" "T*" {
423                fileinto "Wild";
424            }
425        "#;
426        let compiled = compile_sieve(script).unwrap();
427        let actions = evaluate_sieve(&compiled, MSG);
428        assert!(actions
429            .iter()
430            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Wild")));
431    }
432
433    #[test]
434    fn exists_test_present_header() {
435        let script = r#"
436            require "fileinto";
437            if exists "Subject" {
438                fileinto "HasSubject";
439            }
440        "#;
441        let compiled = compile_sieve(script).unwrap();
442        let actions = evaluate_sieve(&compiled, MSG);
443        assert!(actions
444            .iter()
445            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "HasSubject")));
446    }
447
448    #[test]
449    fn exists_test_missing_header() {
450        let script = r#"
451            require "fileinto";
452            if exists "X-Custom-Missing" {
453                fileinto "HasCustom";
454            }
455        "#;
456        let compiled = compile_sieve(script).unwrap();
457        let actions = evaluate_sieve(&compiled, MSG);
458        assert!(!actions
459            .iter()
460            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "HasCustom")));
461    }
462
463    #[test]
464    fn not_condition() {
465        let script = r#"
466            require "fileinto";
467            if not header :contains "Subject" "spam" {
468                fileinto "NotSpam";
469            }
470        "#;
471        let compiled = compile_sieve(script).unwrap();
472        let actions = evaluate_sieve(&compiled, MSG);
473        assert!(actions
474            .iter()
475            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "NotSpam")));
476    }
477
478    #[test]
479    fn allof_both_true() {
480        let script = r#"
481            require "fileinto";
482            if allof (header :contains "Subject" "Test", header :contains "From" "sender") {
483                fileinto "Both";
484            }
485        "#;
486        let compiled = compile_sieve(script).unwrap();
487        let actions = evaluate_sieve(&compiled, MSG);
488        assert!(actions
489            .iter()
490            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Both")));
491    }
492
493    #[test]
494    fn allof_one_false() {
495        let script = r#"
496            require "fileinto";
497            if allof (header :contains "Subject" "Test", header :contains "From" "nobody") {
498                fileinto "Both";
499            }
500        "#;
501        let compiled = compile_sieve(script).unwrap();
502        let actions = evaluate_sieve(&compiled, MSG);
503        assert!(!actions
504            .iter()
505            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Both")));
506    }
507
508    #[test]
509    fn anyof_one_true() {
510        let script = r#"
511            require "fileinto";
512            if anyof (header :contains "Subject" "nope", header :contains "From" "sender") {
513                fileinto "Either";
514            }
515        "#;
516        let compiled = compile_sieve(script).unwrap();
517        let actions = evaluate_sieve(&compiled, MSG);
518        assert!(actions
519            .iter()
520            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Either")));
521    }
522
523    #[test]
524    fn anyof_none_true() {
525        let script = r#"
526            require "fileinto";
527            if anyof (header :contains "Subject" "nope", header :contains "From" "nobody") {
528                fileinto "Either";
529            }
530        "#;
531        let compiled = compile_sieve(script).unwrap();
532        let actions = evaluate_sieve(&compiled, MSG);
533        assert!(!actions
534            .iter()
535            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Either")));
536    }
537
538    #[test]
539    fn address_test() {
540        let script = r#"
541            require "fileinto";
542            if address :contains "From" "sender" {
543                fileinto "FromSender";
544            }
545        "#;
546        let compiled = compile_sieve(script).unwrap();
547        let actions = evaluate_sieve(&compiled, MSG);
548        assert!(actions
549            .iter()
550            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "FromSender")));
551    }
552
553    #[test]
554    fn multiple_redirects() {
555        // sieve-rs may deduplicate or stop after first redirect per RFC 5228 §2.10.3
556        let script = r#"
557            redirect "a@example.com";
558            redirect "b@example.com";
559        "#;
560        let compiled = compile_sieve(script).unwrap();
561        let actions = evaluate_sieve(&compiled, MSG);
562        let redirects: Vec<_> = actions
563            .iter()
564            .filter(|a| matches!(a, SieveAction::Redirect(_)))
565            .collect();
566        assert!(!redirects.is_empty());
567    }
568
569    #[test]
570    fn evaluate_with_minimal_message() {
571        // bare minimum: just headers with no body
572        let minimal = b"From: a@b.c\r\n\r\n";
573        let compiled = compile_sieve("keep;").unwrap();
574        let actions = evaluate_sieve(&compiled, minimal);
575        assert!(actions.iter().any(|a| matches!(a, SieveAction::Keep)));
576    }
577
578    #[test]
579    fn evaluate_with_empty_message() {
580        let compiled = compile_sieve("keep;").unwrap();
581        let actions = evaluate_sieve(&compiled, b"");
582        assert!(!actions.is_empty());
583    }
584
585    #[test]
586    fn sieve_action_debug_clone() {
587        let action = SieveAction::FileInto("test".to_string());
588        let cloned = action.clone();
589        // verify Debug is implemented
590        let debug_str = format!("{:?}", cloned);
591        assert!(debug_str.contains("FileInto"));
592    }
593
594    #[test]
595    fn nested_if_conditions() {
596        let script = r#"
597            require "fileinto";
598            if header :contains "From" "sender" {
599                if header :contains "Subject" "Test" {
600                    fileinto "Nested";
601                }
602            }
603        "#;
604        let compiled = compile_sieve(script).unwrap();
605        let actions = evaluate_sieve(&compiled, MSG);
606        assert!(actions
607            .iter()
608            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Nested")));
609    }
610
611    #[test]
612    fn elsif_fallthrough() {
613        let script = r#"
614            require "fileinto";
615            if header :contains "Subject" "nope" {
616                fileinto "A";
617            } elsif header :contains "Subject" "also_nope" {
618                fileinto "B";
619            } else {
620                fileinto "Fallback";
621            }
622        "#;
623        let compiled = compile_sieve(script).unwrap();
624        let actions = evaluate_sieve(&compiled, MSG);
625        assert!(actions
626            .iter()
627            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Fallback")));
628    }
629
630    #[test]
631    fn stop_halts_processing() {
632        let script = r#"
633            require "fileinto";
634            fileinto "First";
635            stop;
636            fileinto "Second";
637        "#;
638        let compiled = compile_sieve(script).unwrap();
639        let actions = evaluate_sieve(&compiled, MSG);
640        assert!(actions
641            .iter()
642            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "First")));
643        // "Second" should not appear because stop halts
644        assert!(!actions
645            .iter()
646            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Second")));
647    }
648
649    #[test]
650    fn compile_error_message_is_descriptive() {
651        let err = compile_sieve("invalid sieve {{{}}}").unwrap_err();
652        assert!(!err.is_empty(), "error message should not be empty");
653    }
654
655    #[test]
656    fn header_contains_multiple_keys() {
657        // test matching against multiple header names
658        let script = r#"
659            require "fileinto";
660            if header :contains ["Subject", "From"] "example" {
661                fileinto "MultiKey";
662            }
663        "#;
664        let compiled = compile_sieve(script).unwrap();
665        let actions = evaluate_sieve(&compiled, MSG);
666        // "example" appears in From: sender@example.com
667        assert!(actions
668            .iter()
669            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "MultiKey")));
670    }
671
672    #[test]
673    fn fileinto_and_discard() {
674        // fileinto then discard - discard should terminate
675        let script = r#"
676            require "fileinto";
677            fileinto "Archive";
678            discard;
679        "#;
680        let compiled = compile_sieve(script).unwrap();
681        let actions = evaluate_sieve(&compiled, MSG);
682        assert!(actions
683            .iter()
684            .any(|a| matches!(a, SieveAction::FileInto(_))));
685        assert!(actions
686            .iter()
687            .any(|a| matches!(a, SieveAction::Discard)));
688    }
689
690    // --- complex allof/anyof nesting ---
691
692    #[test]
693    fn nested_allof_inside_anyof() {
694        let script = r#"
695            require "fileinto";
696            if anyof (
697                allof (header :contains "Subject" "Test", header :contains "From" "sender"),
698                header :contains "To" "nobody"
699            ) {
700                fileinto "NestedLogic";
701            }
702        "#;
703        let compiled = compile_sieve(script).unwrap();
704        let actions = evaluate_sieve(&compiled, MSG);
705        assert!(actions
706            .iter()
707            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "NestedLogic")));
708    }
709
710    #[test]
711    fn nested_anyof_inside_allof() {
712        let script = r#"
713            require "fileinto";
714            if allof (
715                anyof (header :contains "Subject" "Test", header :contains "Subject" "Hello"),
716                header :contains "From" "sender"
717            ) {
718                fileinto "AnyInAll";
719            }
720        "#;
721        let compiled = compile_sieve(script).unwrap();
722        let actions = evaluate_sieve(&compiled, MSG);
723        assert!(actions
724            .iter()
725            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "AnyInAll")));
726    }
727
728    #[test]
729    fn nested_allof_inside_allof() {
730        let script = r#"
731            require "fileinto";
732            if allof (
733                allof (header :contains "Subject" "Test", exists "From"),
734                allof (header :contains "To" "rcpt", exists "Date")
735            ) {
736                fileinto "DeepAllof";
737            }
738        "#;
739        let compiled = compile_sieve(script).unwrap();
740        let actions = evaluate_sieve(&compiled, MSG);
741        assert!(actions
742            .iter()
743            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "DeepAllof")));
744    }
745
746    #[test]
747    fn nested_anyof_inside_anyof() {
748        let script = r#"
749            require "fileinto";
750            if anyof (
751                anyof (header :contains "Subject" "nope", header :contains "From" "nobody"),
752                anyof (header :contains "To" "missing", header :contains "Subject" "Test")
753            ) {
754                fileinto "DeepAnyof";
755            }
756        "#;
757        let compiled = compile_sieve(script).unwrap();
758        let actions = evaluate_sieve(&compiled, MSG);
759        assert!(actions
760            .iter()
761            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "DeepAnyof")));
762    }
763
764    #[test]
765    fn nested_allof_inside_anyof_all_false() {
766        let script = r#"
767            require "fileinto";
768            if anyof (
769                allof (header :contains "Subject" "nope", header :contains "From" "sender"),
770                allof (header :contains "Subject" "Test", header :contains "From" "nobody")
771            ) {
772                fileinto "ShouldNotMatch";
773            }
774        "#;
775        let compiled = compile_sieve(script).unwrap();
776        let actions = evaluate_sieve(&compiled, MSG);
777        assert!(!actions
778            .iter()
779            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "ShouldNotMatch")));
780    }
781
782    #[test]
783    fn not_with_allof() {
784        let script = r#"
785            require "fileinto";
786            if not allof (header :contains "Subject" "Test", header :contains "From" "nobody") {
787                fileinto "NotAllof";
788            }
789        "#;
790        let compiled = compile_sieve(script).unwrap();
791        let actions = evaluate_sieve(&compiled, MSG);
792        assert!(actions
793            .iter()
794            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "NotAllof")));
795    }
796
797    #[test]
798    fn not_with_anyof() {
799        let script = r#"
800            require "fileinto";
801            if not anyof (header :contains "Subject" "nope", header :contains "From" "nobody") {
802                fileinto "NotAnyof";
803            }
804        "#;
805        let compiled = compile_sieve(script).unwrap();
806        let actions = evaluate_sieve(&compiled, MSG);
807        assert!(actions
808            .iter()
809            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "NotAnyof")));
810    }
811
812    // --- vacation auto-reply ---
813
814    #[test]
815    fn vacation_auto_reply() {
816        let script = r#"
817            require "vacation";
818            vacation :days 7 :subject "Out of office" "I am on vacation until next week.";
819        "#;
820        let compiled = compile_sieve(script).unwrap();
821        let actions = evaluate_sieve_with_envelope(
822            &compiled,
823            MSG,
824            Some("sender@example.com"),
825            Some("rcpt@example.com"),
826        );
827        // vacation should produce a Vacation action with the auto-reply body
828        assert!(
829            actions.iter().any(|a| matches!(a, SieveAction::Vacation(_, body) if !body.is_empty())),
830            "expected Vacation action, got: {actions:?}"
831        );
832    }
833
834    #[test]
835    fn vacation_with_condition() {
836        let script = r#"
837            require ["vacation", "fileinto"];
838            if header :contains "Subject" "Test" {
839                vacation :days 1 :subject "Auto-reply" "Got your test message.";
840            }
841            fileinto "INBOX";
842        "#;
843        let compiled = compile_sieve(script).unwrap();
844        let actions = evaluate_sieve_with_envelope(
845            &compiled,
846            MSG,
847            Some("sender@example.com"),
848            Some("rcpt@example.com"),
849        );
850        assert!(
851            actions.iter().any(|a| matches!(a, SieveAction::Vacation(_, _))),
852            "expected Vacation action, got: {actions:?}"
853        );
854    }
855
856    #[test]
857    fn vacation_from_address() {
858        let script = r#"
859            require "vacation";
860            vacation :days 3
861                     :subject "Away"
862                     :from "noreply@example.com"
863                     "I am currently unavailable.";
864        "#;
865        let compiled = compile_sieve(script).unwrap();
866        let actions = evaluate_sieve_with_envelope(
867            &compiled,
868            MSG,
869            Some("sender@example.com"),
870            Some("rcpt@example.com"),
871        );
872        assert!(
873            actions.iter().any(|a| matches!(a, SieveAction::Vacation(_, _))),
874            "expected Vacation action, got: {actions:?}"
875        );
876    }
877
878    // --- fileinto multiple folders ---
879
880    #[test]
881    fn fileinto_multiple_folders() {
882        let script = r#"
883            require "fileinto";
884            fileinto "Folder1";
885            fileinto "Folder2";
886            fileinto "Folder3";
887        "#;
888        let compiled = compile_sieve(script).unwrap();
889        let actions = evaluate_sieve(&compiled, MSG);
890        let fileinto_count = actions
891            .iter()
892            .filter(|a| matches!(a, SieveAction::FileInto(_)))
893            .count();
894        assert!(fileinto_count >= 3, "expected at least 3 fileinto actions, got {fileinto_count}");
895    }
896
897    #[test]
898    fn fileinto_conditional_multiple_folders() {
899        let script = r#"
900            require "fileinto";
901            if header :contains "From" "sender" {
902                fileinto "FromSender";
903            }
904            if header :contains "Subject" "Test" {
905                fileinto "HasTest";
906            }
907            if header :contains "To" "rcpt" {
908                fileinto "ToRcpt";
909            }
910        "#;
911        let compiled = compile_sieve(script).unwrap();
912        let actions = evaluate_sieve(&compiled, MSG);
913        let folders: Vec<_> = actions
914            .iter()
915            .filter_map(|a| match a {
916                SieveAction::FileInto(f) => Some(f.as_str()),
917                _ => None,
918            })
919            .collect();
920        assert!(folders.contains(&"FromSender"));
921        assert!(folders.contains(&"HasTest"));
922        assert!(folders.contains(&"ToRcpt"));
923    }
924
925    #[test]
926    fn fileinto_with_redirect() {
927        let script = r#"
928            require "fileinto";
929            fileinto "Archive";
930            redirect "backup@example.com";
931        "#;
932        let compiled = compile_sieve(script).unwrap();
933        let actions = evaluate_sieve(&compiled, MSG);
934        assert!(actions
935            .iter()
936            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Archive")));
937        assert!(actions
938            .iter()
939            .any(|a| matches!(a, SieveAction::Redirect(addr) if addr == "backup@example.com")));
940    }
941
942    // --- reject and discard edge cases ---
943
944    #[test]
945    fn reject_with_long_reason() {
946        let reason = "This mailbox does not accept unsolicited messages. \
947                       Please contact the administrator for assistance.";
948        let script = format!(
949            "require \"reject\";\nreject \"{reason}\";",
950        );
951        let compiled = compile_sieve(&script).unwrap();
952        let actions = evaluate_sieve(&compiled, MSG);
953        assert_eq!(actions.len(), 1);
954        assert!(matches!(&actions[0], SieveAction::Reject(r) if r == reason));
955    }
956
957    #[test]
958    fn reject_conditional() {
959        let script = r#"
960            require ["reject", "fileinto"];
961            if header :contains "Subject" "spam" {
962                reject "No spam allowed";
963            } else {
964                fileinto "INBOX";
965            }
966        "#;
967        let compiled = compile_sieve(script).unwrap();
968        let actions = evaluate_sieve(&compiled, MSG);
969        // subject is "Test", not "spam", so should fileinto INBOX
970        assert!(actions
971            .iter()
972            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "INBOX")));
973        assert!(!actions
974            .iter()
975            .any(|a| matches!(a, SieveAction::Reject(_))));
976    }
977
978    #[test]
979    fn discard_conditional() {
980        let script = r#"
981            require "fileinto";
982            if header :contains "From" "spammer" {
983                discard;
984            } else {
985                fileinto "INBOX";
986            }
987        "#;
988        let compiled = compile_sieve(script).unwrap();
989        let actions = evaluate_sieve(&compiled, MSG);
990        assert!(actions
991            .iter()
992            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "INBOX")));
993        assert!(!actions
994            .iter()
995            .any(|a| matches!(a, SieveAction::Discard)));
996    }
997
998    #[test]
999    fn discard_with_matching_condition() {
1000        let script = r#"
1001            if header :contains "Subject" "Test" {
1002                discard;
1003            }
1004        "#;
1005        let compiled = compile_sieve(script).unwrap();
1006        let actions = evaluate_sieve(&compiled, MSG);
1007        assert_eq!(actions.len(), 1);
1008        assert!(matches!(actions[0], SieveAction::Discard));
1009    }
1010
1011    // --- size comparison (over/under) ---
1012
1013    #[test]
1014    fn size_under_no_match() {
1015        // MSG is ~110 bytes, so size :under 10 should not match
1016        let script = r#"
1017            require "fileinto";
1018            if size :under 10 {
1019                fileinto "Tiny";
1020            }
1021        "#;
1022        let compiled = compile_sieve(script).unwrap();
1023        let actions = evaluate_sieve(&compiled, MSG);
1024        assert!(!actions
1025            .iter()
1026            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Tiny")));
1027    }
1028
1029    #[test]
1030    fn size_over_with_k_unit() {
1031        // MSG is ~110 bytes, size :over 1K (1024) should not match
1032        let script = r#"
1033            require "fileinto";
1034            if size :over 1K {
1035                fileinto "OverOneK";
1036            }
1037        "#;
1038        let compiled = compile_sieve(script).unwrap();
1039        let actions = evaluate_sieve(&compiled, MSG);
1040        assert!(!actions
1041            .iter()
1042            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "OverOneK")));
1043    }
1044
1045    #[test]
1046    fn size_combined_with_header_check() {
1047        let script = r#"
1048            require "fileinto";
1049            if allof (size :under 1M, header :contains "Subject" "Test") {
1050                fileinto "SmallTest";
1051            }
1052        "#;
1053        let compiled = compile_sieve(script).unwrap();
1054        let actions = evaluate_sieve(&compiled, MSG);
1055        assert!(actions
1056            .iter()
1057            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "SmallTest")));
1058    }
1059
1060    #[test]
1061    fn size_over_with_large_message() {
1062        // create a message larger than 1K
1063        let mut large_msg = b"From: a@b.c\r\nTo: d@e.f\r\nSubject: Big\r\n\r\n".to_vec();
1064        large_msg.extend(vec![b'X'; 2048]);
1065        let script = r#"
1066            require "fileinto";
1067            if size :over 1K {
1068                fileinto "Large";
1069            }
1070        "#;
1071        let compiled = compile_sieve(script).unwrap();
1072        let actions = evaluate_sieve(&compiled, &large_msg);
1073        assert!(actions
1074            .iter()
1075            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Large")));
1076    }
1077
1078    // --- regex matching ---
1079
1080    #[test]
1081    fn regex_match_subject() {
1082        let script = r#"
1083            require ["fileinto", "regex"];
1084            if header :regex "Subject" "^T[a-z]+$" {
1085                fileinto "Regex";
1086            }
1087        "#;
1088        // regex extension may or may not be supported
1089        match compile_sieve(script) {
1090            Ok(compiled) => {
1091                let actions = evaluate_sieve(&compiled, MSG);
1092                assert!(actions
1093                    .iter()
1094                    .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Regex")));
1095            }
1096            Err(_) => {
1097                // regex not supported by this sieve implementation, that's ok
1098            }
1099        }
1100    }
1101
1102    #[test]
1103    fn regex_match_from_domain() {
1104        let script = r#"
1105            require ["fileinto", "regex"];
1106            if header :regex "From" "example\\.com" {
1107                fileinto "RegexDomain";
1108            }
1109        "#;
1110        match compile_sieve(script) {
1111            Ok(compiled) => {
1112                let actions = evaluate_sieve(&compiled, MSG);
1113                assert!(actions
1114                    .iter()
1115                    .any(|a| matches!(a, SieveAction::FileInto(f) if f == "RegexDomain")));
1116            }
1117            Err(_) => {
1118                // regex not supported
1119            }
1120        }
1121    }
1122
1123    #[test]
1124    fn regex_no_match() {
1125        let script = r#"
1126            require ["fileinto", "regex"];
1127            if header :regex "Subject" "^[0-9]+$" {
1128                fileinto "Numbers";
1129            }
1130        "#;
1131        match compile_sieve(script) {
1132            Ok(compiled) => {
1133                let actions = evaluate_sieve(&compiled, MSG);
1134                assert!(!actions
1135                    .iter()
1136                    .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Numbers")));
1137            }
1138            Err(_) => {
1139                // regex not supported
1140            }
1141        }
1142    }
1143
1144    // --- multi-rule combination scenarios ---
1145
1146    #[test]
1147    fn complex_multi_rule_pipeline() {
1148        let script = r#"
1149            require ["fileinto", "reject"];
1150            if header :contains "From" "blocked@evil.com" {
1151                reject "Blocked sender";
1152            }
1153            if header :contains "Subject" "Test" {
1154                fileinto "Tests";
1155            }
1156            if header :contains "To" "rcpt@example.com" {
1157                fileinto "Personal";
1158            }
1159        "#;
1160        let compiled = compile_sieve(script).unwrap();
1161        let actions = evaluate_sieve(&compiled, MSG);
1162        // from is not blocked, so no reject
1163        assert!(!actions
1164            .iter()
1165            .any(|a| matches!(a, SieveAction::Reject(_))));
1166        // subject matches "Test"
1167        assert!(actions
1168            .iter()
1169            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Tests")));
1170        // to matches
1171        assert!(actions
1172            .iter()
1173            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Personal")));
1174    }
1175
1176    #[test]
1177    fn priority_based_classification() {
1178        // simulate a multi-tier classification system
1179        let script = r#"
1180            require "fileinto";
1181            if header :contains "Subject" "URGENT" {
1182                fileinto "Priority";
1183            } elsif header :contains "Subject" "Test" {
1184                fileinto "Testing";
1185            } elsif header :contains "From" "newsletter" {
1186                fileinto "Newsletters";
1187            } else {
1188                fileinto "General";
1189            }
1190        "#;
1191        let compiled = compile_sieve(script).unwrap();
1192        let actions = evaluate_sieve(&compiled, MSG);
1193        assert!(actions
1194            .iter()
1195            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Testing")));
1196    }
1197
1198    #[test]
1199    fn fileinto_redirect_keep_combination() {
1200        let script = r#"
1201            require "fileinto";
1202            fileinto "Archive";
1203            redirect "copy@example.com";
1204            keep;
1205        "#;
1206        let compiled = compile_sieve(script).unwrap();
1207        let actions = evaluate_sieve(&compiled, MSG);
1208        assert!(actions
1209            .iter()
1210            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Archive")));
1211        assert!(actions
1212            .iter()
1213            .any(|a| matches!(a, SieveAction::Redirect(a) if a == "copy@example.com")));
1214        assert!(actions.iter().any(|a| matches!(a, SieveAction::Keep)));
1215    }
1216
1217    #[test]
1218    fn nested_if_with_multiple_actions() {
1219        let script = r#"
1220            require "fileinto";
1221            if header :contains "From" "sender" {
1222                fileinto "FromMatch";
1223                if header :contains "Subject" "Test" {
1224                    fileinto "SubjectMatch";
1225                    if header :contains "To" "rcpt" {
1226                        fileinto "ToMatch";
1227                    }
1228                }
1229            }
1230        "#;
1231        let compiled = compile_sieve(script).unwrap();
1232        let actions = evaluate_sieve(&compiled, MSG);
1233        let folders: Vec<_> = actions
1234            .iter()
1235            .filter_map(|a| match a {
1236                SieveAction::FileInto(f) => Some(f.as_str()),
1237                _ => None,
1238            })
1239            .collect();
1240        assert!(folders.contains(&"FromMatch"));
1241        assert!(folders.contains(&"SubjectMatch"));
1242        assert!(folders.contains(&"ToMatch"));
1243    }
1244
1245    #[test]
1246    fn stop_prevents_later_rules() {
1247        let script = r#"
1248            require "fileinto";
1249            if header :contains "Subject" "Test" {
1250                fileinto "Matched";
1251                stop;
1252            }
1253            fileinto "ShouldNotReach";
1254        "#;
1255        let compiled = compile_sieve(script).unwrap();
1256        let actions = evaluate_sieve(&compiled, MSG);
1257        assert!(actions
1258            .iter()
1259            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Matched")));
1260        assert!(!actions
1261            .iter()
1262            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "ShouldNotReach")));
1263    }
1264
1265    // --- invalid script error handling ---
1266
1267    #[test]
1268    fn compile_missing_require() {
1269        // using fileinto without require should fail or produce error
1270        let script = r#"fileinto "Test";"#;
1271        let result = compile_sieve(script);
1272        // per RFC 5228, fileinto needs require; implementation may reject
1273        // either compile error or runtime implicit keep are acceptable
1274        if let Ok(compiled) = result {
1275            let actions = evaluate_sieve(&compiled, MSG);
1276            assert!(!actions.is_empty());
1277        }
1278    }
1279
1280    #[test]
1281    fn compile_unclosed_string() {
1282        let result = compile_sieve("require \"fileinto;\nfileinto \"Test;");
1283        assert!(result.is_err());
1284    }
1285
1286    #[test]
1287    fn compile_unknown_extension() {
1288        // sieve-rs compiler accepts unknown extensions without error,
1289        // so we just verify it doesn't panic and produces a valid compiled script
1290        let result = compile_sieve("require \"nonexistent_extension_xyz\";");
1291        if let Ok(compiled) = result {
1292            let actions = evaluate_sieve(&compiled, MSG);
1293            assert!(actions.iter().any(|a| matches!(a, SieveAction::Keep)));
1294        }
1295    }
1296
1297    #[test]
1298    fn compile_missing_semicolon() {
1299        let result = compile_sieve("require \"fileinto\"\nfileinto \"Test\"");
1300        assert!(result.is_err());
1301    }
1302
1303    #[test]
1304    fn compile_mismatched_braces() {
1305        let result = compile_sieve("require \"fileinto\";\nif true { fileinto \"Test\";");
1306        assert!(result.is_err());
1307    }
1308
1309    #[test]
1310    fn compile_empty_require_list() {
1311        // require with empty list: require [];
1312        let result = compile_sieve("require [];");
1313        // this may or may not be valid depending on the implementation
1314        // we just check it doesn't panic
1315        let _ = result;
1316    }
1317
1318    #[test]
1319    fn compile_duplicate_require() {
1320        let script = r#"
1321            require "fileinto";
1322            require "fileinto";
1323            fileinto "Test";
1324        "#;
1325        // duplicate require may or may not be an error
1326        let _ = compile_sieve(script);
1327    }
1328
1329    // --- address part tests ---
1330
1331    #[test]
1332    fn address_localpart() {
1333        let script = r#"
1334            require "fileinto";
1335            if address :localpart :is "From" "sender" {
1336                fileinto "LocalPart";
1337            }
1338        "#;
1339        let compiled = compile_sieve(script).unwrap();
1340        let actions = evaluate_sieve(&compiled, MSG);
1341        assert!(actions
1342            .iter()
1343            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "LocalPart")));
1344    }
1345
1346    #[test]
1347    fn address_domain() {
1348        let script = r#"
1349            require "fileinto";
1350            if address :domain :is "From" "example.com" {
1351                fileinto "DomainMatch";
1352            }
1353        "#;
1354        let compiled = compile_sieve(script).unwrap();
1355        let actions = evaluate_sieve(&compiled, MSG);
1356        assert!(actions
1357            .iter()
1358            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "DomainMatch")));
1359    }
1360
1361    #[test]
1362    fn address_domain_no_match() {
1363        let script = r#"
1364            require "fileinto";
1365            if address :domain :is "From" "other.com" {
1366                fileinto "WrongDomain";
1367            }
1368        "#;
1369        let compiled = compile_sieve(script).unwrap();
1370        let actions = evaluate_sieve(&compiled, MSG);
1371        assert!(!actions
1372            .iter()
1373            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "WrongDomain")));
1374    }
1375
1376    // --- multi-value header matching ---
1377
1378    #[test]
1379    fn header_contains_multiple_values() {
1380        let script = r#"
1381            require "fileinto";
1382            if header :contains "Subject" ["spam", "Test", "urgent"] {
1383                fileinto "AnyValue";
1384            }
1385        "#;
1386        let compiled = compile_sieve(script).unwrap();
1387        let actions = evaluate_sieve(&compiled, MSG);
1388        assert!(actions
1389            .iter()
1390            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "AnyValue")));
1391    }
1392
1393    #[test]
1394    fn header_contains_multiple_values_none_match() {
1395        let script = r#"
1396            require "fileinto";
1397            if header :contains "Subject" ["spam", "urgent", "newsletter"] {
1398                fileinto "AnyValue";
1399            }
1400        "#;
1401        let compiled = compile_sieve(script).unwrap();
1402        let actions = evaluate_sieve(&compiled, MSG);
1403        assert!(!actions
1404            .iter()
1405            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "AnyValue")));
1406    }
1407
1408    // --- special message scenarios ---
1409
1410    #[test]
1411    fn multipart_message() {
1412        let msg = b"From: sender@example.com\r\n\
1413                     To: rcpt@example.com\r\n\
1414                     Subject: Multipart Test\r\n\
1415                     MIME-Version: 1.0\r\n\
1416                     Content-Type: multipart/alternative; boundary=\"boundary\"\r\n\
1417                     \r\n\
1418                     --boundary\r\n\
1419                     Content-Type: text/plain\r\n\
1420                     \r\n\
1421                     Plain text\r\n\
1422                     --boundary\r\n\
1423                     Content-Type: text/html\r\n\
1424                     \r\n\
1425                     <p>HTML</p>\r\n\
1426                     --boundary--\r\n";
1427        let script = r#"
1428            require "fileinto";
1429            if header :contains "Subject" "Multipart" {
1430                fileinto "Multi";
1431            }
1432        "#;
1433        let compiled = compile_sieve(script).unwrap();
1434        let actions = evaluate_sieve(&compiled, msg);
1435        assert!(actions
1436            .iter()
1437            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Multi")));
1438    }
1439
1440    #[test]
1441    fn message_with_many_headers() {
1442        let msg = b"From: sender@example.com\r\n\
1443                     To: rcpt@example.com\r\n\
1444                     Cc: cc@example.com\r\n\
1445                     Bcc: bcc@example.com\r\n\
1446                     Subject: Complex Headers\r\n\
1447                     Date: Mon, 01 Jan 2024 00:00:00 +0000\r\n\
1448                     Reply-To: reply@example.com\r\n\
1449                     X-Priority: 1\r\n\
1450                     X-Mailer: TestMailer\r\n\
1451                     Message-ID: <test123@example.com>\r\n\
1452                     \r\n\
1453                     Body content";
1454        let script = r#"
1455            require "fileinto";
1456            if allof (
1457                exists "X-Priority",
1458                header :contains "X-Mailer" "TestMailer",
1459                header :contains "Subject" "Complex"
1460            ) {
1461                fileinto "ComplexHeaders";
1462            }
1463        "#;
1464        let compiled = compile_sieve(script).unwrap();
1465        let actions = evaluate_sieve(&compiled, msg);
1466        assert!(actions
1467            .iter()
1468            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "ComplexHeaders")));
1469    }
1470
1471    // --- case sensitivity ---
1472
1473    #[test]
1474    fn header_contains_case_insensitive_value() {
1475        // RFC 5228: :contains comparisons are case-insensitive by default
1476        let script = r#"
1477            require "fileinto";
1478            if header :contains "Subject" "test" {
1479                fileinto "CaseInsensitive";
1480            }
1481        "#;
1482        let compiled = compile_sieve(script).unwrap();
1483        let actions = evaluate_sieve(&compiled, MSG);
1484        // "test" should match "Test" case-insensitively
1485        assert!(actions
1486            .iter()
1487            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "CaseInsensitive")));
1488    }
1489
1490    #[test]
1491    fn header_is_case_insensitive() {
1492        let script = r#"
1493            require "fileinto";
1494            if header :is "Subject" "test" {
1495                fileinto "CaseExact";
1496            }
1497        "#;
1498        let compiled = compile_sieve(script).unwrap();
1499        let actions = evaluate_sieve(&compiled, MSG);
1500        // :is should still be case-insensitive per RFC 5228
1501        assert!(actions
1502            .iter()
1503            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "CaseExact")));
1504    }
1505
1506    // --- realistic spam filtering scenario ---
1507
1508    #[test]
1509    fn realistic_spam_filter_pipeline() {
1510        let spam_msg = b"From: spammer@evil.com\r\n\
1511                          To: victim@example.com\r\n\
1512                          Subject: FREE MONEY!!!\r\n\
1513                          Date: Mon, 01 Jan 2024 00:00:00 +0000\r\n\
1514                          X-Spam-Score: 9.5\r\n\
1515                          \r\n\
1516                          Click here to claim your prize!";
1517        let script = r#"
1518            require ["fileinto", "reject"];
1519            if anyof (
1520                header :contains "Subject" "FREE MONEY",
1521                header :contains "From" "evil.com",
1522                header :contains "X-Spam-Score" "9"
1523            ) {
1524                fileinto "Junk";
1525            }
1526        "#;
1527        let compiled = compile_sieve(script).unwrap();
1528        let actions = evaluate_sieve(&compiled, spam_msg);
1529        assert!(actions
1530            .iter()
1531            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Junk")));
1532    }
1533
1534    #[test]
1535    fn realistic_mailing_list_sorting() {
1536        let list_msg = b"From: list-owner@lists.example.com\r\n\
1537                          To: user@example.com\r\n\
1538                          Subject: [dev] Weekly update\r\n\
1539                          List-Id: <dev.lists.example.com>\r\n\
1540                          Date: Mon, 01 Jan 2024 00:00:00 +0000\r\n\
1541                          \r\n\
1542                          This week's update...";
1543        let script = r#"
1544            require "fileinto";
1545            if header :contains "List-Id" "dev.lists" {
1546                fileinto "Lists/Dev";
1547            } elsif header :contains "List-Id" "announce.lists" {
1548                fileinto "Lists/Announce";
1549            } elsif exists "List-Id" {
1550                fileinto "Lists/Other";
1551            }
1552        "#;
1553        let compiled = compile_sieve(script).unwrap();
1554        let actions = evaluate_sieve(&compiled, list_msg);
1555        assert!(actions
1556            .iter()
1557            .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Lists/Dev")));
1558    }
1559}