1use crate::card::Card;
26use crate::folders::Role;
27use crate::headers::RawHeaders;
28use crate::i18n::Translator;
29use crate::reach::Who;
30use serde::{Deserialize, Serialize};
31use std::cell::OnceCell;
32
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "kebab-case")]
38pub enum Field {
39 From,
41 To,
43 Cc,
45 ReplyTo,
47 Subject,
48 Body,
50 Attachment,
52 AttachmentType,
54 Sender,
56 List,
58 Date,
60 Weekday,
62 Hour,
64 Size,
66 Anywhere,
69 Account,
72 Folder,
75 Mark,
77 #[default]
79 #[serde(other)]
80 Unknown,
81}
82
83impl Field {
84 pub const ALL: [Field; 15] = [
86 Field::From,
87 Field::To,
88 Field::Cc,
89 Field::ReplyTo,
90 Field::Subject,
91 Field::Body,
92 Field::Anywhere,
93 Field::Attachment,
94 Field::AttachmentType,
95 Field::Sender,
96 Field::List,
97 Field::Date,
98 Field::Weekday,
99 Field::Hour,
100 Field::Size,
101 ];
102
103 pub const SEARCH: [Field; 15] = [
107 Field::Anywhere,
108 Field::From,
109 Field::To,
110 Field::Cc,
111 Field::Subject,
112 Field::Body,
113 Field::Attachment,
114 Field::AttachmentType,
115 Field::Date,
116 Field::Size,
117 Field::Sender,
118 Field::List,
119 Field::Account,
120 Field::Folder,
121 Field::Mark,
122 ];
123
124 pub fn id(self) -> &'static str {
126 match self {
127 Field::From => "from",
128 Field::To => "to",
129 Field::Cc => "cc",
130 Field::ReplyTo => "reply-to",
131 Field::Subject => "subject",
132 Field::Body => "body",
133 Field::Attachment => "attachment",
134 Field::AttachmentType => "attachment-type",
135 Field::Sender => "sender",
136 Field::List => "list",
137 Field::Date => "date",
138 Field::Weekday => "weekday",
139 Field::Hour => "hour",
140 Field::Size => "size",
141 Field::Anywhere => "anywhere",
142 Field::Account => "account",
143 Field::Folder => "folder",
144 Field::Mark => "mark",
145 Field::Unknown => "unknown",
146 }
147 }
148
149 pub fn parse(id: &str) -> Option<Field> {
150 Field::ALL.into_iter().find(|f| f.id() == id.trim())
151 }
152
153 pub fn tests(self) -> &'static [Test] {
155 match self {
156 Field::From | Field::To | Field::Cc | Field::ReplyTo | Field::Subject => &[Test::Contains, Test::NotContains, Test::Is, Test::IsNot],
157 Field::Body => &[Test::Contains, Test::NotContains],
158 Field::Attachment | Field::List => &[Test::Exists, Test::Missing, Test::Contains, Test::NotContains],
159 Field::AttachmentType | Field::Sender | Field::Weekday => &[Test::Is, Test::IsNot],
160 Field::Date | Field::Hour => &[Test::After, Test::Before, Test::Between],
161 Field::Size => &[Test::Above, Test::Below],
162 Field::Anywhere => &[Test::Contains, Test::NotContains],
163 Field::Account | Field::Folder | Field::Mark => &[Test::Is, Test::IsNot],
165 Field::Unknown => &[],
166 }
167 }
168
169 pub fn value(self, test: Test) -> Value {
171 match (self, test) {
172 (_, Test::Exists | Test::Missing) => Value::Nothing,
173 (Field::Date, Test::Between) => Value::Dates,
174 (Field::Date, _) => Value::Date,
175 (Field::Hour, Test::Between) => Value::Times,
176 (Field::Hour, _) => Value::Time,
177 (Field::Weekday, _) => Value::Days,
178 (Field::Size, _) => Value::Size,
179 (Field::AttachmentType, _) => Value::Kind,
180 (Field::Sender, _) => Value::Who,
181 _ => Value::Text,
182 }
183 }
184}
185
186#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
188#[serde(rename_all = "kebab-case")]
189pub enum Test {
190 Contains,
191 NotContains,
192 Is,
194 IsNot,
195 Before,
197 After,
199 Between,
201 Above,
203 Below,
205 Exists,
207 Missing,
209 #[default]
211 #[serde(other)]
212 Unknown,
213}
214
215impl Test {
216 pub const ALL: [Test; 11] = [Test::Contains, Test::NotContains, Test::Is, Test::IsNot, Test::Before, Test::After, Test::Between, Test::Above, Test::Below, Test::Exists, Test::Missing];
217
218 pub fn id(self) -> &'static str {
220 match self {
221 Test::Contains => "contains",
222 Test::NotContains => "not-contains",
223 Test::Is => "is",
224 Test::IsNot => "is-not",
225 Test::Before => "before",
226 Test::After => "after",
227 Test::Between => "between",
228 Test::Above => "above",
229 Test::Below => "below",
230 Test::Exists => "exists",
231 Test::Missing => "missing",
232 Test::Unknown => "unknown",
233 }
234 }
235
236 pub fn parse(id: &str) -> Option<Test> {
237 Test::ALL.into_iter().find(|t| t.id() == id.trim())
238 }
239
240 pub fn turned(self) -> Option<Test> {
242 match self {
243 Test::NotContains => Some(Test::Contains),
244 Test::IsNot => Some(Test::Is),
245 Test::Missing => Some(Test::Exists),
246 _ => None,
247 }
248 }
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
253#[serde(rename_all = "kebab-case")]
254pub enum Value {
255 Nothing,
257 Text,
258 Date,
260 Dates,
262 Time,
264 Times,
266 Days,
268 Size,
270 Kind,
272 Who,
274}
275
276pub const KINDS: [&str; 8] = ["pdf", "image", "document", "archive", "calendar", "audio", "video", "text"];
278
279pub const WHO: [&str; 5] = ["known", "safe", "neutral", "restricted", "stranger"];
283
284pub const DAYS: [&str; 7] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
286
287pub const MARKS: [&str; 3] = ["read", "flagged", "answered"];
289
290pub const ROLES: [(&str, Role); 6] = [("inbox", Role::Inbox), ("sent", Role::Sent), ("drafts", Role::Drafts), ("archive", Role::Archive), ("junk", Role::Junk), ("trash", Role::Trash)];
293
294pub fn folder_named(value: &str, role: Role, name: &str) -> bool {
297 let value = value.trim();
298 match ROLES.iter().find(|(id, _)| id.eq_ignore_ascii_case(value)) {
299 Some((_, Role::Archive)) => matches!(role, Role::Archive | Role::All),
300 Some((_, wanted)) => *wanted == role,
301 None => !name.is_empty() && fold(value) == fold(name),
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
308pub struct Place {
309 pub account: String,
311 pub folder: String,
313 pub role: Role,
314 pub flags: String,
316}
317
318#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
320pub struct Condition {
321 #[serde(default)]
322 pub field: Field,
323 #[serde(default)]
324 pub test: Test,
325 #[serde(default, skip_serializing_if = "String::is_empty")]
328 pub value: String,
329 #[serde(default, skip_serializing_if = "String::is_empty")]
331 pub until: String,
332}
333
334impl Condition {
335 pub fn new(field: Field, test: Test, value: &str) -> Condition {
336 Condition { field, test, value: value.to_string(), until: String::new() }
337 }
338
339 pub fn problem(&self) -> Option<&'static str> {
342 if !self.field.tests().contains(&self.test) {
343 return Some("filter-problem-unknown");
344 }
345 let kind = self.field.value(self.test);
346 let fine = match kind {
347 Value::Nothing => true,
348 Value::Text => !fold(&self.value).is_empty(),
349 Value::Date => day(&self.value).is_some(),
350 Value::Dates => matches!((day(&self.value), day(&self.until)), (Some(from), Some(to)) if from <= to),
351 Value::Time => time(&self.value).is_some(),
352 Value::Times => time(&self.value).is_some() && time(&self.until).is_some(),
353 Value::Days => !days(&self.value).is_empty(),
354 Value::Size => size(&self.value).is_some(),
355 Value::Kind => KINDS.contains(&self.value.trim()),
356 Value::Who => WHO.contains(&self.value.trim()),
357 };
358 (!fine).then_some(match kind {
359 Value::Date | Value::Dates => "filter-problem-date",
360 Value::Time | Value::Times => "filter-problem-time",
361 Value::Size => "filter-problem-size",
362 _ => "filter-problem-value",
363 })
364 }
365
366 pub fn matches(&self, message: &Message) -> bool {
369 self.problem().is_none() && self.holds(message).unwrap_or(false)
370 }
371
372 fn holds(&self, m: &Message) -> Option<bool> {
374 if let Some(positive) = self.test.turned() {
375 return Condition { test: positive, ..self.clone() }.holds(m).map(|held| !held);
376 }
377 let value = fold(&self.value);
378 match self.field {
379 Field::From | Field::To | Field::Cc | Field::ReplyTo => {
380 let people = m.people(self.field);
381 Some(match self.test {
382 Test::Contains => people.iter().any(|p| p.both.contains(&value)),
383 _ => people.iter().any(|p| p.address == value || (!p.name.is_empty() && p.name == value)),
384 })
385 }
386 Field::Subject => Some(if self.test == Test::Contains { m.subject().contains(&value) } else { *m.subject() == value }),
387 Field::Body => Some(m.text_contains(&value)),
388 Field::Attachment => Some(match self.test {
389 Test::Exists => !m.card.attachments.is_empty(),
390 _ => m.names().iter().any(|name| name.contains(&value)),
391 }),
392 Field::AttachmentType => Some(m.card.shape.parts.iter().any(|(name, mime)| attachment_kind(name, mime) == self.value.trim())),
393 Field::Sender => m.who().map(|who| who_is(who, self.value.trim())),
394 Field::List => Some(match self.test {
395 Test::Exists => m.card.is_list,
396 _ => m.list().contains(&value),
397 }),
398 Field::Date => {
399 let arrived = m.arrived()?.date();
400 Some(match self.test {
401 Test::Before => arrived < day(&self.value)?,
402 Test::After => arrived > day(&self.value)?,
403 _ => day(&self.value)? <= arrived && arrived <= day(&self.until)?,
404 })
405 }
406 Field::Weekday => {
407 let arrived = m.arrived()?.weekday().to_monday_zero_offset();
408 Some(days(&self.value).contains(&(arrived as usize)))
409 }
410 Field::Hour => {
411 let at = m.arrived()?.time();
412 let from = time(&self.value)?;
413 Some(match self.test {
414 Test::Before => at < from,
415 Test::After => at >= from,
416 _ => {
417 let to = time(&self.until)?;
418 if from <= to { from <= at && at < to } else { at >= from || at < to }
419 }
420 })
421 }
422 Field::Size => {
423 let (bytes, limit) = (m.facts.size?, size(&self.value)?);
424 Some(if self.test == Test::Above { bytes > limit } else { bytes < limit })
425 }
426 Field::Anywhere => {
427 let people = [Field::From, Field::To, Field::Cc].into_iter().any(|f| m.people(f).iter().any(|p| p.both.contains(&value)));
428 Some(people || m.subject().contains(&value) || m.text_contains(&value))
429 }
430 Field::Account => m.place.as_ref().map(|p| p.account == self.value.trim()),
431 Field::Folder => m.place.as_ref().map(|p| folder_named(&self.value, p.role, &p.folder)),
432 Field::Mark => m.place.as_ref().map(|p| match self.value.trim() {
433 "read" => p.flags.contains('S'),
434 "flagged" => p.flags.contains('F'),
435 "answered" => p.flags.contains('R'),
436 _ => false,
437 }),
438 Field::Unknown => None,
439 }
440 }
441
442 pub fn said(&self, tr: &Translator) -> String {
444 if self.problem().is_some() {
445 return tr.text("filter-if-unfinished", None);
446 }
447 let mut args = crate::i18n::args();
448 args.set("field", self.field.id());
449 args.set("test", self.test.id());
450 args.set("value", self.value.trim().to_string());
451 let today = jiff::Zoned::now().date();
452 let id = match self.field.value(self.test) {
453 Value::Nothing => match self.field {
454 Field::Attachment => "filter-if-attachment",
455 _ => "filter-if-list",
456 },
457 Value::Text => "filter-if-text",
458 Value::Kind => {
459 args.set("kind", tr.text(&format!("filter-kind-{}", self.value.trim()), None));
460 "filter-if-kind"
461 }
462 Value::Who => {
463 args.set("who", tr.text(&format!("filter-who-{}", self.value.trim()), None));
464 "filter-if-sender"
465 }
466 Value::Date | Value::Dates => {
467 args.set("value", day(&self.value).map(|d| tr.day_in(d, today)).unwrap_or_default());
468 args.set("until", day(&self.until).map(|d| tr.day_in(d, today)).unwrap_or_default());
469 "filter-if-date"
470 }
471 Value::Days => {
472 let names: Vec<String> = days(&self.value).into_iter().map(|d| tr.text(&format!("weekday-{}", d + 1), None)).collect();
473 args.set("days", joined(tr, &names, "filter-join-any"));
474 "filter-if-weekday"
475 }
476 Value::Time | Value::Times => {
477 args.set("value", time(&self.value).map(|t| t.strftime("%H:%M").to_string()).unwrap_or_default());
478 args.set("until", time(&self.until).map(|t| t.strftime("%H:%M").to_string()).unwrap_or_default());
479 "filter-if-hour"
480 }
481 Value::Size => {
482 args.set("value", size(&self.value).map(|bytes| said_size(tr, bytes)).unwrap_or_default());
483 "filter-if-size"
484 }
485 };
486 tr.text(id, Some(&args))
487 }
488}
489
490pub fn holds(conditions: &[Condition], join: Join, message: &Message) -> bool {
492 match join {
493 Join::All => !conditions.is_empty() && conditions.iter().all(|c| c.matches(message)),
494 Join::Any => conditions.iter().any(|c| c.matches(message)),
495 }
496}
497
498#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
500#[serde(rename_all = "lowercase")]
501pub enum Join {
502 #[default]
503 All,
504 Any,
505}
506
507impl Join {
508 fn is_all(&self) -> bool {
509 *self == Join::All
510 }
511}
512
513#[derive(Debug, Clone)]
517pub struct Facts {
518 pub who: Option<Who>,
521 pub unproven: bool,
524 pub size: Option<u64>,
526 pub arrived: Option<i64>,
528 pub zone: jiff::tz::TimeZone,
530}
531
532impl Facts {
533 pub fn of(card: &Card) -> Facts {
535 let size = card.path.as_ref().and_then(|path| std::fs::metadata(path).ok()).map(|m| m.len());
536 Facts { who: None, unproven: false, size, arrived: crate::porch::sent(card), zone: jiff::tz::TimeZone::system() }
537 }
538}
539
540#[derive(Debug, Clone, Default)]
542struct Person {
543 name: String,
544 address: String,
545 both: String,
547}
548
549impl Person {
550 fn new(name: Option<&str>, address: Option<&str>) -> Person {
551 let (name, address) = (fold(name.unwrap_or("")), fold(address.unwrap_or("")));
552 let both = match (name.is_empty(), address.is_empty()) {
553 (false, false) => format!("{name} <{address}>"),
554 (true, _) => address.clone(),
555 (false, true) => name.clone(),
556 };
557 Person { name, address, both }
558 }
559}
560
561pub struct Message<'a> {
564 pub card: &'a Card,
565 pub facts: Facts,
566 from: OnceCell<Vec<Person>>,
567 to: OnceCell<Vec<Person>>,
568 cc: OnceCell<Vec<Person>>,
569 reply_to: OnceCell<Vec<Person>>,
570 subject: OnceCell<String>,
571 excerpt: OnceCell<String>,
572 text: OnceCell<String>,
573 names: OnceCell<Vec<String>>,
574 list: OnceCell<String>,
575 place: Option<Place>,
577}
578
579const KEPT: usize = 6000;
582
583impl<'a> Message<'a> {
584 pub fn new(card: &'a Card, facts: Facts) -> Message<'a> {
585 Message {
586 card,
587 facts,
588 from: OnceCell::new(),
589 to: OnceCell::new(),
590 cc: OnceCell::new(),
591 reply_to: OnceCell::new(),
592 subject: OnceCell::new(),
593 excerpt: OnceCell::new(),
594 text: OnceCell::new(),
595 names: OnceCell::new(),
596 list: OnceCell::new(),
597 place: None,
598 }
599 }
600
601 pub fn placed(mut self, place: Place) -> Message<'a> {
603 self.place = Some(place);
604 self
605 }
606
607 pub fn of(card: &'a Card) -> Message<'a> {
609 Message::new(card, Facts::of(card))
610 }
611
612 fn people(&self, field: Field) -> &[Person] {
613 let cell = match field {
614 Field::From => &self.from,
615 Field::To => &self.to,
616 Field::Cc => &self.cc,
617 _ => &self.reply_to,
618 };
619 cell.get_or_init(|| {
620 if self.facts.unproven && matches!(field, Field::From | Field::ReplyTo) {
622 return Vec::new();
623 }
624 match field {
625 Field::From if self.card.from_name.is_none() && self.card.from_address.is_none() => Vec::new(),
626 Field::From => vec![Person::new(self.card.from_name.as_deref(), self.card.from_address.as_deref())],
627 Field::To => addresses(&self.card.headers, "To"),
628 Field::Cc => addresses(&self.card.headers, "Cc"),
629 _ => addresses(&self.card.headers, "Reply-To"),
630 }
631 })
632 }
633
634 fn subject(&self) -> &String {
635 self.subject.get_or_init(|| fold(&self.card.subject))
636 }
637
638 fn text_contains(&self, value: &str) -> bool {
641 if self.excerpt.get_or_init(|| fold(&self.card.excerpt)).contains(value) {
642 return true;
643 }
644 self.card.excerpt.chars().count() >= KEPT && self.text.get_or_init(|| self.card.full_text().map(|t| fold(&t)).unwrap_or_default()).contains(value)
645 }
646
647 fn names(&self) -> &[String] {
648 self.names.get_or_init(|| self.card.attachments.iter().map(|name| fold(name)).collect())
649 }
650
651 fn list(&self) -> &String {
652 self.list.get_or_init(|| self.card.headers.first("List-Id").map(fold).unwrap_or_default())
653 }
654
655 fn who(&self) -> Option<Who> {
656 if self.facts.unproven { Some(Who::Stranger) } else { self.facts.who }
657 }
658
659 fn arrived(&self) -> Option<jiff::civil::DateTime> {
661 let at = jiff::Timestamp::from_second(self.facts.arrived?).ok()?;
662 Some(at.to_zoned(self.facts.zone.clone()).datetime())
663 }
664}
665
666fn addresses(headers: &RawHeaders, name: &str) -> Vec<Person> {
668 let mut block = String::new();
669 for value in headers.all(name) {
670 block.push_str(&format!("{name}: {value}\r\n"));
671 }
672 if block.is_empty() {
673 return Vec::new();
674 }
675 block.push_str("\r\n");
676 let Some(parsed) = mail_parser::MessageParser::default().parse_headers(block.as_bytes()) else { return Vec::new() };
677 let list = match name {
678 "To" => parsed.to(),
679 "Cc" => parsed.cc(),
680 _ => parsed.reply_to(),
681 };
682 list.map(|l| l.iter().map(|a| Person::new(a.name(), a.address())).collect()).unwrap_or_default()
683}
684
685fn who_is(who: Who, value: &str) -> bool {
686 match value {
687 "known" => matches!(who, Who::Safe | Who::Neutral | Who::Restricted),
688 "safe" => who == Who::Safe,
689 "neutral" => who == Who::Neutral,
690 "restricted" => who == Who::Restricted,
691 "stranger" => who == Who::Stranger,
692 _ => false,
693 }
694}
695
696pub fn attachment_kind(name: &str, mime: &str) -> &'static str {
698 let mime = mime.trim().to_ascii_lowercase();
699 let ending = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
700 let by_ending = || match ending.as_str() {
701 "pdf" => "pdf",
702 "png" | "jpg" | "jpeg" | "gif" | "webp" | "heic" | "tif" | "tiff" | "bmp" | "svg" => "image",
703 "doc" | "docx" | "odt" | "rtf" | "xls" | "xlsx" | "ods" | "csv" | "ppt" | "pptx" | "odp" | "pages" | "numbers" | "key" => "document",
704 "zip" | "7z" | "rar" | "gz" | "tgz" | "tar" | "xz" | "bz2" => "archive",
705 "ics" | "vcs" => "calendar",
706 "mp3" | "m4a" | "ogg" | "opus" | "wav" | "flac" | "aac" | "amr" => "audio",
707 "mp4" | "mov" | "mkv" | "webm" | "avi" | "m4v" => "video",
708 "txt" | "md" | "eml" => "text",
709 _ => "other",
710 };
711 match mime.as_str() {
712 "application/pdf" => "pdf",
713 "text/calendar" | "application/ics" => "calendar",
714 m if m.starts_with("image/") => "image",
715 m if m.starts_with("audio/") => "audio",
716 m if m.starts_with("video/") => "video",
717 m if m.contains("zip") || m.contains("compressed") || m.contains("x-tar") || m.contains("x-7z") || m.contains("x-rar") => "archive",
718 m if m.contains("word") || m.contains("officedocument") || m.contains("opendocument") || m.contains("excel") || m.contains("powerpoint") || m.contains("rtf") || m == "text/csv" => "document",
719 m if m.starts_with("text/") => "text",
720 _ => by_ending(),
721 }
722}
723
724pub fn fold(text: &str) -> String {
726 let mut out = String::with_capacity(text.len());
727 let mut space = false;
728 for c in text.chars().map(crate::text::fold_char) {
729 if c.is_whitespace() {
730 space = !out.is_empty();
731 } else {
732 if space {
733 out.push(' ');
734 space = false;
735 }
736 out.push(c);
737 }
738 }
739 out
740}
741
742fn day(text: &str) -> Option<jiff::civil::Date> {
744 text.trim().parse().ok()
745}
746
747fn time(text: &str) -> Option<jiff::civil::Time> {
749 let (hours, minutes) = text.trim().split_once([':', 'h', 'H'])?;
750 let (hours, minutes): (i8, i8) = (hours.trim().parse().ok()?, if minutes.trim().is_empty() { 0 } else { minutes.trim().parse().ok()? });
751 jiff::civil::Time::new(hours, minutes, 0, 0).ok()
752}
753
754fn days(text: &str) -> Vec<usize> {
756 let mut found: Vec<usize> = text.split(',').filter_map(|d| DAYS.iter().position(|day| *day == d.trim().to_ascii_lowercase())).collect();
757 found.sort_unstable();
758 found.dedup();
759 found
760}
761
762pub fn size(text: &str) -> Option<u64> {
764 let text = text.trim().replace(',', ".");
765 let split = text.find(|c: char| !(c.is_ascii_digit() || c == '.')).unwrap_or(text.len());
766 let (number, unit) = text.split_at(split);
767 let number: f64 = number.parse().ok().filter(|n: &f64| n.is_finite() && *n >= 0.0)?;
768 let unit = match unit.trim().to_ascii_lowercase().as_str() {
769 "b" | "o" | "bytes" | "octets" => 1.0,
770 "k" | "kb" | "ko" | "kib" => 1024.0,
771 "" | "m" | "mb" | "mo" | "mib" => 1024.0 * 1024.0,
772 "g" | "gb" | "go" | "gib" => 1024.0 * 1024.0 * 1024.0,
773 _ => return None,
774 };
775 Some((number * unit).round() as u64)
776}
777
778fn said_size(tr: &Translator, bytes: u64) -> String {
780 let mut args = crate::i18n::args();
781 let (id, value) = if bytes >= 1024 * 1024 { ("size-mb", tr.decimal(bytes as f32 / (1024.0 * 1024.0))) } else { ("size-kb", (bytes / 1024).to_string()) };
782 args.set("n", value);
783 tr.text(id, Some(&args))
784}
785
786fn joined(tr: &Translator, words: &[String], last: &str) -> String {
788 match words {
789 [] => String::new(),
790 [one] => one.clone(),
791 [rest @ .., end] => format!("{}{}{end}", rest.join(", "), tr.text(last, None)),
792 }
793}
794
795#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
799#[serde(tag = "do", rename_all = "kebab-case")]
800pub enum Act {
801 Move { folder: String },
804 Archive,
807 Junk,
811 Flag,
813 Read,
815 Trash,
817 Keyword { name: String },
819 #[serde(other)]
821 Unknown,
822}
823
824impl Act {
825 pub const IDS: [&'static str; 7] = ["move", "archive", "junk", "flag", "read", "trash", "keyword"];
827
828 pub fn id(&self) -> &'static str {
829 match self {
830 Act::Move { .. } => "move",
831 Act::Archive => "archive",
832 Act::Junk => "junk",
833 Act::Flag => "flag",
834 Act::Read => "read",
835 Act::Trash => "trash",
836 Act::Keyword { .. } => "keyword",
837 Act::Unknown => "unknown",
838 }
839 }
840
841 pub fn of(id: &str, name: &str) -> Act {
843 match id.trim() {
844 "move" => Act::Move { folder: name.trim().to_string() },
845 "archive" => Act::Archive,
846 "junk" => Act::Junk,
847 "flag" => Act::Flag,
848 "read" => Act::Read,
849 "trash" => Act::Trash,
850 "keyword" => Act::Keyword { name: name.trim().to_string() },
851 _ => Act::Unknown,
852 }
853 }
854
855 pub fn name(&self) -> &str {
857 match self {
858 Act::Move { folder } => folder,
859 Act::Keyword { name } => name,
860 _ => "",
861 }
862 }
863
864 pub fn problem(&self) -> Option<&'static str> {
866 match self {
867 Act::Move { folder } if folder.trim().is_empty() => Some("filter-problem-folder"),
868 Act::Keyword { name } if !keyword(name) => Some("filter-problem-keyword"),
869 Act::Unknown => Some("filter-problem-unknown"),
870 _ => None,
871 }
872 }
873
874 pub fn said(&self, tr: &Translator) -> String {
877 if matches!(self, Act::Move { .. } | Act::Keyword { .. }) && self.problem().is_some() {
878 return tr.text("filter-then-unfinished", None);
879 }
880 let mut args = crate::i18n::args();
881 args.set("name", self.name().trim().to_string());
882 tr.text(&format!("filter-then-{}", self.id()), Some(&args))
883 }
884}
885
886pub const FILTERED: &str = "$SioulFiltered";
890
891pub fn keyword(name: &str) -> bool {
895 let name = name.trim();
896 let lower = name.to_ascii_lowercase();
897 !name.is_empty()
898 && name.len() <= 64
899 && !name.starts_with('\\')
900 && name.chars().all(|c| c.is_ascii_graphic() && !"(){%*\"\\]".contains(c))
901 && lower != "$junk"
902 && lower != "$notjunk"
903 && lower != FILTERED.to_ascii_lowercase()
904}
905
906#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
910pub struct Filter {
911 #[serde(default, skip_serializing_if = "String::is_empty")]
913 pub name: String,
914 #[serde(default = "on", skip_serializing_if = "is_on")]
916 pub enabled: bool,
917 #[serde(default, skip_serializing_if = "Vec::is_empty")]
919 pub accounts: Vec<String>,
920 #[serde(default, rename = "match", skip_serializing_if = "Join::is_all")]
922 pub join: Join,
923 #[serde(default, rename = "if")]
924 pub conditions: Vec<Condition>,
925 #[serde(default, rename = "then")]
926 pub actions: Vec<Act>,
927 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
929 pub stop: bool,
930}
931
932fn on() -> bool {
933 true
934}
935
936fn is_on(enabled: &bool) -> bool {
937 *enabled
938}
939
940impl Default for Filter {
941 fn default() -> Filter {
942 Filter { name: String::new(), enabled: true, accounts: Vec::new(), join: Join::All, conditions: Vec::new(), actions: Vec::new(), stop: false }
943 }
944}
945
946impl Filter {
947 pub fn problem(&self) -> Option<&'static str> {
950 if self.conditions.is_empty() {
951 return Some("filter-problem-no-condition");
952 }
953 if self.actions.is_empty() {
954 return Some("filter-problem-no-action");
955 }
956 self.conditions.iter().find_map(Condition::problem).or_else(|| self.actions.iter().find_map(Act::problem))
957 }
958
959 pub fn unknown(&self) -> bool {
962 self.conditions.iter().any(|c| c.field == Field::Unknown || c.test == Test::Unknown) || self.actions.contains(&Act::Unknown)
963 }
964
965 pub fn runs_on(&self, account: &str) -> bool {
967 self.enabled && self.problem().is_none() && (self.accounts.is_empty() || self.accounts.iter().any(|a| a == account))
968 }
969
970 pub fn matches(&self, message: &Message) -> bool {
972 holds(&self.conditions, self.join, message)
973 }
974
975 pub fn said(&self, tr: &Translator) -> String {
977 if self.conditions.is_empty() && self.actions.is_empty() {
978 return tr.text("filter-said-empty", None);
979 }
980 let conditions: Vec<String> = self.conditions.iter().map(|c| c.said(tr)).collect();
981 let join = if self.join == Join::Any { "filter-join-any" } else { "filter-join-all" };
982 let mut actions: Vec<String> = self.actions.iter().map(|a| a.said(tr)).collect();
983 if self.stop {
984 actions.push(tr.text("filter-then-stop", None));
985 }
986 let mut args = crate::i18n::args();
987 args.set("conditions", if conditions.is_empty() { tr.text("filter-if-none", None) } else { conditions.join(&tr.text(join, None)) });
988 args.set("actions", if self.actions.is_empty() { tr.text("filter-then-none", None) } else { actions.join(", ") });
989 let said = tr.text("filter-said", Some(&args));
991 let mut letters = said.chars();
992 match letters.next() {
993 Some(first) => first.to_uppercase().chain(letters).collect(),
994 None => said,
995 }
996 }
997}
998
999pub fn lenient<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<Filter>, D::Error> {
1002 let value = toml::Value::deserialize(deserializer)?;
1003 let toml::Value::Array(items) = value else { return Ok(Vec::new()) };
1004 Ok(items.into_iter().filter_map(|item| item.try_into::<Filter>().ok()).collect())
1005}
1006
1007#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1009#[serde(rename_all = "lowercase")]
1010pub enum Destination {
1011 Folder(String),
1013 Archive,
1015 Junk,
1016 Trash,
1017}
1018
1019#[derive(Debug, Clone, Default, PartialEq, Eq)]
1022pub struct Plan {
1023 pub to: Option<Destination>,
1024 pub read: bool,
1025 pub flag: bool,
1026 pub keywords: Vec<String>,
1027 pub by: Vec<usize>,
1029}
1030
1031impl Plan {
1032 pub fn is_empty(&self) -> bool {
1034 self.by.is_empty()
1035 }
1036
1037 pub fn leaves(&self) -> bool {
1039 self.to.is_some()
1040 }
1041
1042 pub fn quiet(&self) -> bool {
1044 self.leaves() || self.read
1045 }
1046}
1047
1048pub fn plan(filters: &[Filter], account: &str, message: &Message) -> Plan {
1052 let mut plan = Plan::default();
1053 for (at, filter) in filters.iter().enumerate() {
1054 if !filter.runs_on(account) || !filter.matches(message) {
1055 continue;
1056 }
1057 plan.by.push(at);
1058 for act in &filter.actions {
1059 match act {
1060 Act::Move { folder } => {
1061 plan.to.get_or_insert_with(|| Destination::Folder(folder.trim().to_string()));
1062 }
1063 Act::Archive => {
1064 plan.to.get_or_insert(Destination::Archive);
1065 }
1066 Act::Junk => {
1067 plan.to.get_or_insert(Destination::Junk);
1068 }
1069 Act::Trash => {
1070 plan.to.get_or_insert(Destination::Trash);
1071 }
1072 Act::Read => plan.read = true,
1073 Act::Flag => plan.flag = true,
1074 Act::Keyword { name } => {
1075 if !plan.keywords.iter().any(|k| k.eq_ignore_ascii_case(name.trim())) {
1076 plan.keywords.push(name.trim().to_string());
1077 }
1078 }
1079 Act::Unknown => {}
1080 }
1081 }
1082 if filter.stop {
1083 break;
1084 }
1085 }
1086 plan
1087}
1088
1089pub fn untouched(judged: &crate::porch::Triaged) -> bool {
1094 use crate::porch::Lane;
1095 matches!(judged.lane, Lane::SetAside | Lane::Hostile | Lane::Review | Lane::RightNow)
1096}
1097
1098pub fn facts_of(judged: &crate::porch::Triaged, senders: &crate::porch::Senders) -> Facts {
1101 let unproven = judged.trust == crate::trust::Trust::Forged || judged.reasons.contains(&crate::porch::Reason::NotAuthenticated);
1102 Facts { who: Some(senders.who_of(&judged.card)), unproven, ..Facts::of(&judged.card) }
1103}
1104
1105pub fn plans(filters: &[Filter], judged: &[crate::porch::Triaged], senders: &crate::porch::Senders) -> Vec<(std::path::PathBuf, Plan)> {
1108 judged
1109 .iter()
1110 .filter(|t| !untouched(t))
1111 .filter_map(|t| {
1112 let path = t.card.path.clone()?;
1113 let account = t.card.account.as_deref()?;
1114 let plan = plan(filters, account, &Message::new(&t.card, facts_of(t, senders)));
1115 (!plan.is_empty()).then_some((path, plan))
1116 })
1117 .collect()
1118}
1119
1120#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1124pub struct Choice {
1125 pub id: String,
1126 pub label: String,
1127}
1128
1129#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1131pub struct FieldForm {
1132 pub id: String,
1133 pub label: String,
1134 pub tests: Vec<TestForm>,
1135}
1136
1137#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1138pub struct TestForm {
1139 pub id: String,
1140 pub label: String,
1141 pub value: Value,
1142}
1143
1144#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1146pub struct Form {
1147 pub fields: Vec<FieldForm>,
1148 pub actions: Vec<Choice>,
1149 pub kinds: Vec<Choice>,
1150 pub who: Vec<Choice>,
1151 pub days: Vec<Choice>,
1153}
1154
1155pub fn form(tr: &Translator) -> Form {
1157 let test_label = |field: Field, test: Test| match test {
1158 Test::Exists | Test::Missing => tr.text(&format!("filter-test-{}-{}", test.id(), field.id()), None),
1159 _ => tr.text(&format!("filter-test-{}", test.id()), None),
1160 };
1161 let fields = Field::ALL
1162 .iter()
1163 .map(|&field| FieldForm {
1164 id: field.id().into(),
1165 label: tr.text(&format!("filter-field-{}", field.id()), None),
1166 tests: field.tests().iter().map(|&test| TestForm { id: test.id().into(), label: test_label(field, test), value: field.value(test) }).collect(),
1167 })
1168 .collect();
1169 let choices = |ids: &[&str], prefix: &str| ids.iter().map(|id| Choice { id: id.to_string(), label: tr.text(&format!("{prefix}{id}"), None) }).collect::<Vec<_>>();
1170 let days = (0..7).map(|d| Choice { id: DAYS[d].into(), label: tr.text(&format!("weekday-{}", d + 1), None) }).collect();
1171 Form { fields, actions: choices(&Act::IDS, "filter-act-"), kinds: choices(&KINDS, "filter-kind-"), who: choices(&WHO, "filter-who-"), days }
1172}
1173
1174#[derive(Debug, Clone, PartialEq, Eq)]
1178pub enum Proposed {
1179 AssignProject(String),
1181 File(Shelf),
1183 SetAside(String),
1185 Label(String),
1187 Junk,
1189 Trash,
1191 Flag,
1193 ProposeTask { title: String, due: Option<String> },
1195}
1196
1197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1198pub enum Shelf {
1199 Newsletters,
1200 Notifications,
1201 Receipts,
1202 Calendar,
1203}
1204
1205impl Shelf {
1206 pub fn parse(name: &str) -> Option<Shelf> {
1208 match name.trim().to_ascii_lowercase().as_str() {
1209 "newsletters" => Some(Shelf::Newsletters),
1210 "notifications" => Some(Shelf::Notifications),
1211 "receipts" => Some(Shelf::Receipts),
1212 "calendar" => Some(Shelf::Calendar),
1213 _ => None,
1214 }
1215 }
1216}
1217
1218#[derive(Debug, Clone, Deserialize)]
1221pub struct FolderMapping {
1222 pub folder: String,
1224 #[serde(default, alias = "case")]
1226 pub project: Option<String>,
1227 #[serde(default)]
1228 pub shelf: Option<String>,
1229 #[serde(default)]
1230 pub junk: bool,
1231}
1232
1233impl FolderMapping {
1234 pub fn actions_for(mappings: &[FolderMapping], folder: &str) -> Vec<Proposed> {
1236 let Some(m) = mappings.iter().find(|m| m.folder.eq_ignore_ascii_case(folder)) else { return Vec::new() };
1237 let project = m.project.clone().map(Proposed::AssignProject);
1238 let shelf = m.shelf.as_deref().and_then(Shelf::parse).map(Proposed::File);
1239 let junk = m.junk.then_some(Proposed::Junk);
1240 [project, shelf, junk].into_iter().flatten().collect()
1241 }
1242}
1243
1244#[cfg(test)]
1245mod tests {
1246 use super::*;
1247
1248 #[test]
1249 fn maps_virtual_secretary_folders() {
1250 let mappings: Vec<FolderMapping> = toml::from_str::<std::collections::HashMap<String, Vec<FolderMapping>>>(
1251 r#"
1252 [[map]]
1253 folder = "INBOX.Money.Taxes"
1254 project = "taxes"
1255 [[map]]
1256 folder = "INBOX.Money.Health"
1257 case = "health"
1258 [[map]]
1259 folder = "INBOX.Services.Notifications"
1260 shelf = "notifications"
1261 [[map]]
1262 folder = "INBOX.spam"
1263 junk = true
1264 "#,
1265 )
1266 .unwrap()
1267 .remove("map")
1268 .unwrap();
1269 assert_eq!(FolderMapping::actions_for(&mappings, "inbox.money.taxes"), vec![Proposed::AssignProject("taxes".into())]);
1270 assert_eq!(FolderMapping::actions_for(&mappings, "INBOX.Money.Health"), vec![Proposed::AssignProject("health".into())], "the word before the name changed");
1271 assert_eq!(FolderMapping::actions_for(&mappings, "INBOX.spam"), vec![Proposed::Junk]);
1272 assert!(FolderMapping::actions_for(&mappings, "INBOX").is_empty());
1273 }
1274
1275 fn card(raw: &str) -> Card {
1278 Card::from_bytes(raw.replace('\n', "\r\n").as_bytes()).unwrap()
1279 }
1280
1281 fn statement() -> Card {
1283 card(
1284 "From: \"Banque Exemple\" <Alertes@Bank.example>\n\
1285 To: \"Noa\" <noa@home.example>, team@work.example\n\
1286 Cc: =?UTF-8?Q?Ren=C3=A9e?= <renee@family.example>\n\
1287 Reply-To: support@bank.example\n\
1288 Subject: Votre RELEVÉ de compte\n\
1289 List-Id: \"Alertes\" <alertes.bank.example>\n\
1290 Date: Thu, 01 Oct 2026 10:00:00 +0200\n\
1291 MIME-Version: 1.0\n\
1292 Content-Type: multipart/mixed; boundary=b\n\
1293 \n\
1294 --b\n\
1295 Content-Type: text/plain; charset=utf-8\n\
1296 \n\
1297 Bonjour, votre relevé est disponible.\n\
1298 --b\n\
1299 Content-Type: application/pdf; name=releve-octobre.pdf\n\
1300 Content-Disposition: attachment; filename=releve-octobre.pdf\n\
1301 Content-Transfer-Encoding: base64\n\
1302 \n\
1303 JVBERi0=\n\
1304 --b--\n",
1305 )
1306 }
1307
1308 fn facts() -> Facts {
1310 Facts { who: Some(Who::Neutral), unproven: false, size: Some(5000), arrived: Some(1_790_841_600), zone: jiff::tz::TimeZone::fixed(jiff::tz::offset(2)) }
1311 }
1312
1313 fn holds_on(card: &Card, facts: Facts, field: Field, test: Test, value: &str) -> bool {
1314 Condition::new(field, test, value).matches(&Message::new(card, facts))
1315 }
1316
1317 #[test]
1318 fn every_field_reads_case_and_accents_aside() {
1319 let mail = statement();
1320 let yes = |field, test, value: &str| assert!(holds_on(&mail, facts(), field, test, value), "{field:?} {test:?} {value:?} should hold");
1321 let no = |field, test, value: &str| assert!(!holds_on(&mail, facts(), field, test, value), "{field:?} {test:?} {value:?} should not hold");
1322 yes(Field::From, Test::Contains, "@BANK.example");
1324 yes(Field::From, Test::Is, "alertes@bank.example");
1325 yes(Field::From, Test::Is, "banque exemple");
1326 no(Field::From, Test::Is, "bank.example");
1327 no(Field::From, Test::NotContains, "@bank");
1328 yes(Field::From, Test::IsNot, "someone@else.example");
1329 yes(Field::To, Test::Contains, "team@work");
1331 yes(Field::To, Test::Is, "Noa");
1332 yes(Field::Cc, Test::Contains, "renee");
1333 yes(Field::Cc, Test::Is, "Renée");
1334 yes(Field::ReplyTo, Test::Is, "support@bank.example");
1335 no(Field::ReplyTo, Test::Contains, "alertes");
1336 yes(Field::Subject, Test::Contains, "releve de");
1338 no(Field::Subject, Test::Is, "releve");
1339 yes(Field::Subject, Test::Is, "votre relevé de compte");
1340 yes(Field::Body, Test::Contains, "Relevé est");
1341 yes(Field::Body, Test::NotContains, "facture");
1342 yes(Field::Attachment, Test::Exists, "");
1344 no(Field::Attachment, Test::Missing, "");
1345 yes(Field::Attachment, Test::Contains, "OCTOBRE");
1346 yes(Field::AttachmentType, Test::Is, "pdf");
1347 yes(Field::AttachmentType, Test::IsNot, "image");
1348 yes(Field::List, Test::Exists, "");
1350 yes(Field::List, Test::Contains, "alertes.bank");
1351 no(Field::List, Test::NotContains, "bank.example");
1352 }
1353
1354 #[test]
1355 fn when_it_arrived_and_how_large() {
1356 let mail = statement();
1357 let at = |field, test, value: &str, until: &str| Condition { field, test, value: value.into(), until: until.into() }.matches(&Message::new(&mail, facts()));
1358 assert!(at(Field::Date, Test::After, "2026-09-30", ""));
1360 assert!(!at(Field::Date, Test::After, "2026-10-01", ""));
1361 assert!(!at(Field::Date, Test::Before, "2026-10-01", ""));
1362 assert!(at(Field::Date, Test::Between, "2026-10-01", "2026-10-31"));
1363 assert!(!at(Field::Date, Test::Between, "2026-10-31", "2026-10-01"), "a range backwards does not read");
1364 assert!(at(Field::Weekday, Test::Is, "thu", ""));
1366 assert!(!at(Field::Weekday, Test::Is, "sat,sun", ""));
1367 assert!(at(Field::Weekday, Test::IsNot, "sat, SUN", ""));
1368 assert!(at(Field::Hour, Test::After, "09:30", ""));
1369 assert!(at(Field::Hour, Test::After, "10:00", ""));
1370 assert!(!at(Field::Hour, Test::Before, "9h30", ""));
1371 assert!(at(Field::Hour, Test::Between, "09:00", "11:00"));
1372 assert!(!at(Field::Hour, Test::Between, "22:00", "06:00"));
1373 assert!(at(Field::Size, Test::Above, "4 KB", ""));
1375 assert!(at(Field::Size, Test::Below, "0,5 Mo", ""));
1376 assert!(!at(Field::Size, Test::Above, "1", ""), "a size without its unit is in megabytes");
1377 let unknown = Facts { size: None, arrived: None, ..facts() };
1379 assert!(!Condition::new(Field::Size, Test::Above, "1 KB").matches(&Message::new(&mail, unknown.clone())));
1380 assert!(!Condition::new(Field::Size, Test::Below, "1 KB").matches(&Message::new(&mail, unknown.clone())));
1381 assert!(!Condition::new(Field::Weekday, Test::IsNot, "sat").matches(&Message::new(&mail, unknown)));
1382 }
1383
1384 #[test]
1385 fn a_sender_nothing_proves_is_nobody() {
1386 let mail = statement();
1387 assert!(holds_on(&mail, facts(), Field::Sender, Test::Is, "known"));
1389 assert!(holds_on(&mail, facts(), Field::Sender, Test::IsNot, "stranger"));
1390 assert!(!holds_on(&mail, facts(), Field::Sender, Test::Is, "safe"));
1391 let unjudged = Facts { who: None, ..facts() };
1393 assert!(!holds_on(&mail, unjudged.clone(), Field::Sender, Test::Is, "stranger"));
1394 assert!(!holds_on(&mail, unjudged, Field::Sender, Test::IsNot, "stranger"));
1395 let unproven = Facts { unproven: true, ..facts() };
1397 assert!(!holds_on(&mail, unproven.clone(), Field::From, Test::Contains, "@bank.example"));
1398 assert!(holds_on(&mail, unproven.clone(), Field::From, Test::NotContains, "@bank.example"));
1399 assert!(!holds_on(&mail, unproven.clone(), Field::ReplyTo, Test::Is, "support@bank.example"));
1400 assert!(holds_on(&mail, unproven.clone(), Field::Sender, Test::Is, "stranger"));
1401 assert!(holds_on(&mail, unproven, Field::Subject, Test::Contains, "relevé"));
1403 }
1404
1405 #[test]
1406 fn what_does_not_read_never_holds() {
1407 let mail = statement();
1408 let problem = |c: Condition| c.problem();
1409 assert_eq!(problem(Condition::new(Field::Subject, Test::Contains, " ")), Some("filter-problem-value"));
1410 assert_eq!(problem(Condition::new(Field::Date, Test::Before, "1er octobre")), Some("filter-problem-date"));
1411 assert_eq!(problem(Condition::new(Field::Hour, Test::After, "25:00")), Some("filter-problem-time"));
1412 assert_eq!(problem(Condition::new(Field::Size, Test::Above, "big")), Some("filter-problem-size"));
1413 assert_eq!(problem(Condition::new(Field::Body, Test::Is, "x")), Some("filter-problem-unknown"), "the text is never whole");
1414 assert_eq!(problem(Condition::new(Field::Sender, Test::Is, "blocked")), Some("filter-problem-value"));
1415 assert_eq!(problem(Condition::new(Field::Attachment, Test::Exists, "")), None);
1416 assert!(!Condition::new(Field::Subject, Test::NotContains, "").matches(&Message::of(&mail)));
1418 let mut filter = Filter::default();
1420 assert_eq!(filter.problem(), Some("filter-problem-no-condition"));
1421 filter.conditions.push(Condition::new(Field::From, Test::Contains, "@bank.example"));
1422 assert_eq!(filter.problem(), Some("filter-problem-no-action"));
1423 filter.actions.push(Act::Move { folder: " ".into() });
1424 assert_eq!(filter.problem(), Some("filter-problem-folder"));
1425 filter.actions = vec![Act::Keyword { name: "$Junk".into() }];
1426 assert_eq!(filter.problem(), Some("filter-problem-keyword"));
1427 filter.actions = vec![Act::Keyword { name: "Factures".into() }];
1428 assert_eq!(filter.problem(), None);
1429 assert!(filter.runs_on("home"));
1430 assert!(!keyword("two words") && !keyword(FILTERED) && !keyword("\\Seen") && keyword("$label1"));
1431 }
1432
1433 #[test]
1434 fn the_first_move_decides_the_rest_adds_up() {
1435 let mail = statement();
1436 let message = Message::new(&mail, facts());
1437 let filter = |accounts: &[&str], value: &str, actions: Vec<Act>, stop: bool| Filter {
1438 accounts: accounts.iter().map(|a| a.to_string()).collect(),
1439 conditions: vec![Condition::new(Field::From, Test::Contains, value)],
1440 actions,
1441 stop,
1442 ..Filter::default()
1443 };
1444 let filters = vec![
1445 filter(&["work"], "@bank.example", vec![Act::Trash], false),
1446 filter(&[], "@bank.example", vec![Act::Move { folder: "Banque".into() }, Act::Read], false),
1447 filter(&["home"], "@nobody.example", vec![Act::Junk], false),
1448 filter(&[], "bank", vec![Act::Junk, Act::Flag, Act::Keyword { name: "Factures".into() }], true),
1449 filter(&[], "bank", vec![Act::Keyword { name: "never".into() }], false),
1450 ];
1451 let plan = plan(&filters, "home", &message);
1452 assert_eq!(plan.by, vec![1, 3], "the first is work's, the third does not match, the fifth comes after a stop");
1453 assert_eq!(plan.to, Some(Destination::Folder("Banque".into())));
1454 assert!(plan.read && plan.flag && plan.leaves() && plan.quiet());
1455 assert_eq!(plan.keywords, vec!["Factures".to_string()]);
1456 let mut off = filters.clone();
1458 off[1].enabled = false;
1459 assert_eq!(super::plan(&off, "home", &message).to, Some(Destination::Junk));
1460 assert!(super::plan(&filters[2..3], "home", &message).is_empty());
1461 let mut either = filter(&[], "@nobody.example", vec![Act::Flag], false);
1463 either.conditions.push(Condition::new(Field::Subject, Test::Contains, "relevé"));
1464 assert!(!either.matches(&message));
1465 either.join = Join::Any;
1466 assert!(either.matches(&message));
1467 }
1468
1469 #[derive(Deserialize)]
1470 struct Mail {
1471 #[serde(default, rename = "filter", deserialize_with = "lenient")]
1472 filters: Vec<Filter>,
1473 }
1474
1475 #[test]
1476 fn filters_read_and_write_as_the_configuration_has_them() {
1477 let text = r#"
1478 [[filter]]
1479 name = "Bank"
1480 accounts = ["home"]
1481 if = [{ field = "from", test = "contains", value = "@bank.example" }, { field = "date", test = "between", value = "2026-10-01", until = "2026-10-31" }]
1482 then = [{ do = "move", folder = "Banque" }, { do = "read" }]
1483 stop = true
1484
1485 [[filter]]
1486 match = "any"
1487 enabled = false
1488 if = [{ field = "attachment", test = "exists" }, { field = "frobnicate", test = "contains", value = "x" }]
1489 then = [{ do = "keyword", name = "$label1" }, { do = "explode" }]
1490
1491 [[filter]]
1492 if = "not a list"
1493 "#;
1494 let read: Mail = toml::from_str(text).unwrap();
1495 assert_eq!(read.filters.len(), 2, "the third does not read, and is left out alone");
1496 let bank = &read.filters[0];
1497 assert_eq!((bank.name.as_str(), bank.accounts.clone(), bank.join, bank.stop, bank.enabled), ("Bank", vec!["home".to_string()], Join::All, true, true));
1498 assert_eq!(bank.conditions[1], Condition { field: Field::Date, test: Test::Between, value: "2026-10-01".into(), until: "2026-10-31".into() });
1499 assert_eq!(bank.actions, vec![Act::Move { folder: "Banque".into() }, Act::Read]);
1500 let odd = &read.filters[1];
1502 assert_eq!((odd.join, odd.enabled), (Join::Any, false));
1503 assert_eq!(odd.conditions[1].field, Field::Unknown);
1504 assert_eq!(odd.actions[1], Act::Unknown);
1505 assert_eq!(odd.problem(), Some("filter-problem-unknown"));
1506 #[derive(Serialize)]
1508 struct Out<'a> {
1509 filter: &'a [Filter],
1510 }
1511 let written = toml::to_string(&Out { filter: &read.filters[..1] }).unwrap();
1512 assert!(!written.contains("enabled") && !written.contains("match"), "{written}");
1513 let again: Mail = toml::from_str(&written).unwrap();
1514 assert_eq!(again.filters, read.filters[..1].to_vec());
1515 }
1516
1517 #[test]
1518 fn a_filter_in_a_sentence() {
1519 let filter = Filter {
1520 conditions: vec![Condition::new(Field::From, Test::Contains, "@bank.example"), Condition::new(Field::AttachmentType, Test::Is, "pdf")],
1521 actions: vec![Act::Move { folder: "Banque".into() }, Act::Read],
1522 stop: true,
1523 ..Filter::default()
1524 };
1525 let en = Translator::new("en");
1526 assert_eq!(filter.said(&en), "From contains “@bank.example” and an attachment is a PDF → into “Banque”, marked read, and no other filter");
1527 let fr = Translator::new("fr");
1528 assert_eq!(filter.said(&fr), "De contient «\u{202f}@bank.example\u{202f}» et une pièce jointe est un PDF → dans «\u{202f}Banque\u{202f}», marqué comme lu, et aucun autre filtre");
1529 for tr in [&en, &fr] {
1531 let form = form(tr);
1532 let labels = form.fields.iter().flat_map(|f| std::iter::once(&f.label).chain(f.tests.iter().map(|t| &t.label))).chain(form.actions.iter().chain(&form.kinds).chain(&form.who).chain(&form.days).map(|c| &c.label));
1533 for label in labels {
1534 assert!(!label.starts_with("filter-") && !label.is_empty(), "{}: {label}", tr.language());
1535 }
1536 for field in Field::ALL {
1537 for &test in field.tests() {
1538 let value = match field.value(test) {
1539 Value::Nothing => "",
1540 Value::Text => "x",
1541 Value::Date | Value::Dates => "2026-10-01",
1542 Value::Time | Value::Times => "08:00",
1543 Value::Days => "sat,sun",
1544 Value::Size => "5 MB",
1545 Value::Kind => "image",
1546 Value::Who => "stranger",
1547 };
1548 let said = Condition { field, test, value: value.into(), until: value.into() }.said(tr);
1549 assert!(!said.contains("filter-") && !said.contains('{'), "{}: {field:?} {test:?}: {said}", tr.language());
1550 }
1551 }
1552 for id in Act::IDS {
1553 let said = Act::of(id, "x").said(tr);
1554 assert!(!said.starts_with("filter-"), "{}: {id}: {said}", tr.language());
1555 }
1556 }
1557 }
1558}