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"
|
ldapcfg "github.com/archivmail/internal/ldapconfig"
|
||||||
"github.com/archivmail/internal/mailer"
|
"github.com/archivmail/internal/mailer"
|
||||||
pop3store "github.com/archivmail/internal/pop3"
|
pop3store "github.com/archivmail/internal/pop3"
|
||||||
|
"github.com/archivmail/internal/smtpoutconfig"
|
||||||
"github.com/archivmail/internal/smtpd"
|
"github.com/archivmail/internal/smtpd"
|
||||||
"github.com/archivmail/internal/storage"
|
"github.com/archivmail/internal/storage"
|
||||||
tenantstore "github.com/archivmail/internal/tenantstore"
|
tenantstore "github.com/archivmail/internal/tenantstore"
|
||||||
@@ -185,6 +186,28 @@ func main() {
|
|||||||
|
|
||||||
// PROJ-28: Self-Service Onboarding — mailer + token store + FQDN
|
// PROJ-28: Self-Service Onboarding — mailer + token store + FQDN
|
||||||
mlr := mailer.New(cfg.SMTPOut)
|
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.SetMailer(mlr)
|
||||||
srv.SetFQDN(cfg.Server.FQDN)
|
srv.SetFQDN(cfg.Server.FQDN)
|
||||||
if cfg.Server.FQDN == "" {
|
if cfg.Server.FQDN == "" {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
ldapcfg "github.com/archivmail/internal/ldapconfig"
|
ldapcfg "github.com/archivmail/internal/ldapconfig"
|
||||||
"github.com/archivmail/internal/mailer"
|
"github.com/archivmail/internal/mailer"
|
||||||
pop3store "github.com/archivmail/internal/pop3"
|
pop3store "github.com/archivmail/internal/pop3"
|
||||||
|
"github.com/archivmail/internal/smtpoutconfig"
|
||||||
"github.com/archivmail/internal/smtpd"
|
"github.com/archivmail/internal/smtpd"
|
||||||
"github.com/archivmail/internal/storage"
|
"github.com/archivmail/internal/storage"
|
||||||
"github.com/archivmail/internal/tenantstore"
|
"github.com/archivmail/internal/tenantstore"
|
||||||
@@ -86,6 +87,7 @@ type Server struct {
|
|||||||
mailer *mailer.Mailer
|
mailer *mailer.Mailer
|
||||||
tokenStore *tokenstore.Store
|
tokenStore *tokenstore.Store
|
||||||
fqdn string // from server.fqdn config (PROJ-28)
|
fqdn string // from server.fqdn config (PROJ-28)
|
||||||
|
smtpOutStore *smtpoutconfig.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSMTPDaemon wires the SMTP daemon into the API server after construction.
|
// SetSMTPDaemon wires the SMTP daemon into the API server after construction.
|
||||||
@@ -137,6 +139,11 @@ func (s *Server) SetFQDN(fqdn string) {
|
|||||||
s.fqdn = fqdn
|
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.
|
// New creates and wires up a new API server.
|
||||||
func New(
|
func New(
|
||||||
cfg config.APIConfig,
|
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("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)))
|
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
|
// 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/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)))
|
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"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/archivmail/config"
|
"github.com/archivmail/config"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
|
|
||||||
// Mailer sends transactional emails via the configured SMTP-Out relay.
|
// Mailer sends transactional emails via the configured SMTP-Out relay.
|
||||||
type Mailer struct {
|
type Mailer struct {
|
||||||
|
mu sync.RWMutex
|
||||||
cfg config.SMTPOutConfig
|
cfg config.SMTPOutConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,30 +26,43 @@ func New(cfg config.SMTPOutConfig) *Mailer {
|
|||||||
return &Mailer{cfg: cfg}
|
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).
|
// IsConfigured returns true when the smtp_out config is usable (host + from set).
|
||||||
func (m *Mailer) IsConfigured() bool {
|
func (m *Mailer) IsConfigured() bool {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
return m.cfg.Host != "" && m.cfg.From != ""
|
return m.cfg.Host != "" && m.cfg.From != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send sends an HTML + plaintext email to a single recipient.
|
// Send sends an HTML + plaintext email to a single recipient.
|
||||||
func (m *Mailer) Send(to, subject, htmlBody, textBody string) error {
|
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")
|
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
|
var auth smtp.Auth
|
||||||
if m.cfg.User != "" {
|
if cfg.User != "" {
|
||||||
auth = smtp.PlainAuth("", m.cfg.User, m.cfg.Password, m.cfg.Host)
|
auth = smtp.PlainAuth("", cfg.User, cfg.Password, cfg.Host)
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.cfg.TLS {
|
if cfg.TLS {
|
||||||
return sendTLS(addr, m.cfg.Host, auth, m.cfg.From, to, msg)
|
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 {
|
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 { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab";
|
||||||
import { RetentionTab } from "@/components/admin/tabs/RetentionTab";
|
import { RetentionTab } from "@/components/admin/tabs/RetentionTab";
|
||||||
import { QuotaTab } from "@/components/admin/tabs/QuotaTab";
|
import { QuotaTab } from "@/components/admin/tabs/QuotaTab";
|
||||||
|
import { SMTPOutTab } from "@/components/admin/tabs/SMTPOutTab";
|
||||||
import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs";
|
import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs";
|
||||||
|
|
||||||
const AUDIT_PAGE_SIZE = 25;
|
const AUDIT_PAGE_SIZE = 25;
|
||||||
@@ -811,6 +812,7 @@ export default function AdminPage() {
|
|||||||
{isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
|
||||||
{isSuperAdmin && <TabsTrigger value="retention">Retention</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="retention">Retention</TabsTrigger>}
|
||||||
{isSuperAdmin && <TabsTrigger value="quotas">Quotas</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="quotas">Quotas</TabsTrigger>}
|
||||||
|
{isSuperAdmin && <TabsTrigger value="smtp-out">SMTP-Out</TabsTrigger>}
|
||||||
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -1100,6 +1102,11 @@ export default function AdminPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<TabsContent value="smtp-out">
|
||||||
|
<SMTPOutTab />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
<TabsContent value="modules" className="mt-4">
|
<TabsContent value="modules" className="mt-4">
|
||||||
<ModulesTab />
|
<ModulesTab />
|
||||||
</TabsContent>
|
</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