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:
@@ -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