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