feat(PROJ-8): Automatischer IMAP-Sync (Cron-Scheduler)
Backend:
- internal/imap/store.go: 7 neue Felder (sync_interval_min, last_sync_at,
last_sync_count, last_uid, sync_running, sync_status, sync_error_msg)
DB-Migration via ALTER TABLE ADD COLUMN IF NOT EXISTS
Neue Methoden: ListAll, UpdateSyncInterval, SetSyncRunning, UpdateSyncResult
- internal/imap/scheduler.go: Scheduler mit time.Ticker (1 min),
inkrementeller Sync via UID SEARCH UID <lastUID+1>:*,
exponential backoff (3 Versuche: 1s / 60s / 300s),
sync_running-Flag verhindert parallele Syncs
- internal/api/server.go: POST /api/imap/{id}/sync (manueller Trigger),
PATCH /api/imap/{id} (sync_interval_min setzen, 0 oder 5-1440 min)
- cmd/archivmail/main.go: Scheduler gestartet + via SetImap verdrahtet
Frontend:
- src/lib/api.ts: 6 neue ImapAccount-Felder, triggerImapSync, updateImapInterval
- src/app/imap/page.tsx: Intervall-Dropdown, "Sync jetzt"-Button,
Letzter-Sync-Anzeige mit Status-Badge, Polling auch bei sync_running
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+95
-7
@@ -10,6 +10,8 @@ import {
|
||||
testImapConnection,
|
||||
startImapImport,
|
||||
getImapProgress,
|
||||
triggerImapSync,
|
||||
updateImapInterval,
|
||||
type ImapAccount,
|
||||
type ImapFolder,
|
||||
} from "@/lib/api";
|
||||
@@ -83,17 +85,18 @@ export default function ImapPage() {
|
||||
if (user) loadAccounts();
|
||||
}, [user, loadAccounts]);
|
||||
|
||||
// Start polling for running accounts
|
||||
// Start polling for running accounts (import or sync)
|
||||
useEffect(() => {
|
||||
for (const acc of accounts) {
|
||||
if (acc.status === "running" && !pollingRefs.current.has(acc.id)) {
|
||||
const isActive = acc.status === "running" || acc.sync_running;
|
||||
if (isActive && !pollingRefs.current.has(acc.id)) {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const updated = await getImapProgress(acc.id);
|
||||
setAccounts((prev) =>
|
||||
prev.map((a) => (a.id === updated.id ? updated : a))
|
||||
);
|
||||
if (updated.status !== "running") {
|
||||
if (updated.status !== "running" && !updated.sync_running) {
|
||||
clearInterval(pollingRefs.current.get(acc.id)!);
|
||||
pollingRefs.current.delete(acc.id);
|
||||
}
|
||||
@@ -105,10 +108,10 @@ export default function ImapPage() {
|
||||
pollingRefs.current.set(acc.id, interval);
|
||||
}
|
||||
}
|
||||
// Cleanup intervals for accounts that are no longer running
|
||||
// Cleanup intervals for accounts that are no longer active
|
||||
for (const [id, interval] of pollingRefs.current) {
|
||||
const acc = accounts.find((a) => a.id === id);
|
||||
if (!acc || acc.status !== "running") {
|
||||
if (!acc || (acc.status !== "running" && !acc.sync_running)) {
|
||||
clearInterval(interval);
|
||||
pollingRefs.current.delete(id);
|
||||
}
|
||||
@@ -206,6 +209,25 @@ export default function ImapPage() {
|
||||
setDeleteConfirm(null);
|
||||
}
|
||||
|
||||
async function handleSyncNow(id: number) {
|
||||
try {
|
||||
const updated = await triggerImapSync(id);
|
||||
setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a)));
|
||||
} catch {
|
||||
// ignore — conflict means sync already running
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIntervalChange(id: number, value: string) {
|
||||
const intervalMin = parseInt(value, 10);
|
||||
try {
|
||||
const updated = await updateImapInterval(id, intervalMin);
|
||||
setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a)));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExcluded(folderName: string) {
|
||||
setExcludedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -229,6 +251,20 @@ export default function ImapPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function syncBadge(acc: ImapAccount) {
|
||||
if (acc.sync_running) {
|
||||
return <Badge className="bg-blue-500 text-white">Sync laeuft...</Badge>;
|
||||
}
|
||||
if (!acc.sync_status) return null;
|
||||
if (acc.sync_status === "ok") {
|
||||
return <Badge className="bg-green-600 text-white">Sync OK</Badge>;
|
||||
}
|
||||
if (acc.sync_status === "error") {
|
||||
return <Badge variant="destructive">Sync Fehler</Badge>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
|
||||
@@ -305,13 +341,57 @@ export default function ImapPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* PROJ-8: Sync status */}
|
||||
{acc.last_sync_at && (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Letzter Sync:{" "}
|
||||
{new Date(acc.last_sync_at).toLocaleString("de-DE")} (
|
||||
{acc.last_sync_count} neu)
|
||||
</p>
|
||||
{syncBadge(acc)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{acc.sync_running && !acc.last_sync_at && syncBadge(acc)}
|
||||
|
||||
{acc.sync_error_msg && acc.sync_status === "error" && (
|
||||
<p className="mb-3 text-sm text-destructive">
|
||||
Sync-Fehler: {acc.sync_error_msg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{acc.excluded_folders && acc.excluded_folders.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Ausgeschlossene Ordner: {acc.excluded_folders.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* PROJ-8: Sync interval selector */}
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Auto-Sync:
|
||||
</span>
|
||||
<Select
|
||||
value={String(acc.sync_interval_min ?? 0)}
|
||||
onValueChange={(v) => handleIntervalChange(acc.id, v)}
|
||||
>
|
||||
<SelectTrigger className="w-40 h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">Deaktiviert</SelectItem>
|
||||
<SelectItem value="5">5 min</SelectItem>
|
||||
<SelectItem value="15">15 min</SelectItem>
|
||||
<SelectItem value="30">30 min</SelectItem>
|
||||
<SelectItem value="60">1 Stunde</SelectItem>
|
||||
<SelectItem value="360">6 Stunden</SelectItem>
|
||||
<SelectItem value="1440">24 Stunden</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={acc.status === "running"}
|
||||
@@ -319,10 +399,18 @@ export default function ImapPage() {
|
||||
>
|
||||
Import starten
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={acc.status === "running" || acc.sync_running}
|
||||
onClick={() => handleSyncNow(acc.id)}
|
||||
>
|
||||
Sync jetzt
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={acc.status === "running"}
|
||||
disabled={acc.status === "running" || acc.sync_running}
|
||||
onClick={() => setDeleteConfirm(acc.id)}
|
||||
>
|
||||
Loeschen
|
||||
|
||||
Reference in New Issue
Block a user