feat(PROJ-14): POP3-Import — Client, Store, Importer, API-Routen, Frontend-Seite

This commit is contained in:
sysops
2026-03-17 19:48:14 +01:00
parent 5e69c29f16
commit adffff7ee1
9 changed files with 1494 additions and 1 deletions
+486
View File
@@ -0,0 +1,486 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useAuth } from "@/hooks/useAuth";
import { Navbar } from "@/components/navbar";
import {
getPop3Accounts,
createPop3Account,
deletePop3Account,
testPop3Connection,
startPop3Import,
getPop3Progress,
type Pop3Account,
} from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardHeader,
CardContent,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
// Default ports per TLS mode
function defaultPort(tls: string): string {
return tls === "ssl" ? "995" : "110";
}
export default function Pop3Page() {
const { user, loading: authLoading } = useAuth();
const [accounts, setAccounts] = useState<Pop3Account[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
// Form state
const [formName, setFormName] = useState("");
const [formHost, setFormHost] = useState("");
const [formPort, setFormPort] = useState("110");
const [formTls, setFormTls] = useState("none");
const [formTlsSkipVerify, setFormTlsSkipVerify] = useState(false);
const [formUsername, setFormUsername] = useState("");
const [formPassword, setFormPassword] = useState("");
// Test state
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string; message_count?: number; total_size_bytes?: number } | null>(null);
// Saving state
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
// Polling refs
const pollingRefs = useRef<Map<number, ReturnType<typeof setInterval>>>(new Map());
const loadAccounts = useCallback(async () => {
try {
const data = await getPop3Accounts();
setAccounts(data);
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (user) loadAccounts();
}, [user, loadAccounts]);
// Start polling for running accounts
useEffect(() => {
for (const acc of accounts) {
if (acc.status === "running" && !pollingRefs.current.has(acc.id)) {
const interval = setInterval(async () => {
try {
const updated = await getPop3Progress(acc.id);
setAccounts((prev) =>
prev.map((a) => (a.id === updated.id ? updated : a))
);
if (updated.status !== "running") {
clearInterval(pollingRefs.current.get(acc.id)!);
pollingRefs.current.delete(acc.id);
}
} catch {
clearInterval(pollingRefs.current.get(acc.id)!);
pollingRefs.current.delete(acc.id);
}
}, 2000);
pollingRefs.current.set(acc.id, interval);
}
}
// Cleanup intervals for accounts that are no longer running
for (const [id, interval] of pollingRefs.current) {
const acc = accounts.find((a) => a.id === id);
if (!acc || acc.status !== "running") {
clearInterval(interval);
pollingRefs.current.delete(id);
}
}
}, [accounts]);
// Cleanup on unmount
useEffect(() => {
return () => {
for (const interval of pollingRefs.current.values()) {
clearInterval(interval);
}
};
}, []);
function resetForm() {
setFormName("");
setFormHost("");
setFormPort("110");
setFormTls("none");
setFormTlsSkipVerify(false);
setFormUsername("");
setFormPassword("");
setTestResult(null);
setSaveError("");
}
function handleTlsChange(value: string) {
setFormTls(value);
setFormPort(defaultPort(value));
}
async function handleTest() {
setTesting(true);
setTestResult(null);
try {
const result = await testPop3Connection({
host: formHost,
port: parseInt(formPort, 10) || 110,
tls: formTls,
tls_skip_verify: formTlsSkipVerify,
username: formUsername,
password: formPassword,
});
setTestResult(result);
} catch (err) {
setTestResult({
ok: false,
message: err instanceof Error ? err.message : "Verbindungstest fehlgeschlagen",
});
} finally {
setTesting(false);
}
}
async function handleSave() {
setSaving(true);
setSaveError("");
try {
await createPop3Account({
name: formName,
host: formHost,
port: parseInt(formPort, 10) || 110,
tls: formTls,
tls_skip_verify: formTlsSkipVerify,
username: formUsername,
password: formPassword,
});
setDialogOpen(false);
resetForm();
await loadAccounts();
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Speichern fehlgeschlagen");
} finally {
setSaving(false);
}
}
async function handleStartImport(id: number) {
try {
const updated = await startPop3Import(id);
setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a)));
} catch {
// ignore — conflict means import already running
}
}
async function handleDelete(id: number) {
try {
await deletePop3Account(id);
setAccounts((prev) => prev.filter((a) => a.id !== id));
} catch {
// ignore
}
setDeleteConfirm(null);
}
function statusBadge(status: string) {
switch (status) {
case "running":
return <Badge className="bg-blue-600 text-white">Importiert...</Badge>;
case "error":
return <Badge variant="destructive">Fehler</Badge>;
default:
return <Badge variant="secondary">Bereit</Badge>;
}
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
return (
<div className="min-h-screen">
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
<main className="mx-auto max-w-4xl px-4 py-6">
{(authLoading || !user) ? (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
) : (<>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">POP3 Import</h1>
<Button
onClick={() => {
resetForm();
setDialogOpen(true);
}}
>
Konto hinzufuegen
</Button>
</div>
{loading ? (
<div className="space-y-4">
{[1, 2].map((i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
) : accounts.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Noch keine POP3-Konten konfiguriert. Klicken Sie auf &quot;Konto
hinzufuegen&quot;, um zu beginnen.
</CardContent>
</Card>
) : (
<div className="space-y-4">
{accounts.map((acc) => (
<Card key={acc.id}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<h3 className="font-semibold">{acc.name}</h3>
<p className="text-sm text-muted-foreground">
{acc.host}:{acc.port} ({acc.tls.toUpperCase()}) &middot; {acc.username}
</p>
</div>
{statusBadge(acc.status)}
</CardHeader>
<CardContent>
{acc.status === "running" && acc.progress_total > 0 && (
<div className="mb-3 space-y-1">
<Progress
value={(acc.progress_current / acc.progress_total) * 100}
/>
<p className="text-xs text-muted-foreground">
{acc.progress_current} von {acc.progress_total} E-Mails
</p>
</div>
)}
{acc.status === "error" && acc.error_msg && (
<p className="mb-3 text-sm text-destructive">
{acc.error_msg}
</p>
)}
{acc.last_import_at && (
<p className="text-sm text-muted-foreground mb-3">
Letzter Import:{" "}
{new Date(acc.last_import_at).toLocaleString("de-DE")} (
{acc.last_import_count} E-Mails)
</p>
)}
<div className="flex gap-2 flex-wrap">
<Button
size="sm"
disabled={acc.status === "running"}
onClick={() => handleStartImport(acc.id)}
>
Import starten
</Button>
<Button
size="sm"
variant="destructive"
disabled={acc.status === "running"}
onClick={() => setDeleteConfirm(acc.id)}
>
Loeschen
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Add Account Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>POP3-Konto hinzufuegen</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="pop3-name">Name</Label>
<Input
id="pop3-name"
placeholder="z.B. Firmen-Mail"
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="pop3-host">Host</Label>
<Input
id="pop3-host"
placeholder="pop3.example.com"
value={formHost}
onChange={(e) => setFormHost(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="pop3-port">Port</Label>
<Input
id="pop3-port"
type="number"
value={formPort}
onChange={(e) => setFormPort(e.target.value)}
/>
</div>
</div>
<div className="space-y-1">
<Label>Verschluesselung</Label>
<Select value={formTls} onValueChange={handleTlsChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ssl">SSL/TLS (Port 995)</SelectItem>
<SelectItem value="starttls">STARTTLS (Port 110)</SelectItem>
<SelectItem value="none">Unverschluesselt (Port 110)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Switch
id="pop3-tls-skip"
checked={formTlsSkipVerify}
onCheckedChange={setFormTlsSkipVerify}
/>
<Label htmlFor="pop3-tls-skip" className="cursor-pointer">
TLS-Zertifikat nicht pruefen (unsicher)
</Label>
</div>
<div className="space-y-1">
<Label htmlFor="pop3-user">Benutzername</Label>
<Input
id="pop3-user"
placeholder="user@example.com"
value={formUsername}
onChange={(e) => setFormUsername(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="pop3-pass">Passwort</Label>
<Input
id="pop3-pass"
type="password"
value={formPassword}
onChange={(e) => setFormPassword(e.target.value)}
/>
</div>
<Button
variant="outline"
onClick={handleTest}
disabled={testing || !formHost || !formUsername || !formPassword}
className="w-full"
>
{testing ? "Teste Verbindung..." : "Verbindung testen"}
</Button>
{testResult && (
<div className={`rounded p-3 text-sm ${testResult.ok ? "bg-green-50 dark:bg-green-950 text-green-800 dark:text-green-200" : "bg-red-50 dark:bg-red-950 text-red-800 dark:text-red-200"}`}>
<p className="font-medium">{testResult.ok ? "Verbindung erfolgreich" : "Fehler"}</p>
<p>{testResult.message}</p>
{testResult.ok && testResult.message_count !== undefined && (
<p className="mt-1 text-xs opacity-80">
{testResult.message_count} E-Mails &middot;{" "}
{formatBytes(testResult.total_size_bytes ?? 0)} gesamt
</p>
)}
</div>
)}
{saveError && (
<p className="text-sm text-destructive">{saveError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDialogOpen(false);
resetForm();
}}
>
Abbrechen
</Button>
<Button
onClick={handleSave}
disabled={
saving ||
!formName ||
!formHost ||
!formUsername ||
!formPassword
}
>
{saving ? "Speichert..." : "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteConfirm !== null}
onOpenChange={() => setDeleteConfirm(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Konto loeschen?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Soll dieses POP3-Konto wirklich entfernt werden? Bereits
importierte E-Mails bleiben im Archiv erhalten.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={() => deleteConfirm !== null && handleDelete(deleteConfirm)}
>
Loeschen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>)}
</main>
</div>
);
}
+6
View File
@@ -48,6 +48,12 @@ export function Navbar({ username, role }: NavbarProps) {
>
IMAP Import
</Link>
<Link
href="/pop3"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
POP3 Import
</Link>
{role === "admin" && (
<Link
href="/admin"
+72
View File
@@ -384,6 +384,78 @@ export async function updateImapInterval(id: number, intervalMin: number): Promi
});
}
// ── POP3 ──────────────────────────────────────────────────────────────────
export interface Pop3Account {
id: number;
owner: string;
name: string;
host: string;
port: number;
tls: string;
tls_skip_verify: boolean;
username: string;
status: string;
error_msg: string;
last_import_at?: string;
last_import_count: number;
progress_current: number;
progress_total: number;
created_at: string;
}
export interface Pop3TestResult {
ok: boolean;
message: string;
message_count?: number;
total_size_bytes?: number;
}
export async function getPop3Accounts(): Promise<Pop3Account[]> {
return request<Pop3Account[]>("/api/pop3");
}
export async function createPop3Account(data: {
name: string;
host: string;
port: number;
tls: string;
tls_skip_verify: boolean;
username: string;
password: string;
}): Promise<Pop3Account> {
return request<Pop3Account>("/api/pop3", {
method: "POST",
body: JSON.stringify(data),
});
}
export async function deletePop3Account(id: number): Promise<void> {
await request<void>(`/api/pop3/${id}`, { method: "DELETE" });
}
export async function testPop3Connection(data: {
host: string;
port: number;
tls: string;
tls_skip_verify: boolean;
username: string;
password: string;
}): Promise<Pop3TestResult> {
return request<Pop3TestResult>("/api/pop3/test", {
method: "POST",
body: JSON.stringify(data),
});
}
export async function startPop3Import(id: number): Promise<Pop3Account> {
return request<Pop3Account>(`/api/pop3/${id}/import`, { method: "POST" });
}
export async function getPop3Progress(id: number): Promise<Pop3Account> {
return request<Pop3Account>(`/api/pop3/${id}/progress`);
}
// ── System Stats ──────────────────────────────────────────────────────────
export interface SystemStatsCPU {