1use std::fmt;
2use std::io;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::mpsc::{Receiver, Sender};
7use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10
11use crate::actions::{self, ActionError};
12use crate::config::{AccountConfig, ConfigError, Identity};
13use crate::credentials::{self, CredentialError};
14use crate::mail_ops::imap::ImapOps;
15use crate::mail_ops::{Envelope, IdleOutcome, MailError, MailOps, RemoteFolder};
16use crate::message::{clean, parse_headers, thread_id};
17use crate::paths::Paths;
18use crate::rules::apply::{ApplyError, apply, ensure_body_text};
19use crate::rules::engine::{Context, Mode, evaluate, folder_needs_body};
20use crate::rules::{Action, CompiledRule, RulesError};
21use crate::store::{Folder, Message, Store, StoreError};
22use crate::time::{clock, now};
23use crate::trash::{RestoreError, Trash};
24
25#[derive(Debug, thiserror::Error)]
26pub enum SyncError {
27 #[error(transparent)]
28 Action(#[from] ActionError),
29 #[error(transparent)]
30 Restore(#[from] RestoreError),
31 #[error(transparent)]
32 Mail(#[from] MailError),
33 #[error(transparent)]
34 Store(#[from] StoreError),
35 #[error(transparent)]
36 Apply(#[from] ApplyError),
37 #[error(transparent)]
38 Rules(#[from] RulesError),
39 #[error(transparent)]
40 Credentials(#[from] CredentialError),
41 #[error(transparent)]
42 Config(#[from] ConfigError),
43 #[error(transparent)]
44 Io(#[from] io::Error),
45 #[error("stopped")]
46 Stopped,
47 #[error("crashed: {0}")]
48 Crashed(String),
49}
50
51pub type RequestId = u64;
53
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub enum Event {
56 Activity {
57 account: String,
58 activity: Activity,
59 },
60 ActionDone {
61 account: String,
62 folder: String,
63 results: EventResults,
64 request: RequestId,
65 },
66 BodiesFetched {
67 account: String,
68 request: RequestId,
69 fetched: usize,
70 },
71 BodyReady {
72 account: String,
73 folder: String,
74 uid: u32,
75 request: RequestId,
76 },
77 CommandFailed {
78 account: String,
79 request: RequestId,
80 message: String,
81 },
82 Error {
83 account: String,
84 message: String,
85 },
86 NewMail {
87 account: String,
88 folder: String,
89 uid: u32,
90 from: String,
91 subject: String,
92 },
93 ServerSearchDone {
95 account: String,
96 request: RequestId,
97 query: String,
98 hits: Vec<(String, u32)>,
99 },
100 Restored {
101 account: String,
102 folder: String,
103 request: RequestId,
104 },
105 RuleApplied {
106 account: String,
107 request: RequestId,
108 evaluated: usize,
109 actions: usize,
110 errors: Vec<String>,
111 },
112 Synced {
113 account: String,
114 new_messages: usize,
115 actions: usize,
116 requests: Vec<RequestId>,
117 errors: Vec<String>,
119 },
120}
121
122impl Event {
123 pub fn request_ids(&self) -> Vec<RequestId> {
125 match self {
126 Event::ActionDone { request, .. }
127 | Event::BodiesFetched { request, .. }
128 | Event::BodyReady { request, .. }
129 | Event::CommandFailed { request, .. }
130 | Event::Restored { request, .. }
131 | Event::RuleApplied { request, .. }
132 | Event::ServerSearchDone { request, .. } => vec![*request],
133 Event::Synced { requests, .. } => requests.clone(),
134 Event::Activity { .. } | Event::Error { .. } | Event::NewMail { .. } => Vec::new(),
135 }
136 }
137}
138
139#[derive(Debug, Default)]
140pub struct RulesRun {
141 pub evaluated: usize,
142 pub actions: usize,
143 pub events: Vec<Event>,
145}
146
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub enum Activity {
149 Connecting,
150 ListingFolders,
151 SyncingFolder {
152 folder: String,
153 index: usize,
154 of: usize,
155 },
156 FetchingHeaders {
157 folder: String,
158 done: usize,
159 total: usize,
160 },
161 FetchingBodies {
162 folder: String,
163 done: usize,
164 total: usize,
165 },
166 RunningRules {
167 folder: String,
168 },
169 RunningCommand {
170 what: String,
171 },
172 Idle {
173 since: i64,
174 },
175 Offline {
176 reason: String,
177 retry_at: i64,
178 },
179 NotRunning {
181 reason: String,
182 },
183}
184
185impl fmt::Display for Activity {
186 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188 let text = match self {
189 Activity::Connecting => "connecting…".into(),
190 Activity::ListingFolders => "listing folders".into(),
191 Activity::SyncingFolder { folder, index, of } => {
192 format!("{} ({index}/{of})", clean(folder, false))
193 }
194 Activity::FetchingHeaders {
195 folder,
196 done,
197 total,
198 } => fetching(folder, "headers", *done, *total),
199 Activity::FetchingBodies {
200 folder,
201 done,
202 total,
203 } => fetching(folder, "bodies", *done, *total),
204 Activity::RunningRules { folder } => {
205 format!("running rules on {}", clean(folder, false))
206 }
207 Activity::RunningCommand { what } => clean(what, false),
208 Activity::Idle { since } => format!("up to date · {}", clock(*since)),
209 Activity::Offline { reason, retry_at } => {
210 format!(
211 "offline ({}) · retry {}",
212 clean(reason, false),
213 clock(*retry_at)
214 )
215 }
216 Activity::NotRunning { reason } => format!("not running ({})", clean(reason, false)),
217 };
218 f.write_str(&text)
219 }
220}
221
222fn fetching(folder: &str, what: &str, done: usize, total: usize) -> String {
224 format!(
225 "{} {what} {} / {}",
226 clean(folder, false),
227 group_digits(done as u64, ','),
228 group_digits(total as u64, ',')
229 )
230}
231
232pub fn group_digits(n: u64, separator: char) -> String {
234 let digits = n.to_string();
235 let mut grouped = String::with_capacity(digits.len() + digits.len() / 3);
236 for (i, digit) in digits.chars().enumerate() {
237 if i > 0 && (digits.len() - i).is_multiple_of(3) {
238 grouped.push(separator);
239 }
240 grouped.push(digit);
241 }
242 grouped
243}
244
245#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247pub enum Command {
248 Apply {
250 folder: String,
251 uids: Vec<u32>,
252 #[serde(with = "ActionDef")]
253 action: Action,
254 by: String,
255 },
256 ApplyRule {
257 name: String,
258 },
259 FetchBodies {
260 folder: Option<String>,
261 },
262 FetchBody {
263 folder: String,
264 uid: u32,
265 },
266 Restore {
267 file: PathBuf,
268 },
269 SearchServer {
271 folder: Option<String>,
272 query: String,
273 },
274 SyncNow,
275}
276
277#[derive(Serialize, Deserialize)]
279#[serde(remote = "Action", rename_all = "snake_case")]
280enum ActionDef {
281 Archive,
282 Delete,
283 Flag,
284 Junk,
285 MarkRead,
286 MarkUnread,
287 Move(String),
288 NotJunk,
289 Notify,
290 Silent,
291 Stop,
292 Tag(String),
293 Trash,
294 Unflag,
295}
296
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
298pub struct Job {
299 pub request: RequestId,
300 pub command: Command,
301}
302
303#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
305pub enum NextPass {
306 #[default]
307 None,
308 Inbox,
310 Full,
311}
312
313#[derive(Debug, Default, PartialEq)]
314pub struct CommandsRun {
315 pub used_connection: bool,
316 pub next: NextPass,
317}
318
319pub type Checkpoint<'a> = dyn FnMut(&mut dyn MailOps, Activity) -> Result<bool, SyncError> + 'a;
322
323const CHUNK: usize = 500;
325
326#[cfg(any(test, feature = "testing"))]
327thread_local! {
328 pub static CHUNK_OVERRIDE: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
330}
331
332fn chunk_size() -> usize {
333 #[cfg(any(test, feature = "testing"))]
334 if let Some(size) = CHUNK_OVERRIDE.get() {
335 return size;
336 }
337 CHUNK
338}
339
340pub fn sync_folder(
341 ops: &mut dyn MailOps,
342 store: &Store,
343 folder: &RemoteFolder,
344) -> Result<usize, SyncError> {
345 sync_folder_with(ops, store, folder, &mut |_, _| Ok(false))
346}
347
348pub fn sync_folder_with(
349 ops: &mut dyn MailOps,
350 store: &Store,
351 folder: &RemoteFolder,
352 checkpoint: &mut Checkpoint<'_>,
353) -> Result<usize, SyncError> {
354 let info = ops.select(&folder.name)?;
355 let stored = store.folder(&folder.name)?;
356 let (last_uid, starts_tracking) = match &stored {
358 Some(f) if f.uidvalidity == info.uidvalidity => (f.last_uid, false),
359 Some(f) if f.uidvalidity != 0 => {
361 log::info!(
362 "{}: UIDVALIDITY changed {} -> {}, resyncing",
363 folder.name,
364 f.uidvalidity,
365 info.uidvalidity
366 );
367 (0, true)
368 }
369 _ => (0, true),
370 };
371
372 let flag_fetch = if starts_tracking {
373 FlagFetch::None
374 } else {
375 flag_fetch(
376 store.flags_synced(&folder.name)?,
377 info.highest_modseq,
378 now(),
379 )
380 };
381 let updates = match flag_fetch {
382 FlagFetch::None => Vec::new(),
383 FlagFetch::All => ops.fetch_flags(last_uid, None)?,
384 FlagFetch::ChangedSince(modseq) => ops.fetch_flags(last_uid, Some(modseq))?,
385 };
386 let uids: Vec<u32> = ops
387 .search_uids(last_uid + 1)?
388 .into_iter()
389 .filter(|&uid| uid > last_uid)
390 .collect();
391 let present: Option<Vec<u32>> = if flag_fetch == FlagFetch::All {
393 Some(updates.iter().map(|u| u.uid).collect())
394 } else if !starts_tracking
395 && info.exists as usize != store.message_count(&folder.name)? as usize + uids.len()
396 {
397 Some(ops.search_uids(1)?)
398 } else {
399 None
400 };
401 let row = |last_uid| Folder {
402 name: folder.name.clone(),
403 uidvalidity: info.uidvalidity,
404 last_uid,
405 special_use: folder.special_use.clone(),
406 delimiter: folder.delimiter.clone(),
407 };
408
409 store.transaction(|| {
410 if starts_tracking && stored.is_some() {
411 store.reset_folder(&folder.name, info.uidvalidity)?;
412 }
413 store.upsert_folder(&row(last_uid))?;
414 if starts_tracking {
415 store.set_initial_uid_next(&folder.name, uids.last().map_or(0, |uid| uid + 1))?;
416 store.set_rules_uid(&folder.name, 0)?;
417 store.set_notified_uid(&folder.name, 0)?;
418 }
419 for u in &updates {
420 store.update_flags(&folder.name, u.uid, &u.flags.join(" "))?;
421 }
422 if let Some(present) = &present {
423 store.remove_missing(&folder.name, last_uid, present)?;
424 }
425 if starts_tracking || flag_fetch != FlagFetch::None {
427 store.set_flags_synced(&folder.name, info.highest_modseq.unwrap_or(0), now())?;
428 }
429 Ok::<_, SyncError>(())
430 })?;
431
432 let total = uids.len();
433 let mut done = 0;
434 let mut new = 0;
435 for chunk in uids.chunks(chunk_size()) {
436 let progress = Activity::FetchingHeaders {
437 folder: folder.name.clone(),
438 done,
439 total,
440 };
441 if checkpoint(ops, progress)? {
443 let uidvalidity = ops.select(&folder.name)?.uidvalidity;
444 if uidvalidity != info.uidvalidity {
445 log::info!(
446 "{}: UIDVALIDITY changed {} -> {} during sync, resyncing next pass",
447 folder.name,
448 info.uidvalidity,
449 uidvalidity
450 );
451 break;
452 }
453 }
454 let (first, last) = (chunk[0], chunk[chunk.len() - 1]);
455 let envelopes = ops.fetch_envelopes(first, last)?;
456 store.transaction(|| {
457 for env in envelopes {
458 store.insert_message(&to_message(&folder.name, env))?;
459 new += 1;
460 }
461 store.upsert_folder(&row(last))
462 })?;
463 done += chunk.len();
464 }
465 if total > 0 {
466 checkpoint(
467 ops,
468 Activity::FetchingHeaders {
469 folder: folder.name.clone(),
470 done,
471 total,
472 },
473 )?;
474 }
475 Ok(new)
476}
477
478const FULL_FLAGS_EVERY_SECS: i64 = 30 * 60;
480
481#[derive(Debug, PartialEq)]
482enum FlagFetch {
483 None,
484 All,
485 ChangedSince(u64),
486}
487
488fn flag_fetch(
490 (stored_modseq, synced_at): (u64, i64),
491 highest_modseq: Option<u64>,
492 now: i64,
493) -> FlagFetch {
494 match highest_modseq {
495 Some(highest) if stored_modseq == 0 || highest < stored_modseq => FlagFetch::All,
496 Some(highest) if highest == stored_modseq => FlagFetch::None,
497 Some(_) => FlagFetch::ChangedSince(stored_modseq),
498 None if now - synced_at >= FULL_FLAGS_EVERY_SECS => FlagFetch::All,
499 None => FlagFetch::None,
500 }
501}
502
503fn to_message(folder: &str, env: Envelope) -> Message {
504 let parsed = parse_headers(&env.headers);
505 Message {
506 folder: folder.to_string(),
507 uid: env.uid,
508 thread_id: thread_id(&parsed, folder, env.uid),
509 message_id: parsed.message_id,
510 from_addr: parsed.from,
511 to_addr: parsed.to,
512 cc_addr: parsed.cc,
513 delivered_to: parsed.delivered_to,
514 in_reply_to: parsed.in_reply_to,
515 refs: if parsed.references.is_empty() {
516 None
517 } else {
518 Some(parsed.references.join(" "))
519 },
520 subject: parsed.subject,
521 date: parsed.date,
522 internaldate: env.internaldate,
523 flags: env.flags.join(" "),
524 size: env.size,
525 headers: env.headers,
526 body_text: None,
527 }
528}
529
530#[cfg(any(test, feature = "testing"))]
531pub fn sync_all(ops: &mut dyn MailOps, store: &Store) -> Result<(usize, Vec<String>), SyncError> {
532 sync_all_with(ops, store, &mut |_, _| Ok(false))
533}
534
535pub fn sync_all_with(
538 ops: &mut dyn MailOps,
539 store: &Store,
540 checkpoint: &mut Checkpoint<'_>,
541) -> Result<(usize, Vec<String>), SyncError> {
542 checkpoint(ops, Activity::ListingFolders)?;
543 let mut folders = ops.list_folders()?;
544 folders.retain(|f| {
546 !matches!(
547 f.special_use.as_deref(),
548 Some("All" | "Flagged" | "Important")
549 )
550 });
551 special_use_by_name(&mut folders);
552 folders.sort_by_key(|f| !f.name.eq_ignore_ascii_case("INBOX"));
554 let of = folders.len();
555 let mut new = 0;
556 let mut errors = Vec::new();
557 for (index, folder) in folders.iter().enumerate() {
558 let syncing = Activity::SyncingFolder {
559 folder: folder.name.clone(),
560 index: index + 1,
561 of,
562 };
563 checkpoint(ops, syncing)?;
564 match sync_folder_with(ops, store, folder, checkpoint) {
565 Ok(found) => new += found,
566 Err(e) if matches!(e, SyncError::Stopped) || is_lost_connection(&e) => return Err(e),
567 Err(e) => errors.push(format!("{}: {e}; skipped this pass", folder.name)),
568 }
569 }
570 forget_unlisted_folders(store, &folders)?;
571 Ok((new, errors))
572}
573
574fn forget_unlisted_folders(store: &Store, synced: &[RemoteFolder]) -> Result<(), SyncError> {
576 for stored in store.folders()? {
577 if stored.uidvalidity != 0 && !synced.iter().any(|f| f.name == stored.name) {
578 log::info!(
579 "{}: no longer synced, removing it from the store",
580 stored.name
581 );
582 store.remove_folder(&stored.name)?;
583 }
584 }
585 Ok(())
586}
587
588fn special_use_by_name(folders: &mut [RemoteFolder]) {
590 for role in ["Archive", "Drafts", "Junk", "Sent", "Trash"] {
591 if folders
592 .iter()
593 .any(|f| f.special_use.as_deref() == Some(role))
594 {
595 continue;
596 }
597 if let Some(folder) = folders
598 .iter_mut()
599 .find(|f| f.special_use.is_none() && f.name.eq_ignore_ascii_case(role))
600 {
601 folder.special_use = Some(role.to_string());
602 }
603 }
604}
605
606pub fn load_rules_for(
607 store: &Store,
608 path: &Path,
609 now: i64,
610) -> Result<Vec<CompiledRule>, RulesError> {
611 let file = crate::rules::load(path)?;
612 let mut compiled = crate::rules::compile(&file)?;
613 let enabled: Vec<&str> = compiled
615 .iter()
616 .filter(|r| r.rule.enabled)
617 .map(|r| r.rule.name.as_str())
618 .collect();
619 store.forget_rules_except(&enabled)?;
620 for rule in compiled.iter_mut().filter(|r| r.rule.enabled) {
621 rule.first_seen_at =
622 store.rule_first_seen(&rule.rule.name, &rule.rule.definition()?, now)?;
623 }
624 Ok(compiled)
625}
626
627#[allow(clippy::too_many_arguments)]
628pub fn run_rules(
629 ops: &mut dyn MailOps,
630 store: &Store,
631 trash: &Trash,
632 rules: &[CompiledRule],
633 account: &AccountConfig,
634 identity: &Identity,
635 mode: Mode,
636 now: i64,
637) -> Result<RulesRun, SyncError> {
638 run_rules_with(
639 ops,
640 store,
641 trash,
642 rules,
643 account,
644 identity,
645 mode,
646 now,
647 &mut |_, _| Ok(false),
648 )
649}
650
651#[allow(clippy::too_many_arguments)]
652pub fn run_rules_with(
653 ops: &mut dyn MailOps,
654 store: &Store,
655 trash: &Trash,
656 rules: &[CompiledRule],
657 account: &AccountConfig,
658 identity: &Identity,
659 mode: Mode,
660 now: i64,
661 checkpoint: &mut Checkpoint<'_>,
662) -> Result<RulesRun, SyncError> {
663 let ctx = Context {
664 account: &account.name,
665 identity,
666 now,
667 mode,
668 notify_default: account.notify,
669 };
670 let mut folders: Vec<String> = rules
671 .iter()
672 .filter(|r| r.rule.enabled && r.applies_to_account(&account.name))
673 .map(|r| r.folder().to_string())
674 .collect();
675 folders.push("INBOX".to_string());
676 folders.sort();
677 folders.dedup();
678
679 let mut run = RulesRun::default();
680 'folders: for folder in folders.iter().cloned() {
681 checkpoint(
683 ops,
684 Activity::RunningRules {
685 folder: folder.clone(),
686 },
687 )?;
688 let Some(stored) = store.folder(&folder)? else {
689 continue;
690 };
691 let info = match ops.select(&folder) {
692 Ok(info) => info,
693 Err(e) => {
694 run.events.push(account_error(
695 account,
696 format!("{folder}: {e}; skipping its rules"),
697 ));
698 continue;
699 }
700 };
701 if info.uidvalidity != stored.uidvalidity {
703 if stored.uidvalidity != 0 {
705 run.events.push(account_error(
706 account,
707 format!(
708 "{folder}: folder changed on server; resync needed, skipping its rules"
709 ),
710 ));
711 }
712 continue;
713 }
714 let needs_body = folder_needs_body(rules, &account.name, &folder);
715 let (rules_uid, notified_uid) = (store.rules_uid(&folder)?, store.notified_uid(&folder)?);
716 let unseen = unseen_by_rules(store, &stored)?;
717 let fresh = |uid: u32| mode == Mode::Normal && unseen(uid);
718 let messages = store.messages_in_folder(&folder)?;
719 let highest_uid = messages.last().map(|m| m.uid.min(stored.last_uid));
720 let wants_body = |m: &Message| {
722 needs_body
723 && m.body_text.is_none()
724 && fresh(m.uid)
725 && m.size.is_none_or(|size| size <= MAX_RULE_BODY)
726 };
727 let bodies_total = if needs_body {
728 messages.iter().filter(|m| wants_body(m)).count()
729 } else {
730 0
731 };
732 let mut bodies_done = 0;
733 let mut commands_ran = false;
735 for mut msg in messages {
736 let is_fresh = fresh(msg.uid);
737 if wants_body(&msg) {
739 let progress = Activity::FetchingBodies {
740 folder: folder.clone(),
741 done: bodies_done,
742 total: bodies_total,
743 };
744 if checkpoint(ops, progress)? {
745 commands_ran = true;
746 if ops.select(&folder)?.uidvalidity != info.uidvalidity {
747 let message = format!(
748 "{folder}: folder changed on server; resync needed, skipping its rules"
749 );
750 run.events.push(account_error(account, message));
751 continue 'folders;
752 }
753 }
754 }
755 if commands_ran {
756 match store.message(&folder, msg.uid)? {
757 Some(current) => msg = current,
758 None => continue,
759 }
760 }
761 if wants_body(&msg) {
762 match ensure_body_text(&msg, ops, store) {
763 Ok(text) => msg.body_text = Some(text),
764 Err(e) => log::warn!("{}/{}: body fetch failed: {e}", msg.folder, msg.uid),
765 }
766 bodies_done += 1;
767 }
768 let plan = evaluate(rules, &msg, &ctx);
769 run.evaluated += 1;
770 if !plan.actions.is_empty() {
771 match apply(&plan, &msg, ops, store, trash, now) {
772 Ok(executed) => run.actions += executed,
773 Err(e) => {
774 run.events.push(account_error(
775 account,
776 format!("{}/{}: {e}", msg.folder, msg.uid),
777 ));
778 continue;
779 }
780 }
781 }
782 if is_fresh && msg.uid > notified_uid && plan.notify && folder == "INBOX" {
783 run.events.push(new_mail(account, &msg));
784 }
785 }
786 if mode == Mode::Normal
787 && let Some(highest_uid) = highest_uid
788 {
789 if highest_uid > rules_uid {
790 store.set_rules_uid(&folder, highest_uid)?;
791 }
792 if highest_uid > notified_uid {
793 store.set_notified_uid(&folder, highest_uid)?;
794 }
795 }
796 }
797 if mode == Mode::Normal {
798 store.mark_rules_seen_except(&folders)?;
801 }
802 Ok(run)
803}
804
805pub const MAX_RULE_BODY: u32 = 25 * 1024 * 1024;
807
808fn unseen_by_rules(
811 store: &Store,
812 folder: &Folder,
813) -> Result<impl Fn(u32) -> bool + use<>, StoreError> {
814 let rules_uid = store.rules_uid(&folder.name)?;
815 let initial_uid_next = store.initial_uid_next(&folder.name)?;
816 let last_uid = folder.last_uid;
817 Ok(move |uid| uid > rules_uid && uid >= initial_uid_next && uid <= last_uid)
818}
819
820fn new_mail(account: &AccountConfig, msg: &Message) -> Event {
821 Event::NewMail {
822 account: account.name.clone(),
823 folder: msg.folder.clone(),
824 uid: msg.uid,
825 from: msg.from_addr.clone().unwrap_or_default(),
826 subject: msg.subject.clone().unwrap_or_default(),
827 }
828}
829
830fn account_error(account: &AccountConfig, message: String) -> Event {
831 log::warn!("{}: {message}", account.name);
832 Event::Error {
833 account: account.name.clone(),
834 message,
835 }
836}
837
838#[allow(clippy::too_many_arguments)]
842fn run_commands(
843 ops: &mut dyn MailOps,
844 store: &Store,
845 trash: &Trash,
846 account: &AccountConfig,
847 identity: &Identity,
848 rules_path: &Path,
849 commands: &Receiver<Job>,
850 carried: &mut Carried,
851 events: &Sender<Event>,
852) -> Result<CommandsRun, SyncError> {
853 let mut run = CommandsRun::default();
854 while let Ok(Job { request, command }) = commands.try_recv() {
855 if command == Command::SyncNow {
856 run.next = NextPass::Full;
857 carried.sync_requests.push(request);
858 continue;
859 }
860 carried.running = Some(request);
861 let ran = run_command(
862 ops, store, trash, account, identity, rules_path, request, command, events,
863 );
864 carried.running = None;
865 let ran = ran?;
866 run.used_connection |= ran.used_connection;
867 run.next = run.next.max(ran.next);
868 }
869 Ok(run)
870}
871
872fn sync_now(ops: &mut dyn MailOps, store: &Store, folder: &str) {
875 let stored = store.folder(folder).ok().flatten();
876 let remote = RemoteFolder {
877 name: folder.to_string(),
878 special_use: stored.as_ref().and_then(|f| f.special_use.clone()),
879 delimiter: stored.and_then(|f| f.delimiter),
880 };
881 if let Err(e) = sync_folder(ops, store, &remote) {
882 log::warn!("{folder}: sync after a command failed: {e}");
883 }
884}
885
886#[allow(clippy::too_many_arguments)]
888fn run_command(
889 ops: &mut dyn MailOps,
890 store: &Store,
891 trash: &Trash,
892 account: &AccountConfig,
893 identity: &Identity,
894 rules_path: &Path,
895 request: RequestId,
896 command: Command,
897 events: &Sender<Event>,
898) -> Result<CommandsRun, SyncError> {
899 crate::crash::note(&command);
900 let name = || account.name.clone();
901 let failed = |request, message: String| {
902 log::warn!("{}: {message}", account.name);
903 Event::CommandFailed {
904 account: account.name.clone(),
905 request,
906 message,
907 }
908 };
909 let mut run = CommandsRun::default();
910 let mut context = String::new();
912 let answer: Result<Event, SyncError> = match command {
913 Command::SyncNow => Ok(failed(
915 request,
916 "sync now is answered by the next full pass".to_string(),
917 )),
918 Command::Apply {
919 folder,
920 uids,
921 action,
922 by,
923 } => {
924 run.used_connection = true;
925 let _ = events.send(Event::Activity {
926 account: name(),
927 activity: Activity::RunningCommand {
928 what: describe(&action, uids.len()),
929 },
930 });
931 let outcome = actions::run(ops, store, trash, &folder, &uids, &action, &by, now());
932 let (results, lost) = match outcome {
933 Ok(results) => split_lost(results),
934 Err(e) => (
935 uids.iter().map(|&uid| (uid, Err(e.to_string()))).collect(),
936 connection_lost(&e).then_some(e),
937 ),
938 };
939 if results.iter().any(|(_, result)| result.is_ok()) {
942 let target = crate::rules::apply::move_target(store, &folder, &action);
943 if let Ok(Some(target)) = target {
944 sync_now(ops, store, &target);
945 }
946 if matches!(
947 action,
948 Action::Archive
949 | Action::Delete
950 | Action::Junk
951 | Action::Move(_)
952 | Action::NotJunk
953 | Action::Trash
954 ) {
955 run.next = NextPass::Inbox;
956 }
957 }
958 let _ = events.send(Event::ActionDone {
959 account: name(),
960 folder,
961 results,
962 request,
963 });
964 return match lost {
965 Some(e) => Err(e.into()),
966 None => Ok(run),
967 };
968 }
969 Command::ApplyRule { name: rule_name } => {
970 let rule = match crate::rules::enabled_rule(rules_path, &rule_name) {
971 Ok(rule) => rule,
972 Err(message) => {
973 let _ = events.send(failed(request, message.to_string()));
974 return Ok(run);
975 }
976 };
977 run.used_connection = true;
978 let rules = std::slice::from_ref(&rule);
979 run_rules(
980 ops,
981 store,
982 trash,
983 rules,
984 account,
985 identity,
986 Mode::ApplyExisting,
987 now(),
988 )
989 .map(|rules_run| Event::RuleApplied {
990 account: name(),
991 request,
992 evaluated: rules_run.evaluated,
993 actions: rules_run.actions,
994 errors: rules_run
995 .events
996 .into_iter()
997 .filter_map(|event| match event {
998 Event::Error { message, .. } => Some(message),
999 _ => None,
1000 })
1001 .collect(),
1002 })
1003 }
1004 Command::FetchBodies { folder } => {
1005 run.used_connection = true;
1006 fetch_bodies_in(ops, store, folder.as_deref())
1007 .map(|fetched| Event::BodiesFetched {
1008 account: name(),
1009 request,
1010 fetched,
1011 })
1012 .map_err(SyncError::from)
1013 }
1014 Command::FetchBody { folder, uid } => {
1015 run.used_connection = true;
1016 context = format!("{folder}/{uid}: ");
1017 actions::fetch_body(ops, store, &folder, uid)
1018 .map(|()| Event::BodyReady {
1019 account: name(),
1020 folder,
1021 uid,
1022 request,
1023 })
1024 .map_err(SyncError::from)
1025 }
1026 Command::SearchServer { folder, query } => {
1027 run.used_connection = true;
1028 search_server_in(ops, store, folder.as_deref(), &query)
1029 .map(|hits| Event::ServerSearchDone {
1030 account: name(),
1031 request,
1032 query,
1033 hits,
1034 })
1035 .map_err(SyncError::from)
1036 }
1037 Command::Restore { file } => {
1038 run.used_connection = true;
1039 trash
1040 .restore(ops, &file)
1041 .map(|folder| {
1042 sync_now(ops, store, &folder);
1043 run.next = NextPass::Inbox;
1044 Event::Restored {
1045 account: name(),
1046 folder,
1047 request,
1048 }
1049 })
1050 .map_err(SyncError::from)
1051 }
1052 };
1053 match answer {
1054 Ok(event) => {
1055 let _ = events.send(event);
1056 Ok(run)
1057 }
1058 Err(e) => {
1059 let _ = events.send(failed(request, format!("{context}{e}")));
1060 if is_lost_connection(&e) {
1061 Err(e)
1062 } else {
1063 Ok(run)
1064 }
1065 }
1066 }
1067}
1068
1069fn fetch_bodies_in(
1072 ops: &mut dyn MailOps,
1073 store: &Store,
1074 folder: Option<&str>,
1075) -> Result<usize, ActionError> {
1076 if let Some(folder) = folder {
1077 return actions::fetch_bodies(ops, store, folder);
1078 }
1079 let mut fetched = 0;
1080 for folder in store.folders()? {
1081 match actions::fetch_bodies(ops, store, &folder.name) {
1082 Ok(count) => fetched += count,
1083 Err(e) if connection_lost(&e) => return Err(e),
1084 Err(e) => log::warn!("{}: body fetch skipped: {e}", folder.name),
1085 }
1086 }
1087 Ok(fetched)
1088}
1089
1090fn search_server_in(
1093 ops: &mut dyn MailOps,
1094 store: &Store,
1095 folder: Option<&str>,
1096 query: &str,
1097) -> Result<Vec<(String, u32)>, ActionError> {
1098 let words = actions::search_words(query);
1099 let folders = match folder {
1100 Some(folder) => vec![folder.to_string()],
1101 None if words.is_empty() => Vec::new(),
1102 None => store
1103 .folders()?
1104 .into_iter()
1105 .filter(|f| f.uidvalidity != 0)
1106 .map(|f| f.name)
1107 .collect(),
1108 };
1109 let mut hits = Vec::new();
1110 for name in folders {
1111 match actions::search_server(ops, store, &name, &words) {
1112 Ok(uids) => hits.extend(uids.into_iter().map(|uid| (name.clone(), uid))),
1113 Err(e) if folder.is_some() || connection_lost(&e) => return Err(e),
1114 Err(e) => log::warn!("{name}: server search skipped: {e}"),
1115 }
1116 }
1117 Ok(hits)
1118}
1119
1120fn is_connection_error(e: &MailError) -> bool {
1121 matches!(e, MailError::Io(_) | MailError::Connect(_))
1122}
1123
1124fn is_lost_connection(e: &SyncError) -> bool {
1125 match e {
1126 SyncError::Action(e) => connection_lost(e),
1127 SyncError::Mail(e) | SyncError::Restore(RestoreError::Mail(e)) => is_connection_error(e),
1128 _ => false,
1129 }
1130}
1131
1132fn connection_lost(e: &ActionError) -> bool {
1133 match e {
1134 ActionError::Mail(e) | ActionError::Apply(ApplyError::Mail(e)) => is_connection_error(e),
1135 _ => false,
1136 }
1137}
1138
1139pub type EventResults = Vec<(u32, Result<usize, String>)>;
1140
1141fn split_lost(results: Vec<actions::UidResult>) -> (EventResults, Option<ActionError>) {
1143 let mut lost = None;
1144 let strings = results
1145 .into_iter()
1146 .map(|(uid, result)| match result {
1147 Ok(count) => (uid, Ok(count)),
1148 Err(e) => {
1149 let message = e.to_string();
1150 if lost.is_none() && connection_lost(&e) {
1151 lost = Some(e);
1152 }
1153 (uid, Err(message))
1154 }
1155 })
1156 .collect();
1157 (strings, lost)
1158}
1159
1160fn describe(action: &Action, count: usize) -> String {
1161 let verb = match action {
1162 Action::Archive => "archiving",
1163 Action::Delete | Action::Trash => "deleting",
1164 Action::Flag => "flagging",
1165 Action::Junk => "marking junk",
1166 Action::NotJunk => "marking not junk",
1167 Action::MarkRead => "marking read",
1168 Action::MarkUnread => "marking unread",
1169 Action::Move(_) => "moving",
1170 Action::Notify | Action::Silent | Action::Stop => "updating",
1171 Action::Tag(_) => "tagging",
1172 Action::Unflag => "unflagging",
1173 };
1174 let plural = if count == 1 { "" } else { "s" };
1175 format!("{verb} {count} message{plural}")
1176}
1177
1178pub fn connect(account: &AccountConfig) -> Result<ImapOps, SyncError> {
1180 let secret = credentials::resolve(account)?;
1181 Ok(ImapOps::connect(account, &secret)?)
1182}
1183
1184fn sync_inbox_with(
1186 ops: &mut dyn MailOps,
1187 store: &Store,
1188 checkpoint: &mut Checkpoint<'_>,
1189) -> Result<usize, SyncError> {
1190 let inbox = RemoteFolder {
1191 name: "INBOX".into(),
1192 special_use: None,
1193 delimiter: None,
1194 };
1195 let syncing = Activity::SyncingFolder {
1196 folder: inbox.name.clone(),
1197 index: 1,
1198 of: 1,
1199 };
1200 checkpoint(ops, syncing)?;
1201 sync_folder_with(ops, store, &inbox, checkpoint)
1202}
1203
1204#[derive(Default)]
1206struct Carried {
1207 sync_requests: Vec<RequestId>,
1209 running: Option<RequestId>,
1211 rules: LoadedRules,
1212}
1213
1214#[derive(Default)]
1215struct LoadedRules {
1216 last_good: Option<Vec<CompiledRule>>,
1218 error: Option<String>,
1220}
1221
1222impl LoadedRules {
1223 fn reload(
1225 &mut self,
1226 store: &Store,
1227 path: &Path,
1228 account: &AccountConfig,
1229 events: &Sender<Event>,
1230 ) {
1231 match load_rules_for(store, path, now()) {
1232 Ok(rules) => {
1233 self.last_good = Some(rules);
1234 self.error = None;
1235 }
1236 Err(e) => {
1237 self.last_good = self.last_good.take().filter(|rules| !rules.is_empty());
1238 let kept = if self.last_good.is_some() {
1239 "keeping the previous rules"
1240 } else {
1241 "running no rules until it is fixed"
1242 };
1243 let error = format!("{e}; {kept}");
1244 if self.error.as_ref() != Some(&error) {
1245 let _ = events.send(account_error(account, error.clone()));
1246 }
1247 self.error = Some(error);
1248 }
1249 }
1250 }
1251
1252 fn run(
1254 &self,
1255 ops: &mut dyn MailOps,
1256 store: &Store,
1257 trash: &Trash,
1258 account: &AccountConfig,
1259 identity: &Identity,
1260 checkpoint: &mut Checkpoint<'_>,
1261 ) -> Result<RulesRun, SyncError> {
1262 let Some(rules) = &self.last_good else {
1263 return Ok(RulesRun {
1264 events: notify_unruled(store, account, identity)?,
1265 ..RulesRun::default()
1266 });
1267 };
1268 run_rules_with(
1269 ops,
1270 store,
1271 trash,
1272 rules,
1273 account,
1274 identity,
1275 Mode::Normal,
1276 now(),
1277 checkpoint,
1278 )
1279 }
1280}
1281
1282fn notify_unruled(
1285 store: &Store,
1286 account: &AccountConfig,
1287 identity: &Identity,
1288) -> Result<Vec<Event>, SyncError> {
1289 let Some(inbox) = store.folder("INBOX")? else {
1290 return Ok(Vec::new());
1291 };
1292 let unseen = unseen_by_rules(store, &inbox)?;
1293 let notified_uid = store.notified_uid("INBOX")?;
1294 let ctx = Context {
1295 account: &account.name,
1296 identity,
1297 now: now(),
1298 mode: Mode::Normal,
1299 notify_default: account.notify,
1300 };
1301 let messages = store.messages_in_folder("INBOX")?;
1302 let events = messages
1303 .iter()
1304 .filter(|msg| unseen(msg.uid) && msg.uid > notified_uid && evaluate(&[], msg, &ctx).notify)
1305 .map(|msg| new_mail(account, msg))
1306 .collect();
1307 if let Some(highest_uid) = messages
1308 .last()
1309 .map(|msg| msg.uid.min(inbox.last_uid))
1310 .filter(|&uid| uid > notified_uid)
1311 {
1312 store.set_notified_uid("INBOX", highest_uid)?;
1313 }
1314 Ok(events)
1315}
1316
1317struct AccountSync<'a> {
1319 account: &'a AccountConfig,
1320 store: Store,
1321 trash: Trash,
1322 identity: Identity,
1323 rules_path: PathBuf,
1324}
1325
1326impl<'a> AccountSync<'a> {
1327 fn open(account: &'a AccountConfig, paths: &Paths) -> Result<AccountSync<'a>, SyncError> {
1328 Ok(AccountSync {
1329 store: Store::open_account(paths, &account.name)?,
1330 account,
1331 trash: Trash::new(paths.trash_dir(&account.name)),
1332 identity: account.identity()?,
1333 rules_path: paths.rules_file(),
1334 })
1335 }
1336
1337 fn pass(
1341 &mut self,
1342 ops: &mut dyn MailOps,
1343 full: bool,
1344 carried: &mut Carried,
1345 events: &Sender<Event>,
1346 commands: &Receiver<Job>,
1347 shutdown: &AtomicBool,
1348 ) -> Result<NextPass, SyncError> {
1349 carried
1350 .rules
1351 .reload(&self.store, &self.rules_path, self.account, events);
1352 let rules = std::mem::take(&mut carried.rules);
1354 let result =
1355 self.sync_and_run_rules(ops, full, &rules, carried, events, commands, shutdown);
1356 carried.rules = rules;
1357 result
1358 }
1359
1360 #[allow(clippy::too_many_arguments)]
1361 fn sync_and_run_rules(
1362 &self,
1363 ops: &mut dyn MailOps,
1364 full: bool,
1365 rules: &LoadedRules,
1366 carried: &mut Carried,
1367 events: &Sender<Event>,
1368 commands: &Receiver<Job>,
1369 shutdown: &AtomicBool,
1370 ) -> Result<NextPass, SyncError> {
1371 let answering = if full { carried.sync_requests.len() } else { 0 };
1372 let (account, store, trash) = (self.account, &self.store, &self.trash);
1373 let (identity, rules_path) = (&self.identity, self.rules_path.as_path());
1374 let activity = |activity| Event::Activity {
1375 account: account.name.clone(),
1376 activity,
1377 };
1378 let mut next = NextPass::None;
1379 let mut lost = None;
1381 let mut checkpoint = |ops: &mut dyn MailOps, step: Activity| {
1382 if lost.is_some() {
1383 return Err(SyncError::Stopped);
1384 }
1385 let _ = events.send(activity(step));
1386 if shutdown.load(Ordering::Acquire) {
1387 return Err(SyncError::Stopped);
1388 }
1389 match run_commands(
1390 ops, store, trash, account, identity, rules_path, commands, carried, events,
1391 ) {
1392 Ok(run) => {
1393 next = next.max(run.next);
1394 Ok(run.used_connection)
1395 }
1396 Err(e) => {
1397 lost = Some(e);
1398 Err(SyncError::Stopped)
1399 }
1400 }
1401 };
1402 let synced = if full {
1403 sync_all_with(ops, store, &mut checkpoint)
1404 } else {
1405 sync_inbox_with(ops, store, &mut checkpoint).map(|new| (new, Vec::new()))
1406 };
1407 let ran = synced.and_then(|(new, errors)| {
1408 for message in &errors {
1409 let _ = events.send(account_error(account, message.clone()));
1410 }
1411 let run = rules.run(ops, store, trash, account, identity, &mut checkpoint)?;
1412 Ok((new, errors, run))
1413 });
1414 if let Some(e) = lost {
1415 return Err(e);
1416 }
1417 let (new, mut errors, run) = ran?;
1418 errors.extend(rules.error.clone());
1419 for event in run.events {
1420 if let Event::Error { message, .. } = &event {
1421 errors.push(message.clone());
1422 }
1423 let _ = events.send(event);
1424 }
1425 let _ = events.send(Event::Synced {
1426 account: account.name.clone(),
1427 new_messages: new,
1428 actions: run.actions,
1429 requests: carried.sync_requests.drain(..answering).collect(),
1430 errors,
1431 });
1432 Ok(next)
1433 }
1434}
1435
1436#[cfg(any(test, feature = "testing"))]
1437pub fn run_once(
1438 account: &AccountConfig,
1439 paths: &Paths,
1440 events: &Sender<Event>,
1441) -> Result<(), SyncError> {
1442 let mut ops = connect(account)?;
1443 let (_, no_commands) = std::sync::mpsc::channel();
1444 AccountSync::open(account, paths)?.pass(
1445 &mut ops,
1446 true,
1447 &mut Carried::default(),
1448 events,
1449 &no_commands,
1450 &AtomicBool::new(false),
1451 )?;
1452 Ok(())
1453}
1454
1455#[allow(clippy::too_many_arguments)]
1456pub fn run_loop(
1457 account: AccountConfig,
1458 paths: Paths,
1459 events: Sender<Event>,
1460 shutdown: Arc<AtomicBool>,
1461 commands: Receiver<Job>,
1462 wake: Arc<AtomicBool>,
1463 connect: impl FnMut() -> Result<Box<dyn MailOps>, SyncError>,
1464) {
1465 let name = account.name.clone();
1466 let (stop, woken) = (shutdown.clone(), wake.clone());
1467 run_loop_with(
1468 account,
1469 paths,
1470 events,
1471 shutdown,
1472 commands,
1473 wake,
1474 connect,
1475 |delay| {
1476 log::warn!("{name}: reconnecting in {}s", delay.as_secs());
1477 let until = std::time::Instant::now() + delay;
1479 while std::time::Instant::now() < until
1480 && !stop.load(Ordering::Acquire)
1481 && !woken.swap(false, Ordering::AcqRel)
1482 {
1483 std::thread::sleep(Duration::from_millis(100));
1484 }
1485 },
1486 );
1487}
1488
1489#[allow(clippy::too_many_arguments)]
1491pub fn run_loop_with(
1492 account: AccountConfig,
1493 paths: Paths,
1494 events: Sender<Event>,
1495 shutdown: Arc<AtomicBool>,
1496 commands: Receiver<Job>,
1497 wake: Arc<AtomicBool>,
1498 mut connect: impl FnMut() -> Result<Box<dyn MailOps>, SyncError>,
1499 mut sleep: impl FnMut(Duration),
1500) {
1501 let mut backoff = Duration::from_secs(5);
1502 let mut carried = Carried::default();
1503 while !shutdown.load(Ordering::Acquire) {
1504 let mut completed_cycle = false;
1505 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1507 run_session(
1508 &account,
1509 &paths,
1510 &events,
1511 &shutdown,
1512 &commands,
1513 &wake,
1514 &mut carried,
1515 &mut connect,
1516 &mut completed_cycle,
1517 )
1518 }))
1519 .unwrap_or_else(|panic| Err(SyncError::Crashed(panic_message(&*panic))));
1520 if completed_cycle {
1521 backoff = Duration::from_secs(5);
1522 }
1523 match result {
1524 Ok(()) => break,
1525 Err(_) if shutdown.load(Ordering::Acquire) => break,
1526 Err(e) => {
1527 let _ = events.send(Event::Error {
1528 account: account.name.clone(),
1529 message: e.to_string(),
1530 });
1531 let (reason, retry_at) = (e.to_string(), now() + backoff.as_secs() as i64);
1532 let offline = offline_message(&account.name, &reason, retry_at);
1533 let _ = events.send(Event::Activity {
1534 account: account.name.clone(),
1535 activity: Activity::Offline { reason, retry_at },
1536 });
1537 fail_requests(&account, &events, &commands, &mut carried, &offline);
1538 sleep(backoff);
1539 backoff = (backoff * 2).min(Duration::from_secs(300));
1540 }
1541 }
1542 }
1543 let stopped = format!("{} stopped", account.name);
1544 fail_requests(&account, &events, &commands, &mut carried, &stopped);
1545}
1546
1547fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
1548 panic
1549 .downcast_ref::<&str>()
1550 .map(|text| text.to_string())
1551 .or_else(|| panic.downcast_ref::<String>().cloned())
1552 .unwrap_or_else(|| "unknown panic".into())
1553}
1554
1555pub fn offline_message(account: &str, reason: &str, retry_at: i64) -> String {
1557 format!(
1558 "{account} is offline ({reason}); retrying at {}",
1559 clock(retry_at)
1560 )
1561}
1562
1563fn fail_requests(
1566 account: &AccountConfig,
1567 events: &Sender<Event>,
1568 commands: &Receiver<Job>,
1569 carried: &mut Carried,
1570 message: &str,
1571) {
1572 let held = carried
1573 .running
1574 .take()
1575 .into_iter()
1576 .chain(carried.sync_requests.drain(..));
1577 let queued = std::iter::from_fn(|| commands.try_recv().ok().map(|job| job.request));
1578 for request in held.chain(queued) {
1579 let _ = events.send(Event::CommandFailed {
1580 account: account.name.clone(),
1581 request,
1582 message: message.to_string(),
1583 });
1584 }
1585}
1586
1587#[allow(clippy::too_many_arguments)]
1588fn run_session(
1589 account: &AccountConfig,
1590 paths: &Paths,
1591 events: &Sender<Event>,
1592 shutdown: &AtomicBool,
1593 commands: &Receiver<Job>,
1594 wake: &AtomicBool,
1595 carried: &mut Carried,
1596 connect: &mut impl FnMut() -> Result<Box<dyn MailOps>, SyncError>,
1597 completed_cycle: &mut bool,
1598) -> Result<(), SyncError> {
1599 let _ = events.send(Event::Activity {
1600 account: account.name.clone(),
1601 activity: Activity::Connecting,
1602 });
1603 let mut ops = connect()?;
1604 let mut state = AccountSync::open(account, paths)?;
1605 let interval = Duration::from_secs(account.sync_interval_secs.max(10));
1606 let mut last_purge = 0i64;
1607 let mut full = true;
1608 let mut pass = true;
1609 let mut next = NextPass::None;
1610
1611 while !shutdown.load(Ordering::Acquire) {
1612 if pass {
1613 next = state.pass(ops.as_mut(), full, carried, events, commands, shutdown)?;
1614 if now() - last_purge > 3600 {
1615 match state.trash.purge(account.trash_retention_days, now()) {
1616 Ok(0) => {}
1617 Ok(removed) => log::info!("{}: purged {removed} trash files", account.name),
1618 Err(e) => log::warn!("{}: trash purge failed: {e}", account.name),
1619 }
1620 last_purge = now();
1621 }
1622 }
1623 let drained = run_commands(
1624 ops.as_mut(),
1625 &state.store,
1626 &state.trash,
1627 account,
1628 &state.identity,
1629 &state.rules_path,
1630 commands,
1631 carried,
1632 events,
1633 )?;
1634 let wanted = drained.next.max(std::mem::take(&mut next));
1635 if wanted != NextPass::None {
1636 (full, pass) = (wanted == NextPass::Full, true);
1637 continue;
1638 }
1639 let _ = events.send(Event::Activity {
1640 account: account.name.clone(),
1641 activity: Activity::Idle { since: now() },
1642 });
1643 ops.select("INBOX")?;
1644 let outcome = ops.idle(interval, wake)?;
1645 *completed_cycle = true;
1647 (full, pass) = match outcome {
1648 IdleOutcome::NewMail => (false, true),
1649 IdleOutcome::Timeout => (true, true),
1650 IdleOutcome::Interrupted if shutdown.load(Ordering::Acquire) => return Ok(()),
1651 IdleOutcome::Interrupted => {
1653 wake.swap(false, Ordering::AcqRel);
1654 (full, false)
1655 }
1656 };
1657 }
1658 Ok(())
1659}
1660
1661#[cfg(test)]
1662mod tests;