Skip to main content

postvak_core/
sync.rs

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
51/// Names one command so the event that completes it can be matched to whoever sent it.
52pub 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    /// The stored messages a `SearchServer` found, as (folder, uid); `query` tells a window whether it still wants them.
94    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        /// The errors this pass also sent as `Error` events, and the rules file's while it is invalid.
118        errors: Vec<String>,
119    },
120}
121
122impl Event {
123    /// The requests this event completes, so the daemon can reply to whoever sent them.
124    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    /// NewMail notifications and per-folder or per-message errors that did not stop the run.
144    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    /// The account has no sync thread, so it never syncs until the daemon restarts or its config changes.
180    NotRunning {
181        reason: String,
182    },
183}
184
185impl fmt::Display for Activity {
186    /// The status line text the window and `postvak daemon status` show.
187    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
222/// "INBOX headers 1,200 / 5,000".
223fn 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
232/// `n` with `separator` between groups of three digits.
233pub 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/// Work the daemon asks an account's sync thread to do on its connection, on a client's behalf.
246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247pub enum Command {
248    /// `by` is logged as the action's rule name.
249    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    /// Searches the server for messages with every word of `query`, in `folder` or every synced folder.
270    SearchServer {
271        folder: Option<String>,
272        query: String,
273    },
274    SyncNow,
275}
276
277/// `Action` skips its user-only variants in rules.toml, so the wire derives them separately.
278#[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/// The pass a command needs after it; when several ask, the larger wins.
304#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
305pub enum NextPass {
306    #[default]
307    None,
308    /// Syncs INBOX and runs the rules, e.g. on mail a move put in a folder it already synced.
309    Inbox,
310    Full,
311}
312
313#[derive(Debug, Default, PartialEq)]
314pub struct CommandsRun {
315    pub used_connection: bool,
316    pub next: NextPass,
317}
318
319/// Called between steps of a pass with what is happening; may run queued commands on the connection.
320/// Returns true when it used the connection, so the caller selects its folder again.
321pub type Checkpoint<'a> = dyn FnMut(&mut dyn MailOps, Activity) -> Result<bool, SyncError> + 'a;
322
323/// Envelopes are fetched this many messages at a time, each batch committed on its own.
324const CHUNK: usize = 500;
325
326#[cfg(any(test, feature = "testing"))]
327thread_local! {
328    /// Replaces `CHUNK` for syncs on this thread, so a test proves chunking with a dozen messages.
329    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    // Newly tracked, a placeholder, or reset: everything listed now was already on the server.
357    let (last_uid, starts_tracking) = match &stored {
358        Some(f) if f.uidvalidity == info.uidvalidity => (f.last_uid, false),
359        // uidvalidity 0 marks a placeholder created by a rule move into a folder not synced yet.
360        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    // Without an expunge, the server holds exactly the stored messages plus the new ones.
392    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        // New messages are fetched after the select, so their flags are at least this fresh.
426        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        // A command ran on the connection and may have selected another folder.
442        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
478/// Without CONDSTORE, flag changes made elsewhere show up within this long.
479const FULL_FLAGS_EVERY_SECS: i64 = 30 * 60;
480
481#[derive(Debug, PartialEq)]
482enum FlagFetch {
483    None,
484    All,
485    ChangedSince(u64),
486}
487
488/// Which flags a tracked folder needs; a full fetch of a large folder costs megabytes, which Gmail counts against its daily cap.
489fn 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
535/// Syncs every listed folder. A folder that fails is skipped and reported in the second list; the others still sync.
536/// A lost connection or a stop ends the pass with that error instead.
537pub 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    // Gmail's All Mail, Starred and Important hold copies of messages in other folders; syncing them doubles the traffic.
545    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    // New mail shows up before the rest of a first sync, which can take minutes.
553    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
574/// Drops store folders this pass did not sync, with their messages; rule-move placeholders wait for the server to list them.
575fn 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
588/// Spec section 6: a role no folder is marked with goes to the folder carrying that name, ignoring case.
589fn 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    // A disabled rule, such as a pending proposal, starts its clock when it is enabled, not when it was written.
614    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        // Before the select, so a command run here leaves nothing to select again.
682        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        // Stored uids belong to another UIDVALIDITY: acting on them would hit unrelated server messages.
702        if info.uidvalidity != stored.uidvalidity {
703            // uidvalidity 0 is a placeholder from a rule move; the next full sync adopts the server value.
704            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        // A rule's body condition does not download a message larger than this; such mail counts as body unknown.
721        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        // Set once a command used the connection; from then on each row is read again, as it may have moved or changed.
734        let mut commands_ran = false;
735        for mut msg in messages {
736            let is_fresh = fresh(msg.uid);
737            // Mail found by a first sync or resync already sat on the server; fetching it would download the whole folder.
738            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        // Folders no rule looks at count as seen up to their last uid, so a rule added for one later finds no old
799        // mail fresh.
800        store.mark_rules_seen_except(&folders)?;
801    }
802    Ok(run)
803}
804
805/// The largest message, in bytes, a rule's body condition downloads by itself.
806pub const MAX_RULE_BODY: u32 = 25 * 1024 * 1024;
807
808/* Whether a uid is fresh: not yet seen by the rules, not already on the server when the folder was first tracked, and
809synced. A row a move stored above last_uid waits for sync, so unsynced mail below it is not marked seen too early. */
810fn 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/// Runs every queued command in arrival order. A failed command is reported and the next one runs; a lost
839/// connection ends the drain with the error, so the session reconnects. `SyncNow` requests go onto
840/// `carried.sync_requests` for the next full pass to answer.
841#[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
872/// Syncs `folder` now so front ends see a change at once, and the rules pass after it finds the mail; a failure is
873/// left to the next full pass.
874fn 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/// Runs one command other than `SyncNow` and sends its answer; `Err` only for a lost connection.
887#[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    // Prefixed to a failure's message, so it names the message it is about.
911    let mut context = String::new();
912    let answer: Result<Event, SyncError> = match command {
913        // run_commands holds SyncNow for the next full pass, so none arrives here; answer it rather than panic if one does.
914        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            /* A move only drops the source row, so the target is synced before `ActionDone` makes the GUI reload. The
940            rules pass after it runs the target folder's rules. */
941            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
1069/// Downloads the missing bodies of the named folder, failing if it cannot, or of every stored folder, skipping any
1070/// that fail. Only a lost connection stops the walk.
1071fn 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
1090/// Searches the named folder on the server, failing if it cannot, or every synced folder, skipping any that fail. Only a
1091/// lost connection stops the walk.
1092fn 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
1141/// Stringifies per-uid results for the event, keeping the first connection loss so it can end the drain.
1142fn 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
1178/// Resolves the account's password and logs in.
1179pub fn connect(account: &AccountConfig) -> Result<ImapOps, SyncError> {
1180    let secret = credentials::resolve(account)?;
1181    Ok(ImapOps::connect(account, &secret)?)
1182}
1183
1184/// Syncs INBOX alone, draining queued commands first like a full pass does before each folder.
1185fn 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/// What an account's thread carries from one session to the next.
1205#[derive(Default)]
1206struct Carried {
1207    /// `SyncNow` requests the next full pass answers.
1208    sync_requests: Vec<RequestId>,
1209    /// The command running now, so one that panics still gets its answer.
1210    running: Option<RequestId>,
1211    rules: LoadedRules,
1212}
1213
1214#[derive(Default)]
1215struct LoadedRules {
1216    /// The last rules that loaded; None until some have, and while the file is invalid with no rule to fall back on.
1217    last_good: Option<Vec<CompiledRule>>,
1218    /// The error of the last load, so each bad edit is reported once rather than every pass.
1219    error: Option<String>,
1220}
1221
1222impl LoadedRules {
1223    /// Loads the rules file again; when it is invalid keeps the last good rules and reports a new error once.
1224    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    /// Runs the last good rules. With none it only notifies, leaving the mail unseen so the rules catch up on it.
1253    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
1282/// NewMail for fresh INBOX mail no notification covered yet, as the account's notify setting says. Moves only the
1283/// notified mark, so the rules still catch up on that mail.
1284fn 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
1317/// What one account's passes share: its store, trash and identity.
1318struct 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    /// Reloads the rules, syncs every folder (`full`) or only INBOX, runs the rules and sends the events. A full pass
1338    /// answers the `SyncNow` requests in `carried` when it starts; ones drained at its checkpoints are kept there for
1339    /// the next. Queued commands run at the checkpoints; returns the pass they want next.
1340    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        // The checkpoint holds `carried` for the whole pass, so the rules stay outside it until the pass ends.
1353        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        // After a command hits a lost connection nothing else may touch it this pass.
1380        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            // Stop or a new command cuts this wait short; clearing the flag keeps later waits at full length.
1478            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/// The loop body, with connection and back-off sleeping injected so tests can drive it with the fake.
1490#[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        // A bug in one session must still answer its requests and leave the account retrying, not silently dead.
1506        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
1555/// Why an offline account cannot run a command: `<account> is offline (<reason>); retrying at <HH:MM>`, local time.
1556pub 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
1563/// Fails the running command, the held `SyncNow` requests and every queued job, so each request still gets its one
1564/// answer.
1565fn 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        // A pass plus a wait means the connection is healthy; a pass alone does not, e.g. when IDLE always fails.
1646        *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            // Woken for queued commands: run them, then wait again.
1652            IdleOutcome::Interrupted => {
1653                wake.swap(false, Ordering::AcqRel);
1654                (full, false)
1655            }
1656        };
1657    }
1658    Ok(())
1659}
1660
1661#[cfg(test)]
1662mod tests;