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
+23
View File
@@ -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 == "" {
+13
View File
@@ -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)))
+118
View File
@@ -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 := `<p>Dies ist eine Test-E-Mail von <strong>archivmail</strong>.<br>Der SMTP-Relay ist korrekt konfiguriert.</p>`
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,
})
}
+23 -8
View File
@@ -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 {
+205
View File
@@ -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
}
+7
View File
@@ -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 && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="retention">Retention</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="quotas">Quotas</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="smtp-out">SMTP-Out</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
</TabsList>
@@ -1100,6 +1102,11 @@ export default function AdminPage() {
</TabsContent>
)}
{isSuperAdmin && (
<TabsContent value="smtp-out">
<SMTPOutTab />
</TabsContent>
)}
<TabsContent value="modules" className="mt-4">
<ModulesTab />
</TabsContent>
+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>
);
}