feat(PROJ-28): SMTP-Out Relay — DB-Konfiguration + Admin-Tab

- smtpoutconfig.Store: AES-256-GCM verschlüsseltes Passwort in DB (id=1 Singleton)
- Mailer: Reload() für Runtime-Konfigurationswechsel (sync.RWMutex)
- API: GET/PUT/DELETE /api/admin/smtp-out + POST /api/admin/smtp-out/test
- Admin-Tab: Host, Port, User, Passwort, TLS-Switch, From, Test-Button, Status-Badge
- Startup: Lädt DB-Konfiguration und aktiviert Mailer ohne Restart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-31 22:36:57 +02:00
parent 7371a73b3e
commit c1a9004720
7 changed files with 698 additions and 8 deletions
+309
View File
@@ -0,0 +1,309 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Alert, AlertDescription } from "@/components/ui/alert";
// ── Types ─────────────────────────────────────────────────────────────────────
interface SMTPOutConfig {
id?: number;
enabled: boolean;
host: string;
port: number;
user: string;
password: string;
tls: boolean;
from: string;
updated_at?: string;
updated_by?: string;
}
const defaultForm: SMTPOutConfig = {
enabled: true,
host: "",
port: 587,
user: "",
password: "",
tls: false,
from: "",
};
// ── API helpers ───────────────────────────────────────────────────────────────
async function fetchConfig(): Promise<{ configured: boolean; config?: SMTPOutConfig }> {
const res = await fetch("/api/admin/smtp-out", { credentials: "include" });
if (!res.ok) throw new Error("Laden fehlgeschlagen");
return res.json();
}
async function saveConfig(cfg: SMTPOutConfig): Promise<void> {
const res = await fetch("/api/admin/smtp-out", {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(cfg),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { error?: string }).error ?? "Speichern fehlgeschlagen");
}
}
async function deleteConfig(): Promise<void> {
const res = await fetch("/api/admin/smtp-out", { method: "DELETE", credentials: "include" });
if (!res.ok) throw new Error("Löschen fehlgeschlagen");
}
async function testConfig(): Promise<string> {
const res = await fetch("/api/admin/smtp-out/test", { method: "POST", credentials: "include" });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as { error?: string }).error ?? "Test fehlgeschlagen");
return (data as { sent_to?: string }).sent_to ?? "";
}
// ── Component ─────────────────────────────────────────────────────────────────
export function SMTPOutTab() {
const [configured, setConfigured] = useState(false);
const [form, setForm] = useState<SMTPOutConfig>(defaultForm);
const [changePassword, setChangePassword] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null);
const [error, setError] = useState("");
const [updatedBy, setUpdatedBy] = useState("");
const [updatedAt, setUpdatedAt] = useState("");
const load = useCallback(() => {
setLoading(true);
fetchConfig()
.then((data) => {
setConfigured(data.configured);
if (data.config) {
setForm({ ...data.config, password: "" });
setUpdatedBy(data.config.updated_by ?? "");
setUpdatedAt(data.config.updated_at ?? "");
} else {
setForm(defaultForm);
}
setChangePassword(!data.configured);
})
.catch(() => setError("Konfiguration konnte nicht geladen werden"))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setSaving(true);
try {
const payload = { ...form };
if (!changePassword) payload.password = "";
await saveConfig(payload);
setTestResult(null);
load();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Fehler");
} finally {
setSaving(false);
}
};
const handleTest = async () => {
setError("");
setTestResult(null);
setTesting(true);
try {
const sentTo = await testConfig();
setTestResult({ ok: true, msg: `Test-E-Mail gesendet an ${sentTo}` });
} catch (e: unknown) {
setTestResult({ ok: false, msg: e instanceof Error ? e.message : "Test fehlgeschlagen" });
} finally {
setTesting(false);
}
};
const handleDelete = async () => {
if (!confirm("SMTP-Out-Konfiguration wirklich löschen?")) return;
setError("");
try {
await deleteConfig();
load();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Fehler");
}
};
const set = (field: keyof SMTPOutConfig, value: unknown) =>
setForm((prev) => ({ ...prev, [field]: value }));
return (
<div className="mt-4 space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>SMTP-Out Relay</CardTitle>
<Badge variant={configured && form.enabled ? "default" : "secondary"}>
{configured && form.enabled ? "Aktiv" : configured ? "Deaktiviert" : "Nicht konfiguriert"}
</Badge>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Ausgehender SMTP-Relay für Signup-Bestätigungen, Passwort-Reset und Einladungslinks.
Das Passwort wird verschlüsselt gespeichert.
{configured && updatedBy && (
<span> Zuletzt geändert von <strong>{updatedBy}</strong>
{updatedAt && ` am ${new Date(updatedAt).toLocaleDateString("de-DE")}`}.
</span>
)}
</p>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{loading ? (
<p className="text-sm text-muted-foreground">Lädt...</p>
) : (
<form onSubmit={handleSave} className="space-y-4">
{/* Enabled toggle */}
<div className="flex items-center gap-3">
<Switch
id="enabled"
checked={form.enabled}
onCheckedChange={(v) => set("enabled", v)}
/>
<Label htmlFor="enabled">Relay aktiviert</Label>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor="host">Host</Label>
<Input
id="host"
placeholder="mail.firma.de"
value={form.host}
onChange={(e) => set("host", e.target.value)}
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="port">Port</Label>
<Input
id="port"
type="number"
placeholder="587"
value={form.port}
onChange={(e) => set("port", parseInt(e.target.value, 10) || 587)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="user">Benutzername</Label>
<Input
id="user"
placeholder="archivmail@firma.de (leer = anonym)"
value={form.user}
onChange={(e) => set("user", e.target.value)}
autoComplete="username"
/>
</div>
<div className="space-y-1">
<Label htmlFor="from">Absender (From)</Label>
<Input
id="from"
placeholder="archivmail <noreply@firma.de>"
value={form.from}
onChange={(e) => set("from", e.target.value)}
required
/>
</div>
</div>
{/* Password */}
<div className="space-y-2">
{configured && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="changePw"
className="h-4 w-4"
checked={changePassword}
onChange={(e) => setChangePassword(e.target.checked)}
/>
<Label htmlFor="changePw" className="font-normal text-sm cursor-pointer">
Passwort ändern
</Label>
</div>
)}
{(!configured || changePassword) && (
<div className="space-y-1">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
placeholder="leer = kein Auth"
value={form.password}
onChange={(e) => set("password", e.target.value)}
autoComplete="new-password"
/>
</div>
)}
</div>
{/* TLS */}
<div className="flex items-center gap-3">
<Switch
id="tls"
checked={form.tls}
onCheckedChange={(v) => set("tls", v)}
/>
<Label htmlFor="tls">
TLS (Port 465) deaktiviert = STARTTLS (Port 587)
</Label>
</div>
{/* Test result */}
{testResult && (
<Alert variant={testResult.ok ? "default" : "destructive"}>
<AlertDescription>{testResult.msg}</AlertDescription>
</Alert>
)}
{/* Actions */}
<div className="flex flex-wrap gap-2 pt-2">
<Button type="submit" disabled={saving}>
{saving ? "Speichern..." : "Speichern"}
</Button>
<Button
type="button"
variant="outline"
disabled={testing || !configured}
onClick={handleTest}
>
{testing ? "Sende..." : "Test-E-Mail senden"}
</Button>
{configured && (
<Button type="button" variant="destructive" onClick={handleDelete}>
Konfiguration löschen
</Button>
)}
</div>
</form>
)}
</CardContent>
</Card>
</div>
);
}