mirror of
https://github.com/spartanz51/tutabridge.git
synced 2026-06-24 10:54:32 +02:00
Complete mailbox backup to .eml files (#6)
* Add complete mailbox backup to .eml files (CLI) `tutabridge backup <dir>` exports every mail of every folder to a plaintext `.eml` tree, mirroring the IMAP folder hierarchy. A backup must be *complete*: it enumerates all mails per folder from the server (`limit == 0`), not just the `sync_limit`-capped subset the bridge keeps cached. Dumping only the synced subset would silently drop mail — live-tested here against an INBOX with 6288 server-side mails vs 1050 cached, all 6288 exported. The encrypted local cache (`.eml.enc`) is used as a fast path; only never-synced mails trigger a rate-limited (150ms) server fetch. Format: one `.eml` per mail in `<output>/<folder path>/<YYYYMMDD-HHMMSS>_<id>.eml`. EML is the most portable target — native to Thunderbird/Apple Mail/ Outlook, no Maildir `:2,S` colons that break on Windows, and a single corrupt file never takes down the whole archive. Folder path segments are sanitised for cross-platform filesystems (Windows-illegal chars + trailing dot/space stripped); the date prefix makes a directory listing sort chronologically. `backup::export_eml` is surface-agnostic (takes a progress callback) so a GUI button can wrap the same engine later. Per-mail failures are collected in `BackupStats::errors` rather than aborting the run. The CLI shares the keychain/password login flow with the bridge via the new `login_session` helper, and opens the cache without the bridge's reset-on-key-mismatch (a backup must never destroy the cache). 8 backup unit/integration tests: filename + folder sanitisation, date stamp, and an end-to-end export over a mock backend asserting the cache-vs-server split, file tree layout, and verbatim cached bodies. GUI button is a follow-up (needs the Tauri dialog plugin). * Make backup resumable / incremental Skip a mail when its `.eml` is already on disk, before any cache read or server fetch. The filename is deterministic (stable receivedDate + element id) and mail content is immutable, so an existing file is never stale. This turns an interrupted backup into a resume (re-run continues where it stopped) and a periodic re-backup into an incremental one (only new mail is fetched — the expensive part). New `BackupStats::skipped` counter, surfaced in the CLI summary. Two tests: a re-run skips every already-exported mail (zero server loads), and an incremental run fetches only the newly-arrived mail. * Add Backup tab to the GUI A "Backup" tab wraps the same `backup::export_eml` engine as the CLI: a native folder picker (tauri-plugin-dialog), a live per-folder progress bar driven by `bridge://backup-progress` events, and a result summary (mails written, folders, MB, cache vs server vs skipped). `BridgeHandle` now keeps the logged-in backend + local cache after `start` and exposes them via `backend_and_store()`, so the `export_mails` command reuses the live session instead of opening a second one — and drops the handle lock before the (minutes-long) export so status/stats stay responsive. The button is disabled unless the bridge is running. `BackupStats` is now `Serialize` so it can cross the Tauri boundary. * Keep backup state across tab switches The Backup tab is conditionally rendered, so switching away unmounted `BackupPanel` mid-export — dropping its progress + result state and the `bridge://backup-progress` listener while the Rust task kept running. Coming back showed an idle panel even though the backup was still going. Lift all backup state (busy / progress / result / error), the `startBackup` action, and the progress listener into the always-mounted `useBridge` hook. The listener is now active regardless of which tab is shown, and `BackupPanel` is purely presentational — switch tabs freely mid-backup and the progress is intact on return. `startBackup` guards against a double launch while one is in flight.
This commit is contained in:
+125
-37
@@ -1,8 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
use log::{info, warn};
|
||||
use tutabridge_core::{
|
||||
bridge as bridge_helpers, config, event_handler, imap, smtp, store::LocalStore, sync, tls,
|
||||
tuta,
|
||||
backup, bridge as bridge_helpers, config, event_handler, imap, smtp, store::LocalStore, sync,
|
||||
tls, tuta,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
@@ -13,6 +13,26 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
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 <dir>` does a one-shot export
|
||||
// and exits.
|
||||
let args: Vec<String> = 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 <output-directory>");
|
||||
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 <dir> export every mail to <dir> 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,
|
||||
_ => {
|
||||
@@ -45,41 +65,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
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 totp_cb = 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)
|
||||
}));
|
||||
|
||||
// Try keyring session first, only prompt for password if needed
|
||||
let session = match tuta::login_with_2fa(&cfg, None, Some(totp_cb)).await {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
let password = rpassword::prompt_password(format!("Password for {}: ", cfg.email))?;
|
||||
let totp_cb2 = 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)
|
||||
}));
|
||||
tuta::login_with_2fa(&cfg, Some(&password), Some(totp_cb2))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?
|
||||
}
|
||||
};
|
||||
let session = login_session(&cfg).await?;
|
||||
info!("Logged in as {}", cfg.email);
|
||||
|
||||
let storage_key = session.derive_storage_key().await
|
||||
@@ -233,3 +219,105 @@ async fn main() -> anyhow::Result<()> {
|
||||
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<tuta::TutaSession> {
|
||||
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 <dir>` — 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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user