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:
Anthony M
2026-05-29 14:43:47 +02:00
committed by GitHub
parent 8c1c1dfc54
commit d27c278cab
18 changed files with 1196 additions and 41 deletions
+125 -37
View File
@@ -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(())
}