c1a9004720
- 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>
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
"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>
|
|
);
|
|
}
|