Skip to main content

sioul_sync/
fetch.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Fetching new mail, read-only, into the account's Maildir.
5//!
6//! The first sync brings the last days of each folder (`sync_days`, 14 by
7//! default), so the Porch shows soon. Then what arrives, by UID, the inbox
8//! first, and, a round at a time, everything older, newest first: all your
9//! mail is kept here, and the history setting only chooses what the window
10//! shows. Older mail never takes the disk's reserve (`disk::room`): it waits,
11//! and the window says so. Where sync stopped is kept per account and folder
12//! in `$XDG_STATE_HOME/sioul/sync/<id>.toml`, after every batch, so an
13//! interrupted sync resumes where it stopped.
14//!
15//! `watch` keeps the inbox open with IDLE (RFC 2177), so a code reaches you
16//! within seconds of reaching your provider.
17
18use crate::SyncError;
19use crate::imap::{self, COMMAND, Imap, Server};
20use crate::mailbox;
21use crate::verify::Verifier;
22use sioul_core::folders::{self, Folder, Role};
23use async_imap::types::Flag;
24use futures_util::StreamExt;
25use serde::{Deserialize, Serialize};
26use sioul_core::card::ImapOrigin;
27use sioul_core::config::{Account, DEFAULT_SYNC_DAYS, state_dir};
28use sioul_core::maildir::{self, Fetched};
29use std::collections::{BTreeMap, BTreeSet};
30use std::path::{Path, PathBuf};
31use std::sync::atomic::{AtomicBool, Ordering};
32use std::sync::{Condvar, Mutex, PoisonError};
33use std::task::{Poll, Waker};
34use std::time::{Duration, Instant, SystemTime};
35
36/// What one sync brought.
37#[derive(Debug, Clone, Default)]
38pub struct Report {
39    pub account: String,
40    /// The files written, oldest first.
41    pub new: Vec<PathBuf>,
42    /// The first sync of the inbox: `new` holds the last days of mail, not only arrivals.
43    pub first: bool,
44    /// Messages in the inbox, on the server.
45    pub inbox: u32,
46    /// Files written besides the inbox's arrivals: other folders, older mail.
47    /// Tied to their projects like the rest, never notified.
48    pub elsewhere: Vec<PathBuf>,
49    /// Older mail waits: fetching it would take the disk's reserve.
50    pub held_back: bool,
51}
52
53const INBOX: &str = "INBOX";
54/// Messages per FETCH: small enough to save progress often.
55const BATCH: usize = 50;
56/// Older messages fetched per round, so new mail never waits long behind them.
57const OLDER_PER_ROUND: usize = 300;
58const FETCH: Duration = Duration::from_secs(180);
59/// IDLE is renewed this often: RFC 2177 asks for less than 29 minutes, and a
60/// connection that died silently is noticed sooner.
61const IDLE: Duration = Duration::from_secs(5 * 60);
62/// Without IDLE, Sioul looks this often.
63const POLL: Duration = Duration::from_secs(2 * 60);
64/// The first wait before reconnecting; it doubles up to ten minutes.
65const FIRST_PAUSE: Duration = Duration::from_secs(30);
66const LONGEST_PAUSE: Duration = Duration::from_secs(10 * 60);
67
68/// Real time: every folder fetched this often, for a code or a password you wait for.
69const REALTIME: Duration = Duration::from_secs(60);
70/// Paces longer than this keep no connection open between fetches: IDLE
71/// would bring the inbox sooner than wished (RFC 2177 renews under 29 minutes).
72const SLOW: Duration = Duration::from_secs(25 * 60);
73
74/// How often a long wait looks at the wall clock: the monotonic clock, which
75/// timers count, stops while a phone sleeps.
76const LOOK: Duration = Duration::from_secs(60);
77
78/// How a watcher is told to stop, or to fetch now instead of waiting, and at what pace.
79#[derive(Debug, Default)]
80pub struct Control {
81    stop: AtomicBool,
82    nudge: AtomicBool,
83    /// "Sync now": a refused password is tried again too.
84    retry: AtomicBool,
85    /// Every folder, every minute, until switched off.
86    realtime: AtomicBool,
87    /// Seconds between two fetches of the other folders; 0 for the default.
88    pace: std::sync::atomic::AtomicU64,
89    /// The app in the background (a phone's app put away): nothing starts on
90    /// a timer but the inbox's watch; a nudge still does, and leaving it is one.
91    quiet: AtomicBool,
92    /// Wakes the pauses at once (`ring`); `lock` keeps a ring from passing
93    /// unheard between a look at the flags and the wait.
94    bell: Condvar,
95    lock: Mutex<()>,
96    /// The async waits (the mail watcher's), woken with the pauses.
97    wakers: Mutex<Vec<Waker>>,
98    /// The watch has ended, its last write done (`end`): what waits on it
99    /// (`wait_ended`) may delete its folder without a fetch writing it again.
100    ended: AtomicBool,
101}
102
103impl Control {
104    /// Real time on or off; on, it fetches now.
105    pub fn set_realtime(&self, on: bool) {
106        self.realtime.store(on, Ordering::Relaxed);
107        if on {
108            self.nudge();
109        }
110    }
111
112    pub fn realtime(&self) -> bool {
113        self.realtime.load(Ordering::Relaxed)
114    }
115
116    /// The pace between two fetches; zero for the default.
117    pub fn set_pace(&self, every: Duration) {
118        self.pace.store(every.as_secs(), Ordering::Relaxed);
119    }
120
121    /// How long the inbox waits in IDLE, and the other folders between fetches.
122    fn round(&self) -> Duration {
123        if self.realtime() {
124            return REALTIME;
125        }
126        match self.pace.load(Ordering::Relaxed) {
127            0 => IDLE,
128            seconds => Duration::from_secs(seconds),
129        }
130    }
131
132    /// A pace slow enough to keep no connection open between fetches.
133    fn slow(&self) -> bool {
134        !self.realtime() && self.round() > SLOW
135    }
136
137    /// What a round of the mail watcher brings besides the inbox's new mail:
138    /// the inbox's changes and older mail, and the other folders when they
139    /// are `due`. Quiet, neither: they wait for the first round after.
140    fn brings(&self, due: bool) -> (bool, bool) {
141        let full = !self.quiet();
142        (full, full && due)
143    }
144
145    /// Quiet or not: the app put away, or back. Quiet, a watcher starts
146    /// nothing on its timer but the inbox's IDLE (new mail is still noticed,
147    /// the other folders wait); leaving it wakes the watcher, as a nudge.
148    pub fn set_quiet(&self, quiet: bool) {
149        if self.quiet.swap(quiet, Ordering::Relaxed) && !quiet {
150            self.nudge();
151        }
152    }
153
154    pub fn quiet(&self) -> bool {
155        self.quiet.load(Ordering::Relaxed)
156    }
157
158    /// Ends the watch at the next occasion: at once when waiting.
159    pub fn stop(&self) {
160        self.stop.store(true, Ordering::Relaxed);
161        self.ring();
162    }
163
164    /// Fetches now: ends the current wait, IDLE or pause.
165    pub fn nudge(&self) {
166        self.nudge.store(true, Ordering::Relaxed);
167        self.ring();
168    }
169
170    /// "Sync now": a nudge, after which a watcher parked on a refused
171    /// password tries it again, unchanged (see `Parked`).
172    pub fn retry(&self) {
173        self.retry.store(true, Ordering::Relaxed);
174        self.nudge();
175    }
176
177    /// Whether "Sync now" was asked since last looked.
178    pub(crate) fn retried(&self) -> bool {
179        self.retry.swap(false, Ordering::Relaxed)
180    }
181
182    pub fn stopped(&self) -> bool {
183        self.stop.load(Ordering::Relaxed)
184    }
185
186    /// Wakes every wait, to look at the flags again.
187    fn ring(&self) {
188        let _held = self.lock.lock().unwrap_or_else(PoisonError::into_inner);
189        self.bell.notify_all();
190        for waker in std::mem::take(&mut *self.wakers.lock().unwrap_or_else(PoisonError::into_inner)) {
191            waker.wake();
192        }
193    }
194
195    /// An async wait, woken at the next ring.
196    fn listen(&self, waker: &Waker) {
197        let mut wakers = self.wakers.lock().unwrap_or_else(PoisonError::into_inner);
198        if !wakers.iter().any(|w| w.will_wake(waker)) {
199            wakers.push(waker.clone());
200        }
201    }
202
203    /// Waits `length` on this thread, or less when stopped or nudged. Counted
204    /// on the wall clock too: a phone asleep for an hour ends a pause of 15
205    /// minutes as soon as it wakes. While quiet, only a nudge ends it.
206    pub fn pause(&self, length: Duration) {
207        self.pause_by(length, SystemTime::now, LOOK);
208    }
209
210    /// `pause`, the wall clock read with `wall`, at least every `look`.
211    fn pause_by(&self, length: Duration, wall: impl Fn() -> SystemTime, look: Duration) {
212        let end = Deadline::after(length, wall());
213        let mut held = self.lock.lock().unwrap_or_else(PoisonError::into_inner);
214        while !self.wakes() {
215            let left = end.left(wall());
216            held = if self.quiet() {
217                self.bell.wait(held).unwrap_or_else(PoisonError::into_inner)
218            } else if left.is_zero() {
219                return;
220            } else {
221                self.bell.wait_timeout(held, left.min(look)).unwrap_or_else(PoisonError::into_inner).0
222            };
223        }
224    }
225
226    /// Waits for a nudge, however long, quiet or not: a watcher parked after
227    /// a lasting error, instead of ending. False when stopped.
228    pub fn park(&self) -> bool {
229        let mut held = self.lock.lock().unwrap_or_else(PoisonError::into_inner);
230        while !self.wakes() {
231            held = self.bell.wait(held).unwrap_or_else(PoisonError::into_inner);
232        }
233        !self.stopped()
234    }
235
236    /// Whether the wait should end; a nudge is used up by ending one.
237    fn wakes(&self) -> bool {
238        self.stopped() || self.nudge.swap(false, Ordering::Relaxed)
239    }
240
241    /// Said by whoever ran the watch (`watch`), once it has returned:
242    /// nothing of it writes any more.
243    pub fn end(&self) {
244        self.ended.store(true, Ordering::Relaxed);
245        self.ring();
246    }
247
248    /// Waits until the watch has ended (`end`), at most `within`: whether it has.
249    pub fn wait_ended(&self, within: Duration) -> bool {
250        let until = std::time::Instant::now() + within;
251        let mut held = self.lock.lock().unwrap_or_else(PoisonError::into_inner);
252        while !self.ended.load(Ordering::Relaxed) {
253            let left = until.saturating_duration_since(std::time::Instant::now());
254            if left.is_zero() {
255                return false;
256            }
257            held = self.bell.wait_timeout(held, left).unwrap_or_else(PoisonError::into_inner).0;
258        }
259        true
260    }
261}
262
263/// A refused password is tried again, unchanged, this long after at the
264/// soonest, however often the watcher is nudged: retries can lock an account.
265const REFUSED_AGAIN: Duration = Duration::from_secs(60 * 60);
266
267/// A watcher parked after a lasting error: it waits for a nudge (a new
268/// password, "Sync now", the app back) instead of ending, then tries again;
269/// a refused password, unchanged, not before `REFUSED_AGAIN`, unless asked
270/// to ([`Control::retry`]).
271pub(crate) struct Parked {
272    refused: Option<String>,
273    since: SystemTime,
274}
275
276impl Parked {
277    /// After `error`, met with `password` (the one refused, when it says so).
278    pub(crate) fn after(error: &SyncError, password: Option<String>) -> Parked {
279        Parked { refused: password.filter(|_| error.wants_password()), since: SystemTime::now() }
280    }
281
282    /// Whether going on depends on the password kept: one was refused.
283    pub(crate) fn refused(&self) -> bool {
284        self.refused.is_some()
285    }
286
287    /// Whether to try again, nudged, with the password kept now.
288    pub(crate) fn again(&self, kept: Option<&str>) -> bool {
289        self.refused.as_deref().is_none_or(|refused| kept.is_some_and(|k| k != refused) || SystemTime::now().duration_since(self.since).is_ok_and(|d| d >= REFUSED_AGAIN))
290    }
291}
292
293/// The end of a wait, on both clocks: the wall clock counts a phone's sleep,
294/// the monotonic clock keeps a wall clock put back from stretching it.
295struct Deadline {
296    wall: Option<SystemTime>,
297    steady: Option<Instant>,
298}
299
300impl Deadline {
301    fn after(length: Duration, now: SystemTime) -> Deadline {
302        Deadline { wall: now.checked_add(length), steady: Instant::now().checked_add(length) }
303    }
304
305    /// What is left of it, by whichever clock is further along; zero when over.
306    fn left(&self, now: SystemTime) -> Duration {
307        let wall = self.wall.map_or(Duration::MAX, |end| end.duration_since(now).unwrap_or_default());
308        let steady = self.steady.map_or(Duration::MAX, |end| end.saturating_duration_since(Instant::now()));
309        wall.min(steady)
310    }
311}
312
313/// Logs in and opens the inbox read-only: the number of messages in it.
314pub fn test(account: &Account, password: &str) -> Result<u32, SyncError> {
315    let server = Server::of(account)?;
316    block_on(async {
317        let mut session = imap::open(&server, password).await?;
318        let inbox = imap::within(COMMAND, session.examine(INBOX)).await?.map_err(imap::server)?;
319        let _ = session.logout().await;
320        Ok(inbox.exists)
321    })
322}
323
324/// Fetches what is new since the last sync, in every folder; the first time,
325/// the last `sync_days` days. The report is the inbox's.
326pub fn sync(account: &Account, password: &str) -> Result<Report, SyncError> {
327    let server = Server::of(account)?;
328    block_on(async {
329        let mut session = imap::open(&server, password).await?;
330        let mut report = fetch_new(&mut session, account).await;
331        if let Ok(report) = report.as_mut() {
332            let (elsewhere, held_back) = other_folders(&mut session, account).await?;
333            report.elsewhere.extend(elsewhere);
334            report.held_back |= held_back;
335        }
336        let _ = session.logout().await;
337        report
338    })
339}
340
341/// The inbox's arrivals alone, in one connection and without IDLE: what a
342/// phone's background service fetches at its rhythm, while Sioul is closed
343/// (crates/sioul-app/src/steps.rs). One fetch at a time per account, across
344/// processes too, as for every fetch (`fetch_folder`).
345pub fn inbox(account: &Account, password: &str) -> Result<Report, SyncError> {
346    let server = Server::of(account)?;
347    block_on(async {
348        let mut session = imap::open(&server, password).await?;
349        let report = fetch_folder(&mut session, account, &folders::folder(INBOX, None, None), false).await;
350        let _ = session.logout().await;
351        report
352    })
353}
354
355/// Lists the folders, keeps the list, and fetches every folder but the inbox;
356/// returns the files written, and whether older mail waits for room.
357async fn other_folders(session: &mut Imap, account: &Account) -> Result<(Vec<PathBuf>, bool), SyncError> {
358    let list = mailbox::list(session).await?;
359    mailbox::save(&account.id, &list)?;
360    let (mut written, mut held_back) = (Vec::new(), false);
361    for folder in list.iter().filter(|f| f.role != Role::Inbox && mailbox::fetched(f, &list, account)) {
362        let report = fetch_folder(session, account, folder, true).await?;
363        written.extend(report.new.into_iter().chain(report.elsewhere));
364        held_back |= report.held_back;
365    }
366    Ok((written, held_back))
367}
368
369/// Keeps the inbox open and fetches what arrives, until stopped (see
370/// [`Control`]). Every sync is reported, even an empty one, and every error,
371/// before trying again; after a lasting one (a refused login, see
372/// [`SyncError::is_lasting`]) it waits for a nudge, then tries again with the
373/// password kept then (see `Parked`).
374pub fn watch(account: &Account, password: &str, control: &Control, mut report: impl FnMut(Result<Report, SyncError>)) {
375    let server = match Server::of(account) {
376        Ok(server) => server,
377        Err(e) => return report(Err(e)),
378    };
379    let runtime = match runtime() {
380        Ok(runtime) => runtime,
381        Err(e) => return report(Err(e)),
382    };
383    runtime.block_on(async {
384        let mut password = password.to_string();
385        let mut pause = FIRST_PAUSE;
386        while !control.stopped() {
387            let Err(e) = keep_open(&server, &password, account, control, &mut report, &mut pause).await else { continue };
388            let parked = e.is_lasting().then(|| Parked::after(&e, Some(password.clone())));
389            report(Err(e));
390            if let Some(parked) = parked {
391                control.retried();
392                loop {
393                    woken(control).await;
394                    if control.stopped() {
395                        return;
396                    }
397                    if !parked.refused() {
398                        break;
399                    }
400                    let kept = crate::secret::password(account).ok();
401                    if parked.again(kept.as_deref()) || control.retried() {
402                        password = kept.unwrap_or(password);
403                        break;
404                    }
405                }
406                pause = FIRST_PAUSE;
407                continue;
408            }
409            // Nudged (the app back, "Sync now"): tried again at once, and soon again if it fails.
410            pause = if wait(pause, control).await { FIRST_PAUSE } else { (pause * 2).min(LONGEST_PAUSE) };
411        }
412    });
413}
414
415/// One connection, kept as long as it lives: fetch, wait for news, fetch again.
416async fn keep_open(
417    server: &Server,
418    password: &str,
419    account: &Account,
420    control: &Control,
421    report: &mut impl FnMut(Result<Report, SyncError>),
422    pause: &mut Duration,
423) -> Result<(), SyncError> {
424    let mut session = imap::open(server, password).await?;
425    let has_idle = imap::within(COMMAND, session.capabilities()).await?.map_err(imap::server)?.has_str("IDLE");
426    // The other folders: at the start, then at each quiet round (five minutes), not at each arrival.
427    let mut others_due = true;
428    while !control.stopped() {
429        let (full, others) = control.brings(others_due);
430        let mut new = fetch_folder(&mut session, account, &folders::folder(INBOX, None, None), full).await?;
431        if others {
432            let (elsewhere, held_back) = other_folders(&mut session, account).await?;
433            new.elsewhere.extend(elsewhere);
434            new.held_back |= held_back;
435        }
436        *pause = FIRST_PAUSE;
437        report(Ok(new));
438        if control.stopped() {
439            break;
440        }
441        // A slow pace (a public address read twice a day): no connection kept meanwhile.
442        if control.slow() {
443            let _ = session.logout().await;
444            wait(control.round(), control).await;
445            return Ok(());
446        }
447        if !has_idle {
448            wait(control.round().min(POLL), control).await;
449            others_due = true;
450            continue;
451        }
452        let mut idle = session.idle();
453        imap::within(COMMAND, idle.init()).await?.map_err(imap::server)?;
454        {
455            let (news, _interrupt) = idle.wait_with_timeout(control.round().min(IDLE));
456            tokio::select! {
457                answer = news => {
458                    others_due = answer.map_err(imap::server)? == async_imap::extensions::idle::IdleResponse::Timeout;
459                }
460                () = woken(control) => { others_due = true; }
461            }
462        }
463        session = imap::within(COMMAND, idle.done()).await?.map_err(imap::server)?;
464    }
465    let _ = session.logout().await;
466    Ok(())
467}
468
469/// Fetches the inbox's new messages into the Maildir.
470async fn fetch_new(session: &mut Imap, account: &Account) -> Result<Report, SyncError> {
471    fetch_folder(session, account, &folders::folder(INBOX, None, None), true).await
472}
473
474/// Fetches a folder's new messages into its Maildir, then (`full`) brings
475/// back what changed there on the server and older mail. One fetch at a time
476/// per account, across processes too (the window and `sioul sync`):
477/// otherwise both would see the same new UIDs and write each message twice.
478async fn fetch_folder(session: &mut Imap, account: &Account, folder: &Folder, full: bool) -> Result<Report, SyncError> {
479    let state_path = state_dir().join("sync").join(format!("{}.toml", account.id));
480    let _lock = lock(&state_path.with_extension("lock"))?;
481    let inbox = imap::within(COMMAND, session.examine(&folder.name)).await?.map_err(imap::server)?;
482    let validity = inbox.uid_validity.unwrap_or(0);
483    let mut state = SyncState::load(&state_path);
484    let key = folder.name.as_str();
485    // A new UIDVALIDITY means the server renumbered the folder: start over.
486    let known = state.folders.get(key).copied().filter(|f| f.uidvalidity == validity);
487    let last_uid = known.map_or(0, |f| f.last_uid);
488    let query = match known {
489        // The last UID a server may give (2³²−1) has no next one.
490        Some(_) => format!("UID {}:*", last_uid.saturating_add(1)),
491        None => format!("SINCE {}", imap_date(first_window(account))),
492    };
493    let found = imap::within(COMMAND, session.uid_search(&query)).await?.map_err(imap::server)?;
494    let root = account.maildir_path().join(&folder.local);
495    let (uids, mut newest) = to_fetch(found, last_uid, &root, validity);
496    let mut report = Report { account: account.id.clone(), new: Vec::new(), first: known.is_none(), inbox: inbox.exists, elsewhere: Vec::new(), held_back: false };
497    // Without DNS (offline), mail is stored unchecked; a later pass checks it.
498    let verifier = if uids.is_empty() { None } else { Verifier::new() };
499    for batch in uids.chunks(BATCH) {
500        let set = batch.iter().map(u32::to_string).collect::<Vec<_>>().join(",");
501        let arrived = imap::within(FETCH, fetch_batch(session, &set)).await??;
502        let written = store_checked(arrived, validity, &root, verifier.as_ref()).await?;
503        newest = written.iter().map(|(uid, _)| *uid).fold(newest, u32::max);
504        report.new.extend(written.into_iter().map(|(_, path)| path));
505        let reached = known.map_or_else(|| Reach::first(account), |k| k.reach());
506        state.folders.insert(key.into(), FolderState { uidvalidity: validity, last_uid: newest, since: reached.since, everything: reached.everything });
507        state.save(&state_path)?;
508    }
509    if uids.is_empty() && newest > last_uid {
510        // Here already, from a sync stopped before it could say so.
511        let reached = known.map_or_else(|| Reach::first(account), |k| k.reach());
512        state.folders.insert(key.into(), FolderState { uidvalidity: validity, last_uid: newest, since: reached.since, everything: reached.everything });
513        state.save(&state_path)?;
514    }
515    if known.is_none() && uids.is_empty() {
516        // Nothing in the first window: remember the folder anyway, so the next
517        // sync looks only at arrivals. Without UIDNEXT, the window is used again.
518        if let Some(next) = inbox.uid_next {
519            let reached = Reach::first(account);
520            state.folders.insert(key.into(), FolderState { uidvalidity: validity, last_uid: next.saturating_sub(1), since: reached.since, everything: reached.everything });
521            state.save(&state_path)?;
522        }
523    }
524    if let Some(known) = known
525        && full
526    {
527        mailbox::reconcile(session, &root, validity).await?;
528        backfill(session, &mut state, &state_path, key, known, &root, validity, &mut report).await?;
529    }
530    Ok(report)
531}
532
533/// The UIDs to fetch of those found, oldest first, and the newest of those
534/// here already (else `last_uid`). Here already: a sync stopped between
535/// storing mail and saving where it stopped, or whose state was lost, finds
536/// them again, and they are not stored twice.
537fn to_fetch(found: impl IntoIterator<Item = u32>, last_uid: u32, root: &Path, validity: u32) -> (Vec<u32>, u32) {
538    // "UID n:*" also returns the newest message when nothing is newer (RFC 9051 §6.4.8).
539    let mut uids: Vec<u32> = found.into_iter().filter(|uid| *uid > last_uid).collect();
540    if uids.is_empty() {
541        return (uids, last_uid);
542    }
543    let here = local_uids(root, validity);
544    let newest = uids.iter().copied().filter(|uid| here.contains(uid)).fold(last_uid, u32::max);
545    uids.retain(|uid| !here.contains(uid));
546    uids.sort_unstable();
547    (uids, newest)
548}
549
550/// How far back a folder was fetched: since a day (Unix seconds), or everything.
551#[derive(Debug, Clone, Copy, PartialEq, Eq)]
552struct Reach {
553    since: Option<i64>,
554    everything: bool,
555}
556
557impl Reach {
558    /// What a first sync brings: the first window.
559    fn first(account: &Account) -> Reach {
560        Reach { since: first_window(account).to_zoned(jiff::tz::TimeZone::UTC).ok().map(|z| z.timestamp().as_second()), everything: false }
561    }
562}
563
564impl FolderState {
565    fn reach(self) -> Reach {
566        Reach { since: self.since, everything: self.everything }
567    }
568}
569
570/// The older messages of a folder, those not here yet, a round at a time,
571/// newest first, each round sized first so the disk keeps its reserve; the
572/// folder remembers once it holds everything.
573#[allow(clippy::too_many_arguments)]
574async fn backfill(session: &mut Imap, state: &mut SyncState, state_path: &Path, key: &str, known: FolderState, root: &Path, validity: u32, report: &mut Report) -> Result<(), SyncError> {
575    if known.everything {
576        return Ok(());
577    }
578    let found = imap::within(COMMAND, session.uid_search("ALL")).await?.map_err(imap::server)?;
579    let here: BTreeSet<u32> = local_uids(root, validity);
580    let mut older: Vec<u32> = found.into_iter().filter(|uid| *uid <= known.last_uid && !here.contains(uid)).collect();
581    older.sort_unstable_by(|a, b| b.cmp(a));
582    let round: Vec<u32> = older.iter().take(OLDER_PER_ROUND).copied().collect();
583    // What the disk can take of this round, the newest first.
584    let sizes = imap::within(FETCH, sizes_of(session, &round)).await??;
585    let mut room = crate::disk::room(root);
586    let mut take = Vec::new();
587    for uid in &round {
588        let size = u64::from(sizes.get(uid).copied().unwrap_or(0));
589        if size > room {
590            report.held_back = true;
591            break;
592        }
593        room -= size;
594        take.push(*uid);
595    }
596    take.sort_unstable();
597    let verifier = if take.is_empty() { None } else { Verifier::new() };
598    for batch in take.chunks(BATCH) {
599        let set = batch.iter().map(u32::to_string).collect::<Vec<_>>().join(",");
600        let arrived = imap::within(FETCH, fetch_batch(session, &set)).await??;
601        report.elsewhere.extend(store_checked(arrived, validity, root, verifier.as_ref()).await?.into_iter().map(|(_, path)| path));
602    }
603    if !report.held_back && take.len() == older.len() {
604        let mut folder = state.folders.get(key).copied().unwrap_or(known);
605        folder.since = None;
606        folder.everything = true;
607        state.folders.insert(key.into(), folder);
608        state.save(state_path)?;
609    }
610    Ok(())
611}
612
613/// The sizes of messages on the server (RFC822.SIZE), by UID, before fetching them.
614async fn sizes_of(session: &mut Imap, uids: &[u32]) -> Result<std::collections::HashMap<u32, u32>, SyncError> {
615    let mut sizes = std::collections::HashMap::new();
616    if uids.is_empty() {
617        return Ok(sizes);
618    }
619    let set = uids.iter().map(u32::to_string).collect::<Vec<_>>().join(",");
620    let mut stream = session.uid_fetch(&set, "(UID RFC822.SIZE)").await.map_err(imap::server)?;
621    while let Some(fetch) = stream.next().await {
622        let fetch = fetch.map_err(imap::server)?;
623        if let (Some(uid), Some(size)) = (fetch.uid, fetch.size) {
624            sizes.insert(uid, size);
625        }
626    }
627    Ok(sizes)
628}
629
630/// The UIDs of a folder's messages kept here, for this UIDVALIDITY.
631fn local_uids(root: &Path, validity: u32) -> BTreeSet<u32> {
632    ["new", "cur"]
633        .iter()
634        .filter_map(|sub| std::fs::read_dir(root.join(sub)).ok())
635        .flatten()
636        .filter_map(Result::ok)
637        .filter_map(|e| maildir::origin_of(&e.path()))
638        .filter(|o| o.validity == validity)
639        .map(|o| o.uid)
640        .collect()
641}
642
643/// A message as the server gave it, before Sioul checks and stores it.
644pub(crate) struct Arrived {
645    uid: u32,
646    flags: String,
647    received: Option<i64>,
648    raw: Vec<u8>,
649}
650
651/// One FETCH: every message of `set`. BODY.PEEK leaves the \Seen flag alone.
652pub(crate) async fn fetch_batch(session: &mut Imap, set: &str) -> Result<Vec<Arrived>, SyncError> {
653    let mut stream = session.uid_fetch(set, "(UID FLAGS INTERNALDATE BODY.PEEK[])").await.map_err(imap::server)?;
654    let mut arrived = Vec::new();
655    while let Some(fetch) = stream.next().await {
656        let fetch = fetch.map_err(imap::server)?;
657        let (Some(uid), Some(raw)) = (fetch.uid, fetch.body()) else { continue };
658        arrived.push(Arrived { uid, flags: maildir_flags(fetch.flags()), received: fetch.internal_date().map(|d| d.timestamp()), raw: raw.to_vec() });
659    }
660    Ok(arrived)
661}
662
663/// Checks each message (SPF, DKIM, DMARC…, see `verify`) and writes it into the Maildir.
664pub(crate) async fn store_checked(arrived: Vec<Arrived>, validity: u32, root: &Path, verifier: Option<&Verifier>) -> Result<Vec<(u32, PathBuf)>, SyncError> {
665    let mut written = Vec::new();
666    for message in arrived {
667        let raw = match verifier {
668            Some(v) => v.stamp(&message.raw).await,
669            None => message.raw,
670        };
671        let fetched = Fetched { origin: ImapOrigin { validity, uid: message.uid }, flags: message.flags, received: message.received, raw: &raw };
672        let path = maildir::store(root, &fetched).map_err(|e| SyncError::Disk(format!("{}: {e}", root.display())))?;
673        written.push((message.uid, path));
674    }
675    Ok(written)
676}
677
678/// IMAP's system flags as Maildir's letters (<https://cr.yp.to/proto/maildir.html>),
679/// and the keywords kept, `$Junk` and `$NotJunk`, as Dovecot's lowercase
680/// letters (`maildir::KEYWORDS`): what you or a mail client said of a message,
681/// which the spam filter learns from and the Porch heeds. `reconcile` keeps them in step.
682pub(crate) fn maildir_flags<'a>(flags: impl Iterator<Item = Flag<'a>>) -> String {
683    flags
684        .filter_map(|flag| match flag {
685            Flag::Seen => Some('S'),
686            Flag::Answered => Some('R'),
687            Flag::Flagged => Some('F'),
688            Flag::Deleted => Some('T'),
689            Flag::Draft => Some('D'),
690            Flag::Custom(keyword) => maildir::keyword_letter(&keyword),
691            _ => None,
692        })
693        .collect()
694}
695
696/// The first day a first sync brings: the first window (`sync_days`); older
697/// mail follows, a round at a time.
698fn first_window(account: &Account) -> jiff::civil::Date {
699    let days = i64::from(account.sync_days.unwrap_or(DEFAULT_SYNC_DAYS));
700    let today = jiff::Zoned::now().date();
701    today.checked_sub(jiff::Span::new().days(days)).unwrap_or(today)
702}
703
704/// A day as IMAP writes dates: "18-Sep-2026", with English month names
705/// whatever your language (RFC 9051 §9, date-month).
706fn imap_date(day: jiff::civil::Date) -> String {
707    const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
708    format!("{}-{}-{}", day.day(), MONTHS[usize::from(day.month().unsigned_abs()) - 1], day.year())
709}
710
711/// Where sync stopped, per folder.
712#[derive(Debug, Default, Serialize, Deserialize)]
713struct SyncState {
714    #[serde(default)]
715    folders: BTreeMap<String, FolderState>,
716}
717
718#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)]
719struct FolderState {
720    uidvalidity: u32,
721    last_uid: u32,
722    /// The first day fetched, Unix seconds; unknown for folders synced before Sioul kept it.
723    #[serde(default)]
724    since: Option<i64>,
725    /// Everything was fetched.
726    #[serde(default)]
727    everything: bool,
728}
729
730/// How much of a folder is kept here: all of it, all of it since a day (Unix
731/// seconds), or nothing known (never fetched, left on the server, or fetched
732/// before Sioul kept how far back). A search asks the server for the rest.
733#[derive(Debug, Clone, Copy, PartialEq, Eq)]
734pub enum Kept {
735    Everything,
736    Since(i64),
737    Unknown,
738}
739
740/// How much of `folder` (its server name) this device holds, as its last sync left it.
741pub fn kept(account: &Account, folder: &str) -> Kept {
742    let state = SyncState::load(&state_dir().join("sync").join(format!("{}.toml", account.id)));
743    match state.folders.get(folder) {
744        Some(f) if f.everything => Kept::Everything,
745        Some(FolderState { since: Some(since), .. }) => Kept::Since(*since),
746        _ => Kept::Unknown,
747    }
748}
749
750/// Where a folder's sync stopped, forgotten: the next sync of it starts over.
751pub(crate) fn forget_folder_state(account: &Account, name: &str) -> Result<(), SyncError> {
752    let state_path = state_dir().join("sync").join(format!("{}.toml", account.id));
753    let _lock = lock(&state_path.with_extension("lock"))?;
754    let mut state = SyncState::load(&state_path);
755    if state.folders.remove(name).is_some() {
756        state.save(&state_path)?;
757    }
758    Ok(())
759}
760
761impl SyncState {
762    fn load(path: &Path) -> SyncState {
763        std::fs::read_to_string(path).ok().and_then(|text| toml::from_str(&text).ok()).unwrap_or_default()
764    }
765
766    /// Written next to its place, then moved: an interruption never leaves
767    /// half a state, which would be read as none (every folder's first window
768    /// fetched again).
769    fn save(&self, path: &Path) -> Result<(), SyncError> {
770        let fail = |e: std::io::Error| SyncError::Disk(format!("{}: {e}", path.display()));
771        if let Some(parent) = path.parent() {
772            std::fs::create_dir_all(parent).map_err(fail)?;
773        }
774        let text = toml::to_string(self).map_err(|e| SyncError::Disk(e.to_string()))?;
775        let temporary = path.with_extension("toml.new");
776        std::fs::write(&temporary, text).map_err(fail)?;
777        std::fs::rename(&temporary, path).map_err(fail)
778    }
779}
780
781/// An exclusive lock on a file, released when the returned file is dropped.
782pub(crate) fn lock(path: &Path) -> Result<std::fs::File, SyncError> {
783    let fail = |e: std::io::Error| SyncError::Disk(format!("{}: {e}", path.display()));
784    if let Some(parent) = path.parent() {
785        std::fs::create_dir_all(parent).map_err(fail)?;
786    }
787    let file = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(path).map_err(fail)?;
788    file.lock().map_err(fail)?;
789    Ok(file)
790}
791
792fn runtime() -> Result<tokio::runtime::Runtime, SyncError> {
793    tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| SyncError::Network(e.to_string()))
794}
795
796pub(crate) fn block_on<T>(future: impl Future<Output = Result<T, SyncError>>) -> Result<T, SyncError> {
797    runtime()?.block_on(future)
798}
799
800/// Returns when the watcher is stopped or nudged.
801async fn woken(control: &Control) {
802    std::future::poll_fn(|context| {
803        if control.wakes() {
804            return Poll::Ready(());
805        }
806        control.listen(context.waker());
807        // Rung between the look and the listening: not missed.
808        if control.wakes() { Poll::Ready(()) } else { Poll::Pending }
809    })
810    .await;
811}
812
813/// Waits `length`, or less when stopped or nudged; on the wall clock too (see
814/// `Deadline`). Whether it was nudged.
815async fn wait(length: Duration, control: &Control) -> bool {
816    let end = Deadline::after(length, SystemTime::now());
817    loop {
818        let left = end.left(SystemTime::now());
819        if left.is_zero() {
820            return false;
821        }
822        tokio::select! {
823            () = tokio::time::sleep(left.min(LOOK)) => {}
824            () = woken(control) => return !control.stopped(),
825        }
826    }
827}
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832
833    #[test]
834    fn imap_dates_are_english() {
835        let day = jiff::civil::date(2026, 9, 18);
836        assert_eq!(imap_date(day), "18-Sep-2026");
837    }
838
839    #[test]
840    fn the_first_sync_is_short_whatever_the_history() {
841        let mut account = Account::imap("a", "a@example.org", "imap.example.org", 993, Default::default(), None);
842        let today = jiff::Zoned::now().date();
843        assert_eq!(first_window(&account), today.checked_sub(jiff::Span::new().days(i64::from(DEFAULT_SYNC_DAYS))).unwrap());
844        // The history only chooses what the window shows: everything is fetched after the first window.
845        account.history_weeks = Some(0);
846        account.sync_days = Some(3);
847        assert_eq!(first_window(&account), today.checked_sub(jiff::Span::new().days(3)).unwrap());
848        assert!(!Reach::first(&account).everything);
849    }
850
851    /// F3b: a nudge ends a pause at once, not at the next look half a second later.
852    #[test]
853    fn a_nudge_ends_a_pause_at_once() {
854        let control = Control::default();
855        let started = Instant::now();
856        std::thread::scope(|s| {
857            s.spawn(|| {
858                std::thread::sleep(Duration::from_millis(50));
859                control.nudge();
860            });
861            control.pause(Duration::from_secs(10));
862        });
863        assert!(started.elapsed() < Duration::from_millis(400), "{:?}", started.elapsed());
864    }
865
866    /// F3b: the same for the mail watcher's waits, which say they were
867    /// nudged (a failed connection is then tried again at once, and its
868    /// pause starts over).
869    #[test]
870    fn a_nudge_ends_a_wait_at_once() {
871        let control = Control::default();
872        let started = Instant::now();
873        std::thread::scope(|s| {
874            s.spawn(|| {
875                std::thread::sleep(Duration::from_millis(50));
876                control.nudge();
877            });
878            assert!(runtime().unwrap().block_on(wait(Duration::from_secs(10), &control)), "nudged");
879        });
880        assert!(started.elapsed() < Duration::from_millis(400), "{:?}", started.elapsed());
881        assert!(!runtime().unwrap().block_on(wait(Duration::from_millis(20), &control)), "to its end");
882    }
883
884    /// A1: a pause counts the wall clock: a phone asleep past its end ends it
885    /// as it wakes, not fifteen awake minutes later.
886    #[test]
887    fn a_pause_counts_the_time_asleep() {
888        let control = Control::default();
889        let start = SystemTime::now();
890        let looks = std::cell::Cell::new(0);
891        // The clock read at the start, at the first look, then after an hour asleep.
892        let wall = || {
893            looks.set(looks.get() + 1);
894            if looks.get() <= 2 { start } else { start + Duration::from_secs(3600) }
895        };
896        let started = Instant::now();
897        control.pause_by(Duration::from_secs(15 * 60), wall, Duration::from_millis(20));
898        assert!(started.elapsed() < Duration::from_secs(1), "{:?}", started.elapsed());
899        assert_eq!(looks.get(), 3);
900    }
901
902    /// Quiet (the app put away): a pause ends only when nudged, and leaving
903    /// quiet is a nudge; the mail watcher brings the inbox's new mail only.
904    #[test]
905    fn quiet_waits_for_a_nudge() {
906        let control = Control::default();
907        assert_eq!(control.brings(true), (true, true));
908        control.set_quiet(true);
909        assert_eq!(control.brings(true), (false, false));
910        let (done, ended) = std::sync::mpsc::channel();
911        std::thread::scope(|s| {
912            s.spawn(|| {
913                control.pause(Duration::from_millis(30));
914                done.send(()).unwrap();
915            });
916            assert!(ended.recv_timeout(Duration::from_millis(300)).is_err(), "not on its timer");
917            control.nudge();
918            ended.recv_timeout(Duration::from_secs(5)).expect("a nudge ends it");
919            s.spawn(|| {
920                control.pause(Duration::from_millis(30));
921                done.send(()).unwrap();
922            });
923            assert!(ended.recv_timeout(Duration::from_millis(300)).is_err(), "still quiet");
924            control.set_quiet(false);
925            ended.recv_timeout(Duration::from_secs(5)).expect("leaving quiet ends it");
926        });
927        assert_eq!(control.brings(false), (true, false));
928    }
929
930    /// A2: a watcher parked after a lasting error waits for a nudge, quiet
931    /// or not; stopped, it ends.
932    #[test]
933    fn a_parked_watcher_waits_for_a_nudge() {
934        let control = Control::default();
935        control.set_quiet(true);
936        let (done, ended) = std::sync::mpsc::channel();
937        std::thread::scope(|s| {
938            s.spawn(|| done.send(control.park()).unwrap());
939            assert!(ended.recv_timeout(Duration::from_millis(200)).is_err(), "parked");
940            control.nudge();
941            assert_eq!(ended.recv_timeout(Duration::from_secs(5)), Ok(true), "nudged: tries again");
942            s.spawn(|| done.send(control.park()).unwrap());
943            control.stop();
944            assert_eq!(ended.recv_timeout(Duration::from_secs(5)), Ok(false), "stopped: ends");
945        });
946    }
947
948    /// The review of 15 October 2026, L5: what deletes a watcher's folder
949    /// waits for its end, not only its stop; a watch that does not end in
950    /// time is said so.
951    #[test]
952    fn the_end_of_a_watch_is_waited_for() {
953        let control = Control::default();
954        control.stop();
955        assert!(!control.wait_ended(Duration::from_millis(50)), "stopped is not ended: a fetch may still write");
956        let (done, ended) = std::sync::mpsc::channel();
957        std::thread::scope(|s| {
958            s.spawn(|| done.send(control.wait_ended(Duration::from_secs(10))).unwrap());
959            assert!(ended.recv_timeout(Duration::from_millis(200)).is_err(), "waiting");
960            control.end();
961            assert_eq!(ended.recv_timeout(Duration::from_secs(5)), Ok(true), "ended: the wait ends at once");
962        });
963        assert!(control.wait_ended(Duration::ZERO), "ended stays ended");
964    }
965
966    /// A2: a refused password is tried again once another is kept, or an hour
967    /// later, however often nudged; anything else lasting, at the next nudge.
968    #[test]
969    fn a_refused_password_waits_for_another() {
970        let parked = Parked::after(&SyncError::Login("refused".into()), Some("old".into()));
971        assert!(parked.refused());
972        assert!(!parked.again(Some("old")) && !parked.again(None));
973        assert!(parked.again(Some("new")));
974        let hour_later = Parked { since: SystemTime::now() - REFUSED_AGAIN, ..parked };
975        assert!(hour_later.again(Some("old")));
976        let portal = Parked::after(&SyncError::Tls("certificate".into()), Some("old".into()));
977        assert!(!portal.refused() && portal.again(Some("old")));
978    }
979
980    /// A folder of this test's own, empty.
981    fn scratch(name: &str) -> PathBuf {
982        let dir = std::env::temp_dir().join(format!("sioul-fetch-{name}-{}", std::process::id()));
983        let _ = std::fs::remove_dir_all(&dir);
984        std::fs::create_dir_all(&dir).unwrap();
985        dir
986    }
987
988    /// F2: a sync stopped between storing mail and saving where it stopped,
989    /// or whose state was lost, does not store the same messages twice.
990    #[test]
991    fn mail_already_here_is_not_fetched_again() {
992        let root = scratch("already-here");
993        let message = |validity, uid| Fetched { origin: ImapOrigin { validity, uid }, flags: String::new(), received: None, raw: b"Subject: x\r\n\r\nx\r\n" };
994        for (validity, uid) in [(7, 3), (7, 4), (6, 5)] {
995            maildir::store(&root, &message(validity, uid)).unwrap();
996        }
997        assert_eq!(to_fetch([5, 3, 4], 2, &root, 7), (vec![5], 4), "5 of another UIDVALIDITY is another message");
998        // Where sync stopped forgotten: the first window again, nothing twice.
999        assert_eq!(to_fetch([3, 4], 0, &root, 7), (vec![], 4));
1000        assert_eq!(to_fetch([4], 4, &root, 7), (vec![], 4), "the newest, given again by UID 5:*");
1001        let _ = std::fs::remove_dir_all(root);
1002    }
1003
1004    /// F2: where sync stopped is written next to its place, then moved: a
1005    /// write cut short never leaves half a state, which would be read as
1006    /// none (the last 14 days of every folder fetched again).
1007    #[test]
1008    fn where_sync_stopped_is_replaced_whole() {
1009        let dir = scratch("state");
1010        let path = dir.join("account.toml");
1011        let mut state = SyncState::default();
1012        state.folders.insert("INBOX".into(), FolderState { uidvalidity: 7, last_uid: 41, since: None, everything: true });
1013        state.save(&path).unwrap();
1014        // Another name for the same file: written over in place, it would change too.
1015        let before = dir.join("before.toml");
1016        std::fs::hard_link(&path, &before).unwrap();
1017        state.folders.get_mut("INBOX").unwrap().last_uid = 42;
1018        state.save(&path).unwrap();
1019        assert_eq!(SyncState::load(&before).folders["INBOX"].last_uid, 41, "replaced, not written over");
1020        assert_eq!(SyncState::load(&path).folders["INBOX"].last_uid, 42);
1021        assert!(!path.with_extension("toml.new").exists());
1022        let _ = std::fs::remove_dir_all(dir);
1023    }
1024
1025    #[test]
1026    fn flags_become_maildir_letters() {
1027        let flags = [Flag::Seen, Flag::Flagged, Flag::Recent];
1028        assert_eq!(maildir_flags(flags.into_iter()), "SF");
1029        // `$Junk` and `$NotJunk` are kept, whatever their case; other keywords are not.
1030        let flags = [Flag::Seen, Flag::Custom("$NotJunk".into()), Flag::Custom("$Forwarded".into()), Flag::Custom("$junk".into()), Flag::Custom("NonJunk".into())];
1031        assert_eq!(maildir_flags(flags.into_iter()), format!("S{}{}", maildir::NOT_JUNK, maildir::JUNK));
1032    }
1033}