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