Skip to main content

sioul_sync/
filters.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Mail filters, on the server (docs/client.md, "Filters"; what they say and
5//! how a message is matched: `sioul_core::rules`).
6//!
7//! After each fetch, on every device that fetches (the window's watchers, a
8//! phone's background step, `sioul watch`), the inbox's arrivals still unread
9//! are judged as the Porch judges them, what it protects left out (mail set
10//! aside, hostile mail, the spam filter's review queue, a code you asked
11//! for), and what the filters that match say is done on the server: flags
12//! and keywords first, then the move, one command per folder. Never on an
13//! account's first fetch (its last two weeks), never on what was there
14//! before: running them on the whole inbox is yours to ask (`run`, unmarked).
15//!
16//! No race between devices: before acting, a device marks the message
17//! `$SioulFiltered` (`rules::FILTERED`) and acts only on what it marked.
18//! With CONDSTORE (RFC 7162), the mark is set only if nothing changed the
19//! message since its flags were read (`UNCHANGEDSINCE`): of two devices
20//! fetching it in the same instant, one marks it, the other's store fails and
21//! it leaves the message alone. Without CONDSTORE, the flags are read, then
22//! the mark set: two devices would have to read in the same round trip. A
23//! message read meanwhile, or gone from the inbox, is left alone. An act
24//! that fails takes the mark off again, and the message waits for the next
25//! fetch here (`Waiting`: three tries, two days) or another device's. A
26//! server that keeps no keywords of its own (no `\*` in PERMANENTFLAGS:
27//! Exchange, Outlook.com) cannot carry the mark: each device acts on what it
28//! fetched; a move, a flag or a read mark done twice leaves the message as once.
29//!
30//! What is told (`Done::to_tell`, for the new-mail notifications): a message
31//! a filter could not act on here is told as any new mail is, by the device
32//! that fetched it; one it acted on, read meanwhile or gone, is not. One
33//! another device marked is that device's to do: not told here yet, it waits
34//! here too and is looked at again at the next fetch (the other device's act
35//! wakes the inbox's watcher). Unmarked and still there, unread, it is this
36//! device's to do; still marked ten minutes on (`STUCK`: that device stopped
37//! half-way), this device takes it over: it takes the mark off, only if
38//! nothing changed the message meanwhile (CONDSTORE), marks it again as its
39//! own and acts, so that no message stays marked and unfiltered for good. An
40//! act that fails then is told and tried again, as any.
41
42use crate::SyncError;
43use crate::imap::{self, COMMAND, Imap, Server};
44use crate::mailbox;
45use async_imap::types::Flag;
46use futures_util::StreamExt;
47use serde::{Deserialize, Serialize};
48use sioul_core::card::ImapOrigin;
49use sioul_core::config::{Account, Config, state_dir};
50use sioul_core::folders::{self, Folder, Role};
51use sioul_core::maildir;
52use sioul_core::rules::{self, Destination, Plan};
53use std::collections::{BTreeMap, BTreeSet};
54use std::path::{Path, PathBuf};
55
56/// One message of an inbox, and what the filters do to it.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Job {
59    pub file: PathBuf,
60    pub plan: Plan,
61    /// Tried before and failed: it may be read now (the flags went, the move did not).
62    pub again: bool,
63    /// Found marked by another device before, since then (Unix seconds): looked at again.
64    pub claimed_since: Option<i64>,
65}
66
67/// Why a message could not be filtered.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum Failure {
70    /// No folder of that name in the account (as the filter names it).
71    NoFolder(String),
72    /// The server keeps no keywords: a filter whose only act is one has nothing to do.
73    NoKeywords,
74    /// The server refused, or did not answer.
75    Server(String),
76    /// The server renumbered the inbox since it was fetched here: no longer that message.
77    Renumbered,
78}
79
80/// What became of a run.
81#[derive(Debug, Clone, Default)]
82pub struct Done {
83    /// Filtered here.
84    pub acted: Vec<Job>,
85    /// Left alone, someone having dealt with it: read meanwhile, or gone from the inbox.
86    pub left: Vec<PathBuf>,
87    /// Marked by another device, which acts on it: not told here, and looked at again at the next fetch.
88    pub elsewhere: Vec<Job>,
89    /// Not done, and why; marked again for nobody: tried again at the next fetch. Told as new mail.
90    pub failed: Vec<(Job, Failure)>,
91    /// The server keeps no keywords: nothing was marked.
92    pub unmarked: bool,
93}
94
95impl Done {
96    /// The messages a filter could not act on here: told as any new mail is
97    /// (the window's `mailnote`), which held them back for the filters. A
98    /// retry that works later does not tell them again.
99    pub fn to_tell(&self) -> Vec<PathBuf> {
100        self.failed.iter().map(|(job, _)| job.file.clone()).collect()
101    }
102
103    /// What could not be done, in a sentence, for `account` (its address as
104    /// you read it): the first failure, with how many failed so; none when
105    /// everything was done.
106    pub fn said(&self, tr: &sioul_core::i18n::Translator, account: &str) -> Option<String> {
107        let (_, first) = self.failed.first()?;
108        let alike = self.failed.iter().filter(|(_, why)| std::mem::discriminant(why) == std::mem::discriminant(first)).count();
109        let mut args = tr.counted(alike);
110        args.set("account", account.to_string());
111        let id = match first {
112            Failure::NoFolder(folder) => {
113                args.set("folder", folder.clone());
114                "filter-failed-folder"
115            }
116            Failure::NoKeywords => "filter-failed-keywords",
117            Failure::Server(_) => "filter-failed-server",
118            Failure::Renumbered => "filter-failed-renumbered",
119        };
120        Some(tr.text(id, Some(&args)))
121    }
122}
123
124/// What the filters do to these messages of `account`'s inbox, judged as
125/// the Porch judges them now: what it protects, and what no filter takes, left out.
126pub fn jobs(config: &Config, account: &Account, files: &[PathBuf]) -> Vec<Job> {
127    let filters = &config.mail.filters;
128    if files.is_empty() || !filters.iter().any(|f| f.runs_on(&account.id)) {
129        return Vec::new();
130    }
131    let ties = sioul_core::links::LocalLinks::load(&sioul_core::links::LocalLinks::default_path());
132    let store = config.notes_root_path().and_then(|root| sioul_core::projects::ProjectStore::load(&root).ok()).map(|s| s.with_ties(&ties));
133    let known = sioul_core::porch::KnownSenders::load(&config.known_senders_path());
134    let senders = sioul_core::porch::Senders::load(config);
135    let now = jiff::Timestamp::now().as_second();
136    let judged = sioul_core::porch::judge(files, &config.mail_sources(), store.as_ref(), &known, &senders, now);
137    rules::plans(filters, &judged, &senders).into_iter().filter(|(file, _)| files.contains(file)).map(|(file, plan)| Job { file, plan, again: false, claimed_since: None }).collect()
138}
139
140/// The messages of `account`'s inbox kept here, read or not.
141pub fn inbox_files(account: &Account) -> Vec<PathBuf> {
142    let root = account.maildir_path();
143    ["new", "cur"]
144        .iter()
145        .filter_map(|sub| std::fs::read_dir(root.join(sub)).ok())
146        .flatten()
147        .filter_map(Result::ok)
148        .map(|e| e.path())
149        .filter(|p| maildir::origin_of(p).is_some())
150        .collect()
151}
152
153/// After a fetch of `account`'s inbox (`new`: its arrivals, oldest first,
154/// none at all when only something waits): those still unread, and those an
155/// earlier look left waiting (a failure, another device's mark), filtered on
156/// the server, each marked first. Nothing on its first fetch.
157pub fn after_fetch(config: &Config, account: &Account, password: &str, new: &[PathBuf], first: bool) -> Done {
158    if first || !config.mail.filters.iter().any(|f| f.runs_on(&account.id)) {
159        return Done::default();
160    }
161    let (waiting, mut settled) = Waiting::files_of(account, jiff::Timestamp::now().as_second());
162    let mut files: Vec<PathBuf> = new.iter().filter(|f| !maildir::flags_of(f).contains('S')).cloned().collect();
163    for (file, _) in &waiting {
164        if !files.contains(file) {
165            files.push(file.clone());
166        }
167    }
168    if files.is_empty() {
169        Waiting::note(account, &Done::default(), &settled);
170        return Done::default();
171    }
172    let mut jobs = jobs(config, account, &files);
173    for job in &mut jobs {
174        if let Some((_, wait)) = waiting.iter().find(|(file, _)| *file == job.file) {
175            if wait.elsewhere {
176                job.claimed_since = Some(wait.since);
177            } else {
178                job.again = true;
179            }
180        }
181    }
182    let done = if jobs.is_empty() {
183        Done::default()
184    } else {
185        run(account, password, &jobs, true).unwrap_or_else(|e| Done { failed: jobs.iter().map(|j| (j.clone(), Failure::Server(format!("{e:?}")))).collect(), ..Done::default() })
186    };
187    // What waited and no filter takes any more (changed meanwhile, read, set aside) waits no more.
188    settled.extend(waiting.iter().filter(|(file, _)| !jobs.iter().any(|j| &j.file == file)).map(|(_, wait)| (wait.validity, wait.uid)));
189    Waiting::note(account, &done, &settled);
190    done
191}
192
193/// Whether messages of `account` are due for another look here (another
194/// device's mark to see through; a failure to try again, ten minutes after
195/// the last try at least): its next fetch runs the filters for them, new mail or not.
196pub fn due(account: &Account) -> bool {
197    let now = jiff::Timestamp::now().as_second();
198    Waiting::load(&Waiting::path()).messages.iter().any(|w| w.account == account.id && w.due(now))
199}
200
201/// The plans done on `account`'s inbox, in one session: with `claim`, each
202/// message marked first, and left alone when another device marked it, it
203/// was read meanwhile or it left the inbox (after a fetch); without, every
204/// job, as you asked (the whole inbox). The copies here follow.
205pub fn run(account: &Account, password: &str, jobs: &[Job], claim: bool) -> Result<Done, SyncError> {
206    let server = Server::of(account)?;
207    let placed: Vec<(ImapOrigin, Job)> = jobs.iter().filter_map(|j| Some((maildir::origin_of(&j.file)?, j.clone()))).collect();
208    if placed.is_empty() {
209        return Ok(Done::default());
210    }
211    let done = crate::fetch::block_on(async {
212        let mut session = imap::open(&server, password).await?;
213        let done = run_in(&mut session, account, placed, claim).await;
214        let _ = session.logout().await;
215        done
216    })?;
217    for job in &done.acted {
218        follow_here(job);
219    }
220    Ok(done)
221}
222
223async fn run_in(session: &mut Imap, account: &Account, placed: Vec<(ImapOrigin, Job)>, claim: bool) -> Result<Done, SyncError> {
224    let capabilities = imap::within(COMMAND, session.capabilities()).await?.map_err(imap::server)?;
225    let (can_move, uidplus, condstore) = (capabilities.has_str("MOVE"), capabilities.has_str("UIDPLUS"), capabilities.has_str("CONDSTORE"));
226    let inbox = if condstore { imap::within(COMMAND, session.select_condstore("INBOX")).await? } else { imap::within(COMMAND, session.select("INBOX")).await? }.map_err(imap::server)?;
227    let validity = inbox.uid_validity.unwrap_or(0);
228    // Keywords of its own: `\*` among the flags it keeps, or the mark itself.
229    let marks = inbox.permanent_flags.iter().any(|f| matches!(f, Flag::MayCreate) || matches!(f, Flag::Custom(k) if k.eq_ignore_ascii_case(rules::FILTERED)));
230    let mut done = Done { unmarked: !marks, ..Done::default() };
231    let mut here: Vec<(u32, Job)> = Vec::new();
232    for (origin, job) in placed {
233        // Renumbered since: no longer this message, not filtered; told as new mail.
234        if origin.validity == validity { here.push((origin.uid, job)) } else { done.failed.push((job, Failure::Renumbered)) }
235    }
236    let mine = if claim { claimed(session, here, marks, condstore, &mut done).await? } else { here };
237    // Flags and keywords first, each message its own; then the moves, one per folder.
238    let mut moves: BTreeMap<String, Vec<(u32, Job)>> = BTreeMap::new();
239    let mut failed: Vec<(u32, Job, Failure)> = Vec::new();
240    for (uid, job) in mine {
241        let junk = job.plan.to == Some(Destination::Junk);
242        let mut added: Vec<String> = Vec::new();
243        if job.plan.read {
244            added.push("\\Seen".into());
245        }
246        if job.plan.flag {
247            added.push("\\Flagged".into());
248        }
249        if marks {
250            added.extend(job.plan.keywords.iter().cloned());
251            if junk {
252                added.push("$Junk".into());
253            }
254        }
255        // Nothing this server can keep, and nowhere to go: a keyword alone, without keywords.
256        if added.is_empty() && job.plan.to.is_none() {
257            failed.push((uid, job, Failure::NoKeywords));
258            continue;
259        }
260        if !added.is_empty() {
261            if let Err(e) = mailbox::store(session, &uid.to_string(), &format!("+FLAGS.SILENT ({})", added.join(" "))).await {
262                failed.push((uid, job, Failure::Server(format!("{e:?}"))));
263                continue;
264            }
265            if junk && marks {
266                let _ = mailbox::store(session, &uid.to_string(), "-FLAGS.SILENT ($NotJunk)").await;
267            }
268        }
269        match &job.plan.to {
270            None => done.acted.push(job),
271            Some(to) => match target(session, account, to).await {
272                Ok(Some(folder)) => moves.entry(folder).or_default().push((uid, job)),
273                // Into the inbox: where it is already.
274                Ok(None) => done.acted.push(job),
275                Err(why) => failed.push((uid, job, why)),
276            },
277        }
278    }
279    for (folder, list) in moves {
280        let uids: BTreeSet<u32> = list.iter().map(|(uid, _)| *uid).collect();
281        match mailbox::move_to(session, &mailbox::ranges(&uids), &folder, can_move, uidplus).await {
282            Ok(()) => done.acted.extend(list.into_iter().map(|(_, job)| job)),
283            Err(e) => failed.extend(list.into_iter().map(|(uid, job)| (uid, job, Failure::Server(format!("{e:?}"))))),
284        }
285    }
286    // Not done: unmarked again, for the next fetch or another device to try.
287    if claim && marks && !failed.is_empty() {
288        let uids: BTreeSet<u32> = failed.iter().map(|(uid, _, _)| *uid).collect();
289        let _ = mailbox::store(session, &mailbox::ranges(&uids), &format!("-FLAGS.SILENT ({})", rules::FILTERED)).await;
290    }
291    done.failed.extend(failed.into_iter().map(|(_, job, why)| (job, why)));
292    Ok(done)
293}
294
295/// The jobs this device may do: those still unread (or tried before), not
296/// marked by another device, still in the inbox, and those another device
297/// marked and left (`Found::Stuck`), their mark taken off first; then
298/// marked, each only if nothing changed it since its flags were read
299/// (CONDSTORE), else all at once.
300async fn claimed(session: &mut Imap, jobs: Vec<(u32, Job)>, marks: bool, condstore: bool, done: &mut Done) -> Result<Vec<(u32, Job)>, SyncError> {
301    let uids: BTreeSet<u32> = jobs.iter().map(|(uid, _)| *uid).collect();
302    // Each message's flags now: read, marked, and its mod-sequence where the server counts changes.
303    let mut now: BTreeMap<u32, (bool, bool, Option<u64>)> = BTreeMap::new();
304    {
305        let query = if condstore { "(UID FLAGS MODSEQ)" } else { "(UID FLAGS)" };
306        let mut fetches = imap::within(COMMAND, session.uid_fetch(mailbox::ranges(&uids), query)).await?.map_err(imap::server)?;
307        while let Some(fetch) = fetches.next().await {
308            let fetch = fetch.map_err(imap::server)?;
309            let Some(uid) = fetch.uid else { continue };
310            let seen = fetch.flags().any(|f| matches!(f, Flag::Seen));
311            let marked = fetch.flags().any(|f| matches!(&f, Flag::Custom(k) if k.eq_ignore_ascii_case(rules::FILTERED)));
312            now.insert(uid, (seen, marked, fetch.modseq));
313        }
314    }
315    let at = jiff::Timestamp::now().as_second();
316    let mut free: Vec<(u32, Job, Option<u64>)> = Vec::new();
317    let mut left: Vec<(u32, Job, Option<u64>)> = Vec::new();
318    for (uid, job) in jobs {
319        let flags = now.get(&uid);
320        let modseq = flags.and_then(|(_, _, modseq)| *modseq);
321        match found(flags.map(|(seen, marked, _)| (*seen, *marked)), &job, at) {
322            Found::Free => free.push((uid, job, modseq)),
323            Found::Settled => done.left.push(job.file),
324            Found::Elsewhere => done.elsewhere.push(job),
325            Found::Stuck => left.push((uid, job, modseq)),
326        }
327    }
328    // A mark another device left: taken off, only if nothing changed the
329    // message since its flags were read; then the message is free, its new
330    // mod-sequence the one the server gives back. Changed meanwhile (that
331    // device came back, or another took it over first): looked at again.
332    for (uid, job, modseq) in left {
333        let unmark = format!("-FLAGS.SILENT ({})", rules::FILTERED);
334        match modseq.filter(|_| condstore) {
335            Some(modseq) => match stored(session, &uid.to_string(), &format!("(UNCHANGEDSINCE {modseq}) {unmark}")).await?.get(&uid) {
336                Some(after) => free.push((uid, job, *after)),
337                None => done.elsewhere.push(job),
338            },
339            None => {
340                mailbox::store(session, &uid.to_string(), &unmark).await?;
341                free.push((uid, job, None));
342            }
343        }
344    }
345    if !marks || free.is_empty() {
346        return Ok(free.into_iter().map(|(uid, job, _)| (uid, job)).collect());
347    }
348    let mut mine = Vec::new();
349    let mut plain = Vec::new();
350    for (uid, job, modseq) in free {
351        match modseq.filter(|_| condstore) {
352            Some(modseq) => {
353                // Only the messages it changed come back, with their new mod-sequence (RFC 7162 §3.1.3).
354                let echoed = stored(session, &uid.to_string(), &format!("(UNCHANGEDSINCE {modseq}) +FLAGS.SILENT ({})", rules::FILTERED)).await?;
355                // Changed meanwhile: another device's mark, most likely; looked at again.
356                if echoed.contains_key(&uid) { mine.push((uid, job)) } else { done.elsewhere.push(job) }
357            }
358            None => plain.push((uid, job)),
359        }
360    }
361    if !plain.is_empty() {
362        let uids: BTreeSet<u32> = plain.iter().map(|(uid, _)| *uid).collect();
363        mailbox::store(session, &mailbox::ranges(&uids), &format!("+FLAGS.SILENT ({})", rules::FILTERED)).await?;
364        mine.extend(plain);
365    }
366    Ok(mine)
367}
368
369/// A STORE, and the messages the server says it changed, by UID, each with
370/// its new mod-sequence when the server counts changes (RFC 7162 §3.1.3).
371async fn stored(session: &mut Imap, uid: &str, query: &str) -> Result<BTreeMap<u32, Option<u64>>, SyncError> {
372    let mut answers = imap::within(COMMAND, session.uid_store(uid, query)).await?.map_err(imap::server)?;
373    let mut echoed = BTreeMap::new();
374    while let Some(answer) = answers.next().await {
375        let answer = answer.map_err(imap::server)?;
376        if let Some(uid) = answer.uid {
377            echoed.insert(uid, answer.modseq);
378        }
379    }
380    Ok(echoed)
381}
382
383/// What a device may do with a message it is to filter, by its flags on the server now.
384#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385enum Found {
386    /// Unread, unmarked: this device marks it and acts.
387    Free,
388    /// Read meanwhile, or gone from the inbox: someone dealt with it.
389    Settled,
390    /// Marked by another device, which acts on it: looked at again later.
391    Elsewhere,
392    /// Marked by another device that has not acted on it for `STUCK`: this
393    /// device takes the mark off and does it.
394    Stuck,
395}
396
397/// Another device's mark that stays this long, the message neither read nor
398/// gone: that device stopped half-way (closed, out of battery, out of reach).
399const STUCK: i64 = 10 * 60;
400
401/// What a device may do with `job`'s message, from its flags on the server
402/// (`flags`: read, marked; none: gone from the inbox), `now` in Unix seconds.
403fn found(flags: Option<(bool, bool)>, job: &Job, now: i64) -> Found {
404    match flags {
405        None => Found::Settled,
406        // Read meanwhile; a failure tried again goes on (its flags may have gone, not its move).
407        Some((true, _)) if !job.again => Found::Settled,
408        Some((_, true)) => match job.claimed_since {
409            Some(since) if now - since >= STUCK => Found::Stuck,
410            _ => Found::Elsewhere,
411        },
412        Some(_) => Found::Free,
413    }
414}
415
416/// The server's name of the folder a plan sends a message to; none for the inbox itself.
417async fn target(session: &mut Imap, account: &Account, to: &Destination) -> Result<Option<String>, Failure> {
418    let (role, name) = match to {
419        Destination::Junk => (Role::Junk, "Junk"),
420        Destination::Trash => (Role::Trash, "Trash"),
421        // As the Archive button: its archive, else Gmail's All Mail, else one made.
422        Destination::Archive => {
423            let known = mailbox::folders(&account.id);
424            if let Some(folder) = folders::preferred(&known, Role::Archive).or(folders::preferred(&known, Role::All)) {
425                return Ok(Some(folder.name.clone()));
426            }
427            (Role::Archive, "Archive")
428        }
429        Destination::Folder(name) => {
430            return match folder_named(&account.id, name) {
431                Some(folder) if folder.role == Role::Inbox => Ok(None),
432                Some(folder) => Ok(Some(folder.name)),
433                None => Err(Failure::NoFolder(name.clone())),
434            };
435        }
436    };
437    mailbox::role_folder(session, account, role, name).await.map(Some).map_err(|e| Failure::Server(format!("{e:?}")))
438}
439
440/// The folder of `account` a filter names: as you read it ("Banque",
441/// "Factures/2026"), else as its server writes it ("INBOX.Banque"), case
442/// aside, and the levels' separator, "/" or ".", either way.
443pub fn folder_named(account: &str, name: &str) -> Option<Folder> {
444    let wanted = name.trim().to_lowercase();
445    if wanted.is_empty() {
446        return None;
447    }
448    let all = mailbox::folders(account);
449    let levels = |text: &str| text.to_lowercase().replace(['/', '.'], "\u{1f}");
450    all.iter()
451        .find(|f| f.display.to_lowercase() == wanted || f.name.to_lowercase() == wanted)
452        .or_else(|| all.iter().find(|f| levels(&f.display) == levels(&wanted)))
453        .cloned()
454}
455
456/// The copy here, once the server took a job: gone when it left the inbox
457/// (the next fetch brings it into its folder), else its new flags.
458fn follow_here(job: &Job) {
459    let Some(file) = maildir::locate(&job.file) else { return };
460    if job.plan.leaves() {
461        let _ = std::fs::remove_file(&file);
462        return;
463    }
464    let mut flags = maildir::flags_of(&file);
465    for (wanted, letter) in [(job.plan.read, 'S'), (job.plan.flag, 'F')] {
466        if wanted && !flags.contains(letter) {
467            flags.push(letter);
468        }
469    }
470    let _ = maildir::set_flags(&file, &flags);
471}
472
473/// Messages whose filtering failed here, tried again at the next fetches
474/// (three tries in two days at most), and those another device marked,
475/// looked at again until it acts or `STUCK` (`$XDG_STATE_HOME/sioul/filters/waiting.toml`,
476/// by account and place on the server; never a word of the message).
477#[derive(Debug, Default, Serialize, Deserialize)]
478struct Waiting {
479    #[serde(default, rename = "message")]
480    messages: Vec<Wait>,
481}
482
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
484struct Wait {
485    account: String,
486    validity: u32,
487    uid: u32,
488    tries: u32,
489    /// The first failure, or when another device's mark was first found, Unix seconds.
490    since: i64,
491    /// Marked by another device: looked at again, never tried here while so.
492    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
493    elsewhere: bool,
494}
495
496const TRIES: u32 = 3;
497const WAITS: i64 = 2 * 86_400;
498/// A failure is tried again this long after the last try at least (counted from the first).
499const RETRY: i64 = 10 * 60;
500
501impl Wait {
502    /// Due for another look: another device's mark, at each fetch; a failure,
503    /// ten minutes after its last try at least.
504    fn due(&self, now: i64) -> bool {
505        self.elsewhere || now - self.since >= i64::from(self.tries) * RETRY
506    }
507}
508
509impl Waiting {
510    fn path() -> PathBuf {
511        state_dir().join("filters").join("waiting.toml")
512    }
513
514    fn load(path: &Path) -> Waiting {
515        std::fs::read_to_string(path).ok().and_then(|text| toml::from_str(&text).ok()).unwrap_or_default()
516    }
517
518    /// The files of `account`'s inbox due for another look at `now`, with
519    /// why; and where those no longer here were (gone from the inbox: waiting no more).
520    fn files_of(account: &Account, now: i64) -> (Vec<(PathBuf, Wait)>, Vec<(u32, u32)>) {
521        let waiting = Waiting::load(&Waiting::path());
522        let wanted: Vec<&Wait> = waiting.messages.iter().filter(|w| w.account == account.id).collect();
523        if wanted.is_empty() {
524            return (Vec::new(), Vec::new());
525        }
526        let here: Vec<(PathBuf, Wait)> = inbox_files(account)
527            .into_iter()
528            .filter_map(|file| {
529                let origin = maildir::origin_of(&file)?;
530                let wait = wanted.iter().find(|w| w.validity == origin.validity && w.uid == origin.uid)?;
531                Some((file, (*wait).clone()))
532            })
533            .collect();
534        let gone = wanted.iter().filter(|w| !here.iter().any(|(_, h)| h == **w)).map(|w| (w.validity, w.uid)).collect();
535        (here.into_iter().filter(|(_, wait)| wait.due(now)).collect(), gone)
536    }
537
538    /// What a run did: its failures wait (one more try each), what another
539    /// device marked waits to be looked at again, the rest and `settled` wait
540    /// no more; three tries, or two days, and a message waits no more.
541    fn note(account: &Account, done: &Done, settled: &[(u32, u32)]) {
542        let path = Waiting::path();
543        let at = |file: &PathBuf| maildir::origin_of(file).map(|o| (o.validity, o.uid));
544        let now = jiff::Timestamp::now().as_second();
545        // Never tried again: a keyword its server cannot keep, a renumbered inbox.
546        let (never, failed): (Vec<_>, Vec<_>) = done.failed.iter().partition(|(_, why)| matches!(why, Failure::NoKeywords | Failure::Renumbered));
547        let failed: BTreeSet<(u32, u32)> = failed.iter().filter_map(|(job, _)| at(&job.file)).collect();
548        // Another device's to do: since when it was first found so.
549        let elsewhere: BTreeMap<(u32, u32), i64> = done.elsewhere.iter().filter_map(|job| Some((at(&job.file)?, job.claimed_since.unwrap_or(now)))).collect();
550        let mut over: BTreeSet<(u32, u32)> = done.acted.iter().map(|j| &j.file).chain(&done.left).chain(never.iter().map(|(job, _)| &job.file)).filter_map(at).collect();
551        over.extend(settled.iter().copied());
552        if failed.is_empty() && elsewhere.is_empty() && over.is_empty() && !path.exists() {
553            return;
554        }
555        sioul_core::filelock::with_lock(&path, || {
556            let mut waiting = Waiting::load(&path);
557            let mine = |w: &Wait, (validity, uid): (u32, u32)| w.account == account.id && w.validity == validity && w.uid == uid;
558            for &origin in &failed {
559                match waiting.messages.iter_mut().find(|w| mine(w, origin)) {
560                    // Another device's until now: this device's own tries from here on.
561                    Some(wait) if wait.elsewhere => {
562                        wait.elsewhere = false;
563                        wait.tries = 1;
564                        wait.since = now;
565                    }
566                    Some(wait) => wait.tries += 1,
567                    None => waiting.messages.push(Wait { account: account.id.clone(), validity: origin.0, uid: origin.1, tries: 1, since: now, elsewhere: false }),
568                }
569            }
570            for (&origin, &since) in &elsewhere {
571                match waiting.messages.iter_mut().find(|w| mine(w, origin)) {
572                    Some(wait) if wait.elsewhere => {}
573                    // A failure here, another device's to do now: looked at again, not tried.
574                    Some(wait) => {
575                        wait.elsewhere = true;
576                        wait.since = since;
577                    }
578                    None => waiting.messages.push(Wait { account: account.id.clone(), validity: origin.0, uid: origin.1, tries: 0, since, elsewhere: true }),
579                }
580            }
581            waiting.messages.retain(|w| !(w.account == account.id && over.contains(&(w.validity, w.uid))) && (w.elsewhere || w.tries < TRIES) && now - w.since < WAITS);
582            let text = toml::to_string(&waiting).unwrap_or_default();
583            if let Some(parent) = path.parent() {
584                let _ = std::fs::create_dir_all(parent);
585            }
586            let _ = std::fs::write(&path, text);
587        });
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use sioul_core::rules::{Act, Condition, Field, Filter, Test};
595
596    #[test]
597    fn a_filter_names_a_folder_as_you_read_it() {
598        crate::dav::stand_in::home();
599        let account = format!("filters-folders-{}", std::process::id());
600        let path = sioul_core::folders::saved_path(&account);
601        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
602        let listed = [sioul_core::folders::folder("INBOX", Some("."), None), sioul_core::folders::folder("INBOX.Banque", Some("."), None), sioul_core::folders::folder("INBOX.Factures.2026", Some("."), None)];
603        mailbox::save(&account, &listed).unwrap();
604        assert_eq!(folder_named(&account, "banque").map(|f| f.name), Some("INBOX.Banque".into()));
605        assert_eq!(folder_named(&account, "INBOX.Banque").map(|f| f.name), Some("INBOX.Banque".into()));
606        assert_eq!(folder_named(&account, "Factures/2026").map(|f| f.name), Some("INBOX.Factures.2026".into()));
607        assert_eq!(folder_named(&account, "inbox").map(|f| f.role), Some(Role::Inbox));
608        assert_eq!(folder_named(&account, "Impôts"), None);
609        let _ = std::fs::remove_file(path);
610    }
611
612    #[test]
613    fn what_a_job_leaves_here() {
614        let root = std::env::temp_dir().join(format!("sioul-filters-here-{}", std::process::id()));
615        let _ = std::fs::remove_dir_all(&root);
616        let raw = b"From: a@bank.example\r\nSubject: x\r\n\r\nx\r\n".to_vec();
617        let stored = |uid| maildir::store(&root, &maildir::Fetched { origin: ImapOrigin { validity: 7, uid }, flags: String::new(), received: None, raw: &raw }).unwrap();
618        let (kept, moved) = (stored(1), stored(2));
619        follow_here(&Job { file: kept.clone(), plan: Plan { read: true, flag: true, by: vec![0], ..Plan::default() }, again: false, claimed_since: None });
620        let kept = maildir::locate(&kept).unwrap();
621        assert!(maildir::flags_of(&kept).contains('S') && maildir::flags_of(&kept).contains('F'));
622        follow_here(&Job { file: moved.clone(), plan: Plan { to: Some(Destination::Folder("Banque".into())), by: vec![0], ..Plan::default() }, again: false, claimed_since: None });
623        assert!(maildir::locate(&moved).is_none(), "moved: the next fetch brings it into its folder");
624        let _ = std::fs::remove_dir_all(&root);
625        // A filter that matches nothing makes no job; the jobs say what each would do.
626        let filter = Filter { conditions: vec![Condition::new(Field::From, Test::Contains, "@bank.example")], actions: vec![Act::Read], ..Filter::default() };
627        assert!(filter.runs_on("home"));
628    }
629
630    #[test]
631    fn what_is_told_and_what_waits() {
632        let job = |name: &str, again: bool, claimed_since: Option<i64>| Job { file: PathBuf::from(name), plan: Plan { read: true, by: vec![0], ..Plan::default() }, again, claimed_since };
633        let now = 1_800_000_000;
634        // Gone or read: someone dealt with it. Another device's mark: its to do, ten minutes long. Unmarked and unread: ours.
635        assert_eq!(found(None, &job("a", false, None), now), Found::Settled);
636        assert_eq!(found(Some((true, false)), &job("a", false, None), now), Found::Settled);
637        assert_eq!(found(Some((true, false)), &job("a", true, None), now), Found::Free, "a failure tried again goes on: its move may be missing still");
638        assert_eq!(found(Some((false, true)), &job("a", false, None), now), Found::Elsewhere);
639        assert_eq!(found(Some((false, true)), &job("a", false, Some(now - 60)), now), Found::Elsewhere);
640        assert_eq!(found(Some((false, true)), &job("a", false, Some(now - STUCK)), now), Found::Stuck, "that device stopped half-way: taken over here");
641        assert_eq!(found(Some((true, true)), &job("a", false, Some(now - STUCK)), now), Found::Settled, "read meanwhile: nothing to tell");
642        assert_eq!(found(Some((false, false)), &job("a", false, Some(now - 60)), now), Found::Free, "the other device's mark gone, the message still there and unread: ours to do");
643        // Looked at again: another device's mark at each fetch; a failure ten minutes after its last try.
644        let wait = |tries: u32, since: i64, elsewhere: bool| Wait { account: "home".into(), validity: 7, uid: 1, tries, since, elsewhere };
645        assert!(wait(0, now, true).due(now));
646        assert!(!wait(1, now - 60, false).due(now) && wait(1, now - RETRY, false).due(now));
647        assert!(!wait(2, now - RETRY, false).due(now) && wait(2, now - 2 * RETRY, false).due(now));
648        // Told as new mail: whatever a filter could not act on here; not what it did, what someone dealt with, nor what another device holds.
649        let done = Done {
650            acted: vec![job("moved", false, None)],
651            left: vec![PathBuf::from("read")],
652            elsewhere: vec![job("held", false, Some(now))],
653            failed: vec![(job("no-folder", false, None), Failure::NoFolder("Banque".into())), (job("renumbered", false, None), Failure::Renumbered)],
654            unmarked: false,
655        };
656        assert_eq!(done.to_tell(), ["no-folder", "renumbered"].map(PathBuf::from).to_vec());
657        assert!(Done::default().to_tell().is_empty());
658        // Said: the first failure, with how many failed so.
659        let tr = sioul_core::i18n::Translator::new("en");
660        let said = done.said(&tr, "noa@example.org").unwrap();
661        assert!(said.starts_with("One message of noa@example.org could not be moved") && said.contains("“Banque”"), "{said}");
662        let renumbered = Done { failed: done.failed[1..].to_vec(), ..Done::default() };
663        assert!(renumbered.said(&tr, "noa@example.org").unwrap().contains("renumbered"));
664        assert!(Done::default().said(&tr, "x").is_none());
665    }
666
667    /// Against GreenMail (docs/building.md), with invented mail only:
668    /// `SIOUL_TEST_INSECURE_TLS=1 cargo test -p sioul-sync --features
669    /// insecure-test-tls -- --ignored greenmail_filters`. Two devices filter
670    /// the same arrivals: one acts, the other leaves them; a code is never
671    /// touched; a folder missing fails, unmarks, and the next fetch tries
672    /// again; the run you ask for takes read mail too.
673    #[cfg(feature = "insecure-test-tls")]
674    #[test]
675    #[ignore]
676    fn greenmail_filters_act_once_and_try_again() {
677        use sioul_core::config::Security;
678        let home = crate::dav::stand_in::home();
679        assert!(std::env::var_os("SIOUL_TEST_INSECURE_TLS").is_some(), "GreenMail tests need SIOUL_TEST_INSECURE_TLS=1 and the container sioul-greenmail (docs/building.md)");
680        let login = format!("filters-{}-{}@example.org", std::process::id(), jiff::Timestamp::now().as_second());
681        let mut account = Account::imap("filters-tester", &login, "localhost", 3993, Security::Tls, None);
682        account.maildir = Some(home.join(format!("mail-filters-{}", std::process::id())).display().to_string());
683        let server = Server::of(&account).unwrap();
684        // Dated now: a code is a code while it is valid.
685        let date = jiff::Zoned::now().strftime("%a, %d %b %Y %H:%M:%S %z").to_string();
686        let message = |from: &str, subject: &str, extra: &str, body: &str| format!("From: {from}\r\nTo: {login}\r\nSubject: {subject}\r\nDate: {date}\r\nMessage-ID: <{}.{}@example.org>\r\n{extra}MIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n{body}\r\n", subject.replace(' ', "."), std::process::id()).into_bytes();
687        let on_server = |what: &'static str, folder: &'static str| {
688            crate::fetch::block_on(async {
689                let mut session = imap::open(&server, "x").await?;
690                imap::within(COMMAND, session.select(folder)).await?.map_err(imap::server)?;
691                let mut found: Vec<(String, Vec<String>)> = Vec::new();
692                {
693                    let mut fetches = imap::within(COMMAND, session.uid_fetch("1:*", "(UID FLAGS BODY.PEEK[HEADER])")).await?.map_err(imap::server)?;
694                    while let Some(fetch) = fetches.next().await {
695                        let fetch = fetch.map_err(imap::server)?;
696                        let header = String::from_utf8_lossy(fetch.header().unwrap_or_default()).to_string();
697                        let subject = header.lines().find_map(|l| l.strip_prefix("Subject:")).unwrap_or("").trim().to_string();
698                        found.push((subject, fetch.flags().map(|f| format!("{f:?}")).collect()));
699                    }
700                }
701                let _ = session.logout().await;
702                let _ = what;
703                Ok(found)
704            })
705            .unwrap()
706        };
707        // A first fetch of an empty inbox: the next ones bring arrivals.
708        crate::fetch::block_on(async {
709            let mut session = imap::open(&server, "x").await?;
710            let _ = imap::within(COMMAND, session.create("Banque")).await?;
711            let listed = mailbox::list(&mut session).await?;
712            mailbox::save(&account.id, &listed)?;
713            let _ = session.logout().await;
714            Ok(())
715        })
716        .unwrap();
717        assert!(crate::fetch::inbox(&account, "x").unwrap().first);
718        let arrivals = [
719            message("Banque Exemple <alertes@bank.example>", "Votre relevé", "", "Votre relevé d’octobre est prêt."),
720            message("Type & Pixels <news@shop.example>", "La lettre", "List-Id: <lettre.shop.example>\r\n", "Les nouvelles."),
721            message("Paul <paul@example.org>", "Déjeuner", "", "Midi ?"),
722            message("Banque Exemple <no-reply@bank.example>", "Your sign-in code", "", "Your Bank Example code: 482913. It expires in 10 minutes."),
723            message("Boutique Exemple <commandes@shop.example>", "Votre facture", "", "Votre facture est jointe."),
724        ];
725        crate::fetch::block_on(async {
726            let mut session = imap::open(&server, "x").await?;
727            for raw in &arrivals {
728                imap::within(COMMAND, session.append("INBOX", None, None, raw)).await?.map_err(imap::server)?;
729            }
730            // Another device marked the newsletter first.
731            imap::within(COMMAND, session.select("INBOX")).await?.map_err(imap::server)?;
732            mailbox::store(&mut session, "2", &format!("+FLAGS.SILENT ({})", rules::FILTERED)).await?;
733            let _ = session.logout().await;
734            Ok(())
735        })
736        .unwrap();
737        let report = crate::fetch::inbox(&account, "x").unwrap();
738        assert!(!report.first && report.new.len() == 5, "{report:?}");
739        let filter = |field, value: &str, actions: Vec<Act>| Filter { conditions: vec![Condition::new(field, Test::Contains, value)], actions, ..Filter::default() };
740        let mut config = Config::default();
741        config.accounts = vec![account.clone()];
742        config.mail.filters = vec![
743            filter(Field::From, "@bank.example", vec![Act::Move { folder: "banque".into() }, Act::Read]),
744            Filter { conditions: vec![Condition::new(Field::List, Test::Exists, "")], actions: vec![Act::Flag], ..Filter::default() },
745            filter(Field::Subject, "déjeuner", vec![Act::Move { folder: "Nowhere".into() }]),
746            // No archive on this server: one is made, as the Archive button makes it.
747            filter(Field::Subject, "facture", vec![Act::Archive]),
748        ];
749        let done = after_fetch(&config, &account, "x", &report.new, false);
750        let subjects = |files: &[PathBuf]| files.iter().filter_map(|f| maildir::read_one(f)).map(|c| c.subject).collect::<Vec<_>>();
751        let mut by: Vec<Vec<usize>> = done.acted.iter().map(|j| j.plan.by.clone()).collect();
752        by.sort();
753        assert_eq!(by, vec![vec![0], vec![3]], "{done:?}");
754        let held: Vec<PathBuf> = done.elsewhere.iter().map(|j| j.file.clone()).collect();
755        assert_eq!(subjects(&held), vec!["La lettre".to_string()], "marked by another device: its to do, not told here");
756        assert!(done.left.is_empty(), "{done:?}");
757        assert_eq!(done.failed.len(), 1, "{done:?}");
758        assert_eq!(done.failed[0].1, Failure::NoFolder("Nowhere".into()));
759        // Told as new mail: what a filter could not act on, Paul's; not what was done, nor what another device holds.
760        assert_eq!(subjects(&done.to_tell()), vec!["Déjeuner".to_string()]);
761        assert!(!done.unmarked, "GreenMail keeps keywords");
762        // On the server: the statement in Banque, read and marked; the code, the newsletter, Paul's in the inbox, Paul's unmarked again.
763        let inbox = on_server("inbox", "INBOX");
764        let banque = on_server("banque", "Banque");
765        assert_eq!(banque.len(), 1, "{banque:?}");
766        assert_eq!(on_server("archive", "Archive").len(), 1, "the invoice archived");
767        assert!(banque[0].0.contains("relev") && banque[0].1.iter().any(|f| f.contains("Seen")) && banque[0].1.iter().any(|f| f.contains(rules::FILTERED)), "{banque:?}");
768        assert_eq!(inbox.len(), 3, "{inbox:?}");
769        let paul = inbox.iter().find(|(s, _)| s.contains("jeuner")).unwrap();
770        assert!(!paul.1.iter().any(|f| f.contains(rules::FILTERED)), "a failed act unmarks: {paul:?}");
771        let code = inbox.iter().find(|(s, _)| s.contains("code")).unwrap();
772        assert!(code.1.is_empty() || !code.1.iter().any(|f| f.contains("Seen")), "a code is never filtered: {code:?}");
773        // The copy here followed: gone from the inbox.
774        assert_eq!(inbox_files(&account).len(), 3);
775        // A second device with the same arrivals: the statement and the invoice are gone; nothing to do.
776        let again = run(&account, "x", &done.acted, true).unwrap();
777        assert!(again.acted.is_empty() && again.left.len() == 2, "{again:?}");
778        // The folder made: the next fetch tries Paul's message again, from the waiting list.
779        crate::fetch::block_on(async {
780            let mut session = imap::open(&server, "x").await?;
781            let _ = imap::within(COMMAND, session.create("Nowhere")).await?;
782            let listed = mailbox::list(&mut session).await?;
783            mailbox::save(&account.id, &listed)?;
784            let _ = session.logout().await;
785            Ok(())
786        })
787        .unwrap();
788        // Paul's is tried again ten minutes on; the newsletter is looked at again at each fetch.
789        assert!(due(&account), "the newsletter to look at again: the next fetch runs the filters, new mail or not");
790        let path = Waiting::path();
791        let mut aged = Waiting::load(&path);
792        for wait in aged.messages.iter_mut().filter(|w| !w.elsewhere) {
793            wait.since -= RETRY;
794        }
795        std::fs::write(&path, toml::to_string(&aged).unwrap()).unwrap();
796        let retried = after_fetch(&config, &account, "x", &[], false);
797        assert_eq!(retried.acted.len(), 1, "{retried:?}");
798        assert_eq!(on_server("nowhere", "Nowhere").len(), 1);
799        assert!(retried.to_tell().is_empty() && retried.elsewhere.len() == 1, "the newsletter is still the other device's: {retried:?}");
800        let (waits, gone) = Waiting::files_of(&account, jiff::Timestamp::now().as_second());
801        assert_eq!((waits.iter().map(|(_, w)| w.elsewhere).collect::<Vec<_>>(), gone.len()), (vec![true], 0), "Paul's done; the newsletter looked at again");
802        // That device stopped half-way: its mark stays ten minutes, the message
803        // neither read nor gone. Taken over here: its mark off, marked again,
804        // filtered (flagged), not told, and waiting no more.
805        let mut aged = Waiting::load(&path);
806        for wait in &mut aged.messages {
807            wait.since -= STUCK;
808        }
809        std::fs::write(&path, toml::to_string(&aged).unwrap()).unwrap();
810        let stuck = after_fetch(&config, &account, "x", &[], false);
811        assert!(stuck.failed.is_empty() && stuck.elsewhere.is_empty() && stuck.to_tell().is_empty(), "{stuck:?}");
812        assert_eq!(stuck.acted.iter().map(|j| j.plan.by.clone()).collect::<Vec<_>>(), vec![vec![1]], "{stuck:?}");
813        let inbox = on_server("inbox", "INBOX");
814        assert!(inbox.iter().any(|(s, f)| s.contains("lettre") && f.iter().any(|f| f.contains("Flagged")) && f.iter().any(|f| f.contains(rules::FILTERED))), "{inbox:?}");
815        assert!(Waiting::files_of(&account, i64::MAX / 2).0.is_empty() && !due(&account));
816        // The run you ask for: read mail too, unmarked; the newsletter, read on the server, flagged again.
817        crate::fetch::block_on(async {
818            let mut session = imap::open(&server, "x").await?;
819            imap::within(COMMAND, session.select("INBOX")).await?.map_err(imap::server)?;
820            mailbox::store(&mut session, "1:*", "+FLAGS.SILENT (\\Seen)").await?;
821            let _ = session.logout().await;
822            Ok(())
823        })
824        .unwrap();
825        let _ = crate::fetch::inbox(&account, "x").unwrap();
826        let all = jobs(&config, &account, &inbox_files(&account));
827        assert_eq!(all.len(), 1, "the newsletter alone: the code stays protected while valid: {all:?}");
828        let asked = run(&account, "x", &all, false).unwrap();
829        assert_eq!(asked.acted.len(), 1, "{asked:?}");
830        let inbox = on_server("inbox", "INBOX");
831        assert!(inbox.iter().any(|(s, f)| s.contains("lettre") && f.iter().any(|f| f.contains("Flagged"))), "{inbox:?}");
832    }
833}