// Package config loads the Mail Service process configuration from environment // variables. package config import ( "crypto/tls" "fmt" "strings" "time" "galaxy/mail/internal/telemetry" ) const ( 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" 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" 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" deliveryTTLEnvVar = "MAIL_DELIVERY_TTL" attemptTTLEnvVar = "MAIL_ATTEMPT_TTL" 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 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" ) 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 client and Redis-owned keys used by the // runnable service skeleton. Redis RedisConfig // 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 future command consumer. StreamBlockTimeout time.Duration // OperatorRequestTimeout stores the future application-layer request budget // for trusted operator handlers. OperatorRequestTimeout time.Duration // IdempotencyTTL stores the configured retention for idempotency records. 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 // 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 shared Redis client used by the runnable process. 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 // 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) == "": 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") 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{ DB: defaultRedisDB, OperationTimeout: defaultRedisOperationTimeout, CommandStream: defaultRedisCommandStream, AttemptScheduleKey: defaultRedisAttemptScheduleKey, DeadLetterPrefix: defaultRedisDeadLetterPrefix, }, SMTP: SMTPConfig{ Mode: defaultSMTPMode, Timeout: defaultSMTPTimeout, }, Templates: TemplateConfig{ Dir: defaultTemplateDir, }, AttemptWorkerConcurrency: defaultAttemptWorkerCount, StreamBlockTimeout: defaultStreamBlockTimeout, OperatorRequestTimeout: defaultOperatorRequestTimeout, IdempotencyTTL: defaultIdempotencyTTL, DeliveryTTL: defaultDeliveryTTL, AttemptTTL: defaultAttemptTTL, 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() }