//! Background scheduler for automatic email synchronization. //! //! This module provides a background task that periodically checks email accounts //! and syncs those that are due based on their individual sync intervals. use std::collections::HashMap; use std::sync::Arc; use tauri::Manager; use tokio::time::{interval, Duration}; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; use crate::commands::sync_email_account_inner; #[cfg(not(any(target_os = "ios", target_os = "android")))] use crate::notifications::send_notification; use crate::state::AppState; /// How often the scheduler checks for accounts needing sync (in seconds). const CHECK_INTERVAL_SECS: u64 = 60; /// Maximum backoff multiplier (caps at ~16 minutes between retries). const MAX_BACKOFF_MULTIPLIER: u32 = 16; /// Desktop user ID (matches DESKTOP_USER_ID in state.rs). const DESKTOP_USER_ID: goingson_core::UserId = goingson_core::UserId::from_uuid(uuid::Uuid::from_u128(1)); /// Starts the email sync scheduler background task. /// /// This function runs indefinitely, checking every minute for email accounts /// that need to be synced based on their configured `sync_interval_minutes`. /// /// The scheduler: /// - Queries for accounts where sync is enabled and enough time has passed since last sync /// - Syncs each account using the existing sync logic /// - Logs success/failure for monitoring /// - Continues running even if individual syncs fail pub async fn start_email_sync_scheduler(app: tauri::AppHandle, cancel: CancellationToken) { let mut check_interval = interval(Duration::from_secs(CHECK_INTERVAL_SECS)); // Track consecutive failures per account for exponential backoff let mut failure_counts: HashMap = HashMap::new(); info!("Email sync scheduler started (checking every {} seconds)", CHECK_INTERVAL_SECS); // Infinite tick loop: sleep for CHECK_INTERVAL_SECS, then check all accounts. // The first tick fires immediately (tokio::time::interval behavior). loop { tokio::select! { _ = cancel.cancelled() => { info!("Email sync scheduler shutting down"); break; } _ = check_interval.tick() => {} } // try_state returns None during startup before AppState is managed. // Continuing is safe — we'll pick it up on the next tick once the // app finishes initialization. let state: Arc = match app.try_state::>() { Some(s) => s.inner().clone(), None => { debug!("Email sync scheduler: app state not available yet"); continue; } }; if let Err(e) = check_and_sync_accounts(&app, &state, &mut failure_counts).await { error!("Email sync scheduler error: {}", e); } } } /// Checks for accounts needing sync and syncs them. /// Uses exponential backoff per account: after consecutive failures, an account /// is skipped for 2^n ticks (capped at MAX_BACKOFF_MULTIPLIER). async fn check_and_sync_accounts( app: &tauri::AppHandle, state: &Arc, failure_counts: &mut HashMap, ) -> Result<(), String> { let accounts = state .email_accounts .list_accounts_needing_sync(DESKTOP_USER_ID) .await .map_err(|e| format!("Failed to query accounts needing sync: {}", e))?; if accounts.is_empty() { debug!("Email sync scheduler: no accounts need sync"); return Ok(()); } debug!("Email sync scheduler: {} account(s) need sync", accounts.len()); // Sync each account independently — a failure in one account (e.g. expired // token, network issue) must not prevent other accounts from syncing. for account in accounts { let account_key = account.id.to_string(); // Exponential backoff: skip this tick if the account has been failing let consecutive_failures = failure_counts.get(&account_key).copied().unwrap_or(0); if consecutive_failures > 0 { let backoff = 2u32.pow(consecutive_failures.min(4)).min(MAX_BACKOFF_MULTIPLIER); // Use a simple modulo check: only attempt every `backoff` ticks // This is approximate but avoids needing per-account timestamps if rand_skip(backoff) { debug!( "Backing off sync for {} ({} consecutive failures, retrying every ~{} minutes)", account.account_name, consecutive_failures, backoff ); continue; } } info!( "Auto-syncing email account: {} ({})", account.account_name, account.email_address ); match sync_email_account_inner(state, account.id, Some(false)).await { Ok(result) => { // Success: reset failure count failure_counts.remove(&account_key); info!( "Auto-sync complete for {}: {} new emails (fetched {} from INBOX, {} from Archive)", account.account_name, result.emails_saved, result.inbox_fetched, result.archive_fetched ); // Send notification if enabled and new emails arrived (desktop only) #[cfg(not(any(target_os = "ios", target_os = "android")))] if account.notify_new_emails && result.emails_saved > 0 { let body = if result.emails_saved == 1 { format!("1 new email in {}", account.account_name) } else { format!("{} new emails in {}", result.emails_saved, account.account_name) }; send_notification(app, "New Mail", &body); } } Err(e) => { let count = failure_counts.entry(account_key).or_insert(0); *count = (*count + 1).min(10); // Cap tracking at 10 to avoid overflow warn!( "Auto-sync failed for {} ({}) [{} consecutive failures]: {}", account.account_name, account.email_address, count, e ); } } } Ok(()) } /// Probabilistic skip for backoff: returns true (skip) with probability (backoff-1)/backoff. /// For backoff=2, skips ~50% of ticks. For backoff=16, skips ~94% of ticks. fn rand_skip(backoff: u32) -> bool { use std::time::SystemTime; // Use low bits of system time as a cheap pseudo-random source let nanos = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_default() .subsec_nanos(); (nanos % backoff) != 0 }