1use 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
30const TRIED: usize = 2000;
32const NAMED: usize = 3;
34
35#[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 #[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 #[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#[derive(Debug, Serialize)]
133struct Address {
134 id: String,
135 label: String,
136}
137
138#[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 open: i64,
153 note: String,
155}
156
157static OPENED: Mutex<Option<(usize, String)>> = Mutex::new(None);
160
161pub(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#[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
202pub(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 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
230fn 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 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
272pub(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 let current: Vec<Filter> = config.mail.filters.iter().map(|f| Edited::of(f, &config).filter()).collect();
291 sioul_core::settings::rebased(shown, &filters, ¤t)
292 }
293 _ => filters,
294 };
295 sioul_core::config::set_filters(&path, &filters)
296 })
297 .err()
298 .unwrap_or_default()
299}
300
301pub(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#[derive(Debug, Default, Serialize)]
311struct Found {
312 kind: String,
314 text: String,
316 lines: Vec<String>,
318 count: usize,
320}
321
322static PREVIEWED: Mutex<Vec<(Account, Vec<Job>)>> = Mutex::new(Vec::new());
324
325fn 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 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
349pub(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
375pub(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
401pub(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
413pub(crate) fn perform(planned: &[(Account, Vec<Job>)]) -> Option<String> {
416 let mut done = Done::default();
417 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
438fn 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#[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
456pub(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 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
478pub(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 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 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 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 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 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}