Skip to main content

max / goingson

7.0 KB · 170 lines History Blame Raw
1 //! Background scheduler for automatic email synchronization.
2 //!
3 //! This module provides a background task that periodically checks email accounts
4 //! and syncs those that are due based on their individual sync intervals.
5
6 use std::collections::HashMap;
7 use std::sync::Arc;
8 use tauri::Manager;
9 use tokio::time::{interval, Duration};
10 use tokio_util::sync::CancellationToken;
11 use tracing::{debug, error, info, warn};
12
13 use crate::commands::sync_email_account_inner;
14 #[cfg(not(any(target_os = "ios", target_os = "android")))]
15 use crate::notifications::send_notification;
16 use crate::state::{AppState, DESKTOP_USER_ID};
17
18 /// How often the scheduler checks for accounts needing sync (in seconds).
19 const CHECK_INTERVAL_SECS: u64 = 60;
20
21 /// Maximum backoff multiplier (caps at ~16 minutes between retries).
22 const MAX_BACKOFF_MULTIPLIER: u32 = 16;
23
24 /// Starts the email sync scheduler background task.
25 ///
26 /// This function runs indefinitely, checking every minute for email accounts
27 /// that need to be synced based on their configured `sync_interval_minutes`.
28 ///
29 /// The scheduler:
30 /// - Queries for accounts where sync is enabled and enough time has passed since last sync
31 /// - Syncs each account using the existing sync logic
32 /// - Logs success/failure for monitoring
33 /// - Continues running even if individual syncs fail
34 pub async fn start_email_sync_scheduler(app: tauri::AppHandle, cancel: CancellationToken) {
35 let mut check_interval = interval(Duration::from_secs(CHECK_INTERVAL_SECS));
36 // Track consecutive failures per account for exponential backoff
37 let mut failure_counts: HashMap<String, u32> = HashMap::new();
38
39 info!("Email sync scheduler started (checking every {} seconds)", CHECK_INTERVAL_SECS);
40
41 // Infinite tick loop: sleep for CHECK_INTERVAL_SECS, then check all accounts.
42 // The first tick fires immediately (tokio::time::interval behavior).
43 loop {
44 tokio::select! {
45 _ = cancel.cancelled() => {
46 info!("Email sync scheduler shutting down");
47 break;
48 }
49 _ = check_interval.tick() => {}
50 }
51
52 // try_state returns None during startup before AppState is managed.
53 // Continuing is safe — we'll pick it up on the next tick once the
54 // app finishes initialization.
55 let state: Arc<AppState> = match app.try_state::<Arc<AppState>>() {
56 Some(s) => s.inner().clone(),
57 None => {
58 debug!("Email sync scheduler: app state not available yet");
59 continue;
60 }
61 };
62
63 if let Err(e) = check_and_sync_accounts(&app, &state, &mut failure_counts).await {
64 error!("Email sync scheduler error: {}", e);
65 }
66 }
67 }
68
69 /// Checks for accounts needing sync and syncs them.
70 /// Uses exponential backoff per account: after consecutive failures, an account
71 /// is skipped for 2^n ticks (capped at MAX_BACKOFF_MULTIPLIER).
72 async fn check_and_sync_accounts(
73 app: &tauri::AppHandle,
74 state: &Arc<AppState>,
75 failure_counts: &mut HashMap<String, u32>,
76 ) -> Result<(), String> {
77 let accounts = state
78 .email_accounts
79 .list_accounts_needing_sync(DESKTOP_USER_ID)
80 .await
81 .map_err(|e| format!("Failed to query accounts needing sync: {}", e))?;
82
83 if accounts.is_empty() {
84 debug!("Email sync scheduler: no accounts need sync");
85 return Ok(());
86 }
87
88 debug!("Email sync scheduler: {} account(s) need sync", accounts.len());
89
90 // A failing account never updates last_sync_at, so it stays "due" and remains
91 // in this list every tick; any failure_counts key absent here belongs to an
92 // account that was deleted or had sync disabled. Prune them after the pass so
93 // the map can't grow without bound over a long-running session.
94 let due_account_ids: std::collections::HashSet<String> =
95 accounts.iter().map(|a| a.id.to_string()).collect();
96
97 // Sync each account independently — a failure in one account (e.g. expired
98 // token, network issue) must not prevent other accounts from syncing.
99 for account in accounts {
100 let account_key = account.id.to_string();
101
102 // Exponential backoff: skip this tick if the account has been failing
103 let consecutive_failures = failure_counts.get(&account_key).copied().unwrap_or(0);
104 if consecutive_failures > 0 {
105 let backoff = 2u32.pow(consecutive_failures.min(4)).min(MAX_BACKOFF_MULTIPLIER);
106 // Use a simple modulo check: only attempt every `backoff` ticks
107 // This is approximate but avoids needing per-account timestamps
108 if rand_skip(backoff) {
109 debug!(
110 "Backing off sync for {} ({} consecutive failures, retrying every ~{} minutes)",
111 account.account_name, consecutive_failures, backoff
112 );
113 continue;
114 }
115 }
116
117 info!(
118 "Auto-syncing email account: {} ({})",
119 account.account_name, account.email_address
120 );
121
122 match sync_email_account_inner(state, account.id, Some(false)).await {
123 Ok(result) => {
124 // Success: reset failure count
125 failure_counts.remove(&account_key);
126
127 info!(
128 "Auto-sync complete for {}: {} new emails (fetched {} from INBOX, {} from Archive)",
129 account.account_name,
130 result.emails_saved,
131 result.inbox_fetched,
132 result.archive_fetched
133 );
134
135 // Send notification if enabled and new emails arrived (desktop only)
136 #[cfg(not(any(target_os = "ios", target_os = "android")))]
137 if account.notify_new_emails && result.emails_saved > 0 {
138 let body = if result.emails_saved == 1 {
139 format!("1 new email in {}", account.account_name)
140 } else {
141 format!("{} new emails in {}", result.emails_saved, account.account_name)
142 };
143 send_notification(app, "New Mail", &body);
144 }
145 }
146 Err(e) => {
147 let count = failure_counts.entry(account_key).or_insert(0);
148 *count = (*count + 1).min(10); // Cap tracking at 10 to avoid overflow
149
150 warn!(
151 "Auto-sync failed for {} ({}) [{} consecutive failures]: {}",
152 account.account_name, account.email_address, count, e
153 );
154 }
155 }
156 }
157
158 failure_counts.retain(|account_id, _| due_account_ids.contains(account_id));
159
160 Ok(())
161 }
162
163 /// Probabilistic skip for backoff: returns true (skip) with probability (backoff-1)/backoff.
164 /// For backoff=2, skips ~50% of ticks. For backoff=16, skips ~94% of ticks; backoff=1
165 /// never skips. Uses a real RNG — timer ticks are ~60s apart, so the old
166 /// clock-nanosecond source was effectively arbitrary rather than uniform.
167 fn rand_skip(backoff: u32) -> bool {
168 !rand::random::<u32>().is_multiple_of(backoff)
169 }
170