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
+129 -365
View File
@@ -3,21 +3,21 @@
package config
import (
"crypto/tls"
"fmt"
"log/slog"
"net"
netmail "net/mail"
"net/url"
"os"
"strconv"
"strings"
"time"
"galaxy/notification/internal/telemetry"
"galaxy/postgres"
"galaxy/redisconn"
)
const (
envPrefix = "NOTIFICATION"
shutdownTimeoutEnvVar = "NOTIFICATION_SHUTDOWN_TIMEOUT"
logLevelEnvVar = "NOTIFICATION_LOG_LEVEL"
@@ -26,28 +26,23 @@ const (
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"
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"
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"
recordRetentionEnvVar = "NOTIFICATION_RECORD_RETENTION"
malformedIntentRetentionEnvVar = "NOTIFICATION_MALFORMED_INTENT_RETENTION"
cleanupIntervalEnvVar = "NOTIFICATION_CLEANUP_INTERVAL"
userServiceBaseURLEnvVar = "NOTIFICATION_USER_SERVICE_BASE_URL"
userServiceTimeoutEnvVar = "NOTIFICATION_USER_SERVICE_TIMEOUT"
@@ -71,24 +66,24 @@ const (
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"
defaultIntentsStream = "notification:intents"
defaultIntentsReadBlockTimeout = 2 * time.Second
defaultGatewayClientEventsStream = "gateway:client-events"
defaultGatewayClientEventsStreamMaxLen int64 = 1024
defaultMailDeliveryCommandsStream = "mail:delivery_commands"
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
defaultRecordRetention = 30 * 24 * time.Hour
defaultMalformedIntentRetention = 90 * 24 * time.Hour
defaultCleanupInterval = time.Hour
defaultUserServiceTimeout = time.Second
defaultOTelServiceName = "galaxy-notification"
@@ -109,20 +104,29 @@ type Config struct {
// InternalHTTP configures the private probe HTTP listener.
InternalHTTP InternalHTTPConfig
// Redis configures the shared Redis client used by the process.
// Redis configures the shared Redis connection topology and the inbound
// `notification:intents` stream plus the outbound stream names. Durable
// notification state lives in PostgreSQL after Stage 5 of `PG_PLAN.md`.
Redis RedisConfig
// Streams stores the stable stream names reserved for notification ingress
// and downstream publication.
// Postgres configures the PostgreSQL-backed durable store consumed via
// `pkg/postgres`.
Postgres PostgresConfig
// Streams stores the stable Redis Stream names reserved for 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 stores the frozen retry settings used by the route publishers.
Retry RetryConfig
// Retention stores the periodic SQL retention worker configuration.
Retention RetentionConfig
// UserService configures the trusted user-enrichment dependency.
UserService UserServiceConfig
@@ -174,51 +178,29 @@ func (cfg InternalHTTPConfig) Validate() error {
}
}
// RedisConfig configures the shared Redis client and its connection settings.
// RedisConfig configures the Notification Service Redis connection topology.
// 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
}
// 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}
// Conn carries the connection topology (master, replicas, password, db,
// per-call timeout). Loaded via redisconn.LoadFromEnv("NOTIFICATION").
Conn redisconn.Config
}
// 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
}
return cfg.Conn.Validate()
}
// 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("NOTIFICATION").
Conn postgres.Config
}
// Validate reports whether cfg stores a usable PostgreSQL configuration.
func (cfg PostgresConfig) Validate() error {
return cfg.Conn.Validate()
}
// StreamsConfig stores the stable Redis Stream names used by Notification
@@ -254,8 +236,8 @@ func (cfg StreamsConfig) Validate() error {
}
}
// RetryConfig stores the frozen retry budgets, backoff settings, and retention
// periods used by the service.
// RetryConfig stores the frozen retry budgets, backoff settings, and the
// per-acceptance idempotency window.
type RetryConfig struct {
// PushMaxAttempts stores the route retry budget for the `push` channel.
PushMaxAttempts int
@@ -273,18 +255,13 @@ type RetryConfig struct {
// 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 stores the per-acceptance idempotency window the service
// layer applies to the durable `idempotency_expires_at` column on the
// `records` table.
IdempotencyTTL time.Duration
}
// Validate reports whether cfg stores usable retry and retention settings.
// Validate reports whether cfg stores usable retry settings.
func (cfg RetryConfig) Validate() error {
switch {
case cfg.PushMaxAttempts <= 0:
@@ -299,10 +276,6 @@ func (cfg RetryConfig) Validate() error {
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:
@@ -310,6 +283,36 @@ func (cfg RetryConfig) Validate() error {
}
}
// RetentionConfig stores the durable retention windows applied by the
// periodic SQL retention worker.
type RetentionConfig struct {
// RecordRetention bounds how long records (and their cascaded routes and
// dead_letters) survive after acceptance.
RecordRetention time.Duration
// MalformedIntentRetention bounds how long malformed-intent rows survive
// after their original `recorded_at`.
MalformedIntentRetention 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.RecordRetention <= 0:
return fmt.Errorf("%s must be positive", recordRetentionEnvVar)
case cfg.MalformedIntentRetention <= 0:
return fmt.Errorf("%s must be positive", malformedIntentRetentionEnvVar)
case cfg.CleanupInterval <= 0:
return fmt.Errorf("%s must be positive", cleanupIntervalEnvVar)
default:
return nil
}
}
// UserServiceConfig configures the trusted user-enrichment dependency.
type UserServiceConfig struct {
// BaseURL stores the absolute base URL of the trusted User Service.
@@ -336,12 +339,10 @@ func (cfg UserServiceConfig) Validate() error {
// AdminRoutingConfig stores the type-specific configured administrator email
// lists.
type AdminRoutingConfig struct {
// GeoReviewRecommended stores recipients for
// `geo.review_recommended`.
// GeoReviewRecommended stores recipients for `geo.review_recommended`.
GeoReviewRecommended []string
// GameGenerationFailed stores recipients for
// `game.generation_failed`.
// GameGenerationFailed stores recipients for `game.generation_failed`.
GameGenerationFailed []string
// LobbyRuntimePausedAfterStart stores recipients for
@@ -431,14 +432,16 @@ func DefaultConfig() Config {
IdleTimeout: defaultIdleTimeout,
},
Redis: RedisConfig{
DB: defaultRedisDB,
OperationTimeout: defaultRedisOperationTimeout,
Conn: redisconn.DefaultConfig(),
},
Postgres: PostgresConfig{
Conn: postgres.DefaultConfig(),
},
Streams: StreamsConfig{
Intents: defaultIntentsStream,
GatewayClientEvents: defaultGatewayClientEventsStream,
Intents: defaultIntentsStream,
GatewayClientEvents: defaultGatewayClientEventsStream,
GatewayClientEventsStreamMaxLen: defaultGatewayClientEventsStreamMaxLen,
MailDeliveryCommands: defaultMailDeliveryCommandsStream,
MailDeliveryCommands: defaultMailDeliveryCommandsStream,
},
IntentsReadBlockTimeout: defaultIntentsReadBlockTimeout,
Retry: RetryConfig{
@@ -447,10 +450,13 @@ func DefaultConfig() Config {
RouteLeaseTTL: defaultRouteLeaseTTL,
RouteBackoffMin: defaultRouteBackoffMin,
RouteBackoffMax: defaultRouteBackoffMax,
DeadLetterTTL: defaultDeadLetterTTL,
RecordTTL: defaultRecordTTL,
IdempotencyTTL: defaultIdempotencyTTL,
},
Retention: RetentionConfig{
RecordRetention: defaultRecordRetention,
MalformedIntentRetention: defaultMalformedIntentRetention,
CleanupInterval: defaultCleanupInterval,
},
UserService: UserServiceConfig{
Timeout: defaultUserServiceTimeout,
},
@@ -462,167 +468,21 @@ func DefaultConfig() Config {
}
}
// 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:
if 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)
return fmt.Errorf("load notification config: %w", err)
}
if err := cfg.Postgres.Validate(); err != nil {
return fmt.Errorf("load notification config: %w", err)
}
if err := cfg.Streams.Validate(); err != nil {
return fmt.Errorf("load notification config: %s", err)
@@ -633,6 +493,9 @@ func (cfg Config) Validate() error {
if err := cfg.Retry.Validate(); err != nil {
return fmt.Errorf("load notification config: %s", err)
}
if err := cfg.Retention.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)
}
@@ -646,77 +509,35 @@ func (cfg Config) Validate() error {
return nil
}
func loadStringEnvWithDefault(name string, value string) string {
if raw, ok := os.LookupEnv(name); ok {
return strings.TrimSpace(raw)
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 value
return nil
}
func loadDurationEnvWithDefault(name string, value time.Duration) (time.Duration, error) {
raw, ok := os.LookupEnv(name)
if !ok {
return value, nil
func normalizeMailboxAddress(value string) (string, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "", fmt.Errorf("email address must not be empty")
}
parsed, err := time.ParseDuration(strings.TrimSpace(raw))
parsed, err := netmail.ParseAddress(trimmed)
if err != nil {
return 0, fmt.Errorf("%s: %w", name, err)
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 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)
return strings.ToLower(parsed.Address), nil
}
func parseEmailList(name string, raw string) ([]string, error) {
@@ -743,63 +564,6 @@ func parseEmailList(name string, raw string) ([]string, error) {
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 == "" {