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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user