1use 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#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Job {
59 pub file: PathBuf,
60 pub plan: Plan,
61 pub again: bool,
63 pub claimed_since: Option<i64>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum Failure {
70 NoFolder(String),
72 NoKeywords,
74 Server(String),
76 Renumbered,
78}
79
80#[derive(Debug, Clone, Default)]
82pub struct Done {
83 pub acted: Vec<Job>,
85 pub left: Vec<PathBuf>,
87 pub elsewhere: Vec<Job>,
89 pub failed: Vec<(Job, Failure)>,
91 pub unmarked: bool,
93}
94
95impl Done {
96 pub fn to_tell(&self) -> Vec<PathBuf> {
100 self.failed.iter().map(|(job, _)| job.file.clone()).collect()
101 }
102
103 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
124pub 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
140pub 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
153pub 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 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
193pub 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
201pub 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 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 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 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 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 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 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
295async 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 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 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 let echoed = stored(session, &uid.to_string(), &format!("(UNCHANGEDSINCE {modseq}) +FLAGS.SILENT ({})", rules::FILTERED)).await?;
355 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
369async 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385enum Found {
386 Free,
388 Settled,
390 Elsewhere,
392 Stuck,
395}
396
397const STUCK: i64 = 10 * 60;
400
401fn found(flags: Option<(bool, bool)>, job: &Job, now: i64) -> Found {
404 match flags {
405 None => Found::Settled,
406 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
416async 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 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
440pub 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
456fn 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#[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 since: i64,
491 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
493 elsewhere: bool,
494}
495
496const TRIES: u32 = 3;
497const WAITS: i64 = 2 * 86_400;
498const RETRY: i64 = 10 * 60;
500
501impl Wait {
502 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 assert_eq!(subjects(&done.to_tell()), vec!["Déjeuner".to_string()]);
761 assert!(!done.unmarked, "GreenMail keeps keywords");
762 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 assert_eq!(inbox_files(&account).len(), 3);
775 let again = run(&account, "x", &done.acted, true).unwrap();
777 assert!(again.acted.is_empty() && again.left.len() == 2, "{again:?}");
778 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 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 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 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}