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