Files
galaxy-game/mail/internal/config/config.go
T
2026-04-26 20:34:39 +02:00

404 lines
13 KiB
Go

// Package config loads the Mail Service process configuration from environment
// variables.
package config
import (
"fmt"
"strings"
"time"
"galaxy/mail/internal/telemetry"
"galaxy/postgres"
"galaxy/redisconn"
)
const (
envPrefix = "MAIL"
shutdownTimeoutEnvVar = "MAIL_SHUTDOWN_TIMEOUT"
logLevelEnvVar = "MAIL_LOG_LEVEL"
internalHTTPAddrEnvVar = "MAIL_INTERNAL_HTTP_ADDR"
internalHTTPReadHeaderTimeoutEnvVar = "MAIL_INTERNAL_HTTP_READ_HEADER_TIMEOUT"
internalHTTPReadTimeoutEnvVar = "MAIL_INTERNAL_HTTP_READ_TIMEOUT"
internalHTTPIdleTimeoutEnvVar = "MAIL_INTERNAL_HTTP_IDLE_TIMEOUT"
redisCommandStreamEnvVar = "MAIL_REDIS_COMMAND_STREAM"
smtpModeEnvVar = "MAIL_SMTP_MODE"
smtpAddrEnvVar = "MAIL_SMTP_ADDR"
smtpUsernameEnvVar = "MAIL_SMTP_USERNAME"
smtpPasswordEnvVar = "MAIL_SMTP_PASSWORD"
smtpFromEmailEnvVar = "MAIL_SMTP_FROM_EMAIL"
smtpFromNameEnvVar = "MAIL_SMTP_FROM_NAME"
smtpTimeoutEnvVar = "MAIL_SMTP_TIMEOUT"
smtpInsecureSkipVerifyEnvVar = "MAIL_SMTP_INSECURE_SKIP_VERIFY"
templateDirEnvVar = "MAIL_TEMPLATE_DIR"
attemptWorkerConcurrencyEnvVar = "MAIL_ATTEMPT_WORKER_CONCURRENCY"
streamBlockTimeoutEnvVar = "MAIL_STREAM_BLOCK_TIMEOUT"
operatorRequestTimeoutEnvVar = "MAIL_OPERATOR_REQUEST_TIMEOUT"
idempotencyTTLEnvVar = "MAIL_IDEMPOTENCY_TTL"
deliveryRetentionEnvVar = "MAIL_DELIVERY_RETENTION"
malformedCommandRetentionEnvVar = "MAIL_MALFORMED_COMMAND_RETENTION"
cleanupIntervalEnvVar = "MAIL_CLEANUP_INTERVAL"
otelServiceNameEnvVar = "OTEL_SERVICE_NAME"
otelTracesExporterEnvVar = "OTEL_TRACES_EXPORTER"
otelMetricsExporterEnvVar = "OTEL_METRICS_EXPORTER"
otelExporterOTLPProtocolEnvVar = "OTEL_EXPORTER_OTLP_PROTOCOL"
otelExporterOTLPTracesProtocolEnvVar = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"
otelExporterOTLPMetricsProtocolEnvVar = "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL"
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
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 (
// SMTPModeStub configures the deterministic in-process stub provider.
SMTPModeStub = "stub"
// SMTPModeSMTP configures the real SMTP-backed provider adapter.
SMTPModeSMTP = "smtp"
)
// Config stores the full Mail Service process configuration.
type Config struct {
// ShutdownTimeout bounds graceful shutdown of every long-lived component.
ShutdownTimeout time.Duration
// Logging configures the process-wide structured logger.
Logging LoggingConfig
// InternalHTTP configures the trusted internal HTTP listener.
InternalHTTP InternalHTTPConfig
// 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
// Templates configures the filesystem-backed template catalog root.
Templates TemplateConfig
// AttemptWorkerConcurrency stores how many idle attempt workers the process
// starts.
AttemptWorkerConcurrency int
// StreamBlockTimeout stores the maximum Redis Streams blocking read window
// used by the command consumer.
StreamBlockTimeout time.Duration
// OperatorRequestTimeout stores the application-layer request budget for
// trusted operator handlers.
OperatorRequestTimeout time.Duration
// IdempotencyTTL stores the per-acceptance idempotency window the service
// layer applies to the durable idempotency_expires_at column on
// `deliveries`.
IdempotencyTTL time.Duration
// Retention stores the periodic SQL retention worker configuration.
Retention RetentionConfig
// Telemetry configures the process-wide OpenTelemetry runtime.
Telemetry TelemetryConfig
}
// LoggingConfig configures the process-wide structured logger.
type LoggingConfig struct {
// Level stores the process log level accepted by log/slog.
Level string
}
// InternalHTTPConfig configures the trusted internal HTTP listener.
type InternalHTTPConfig struct {
// Addr stores the TCP listen address.
Addr string
// ReadHeaderTimeout bounds request-header reading.
ReadHeaderTimeout time.Duration
// ReadTimeout bounds reading one request.
ReadTimeout time.Duration
// IdleTimeout bounds how long keep-alive connections stay open.
IdleTimeout time.Duration
}
// Validate reports whether cfg stores a usable internal HTTP listener
// configuration.
func (cfg InternalHTTPConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.Addr) == "":
return fmt.Errorf("internal HTTP addr must not be empty")
case !isTCPAddr(cfg.Addr):
return fmt.Errorf("internal HTTP addr %q must use host:port form", cfg.Addr)
case cfg.ReadHeaderTimeout <= 0:
return fmt.Errorf("internal HTTP read header timeout must be positive")
case cfg.ReadTimeout <= 0:
return fmt.Errorf("internal HTTP read timeout must be positive")
case cfg.IdleTimeout <= 0:
return fmt.Errorf("internal HTTP idle timeout must be positive")
default:
return nil
}
}
// 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 {
// 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
}
// Validate reports whether cfg stores a usable Redis configuration.
func (cfg RedisConfig) Validate() error {
if err := cfg.Conn.Validate(); err != nil {
return err
}
if strings.TrimSpace(cfg.CommandStream) == "" {
return fmt.Errorf("redis command stream 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
}
}
// SMTPConfig configures the selected provider adapter.
type SMTPConfig struct {
// Mode selects the runtime provider implementation. Supported values are
// `stub` and `smtp`.
Mode string
// Addr stores the SMTP server address when Mode is `smtp`.
Addr string
// Username stores the optional SMTP authentication username.
Username string
// Password stores the optional SMTP authentication password.
Password string
// FromEmail stores the RFC 5322 single mailbox used as the envelope sender
// when Mode is `smtp`.
FromEmail string
// FromName stores the optional display name attached to FromEmail.
FromName string
// Timeout stores the maximum SMTP dial-and-send window.
Timeout time.Duration
// InsecureSkipVerify disables SMTP certificate verification. This is meant
// only for local development and black-box tests with self-signed capture
// servers.
InsecureSkipVerify bool
}
// Validate reports whether cfg stores a usable provider configuration.
func (cfg SMTPConfig) Validate() error {
switch cfg.Mode {
case SMTPModeStub:
return nil
case SMTPModeSMTP:
switch {
case strings.TrimSpace(cfg.Addr) == "":
return fmt.Errorf("smtp addr must not be empty")
case !isTCPAddr(cfg.Addr):
return fmt.Errorf("smtp addr %q must use host:port form", cfg.Addr)
case cfg.Timeout <= 0:
return fmt.Errorf("smtp timeout must be positive")
case strings.TrimSpace(cfg.Username) == "" && strings.TrimSpace(cfg.Password) != "":
return fmt.Errorf("smtp username and password must be configured together")
case strings.TrimSpace(cfg.Username) != "" && strings.TrimSpace(cfg.Password) == "":
return fmt.Errorf("smtp username and password must be configured together")
default:
return validateMailbox("smtp from email", cfg.FromEmail)
}
default:
return fmt.Errorf("smtp mode %q is unsupported", cfg.Mode)
}
}
// TemplateConfig configures the filesystem-backed template catalog.
type TemplateConfig struct {
// Dir stores the root directory of the template catalog.
Dir string
}
// TelemetryConfig configures the Mail Service OpenTelemetry runtime.
type TelemetryConfig struct {
// ServiceName overrides the default OpenTelemetry service name.
ServiceName string
// TracesExporter selects the external traces exporter. Supported values are
// `none` and `otlp`.
TracesExporter string
// MetricsExporter selects the external metrics exporter. Supported values
// are `none` and `otlp`.
MetricsExporter string
// TracesProtocol selects the OTLP traces protocol when TracesExporter is
// `otlp`.
TracesProtocol string
// MetricsProtocol selects the OTLP metrics protocol when MetricsExporter is
// `otlp`.
MetricsProtocol string
// StdoutTracesEnabled enables the additional stdout trace exporter used for
// local development and debugging.
StdoutTracesEnabled bool
// StdoutMetricsEnabled enables the additional stdout metric exporter used
// for local development and debugging.
StdoutMetricsEnabled bool
}
// Validate reports whether cfg stores a usable template catalog root.
func (cfg TemplateConfig) Validate() error {
if strings.TrimSpace(cfg.Dir) == "" {
return fmt.Errorf("template dir must not be empty")
}
return nil
}
// DefaultConfig returns the default Mail Service process configuration.
func DefaultConfig() Config {
return Config{
ShutdownTimeout: defaultShutdownTimeout,
Logging: LoggingConfig{
Level: defaultLogLevel,
},
InternalHTTP: InternalHTTPConfig{
Addr: defaultInternalHTTPAddr,
ReadHeaderTimeout: defaultReadHeaderTimeout,
ReadTimeout: defaultReadTimeout,
IdleTimeout: defaultIdleTimeout,
},
Redis: RedisConfig{
Conn: redisconn.DefaultConfig(),
CommandStream: defaultRedisCommandStream,
},
Postgres: PostgresConfig{
Conn: postgres.DefaultConfig(),
},
SMTP: SMTPConfig{
Mode: defaultSMTPMode,
Timeout: defaultSMTPTimeout,
},
Templates: TemplateConfig{
Dir: defaultTemplateDir,
},
AttemptWorkerConcurrency: defaultAttemptWorkerCount,
StreamBlockTimeout: defaultStreamBlockTimeout,
OperatorRequestTimeout: defaultOperatorRequestTimeout,
IdempotencyTTL: defaultIdempotencyTTL,
Retention: RetentionConfig{
DeliveryRetention: defaultDeliveryRetention,
MalformedCommandRetention: defaultMalformedCommandRetention,
CleanupInterval: defaultCleanupInterval,
},
Telemetry: TelemetryConfig{
ServiceName: defaultOTelServiceName,
TracesExporter: "none",
MetricsExporter: "none",
TracesProtocol: "",
MetricsProtocol: "",
StdoutTracesEnabled: false,
StdoutMetricsEnabled: false,
},
}
}
// Validate reports whether cfg contains a supported OpenTelemetry
// configuration.
func (cfg TelemetryConfig) Validate() error {
return telemetry.ProcessConfig{
ServiceName: cfg.ServiceName,
TracesExporter: cfg.TracesExporter,
MetricsExporter: cfg.MetricsExporter,
TracesProtocol: cfg.TracesProtocol,
MetricsProtocol: cfg.MetricsProtocol,
StdoutTracesEnabled: cfg.StdoutTracesEnabled,
StdoutMetricsEnabled: cfg.StdoutMetricsEnabled,
}.Validate()
}