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
Generated
+68
View File
@@ -2863,6 +2863,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.1",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -3649,6 +3650,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log 0.4.29",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -4711,6 +4736,48 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log 0.4.29",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log 0.4.29",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-shell"
version = "2.3.5"
@@ -5399,6 +5466,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-shell",
"tokio",
"tokio-rustls",
+573
View File
@@ -0,0 +1,573 @@
//! Complete mailbox backup to a tree of plaintext `.eml` files.
//!
//! A backup must be *complete* — it exports every mail that exists on the
//! server, **not** just the `sync_limit`-capped subset the bridge keeps hot
//! in its local cache. Backing up only the synced subset would silently drop
//! mail and give a false sense of safety.
//!
//! Strategy: for each folder, enumerate **all** its mails from the server
//! (`limit == 0`), then for each mail use the encrypted local cache as a
//! fast path (decrypt the existing `.eml.enc`) and only fall back to a
//! rate-limited server fetch for mails that were never synced.
//!
//! Output format is plain RFC 2822 `.eml`, one file per mail, in a directory
//! tree mirroring the IMAP folder hierarchy:
//!
//! ```text
//! <output>/
//! ├── INBOX/
//! │ ├── 20260528-144935_OtjDuDU--3-9.eml
//! │ └── …
//! ├── Sent/
//! └── Café/Projets/…
//! ```
//!
//! `.eml` is the most portable choice: every mail client (Thunderbird, Apple
//! Mail, Outlook) opens it natively, it survives Windows filesystems (no
//! Maildir `:2,S` colons), and a single corrupt file never takes down the
//! whole archive.
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::mail::mail_to_rfc2822;
use crate::store::LocalStore;
use crate::tuta::{MailBackend, IMAP_DELIMITER};
use tutasdk::entities::generated::tutanota::TutanotaFile;
/// Politeness delay between two *server* fetches (mails not already cached).
/// Mirrors the syncer's prefetch throttle so a full backup of a large
/// mailbox doesn't trip Tuta's rate limiter. Cache hits are not delayed.
const INTER_FETCH_DELAY: Duration = Duration::from_millis(150);
/// Progress emitted once per mail so a CLI or GUI can render a bar.
#[derive(Debug, Clone)]
pub struct BackupProgress {
/// IMAP path of the folder currently being exported (e.g. `INBOX`).
pub folder: String,
/// Mails completed in this folder so far (1-based, == total when done).
pub done: usize,
/// Total mails in this folder.
pub total: usize,
}
/// Outcome of a backup run. Per-mail failures are collected in `errors`
/// rather than aborting the whole export — a backup should salvage as much
/// as it can.
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct BackupStats {
pub folders: usize,
pub mails_written: usize,
/// Mails served from the encrypted local cache (no network).
pub from_cache: usize,
/// Mails fetched + decrypted from the server during the backup.
pub from_server: usize,
/// Mails whose `.eml` was already on disk and so were skipped — this is
/// what makes a re-run resume an interrupted backup and turns a periodic
/// re-backup into an incremental one (only new mail is fetched).
pub skipped: usize,
pub bytes: u64,
/// Non-fatal per-mail failures (`"<element_id>: <reason>"`).
pub errors: Vec<String>,
}
/// Export every mail of every folder to `output` as `.eml` files.
///
/// `progress` is invoked once per mail. Fatal errors (cannot list folders,
/// cannot create the output directory) return `Err`; per-mail problems are
/// recorded in [`BackupStats::errors`] and do not stop the run.
pub async fn export_eml(
backend: &dyn MailBackend,
local_store: &LocalStore,
output: &Path,
mut progress: impl FnMut(&BackupProgress),
) -> Result<BackupStats, String> {
std::fs::create_dir_all(output).map_err(|e| format!("Cannot create {}: {e}", output.display()))?;
let folders = backend.list_folders().await?;
let mut stats = BackupStats::default();
for folder in &folders {
let folder_dir = folder_output_dir(output, &folder.imap_path);
std::fs::create_dir_all(&folder_dir)
.map_err(|e| format!("Cannot create {}: {e}", folder_dir.display()))?;
stats.folders += 1;
// `limit == 0` => every mail on the server, paginated by the SDK.
let mails = backend.load_mail_ids_for_folder(folder, 0).await?;
let total = mails.len();
for (i, mail) in mails.iter().enumerate() {
let Some(id) = mail._id.as_ref() else {
continue;
};
let eid = id.element_id.to_string();
// The filename is deterministic (stable receivedDate + element
// id), so if it's already on disk this mail was exported by an
// earlier run. Skip it *before* any cache read or server fetch —
// that's what makes a re-run resume an interrupted backup and an
// incremental re-backup cheap. (Mail content is immutable, so an
// existing file is never stale.)
let fname = format!(
"{}_{}.eml",
date_stamp(mail.receivedDate.as_millis()),
sanitize_segment(&eid)
);
let path = folder_dir.join(&fname);
if path.exists() {
stats.skipped += 1;
progress(&BackupProgress {
folder: folder.imap_path.clone(),
done: i + 1,
total,
});
continue;
}
// Fast path: decrypt the cached `.eml.enc` if we have it.
let (eml, from_cache) = match local_store.read_eml(&eid) {
Ok(Some(cached)) => (cached, true),
_ => {
// Slow path: pull body + attachments from the server.
let details = backend.load_mail_details(mail).await.ok().flatten();
let attachments_owned = match backend.load_attachments(mail).await {
Ok(a) => a,
Err(e) => {
stats.errors.push(format!("{eid}: attachments: {e}"));
Vec::new()
}
};
let refs: Vec<(&TutanotaFile, &[u8])> = attachments_owned
.iter()
.map(|(f, d)| (f, d.as_slice()))
.collect();
let eml = mail_to_rfc2822(mail, details.as_ref(), &refs);
tokio::time::sleep(INTER_FETCH_DELAY).await;
(eml, false)
}
};
match std::fs::write(&path, eml.as_bytes()) {
Ok(()) => {
stats.mails_written += 1;
stats.bytes += eml.len() as u64;
if from_cache {
stats.from_cache += 1;
} else {
stats.from_server += 1;
}
}
Err(e) => stats.errors.push(format!("{eid}: write: {e}")),
}
progress(&BackupProgress {
folder: folder.imap_path.clone(),
done: i + 1,
total,
});
}
}
Ok(stats)
}
/// Map an IMAP folder path (`Café/Projets`) to a nested output directory,
/// sanitising each segment for the filesystem.
fn folder_output_dir(base: &Path, imap_path: &str) -> PathBuf {
let mut p = base.to_path_buf();
for segment in imap_path.split(IMAP_DELIMITER) {
if segment.is_empty() {
continue;
}
p.push(sanitize_segment(segment));
}
p
}
/// Replace characters that are illegal (or merely troublesome) in path
/// components across Windows / macOS / Linux. Windows is the strict one:
/// `< > : " / \ | ? *` and control chars are forbidden, and a trailing dot
/// or space is silently stripped by the OS.
fn sanitize_segment(s: &str) -> String {
let cleaned: String = s
.chars()
.map(|c| match c {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
c if (c as u32) < 0x20 => '_',
c => c,
})
.collect();
let trimmed = cleaned.trim_matches(|c| c == ' ' || c == '.');
if trimmed.is_empty() {
"_".to_string()
} else {
trimmed.to_string()
}
}
/// Compact, sortable `YYYYMMDD-HHMMSS` stamp from an epoch-millis value, used
/// as a filename prefix so a folder listing sorts chronologically.
fn date_stamp(millis: u64) -> String {
let secs = millis / 1000;
let days = secs / 86400;
let tod = secs % 86400;
let (y, m, d) = crate::mail::rfc2822::days_to_ymd(days);
let h = tod / 3600;
let mi = (tod % 3600) / 60;
let s = tod % 60;
format!("{:04}{:02}{:02}-{:02}{:02}{:02}", y, m, d, h, mi, s)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_replaces_windows_illegal_chars() {
assert_eq!(sanitize_segment("a:b"), "a_b");
assert_eq!(sanitize_segment("a/b\\c"), "a_b_c");
assert_eq!(sanitize_segment("re: <test>?"), "re_ _test__");
assert_eq!(sanitize_segment("a|b*c\"d"), "a_b_c_d");
}
#[test]
fn sanitize_strips_trailing_dot_and_space() {
assert_eq!(sanitize_segment("name. "), "name");
assert_eq!(sanitize_segment(" spaced "), "spaced");
}
#[test]
fn sanitize_keeps_unicode_and_safe_chars() {
assert_eq!(sanitize_segment("Café"), "Café");
assert_eq!(sanitize_segment("Achat Immo"), "Achat Immo");
assert_eq!(sanitize_segment("OtjDuDU--3-9"), "OtjDuDU--3-9");
}
#[test]
fn sanitize_empty_becomes_underscore() {
assert_eq!(sanitize_segment(""), "_");
assert_eq!(sanitize_segment("..."), "_");
}
#[test]
fn folder_dir_nests_on_imap_delimiter() {
let base = Path::new("/tmp/backup");
let p = folder_output_dir(base, "Café/Projets");
assert_eq!(p, Path::new("/tmp/backup/Café/Projets"));
}
#[test]
fn folder_dir_sanitizes_each_segment() {
let base = Path::new("/tmp/backup");
// A custom folder literally named with a colon would break Windows.
let p = folder_output_dir(base, "Work/A:B");
assert_eq!(p, Path::new("/tmp/backup/Work/A_B"));
}
#[test]
fn date_stamp_is_sortable_and_correct() {
// 2024-12-25 12:37:25 UTC = 1735130245000 ms
assert_eq!(date_stamp(1735130245000), "20241225-123725");
// epoch
assert_eq!(date_stamp(0), "19700101-000000");
}
// --- integration: export_eml over a mock backend + temp store ---
use crate::mail::ParsedMessage;
use crate::tuta::{FolderInfo, MailBackend};
use base64::Engine as _;
use crypto_primitives::aes::Aes256Key;
use crypto_primitives::key::GenericAesKey;
use crypto_primitives::randomizer_facade::RandomizerFacade;
use std::collections::HashMap;
use tutasdk::date::DateTime;
use tutasdk::entities::generated::tutanota::{
Body, Mail, MailAddress, MailDetails, MailSetEntry, Recipients,
};
use tutasdk::folder_system::MailSetKind;
use tutasdk::{GeneratedId, IdTupleGenerated};
fn gid(s: &str) -> GeneratedId {
GeneratedId(s.to_string())
}
fn make_mail(element_id: &str, subject: &str) -> Mail {
Mail {
_id: Some(IdTupleGenerated::new(gid("list1"), gid(element_id))),
_permissions: gid("perm"),
_format: 0,
_ownerEncSessionKey: None,
subject: subject.to_string(),
receivedDate: DateTime::from_millis(1735130245000),
state: 2,
unread: false,
confidential: false,
replyType: 0,
_ownerGroup: None,
differentEnvelopeSender: None,
listUnsubscribe: false,
movedTime: None,
phishingStatus: 0,
authStatus: None,
method: 0,
recipientCount: 1,
encryptionAuthStatus: None,
_ownerKeyVersion: None,
processingState: 0,
processNeeded: false,
sendAt: None,
serverClassificationData: None,
_kdfNonce: None,
sender: MailAddress {
_id: None,
name: "Alice".to_string(),
address: "alice@tuta.com".to_string(),
contact: None,
_errors: Default::default(),
},
attachments: vec![],
conversationEntry: IdTupleGenerated::new(gid("cl"), gid("ce")),
firstRecipient: Some(MailAddress {
_id: None,
name: "Bob".to_string(),
address: "bob@example.com".to_string(),
contact: None,
_errors: Default::default(),
}),
mailDetails: None,
mailDetailsDraft: None,
bucketKey: None,
sets: vec![],
clientSpamClassifierResult: None,
_errors: Default::default(),
}
}
fn make_details(body: &str) -> MailDetails {
MailDetails {
_id: None,
sentDate: DateTime::from_millis(0),
authStatus: 0,
replyTos: vec![],
recipients: Recipients {
_id: None,
toRecipients: vec![],
ccRecipients: vec![],
bccRecipients: vec![],
},
headers: None,
body: Body {
_id: None,
text: Some(body.to_string()),
compressedText: None,
_errors: Default::default(),
},
}
}
fn folder(id: &str, entries: &str, path: &str) -> FolderInfo {
FolderInfo {
id: id.to_string(),
list_id: "folders".to_string(),
entries_list_id: entries.to_string(),
kind: MailSetKind::Inbox,
imap_path: path.to_string(),
special_use: None,
}
}
struct MockBackend {
folders: Vec<FolderInfo>,
mails: HashMap<String, Vec<Mail>>,
server_loads: std::sync::Mutex<usize>,
}
#[async_trait::async_trait]
impl MailBackend for MockBackend {
async fn list_folders(&self) -> Result<Vec<FolderInfo>, String> {
Ok(self.folders.clone())
}
async fn load_mail_ids_for_folder(
&self,
folder: &FolderInfo,
_limit: usize,
) -> Result<Vec<Mail>, String> {
Ok(self.mails.get(&folder.entries_list_id).cloned().unwrap_or_default())
}
async fn load_mail_details(&self, _mail: &Mail) -> Result<Option<MailDetails>, String> {
*self.server_loads.lock().unwrap() += 1;
Ok(Some(make_details("<p>fetched from server</p>")))
}
async fn load_attachments(
&self,
_mail: &Mail,
) -> Result<Vec<(TutanotaFile, Vec<u8>)>, String> {
Ok(vec![])
}
async fn load_mail(&self, _l: &str, _e: &str) -> Result<Option<Mail>, String> {
unimplemented!()
}
async fn decrypt_inline_mail(&self, _j: &str) -> Result<Option<Mail>, String> {
unimplemented!()
}
async fn decrypt_inline_mail_set_entry(
&self,
_j: &str,
) -> Result<Option<MailSetEntry>, String> {
unimplemented!()
}
async fn decrypt_inline_mail_details_blob(
&self,
_j: &str,
) -> Result<Option<MailDetails>, String> {
unimplemented!()
}
async fn set_unread_status(
&self,
_ids: Vec<IdTupleGenerated>,
_u: bool,
) -> Result<(), String> {
unimplemented!()
}
async fn trash_mails(&self, _ids: Vec<IdTupleGenerated>) -> Result<(), String> {
unimplemented!()
}
async fn move_mails(
&self,
_ids: Vec<IdTupleGenerated>,
_t: &FolderInfo,
) -> Result<(), String> {
unimplemented!()
}
async fn send_mail(&self, _m: &ParsedMessage) -> Result<(), String> {
unimplemented!()
}
}
fn temp_store() -> (LocalStore, std::path::PathBuf) {
let randomizer = RandomizerFacade::from_core(rand_core::OsRng);
let key: GenericAesKey = GenericAesKey::Aes256(Aes256Key::generate(&randomizer));
let tmp = std::env::temp_dir().join(format!("tutabridge_backup_test_{}", rand::random::<u64>()));
std::fs::create_dir_all(&tmp).unwrap();
let store = LocalStore::open(&tmp.join("s.db"), &tmp.join("mails"), key).unwrap();
(store, tmp)
}
#[tokio::test]
async fn export_writes_eml_tree_with_cache_and_server_paths() {
let (store, _tmp) = temp_store();
// INBOX has two mails; one is pre-cached, one must be fetched.
let m_cached = make_mail("Cached1--3-9", "Cached subject");
let m_fetch = make_mail("Fetch1--3-9", "Fetched subject");
// Sent has one mail, also fetched.
let m_sent = make_mail("Sent1--3-9", "Sent subject");
// Seed the cache for the cached one only.
store
.write_eml(
"Cached1--3-9",
&mail_to_rfc2822(&m_cached, Some(&make_details("<p>from cache</p>")), &[]),
)
.unwrap();
let mut mails = HashMap::new();
mails.insert("inbox_entries".to_string(), vec![m_cached, m_fetch]);
mails.insert("sent_entries".to_string(), vec![m_sent]);
let backend = MockBackend {
folders: vec![
folder("inbox", "inbox_entries", "INBOX"),
folder("sent", "sent_entries", "Sent"),
],
mails,
server_loads: std::sync::Mutex::new(0),
};
let out = std::env::temp_dir().join(format!("tutabridge_backup_out_{}", rand::random::<u64>()));
let mut progress_calls = 0;
let stats = export_eml(&backend, &store, &out, |_p| progress_calls += 1)
.await
.unwrap();
assert_eq!(stats.folders, 2);
assert_eq!(stats.mails_written, 3);
assert_eq!(stats.from_cache, 1, "the seeded mail should come from cache");
assert_eq!(stats.from_server, 2, "the other two should be fetched");
assert_eq!(progress_calls, 3);
assert!(stats.errors.is_empty());
// Only the two non-cached mails hit the backend.
assert_eq!(*backend.server_loads.lock().unwrap(), 2);
// Files landed in the right per-folder dirs with the date prefix.
let inbox = out.join("INBOX");
let sent = out.join("Sent");
let inbox_files: Vec<_> = std::fs::read_dir(&inbox).unwrap().filter_map(|e| e.ok()).collect();
assert_eq!(inbox_files.len(), 2);
assert_eq!(std::fs::read_dir(&sent).unwrap().count(), 1);
// The cached mail's body must be the cached version, not a re-fetch.
let cached_path = inbox.join("20241225-123725_Cached1--3-9.eml");
let cached_eml = std::fs::read_to_string(&cached_path).unwrap();
let from_cache_b64 = base64::engine::general_purpose::STANDARD.encode(b"<p>from cache</p>");
assert!(cached_eml.contains(&from_cache_b64), "cached body should be served verbatim");
// --- second run resumes: everything is already on disk ---
let stats2 = export_eml(&backend, &store, &out, |_p| {})
.await
.unwrap();
assert_eq!(stats2.skipped, 3, "a re-run must skip every already-exported mail");
assert_eq!(stats2.mails_written, 0);
assert_eq!(stats2.from_server, 0, "no server fetch on a resume");
assert_eq!(
*backend.server_loads.lock().unwrap(),
2,
"server_loads unchanged: the second run hit zero mails"
);
std::fs::remove_dir_all(&out).ok();
}
#[tokio::test]
async fn resume_only_fetches_the_new_mail() {
let (store, _tmp) = temp_store();
let out =
std::env::temp_dir().join(format!("tutabridge_backup_inc_{}", rand::random::<u64>()));
// First backup: one mail fetched.
let mut m1 = HashMap::new();
m1.insert("inbox_entries".to_string(), vec![make_mail("Old1--3-9", "old")]);
let backend1 = MockBackend {
folders: vec![folder("inbox", "inbox_entries", "INBOX")],
mails: m1,
server_loads: std::sync::Mutex::new(0),
};
let s1 = export_eml(&backend1, &store, &out, |_p| {}).await.unwrap();
assert_eq!(s1.from_server, 1);
// A new mail shows up. A re-run against the same output dir must skip
// the old one (already on disk) and only fetch the newcomer.
let mut m2 = HashMap::new();
m2.insert(
"inbox_entries".to_string(),
vec![make_mail("Old1--3-9", "old"), make_mail("New1--3-9", "new")],
);
let backend2 = MockBackend {
folders: vec![folder("inbox", "inbox_entries", "INBOX")],
mails: m2,
server_loads: std::sync::Mutex::new(0),
};
let s2 = export_eml(&backend2, &store, &out, |_p| {}).await.unwrap();
assert_eq!(s2.skipped, 1, "the old mail is already on disk");
assert_eq!(s2.from_server, 1, "only the new mail is fetched");
assert_eq!(
*backend2.server_loads.lock().unwrap(),
1,
"the second backend only loaded the new mail's details"
);
std::fs::remove_dir_all(&out).ok();
}
}
+19
View File
@@ -90,6 +90,10 @@ pub struct BridgeHandle {
stats_dirty_tx: broadcast::Sender<()>,
started_at: Option<std::time::Instant>,
store: Option<Arc<MailStore>>,
/// Logged-in backend + local cache, kept after `start` so a backup can
/// reuse the live session instead of opening a second one.
backend: Option<Arc<dyn MailBackend>>,
local_store: Option<Arc<LocalStore>>,
task: Option<tokio::task::JoinHandle<()>>,
/// Latest event-bus state, populated at `start` and observed by `stats`.
ws_state_rx: Option<watch::Receiver<tutasdk::event_bus::WsState>>,
@@ -106,11 +110,22 @@ impl BridgeHandle {
stats_dirty_tx,
started_at: None,
store: None,
backend: None,
local_store: None,
task: None,
ws_state_rx: None,
}
}
/// The logged-in backend + local cache, available while the bridge is
/// running. Used by the GUI's backup command to reuse the live session.
pub fn backend_and_store(&self) -> Option<(Arc<dyn MailBackend>, Arc<LocalStore>)> {
match (&self.backend, &self.local_store) {
(Some(b), Some(s)) => Some((b.clone(), s.clone())),
_ => None,
}
}
pub fn subscribe_logs(&self) -> broadcast::Receiver<String> {
self.log_tx.subscribe()
}
@@ -222,6 +237,8 @@ impl BridgeHandle {
let backend: Arc<dyn MailBackend> = Arc::new(session);
let store = MailStore::new();
self.store = Some(store.clone());
self.backend = Some(backend.clone());
self.local_store = Some(local_store.clone());
let (tx, rx) = oneshot::channel::<()>();
let (shutdown_sync_tx, shutdown_sync_rx) = watch::channel(false);
self.shutdown_tx = Some(tx);
@@ -424,6 +441,8 @@ impl BridgeHandle {
}
self.started_at = None;
self.ws_state_rx = None;
self.backend = None;
self.local_store = None;
// Final pulse so the UI re-reads `stats()` (now reporting Stopped /
// zero uptime / zero mails) without waiting for a poll.
let _ = self.stats_dirty_tx.send(());
+1
View File
@@ -1,3 +1,4 @@
pub mod backup;
pub mod bridge;
pub mod config;
pub mod event_handler;
+1
View File
@@ -11,6 +11,7 @@ tauri-build = { version = "2", features = [] }
tutabridge-core = { path = "../crates/bridge" }
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
tokio = { version = "1.43", features = ["full"] }
tokio-rustls = { version = "0.26", features = ["ring"] }
serde = { version = "1.0", features = ["derive"] }
+1
View File
@@ -6,6 +6,7 @@
"permissions": [
"core:default",
"shell:allow-open",
"dialog:allow-open",
"core:event:default",
"core:event:allow-listen",
"core:event:allow-emit"
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for the main window","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","core:event:default","core:event:allow-listen","core:event:allow-emit"]}}
{"default":{"identifier":"default","description":"Default capabilities for the main window","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","dialog:allow-open","core:event:default","core:event:allow-listen","core:event:allow-emit"]}}
+66
View File
@@ -2402,6 +2402,72 @@
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
"type": "string",
+66
View File
@@ -2402,6 +2402,72 @@
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
"type": "string",
+58 -1
View File
@@ -1,12 +1,22 @@
use std::sync::Arc;
use tauri::State;
use tauri::{AppHandle, Emitter, State};
use tokio::sync::Mutex;
use tutabridge_core::backup;
use tutabridge_core::bridge::{BridgeHandle, BridgeStats, BridgeStatus};
use tutabridge_core::config::{self, Config};
use tutabridge_core::tuta;
pub type BridgeState = Arc<Mutex<BridgeHandle>>;
/// Progress event pushed to the UI during a backup (`bridge://backup-progress`).
#[derive(Clone, serde::Serialize)]
struct BackupProgressEvent {
folder: String,
done: usize,
total: usize,
finished: bool,
}
#[tauri::command]
pub async fn get_config() -> Result<Config, String> {
match config::load_config() {
@@ -78,3 +88,50 @@ pub async fn regenerate_bridge_password() -> Result<String, String> {
.ok_or("No config found")?;
config::regenerate_bridge_password(&mut cfg).map_err(|e| e.to_string())
}
/// Export every mail to `output_dir` as a tree of `.eml` files. Requires the
/// bridge to be running (reuses its live session + cache). Streams progress
/// via `bridge://backup-progress` events and resolves with the final stats.
#[tauri::command]
pub async fn export_mails(
output_dir: String,
app: AppHandle,
state: State<'_, BridgeState>,
) -> Result<backup::BackupStats, String> {
// Grab the live backend + cache, then drop the lock immediately — a
// backup can run for minutes and must not block status/stats reads.
let (backend, local_store) = {
let handle = state.lock().await;
handle
.backend_and_store()
.ok_or("Start the bridge before backing up")?
};
let out = std::path::Path::new(&output_dir);
let stats = backup::export_eml(&*backend, &local_store, out, |p| {
// Throttle: emit every 20 mails plus the last one of each folder.
if p.done == p.total || p.done % 20 == 0 {
let _ = app.emit(
"bridge://backup-progress",
BackupProgressEvent {
folder: p.folder.clone(),
done: p.done,
total: p.total,
finished: false,
},
);
}
})
.await?;
let _ = app.emit(
"bridge://backup-progress",
BackupProgressEvent {
folder: String::new(),
done: 0,
total: 0,
finished: true,
},
);
Ok(stats)
}
+2
View File
@@ -23,6 +23,7 @@ fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.manage(shared as BridgeState)
.invoke_handler(tauri::generate_handler![
commands::get_config,
@@ -34,6 +35,7 @@ fn main() {
commands::get_stats,
commands::get_bridge_password,
commands::regenerate_bridge_password,
commands::export_mails,
])
.setup(|app| {
let app_handle = app.handle().clone();
+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(())
}
+10
View File
@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-shell": "^2.3.5",
"react": "^19.2.6",
"react-dom": "^19.2.6"
@@ -849,6 +850,15 @@
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-shell": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
+1
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-shell": "^2.3.5",
"react": "^19.2.6",
"react-dom": "^19.2.6"
+18 -1
View File
@@ -4,10 +4,11 @@ import { Dashboard } from "./components/Dashboard";
import { ConnectionPanel } from "./components/ConnectionPanel";
import { ConfigPanel } from "./components/ConfigPanel";
import { LogsPanel } from "./components/LogsPanel";
import { BackupPanel } from "./components/BackupPanel";
import { statusLabel, isError } from "./types";
import "./App.css";
type Tab = "dashboard" | "connection" | "config" | "logs";
type Tab = "dashboard" | "connection" | "config" | "backup" | "logs";
function App() {
const [tab, setTab] = useState<Tab>("dashboard");
@@ -55,6 +56,12 @@ function App() {
>
Config
</button>
<button
className={tab === "backup" ? "active" : ""}
onClick={() => setTab("backup")}
>
Backup
</button>
<button
className={tab === "logs" ? "active" : ""}
onClick={() => setTab("logs")}
@@ -91,6 +98,16 @@ function App() {
onRestart={bridge.restartBridge}
/>
)}
{tab === "backup" && (
<BackupPanel
isRunning={isRunning}
busy={bridge.backupBusy}
progress={bridge.backupProgress}
result={bridge.backupResult}
error={bridge.backupError}
onBackup={bridge.startBackup}
/>
)}
{tab === "logs" && (
<LogsPanel logs={bridge.logs} onClear={bridge.clearLogs} />
)}
+105
View File
@@ -0,0 +1,105 @@
import type { BackupStats, BackupProgress } from "../hooks/useBridge";
interface Props {
isRunning: boolean;
busy: boolean;
progress: BackupProgress | null;
result: BackupStats | null;
error: string | null;
onBackup: () => void;
}
export function BackupPanel({ isRunning, busy, progress, result, error, onBackup }: Props) {
const pct =
progress && progress.total > 0
? Math.round((progress.done / progress.total) * 100)
: 0;
return (
<div className="dashboard">
<div className="panel">
<h2>Backup mailbox</h2>
<p className="muted">
Export <strong>every</strong> email to a folder of <code>.eml</code> files
your complete mailbox, not just the messages currently synced. The files
open in any mail client (Thunderbird, Apple Mail, Outlook). Re-running into
the same folder resumes where it left off and only fetches new mail.
</p>
{!isRunning && (
<p className="muted" style={{ color: "var(--orange)" }}>
Start the bridge first backup reuses its signed-in session.
</p>
)}
<button
className="primary"
disabled={!isRunning || busy}
onClick={onBackup}
style={{ marginTop: "0.75rem" }}
>
{busy ? "Backing up…" : "Choose folder & back up"}
</button>
{busy && progress && (
<div style={{ marginTop: "1rem" }}>
<div className="muted" style={{ marginBottom: "0.35rem" }}>
{progress.folder}: {progress.done} / {progress.total} ({pct}%)
</div>
<progress
value={progress.done}
max={progress.total}
style={{ width: "100%" }}
/>
</div>
)}
{busy && !progress && (
<p className="muted" style={{ marginTop: "1rem" }}>
Enumerating mail
</p>
)}
{result && (
<div style={{ marginTop: "1.25rem" }}>
<div className="stats-grid">
<div className="stat-card">
<div className="stat-value">{result.mails_written}</div>
<div className="stat-label">mails written</div>
</div>
<div className="stat-card">
<div className="stat-value">{result.folders}</div>
<div className="stat-label">folders</div>
</div>
<div className="stat-card">
<div className="stat-value">
{(result.bytes / 1_000_000).toFixed(1)} MB
</div>
<div className="stat-label">on disk</div>
</div>
</div>
<p className="muted" style={{ marginTop: "0.75rem" }}>
{result.from_cache} from local cache, {result.from_server} fetched
from the server
{result.skipped > 0
? `, ${result.skipped} skipped (already backed up)`
: ""}
.
</p>
{result.errors.length > 0 && (
<p className="muted" style={{ color: "var(--orange)" }}>
{result.errors.length} mail(s) could not be exported.
</p>
)}
</div>
)}
{error && (
<p className="muted" style={{ color: "var(--red)", marginTop: "1rem" }}>
Backup failed: {error}
</p>
)}
</div>
</div>
);
}
+80
View File
@@ -1,10 +1,27 @@
import { useState, useEffect, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { open } from "@tauri-apps/plugin-dialog";
import type { Config, BridgeStatus, BridgeStats } from "../types";
const MAX_LOG_LINES = 500;
export interface BackupStats {
folders: number;
mails_written: number;
from_cache: number;
from_server: number;
skipped: number;
bytes: number;
errors: string[];
}
export interface BackupProgress {
folder: string;
done: number;
total: number;
}
export function useBridge() {
const [config, setConfig] = useState<Config | null>(null);
const [status, setStatus] = useState<BridgeStatus | null>(null);
@@ -18,6 +35,15 @@ export function useBridge() {
const [logs, setLogs] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
// Backup state lives here (not in BackupPanel) so it survives tab
// switches — the panel is conditionally rendered and would otherwise
// unmount mid-export, dropping its progress + the event listener while
// the Rust task keeps running.
const [backupBusy, setBackupBusy] = useState(false);
const [backupProgress, setBackupProgress] = useState<BackupProgress | null>(null);
const [backupResult, setBackupResult] = useState<BackupStats | null>(null);
const [backupError, setBackupError] = useState<string | null>(null);
const refresh = useCallback(() => {
invoke<BridgeStatus>("get_status").then(setStatus);
invoke<BridgeStats>("get_stats").then(setStats);
@@ -51,6 +77,28 @@ export function useBridge() {
};
}, []);
// Always-on backup progress listener — independent of which tab is
// mounted, so progress keeps flowing even when the Backup tab is hidden.
useEffect(() => {
const unlisten = listen<BackupProgress & { finished: boolean }>(
"bridge://backup-progress",
(e) => {
if (e.payload.finished) {
setBackupProgress(null);
} else {
setBackupProgress({
folder: e.payload.folder,
done: e.payload.done,
total: e.payload.total,
});
}
},
);
return () => {
unlisten.then((fn) => fn());
};
}, []);
const saveConfig = useCallback(async (cfg: Config) => {
await invoke("save_config", { config: cfg });
setConfig(cfg);
@@ -102,6 +150,33 @@ export function useBridge() {
return newPassword;
}, []);
const startBackup = useCallback(async () => {
if (backupBusy) return;
setBackupError(null);
setBackupResult(null);
let dir: string | null = null;
try {
const picked = await open({ directory: true, title: "Choose a backup folder" });
dir = typeof picked === "string" ? picked : null;
} catch (e) {
setBackupError(String(e));
return;
}
if (!dir) return;
setBackupBusy(true);
setBackupProgress(null);
try {
const stats = await invoke<BackupStats>("export_mails", { outputDir: dir });
setBackupResult(stats);
} catch (e) {
setBackupError(String(e));
} finally {
setBackupBusy(false);
setBackupProgress(null);
}
}, [backupBusy]);
return {
config,
status,
@@ -116,5 +191,10 @@ export function useBridge() {
restartBridge,
clearLogs,
regenerateBridgePassword,
backupBusy,
backupProgress,
backupResult,
backupError,
startBackup,
};
}