Skip to main content

sioul_app/
filters.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Mail filters in the window (docs/client.md, "Filters"; the rules are
5//! `sioul_core::rules`, what reaches the server `sioul_sync::filters`).
6//!
7//! In Mail ▸ ⚙, under "Filters" (`MailFilters.qml`): the one list, each
8//! filter in words, narrowed to an address when you choose one; edited in
9//! place and saved at once into the configuration, as every setting is;
10//! "Try it", which counts what a filter takes in your inboxes now; and
11//! running them all on the inboxes, read mail included: said first, done
12//! after ten seconds to undo (`mail::schedule_filters`).
13//!
14//! After each fetch, on the watcher's own thread (never the window's), the
15//! arrivals still unread are filtered on the server (`after_fetch`); what a
16//! filter moves out of the inbox or marks read is never told as new mail
17//! (`untold`, for `mailnote`), whichever device acts on it.
18
19use crate::backend::{QtThread, Shared, config_path, load_config, say, tr};
20use cxx_qt_lib::QString;
21use serde::{Deserialize, Serialize};
22use sioul_core::config::{Account, Config};
23use sioul_core::folders::Role;
24use sioul_core::porch::{Senders, Triaged};
25use sioul_core::rules::{self, Act, Condition, Field, Filter, Join, Test};
26use sioul_sync::filters::{Done, Job};
27use std::path::PathBuf;
28use std::sync::{Arc, Mutex};
29
30/// "Try it" reads the newest messages of the inboxes, this many at most: enough to see what a filter takes.
31const TRIED: usize = 2000;
32/// Messages named under "Try it": a few, to see that they are the right ones.
33const NAMED: usize = 3;
34
35/// A filter as the editor has it: each part plain, its sentence, what keeps
36/// it from running, and to which addresses alone it applies (said, never read back).
37#[derive(Debug, Clone, Default, Serialize, Deserialize)]
38struct Edited {
39    #[serde(default)]
40    name: String,
41    #[serde(default = "yes")]
42    enabled: bool,
43    #[serde(default)]
44    accounts: Vec<String>,
45    /// One condition is enough.
46    #[serde(default)]
47    any: bool,
48    #[serde(default)]
49    conditions: Vec<EditedCondition>,
50    #[serde(default)]
51    actions: Vec<EditedAct>,
52    #[serde(default)]
53    stop: bool,
54    #[serde(default, skip_deserializing)]
55    said: String,
56    #[serde(default, skip_deserializing)]
57    problem: String,
58    #[serde(default, skip_deserializing)]
59    only: String,
60}
61
62fn yes() -> bool {
63    true
64}
65
66#[derive(Debug, Clone, Default, Serialize, Deserialize)]
67struct EditedCondition {
68    #[serde(default)]
69    field: String,
70    #[serde(default)]
71    test: String,
72    #[serde(default)]
73    value: String,
74    #[serde(default)]
75    until: String,
76}
77
78#[derive(Debug, Clone, Default, Serialize, Deserialize)]
79struct EditedAct {
80    #[serde(default, rename = "do")]
81    id: String,
82    /// Its folder, its keyword.
83    #[serde(default)]
84    name: String,
85}
86
87impl Edited {
88    fn of(filter: &Filter, config: &Config) -> Edited {
89        let only = if filter.accounts.is_empty() {
90            String::new()
91        } else {
92            let names: Vec<String> = filter.accounts.iter().map(|id| config.account(id).and_then(|a| a.address.clone()).unwrap_or_else(|| id.clone())).collect();
93            say("filter-only", &[("accounts", names.join(", "))])
94        };
95        Edited {
96            name: filter.name.clone(),
97            enabled: filter.enabled,
98            accounts: filter.accounts.clone(),
99            any: filter.join == Join::Any,
100            conditions: filter.conditions.iter().map(|c| EditedCondition { field: c.field.id().into(), test: c.test.id().into(), value: c.value.clone(), until: c.until.clone() }).collect(),
101            actions: filter.actions.iter().map(|a| EditedAct { id: a.id().into(), name: a.name().to_string() }).collect(),
102            stop: filter.stop,
103            said: filter.said(tr()),
104            problem: filter.problem().map(|id| tr().text(id, None)).unwrap_or_default(),
105            only,
106        }
107    }
108
109    fn filter(&self) -> Filter {
110        Filter {
111            name: self.name.trim().to_string(),
112            enabled: self.enabled,
113            accounts: self.accounts.iter().map(|a| a.trim().to_string()).filter(|a| !a.is_empty()).collect(),
114            join: if self.any { Join::Any } else { Join::All },
115            conditions: self
116                .conditions
117                .iter()
118                .map(|c| Condition {
119                    field: Field::parse(&c.field).unwrap_or(Field::Unknown),
120                    test: Test::parse(&c.test).unwrap_or(Test::Unknown),
121                    value: c.value.trim().to_string(),
122                    until: c.until.trim().to_string(),
123                })
124                .collect(),
125            actions: self.actions.iter().map(|a| Act::of(&a.id, &a.name)).collect(),
126            stop: self.stop,
127        }
128    }
129}
130
131/// An address, for the narrowing and the editor's "On which addresses".
132#[derive(Debug, Serialize)]
133struct Address {
134    id: String,
135    label: String,
136}
137
138/// A folder a filter may move mail into, by the name you read, with the addresses that have it.
139#[derive(Debug, Serialize)]
140struct FolderChoice {
141    name: String,
142    accounts: Vec<String>,
143}
144
145#[derive(Debug, Serialize)]
146struct View {
147    filters: Vec<Edited>,
148    accounts: Vec<Address>,
149    folders: Vec<FolderChoice>,
150    form: rules::Form,
151    /// The filter to open in the editor, by its place: one just made from a search; -1 for none.
152    open: i64,
153    /// What that search held that a filter does not read, said; "" else.
154    note: String,
155}
156
157/// A filter just made from a search (`new_from`): its place in the list, and
158/// what of the search was left out, said; taken by the next `view`, which opens it.
159static OPENED: Mutex<Option<(usize, String)>> = Mutex::new(None);
160
161/// The filters as the editor shows them, with the addresses, their folders and the editor's words, as JSON.
162pub(crate) fn view() -> String {
163    let config = load_config();
164    let (open, note) = OPENED.lock().ok().and_then(|mut opened| opened.take()).filter(|(at, _)| *at < config.mail.filters.len()).map_or((-1, String::new()), |(at, note)| (at as i64, note));
165    let accounts: Vec<&Account> = config.accounts.iter().filter(|a| a.syncs()).collect();
166    let mut folders: Vec<FolderChoice> = Vec::new();
167    for account in &accounts {
168        for folder in sioul_sync::mailbox::folders(&account.id).into_iter().filter(|f| matches!(f.role, Role::Other | Role::Archive)) {
169            match folders.iter_mut().find(|c| c.name.to_lowercase() == folder.display.to_lowercase()) {
170                Some(choice) => choice.accounts.push(account.id.clone()),
171                None => folders.push(FolderChoice { name: folder.display.clone(), accounts: vec![account.id.clone()] }),
172            }
173        }
174    }
175    folders.sort_by_key(|f| f.name.to_lowercase());
176    let view = View {
177        filters: config.mail.filters.iter().map(|f| Edited::of(f, &config)).collect(),
178        accounts: accounts.iter().map(|a| Address { id: a.id.clone(), label: a.address.clone().unwrap_or_else(|| a.id.clone()) }).collect(),
179        folders,
180        form: rules::form(tr()),
181        open,
182        note,
183    };
184    serde_json::to_string(&view).unwrap_or_default()
185}
186
187/// What "Make it a filter…" gives (MailSearch.qml): the search's conditions,
188/// as `rules::Condition` writes them, all of them or any; a name and actions
189/// when a whole filter is given (the window's pictures), as the editor has them.
190#[derive(Debug, Default, Deserialize)]
191struct Given {
192    #[serde(default)]
193    conditions: Vec<Condition>,
194    #[serde(default, rename = "match")]
195    join: Join,
196    #[serde(default)]
197    name: String,
198    #[serde(default)]
199    actions: Vec<EditedAct>,
200}
201
202/// A new filter, last in the list, from a search (`Given`, as JSON), saved
203/// and opened in the editor the next time the list is shown; what went wrong, else "".
204pub(crate) fn new_from(given: &str) -> String {
205    let given: Given = match serde_json::from_str(given) {
206        Ok(given) => given,
207        Err(e) => return e.to_string(),
208    };
209    // Added last to the list as the configuration holds it now, under its lock, which the sharing takes too.
210    let path = config_path();
211    let added = sioul_core::filelock::with_lock(&path, || {
212        let config = if path.exists() { Config::load(&path)? } else { load_config() };
213        let ids: Vec<String> = config.accounts.iter().filter(|a| a.syncs()).map(|a| a.id.clone()).collect();
214        let (filter, left_out) = from_search(given, &ids);
215        let mut filters = config.mail.filters.clone();
216        filters.push(filter);
217        sioul_core::config::set_filters(&path, &filters).map(|()| (filters, left_out))
218    });
219    let (filters, left_out) = match added {
220        Ok(added) => added,
221        Err(e) => return e,
222    };
223    let note = if left_out { tr().text("filter-from-search-left", None) } else { String::new() };
224    if let Ok(mut opened) = OPENED.lock() {
225        *opened = Some((filters.len() - 1, note));
226    }
227    String::new()
228}
229
230/// A filter from a search's conditions, and whether some were left out. A
231/// filter reads what arrives in an inbox: the search's address conditions
232/// become the addresses it runs on; a folder condition other than the inbox,
233/// and a mark other than "not read", are left out. Without a condition left,
234/// one to fill; without an action given, one folder to choose.
235fn from_search(given: Given, ids: &[String]) -> (Filter, bool) {
236    let mut left_out = false;
237    let mut only: Option<Vec<String>> = None;
238    let mut conditions = Vec::new();
239    for condition in given.conditions {
240        let value = condition.value.trim().to_string();
241        match condition.field {
242            Field::Account => {
243                let kept = only.take().unwrap_or_else(|| ids.to_vec());
244                only = Some(kept.into_iter().filter(|id| (*id == value) == (condition.test == Test::Is)).collect());
245            }
246            Field::Folder if condition.test == Test::Is && rules::folder_named(&value, Role::Inbox, "") => {}
247            Field::Mark if condition.test == Test::IsNot && value == "read" => {}
248            Field::Folder | Field::Mark => left_out = true,
249            _ => conditions.push(condition),
250        }
251    }
252    let accounts = match only {
253        // Every address, or none (conditions at odds): the filter runs on every one.
254        Some(list) if list.is_empty() || list.len() == ids.len() => {
255            left_out |= list.is_empty();
256            Vec::new()
257        }
258        Some(list) => list,
259        None => Vec::new(),
260    };
261    if conditions.is_empty() {
262        conditions.push(Condition::new(Field::From, Test::Contains, ""));
263    }
264    let mut actions: Vec<Act> = given.actions.iter().map(|a| Act::of(&a.id, &a.name)).collect();
265    if actions.is_empty() {
266        actions.push(Act::Move { folder: String::new() });
267    }
268    let filter = Filter { name: given.name.trim().to_string(), accounts, join: given.join, conditions, actions, ..Filter::default() };
269    (filter, left_out)
270}
271
272/// The list written, in its order, as the editor has it (JSON), from the list
273/// it showed (`shown`, JSON; "" for none): the editor's change set over the
274/// filters as the configuration holds them now, under its lock, which the
275/// sharing takes too (`settings::rebased`): a filter another device added
276/// meanwhile stays. What went wrong, else "".
277pub(crate) fn save(list: &str, shown: &str) -> String {
278    let edited: Vec<Edited> = match serde_json::from_str(list) {
279        Ok(edited) => edited,
280        Err(e) => return e.to_string(),
281    };
282    let filters: Vec<Filter> = edited.iter().map(Edited::filter).collect();
283    let shown: Option<Vec<Filter>> = serde_json::from_str::<Vec<Edited>>(shown).ok().map(|shown| shown.iter().map(Edited::filter).collect());
284    let path = config_path();
285    sioul_core::filelock::with_lock(&path, || {
286        let filters = match &shown {
287            Some(shown) if path.exists() => {
288                let config = Config::load(&path)?;
289                // As the editor would show them: what a newer Sioul wrote in them aside (`set_filters` keeps it).
290                let current: Vec<Filter> = config.mail.filters.iter().map(|f| Edited::of(f, &config).filter()).collect();
291                sioul_core::settings::rebased(shown, &filters, &current)
292            }
293            _ => filters,
294        };
295        sioul_core::config::set_filters(&path, &filters)
296    })
297    .err()
298    .unwrap_or_default()
299}
300
301/// One filter being edited, as the list would show it: {said, problem, only}, as JSON.
302pub(crate) fn said(one: &str) -> String {
303    let config = load_config();
304    let edited: Edited = serde_json::from_str(one).unwrap_or_default();
305    let shown = Edited::of(&edited.filter(), &config);
306    serde_json::json!({ "said": shown.said, "problem": shown.problem, "only": shown.only }).to_string()
307}
308
309/// What "Try it" and "Run them on the inboxes" found, for the window.
310#[derive(Debug, Default, Serialize)]
311struct Found {
312    /// "try" for one filter, "preview" for all of them.
313    kind: String,
314    /// The count, in a sentence.
315    text: String,
316    /// "Paul, Déjeuner": a few of the messages one filter takes; each filter's count and sentence for all of them.
317    lines: Vec<String>,
318    /// Messages that would change.
319    count: usize,
320}
321
322/// What all the filters would do, kept from the preview for the run, so that it does what was said.
323static PREVIEWED: Mutex<Vec<(Account, Vec<Job>)>> = Mutex::new(Vec::new());
324
325/// The inboxes' messages kept here, the newest first, at most `limit`, by account.
326fn inboxes(config: &Config, limit: Option<usize>) -> (Vec<(Account, Vec<PathBuf>)>, usize, bool) {
327    let mut all: Vec<(i64, usize, PathBuf)> = Vec::new();
328    let accounts: Vec<Account> = config.accounts.iter().filter(|a| a.syncs()).cloned().collect();
329    for (at, account) in accounts.iter().enumerate() {
330        for file in sioul_sync::filters::inbox_files(account) {
331            // Stored when: the start of its name (`maildir::store`).
332            let stored = file.file_name().and_then(|n| n.to_str()).and_then(|n| n.split('.').next()).and_then(|s| s.parse().ok()).unwrap_or(0);
333            all.push((stored, at, file));
334        }
335    }
336    let total = all.len();
337    all.sort_by_key(|(stored, _, _)| std::cmp::Reverse(*stored));
338    let cut = limit.is_some_and(|limit| total > limit);
339    if let Some(limit) = limit {
340        all.truncate(limit);
341    }
342    let mut by: Vec<(Account, Vec<PathBuf>)> = accounts.into_iter().map(|a| (a, Vec::new())).collect();
343    for (_, at, file) in all {
344        by[at].1.push(file);
345    }
346    (by, total.min(limit.unwrap_or(total)), cut)
347}
348
349/// "Try it": what one filter, as it is being edited (JSON), takes in the
350/// inboxes now, the newest messages first; `mail_filters_found` brings it.
351pub(crate) fn try_one(qt: QtThread, one: String) {
352    std::thread::spawn(move || {
353        let edited: Edited = serde_json::from_str(&one).unwrap_or_default();
354        let mut filter = edited.filter();
355        filter.enabled = true;
356        let found = match filter.problem() {
357            Some(id) => Found { kind: "try".into(), text: tr().text(id, None), ..Found::default() },
358            None => {
359                let mut config = load_config();
360                config.mail.filters = vec![filter];
361                let (inboxes, total, cut) = inboxes(&config, Some(TRIED));
362                let jobs: Vec<Job> = inboxes.iter().flat_map(|(account, files)| sioul_sync::filters::jobs(&config, account, files)).collect();
363                let mut args = tr().counted(jobs.len());
364                args.set("total", total);
365                let text = tr().text(if cut { "filter-tried-newest" } else { "filter-tried" }, Some(&args));
366                let lines = jobs.iter().filter_map(|job| sioul_core::maildir::read_one(&job.file)).take(NAMED).map(|card| say("filter-tried-one", &[("who", card.sender().to_string()), ("subject", card.subject.clone())])).collect();
367                Found { kind: "try".into(), text, lines, count: jobs.len() }
368            }
369        };
370        let json = serde_json::to_string(&found).unwrap_or_default();
371        let _ = qt.queue(move |mut sioul| sioul.as_mut().mail_filters_found(QString::from(&json)));
372    });
373}
374
375/// "Run them on the inboxes": what every filter would do to every message
376/// of the inboxes, read ones included, said first; kept for the run.
377pub(crate) fn preview(qt: QtThread) {
378    std::thread::spawn(move || {
379        let config = load_config();
380        let (inboxes, _, _) = inboxes(&config, None);
381        let planned: Vec<(Account, Vec<Job>)> = inboxes.iter().map(|(account, files)| (account.clone(), sioul_sync::filters::jobs(&config, account, files))).filter(|(_, jobs)| !jobs.is_empty()).collect();
382        let count: usize = planned.iter().map(|(_, jobs)| jobs.len()).sum();
383        let mut lines = Vec::new();
384        for (at, filter) in config.mail.filters.iter().enumerate() {
385            let took = planned.iter().flat_map(|(_, jobs)| jobs).filter(|job| job.plan.by.contains(&at)).count();
386            if took > 0 {
387                let mut args = tr().counted(took);
388                args.set("filter", if filter.name.is_empty() { filter.said(tr()) } else { format!("{} ({})", filter.name, filter.said(tr())) });
389                lines.push(tr().text("filter-preview-line", Some(&args)));
390            }
391        }
392        let text = tr().text(if count == 0 { "filter-preview-none" } else { "filter-preview" }, Some(&tr().counted(count)));
393        if let Ok(mut kept) = PREVIEWED.lock() {
394            *kept = planned;
395        }
396        let json = serde_json::to_string(&Found { kind: "preview".into(), text, lines, count }).unwrap_or_default();
397        let _ = qt.queue(move |mut sioul| sioul.as_mut().mail_filters_found(QString::from(&json)));
398    });
399}
400
401/// "Run them": what the preview said, after ten seconds to undo. What went wrong, else "".
402pub(crate) fn run(qt: &QtThread, shared: &Arc<Shared>) -> String {
403    let planned = PREVIEWED.lock().map(|mut kept| std::mem::take(&mut *kept)).unwrap_or_default();
404    let count: usize = planned.iter().map(|(_, jobs)| jobs.len()).sum();
405    if count == 0 {
406        return tr().text("filter-preview-none", None);
407    }
408    let line = tr().text("filter-running", Some(&tr().counted(count)));
409    crate::mail::schedule_filters(qt, shared, planned, line);
410    String::new()
411}
412
413/// The run, once its ten seconds are over: each account in one connection;
414/// what the status line says.
415pub(crate) fn perform(planned: &[(Account, Vec<Job>)]) -> Option<String> {
416    let mut done = Done::default();
417    // An account that could not be reached: the others are still done, the first such said.
418    let mut unreached: Option<String> = None;
419    for (account, jobs) in planned {
420        let result = sioul_sync::secret::password(account).and_then(|password| sioul_sync::filters::run(account, &password, jobs, false));
421        match result {
422            Ok(one) => {
423                done.acted.extend(one.acted);
424                done.failed.extend(one.failed);
425            }
426            Err(e) => {
427                unreached.get_or_insert_with(|| e.sentence(tr(), &account.id));
428            }
429        }
430    }
431    let mut line = tr().text("filter-ran", Some(&tr().counted(done.acted.len())));
432    for why in [failures(&done), unreached].into_iter().flatten() {
433        line = format!("{line} {why}");
434    }
435    Some(line)
436}
437
438/// What could not be done, in a sentence, for the account of its first
439/// failure (`Done::said`); none when everything was.
440fn failures(done: &Done) -> Option<String> {
441    let (job, _) = done.failed.first()?;
442    let account = load_config().account_of(&job.file).map(|a| a.address.clone().unwrap_or_else(|| a.id.clone())).unwrap_or_default();
443    done.said(tr(), &account)
444}
445
446/// What a filters' run after a fetch did: how many messages it took, what
447/// could not be done, said ("" when all was), and the messages to tell as
448/// new mail all the same (`mailnote::unfiltered`).
449#[derive(Debug, Default)]
450pub(crate) struct Filtered {
451    pub(crate) acted: usize,
452    pub(crate) problem: String,
453    pub(crate) tell: Vec<PathBuf>,
454}
455
456/// After a fetch of `account` (its inbox's arrivals, `new`; none when only
457/// something waits for another look): the filters on the server, on a thread
458/// that may wait for it. What a filter could not act on is to be told as any
459/// new mail, the notifications having held it back for them (`untold`).
460pub(crate) fn after_fetch(account: &Account, new: &[PathBuf], first: bool) -> Filtered {
461    let config = load_config();
462    if first || !config.mail.filters.iter().any(|f| f.runs_on(&account.id)) {
463        return Filtered::default();
464    }
465    let Ok(password) = sioul_sync::secret::password(account) else {
466        // Nothing could be done: what a filter would have taken quietly is told.
467        let unread: Vec<PathBuf> = new.iter().filter(|f| !sioul_core::maildir::flags_of(f).contains('S')).cloned().collect();
468        let tell = sioul_sync::filters::jobs(&config, account, &unread).into_iter().filter(|job| job.plan.quiet()).map(|job| job.file).collect();
469        return Filtered { tell, ..Filtered::default() };
470    };
471    let done = sioul_sync::filters::after_fetch(&config, account, &password, new, first);
472    if !done.failed.is_empty() {
473        eprintln!("sioul: filters: {}: {:?}", account.id, done.failed.iter().map(|(_, f)| f).collect::<Vec<_>>());
474    }
475    Filtered { acted: done.acted.len(), problem: failures(&done).unwrap_or_default(), tell: done.to_tell() }
476}
477
478/// New mail as the notifications first see it: without what a filter would
479/// move out of the inbox, put in the junk or the trash, or mark read, held
480/// back until this device's filters have run (docs/porch.md, "Notifications").
481/// Then what they did is never told, nor what another device marked first and
482/// does; what they could not act on is told as any new mail (`after_fetch`'s
483/// `tell`, `mailnote::unfiltered`).
484pub(crate) fn untold(config: &Config, arrivals: Vec<Triaged>, senders: &Senders) -> Vec<Triaged> {
485    if config.mail.filters.is_empty() {
486        return arrivals;
487    }
488    let quiet: Vec<PathBuf> = rules::plans(&config.mail.filters, &arrivals, senders).into_iter().filter(|(_, plan)| plan.quiet()).map(|(path, _)| path).collect();
489    arrivals.into_iter().filter(|t| t.card.path.as_ref().is_none_or(|p| !quiet.contains(p))).collect()
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    #[test]
497    fn the_editor_and_the_configuration_say_the_same() {
498        let filter = Filter {
499            name: "Bank".into(),
500            accounts: vec!["home".into()],
501            join: Join::Any,
502            conditions: vec![Condition::new(Field::From, Test::Contains, "@bank.example"), Condition { field: Field::Hour, test: Test::Between, value: "22:00".into(), until: "06:00".into() }],
503            actions: vec![Act::Move { folder: "Banque".into() }, Act::Read, Act::Keyword { name: "$label1".into() }],
504            stop: true,
505            ..Filter::default()
506        };
507        let edited = Edited::of(&filter, &Config::default());
508        let json = serde_json::to_string(&edited).unwrap();
509        let back: Edited = serde_json::from_str(&json).unwrap();
510        assert_eq!(back.filter(), filter);
511        assert!(back.said.is_empty() && !edited.said.is_empty(), "said, never read back");
512        // What the editor does not fill reads as a new filter's.
513        let fresh: Edited = serde_json::from_str("{}").unwrap();
514        assert_eq!(fresh.filter(), Filter::default());
515        let odd: Edited = serde_json::from_str(r#"{"conditions": [{"field": "frobnicate", "test": "contains", "value": "x"}], "actions": [{"do": "explode"}]}"#).unwrap();
516        assert_eq!(odd.filter().problem(), Some("filter-problem-unknown"));
517    }
518
519    #[test]
520    fn a_search_becomes_a_filter() {
521        let ids = vec!["home".to_string(), "work".to_string()];
522        // As MailSearch.qml gives it: the inbox and unread mail are what a filter reads anyway.
523        let given: Given = serde_json::from_str(
524            r#"{"conditions": [
525                {"field": "anywhere", "test": "contains", "value": "facture", "until": ""},
526                {"field": "account", "test": "is", "value": "home", "until": ""},
527                {"field": "folder", "test": "is", "value": "inbox", "until": ""},
528                {"field": "mark", "test": "is-not", "value": "read", "until": ""}
529            ], "match": "all"}"#,
530        )
531        .unwrap();
532        let (filter, left_out) = from_search(given, &ids);
533        assert!(!left_out);
534        assert_eq!(filter.accounts, vec!["home".to_string()]);
535        assert_eq!(filter.conditions, vec![Condition::new(Field::Anywhere, Test::Contains, "facture")]);
536        assert_eq!(filter.actions, vec![Act::Move { folder: String::new() }]);
537        assert_eq!(filter.problem(), Some("filter-problem-folder"), "it waits for its folder");
538        // Another folder and flagged mail are left out, and said; "is not" an address: the others.
539        let given: Given = serde_json::from_str(
540            r#"{"conditions": [
541                {"field": "folder", "test": "is", "value": "archive"},
542                {"field": "mark", "test": "is", "value": "flagged"},
543                {"field": "account", "test": "is-not", "value": "home"}
544            ], "match": "any"}"#,
545        )
546        .unwrap();
547        let (filter, left_out) = from_search(given, &ids);
548        assert!(left_out);
549        assert_eq!((filter.accounts.clone(), filter.join), (vec!["work".to_string()], Join::Any));
550        assert_eq!(filter.conditions, vec![Condition::new(Field::From, Test::Contains, "")], "one condition to fill");
551        // Two addresses at odds: every address, said.
552        let given: Given = serde_json::from_str(r#"{"conditions": [{"field": "account", "test": "is", "value": "home"}, {"field": "account", "test": "is", "value": "work"}, {"field": "from", "test": "contains", "value": "@bank.example"}]}"#).unwrap();
553        let (filter, left_out) = from_search(given, &ids);
554        assert!(left_out && filter.accounts.is_empty());
555        // A whole filter given keeps its name and actions.
556        let given: Given = serde_json::from_str(r#"{"name": "Bank", "conditions": [{"field": "from", "test": "contains", "value": "@bank.example"}], "actions": [{"do": "move", "name": "Banque"}, {"do": "read"}]}"#).unwrap();
557        let (filter, _) = from_search(given, &ids);
558        assert_eq!((filter.name.as_str(), filter.actions.clone()), ("Bank", vec![Act::Move { folder: "Banque".into() }, Act::Read]));
559        assert_eq!(filter.problem(), None);
560    }
561}