use std::sync::Arc; use log::{info, warn}; use tutabridge_core::{ backup, bridge as bridge_helpers, config, event_handler, imap, smtp, store::LocalStore, sync, tls, tuta, }; #[tokio::main] async fn main() -> anyhow::Result<()> { tokio_rustls::rustls::crypto::ring::default_provider() .install_default() .map_err(|_| anyhow::anyhow!("Failed to install TLS crypto provider"))?; env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); // Subcommand dispatch. With no subcommand we run the bridge (the default // and overwhelmingly common case); `backup ` does a one-shot export // and exits. let args: Vec = std::env::args().collect(); match args.get(1).map(String::as_str) { Some("backup") => { let Some(output) = args.get(2) else { eprintln!("usage: tutabridge backup "); std::process::exit(2); }; return run_backup(output).await; } Some("--help") | Some("-h") | Some("help") => { println!("tutabridge run the IMAP/SMTP bridge (default)"); println!("tutabridge backup export every mail to as .eml files"); return Ok(()); } _ => {} } let mut cfg = match config::load_config().map_err(|e| anyhow::anyhow!("{e}"))? { Some(cfg) if !cfg.email.is_empty() => cfg, _ => { use std::io::{BufRead, Write}; print!("Tuta email address: "); std::io::stdout().flush()?; let mut email = String::new(); std::io::stdin().lock().read_line(&mut email)?; let email = email.trim().to_string(); if email.is_empty() { anyhow::bail!("Email address is required"); } let cfg = config::Config { email, ..Default::default() }; config::save_config(&cfg).map_err(|e| anyhow::anyhow!("{e}"))?; cfg } }; let bridge_password = config::ensure_bridge_password(&mut cfg) .map_err(|e| anyhow::anyhow!("Bridge password setup failed: {e}"))?; info!("TutaBridge starting..."); let tls_acceptor = tls::load_or_create_tls_acceptor() .map_err(|e| anyhow::anyhow!("TLS setup failed: {e}"))?; info!("TLS initialized"); info!("IMAP will listen on 127.0.0.1:{}", cfg.imap_port); info!("SMTP will listen on 127.0.0.1:{}", cfg.smtp_port); let session = login_session(&cfg).await?; info!("Logged in as {}", cfg.email); let storage_key = session.derive_storage_key().await .map_err(|e| anyhow::anyhow!("{e}"))?; info!("Storage encryption key derived"); let local_store = LocalStore::open( &config::store_db_path(), &config::store_mails_dir(), storage_key, ).map_err(|e| anyhow::anyhow!("{e}"))?; if !local_store.verify_key() { info!("Storage key changed — resetting local cache"); let _ = local_store.reset(); } let local_store = Arc::new(local_store); info!("Local store opened"); // Build the realtime event bus and hydrate its catch-up cursor from // disk so reconnects resume from the last processed batch — the GUI's // `BridgeHandle` does the same dance; the CLI used to skip it entirely // and quietly degrade to "bootstrap-sync only at startup". let bus_access_token = session.access_token.clone(); let bus_user_id = session .user_id() .ok_or_else(|| anyhow::anyhow!("Missing user id from session"))?; let bus_client = Arc::new(tutasdk::event_bus::EventBusClient::new( cfg.api_url.clone(), bridge_helpers::sys_model_version(), bridge_helpers::tutanota_model_version(), tutasdk::CLIENT_VERSION.to_string(), bridge_helpers::CLIENT_NAME.to_string(), )); { // OutOfSync detection: if the oldest cursor is older than the // server's batch-replay window (~44 days), the server cannot // catch us up — wipe so the syncer falls back to a bootstrap. let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as i64) .unwrap_or(0); let expire_ms = tutasdk::event_bus::ENTITY_EVENT_BATCH_EXPIRE.as_millis() as i64; if let Ok(Some(min_ms)) = local_store.event_bus_state_min_updated_at_ms() { if now_ms - min_ms > expire_ms { info!("Cached event-bus state is older than 44 days — wiping and forcing a full re-sync"); if let Err(e) = local_store.clear_event_bus_state() { warn!("Could not clear event_bus_state: {e}"); } } } match local_store.load_event_bus_state() { Ok(s) if !s.is_empty() => { let ids_handle = bus_client.last_batch_ids(); let mut m = ids_handle.lock().unwrap(); let n = s.len(); m.extend(s); info!("Event bus catch-up state loaded ({n} group(s))"); } Ok(_) => info!("Event bus catch-up state is empty (first launch)"), Err(e) => warn!("Could not load event_bus_state: {e}"), } } let bus_ids_for_handler = bus_client.last_batch_ids(); let backend: Arc = Arc::new(session); let store = sync::MailStore::new(); let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); let imap_tls = tls_acceptor.clone(); let smtp_tls = tls_acceptor; let pw = cfg.bridge_password.clone(); // mpsc channel from event bus -> handler. let (event_tx, event_rx) = tokio::sync::mpsc::channel(64); let syncer_handle = tokio::spawn(sync::run_syncer( store.clone(), local_store.clone(), backend.clone(), cfg.sync_limit, shutdown_rx.clone(), )); let bus_handle = { let client = Arc::clone(&bus_client); let token = bus_access_token; let uid = bus_user_id; let shutdown = shutdown_rx.clone(); tokio::spawn(async move { if let Err(e) = client.run(token, uid, event_tx, shutdown).await { use tutasdk::event_bus::EventBusError; if !matches!(e, EventBusError::Stopped) { warn!("Event bus exited: {e}"); } } }) }; // Log every WsState transition at INFO so production logs reveal // reconnect storms without RUST_LOG=debug. { let mut ws_watch = bus_client.state(); let mut shutdown_watch = shutdown_rx.clone(); let mut last = *ws_watch.borrow(); tokio::spawn(async move { loop { tokio::select! { _ = ws_watch.changed() => { let now = *ws_watch.borrow(); if now != last { info!("ws state: {:?} → {:?}", last, now); last = now; } } _ = shutdown_watch.changed() => return, } } }); } let handler_handle = tokio::spawn(event_handler::run_event_handler( store.clone(), local_store.clone(), backend.clone(), cfg.sync_limit, bus_ids_for_handler, event_rx, shutdown_rx.clone(), )); let imap_handle = tokio::spawn(imap::serve( cfg.imap_port, store.clone(), backend.clone(), imap_tls, pw.clone(), )); let smtp_handle = tokio::spawn(smtp::serve(cfg.smtp_port, backend.clone(), smtp_tls, pw)); info!("Bridge is running. Configure Thunderbird with:"); info!(" IMAP server: 127.0.0.1:{} (SSL/TLS)", cfg.imap_port); info!(" SMTP server: 127.0.0.1:{} (SSL/TLS)", cfg.smtp_port); info!(" Username: {}", cfg.email); info!(" Password: {}", bridge_password); info!(" Accept the self-signed certificate when prompted"); tokio::select! { _ = tokio::signal::ctrl_c() => { info!("Shutting down..."); let _ = shutdown_tx.send(true); syncer_handle.abort(); bus_handle.abort(); handler_handle.abort(); Ok(()) } r = imap_handle => r.map_err(|e| anyhow::anyhow!("{e}"))?.map_err(|e| anyhow::anyhow!("{e}")), r = smtp_handle => r.map_err(|e| anyhow::anyhow!("{e}"))?.map_err(|e| anyhow::anyhow!("{e}")), } } /// Interactive TOTP prompt used during a fresh (non-keyring) login. fn make_totp_cb() -> tuta::TwoFactorCallback { tuta::TwoFactorCallback::Totp(Box::new(|| { use std::io::{BufRead, Write}; print!("TOTP code: "); std::io::stdout().flush()?; let mut code_str = String::new(); std::io::stdin().lock().read_line(&mut code_str)?; let code: u32 = code_str .trim() .parse() .map_err(|_| "Invalid TOTP code — must be a number")?; Ok(code) })) } /// Resume the saved keychain session if possible, otherwise prompt for the /// Tuta password (and TOTP). Shared by the bridge-run path and `backup`. async fn login_session(cfg: &config::Config) -> anyhow::Result { match tuta::login_with_2fa(cfg, None, Some(make_totp_cb())).await { Ok(s) => Ok(s), Err(_) => { let password = rpassword::prompt_password(format!("Password for {}: ", cfg.email))?; tuta::login_with_2fa(cfg, Some(&password), Some(make_totp_cb())) .await .map_err(|e| anyhow::anyhow!("{e}")) } } } /// `tutabridge backup ` — export every mail of every folder to a tree of /// `.eml` files under `output`. This is a one-shot operation: it logs in, /// opens the local cache read-only (no reset on key mismatch — a backup must /// never destroy the cache), runs the export with a live progress line, and /// exits. async fn run_backup(output: &str) -> anyhow::Result<()> { let cfg = config::load_config() .map_err(|e| anyhow::anyhow!("{e}"))? .filter(|c| !c.email.is_empty()) .ok_or_else(|| { anyhow::anyhow!("No account configured — run `tutabridge` once to sign in first") })?; let session = login_session(&cfg).await?; info!("Logged in as {}", cfg.email); let storage_key = session .derive_storage_key() .await .map_err(|e| anyhow::anyhow!("{e}"))?; // Open the cache without the reset-on-mismatch the bridge uses: a wrong // key just makes `read_eml` miss and fall through to a server fetch, // which is safe; wiping the cache during a backup would be destructive. let local_store = LocalStore::open( &config::store_db_path(), &config::store_mails_dir(), storage_key, ) .map_err(|e| anyhow::anyhow!("{e}"))?; let output_path = std::path::Path::new(output); info!("Backing up all mail to {} ...", output_path.display()); use std::io::Write; let mut last_folder = String::new(); let stats = backup::export_eml(&session, &local_store, output_path, |p| { if p.folder != last_folder { if !last_folder.is_empty() { eprintln!(); } last_folder = p.folder.clone(); } eprint!("\r {}: {}/{} ", p.folder, p.done, p.total); let _ = std::io::stderr().flush(); }) .await .map_err(|e| anyhow::anyhow!(e))?; if !last_folder.is_empty() { eprintln!(); } info!( "Backup complete: {} mails written ({} from cache, {} fetched), {} skipped (already on disk) across {} folder(s), {:.1} MB", stats.mails_written, stats.from_cache, stats.from_server, stats.skipped, stats.folders, stats.bytes as f64 / 1_000_000.0, ); if !stats.errors.is_empty() { warn!("{} mail(s) could not be exported:", stats.errors.len()); for e in stats.errors.iter().take(20) { warn!(" {e}"); } if stats.errors.len() > 20 { warn!(" … and {} more", stats.errors.len() - 20); } } Ok(()) }