Files
sysops c1a9004720 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>
2026-03-31 22:36:57 +02:00

206 lines
6.0 KiB
Go

// 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
}