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:
+18
-1
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user