// Package config loads the Notification Service process configuration from // environment variables. package config import ( "crypto/tls" "fmt" "log/slog" "net" netmail "net/mail" "net/url" "os" "strconv" "strings" "time" "galaxy/notification/internal/telemetry" ) const ( shutdownTimeoutEnvVar = "NOTIFICATION_SHUTDOWN_TIMEOUT" logLevelEnvVar = "NOTIFICATION_LOG_LEVEL" internalHTTPAddrEnvVar = "NOTIFICATION_INTERNAL_HTTP_ADDR" internalHTTPReadHeaderTimeoutEnvVar = "NOTIFICATION_INTERNAL_HTTP_READ_HEADER_TIMEOUT" internalHTTPReadTimeoutEnvVar = "NOTIFICATION_INTERNAL_HTTP_READ_TIMEOUT" internalHTTPIdleTimeoutEnvVar = "NOTIFICATION_INTERNAL_HTTP_IDLE_TIMEOUT" redisAddrEnvVar = "NOTIFICATION_REDIS_ADDR" redisUsernameEnvVar = "NOTIFICATION_REDIS_USERNAME" redisPasswordEnvVar = "NOTIFICATION_REDIS_PASSWORD" redisDBEnvVar = "NOTIFICATION_REDIS_DB" redisTLSEnabledEnvVar = "NOTIFICATION_REDIS_TLS_ENABLED" redisOperationTimeoutEnvVar = "NOTIFICATION_REDIS_OPERATION_TIMEOUT" intentsStreamEnvVar = "NOTIFICATION_INTENTS_STREAM" intentsReadBlockTimeoutEnvVar = "NOTIFICATION_INTENTS_READ_BLOCK_TIMEOUT" gatewayClientEventsStreamEnvVar = "NOTIFICATION_GATEWAY_CLIENT_EVENTS_STREAM" gatewayClientEventsStreamMaxEnvVar = "NOTIFICATION_GATEWAY_CLIENT_EVENTS_STREAM_MAX_LEN" mailDeliveryCommandsStreamEnvVar = "NOTIFICATION_MAIL_DELIVERY_COMMANDS_STREAM" pushRetryMaxAttemptsEnvVar = "NOTIFICATION_PUSH_RETRY_MAX_ATTEMPTS" emailRetryMaxAttemptsEnvVar = "NOTIFICATION_EMAIL_RETRY_MAX_ATTEMPTS" routeLeaseTTLEnvVar = "NOTIFICATION_ROUTE_LEASE_TTL" routeBackoffMinEnvVar = "NOTIFICATION_ROUTE_BACKOFF_MIN" routeBackoffMaxEnvVar = "NOTIFICATION_ROUTE_BACKOFF_MAX" deadLetterTTLEnvVar = "NOTIFICATION_DEAD_LETTER_TTL" recordTTLEnvVar = "NOTIFICATION_RECORD_TTL" idempotencyTTLEnvVar = "NOTIFICATION_IDEMPOTENCY_TTL" userServiceBaseURLEnvVar = "NOTIFICATION_USER_SERVICE_BASE_URL" userServiceTimeoutEnvVar = "NOTIFICATION_USER_SERVICE_TIMEOUT" adminEmailsGeoReviewRecommendedEnvVar = "NOTIFICATION_ADMIN_EMAILS_GEO_REVIEW_RECOMMENDED" adminEmailsGameGenerationFailedEnvVar = "NOTIFICATION_ADMIN_EMAILS_GAME_GENERATION_FAILED" adminEmailsLobbyRuntimePausedAfterEnvVar = "NOTIFICATION_ADMIN_EMAILS_LOBBY_RUNTIME_PAUSED_AFTER_START" adminEmailsLobbyApplicationSubmittedEnvVar = "NOTIFICATION_ADMIN_EMAILS_LOBBY_APPLICATION_SUBMITTED" 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 = "NOTIFICATION_OTEL_STDOUT_TRACES_ENABLED" otelStdoutMetricsEnabledEnvVar = "NOTIFICATION_OTEL_STDOUT_METRICS_ENABLED" defaultShutdownTimeout = 5 * time.Second defaultLogLevel = "info" defaultInternalHTTPAddr = ":8092" defaultReadHeaderTimeout = 2 * time.Second defaultReadTimeout = 10 * time.Second defaultIdleTimeout = time.Minute defaultRedisDB = 0 defaultRedisOperationTimeout = 250 * time.Millisecond defaultIntentsStream = "notification:intents" defaultIntentsReadBlockTimeout = 2 * time.Second defaultGatewayClientEventsStream = "gateway:client-events" defaultGatewayClientEventsStreamMaxLen int64 = 1024 defaultMailDeliveryCommandsStream = "mail:delivery_commands" defaultPushRetryMaxAttempts = 3 defaultEmailRetryMaxAttempts = 7 defaultRouteLeaseTTL = 5 * time.Second defaultRouteBackoffMin = time.Second defaultRouteBackoffMax = 5 * time.Minute defaultDeadLetterTTL = 720 * time.Hour defaultRecordTTL = 720 * time.Hour defaultIdempotencyTTL = 168 * time.Hour defaultUserServiceTimeout = time.Second defaultOTelServiceName = "galaxy-notification" otelExporterNone = "none" otelExporterOTLP = "otlp" otelProtocolHTTPProtobuf = "http/protobuf" otelProtocolGRPC = "grpc" ) // Config stores the full Notification 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 private probe HTTP listener. InternalHTTP InternalHTTPConfig // Redis configures the shared Redis client used by the process. Redis RedisConfig // Streams stores the stable stream names reserved for notification ingress // and downstream publication. Streams StreamsConfig // IntentsReadBlockTimeout stores the maximum Redis Streams blocking read // window used by the intent consumer. IntentsReadBlockTimeout time.Duration // Retry stores the frozen retry and retention settings. Retry RetryConfig // UserService configures the trusted user-enrichment dependency. UserService UserServiceConfig // AdminRouting stores the type-specific configured administrator email // lists. AdminRouting AdminRoutingConfig // 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 private probe 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 and its connection settings. 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 } // 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") default: return nil } } // StreamsConfig stores the stable Redis Stream names used by Notification // Service. type StreamsConfig struct { // Intents stores the ingress intent stream. Intents string // GatewayClientEvents stores the downstream Gateway client-events stream. GatewayClientEvents string // GatewayClientEventsStreamMaxLen bounds the downstream Gateway // client-events stream with approximate trimming. GatewayClientEventsStreamMaxLen int64 // MailDeliveryCommands stores the downstream Mail Service command stream. MailDeliveryCommands string } // Validate reports whether cfg stores usable stream names. func (cfg StreamsConfig) Validate() error { switch { case strings.TrimSpace(cfg.Intents) == "": return fmt.Errorf("intents stream must not be empty") case strings.TrimSpace(cfg.GatewayClientEvents) == "": return fmt.Errorf("gateway client-events stream must not be empty") case cfg.GatewayClientEventsStreamMaxLen <= 0: return fmt.Errorf("gateway client-events stream max len must be positive") case strings.TrimSpace(cfg.MailDeliveryCommands) == "": return fmt.Errorf("mail delivery-commands stream must not be empty") default: return nil } } // RetryConfig stores the frozen retry budgets, backoff settings, and retention // periods used by the service. type RetryConfig struct { // PushMaxAttempts stores the route retry budget for the `push` channel. PushMaxAttempts int // EmailMaxAttempts stores the route retry budget for the `email` channel. EmailMaxAttempts int // RouteLeaseTTL stores the temporary route-lease lifetime used to avoid // duplicate publication across replicas. RouteLeaseTTL time.Duration // RouteBackoffMin stores the minimum retry backoff. RouteBackoffMin time.Duration // RouteBackoffMax stores the maximum retry backoff. RouteBackoffMax time.Duration // DeadLetterTTL stores the retention period for dead-letter and malformed // intent records. DeadLetterTTL time.Duration // RecordTTL stores the retention period for notification and route records. RecordTTL time.Duration // IdempotencyTTL stores the retention period for idempotency records. IdempotencyTTL time.Duration } // Validate reports whether cfg stores usable retry and retention settings. func (cfg RetryConfig) Validate() error { switch { case cfg.PushMaxAttempts <= 0: return fmt.Errorf("push retry max attempts must be positive") case cfg.EmailMaxAttempts <= 0: return fmt.Errorf("email retry max attempts must be positive") case cfg.RouteLeaseTTL <= 0: return fmt.Errorf("route lease ttl must be positive") case cfg.RouteBackoffMin <= 0: return fmt.Errorf("route backoff min must be positive") case cfg.RouteBackoffMax <= 0: return fmt.Errorf("route backoff max must be positive") case cfg.RouteBackoffMin > cfg.RouteBackoffMax: return fmt.Errorf("route backoff min must not exceed route backoff max") case cfg.DeadLetterTTL <= 0: return fmt.Errorf("dead-letter ttl must be positive") case cfg.RecordTTL <= 0: return fmt.Errorf("record ttl must be positive") case cfg.IdempotencyTTL <= 0: return fmt.Errorf("idempotency ttl must be positive") default: return nil } } // UserServiceConfig configures the trusted user-enrichment dependency. type UserServiceConfig struct { // BaseURL stores the absolute base URL of the trusted User Service. BaseURL string // Timeout bounds one outbound User Service request. Timeout time.Duration } // Validate reports whether cfg stores a usable User Service configuration. func (cfg UserServiceConfig) Validate() error { switch { case strings.TrimSpace(cfg.BaseURL) == "": return fmt.Errorf("user service base URL must not be empty") case !isAbsoluteHTTPURL(cfg.BaseURL): return fmt.Errorf("user service base URL %q must be an absolute http(s) URL", cfg.BaseURL) case cfg.Timeout <= 0: return fmt.Errorf("user service timeout must be positive") default: return nil } } // AdminRoutingConfig stores the type-specific configured administrator email // lists. type AdminRoutingConfig struct { // GeoReviewRecommended stores recipients for // `geo.review_recommended`. GeoReviewRecommended []string // GameGenerationFailed stores recipients for // `game.generation_failed`. GameGenerationFailed []string // LobbyRuntimePausedAfterStart stores recipients for // `lobby.runtime_paused_after_start`. LobbyRuntimePausedAfterStart []string // LobbyApplicationSubmitted stores recipients for public // `lobby.application.submitted` notifications. LobbyApplicationSubmitted []string } // Validate reports whether cfg stores valid normalized administrator email // lists. func (cfg AdminRoutingConfig) Validate() error { if err := validateNormalizedEmailList("geo.review_recommended", cfg.GeoReviewRecommended); err != nil { return err } if err := validateNormalizedEmailList("game.generation_failed", cfg.GameGenerationFailed); err != nil { return err } if err := validateNormalizedEmailList("lobby.runtime_paused_after_start", cfg.LobbyRuntimePausedAfterStart); err != nil { return err } if err := validateNormalizedEmailList("lobby.application.submitted", cfg.LobbyApplicationSubmitted); err != nil { return err } return nil } // TelemetryConfig configures the Notification 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 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() } // DefaultConfig returns the default Notification 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, }, Streams: StreamsConfig{ Intents: defaultIntentsStream, GatewayClientEvents: defaultGatewayClientEventsStream, GatewayClientEventsStreamMaxLen: defaultGatewayClientEventsStreamMaxLen, MailDeliveryCommands: defaultMailDeliveryCommandsStream, }, IntentsReadBlockTimeout: defaultIntentsReadBlockTimeout, Retry: RetryConfig{ PushMaxAttempts: defaultPushRetryMaxAttempts, EmailMaxAttempts: defaultEmailRetryMaxAttempts, RouteLeaseTTL: defaultRouteLeaseTTL, RouteBackoffMin: defaultRouteBackoffMin, RouteBackoffMax: defaultRouteBackoffMax, DeadLetterTTL: defaultDeadLetterTTL, RecordTTL: defaultRecordTTL, IdempotencyTTL: defaultIdempotencyTTL, }, UserService: UserServiceConfig{ Timeout: defaultUserServiceTimeout, }, Telemetry: TelemetryConfig{ ServiceName: defaultOTelServiceName, TracesExporter: otelExporterNone, MetricsExporter: otelExporterNone, }, } } // LoadFromEnv loads the Notification Service process configuration from // environment variables, applying documented defaults where appropriate. func LoadFromEnv() (Config, error) { cfg := DefaultConfig() var err error cfg.ShutdownTimeout, err = loadDurationEnvWithDefault(shutdownTimeoutEnvVar, cfg.ShutdownTimeout) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.Logging.Level = loadStringEnvWithDefault(logLevelEnvVar, cfg.Logging.Level) if err := validateLogLevel(cfg.Logging.Level); err != nil { return Config{}, fmt.Errorf("load notification config: %s: %w", logLevelEnvVar, err) } cfg.InternalHTTP.Addr = loadStringEnvWithDefault(internalHTTPAddrEnvVar, cfg.InternalHTTP.Addr) cfg.InternalHTTP.ReadHeaderTimeout, err = loadDurationEnvWithDefault(internalHTTPReadHeaderTimeoutEnvVar, cfg.InternalHTTP.ReadHeaderTimeout) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.InternalHTTP.ReadTimeout, err = loadDurationEnvWithDefault(internalHTTPReadTimeoutEnvVar, cfg.InternalHTTP.ReadTimeout) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.InternalHTTP.IdleTimeout, err = loadDurationEnvWithDefault(internalHTTPIdleTimeoutEnvVar, cfg.InternalHTTP.IdleTimeout) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.Redis.Addr = loadStringEnvWithDefault(redisAddrEnvVar, cfg.Redis.Addr) cfg.Redis.Username = os.Getenv(redisUsernameEnvVar) cfg.Redis.Password = os.Getenv(redisPasswordEnvVar) cfg.Redis.DB, err = loadIntEnvWithDefault(redisDBEnvVar, cfg.Redis.DB) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.Redis.TLSEnabled, err = loadBoolEnvWithDefault(redisTLSEnabledEnvVar, cfg.Redis.TLSEnabled) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.Redis.OperationTimeout, err = loadDurationEnvWithDefault(redisOperationTimeoutEnvVar, cfg.Redis.OperationTimeout) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.Streams.Intents = loadStringEnvWithDefault(intentsStreamEnvVar, cfg.Streams.Intents) cfg.Streams.GatewayClientEvents = loadStringEnvWithDefault(gatewayClientEventsStreamEnvVar, cfg.Streams.GatewayClientEvents) cfg.Streams.GatewayClientEventsStreamMaxLen, err = loadInt64EnvWithDefault(gatewayClientEventsStreamMaxEnvVar, cfg.Streams.GatewayClientEventsStreamMaxLen) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.Streams.MailDeliveryCommands = loadStringEnvWithDefault(mailDeliveryCommandsStreamEnvVar, cfg.Streams.MailDeliveryCommands) cfg.IntentsReadBlockTimeout, err = loadDurationEnvWithDefault(intentsReadBlockTimeoutEnvVar, cfg.IntentsReadBlockTimeout) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.Retry.PushMaxAttempts, err = loadIntEnvWithDefault(pushRetryMaxAttemptsEnvVar, cfg.Retry.PushMaxAttempts) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.Retry.EmailMaxAttempts, err = loadIntEnvWithDefault(emailRetryMaxAttemptsEnvVar, cfg.Retry.EmailMaxAttempts) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.Retry.RouteLeaseTTL, err = loadDurationEnvWithDefault(routeLeaseTTLEnvVar, cfg.Retry.RouteLeaseTTL) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.Retry.RouteBackoffMin, err = loadDurationEnvWithDefault(routeBackoffMinEnvVar, cfg.Retry.RouteBackoffMin) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.Retry.RouteBackoffMax, err = loadDurationEnvWithDefault(routeBackoffMaxEnvVar, cfg.Retry.RouteBackoffMax) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.Retry.DeadLetterTTL, err = loadDurationEnvWithDefault(deadLetterTTLEnvVar, cfg.Retry.DeadLetterTTL) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.Retry.RecordTTL, err = loadDurationEnvWithDefault(recordTTLEnvVar, cfg.Retry.RecordTTL) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.Retry.IdempotencyTTL, err = loadDurationEnvWithDefault(idempotencyTTLEnvVar, cfg.Retry.IdempotencyTTL) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.UserService.BaseURL = normalizeBaseURL(loadStringEnvWithDefault(userServiceBaseURLEnvVar, cfg.UserService.BaseURL)) cfg.UserService.Timeout, err = loadDurationEnvWithDefault(userServiceTimeoutEnvVar, cfg.UserService.Timeout) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.AdminRouting.GeoReviewRecommended, err = loadEmailListEnv(adminEmailsGeoReviewRecommendedEnvVar, cfg.AdminRouting.GeoReviewRecommended) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.AdminRouting.GameGenerationFailed, err = loadEmailListEnv(adminEmailsGameGenerationFailedEnvVar, cfg.AdminRouting.GameGenerationFailed) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.AdminRouting.LobbyRuntimePausedAfterStart, err = loadEmailListEnv(adminEmailsLobbyRuntimePausedAfterEnvVar, cfg.AdminRouting.LobbyRuntimePausedAfterStart) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.AdminRouting.LobbyApplicationSubmitted, err = loadEmailListEnv(adminEmailsLobbyApplicationSubmittedEnvVar, cfg.AdminRouting.LobbyApplicationSubmitted) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.Telemetry.ServiceName = loadStringEnvWithDefault(otelServiceNameEnvVar, cfg.Telemetry.ServiceName) cfg.Telemetry.TracesExporter = normalizeExporterValue(loadStringEnvWithDefault(otelTracesExporterEnvVar, cfg.Telemetry.TracesExporter)) cfg.Telemetry.MetricsExporter = normalizeExporterValue(loadStringEnvWithDefault(otelMetricsExporterEnvVar, cfg.Telemetry.MetricsExporter)) cfg.Telemetry.TracesProtocol = loadOTLPProtocol( os.Getenv(otelExporterOTLPTracesProtocolEnvVar), os.Getenv(otelExporterOTLPProtocolEnvVar), cfg.Telemetry.TracesExporter, ) cfg.Telemetry.MetricsProtocol = loadOTLPProtocol( os.Getenv(otelExporterOTLPMetricsProtocolEnvVar), os.Getenv(otelExporterOTLPProtocolEnvVar), cfg.Telemetry.MetricsExporter, ) cfg.Telemetry.StdoutTracesEnabled, err = loadBoolEnvWithDefault(otelStdoutTracesEnabledEnvVar, cfg.Telemetry.StdoutTracesEnabled) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } cfg.Telemetry.StdoutMetricsEnabled, err = loadBoolEnvWithDefault(otelStdoutMetricsEnabledEnvVar, cfg.Telemetry.StdoutMetricsEnabled) if err != nil { return Config{}, fmt.Errorf("load notification config: %w", err) } if err := cfg.Validate(); err != nil { return Config{}, err } return cfg, nil } // Validate reports whether cfg contains a consistent Notification Service // process configuration. func (cfg Config) Validate() error { switch { case cfg.ShutdownTimeout <= 0: return fmt.Errorf("load notification config: %s must be positive", shutdownTimeoutEnvVar) case strings.TrimSpace(cfg.Redis.Addr) == "": return fmt.Errorf("load notification config: %s must not be empty", redisAddrEnvVar) case strings.TrimSpace(cfg.UserService.BaseURL) == "": return fmt.Errorf("load notification config: %s must not be empty", userServiceBaseURLEnvVar) } if err := cfg.InternalHTTP.Validate(); err != nil { return fmt.Errorf("load notification config: %s", err) } if err := cfg.Redis.Validate(); err != nil { return fmt.Errorf("load notification config: %s", err) } if err := cfg.Streams.Validate(); err != nil { return fmt.Errorf("load notification config: %s", err) } if cfg.IntentsReadBlockTimeout <= 0 { return fmt.Errorf("load notification config: %s must be positive", intentsReadBlockTimeoutEnvVar) } if err := cfg.Retry.Validate(); err != nil { return fmt.Errorf("load notification config: %s", err) } if err := cfg.UserService.Validate(); err != nil { return fmt.Errorf("load notification config: %s", err) } if err := cfg.AdminRouting.Validate(); err != nil { return fmt.Errorf("load notification config: %s", err) } if err := cfg.Telemetry.Validate(); err != nil { return fmt.Errorf("load notification config: %w", err) } return nil } func loadStringEnvWithDefault(name string, value string) string { if raw, ok := os.LookupEnv(name); ok { return strings.TrimSpace(raw) } return value } func loadDurationEnvWithDefault(name string, value time.Duration) (time.Duration, error) { raw, ok := os.LookupEnv(name) if !ok { return value, nil } parsed, err := time.ParseDuration(strings.TrimSpace(raw)) if err != nil { return 0, fmt.Errorf("%s: %w", name, err) } return parsed, nil } func loadIntEnvWithDefault(name string, value int) (int, error) { raw, ok := os.LookupEnv(name) if !ok { return value, nil } parsed, err := strconv.Atoi(strings.TrimSpace(raw)) if err != nil { return 0, fmt.Errorf("%s: %w", name, err) } return parsed, nil } func loadInt64EnvWithDefault(name string, value int64) (int64, error) { raw, ok := os.LookupEnv(name) if !ok { return value, nil } parsed, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) if err != nil { return 0, fmt.Errorf("%s: %w", name, err) } return parsed, nil } func loadBoolEnvWithDefault(name string, value bool) (bool, error) { raw, ok := os.LookupEnv(name) if !ok { return value, nil } parsed, err := strconv.ParseBool(strings.TrimSpace(raw)) if err != nil { return false, fmt.Errorf("%s: %w", name, err) } return parsed, nil } func loadEmailListEnv(name string, value []string) ([]string, error) { raw, ok := os.LookupEnv(name) if !ok { return append([]string(nil), value...), nil } return parseEmailList(name, raw) } func parseEmailList(name string, raw string) ([]string, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { return nil, nil } parts := strings.Split(trimmed, ",") addresses := make([]string, 0, len(parts)) seen := make(map[string]struct{}, len(parts)) for index, part := range parts { normalized, err := normalizeMailboxAddress(part) if err != nil { return nil, fmt.Errorf("%s[%d]: %w", name, index, err) } if _, ok := seen[normalized]; ok { continue } seen[normalized] = struct{}{} addresses = append(addresses, normalized) } return addresses, nil } func normalizeMailboxAddress(value string) (string, error) { trimmed := strings.TrimSpace(value) if trimmed == "" { return "", fmt.Errorf("email address must not be empty") } parsed, err := netmail.ParseAddress(trimmed) if err != nil { return "", fmt.Errorf("invalid email address %q: %w", trimmed, err) } if parsed.Name != "" { return "", fmt.Errorf("email address %q must not include a display name", trimmed) } return strings.ToLower(parsed.Address), nil } func validateNormalizedEmailList(name string, values []string) error { for index, value := range values { normalized, err := normalizeMailboxAddress(value) if err != nil { return fmt.Errorf("%s[%d]: %w", name, index, err) } if normalized != value { return fmt.Errorf("%s[%d]: email address must already be normalized", name, index) } } return nil } func validateLogLevel(value string) error { var level slog.Level return level.UnmarshalText([]byte(strings.TrimSpace(value))) } func normalizeExporterValue(value string) string { switch strings.TrimSpace(value) { case "", otelExporterNone: return otelExporterNone default: return strings.TrimSpace(value) } } func loadOTLPProtocol(primary string, fallback string, exporter string) string { protocol := strings.TrimSpace(primary) if protocol == "" { protocol = strings.TrimSpace(fallback) } if protocol == "" && exporter == otelExporterOTLP { return otelProtocolHTTPProtobuf } return protocol } func normalizeBaseURL(value string) string { trimmed := strings.TrimSpace(value) if trimmed == "" { return "" } return strings.TrimRight(trimmed, "/") } func isAbsoluteHTTPURL(value string) bool { parsed, err := url.Parse(strings.TrimSpace(value)) if err != nil { return false } if parsed.Scheme != "http" && parsed.Scheme != "https" { return false } return parsed.Host != "" } func isTCPAddr(value string) bool { host, port, err := net.SplitHostPort(strings.TrimSpace(value)) if err != nil { return false } if port == "" { return false } if host == "" { return true } return true }