Add sync-limit config UI + Restart button, fix restart teardown

- Expose the sync limit in the config panel: "Fetch all mail" checkbox (sync_limit=0) + a max-per-folder number input.
- Config fields are editable while the bridge runs; Save persists anytime, a Restart button (stop+start) applies changes.
- Fix the in-process restart: stop() was fire-and-forget and left IMAP/SMTP tasks holding their ports, so the next start failed to bind and aborted the new syncer before it listed folders (empty store). stop() now aborts all three tasks and awaits full teardown.
This commit is contained in:
Anthony M
2026-05-27 17:31:03 +02:00
committed by GitHub
parent bc03d244c6
commit f0656b595b
6 changed files with 128 additions and 13 deletions
+25 -5
View File
@@ -27,6 +27,7 @@ pub struct BridgeHandle {
log_tx: broadcast::Sender<String>,
started_at: Option<std::time::Instant>,
store: Option<Arc<MailStore>>,
task: Option<tokio::task::JoinHandle<()>>,
}
impl BridgeHandle {
@@ -38,6 +39,7 @@ impl BridgeHandle {
log_tx,
started_at: None,
store: None,
task: None,
}
}
@@ -140,7 +142,7 @@ impl BridgeHandle {
let sync_limit = config.sync_limit;
let pw = config.bridge_password.clone();
tokio::spawn(async move {
let task = tokio::spawn(async move {
let imap_tls = tls_acceptor.clone();
let smtp_tls = tls_acceptor;
@@ -154,37 +156,52 @@ impl BridgeHandle {
sync_limit,
shutdown_sync_rx,
));
let imap_handle = tokio::spawn(imap::serve(
let mut imap_handle = tokio::spawn(imap::serve(
imap_port,
store.clone(),
backend.clone(),
imap_tls,
pw.clone(),
));
let smtp_handle = tokio::spawn(smtp::serve(smtp_port, backend.clone(), smtp_tls, pw));
let mut smtp_handle = tokio::spawn(smtp::serve(smtp_port, backend.clone(), smtp_tls, pw));
tokio::select! {
_ = rx => {
let _ = log_tx.send("Bridge shutting down...".to_string());
let _ = shutdown_sync_tx.send(true);
}
r = imap_handle => {
r = &mut imap_handle => {
if let Err(e) = r {
let _ = log_tx.send(format!("IMAP server error: {e}"));
}
}
r = smtp_handle => {
r = &mut smtp_handle => {
if let Err(e) = r {
let _ = log_tx.send(format!("SMTP server error: {e}"));
}
}
}
// Tear everything down and wait for it, so ports are released before
// a subsequent start rebinds them. Skip awaiting a handle that already
// resolved in the select above (re-polling it would panic).
syncer_handle.abort();
imap_handle.abort();
smtp_handle.abort();
if !syncer_handle.is_finished() {
let _ = syncer_handle.await;
}
if !imap_handle.is_finished() {
let _ = imap_handle.await;
}
if !smtp_handle.is_finished() {
let _ = smtp_handle.await;
}
*status.write().await = BridgeStatus::Stopped;
let _ = log_tx.send("Bridge stopped".to_string());
});
self.task = Some(task);
*self.status.write().await = BridgeStatus::Running;
self.emit_log("Bridge is running");
Ok(())
@@ -194,6 +211,9 @@ impl BridgeHandle {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
if let Some(task) = self.task.take() {
let _ = task.await;
}
self.started_at = None;
}
+40
View File
@@ -435,6 +435,46 @@
flex: 1;
}
.form-group label.checkbox-field {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
cursor: pointer;
text-transform: none;
letter-spacing: 0;
font-weight: 400;
}
.form-group label.checkbox-field input[type="checkbox"] {
width: 18px;
height: 18px;
margin: 0;
flex: none;
accent-color: var(--primary);
cursor: pointer;
}
.form-group label.checkbox-field span {
font-size: 13px;
font-weight: 400;
color: var(--on-surface);
line-height: 1;
}
.field-hint {
display: block;
font-size: 11px;
color: var(--on-surface-variant);
margin-top: 6px;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
/* ── Buttons ── */
button {
padding: 9px 18px;
+2
View File
@@ -86,7 +86,9 @@ function App() {
<ConfigPanel
config={bridge.config}
status={bridge.status}
loading={bridge.loading}
onSave={bridge.saveConfig}
onRestart={bridge.restartBridge}
/>
)}
{tab === "logs" && (
+45 -8
View File
@@ -4,14 +4,18 @@ import type { Config, BridgeStatus } from "../types";
interface Props {
config: Config | null;
status: BridgeStatus | null;
loading: boolean;
onSave: (config: Config) => Promise<void>;
onRestart: () => Promise<void>;
}
export function ConfigPanel({ config, status, onSave }: Props) {
export function ConfigPanel({ config, status, loading, onSave, onRestart }: Props) {
const [email, setEmail] = useState("");
const [imapPort, setImapPort] = useState(1143);
const [smtpPort, setSmtpPort] = useState(1025);
const [apiUrl, setApiUrl] = useState("https://app.tuta.com");
const [syncLimit, setSyncLimit] = useState(500);
const [fetchAll, setFetchAll] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
@@ -20,6 +24,8 @@ export function ConfigPanel({ config, status, onSave }: Props) {
setImapPort(config.imap_port);
setSmtpPort(config.smtp_port);
setApiUrl(config.api_url);
setFetchAll(config.sync_limit === 0);
setSyncLimit(config.sync_limit === 0 ? 500 : config.sync_limit);
}
}, [config]);
@@ -31,6 +37,7 @@ export function ConfigPanel({ config, status, onSave }: Props) {
imap_port: imapPort,
smtp_port: smtpPort,
api_url: apiUrl,
sync_limit: fetchAll ? 0 : syncLimit,
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
@@ -45,7 +52,6 @@ export function ConfigPanel({ config, status, onSave }: Props) {
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isRunning}
placeholder="your@tuta.com"
/>
</div>
@@ -56,7 +62,6 @@ export function ConfigPanel({ config, status, onSave }: Props) {
type="number"
value={imapPort}
onChange={(e) => setImapPort(Number(e.target.value))}
disabled={isRunning}
/>
</div>
<div className="form-group">
@@ -65,7 +70,6 @@ export function ConfigPanel({ config, status, onSave }: Props) {
type="number"
value={smtpPort}
onChange={(e) => setSmtpPort(Number(e.target.value))}
disabled={isRunning}
/>
</div>
</div>
@@ -75,12 +79,45 @@ export function ConfigPanel({ config, status, onSave }: Props) {
type="url"
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
disabled={isRunning}
/>
</div>
<button onClick={handleSave} disabled={isRunning || !email}>
{saved ? "Saved!" : "Save"}
</button>
<div className="form-group">
<label>Mail to sync</label>
<label className="checkbox-field">
<input
type="checkbox"
checked={fetchAll}
onChange={(e) => setFetchAll(e.target.checked)}
/>
<span>Fetch all mail (entire account, kept locally)</span>
</label>
{fetchAll ? (
<small className="field-hint">
Downloads every mail from the start can be slow on large accounts.
</small>
) : (
<input
type="number"
min={1}
value={syncLimit}
onChange={(e) => setSyncLimit(Math.max(1, Number(e.target.value)))}
placeholder="Max mails per folder"
/>
)}
</div>
{isRunning && (
<small className="field-hint">Changes apply after a restart.</small>
)}
<div className="form-actions">
<button className="primary" onClick={handleSave} disabled={loading || !email}>
{saved ? "Saved!" : "Save"}
</button>
{isRunning && (
<button onClick={onRestart} disabled={loading}>
{loading ? "Restarting…" : "Restart"}
</button>
)}
</div>
</div>
);
}
+14
View File
@@ -75,6 +75,19 @@ export function useBridge() {
}
}, [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 () => {
@@ -94,6 +107,7 @@ export function useBridge() {
saveConfig,
startBridge,
stopBridge,
restartBridge,
clearLogs,
regenerateBridgePassword,
};
+2
View File
@@ -3,6 +3,8 @@ export interface Config {
imap_port: number;
smtp_port: number;
api_url: string;
/** Max mails synced per folder; 0 = fetch all. */
sync_limit: number;
}
export type BridgeStatus = "Stopped" | "Starting" | "Running" | { Error: string };