Skip to main content

max / goingson

6.7 KB · 168 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;
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 /// Desktop user ID (matches DESKTOP_USER_ID in state.rs).
25 const DESKTOP_USER_ID: goingson_core::UserId = goingson_core::UserId::from_uuid(uuid::Uuid::from_u128(1));
26
27 /// Starts the email sync scheduler background task.
28 ///
29 /// This function runs indefinitely, checking every minute for email accounts
30 /// that need to be synced based on their configured `sync_interval_minutes`.
31 ///
32 /// The scheduler:
33 /// - Queries for accounts where sync is enabled and enough time has passed since last sync
34 /// - Syncs each account using the existing sync logic
35 /// - Logs success/failure for monitoring
36 /// - Continues running even if individual syncs fail
37 pub async fn start_email_sync_scheduler(app: tauri::AppHandle, cancel: CancellationToken) {
38 let mut check_interval = interval(Duration::from_secs(CHECK_INTERVAL_SECS));
39 // Track consecutive failures per account for exponential backoff
40 let mut failure_counts: HashMap<String, u32> = HashMap::new();
41
42 info!("Email sync scheduler started (checking every {} seconds)", CHECK_INTERVAL_SECS);
43
44 // Infinite tick loop: sleep for CHECK_INTERVAL_SECS, then check all accounts.
45 // The first tick fires immediately (tokio::time::interval behavior).
46 loop {
47 tokio::select! {
48 _ = cancel.cancelled() => {
49 info!("Email sync scheduler shutting down");
50 break;
51 }
52 _ = check_interval.tick() => {}
53 }
54
55 // try_state returns None during startup before AppState is managed.
56 // Continuing is safe — we'll pick it up on the next tick once the
57 // app finishes initialization.
58 let state: Arc<AppState> = match app.try_state::<Arc<AppState>>() {
59 Some(s) => s.inner().clone(),
60 None => {
61 debug!("Email sync scheduler: app state not available yet");
62 continue;
63 }
64 };
65
66 if let Err(e) = check_and_sync_accounts(&app, &state, &mut failure_counts).await {
67 error!("Email sync scheduler error: {}", e);
68 }
69 }
70 }
71
72 /// Checks for accounts needing sync and syncs them.
73 /// Uses exponential backoff per account: after consecutive failures, an account
74 /// is skipped for 2^n ticks (capped at MAX_BACKOFF_MULTIPLIER).
75 async fn check_and_sync_accounts(
76 app: &tauri::AppHandle,
77 state: &Arc<AppState>,
78 failure_counts: &mut HashMap<String, u32>,
79 ) -> Result<(), String> {
80 let accounts = state
81 .email_accounts
82 .list_accounts_needing_sync(DESKTOP_USER_ID)
83 .await
84 .map_err(|e| format!("Failed to query accounts needing sync: {}", e))?;
85
86 if accounts.is_empty() {
87 debug!("Email sync scheduler: no accounts need sync");
88 return Ok(());
89 }
90
91 debug!("Email sync scheduler: {} account(s) need sync", accounts.len());
92
93 // Sync each account independently — a failure in one account (e.g. expired
94 // token, network issue) must not prevent other accounts from syncing.
95 for account in accounts {
96 let account_key = account.id.to_string();
97
98 // Exponential backoff: skip this tick if the account has been failing
99 let consecutive_failures = failure_counts.get(&account_key).copied().unwrap_or(0);
100 if consecutive_failures > 0 {
101 let backoff = 2u32.pow(consecutive_failures.min(4)).min(MAX_BACKOFF_MULTIPLIER);
102 // Use a simple modulo check: only attempt every `backoff` ticks
103 // This is approximate but avoids needing per-account timestamps
104 if rand_skip(backoff) {
105 debug!(
106 "Backing off sync for {} ({} consecutive failures, retrying every ~{} minutes)",
107 account.account_name, consecutive_failures, backoff
108 );
109 continue;
110 }
111 }
112
113 info!(
114 "Auto-syncing email account: {} ({})",
115 account.account_name, account.email_address
116 );
117
118 match sync_email_account_inner(state, account.id, Some(false)).await {
119 Ok(result) => {
120 // Success: reset failure count
121 failure_counts.remove(&account_key);
122
123 info!(
124 "Auto-sync complete for {}: {} new emails (fetched {} from INBOX, {} from Archive)",
125 account.account_name,
126 result.emails_saved,
127 result.inbox_fetched,
128 result.archive_fetched
129 );
130
131 // Send notification if enabled and new emails arrived (desktop only)
132 #[cfg(not(any(target_os = "ios", target_os = "android")))]
133 if account.notify_new_emails && result.emails_saved > 0 {
134 let body = if result.emails_saved == 1 {
135 format!("1 new email in {}", account.account_name)
136 } else {
137 format!("{} new emails in {}", result.emails_saved, account.account_name)
138 };
139 send_notification(app, "New Mail", &body);
140 }
141 }
142 Err(e) => {
143 let count = failure_counts.entry(account_key).or_insert(0);
144 *count = (*count + 1).min(10); // Cap tracking at 10 to avoid overflow
145
146 warn!(
147 "Auto-sync failed for {} ({}) [{} consecutive failures]: {}",
148 account.account_name, account.email_address, count, e
149 );
150 }
151 }
152 }
153
154 Ok(())
155 }
156
157 /// Probabilistic skip for backoff: returns true (skip) with probability (backoff-1)/backoff.
158 /// For backoff=2, skips ~50% of ticks. For backoff=16, skips ~94% of ticks.
159 fn rand_skip(backoff: u32) -> bool {
160 use std::time::SystemTime;
161 // Use low bits of system time as a cheap pseudo-random source
162 let nanos = SystemTime::now()
163 .duration_since(SystemTime::UNIX_EPOCH)
164 .unwrap_or_default()
165 .subsec_nanos();
166 (nanos % backoff) != 0
167 }
168