1use 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#[derive(Debug, Clone, Default)]
38pub struct Report {
39 pub account: String,
40 pub new: Vec<PathBuf>,
42 pub first: bool,
44 pub inbox: u32,
46 pub elsewhere: Vec<PathBuf>,
49 pub held_back: bool,
51}
52
53const INBOX: &str = "INBOX";
54const BATCH: usize = 50;
56const OLDER_PER_ROUND: usize = 300;
58const FETCH: Duration = Duration::from_secs(180);
59const IDLE: Duration = Duration::from_secs(5 * 60);
62const POLL: Duration = Duration::from_secs(2 * 60);
64const FIRST_PAUSE: Duration = Duration::from_secs(30);
66const LONGEST_PAUSE: Duration = Duration::from_secs(10 * 60);
67
68const REALTIME: Duration = Duration::from_secs(60);
70const SLOW: Duration = Duration::from_secs(25 * 60);
73
74const LOOK: Duration = Duration::from_secs(60);
77
78#[derive(Debug, Default)]
80pub struct Control {
81 stop: AtomicBool,
82 nudge: AtomicBool,
83 retry: AtomicBool,
85 realtime: AtomicBool,
87 pace: std::sync::atomic::AtomicU64,
89 quiet: AtomicBool,
92 bell: Condvar,
95 lock: Mutex<()>,
96 wakers: Mutex<Vec<Waker>>,
98 ended: AtomicBool,
101}
102
103impl Control {
104 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 pub fn set_pace(&self, every: Duration) {
118 self.pace.store(every.as_secs(), Ordering::Relaxed);
119 }
120
121 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 fn slow(&self) -> bool {
134 !self.realtime() && self.round() > SLOW
135 }
136
137 fn brings(&self, due: bool) -> (bool, bool) {
141 let full = !self.quiet();
142 (full, full && due)
143 }
144
145 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 pub fn stop(&self) {
160 self.stop.store(true, Ordering::Relaxed);
161 self.ring();
162 }
163
164 pub fn nudge(&self) {
166 self.nudge.store(true, Ordering::Relaxed);
167 self.ring();
168 }
169
170 pub fn retry(&self) {
173 self.retry.store(true, Ordering::Relaxed);
174 self.nudge();
175 }
176
177 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 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 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 pub fn pause(&self, length: Duration) {
207 self.pause_by(length, SystemTime::now, LOOK);
208 }
209
210 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 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 fn wakes(&self) -> bool {
238 self.stopped() || self.nudge.swap(false, Ordering::Relaxed)
239 }
240
241 pub fn end(&self) {
244 self.ended.store(true, Ordering::Relaxed);
245 self.ring();
246 }
247
248 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
263const REFUSED_AGAIN: Duration = Duration::from_secs(60 * 60);
266
267pub(crate) struct Parked {
272 refused: Option<String>,
273 since: SystemTime,
274}
275
276impl Parked {
277 pub(crate) fn after(error: &SyncError, password: Option<String>) -> Parked {
279 Parked { refused: password.filter(|_| error.wants_password()), since: SystemTime::now() }
280 }
281
282 pub(crate) fn refused(&self) -> bool {
284 self.refused.is_some()
285 }
286
287 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
293struct 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 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
313pub 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
324pub 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
341pub 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
355async 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
369pub 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 pause = if wait(pause, control).await { FIRST_PAUSE } else { (pause * 2).min(LONGEST_PAUSE) };
411 }
412 });
413}
414
415async 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 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 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
469async 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
474async 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 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 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 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 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 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
533fn to_fetch(found: impl IntoIterator<Item = u32>, last_uid: u32, root: &Path, validity: u32) -> (Vec<u32>, u32) {
538 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
552struct Reach {
553 since: Option<i64>,
554 everything: bool,
555}
556
557impl Reach {
558 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#[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 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
613async 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
630fn 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
643pub(crate) struct Arrived {
645 uid: u32,
646 flags: String,
647 received: Option<i64>,
648 raw: Vec<u8>,
649}
650
651pub(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
663pub(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
678pub(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
696fn 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
704fn 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#[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 #[serde(default)]
724 since: Option<i64>,
725 #[serde(default)]
727 everything: bool,
728}
729
730#[derive(Debug, Clone, Copy, PartialEq, Eq)]
734pub enum Kept {
735 Everything,
736 Since(i64),
737 Unknown,
738}
739
740pub 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
750pub(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 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
781pub(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
800async 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 if control.wakes() { Poll::Ready(()) } else { Poll::Pending }
809 })
810 .await;
811}
812
813async 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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 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 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}