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}
99
100impl Control {
101 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 pub fn set_pace(&self, every: Duration) {
115 self.pace.store(every.as_secs(), Ordering::Relaxed);
116 }
117
118 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 fn slow(&self) -> bool {
131 !self.realtime() && self.round() > SLOW
132 }
133
134 fn brings(&self, due: bool) -> (bool, bool) {
138 let full = !self.quiet();
139 (full, full && due)
140 }
141
142 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 pub fn stop(&self) {
157 self.stop.store(true, Ordering::Relaxed);
158 self.ring();
159 }
160
161 pub fn nudge(&self) {
163 self.nudge.store(true, Ordering::Relaxed);
164 self.ring();
165 }
166
167 pub fn retry(&self) {
170 self.retry.store(true, Ordering::Relaxed);
171 self.nudge();
172 }
173
174 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 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 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 pub fn pause(&self, length: Duration) {
204 self.pause_by(length, SystemTime::now, LOOK);
205 }
206
207 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 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 fn wakes(&self) -> bool {
235 self.stopped() || self.nudge.swap(false, Ordering::Relaxed)
236 }
237}
238
239const REFUSED_AGAIN: Duration = Duration::from_secs(60 * 60);
242
243pub(crate) struct Parked {
248 refused: Option<String>,
249 since: SystemTime,
250}
251
252impl Parked {
253 pub(crate) fn after(error: &SyncError, password: Option<String>) -> Parked {
255 Parked { refused: password.filter(|_| error.wants_password()), since: SystemTime::now() }
256 }
257
258 pub(crate) fn refused(&self) -> bool {
260 self.refused.is_some()
261 }
262
263 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
269struct 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 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
289pub 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
300pub 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
317pub 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
331async 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
345pub 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 pause = if wait(pause, control).await { FIRST_PAUSE } else { (pause * 2).min(LONGEST_PAUSE) };
387 }
388 });
389}
390
391async 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 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 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
445async 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
450async 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 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 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 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 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 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
509fn to_fetch(found: impl IntoIterator<Item = u32>, last_uid: u32, root: &Path, validity: u32) -> (Vec<u32>, u32) {
514 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
528struct Reach {
529 since: Option<i64>,
530 everything: bool,
531}
532
533impl Reach {
534 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#[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 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
589async 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
606fn 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
619pub(crate) struct Arrived {
621 uid: u32,
622 flags: String,
623 received: Option<i64>,
624 raw: Vec<u8>,
625}
626
627pub(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
639pub(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
654pub(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
672fn 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
680fn 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#[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 #[serde(default)]
700 since: Option<i64>,
701 #[serde(default)]
703 everything: bool,
704}
705
706#[derive(Debug, Clone, Copy, PartialEq, Eq)]
710pub enum Kept {
711 Everything,
712 Since(i64),
713 Unknown,
714}
715
716pub 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
726pub(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 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
757pub(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
776async 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 if control.wakes() { Poll::Ready(()) } else { Poll::Pending }
785 })
786 .await;
787}
788
789async 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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 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 #[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 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 #[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 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 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}