Files
tutabridge/ui/src/hooks/useBridge.ts
T
Anthony MandGitHub d27c278cab 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.
2026-05-29 14:43:47 +02:00

201 lines
5.7 KiB
TypeScript

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);
const [stats, setStats] = useState<BridgeStats>({
uptime_secs: null,
mails_synced: 0,
ws_status: "Stopped",
});
const [hasSavedSession, setHasSavedSession] = useState(false);
const [bridgePassword, setBridgePassword] = useState<string | null>(null);
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);
}, []);
useEffect(() => {
invoke<Config>("get_config").then(setConfig);
invoke<boolean>("has_saved_session").then(setHasSavedSession);
invoke<string | null>("get_bridge_password").then(setBridgePassword);
refresh();
// The bridge pushes `bridge://stats` and `bridge://status` whenever
// anything changes (mail count, ws state, start/stop). No setInterval.
const unlistenStats = listen<BridgeStats>("bridge://stats", (e) => setStats(e.payload));
const unlistenStatus = listen<BridgeStatus>("bridge://status", (e) => setStatus(e.payload));
return () => {
unlistenStats.then((fn) => fn());
unlistenStatus.then((fn) => fn());
};
}, [refresh]);
useEffect(() => {
const unlisten = listen<string>("bridge://log", (event) => {
setLogs((prev) => {
const next = [...prev, event.payload];
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
});
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
// 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);
}, []);
const startBridge = useCallback(async (password?: string) => {
setLoading(true);
try {
await invoke("start_bridge", { password: password || null });
refresh();
invoke<string | null>("get_bridge_password").then(setBridgePassword);
} catch (e) {
setStatus({ Error: String(e) });
} finally {
setLoading(false);
}
}, [refresh]);
const stopBridge = useCallback(async () => {
setLoading(true);
try {
await invoke("stop_bridge");
refresh();
} catch (e) {
setStatus({ Error: String(e) });
} finally {
setLoading(false);
}
}, [refresh]);
const restartBridge = useCallback(async () => {
setLoading(true);
try {
await invoke("stop_bridge");
await invoke("start_bridge", { password: null });
refresh();
} catch (e) {
setStatus({ Error: String(e) });
} finally {
setLoading(false);
}
}, [refresh]);
const clearLogs = useCallback(() => setLogs([]), []);
const regenerateBridgePassword = useCallback(async () => {
const newPassword = await invoke<string>("regenerate_bridge_password");
setBridgePassword(newPassword);
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,
stats,
hasSavedSession,
bridgePassword,
logs,
loading,
saveConfig,
startBridge,
stopBridge,
restartBridge,
clearLogs,
regenerateBridgePassword,
backupBusy,
backupProgress,
backupResult,
backupError,
startBackup,
};
}