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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod backup;
|
||||
pub mod bridge;
|
||||
pub mod config;
|
||||
pub mod event_handler;
|
||||
|
||||
Reference in New Issue
Block a user