Skip to main content

sioul_core/
rules.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Mail filters: what a message shows, and what is then done to it on its
5//! server (docs/client.md, "Filters").
6//!
7//! A filter holds conditions, each a field, a test and a value ("From
8//! contains @bank.example"), all of them needed or one enough; and actions:
9//! into a folder, to the archive, to spam, flagged, read, to the trash, a keyword. One flat
10//! list serves every account, each filter on all of them or on some, in the
11//! configuration (`[[mail.filter]]`), shared with it. Filters run on what
12//! arrives unread in an inbox, on whichever device fetches it first
13//! (sioul-sync's `filters`), and on the rest of the inbox when you ask. The
14//! Mail page's search reads the same conditions (`Condition::matches` on a
15//! `Message`).
16//!
17//! Text is compared as Sioul's search compares it: case and accents aside
18//! (`text::fold_char`), each run of spaces one space. A sender nothing
19//! authenticates is read as nobody's: a condition on who sent it holds for no
20//! such message, as a project's route does not (docs/porch.md, "Trust").
21//!
22//! At the end, Virtual Secretary's words (docs/virtual-secretary.md): the
23//! folder it moved a message to becomes a proposal here (`FolderMapping`).
24
25use 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// ---------------------------------------------------------------- conditions
34
35/// What a condition reads of a message.
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "kebab-case")]
38pub enum Field {
39    /// Who sent it: their name and address.
40    From,
41    /// To whom: names and addresses.
42    To,
43    /// Who was copied.
44    Cc,
45    /// Where answers go, when the message names somewhere.
46    ReplyTo,
47    Subject,
48    /// Its text, an HTML message read as text.
49    Body,
50    /// Its attachments: whether it has one, their names.
51    Attachment,
52    /// The kind of file attached (`KINDS`).
53    AttachmentType,
54    /// Who its sender is to you (`WHO`; docs/porch.md, "Who may reach you").
55    Sender,
56    /// A newsletter or a mailing list, and which one (its List-Id).
57    List,
58    /// The day it arrived.
59    Date,
60    /// The day of the week it arrived.
61    Weekday,
62    /// The time of day it arrived.
63    Hour,
64    /// Its size.
65    Size,
66    /// Its sender, recipients, subject or text, any of them: the Mail page's
67    /// search starts with it, and a filter made from a search keeps it.
68    Anywhere,
69    /// The search only: the address it belongs to here, by its account's id
70    /// (a filter says that with its `accounts`). Needs `Message::placed`.
71    Account,
72    /// The search only: its folder, by purpose (`ROLES`: "inbox", "archive"…)
73    /// or by its server's name. Needs `Message::placed`.
74    Folder,
75    /// The search only: read, flagged or answered (`MARKS`). Needs `Message::placed`.
76    Mark,
77    /// A field this Sioul does not know (a newer one wrote it, or a hand): never holds.
78    #[default]
79    #[serde(other)]
80    Unknown,
81}
82
83impl Field {
84    /// Every field, in the order the editor offers them.
85    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    /// The fields the Mail page's search offers, in its order: the filters'
104    /// that say something of mail already here, Anywhere first, and its own
105    /// three (address, folder, marks), which no filter reads.
106    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    /// As the configuration writes it: "from", "reply-to".
125    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    /// The tests it takes, the usual one first.
154    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            // A search's place and marks: an address's id, a folder, one of `MARKS`, all as text.
164            Field::Account | Field::Folder | Field::Mark => &[Test::Is, Test::IsNot],
165            Field::Unknown => &[],
166        }
167    }
168
169    /// What its value is, under `test`.
170    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/// How a condition compares its field with its value.
187#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
188#[serde(rename_all = "kebab-case")]
189pub enum Test {
190    Contains,
191    NotContains,
192    /// The whole of it: an address or a name, a subject, a list.
193    Is,
194    IsNot,
195    /// Before a day (that day left out), before a time.
196    Before,
197    /// After a day (that day left out); from a time on.
198    After,
199    /// From a day to another, both included; from a time to another, across midnight too (22:00 to 06:00).
200    Between,
201    /// Larger than.
202    Above,
203    /// Smaller than.
204    Below,
205    /// There is one: an attachment; a newsletter or a list.
206    Exists,
207    /// There is none.
208    Missing,
209    /// A test this Sioul does not know: never holds.
210    #[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    /// As the configuration writes it: "contains", "not-contains".
219    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    /// The test a negative one turns around: "does not contain" is "contains", turned.
241    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/// What a condition's value is, for the editor to ask it.
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
253#[serde(rename_all = "kebab-case")]
254pub enum Value {
255    /// None: "has an attachment".
256    Nothing,
257    Text,
258    /// A day: "2026-10-01".
259    Date,
260    /// Two days, `value` and `until`.
261    Dates,
262    /// A time of day: "18:00".
263    Time,
264    /// Two times, `value` and `until`.
265    Times,
266    /// Days of the week, `DAYS` joined by commas: "sat,sun".
267    Days,
268    /// A size: "5 MB", "500 KB".
269    Size,
270    /// A kind of attachment, one of `KINDS`.
271    Kind,
272    /// Who the sender is to you, one of `WHO`.
273    Who,
274}
275
276/// The kinds of attachment a condition names.
277pub const KINDS: [&str; 8] = ["pdf", "image", "document", "archive", "calendar", "audio", "video", "text"];
278
279/// Who a sender may be to you, as a condition names it: "known" is anyone in
280/// your address books or on a list (safe, neutral or restricted). The blocked
281/// are not among them: their mail is set aside, and no filter touches it.
282pub const WHO: [&str; 5] = ["known", "safe", "neutral", "restricted", "stranger"];
283
284/// The days of the week as a condition names them, Monday first.
285pub const DAYS: [&str; 7] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
286
287/// The marks a search's condition names (`Field::Mark`): "is not read" is unread.
288pub const MARKS: [&str; 3] = ["read", "flagged", "answered"];
289
290/// The folders a search's condition names by purpose (`Field::Folder`),
291/// whatever their name on each server; Gmail's "All Mail" is its archive.
292pub const ROLES: [(&str, Role); 6] = [("inbox", Role::Inbox), ("sent", Role::Sent), ("drafts", Role::Drafts), ("archive", Role::Archive), ("junk", Role::Junk), ("trash", Role::Trash)];
293
294/// Whether a folder condition's value (a purpose of `ROLES`, or a server's
295/// own name) names a folder of `role` and `name`.
296pub 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/// Where a message is and its marks, for the search's own conditions
306/// (`Field::Account`, `Field::Folder`, `Field::Mark`): a filter has none.
307#[derive(Debug, Clone, PartialEq, Eq)]
308pub struct Place {
309    /// Its account's id.
310    pub account: String,
311    /// Its folder's server name.
312    pub folder: String,
313    pub role: Role,
314    /// Its Maildir letters: S read, F flagged, R answered.
315    pub flags: String,
316}
317
318/// One condition: a field, a test, a value.
319#[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    /// Text, a day ("2026-10-01"), a time ("18:00"), days ("sat,sun"), a size
326    /// ("5 MB"), a kind ("pdf"), who ("stranger"); empty for "has an attachment".
327    #[serde(default, skip_serializing_if = "String::is_empty")]
328    pub value: String,
329    /// The end of a range ("between"): a day or a time.
330    #[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    /// What keeps it from being read, as a message id ("filter-problem-value");
340    /// none when it reads. One that does not read never holds.
341    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    /// Whether `message` shows it. One that does not read, or that the
367    /// message cannot answer (its size unknown, its sender not judged), never holds.
368    pub fn matches(&self, message: &Message) -> bool {
369        self.problem().is_none() && self.holds(message).unwrap_or(false)
370    }
371
372    /// Whether it holds; none when the message cannot tell.
373    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    /// The condition in words: "From contains “@bank.example”", "has a PDF attached".
443    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
490/// Whether all conditions hold (`Join::All`), or one (`Join::Any`). None never holds.
491pub 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/// Every condition, or one of them.
499#[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// ---------------------------------------------------------------- the message read
514
515/// What a condition knows of a message beyond its card, given by whoever asks.
516#[derive(Debug, Clone)]
517pub struct Facts {
518    /// Who its sender is to you (`porch::Senders::who_of`); none when not
519    /// judged: a condition on the sender then never holds.
520    pub who: Option<Who>,
521    /// Nothing proves its sender (forged; SPF and DKIM failed, nothing
522    /// vouching: `trust::authenticated`): read as from nobody.
523    pub unproven: bool,
524    /// Its size in bytes; none when unknown.
525    pub size: Option<u64>,
526    /// When it arrived, as Unix seconds (`porch::sent`); none when unknown.
527    pub arrived: Option<i64>,
528    /// Where its day and its hour are read: this device's time zone.
529    pub zone: jiff::tz::TimeZone,
530}
531
532impl Facts {
533    /// What its file says: its size, when it arrived; its sender not judged.
534    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/// One address of a message, folded.
541#[derive(Debug, Clone, Default)]
542struct Person {
543    name: String,
544    address: String,
545    /// `name <address>`, for "contains".
546    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
561/// A message as conditions read it: its card and the facts beside it, each
562/// field folded once, when a condition first asks for it.
563pub 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    /// Where it is and its marks: the search's (`placed`); none for a filter.
576    place: Option<Place>,
577}
578
579/// The characters of a card's text kept beside it (`card::EXCERPT_LIMIT`):
580/// one shorter holds the whole text; one this long may be cut.
581const 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    /// With where it is and its marks, for the search's own conditions.
602    pub fn placed(mut self, place: Place) -> Message<'a> {
603        self.place = Some(place);
604        self
605    }
606
607    /// With what its file says (`Facts::of`): its sender not judged.
608    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            // Whom it claims to come from, or to answer to, says nothing when nothing proves it.
621            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    /// Whether its text holds `value`: the start kept with the card first,
639    /// the whole text read again from its file only when that start may be cut.
640    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    /// When it arrived, in this device's time zone.
660    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
666/// The addresses of a header (To, Cc, Reply-To), their names decoded.
667fn 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
696/// The kind of an attached file, by its type, else by its name's ending (`KINDS`; "other" for the rest).
697pub 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
724/// Text as conditions compare it: case and accents aside, each run of spaces one space, trimmed.
725pub 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
742/// A day as a condition writes it: "2026-10-01".
743fn day(text: &str) -> Option<jiff::civil::Date> {
744    text.trim().parse().ok()
745}
746
747/// A time of day as a condition writes it: "18:00", "8:30".
748fn 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
754/// Days of the week as a condition writes them ("sat,sun"): their places from Monday (0), in order.
755fn 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
762/// A size as a condition writes it, in bytes: "5 MB", "500 KB", "1.5 Go"; megabytes when it says no unit.
763pub 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
778/// A size in words: "5 MB", "500 KB", in your language's units and decimals.
779fn 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
786/// Words joined: "Saturday or Sunday"; "Monday, Tuesday or Friday".
787fn 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// ---------------------------------------------------------------- actions
796
797/// What a filter does to a message that matches, on its server.
798#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
799#[serde(tag = "do", rename_all = "kebab-case")]
800pub enum Act {
801    /// Into one of its account's folders, named as you read it ("Banque",
802    /// "Factures/2026") or as its server does ("INBOX.Banque").
803    Move { folder: String },
804    /// Into its account's archive, whatever its server calls it ("Archive",
805    /// Gmail's "All Mail"), as the Archive button does: out of the inbox, kept.
806    Archive,
807    /// Spam: marked `$Junk` and moved into its account's Junk folder, as the
808    /// Junk button does, but with no line in the spam filter's label log: a
809    /// filter is no word of yours on that one message.
810    Junk,
811    /// Flagged: important, to follow.
812    Flag,
813    /// Marked read.
814    Read,
815    /// Into the trash, and kept there: never deleted for good.
816    Trash,
817    /// An IMAP keyword, which other mail programs show as a tag ("$label1", "Factures").
818    Keyword { name: String },
819    /// An action this Sioul does not know: the filter waits until it is changed.
820    #[serde(other)]
821    Unknown,
822}
823
824impl Act {
825    /// The actions as the editor offers them, by their ids.
826    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    /// An action from its id and what it names (a folder, a keyword).
842    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    /// What it names: its folder, its keyword; "" for the others.
856    pub fn name(&self) -> &str {
857        match self {
858            Act::Move { folder } => folder,
859            Act::Keyword { name } => name,
860            _ => "",
861        }
862    }
863
864    /// What keeps it from being done, as a message id; none when it can be.
865    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    /// The action in words: "into “Banque”", "marked read"; one still to
875    /// finish (its folder not chosen yet), said so.
876    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
886/// The keyword that says filters went through a message: set on its server
887/// before they act on it, so that another device fetching it too leaves it
888/// alone (sioul-sync's `filters`).
889pub const FILTERED: &str = "$SioulFiltered";
890
891/// Whether `name` can be an IMAP keyword a filter adds (RFC 9051 §2.3.2, an
892/// atom): no space, no `( ) { % * " \ ]`, no control character; never a
893/// system flag, the spam keywords (the spam action sets those) nor Sioul's own mark.
894pub 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// ---------------------------------------------------------------- filters
907
908/// One filter: its conditions, what it does, on which accounts.
909#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
910pub struct Filter {
911    /// Its name, as you gave it; "" lets its sentence name it.
912    #[serde(default, skip_serializing_if = "String::is_empty")]
913    pub name: String,
914    /// Switched off, it is kept and not run.
915    #[serde(default = "on", skip_serializing_if = "is_on")]
916    pub enabled: bool,
917    /// The accounts it runs on, by id; none: every account.
918    #[serde(default, skip_serializing_if = "Vec::is_empty")]
919    pub accounts: Vec<String>,
920    /// Every condition, or one of them.
921    #[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    /// Once it matched, the filters after it are not asked.
928    #[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    /// What keeps it from running, as a message id; none when it can:
948    /// a condition at least, an action at least, each readable.
949    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    /// Whether it holds a field, a test or an action this Sioul does not
960    /// know: a newer one wrote it (`config::set_filters` then keeps it as written).
961    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    /// Whether it runs on `account`'s mail: on, complete, for that account.
966    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    /// Whether `message` shows its conditions: all of them, or one.
971    pub fn matches(&self, message: &Message) -> bool {
972        holds(&self.conditions, self.join, message)
973    }
974
975    /// The filter in one line: "From contains “@bank.example” → into “Banque”, marked read".
976    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        // A sentence: its first condition may begin in lower case ("sent by a newsletter…").
990        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
999/// Reads the filters of the configuration one by one: one that does not read
1000/// is left out, never the whole configuration with it.
1001pub 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/// Where a filter sends a message.
1008#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1009#[serde(rename_all = "lowercase")]
1010pub enum Destination {
1011    /// One of its account's folders, as the filter names it.
1012    Folder(String),
1013    /// Its account's archive, found by its purpose.
1014    Archive,
1015    Junk,
1016    Trash,
1017}
1018
1019/// What the filters do to one message, once added up: the first filter that
1020/// moves it says where it goes; reads, flags and keywords add up.
1021#[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    /// The filters that matched, by their place in the list.
1028    pub by: Vec<usize>,
1029}
1030
1031impl Plan {
1032    /// No filter matched.
1033    pub fn is_empty(&self) -> bool {
1034        self.by.is_empty()
1035    }
1036
1037    /// It leaves the inbox: into a folder, the junk, the trash.
1038    pub fn leaves(&self) -> bool {
1039        self.to.is_some()
1040    }
1041
1042    /// Never told as new mail: it leaves the inbox, or it is marked read (as mail read elsewhere).
1043    pub fn quiet(&self) -> bool {
1044        self.leaves() || self.read
1045    }
1046}
1047
1048/// What `filters` do to `message`, which came to `account`: each filter that
1049/// runs on that account and matches, in their order, until one that stops
1050/// the rest.
1051pub 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
1089/// Whether the Porch keeps a message from every filter: set aside (forged,
1090/// blocked, a borrowed name, your provider's spam), hostile, in your spam
1091/// filter's review queue (it moves or flags that mail itself), or a code you
1092/// asked for, which must reach you where you look for it.
1093pub 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
1098/// The facts beside a message the Porch judged: who its sender is to you,
1099/// and whether anything proves it is them.
1100pub 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
1105/// What `filters` do to each message the Porch judged, for its account;
1106/// none for what it protects (`untouched`), nor where no filter matches.
1107pub 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// ---------------------------------------------------------------- the editor's words
1121
1122/// One choice the editor offers: its id and its words.
1123#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1124pub struct Choice {
1125    pub id: String,
1126    pub label: String,
1127}
1128
1129/// A field as the editor offers it: its tests, and what each asks for.
1130#[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/// Everything the filters' editor offers, in your language.
1145#[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    /// Monday first, short names.
1152    pub days: Vec<Choice>,
1153}
1154
1155/// The editor's words.
1156pub 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// ---------------------------------------------------------------- Virtual Secretary
1175
1176/// What Virtual Secretary's sorting proposes for a message.
1177#[derive(Debug, Clone, PartialEq, Eq)]
1178pub enum Proposed {
1179    /// Put the message in a project, by id.
1180    AssignProject(String),
1181    /// File it on a shelf, out of the way.
1182    File(Shelf),
1183    /// Set it aside, with the reason.
1184    SetAside(String),
1185    /// A free label, like Virtual Secretary's tags or IMAP keywords.
1186    Label(String),
1187    /// Teach the classifier it is junk, and set it aside.
1188    Junk,
1189    /// To the trash, recoverable for 30 days.
1190    Trash,
1191    /// Flagged, to follow.
1192    Flag,
1193    /// Propose a task in the message's project.
1194    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    /// Shelf names as written in the configuration.
1207    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/// Virtual Secretary, compatibility mode A: it keeps sorting on the server;
1219/// the IMAP folder it moved a message to becomes a proposal here.
1220#[derive(Debug, Clone, Deserialize)]
1221pub struct FolderMapping {
1222    /// The IMAP folder, as Virtual Secretary names it: "INBOX.Money.Taxes".
1223    pub folder: String,
1224    /// The project its mail goes to; `case`, the word before the name changed, reads too.
1225    #[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    /// What a message found in `folder` should become.
1235    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    // ------------------------------------------------------------ filters, on invented mail
1276
1277    fn card(raw: &str) -> Card {
1278        Card::from_bytes(raw.replace('\n', "\r\n").as_bytes()).unwrap()
1279    }
1280
1281    /// A bank's statement: a name with an accent in Cc, a list, a PDF.
1282    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    /// Thursday 1 October 2026, 10:00 at UTC+2; 5,000 bytes; a neutral sender.
1309    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        // The sender: its name and address, either whole for "is".
1323        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        // Recipients, a name decoded and folded.
1330        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        // The subject and the text: case, accents and runs of spaces aside.
1337        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        // Attachments: one there, its name, its kind.
1343        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        // A list, and which.
1349        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        // Days: the day itself is neither before nor after it; "between" holds both ends.
1359        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        // Its day of the week and its hour, in its zone: Thursday, 10:00.
1365        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        // Its size: 5,000 bytes.
1374        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        // Unknown, nothing holds: neither larger nor smaller.
1378        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        // Judged: neutral, someone you know.
1388        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        // Not judged: neither "is" nor "is not" holds.
1392        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        // Forged, or failing SPF and DKIM: the name it shows is no one's.
1396        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        // What it says beside the sender still reads.
1402        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        // Turned around, one that does not read holds no more than before.
1417        assert!(!Condition::new(Field::Subject, Test::NotContains, "").matches(&Message::of(&mail)));
1418        // A filter needs a condition and an action.
1419        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        // Switched off, a filter is not asked; none matching, nothing to do.
1457        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        // "Any": one condition is enough; "all": every one.
1462        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        // What this Sioul does not know is kept as unknown: that filter waits, said.
1501        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        // Written back, it reads the same, the usual values left out.
1507        #[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        // Every field, test and action has its words, in both languages.
1530        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}