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...
+ ) : (
+
+ )}
+
+
+
+ );
+}