1#![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#[derive(Debug, Clone)]
49pub enum SieveAction {
50 Keep,
54 FileInto(String),
58 Discard,
60 Redirect(String),
63 Reject(String),
66 Vacation(String, Vec<u8>),
70}
71
72pub 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#[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
87pub 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 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 #[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 #[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 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 #[test]
1014 fn size_under_no_match() {
1015 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 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 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 #[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 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 }
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 }
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 }
1141 }
1142 }
1143
1144 #[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 assert!(!actions
1164 .iter()
1165 .any(|a| matches!(a, SieveAction::Reject(_))));
1166 assert!(actions
1168 .iter()
1169 .any(|a| matches!(a, SieveAction::FileInto(f) if f == "Tests")));
1170 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 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 #[test]
1268 fn compile_missing_require() {
1269 let script = r#"fileinto "Test";"#;
1271 let result = compile_sieve(script);
1272 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 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 let result = compile_sieve("require [];");
1313 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 let _ = compile_sieve(script);
1327 }
1328
1329 #[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 #[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 #[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 #[test]
1474 fn header_contains_case_insensitive_value() {
1475 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 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 assert!(actions
1502 .iter()
1503 .any(|a| matches!(a, SieveAction::FileInto(f) if f == "CaseExact")));
1504 }
1505
1506 #[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}