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:
@@ -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 == "" {
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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