//! IMAP sync: folders, flags, deletions and the 90-day window (§12.3). use std::{ collections::{HashMap, HashSet}, sync::{Mutex, MutexGuard}, }; use crate::{ Account, AccountId, Error, Fetched, Got, Held, Imap, Key, MailboxId, Result, Secret, Store, TlsConnector, Vault, oauth, }; /// Bodies come down for mail this recent. pub const BODY_DAYS: i64 = 90; /// UIDs per FETCH. const BATCH: usize = 100; /// What one sync changed. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Report { pub folders: usize, pub headers: usize, pub bodies: usize, pub removed: usize, } /// The password, or an access token from the refresh token. pub async fn secret(account: &Account, vault: &Vault) -> Result { let name = account.secret_name(); let kept = vault .get(&name)? .ok_or_else(|| Error::Vault(format!("no saved secret for {name}")))?; let Some(provider) = account.auth.provider() else { return Ok(Secret::Password(kept)); }; let tokens = oauth::refresh(&provider?, &kept).await?; if let Some(next) = &tokens.refresh { vault.put(&name, next); } Ok(Secret::Token(tokens.access)) } /// One pass over every folder; `now` is Unix seconds. pub async fn imap( account: &Account, id: AccountId, secret: Secret, tls: &TlsConnector, store: &Mutex, now: i64, ) -> Result { let mut imap = Imap::connect(&account.incoming, &account.login(secret), tls).await?; let folders: Vec<_> = imap .folders() .await? .into_iter() .filter(|folder| folder.selectable) .collect(); let named: Vec<_> = folders .iter() .map(|folder| (folder.name.as_str(), folder.role)) .collect(); let boxes = lock(store)?.set_folders(id, &named)?; let day = since(now)?; let mut report = Report { folders: folders.len(), ..Report::default() }; for (folder, mailbox) in folders.iter().zip(boxes) { one(&mut imap, store, mailbox, &folder.name, &day, &mut report).await?; } imap.logout().await?; lock(store)?.set_synced(id, now)?; log::info!( "mail: account {} synced: {} folders, {} headers, {} bodies, {} removed", id.0, report.folders, report.headers, report.bodies, report.removed ); Ok(report) } async fn one( imap: &mut Imap, store: &Mutex, mailbox: MailboxId, name: &str, day: &str, report: &mut Report, ) -> Result<()> { let opened = imap.examine(name).await?; let held = lock(store)?.open_mailbox(mailbox, opened.uidvalidity)?; if opened.exists == 0 { return remove(store, held.values(), report); } let server: HashMap> = imap.flags().await?.into_iter().collect(); let gone = held .iter() .filter(|(uid, _)| !server.contains_key(uid)) .map(|(_, held)| held); remove(store, gone, report)?; for (uid, held) in &held { match server.get(uid) { Some(flags) if *flags != held.flags => { lock(store)?.set_flags(held.id, &strs(flags))?; } _ => {} } } let recent: HashSet = imap.since(day).await?.into_iter().collect(); let mut headers = Vec::new(); let mut bodies = Vec::new(); for uid in server.keys() { match (held.get(uid), recent.contains(uid)) { (None | Some(Held { body: false, .. }), true) => bodies.push(*uid), (None, false) => headers.push(*uid), _ => {} } } headers.sort_unstable(); bodies.sort_unstable(); let key = |uid| Key::Imap { uidvalidity: opened.uidvalidity, uid, }; for batch in headers.chunks(BATCH) { for got in imap.headers(batch).await? { put( store, mailbox, key(got.uid), &got, Fetched::Headers(&got.bytes), )?; report.headers += 1; } } for batch in bodies.chunks(BATCH) { let (whole, partial): (Vec<_>, Vec<_>) = imap .structures(batch) .await? .into_iter() .partition(|(_, node)| node.complete()); let whole: Vec = whole.into_iter().map(|(uid, _)| uid).collect(); if !whole.is_empty() { for got in imap.bodies(&whole).await? { put( store, mailbox, key(got.uid), &got, Fetched::Whole(&got.bytes), )?; report.bodies += 1; } } // §12.3: attachments wait for a request. for (uid, node) in partial { let Some(got) = imap.text(uid, &node).await? else { continue; }; let missing = node.missing(); let text = Fetched::Text { bytes: &got.bytes, missing: &missing, }; put(store, mailbox, key(uid), &got, text)?; report.bodies += 1; } } Ok(()) } fn put( store: &Mutex, mailbox: MailboxId, key: Key, got: &Got, fetched: Fetched, ) -> Result<()> { lock(store)?.put(mailbox, &key, &strs(&got.flags), fetched)?; Ok(()) } fn remove<'a>( store: &Mutex, gone: impl Iterator, report: &mut Report, ) -> Result<()> { for held in gone { lock(store)?.remove(held.id)?; report.removed += 1; } Ok(()) } fn strs(flags: &[String]) -> Vec<&str> { flags.iter().map(String::as_str).collect() } /// `SINCE` takes a date in English: `19-Jul-2026`. fn since(now: i64) -> Result { let start = jiff::Timestamp::from_second(now - BODY_DAYS * 86_400) .map_err(|err| Error::Store(format!("a sync time: {err}")))?; Ok(start .to_zoned(jiff::tz::TimeZone::UTC) .strftime("%-d-%b-%Y") .to_string()) } fn lock(store: &Mutex) -> Result> { store .lock() .map_err(|_| Error::Store("a store task panicked".into())) } #[cfg(test)] mod tests { #[test] fn the_window_starts_ninety_days_back() { // 2026-10-19 12:00 UTC assert_eq!(super::since(1_792_411_200).unwrap(), "21-Jul-2026"); } }