feat: use postgres

This commit is contained in:
Ilia Denisov
2026-04-26 20:34:39 +02:00
committed by GitHub
parent 48b0056b49
commit fe829285a6
365 changed files with 29223 additions and 24049 deletions
+103 -102
View File
@@ -3,15 +3,18 @@
package config
import (
"crypto/tls"
"fmt"
"strings"
"time"
"galaxy/mail/internal/telemetry"
"galaxy/postgres"
"galaxy/redisconn"
)
const (
envPrefix = "MAIL"
shutdownTimeoutEnvVar = "MAIL_SHUTDOWN_TIMEOUT"
logLevelEnvVar = "MAIL_LOG_LEVEL"
@@ -20,15 +23,7 @@ const (
internalHTTPReadTimeoutEnvVar = "MAIL_INTERNAL_HTTP_READ_TIMEOUT"
internalHTTPIdleTimeoutEnvVar = "MAIL_INTERNAL_HTTP_IDLE_TIMEOUT"
redisAddrEnvVar = "MAIL_REDIS_ADDR"
redisUsernameEnvVar = "MAIL_REDIS_USERNAME"
redisPasswordEnvVar = "MAIL_REDIS_PASSWORD"
redisDBEnvVar = "MAIL_REDIS_DB"
redisTLSEnabledEnvVar = "MAIL_REDIS_TLS_ENABLED"
redisOperationTimeoutEnvVar = "MAIL_REDIS_OPERATION_TIMEOUT"
redisCommandStreamEnvVar = "MAIL_REDIS_COMMAND_STREAM"
redisAttemptScheduleEnvVar = "MAIL_REDIS_ATTEMPT_SCHEDULE_KEY"
redisDeadLetterPrefixEnvVar = "MAIL_REDIS_DEAD_LETTER_PREFIX"
redisCommandStreamEnvVar = "MAIL_REDIS_COMMAND_STREAM"
smtpModeEnvVar = "MAIL_SMTP_MODE"
smtpAddrEnvVar = "MAIL_SMTP_ADDR"
@@ -45,8 +40,10 @@ const (
streamBlockTimeoutEnvVar = "MAIL_STREAM_BLOCK_TIMEOUT"
operatorRequestTimeoutEnvVar = "MAIL_OPERATOR_REQUEST_TIMEOUT"
idempotencyTTLEnvVar = "MAIL_IDEMPOTENCY_TTL"
deliveryTTLEnvVar = "MAIL_DELIVERY_TTL"
attemptTTLEnvVar = "MAIL_ATTEMPT_TTL"
deliveryRetentionEnvVar = "MAIL_DELIVERY_RETENTION"
malformedCommandRetentionEnvVar = "MAIL_MALFORMED_COMMAND_RETENTION"
cleanupIntervalEnvVar = "MAIL_CLEANUP_INTERVAL"
otelServiceNameEnvVar = "OTEL_SERVICE_NAME"
otelTracesExporterEnvVar = "OTEL_TRACES_EXPORTER"
@@ -57,27 +54,24 @@ const (
otelStdoutTracesEnabledEnvVar = "MAIL_OTEL_STDOUT_TRACES_ENABLED"
otelStdoutMetricsEnabledEnvVar = "MAIL_OTEL_STDOUT_METRICS_ENABLED"
defaultShutdownTimeout = 5 * time.Second
defaultLogLevel = "info"
defaultInternalHTTPAddr = ":8080"
defaultReadHeaderTimeout = 2 * time.Second
defaultReadTimeout = 10 * time.Second
defaultIdleTimeout = time.Minute
defaultRedisDB = 0
defaultRedisOperationTimeout = 250 * time.Millisecond
defaultRedisCommandStream = "mail:delivery_commands"
defaultRedisAttemptScheduleKey = "mail:attempt_schedule"
defaultRedisDeadLetterPrefix = "mail:dead_letters:"
defaultSMTPMode = SMTPModeStub
defaultSMTPTimeout = 15 * time.Second
defaultTemplateDir = "templates"
defaultAttemptWorkerCount = 4
defaultStreamBlockTimeout = 2 * time.Second
defaultOperatorRequestTimeout = 5 * time.Second
defaultIdempotencyTTL = 7 * 24 * time.Hour
defaultDeliveryTTL = 30 * 24 * time.Hour
defaultAttemptTTL = 90 * 24 * time.Hour
defaultOTelServiceName = "galaxy-mail"
defaultShutdownTimeout = 5 * time.Second
defaultLogLevel = "info"
defaultInternalHTTPAddr = ":8080"
defaultReadHeaderTimeout = 2 * time.Second
defaultReadTimeout = 10 * time.Second
defaultIdleTimeout = time.Minute
defaultRedisCommandStream = "mail:delivery_commands"
defaultSMTPMode = SMTPModeStub
defaultSMTPTimeout = 15 * time.Second
defaultTemplateDir = "templates"
defaultAttemptWorkerCount = 4
defaultStreamBlockTimeout = 2 * time.Second
defaultOperatorRequestTimeout = 5 * time.Second
defaultIdempotencyTTL = 7 * 24 * time.Hour
defaultDeliveryRetention = 30 * 24 * time.Hour
defaultMalformedCommandRetention = 90 * 24 * time.Hour
defaultCleanupInterval = time.Hour
defaultOTelServiceName = "galaxy-mail"
)
const (
@@ -99,10 +93,15 @@ type Config struct {
// InternalHTTP configures the trusted internal HTTP listener.
InternalHTTP InternalHTTPConfig
// Redis configures the shared Redis client and Redis-owned keys used by the
// runnable service skeleton.
// Redis configures the shared Redis connection topology and the inbound
// `mail:delivery_commands` Stream key. Durable mail state lives in
// PostgreSQL after Stage 4 of `PG_PLAN.md`.
Redis RedisConfig
// Postgres configures the PostgreSQL-backed durable store consumed via
// `pkg/postgres`.
Postgres PostgresConfig
// SMTP configures the runtime mail provider mode and provider-specific
// connection details.
SMTP SMTPConfig
@@ -115,22 +114,20 @@ type Config struct {
AttemptWorkerConcurrency int
// StreamBlockTimeout stores the maximum Redis Streams blocking read window
// used by the future command consumer.
// used by the command consumer.
StreamBlockTimeout time.Duration
// OperatorRequestTimeout stores the future application-layer request budget
// for trusted operator handlers.
// OperatorRequestTimeout stores the application-layer request budget for
// trusted operator handlers.
OperatorRequestTimeout time.Duration
// IdempotencyTTL stores the configured retention for idempotency records.
// IdempotencyTTL stores the per-acceptance idempotency window the service
// layer applies to the durable idempotency_expires_at column on
// `deliveries`.
IdempotencyTTL time.Duration
// DeliveryTTL stores the configured retention for delivery records.
DeliveryTTL time.Duration
// AttemptTTL stores the configured retention for attempt and dead-letter
// records.
AttemptTTL time.Duration
// Retention stores the periodic SQL retention worker configuration.
Retention RetentionConfig
// Telemetry configures the process-wide OpenTelemetry runtime.
Telemetry TelemetryConfig
@@ -176,66 +173,67 @@ func (cfg InternalHTTPConfig) Validate() error {
}
}
// RedisConfig configures the shared Redis client used by the runnable process.
// RedisConfig configures the Mail Service Redis connection topology plus the
// inbound `mail:delivery_commands` Stream key. Per-call timeouts live in
// `Conn.OperationTimeout`.
type RedisConfig struct {
// Addr stores the Redis network address.
Addr string
// Username stores the optional Redis ACL username.
Username string
// Password stores the optional Redis ACL password.
Password string
// DB stores the Redis logical database index.
DB int
// TLSEnabled reports whether TLS must be used for Redis connections.
TLSEnabled bool
// OperationTimeout bounds one Redis round trip including the startup PING.
OperationTimeout time.Duration
// Conn carries the connection topology (master, replicas, password, db,
// per-call timeout). Loaded via redisconn.LoadFromEnv("MAIL").
Conn redisconn.Config
// CommandStream stores the configured Redis Streams key for async command
// intake.
CommandStream string
// AttemptScheduleKey stores the configured sorted-set key of scheduled
// attempts.
AttemptScheduleKey string
// DeadLetterPrefix stores the configured Redis key prefix of dead-letter
// entries.
DeadLetterPrefix string
}
// TLSConfig returns the conservative TLS configuration used by the Redis
// client when TLSEnabled is true.
func (cfg RedisConfig) TLSConfig() *tls.Config {
if !cfg.TLSEnabled {
return nil
}
return &tls.Config{MinVersion: tls.VersionTLS12}
}
// Validate reports whether cfg stores a usable Redis configuration.
func (cfg RedisConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.Addr) == "":
return fmt.Errorf("redis addr must not be empty")
case !isTCPAddr(cfg.Addr):
return fmt.Errorf("redis addr %q must use host:port form", cfg.Addr)
case cfg.DB < 0:
return fmt.Errorf("redis db must not be negative")
case cfg.OperationTimeout <= 0:
return fmt.Errorf("redis operation timeout must be positive")
case strings.TrimSpace(cfg.CommandStream) == "":
if err := cfg.Conn.Validate(); err != nil {
return err
}
if strings.TrimSpace(cfg.CommandStream) == "" {
return fmt.Errorf("redis command stream must not be empty")
case strings.TrimSpace(cfg.AttemptScheduleKey) == "":
return fmt.Errorf("redis attempt schedule key must not be empty")
case strings.TrimSpace(cfg.DeadLetterPrefix) == "":
return fmt.Errorf("redis dead-letter prefix must not be empty")
}
return nil
}
// PostgresConfig configures the PostgreSQL-backed durable store.
type PostgresConfig struct {
// Conn stores the primary plus replica DSN topology and pool tuning.
// Loaded via postgres.LoadFromEnv("MAIL").
Conn postgres.Config
}
// Validate reports whether cfg stores a usable PostgreSQL configuration.
func (cfg PostgresConfig) Validate() error {
return cfg.Conn.Validate()
}
// RetentionConfig stores the durable retention windows applied by the
// periodic SQL retention worker.
type RetentionConfig struct {
// DeliveryRetention bounds how long deliveries (and their cascaded
// attempts, dead letters, recipients, payloads) survive after creation.
DeliveryRetention time.Duration
// MalformedCommandRetention bounds how long malformed-command rows
// survive after their original recorded_at.
MalformedCommandRetention time.Duration
// CleanupInterval stores the wall-clock period between two retention
// passes.
CleanupInterval time.Duration
}
// Validate reports whether cfg stores a usable retention configuration.
func (cfg RetentionConfig) Validate() error {
switch {
case cfg.DeliveryRetention <= 0:
return fmt.Errorf("%s must be positive", deliveryRetentionEnvVar)
case cfg.MalformedCommandRetention <= 0:
return fmt.Errorf("%s must be positive", malformedCommandRetentionEnvVar)
case cfg.CleanupInterval <= 0:
return fmt.Errorf("%s must be positive", cleanupIntervalEnvVar)
default:
return nil
}
@@ -356,11 +354,11 @@ func DefaultConfig() Config {
IdleTimeout: defaultIdleTimeout,
},
Redis: RedisConfig{
DB: defaultRedisDB,
OperationTimeout: defaultRedisOperationTimeout,
CommandStream: defaultRedisCommandStream,
AttemptScheduleKey: defaultRedisAttemptScheduleKey,
DeadLetterPrefix: defaultRedisDeadLetterPrefix,
Conn: redisconn.DefaultConfig(),
CommandStream: defaultRedisCommandStream,
},
Postgres: PostgresConfig{
Conn: postgres.DefaultConfig(),
},
SMTP: SMTPConfig{
Mode: defaultSMTPMode,
@@ -373,8 +371,11 @@ func DefaultConfig() Config {
StreamBlockTimeout: defaultStreamBlockTimeout,
OperatorRequestTimeout: defaultOperatorRequestTimeout,
IdempotencyTTL: defaultIdempotencyTTL,
DeliveryTTL: defaultDeliveryTTL,
AttemptTTL: defaultAttemptTTL,
Retention: RetentionConfig{
DeliveryRetention: defaultDeliveryRetention,
MalformedCommandRetention: defaultMalformedCommandRetention,
CleanupInterval: defaultCleanupInterval,
},
Telemetry: TelemetryConfig{
ServiceName: defaultOTelServiceName,
TracesExporter: "none",
+85 -49
View File
@@ -7,8 +7,27 @@ import (
"github.com/stretchr/testify/require"
)
const (
testRedisMasterAddr = "MAIL_REDIS_MASTER_ADDR"
testRedisPassword = "MAIL_REDIS_PASSWORD"
testRedisDB = "MAIL_REDIS_DB"
testRedisOpTimeout = "MAIL_REDIS_OPERATION_TIMEOUT"
testRedisLegacyTLS = "MAIL_REDIS_TLS_ENABLED"
testRedisLegacyUser = "MAIL_REDIS_USERNAME"
testPostgresDSN = "MAIL_POSTGRES_PRIMARY_DSN"
testPostgresOpT = "MAIL_POSTGRES_OPERATION_TIMEOUT"
demoPostgresDSN = "postgres://mailservice:mailservice@localhost:5432/galaxy?search_path=mail&sslmode=disable"
)
func setMinimalConn(t *testing.T) {
t.Helper()
t.Setenv(testRedisMasterAddr, "127.0.0.1:6379")
t.Setenv(testRedisPassword, "secret")
t.Setenv(testPostgresDSN, demoPostgresDSN)
}
func TestLoadFromEnvUsesDefaults(t *testing.T) {
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
setMinimalConn(t)
cfg, err := LoadFromEnv()
require.NoError(t, err)
@@ -17,39 +36,34 @@ func TestLoadFromEnvUsesDefaults(t *testing.T) {
require.Equal(t, defaults.ShutdownTimeout, cfg.ShutdownTimeout)
require.Equal(t, defaults.Logging, cfg.Logging)
require.Equal(t, defaults.InternalHTTP, cfg.InternalHTTP)
require.Equal(t, "127.0.0.1:6379", cfg.Redis.Addr)
require.Equal(t, defaults.Redis.DB, cfg.Redis.DB)
require.Equal(t, defaults.Redis.OperationTimeout, cfg.Redis.OperationTimeout)
require.Equal(t, "127.0.0.1:6379", cfg.Redis.Conn.MasterAddr)
require.Equal(t, "secret", cfg.Redis.Conn.Password)
require.Equal(t, defaults.Redis.Conn.DB, cfg.Redis.Conn.DB)
require.Equal(t, defaults.Redis.Conn.OperationTimeout, cfg.Redis.Conn.OperationTimeout)
require.Equal(t, defaults.Redis.CommandStream, cfg.Redis.CommandStream)
require.Equal(t, defaults.Redis.AttemptScheduleKey, cfg.Redis.AttemptScheduleKey)
require.Equal(t, defaults.Redis.DeadLetterPrefix, cfg.Redis.DeadLetterPrefix)
require.Equal(t, demoPostgresDSN, cfg.Postgres.Conn.PrimaryDSN)
require.Equal(t, defaults.SMTP, cfg.SMTP)
require.Equal(t, defaults.Templates, cfg.Templates)
require.Equal(t, defaults.AttemptWorkerConcurrency, cfg.AttemptWorkerConcurrency)
require.Equal(t, defaults.StreamBlockTimeout, cfg.StreamBlockTimeout)
require.Equal(t, defaults.OperatorRequestTimeout, cfg.OperatorRequestTimeout)
require.Equal(t, defaults.IdempotencyTTL, cfg.IdempotencyTTL)
require.Equal(t, defaults.DeliveryTTL, cfg.DeliveryTTL)
require.Equal(t, defaults.AttemptTTL, cfg.AttemptTTL)
require.Equal(t, defaults.Retention, cfg.Retention)
require.Equal(t, defaults.Telemetry, cfg.Telemetry)
}
func TestLoadFromEnvAppliesOverrides(t *testing.T) {
setMinimalConn(t)
t.Setenv(shutdownTimeoutEnvVar, "9s")
t.Setenv(logLevelEnvVar, "debug")
t.Setenv(internalHTTPAddrEnvVar, "127.0.0.1:18080")
t.Setenv(internalHTTPReadHeaderTimeoutEnvVar, "3s")
t.Setenv(internalHTTPReadTimeoutEnvVar, "11s")
t.Setenv(internalHTTPIdleTimeoutEnvVar, "61s")
t.Setenv(redisAddrEnvVar, "127.0.0.1:6380")
t.Setenv(redisUsernameEnvVar, "alice")
t.Setenv(redisPasswordEnvVar, "secret")
t.Setenv(redisDBEnvVar, "3")
t.Setenv(redisTLSEnabledEnvVar, "true")
t.Setenv(redisOperationTimeoutEnvVar, "750ms")
t.Setenv(testRedisDB, "3")
t.Setenv(testRedisOpTimeout, "750ms")
t.Setenv(redisCommandStreamEnvVar, "mail:test_commands")
t.Setenv(redisAttemptScheduleEnvVar, "mail:test_schedule")
t.Setenv(redisDeadLetterPrefixEnvVar, "mail:test_dead_letters:")
t.Setenv(testPostgresOpT, "1500ms")
t.Setenv(smtpModeEnvVar, SMTPModeSMTP)
t.Setenv(smtpAddrEnvVar, "127.0.0.1:2525")
t.Setenv(smtpUsernameEnvVar, "mailer")
@@ -63,8 +77,9 @@ func TestLoadFromEnvAppliesOverrides(t *testing.T) {
t.Setenv(streamBlockTimeoutEnvVar, "5s")
t.Setenv(operatorRequestTimeoutEnvVar, "6s")
t.Setenv(idempotencyTTLEnvVar, "48h")
t.Setenv(deliveryTTLEnvVar, "96h")
t.Setenv(attemptTTLEnvVar, "240h")
t.Setenv(deliveryRetentionEnvVar, "96h")
t.Setenv(malformedCommandRetentionEnvVar, "240h")
t.Setenv(cleanupIntervalEnvVar, "30m")
t.Setenv(otelServiceNameEnvVar, "custom-mail")
t.Setenv(otelTracesExporterEnvVar, "otlp")
t.Setenv(otelMetricsExporterEnvVar, "otlp")
@@ -83,17 +98,13 @@ func TestLoadFromEnvAppliesOverrides(t *testing.T) {
ReadTimeout: 11 * time.Second,
IdleTimeout: 61 * time.Second,
}, cfg.InternalHTTP)
require.Equal(t, RedisConfig{
Addr: "127.0.0.1:6380",
Username: "alice",
Password: "secret",
DB: 3,
TLSEnabled: true,
OperationTimeout: 750 * time.Millisecond,
CommandStream: "mail:test_commands",
AttemptScheduleKey: "mail:test_schedule",
DeadLetterPrefix: "mail:test_dead_letters:",
}, cfg.Redis)
require.Equal(t, "127.0.0.1:6379", cfg.Redis.Conn.MasterAddr)
require.Equal(t, "secret", cfg.Redis.Conn.Password)
require.Equal(t, 3, cfg.Redis.Conn.DB)
require.Equal(t, 750*time.Millisecond, cfg.Redis.Conn.OperationTimeout)
require.Equal(t, "mail:test_commands", cfg.Redis.CommandStream)
require.Equal(t, demoPostgresDSN, cfg.Postgres.Conn.PrimaryDSN)
require.Equal(t, 1500*time.Millisecond, cfg.Postgres.Conn.OperationTimeout)
require.Equal(t, SMTPConfig{
Mode: SMTPModeSMTP,
Addr: "127.0.0.1:2525",
@@ -109,8 +120,9 @@ func TestLoadFromEnvAppliesOverrides(t *testing.T) {
require.Equal(t, 5*time.Second, cfg.StreamBlockTimeout)
require.Equal(t, 6*time.Second, cfg.OperatorRequestTimeout)
require.Equal(t, 48*time.Hour, cfg.IdempotencyTTL)
require.Equal(t, 96*time.Hour, cfg.DeliveryTTL)
require.Equal(t, 240*time.Hour, cfg.AttemptTTL)
require.Equal(t, 96*time.Hour, cfg.Retention.DeliveryRetention)
require.Equal(t, 240*time.Hour, cfg.Retention.MalformedCommandRetention)
require.Equal(t, 30*time.Minute, cfg.Retention.CleanupInterval)
require.Equal(t, TelemetryConfig{
ServiceName: "custom-mail",
TracesExporter: "otlp",
@@ -130,9 +142,8 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
}{
{name: "invalid duration", envName: shutdownTimeoutEnvVar, envVal: "later"},
{name: "invalid log level", envName: logLevelEnvVar, envVal: "verbose"},
{name: "invalid redis db", envName: redisDBEnvVar, envVal: "db-three"},
{name: "invalid redis tls", envName: redisTLSEnabledEnvVar, envVal: "sometimes"},
{name: "invalid redis timeout", envName: redisOperationTimeoutEnvVar, envVal: "never"},
{name: "invalid redis db", envName: testRedisDB, envVal: "db-three"},
{name: "invalid redis timeout", envName: testRedisOpTimeout, envVal: "never"},
{name: "invalid smtp mode", envName: smtpModeEnvVar, envVal: "ses"},
{name: "invalid smtp timeout", envName: smtpTimeoutEnvVar, envVal: "fast"},
{name: "invalid smtp insecure skip verify", envName: smtpInsecureSkipVerifyEnvVar, envVal: "sometimes"},
@@ -145,10 +156,9 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
setMinimalConn(t)
t.Setenv(tt.envName, tt.envVal)
if tt.envName == smtpTimeoutEnvVar {
t.Setenv(smtpModeEnvVar, SMTPModeSMTP)
@@ -162,25 +172,45 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
}
}
func TestLoadFromEnvRejectsMissingRequiredRedisAddr(t *testing.T) {
func TestLoadFromEnvRejectsMissingRedisMasterAddr(t *testing.T) {
t.Setenv(testRedisPassword, "secret")
t.Setenv(testPostgresDSN, demoPostgresDSN)
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), "redis addr")
require.Contains(t, err.Error(), "MAIL_REDIS_MASTER_ADDR")
}
func TestLoadFromEnvRejectsInvalidRedisAddr(t *testing.T) {
t.Setenv(redisAddrEnvVar, "127.0.0.1")
func TestLoadFromEnvRejectsMissingPostgresDSN(t *testing.T) {
t.Setenv(testRedisMasterAddr, "127.0.0.1:6379")
t.Setenv(testRedisPassword, "secret")
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), "redis addr")
require.Contains(t, err.Error(), "MAIL_POSTGRES_PRIMARY_DSN")
}
func TestLoadFromEnvRejectsLegacyRedisVars(t *testing.T) {
tests := map[string]string{
"tls": testRedisLegacyTLS,
"username": testRedisLegacyUser,
}
for name, envVar := range tests {
t.Run(name, func(t *testing.T) {
setMinimalConn(t)
t.Setenv(envVar, "anything")
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), envVar)
})
}
}
func TestLoadFromEnvRejectsInvalidSMTPConfiguration(t *testing.T) {
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
t.Setenv(smtpModeEnvVar, SMTPModeSMTP)
t.Run("missing addr", func(t *testing.T) {
setMinimalConn(t)
t.Setenv(smtpModeEnvVar, SMTPModeSMTP)
t.Setenv(smtpFromEmailEnvVar, "noreply@example.com")
_, err := LoadFromEnv()
@@ -189,6 +219,8 @@ func TestLoadFromEnvRejectsInvalidSMTPConfiguration(t *testing.T) {
})
t.Run("missing from email", func(t *testing.T) {
setMinimalConn(t)
t.Setenv(smtpModeEnvVar, SMTPModeSMTP)
t.Setenv(smtpAddrEnvVar, "127.0.0.1:2525")
_, err := LoadFromEnv()
@@ -197,6 +229,8 @@ func TestLoadFromEnvRejectsInvalidSMTPConfiguration(t *testing.T) {
})
t.Run("username without password", func(t *testing.T) {
setMinimalConn(t)
t.Setenv(smtpModeEnvVar, SMTPModeSMTP)
t.Setenv(smtpAddrEnvVar, "127.0.0.1:2525")
t.Setenv(smtpFromEmailEnvVar, "noreply@example.com")
t.Setenv(smtpUsernameEnvVar, "mailer")
@@ -207,6 +241,8 @@ func TestLoadFromEnvRejectsInvalidSMTPConfiguration(t *testing.T) {
})
t.Run("password without username", func(t *testing.T) {
setMinimalConn(t)
t.Setenv(smtpModeEnvVar, SMTPModeSMTP)
t.Setenv(smtpAddrEnvVar, "127.0.0.1:2525")
t.Setenv(smtpFromEmailEnvVar, "noreply@example.com")
t.Setenv(smtpPasswordEnvVar, "secret")
@@ -227,21 +263,21 @@ func TestLoadFromEnvRejectsNonPositiveDurationsAndCounts(t *testing.T) {
{name: "read header timeout", envName: internalHTTPReadHeaderTimeoutEnvVar, envVal: "0s"},
{name: "read timeout", envName: internalHTTPReadTimeoutEnvVar, envVal: "0s"},
{name: "idle timeout", envName: internalHTTPIdleTimeoutEnvVar, envVal: "0s"},
{name: "redis operation timeout", envName: redisOperationTimeoutEnvVar, envVal: "0s"},
{name: "redis operation timeout", envName: testRedisOpTimeout, envVal: "0s"},
{name: "smtp timeout", envName: smtpTimeoutEnvVar, envVal: "0s"},
{name: "attempt worker concurrency", envName: attemptWorkerConcurrencyEnvVar, envVal: "0"},
{name: "stream block timeout", envName: streamBlockTimeoutEnvVar, envVal: "0s"},
{name: "operator request timeout", envName: operatorRequestTimeoutEnvVar, envVal: "0s"},
{name: "idempotency ttl", envName: idempotencyTTLEnvVar, envVal: "0s"},
{name: "delivery ttl", envName: deliveryTTLEnvVar, envVal: "0s"},
{name: "attempt ttl", envName: attemptTTLEnvVar, envVal: "0s"},
{name: "delivery retention", envName: deliveryRetentionEnvVar, envVal: "0s"},
{name: "malformed command retention", envName: malformedCommandRetentionEnvVar, envVal: "0s"},
{name: "cleanup interval", envName: cleanupIntervalEnvVar, envVal: "0s"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
setMinimalConn(t)
t.Setenv(tt.envName, tt.envVal)
if tt.envName == smtpTimeoutEnvVar {
t.Setenv(smtpModeEnvVar, SMTPModeSMTP)
+22 -17
View File
@@ -6,10 +6,17 @@ import (
"strconv"
"strings"
"time"
"galaxy/postgres"
"galaxy/redisconn"
)
// LoadFromEnv builds Config from environment variables and validates the
// resulting configuration.
// resulting configuration. Connection topology for Redis and PostgreSQL is
// delegated to the shared `pkg/redisconn` and `pkg/postgres` LoadFromEnv
// helpers — the Redis loader hard-fails on the deprecated
// `MAIL_REDIS_TLS_ENABLED` / `MAIL_REDIS_USERNAME` env vars; the Postgres
// loader requires a primary DSN.
func LoadFromEnv() (Config, error) {
cfg := DefaultConfig()
@@ -36,24 +43,18 @@ func LoadFromEnv() (Config, error) {
return Config{}, err
}
cfg.Redis.Addr = stringEnv(redisAddrEnvVar, cfg.Redis.Addr)
cfg.Redis.Username = stringEnv(redisUsernameEnvVar, cfg.Redis.Username)
cfg.Redis.Password = stringEnv(redisPasswordEnvVar, cfg.Redis.Password)
cfg.Redis.DB, err = intEnv(redisDBEnvVar, cfg.Redis.DB)
if err != nil {
return Config{}, err
}
cfg.Redis.TLSEnabled, err = boolEnv(redisTLSEnabledEnvVar, cfg.Redis.TLSEnabled)
if err != nil {
return Config{}, err
}
cfg.Redis.OperationTimeout, err = durationEnv(redisOperationTimeoutEnvVar, cfg.Redis.OperationTimeout)
redisConn, err := redisconn.LoadFromEnv(envPrefix)
if err != nil {
return Config{}, err
}
cfg.Redis.Conn = redisConn
cfg.Redis.CommandStream = stringEnv(redisCommandStreamEnvVar, cfg.Redis.CommandStream)
cfg.Redis.AttemptScheduleKey = stringEnv(redisAttemptScheduleEnvVar, cfg.Redis.AttemptScheduleKey)
cfg.Redis.DeadLetterPrefix = stringEnv(redisDeadLetterPrefixEnvVar, cfg.Redis.DeadLetterPrefix)
pgConn, err := postgres.LoadFromEnv(envPrefix)
if err != nil {
return Config{}, err
}
cfg.Postgres.Conn = pgConn
cfg.SMTP.Mode = stringEnv(smtpModeEnvVar, cfg.SMTP.Mode)
cfg.SMTP.Addr = stringEnv(smtpAddrEnvVar, cfg.SMTP.Addr)
@@ -88,11 +89,15 @@ func LoadFromEnv() (Config, error) {
if err != nil {
return Config{}, err
}
cfg.DeliveryTTL, err = durationEnv(deliveryTTLEnvVar, cfg.DeliveryTTL)
cfg.Retention.DeliveryRetention, err = durationEnv(deliveryRetentionEnvVar, cfg.Retention.DeliveryRetention)
if err != nil {
return Config{}, err
}
cfg.AttemptTTL, err = durationEnv(attemptTTLEnvVar, cfg.AttemptTTL)
cfg.Retention.MalformedCommandRetention, err = durationEnv(malformedCommandRetentionEnvVar, cfg.Retention.MalformedCommandRetention)
if err != nil {
return Config{}, err
}
cfg.Retention.CleanupInterval, err = durationEnv(cleanupIntervalEnvVar, cfg.Retention.CleanupInterval)
if err != nil {
return Config{}, err
}
+6 -4
View File
@@ -22,10 +22,6 @@ func (cfg Config) Validate() error {
return fmt.Errorf("%s must be positive", operatorRequestTimeoutEnvVar)
case cfg.IdempotencyTTL <= 0:
return fmt.Errorf("%s must be positive", idempotencyTTLEnvVar)
case cfg.DeliveryTTL <= 0:
return fmt.Errorf("%s must be positive", deliveryTTLEnvVar)
case cfg.AttemptTTL <= 0:
return fmt.Errorf("%s must be positive", attemptTTLEnvVar)
}
if err := cfg.InternalHTTP.Validate(); err != nil {
@@ -34,6 +30,12 @@ func (cfg Config) Validate() error {
if err := cfg.Redis.Validate(); err != nil {
return err
}
if err := cfg.Postgres.Validate(); err != nil {
return fmt.Errorf("postgres: %w", err)
}
if err := cfg.Retention.Validate(); err != nil {
return err
}
if err := cfg.SMTP.Validate(); err != nil {
return err
}