c1a9004720
- 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>
206 lines
6.0 KiB
Go
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
|
|
}
|