From c1a9004720ed11b05239d015b2029c7b9df146c4 Mon Sep 17 00:00:00 2001 From: sysops Date: Tue, 31 Mar 2026 22:36:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(PROJ-28):=20SMTP-Out=20Relay=20=E2=80=94?= =?UTF-8?q?=20DB-Konfiguration=20+=20Admin-Tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- cmd/archivmail/main.go | 23 ++ internal/api/server.go | 13 + internal/api/smtpout_handlers.go | 118 +++++++++ internal/mailer/mailer.go | 31 ++- internal/smtpoutconfig/store.go | 205 +++++++++++++++ src/app/admin/page.tsx | 7 + src/components/admin/tabs/SMTPOutTab.tsx | 309 +++++++++++++++++++++++ 7 files changed, 698 insertions(+), 8 deletions(-) create mode 100644 internal/api/smtpout_handlers.go create mode 100644 internal/smtpoutconfig/store.go create mode 100644 src/components/admin/tabs/SMTPOutTab.tsx diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index ed0e34c..7875362 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -31,6 +31,7 @@ import ( ldapcfg "github.com/archivmail/internal/ldapconfig" "github.com/archivmail/internal/mailer" pop3store "github.com/archivmail/internal/pop3" + "github.com/archivmail/internal/smtpoutconfig" "github.com/archivmail/internal/smtpd" "github.com/archivmail/internal/storage" tenantstore "github.com/archivmail/internal/tenantstore" @@ -185,6 +186,28 @@ func main() { // PROJ-28: Self-Service Onboarding — mailer + token store + FQDN mlr := mailer.New(cfg.SMTPOut) + + // SMTP-Out config store — load from DB, overrides config.yml if present + smtpOutSt, err := smtpoutconfig.New(cfg.Database.DSN(), cfg.API.Secret) + if err != nil { + logger.Error("smtp-out config store init failed", "err", err) + os.Exit(1) + } + defer smtpOutSt.Close() + srv.SetSMTPOutStore(smtpOutSt) + // Override config.yml settings with DB config if available + if dbCfg, err := smtpOutSt.GetWithPassword(context.Background()); err == nil && dbCfg != nil && dbCfg.Enabled { + mlr.Reload(config.SMTPOutConfig{ + Host: dbCfg.Host, + Port: dbCfg.Port, + User: dbCfg.User, + Password: dbCfg.Password, + TLS: dbCfg.TLS, + From: dbCfg.From, + }) + logger.Info("smtp_out: loaded from database") + } + srv.SetMailer(mlr) srv.SetFQDN(cfg.Server.FQDN) if cfg.Server.FQDN == "" { diff --git a/internal/api/server.go b/internal/api/server.go index 12217d5..86c1ac4 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -20,6 +20,7 @@ import ( ldapcfg "github.com/archivmail/internal/ldapconfig" "github.com/archivmail/internal/mailer" pop3store "github.com/archivmail/internal/pop3" + "github.com/archivmail/internal/smtpoutconfig" "github.com/archivmail/internal/smtpd" "github.com/archivmail/internal/storage" "github.com/archivmail/internal/tenantstore" @@ -86,6 +87,7 @@ type Server struct { mailer *mailer.Mailer tokenStore *tokenstore.Store fqdn string // from server.fqdn config (PROJ-28) + smtpOutStore *smtpoutconfig.Store } // SetSMTPDaemon wires the SMTP daemon into the API server after construction. @@ -137,6 +139,11 @@ func (s *Server) SetFQDN(fqdn string) { s.fqdn = fqdn } +// SetSMTPOutStore wires the SMTP-Out config store into the API server. +func (s *Server) SetSMTPOutStore(store *smtpoutconfig.Store) { + s.smtpOutStore = store +} + // New creates and wires up a new API server. func New( cfg config.APIConfig, @@ -209,6 +216,12 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /api/admin/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetRetention))) s.mux.HandleFunc("PUT /api/admin/tenant/{id}/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSetTenantRetention))) + // SMTP-Out Relay Konfiguration — superadmin only + s.mux.HandleFunc("GET /api/admin/smtp-out", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetSMTPOut))) + s.mux.HandleFunc("PUT /api/admin/smtp-out", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSaveSMTPOut))) + s.mux.HandleFunc("DELETE /api/admin/smtp-out", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleDeleteSMTPOut))) + s.mux.HandleFunc("POST /api/admin/smtp-out/test", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleTestSMTPOut))) + // PROJ-29: Quotas — superadmin only s.mux.HandleFunc("GET /api/admin/quotas", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetAllTenantUsage))) s.mux.HandleFunc("GET /api/admin/tenant/{id}/quota", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetTenantUsage))) diff --git a/internal/api/smtpout_handlers.go b/internal/api/smtpout_handlers.go new file mode 100644 index 0000000..5c6b47f --- /dev/null +++ b/internal/api/smtpout_handlers.go @@ -0,0 +1,118 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/archivmail/config" + "github.com/archivmail/internal/audit" + "github.com/archivmail/internal/smtpoutconfig" +) + +// handleGetSMTPOut returns the current SMTP-Out relay config (password masked). +// GET /api/admin/smtp-out — superadmin only. +func (s *Server) handleGetSMTPOut(w http.ResponseWriter, r *http.Request) { + cfg, err := s.smtpOutStore.Get(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if cfg == nil { + writeJSON(w, http.StatusOK, map[string]interface{}{"configured": false}) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"configured": true, "config": cfg}) +} + +// handleSaveSMTPOut upserts the SMTP-Out relay config and reloads the mailer. +// PUT /api/admin/smtp-out — superadmin only. +func (s *Server) handleSaveSMTPOut(w http.ResponseWriter, r *http.Request) { + var body smtpoutconfig.SMTPOutConfig + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + if body.Port == 0 { + body.Port = 587 + } + + sess := sessionFromCtx(r.Context()) + if err := s.smtpOutStore.Save(r.Context(), body, sess.Username); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + // Reload the mailer at runtime — no restart needed + if full, err := s.smtpOutStore.GetWithPassword(r.Context()); err == nil && full != nil { + s.mailer.Reload(config.SMTPOutConfig{ + Host: full.Host, + Port: full.Port, + User: full.User, + Password: full.Password, + TLS: full.TLS, + From: full.From, + }) + } + + if s.audlog != nil { + s.audlog.Log(audit.Entry{ + EventType: "smtp_out_config_saved", + Username: sess.Username, + IPAddress: s.remoteIP(r), + Success: true, + }) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) +} + +// handleDeleteSMTPOut removes the SMTP-Out config and disables the mailer. +// DELETE /api/admin/smtp-out — superadmin only. +func (s *Server) handleDeleteSMTPOut(w http.ResponseWriter, r *http.Request) { + if err := s.smtpOutStore.Delete(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + // Disable mailer + s.mailer.Reload(config.SMTPOutConfig{}) + + sess := sessionFromCtx(r.Context()) + if s.audlog != nil { + s.audlog.Log(audit.Entry{ + EventType: "smtp_out_config_deleted", + Username: sess.Username, + IPAddress: s.remoteIP(r), + Success: true, + }) + } + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) +} + +// handleTestSMTPOut sends a test email to the logged-in admin. +// POST /api/admin/smtp-out/test — superadmin only. +func (s *Server) handleTestSMTPOut(w http.ResponseWriter, r *http.Request) { + if !s.mailer.IsConfigured() { + writeError(w, http.StatusServiceUnavailable, "SMTP-Out nicht konfiguriert") + return + } + + sess := sessionFromCtx(r.Context()) + u, err := s.users.GetByUsername(sess.Username) + if err != nil || u.Email == "" { + writeError(w, http.StatusBadRequest, "Keine E-Mail-Adresse für diesen Account") + return + } + + html := `

Dies ist eine Test-E-Mail von archivmail.
Der SMTP-Relay ist korrekt konfiguriert.

` + txt := "Dies ist eine Test-E-Mail von archivmail. Der SMTP-Relay ist korrekt konfiguriert." + + if err := s.mailer.Send(u.Email, "archivmail – SMTP-Test", html, txt); err != nil { + writeError(w, http.StatusBadGateway, "Versand fehlgeschlagen: "+err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "sent_to": u.Email, + }) +} diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go index 44ea185..6a8f986 100644 --- a/internal/mailer/mailer.go +++ b/internal/mailer/mailer.go @@ -9,6 +9,7 @@ import ( "net" "net/smtp" "strings" + "sync" "time" "github.com/archivmail/config" @@ -16,6 +17,7 @@ import ( // Mailer sends transactional emails via the configured SMTP-Out relay. type Mailer struct { + mu sync.RWMutex cfg config.SMTPOutConfig } @@ -24,30 +26,43 @@ func New(cfg config.SMTPOutConfig) *Mailer { return &Mailer{cfg: cfg} } +// Reload replaces the runtime configuration without restarting the process. +func (m *Mailer) Reload(cfg config.SMTPOutConfig) { + m.mu.Lock() + m.cfg = cfg + m.mu.Unlock() +} + // IsConfigured returns true when the smtp_out config is usable (host + from set). func (m *Mailer) IsConfigured() bool { + m.mu.RLock() + defer m.mu.RUnlock() return m.cfg.Host != "" && m.cfg.From != "" } // Send sends an HTML + plaintext email to a single recipient. func (m *Mailer) Send(to, subject, htmlBody, textBody string) error { - if !m.IsConfigured() { + m.mu.RLock() + cfg := m.cfg + m.mu.RUnlock() + + if cfg.Host == "" || cfg.From == "" { return fmt.Errorf("mailer: smtp_out not configured") } - addr := fmt.Sprintf("%s:%d", m.cfg.Host, port(m.cfg.Port)) + addr := fmt.Sprintf("%s:%d", cfg.Host, port(cfg.Port)) - msg := buildMIME(m.cfg.From, to, subject, htmlBody, textBody) + msg := buildMIME(cfg.From, to, subject, htmlBody, textBody) var auth smtp.Auth - if m.cfg.User != "" { - auth = smtp.PlainAuth("", m.cfg.User, m.cfg.Password, m.cfg.Host) + if cfg.User != "" { + auth = smtp.PlainAuth("", cfg.User, cfg.Password, cfg.Host) } - if m.cfg.TLS { - return sendTLS(addr, m.cfg.Host, auth, m.cfg.From, to, msg) + if cfg.TLS { + return sendTLS(addr, cfg.Host, auth, cfg.From, to, msg) } - return sendSTARTTLS(addr, auth, m.cfg.From, to, msg) + return sendSTARTTLS(addr, auth, cfg.From, to, msg) } func port(p int) int { diff --git a/internal/smtpoutconfig/store.go b/internal/smtpoutconfig/store.go new file mode 100644 index 0000000..b890e61 --- /dev/null +++ b/internal/smtpoutconfig/store.go @@ -0,0 +1,205 @@ +// Package smtpoutconfig persists the outbound SMTP relay configuration in +// PostgreSQL. Exactly one record may exist (id=1). The SMTP password is +// encrypted with AES-256-GCM using the same scheme as internal/ldapconfig. +package smtpoutconfig + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "fmt" + "io" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// SMTPOutConfig is the persisted outbound SMTP relay configuration. +type SMTPOutConfig struct { + ID int64 `json:"id"` + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Password string `json:"password"` // masked as "••••••" in GET responses + TLS bool `json:"tls"` + From string `json:"from"` + UpdatedAt time.Time `json:"updated_at"` + UpdatedBy string `json:"updated_by"` +} + +const createTableSQL = ` +CREATE TABLE IF NOT EXISTS smtp_out_config ( + id BIGSERIAL PRIMARY KEY, + enabled BOOLEAN NOT NULL DEFAULT false, + host TEXT NOT NULL DEFAULT '', + port INT NOT NULL DEFAULT 587, + usr TEXT NOT NULL DEFAULT '', + password BYTEA, + tls BOOLEAN NOT NULL DEFAULT false, + from_addr TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_by TEXT NOT NULL DEFAULT '' +); +` + +// Store manages SMTP-Out configuration persistence. +type Store struct { + pool *pgxpool.Pool + encKey [32]byte +} + +// New connects to PostgreSQL, creates the table if needed, and returns a Store. +func New(dsn, secret string) (*Store, error) { + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, fmt.Errorf("smtpoutconfig: connect: %w", err) + } + + key := sha256.Sum256([]byte(secret + "-smtpout")) + s := &Store{pool: pool, encKey: key} + + if _, err := pool.Exec(ctx, createTableSQL); err != nil { + pool.Close() + return nil, fmt.Errorf("smtpoutconfig: init schema: %w", err) + } + return s, nil +} + +// Close releases the connection pool. +func (s *Store) Close() { s.pool.Close() } + +// Get returns the SMTP-Out configuration with the password masked. +// Returns nil, nil when no configuration has been saved yet. +func (s *Store) Get(ctx context.Context) (*SMTPOutConfig, error) { + cfg, err := s.query(ctx) + if err != nil { + return nil, err + } + if cfg == nil { + return nil, nil + } + if cfg.Password != "" { + cfg.Password = "••••••" + } + return cfg, nil +} + +// GetWithPassword returns the configuration including the decrypted password. +func (s *Store) GetWithPassword(ctx context.Context) (*SMTPOutConfig, error) { + return s.query(ctx) +} + +// Save upserts the SMTP-Out configuration (always uses id=1). +// When password is empty the existing stored password is preserved. +func (s *Store) Save(ctx context.Context, cfg SMTPOutConfig, updatedBy string) error { + var encPw []byte + var err error + + if cfg.Password != "" { + encPw, err = s.encrypt(cfg.Password) + if err != nil { + return fmt.Errorf("smtpoutconfig: encrypt password: %w", err) + } + } else { + // Preserve existing password + existing, qErr := s.query(ctx) + if qErr == nil && existing != nil && existing.Password != "" { + encPw, err = s.encrypt(existing.Password) + if err != nil { + return fmt.Errorf("smtpoutconfig: re-encrypt: %w", err) + } + } + } + + _, err = s.pool.Exec(ctx, ` + INSERT INTO smtp_out_config (id, enabled, host, port, usr, password, tls, from_addr, updated_at, updated_by) + VALUES (1, $1, $2, $3, $4, $5, $6, $7, NOW(), $8) + ON CONFLICT (id) DO UPDATE SET + enabled = EXCLUDED.enabled, + host = EXCLUDED.host, + port = EXCLUDED.port, + usr = EXCLUDED.usr, + password = CASE WHEN EXCLUDED.password IS NULL THEN smtp_out_config.password ELSE EXCLUDED.password END, + tls = EXCLUDED.tls, + from_addr = EXCLUDED.from_addr, + updated_at = NOW(), + updated_by = EXCLUDED.updated_by + `, cfg.Enabled, cfg.Host, cfg.Port, cfg.User, encPw, cfg.TLS, cfg.From, updatedBy) + if err != nil { + return fmt.Errorf("smtpoutconfig: upsert: %w", err) + } + return nil +} + +// Delete removes the SMTP-Out configuration. +func (s *Store) Delete(ctx context.Context) error { + _, err := s.pool.Exec(ctx, `DELETE FROM smtp_out_config WHERE id = 1`) + return err +} + +func (s *Store) query(ctx context.Context) (*SMTPOutConfig, error) { + row := s.pool.QueryRow(ctx, + `SELECT id, enabled, host, port, usr, password, tls, from_addr, updated_at, updated_by + FROM smtp_out_config WHERE id = 1`, + ) + var cfg SMTPOutConfig + var encPw []byte + err := row.Scan(&cfg.ID, &cfg.Enabled, &cfg.Host, &cfg.Port, &cfg.User, &encPw, &cfg.TLS, &cfg.From, &cfg.UpdatedAt, &cfg.UpdatedBy) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("smtpoutconfig: query: %w", err) + } + if len(encPw) > 0 { + pw, err := s.decrypt(encPw) + if err != nil { + return nil, fmt.Errorf("smtpoutconfig: decrypt: %w", err) + } + cfg.Password = pw + } + return &cfg, nil +} + +// ── AES-256-GCM helpers ─────────────────────────────────────────────────────── + +func (s *Store) encrypt(plain string) ([]byte, error) { + block, err := aes.NewCipher(s.encKey[:]) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + return gcm.Seal(nonce, nonce, []byte(plain), nil), nil +} + +func (s *Store) decrypt(data []byte) (string, error) { + block, err := aes.NewCipher(s.encKey[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + ns := gcm.NonceSize() + if len(data) < ns { + return "", fmt.Errorf("smtpoutconfig: ciphertext too short") + } + plain, err := gcm.Open(nil, data[:ns], data[ns:], nil) + if err != nil { + return "", fmt.Errorf("smtpoutconfig: decrypt: %w", err) + } + return string(plain), nil +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 07b99fd..d82c344 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -78,6 +78,7 @@ import { ModulesTab } from "@/components/admin/ModulesTab"; import { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab"; import { RetentionTab } from "@/components/admin/tabs/RetentionTab"; import { QuotaTab } from "@/components/admin/tabs/QuotaTab"; +import { SMTPOutTab } from "@/components/admin/tabs/SMTPOutTab"; import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs"; const AUDIT_PAGE_SIZE = 25; @@ -811,6 +812,7 @@ export default function AdminPage() { {isSuperAdmin && Mandanten} {isSuperAdmin && Retention} {isSuperAdmin && Quotas} + {isSuperAdmin && SMTP-Out} {isSuperAdmin && Module} @@ -1100,6 +1102,11 @@ export default function AdminPage() { )} + {isSuperAdmin && ( + + + + )} diff --git a/src/components/admin/tabs/SMTPOutTab.tsx b/src/components/admin/tabs/SMTPOutTab.tsx new file mode 100644 index 0000000..ccc0ed7 --- /dev/null +++ b/src/components/admin/tabs/SMTPOutTab.tsx @@ -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 { + 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 { + 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 { + 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(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 ( +
+ + +
+ SMTP-Out Relay + + {configured && form.enabled ? "Aktiv" : configured ? "Deaktiviert" : "Nicht konfiguriert"} + +
+
+ +

+ Ausgehender SMTP-Relay für Signup-Bestätigungen, Passwort-Reset und Einladungslinks. + Das Passwort wird verschlüsselt gespeichert. + {configured && updatedBy && ( + Zuletzt geändert von {updatedBy} + {updatedAt && ` am ${new Date(updatedAt).toLocaleDateString("de-DE")}`}. + + )} +

+ + {error && ( + + {error} + + )} + + {loading ? ( +

Lädt...

+ ) : ( +
+ {/* Enabled toggle */} +
+ set("enabled", v)} + /> + +
+ +
+
+ + set("host", e.target.value)} + required + /> +
+
+ + set("port", parseInt(e.target.value, 10) || 587)} + /> +
+
+ + set("user", e.target.value)} + autoComplete="username" + /> +
+
+ + set("from", e.target.value)} + required + /> +
+
+ + {/* Password */} +
+ {configured && ( +
+ setChangePassword(e.target.checked)} + /> + +
+ )} + {(!configured || changePassword) && ( +
+ + set("password", e.target.value)} + autoComplete="new-password" + /> +
+ )} +
+ + {/* TLS */} +
+ set("tls", v)} + /> + +
+ + {/* Test result */} + {testResult && ( + + {testResult.msg} + + )} + + {/* Actions */} +
+ + + {configured && ( + + )} +
+
+ )} +
+
+
+ ); +}