feat: mail service
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadFromEnvUsesDefaults(t *testing.T) {
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
require.NoError(t, err)
|
||||
|
||||
defaults := DefaultConfig()
|
||||
require.Equal(t, defaults.ShutdownTimeout, cfg.ShutdownTimeout)
|
||||
require.Equal(t, defaults.Logging, cfg.Logging)
|
||||
require.Equal(t, defaults.InternalHTTP, cfg.InternalHTTP)
|
||||
require.Equal(t, "127.0.0.1:6379", cfg.Redis.Addr)
|
||||
require.Equal(t, defaults.Redis.DB, cfg.Redis.DB)
|
||||
require.Equal(t, defaults.Redis.OperationTimeout, cfg.Redis.OperationTimeout)
|
||||
require.Equal(t, defaults.Redis.CommandStream, cfg.Redis.CommandStream)
|
||||
require.Equal(t, defaults.Redis.AttemptScheduleKey, cfg.Redis.AttemptScheduleKey)
|
||||
require.Equal(t, defaults.Redis.DeadLetterPrefix, cfg.Redis.DeadLetterPrefix)
|
||||
require.Equal(t, defaults.SMTP, cfg.SMTP)
|
||||
require.Equal(t, defaults.Templates, cfg.Templates)
|
||||
require.Equal(t, defaults.AttemptWorkerConcurrency, cfg.AttemptWorkerConcurrency)
|
||||
require.Equal(t, defaults.StreamBlockTimeout, cfg.StreamBlockTimeout)
|
||||
require.Equal(t, defaults.OperatorRequestTimeout, cfg.OperatorRequestTimeout)
|
||||
require.Equal(t, defaults.IdempotencyTTL, cfg.IdempotencyTTL)
|
||||
require.Equal(t, defaults.DeliveryTTL, cfg.DeliveryTTL)
|
||||
require.Equal(t, defaults.AttemptTTL, cfg.AttemptTTL)
|
||||
require.Equal(t, defaults.Telemetry, cfg.Telemetry)
|
||||
}
|
||||
|
||||
func TestLoadFromEnvAppliesOverrides(t *testing.T) {
|
||||
t.Setenv(shutdownTimeoutEnvVar, "9s")
|
||||
t.Setenv(logLevelEnvVar, "debug")
|
||||
t.Setenv(internalHTTPAddrEnvVar, "127.0.0.1:18080")
|
||||
t.Setenv(internalHTTPReadHeaderTimeoutEnvVar, "3s")
|
||||
t.Setenv(internalHTTPReadTimeoutEnvVar, "11s")
|
||||
t.Setenv(internalHTTPIdleTimeoutEnvVar, "61s")
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6380")
|
||||
t.Setenv(redisUsernameEnvVar, "alice")
|
||||
t.Setenv(redisPasswordEnvVar, "secret")
|
||||
t.Setenv(redisDBEnvVar, "3")
|
||||
t.Setenv(redisTLSEnabledEnvVar, "true")
|
||||
t.Setenv(redisOperationTimeoutEnvVar, "750ms")
|
||||
t.Setenv(redisCommandStreamEnvVar, "mail:test_commands")
|
||||
t.Setenv(redisAttemptScheduleEnvVar, "mail:test_schedule")
|
||||
t.Setenv(redisDeadLetterPrefixEnvVar, "mail:test_dead_letters:")
|
||||
t.Setenv(smtpModeEnvVar, SMTPModeSMTP)
|
||||
t.Setenv(smtpAddrEnvVar, "127.0.0.1:2525")
|
||||
t.Setenv(smtpUsernameEnvVar, "mailer")
|
||||
t.Setenv(smtpPasswordEnvVar, "smtp-secret")
|
||||
t.Setenv(smtpFromEmailEnvVar, "noreply@example.com")
|
||||
t.Setenv(smtpFromNameEnvVar, "Galaxy Mail")
|
||||
t.Setenv(smtpTimeoutEnvVar, "19s")
|
||||
t.Setenv(smtpInsecureSkipVerifyEnvVar, "true")
|
||||
t.Setenv(templateDirEnvVar, "/tmp/templates")
|
||||
t.Setenv(attemptWorkerConcurrencyEnvVar, "8")
|
||||
t.Setenv(streamBlockTimeoutEnvVar, "5s")
|
||||
t.Setenv(operatorRequestTimeoutEnvVar, "6s")
|
||||
t.Setenv(idempotencyTTLEnvVar, "48h")
|
||||
t.Setenv(deliveryTTLEnvVar, "96h")
|
||||
t.Setenv(attemptTTLEnvVar, "240h")
|
||||
t.Setenv(otelServiceNameEnvVar, "custom-mail")
|
||||
t.Setenv(otelTracesExporterEnvVar, "otlp")
|
||||
t.Setenv(otelMetricsExporterEnvVar, "otlp")
|
||||
t.Setenv(otelExporterOTLPProtocolEnvVar, "grpc")
|
||||
t.Setenv(otelStdoutTracesEnabledEnvVar, "true")
|
||||
t.Setenv(otelStdoutMetricsEnabledEnvVar, "true")
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 9*time.Second, cfg.ShutdownTimeout)
|
||||
require.Equal(t, "debug", cfg.Logging.Level)
|
||||
require.Equal(t, InternalHTTPConfig{
|
||||
Addr: "127.0.0.1:18080",
|
||||
ReadHeaderTimeout: 3 * time.Second,
|
||||
ReadTimeout: 11 * time.Second,
|
||||
IdleTimeout: 61 * time.Second,
|
||||
}, cfg.InternalHTTP)
|
||||
require.Equal(t, RedisConfig{
|
||||
Addr: "127.0.0.1:6380",
|
||||
Username: "alice",
|
||||
Password: "secret",
|
||||
DB: 3,
|
||||
TLSEnabled: true,
|
||||
OperationTimeout: 750 * time.Millisecond,
|
||||
CommandStream: "mail:test_commands",
|
||||
AttemptScheduleKey: "mail:test_schedule",
|
||||
DeadLetterPrefix: "mail:test_dead_letters:",
|
||||
}, cfg.Redis)
|
||||
require.Equal(t, SMTPConfig{
|
||||
Mode: SMTPModeSMTP,
|
||||
Addr: "127.0.0.1:2525",
|
||||
Username: "mailer",
|
||||
Password: "smtp-secret",
|
||||
FromEmail: "noreply@example.com",
|
||||
FromName: "Galaxy Mail",
|
||||
Timeout: 19 * time.Second,
|
||||
InsecureSkipVerify: true,
|
||||
}, cfg.SMTP)
|
||||
require.Equal(t, TemplateConfig{Dir: "/tmp/templates"}, cfg.Templates)
|
||||
require.Equal(t, 8, cfg.AttemptWorkerConcurrency)
|
||||
require.Equal(t, 5*time.Second, cfg.StreamBlockTimeout)
|
||||
require.Equal(t, 6*time.Second, cfg.OperatorRequestTimeout)
|
||||
require.Equal(t, 48*time.Hour, cfg.IdempotencyTTL)
|
||||
require.Equal(t, 96*time.Hour, cfg.DeliveryTTL)
|
||||
require.Equal(t, 240*time.Hour, cfg.AttemptTTL)
|
||||
require.Equal(t, TelemetryConfig{
|
||||
ServiceName: "custom-mail",
|
||||
TracesExporter: "otlp",
|
||||
MetricsExporter: "otlp",
|
||||
TracesProtocol: "grpc",
|
||||
MetricsProtocol: "grpc",
|
||||
StdoutTracesEnabled: true,
|
||||
StdoutMetricsEnabled: true,
|
||||
}, cfg.Telemetry)
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envName string
|
||||
envVal string
|
||||
}{
|
||||
{name: "invalid duration", envName: shutdownTimeoutEnvVar, envVal: "later"},
|
||||
{name: "invalid log level", envName: logLevelEnvVar, envVal: "verbose"},
|
||||
{name: "invalid redis db", envName: redisDBEnvVar, envVal: "db-three"},
|
||||
{name: "invalid redis tls", envName: redisTLSEnabledEnvVar, envVal: "sometimes"},
|
||||
{name: "invalid redis timeout", envName: redisOperationTimeoutEnvVar, envVal: "never"},
|
||||
{name: "invalid smtp mode", envName: smtpModeEnvVar, envVal: "ses"},
|
||||
{name: "invalid smtp timeout", envName: smtpTimeoutEnvVar, envVal: "fast"},
|
||||
{name: "invalid smtp insecure skip verify", envName: smtpInsecureSkipVerifyEnvVar, envVal: "sometimes"},
|
||||
{name: "invalid worker count", envName: attemptWorkerConcurrencyEnvVar, envVal: "many"},
|
||||
{name: "invalid otel traces exporter", envName: otelTracesExporterEnvVar, envVal: "stdout"},
|
||||
{name: "invalid otel metrics exporter", envName: otelMetricsExporterEnvVar, envVal: "stdout"},
|
||||
{name: "invalid otel traces protocol", envName: otelExporterOTLPTracesProtocolEnvVar, envVal: "udp"},
|
||||
{name: "invalid otel metrics protocol", envName: otelExporterOTLPMetricsProtocolEnvVar, envVal: "udp"},
|
||||
{name: "invalid otel stdout traces", envName: otelStdoutTracesEnabledEnvVar, envVal: "sometimes"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
|
||||
t.Setenv(tt.envName, tt.envVal)
|
||||
if tt.envName == smtpTimeoutEnvVar {
|
||||
t.Setenv(smtpModeEnvVar, SMTPModeSMTP)
|
||||
t.Setenv(smtpAddrEnvVar, "127.0.0.1:2525")
|
||||
t.Setenv(smtpFromEmailEnvVar, "noreply@example.com")
|
||||
}
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsMissingRequiredRedisAddr(t *testing.T) {
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "redis addr")
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsInvalidRedisAddr(t *testing.T) {
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "redis addr")
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsInvalidSMTPConfiguration(t *testing.T) {
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
|
||||
t.Setenv(smtpModeEnvVar, SMTPModeSMTP)
|
||||
|
||||
t.Run("missing addr", func(t *testing.T) {
|
||||
t.Setenv(smtpFromEmailEnvVar, "noreply@example.com")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "smtp addr")
|
||||
})
|
||||
|
||||
t.Run("missing from email", func(t *testing.T) {
|
||||
t.Setenv(smtpAddrEnvVar, "127.0.0.1:2525")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "smtp from email")
|
||||
})
|
||||
|
||||
t.Run("username without password", func(t *testing.T) {
|
||||
t.Setenv(smtpAddrEnvVar, "127.0.0.1:2525")
|
||||
t.Setenv(smtpFromEmailEnvVar, "noreply@example.com")
|
||||
t.Setenv(smtpUsernameEnvVar, "mailer")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "smtp username and password")
|
||||
})
|
||||
|
||||
t.Run("password without username", func(t *testing.T) {
|
||||
t.Setenv(smtpAddrEnvVar, "127.0.0.1:2525")
|
||||
t.Setenv(smtpFromEmailEnvVar, "noreply@example.com")
|
||||
t.Setenv(smtpPasswordEnvVar, "secret")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "smtp username and password")
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsNonPositiveDurationsAndCounts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envName string
|
||||
envVal string
|
||||
}{
|
||||
{name: "shutdown timeout", envName: shutdownTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "read header timeout", envName: internalHTTPReadHeaderTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "read timeout", envName: internalHTTPReadTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "idle timeout", envName: internalHTTPIdleTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "redis operation timeout", envName: redisOperationTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "smtp timeout", envName: smtpTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "attempt worker concurrency", envName: attemptWorkerConcurrencyEnvVar, envVal: "0"},
|
||||
{name: "stream block timeout", envName: streamBlockTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "operator request timeout", envName: operatorRequestTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "idempotency ttl", envName: idempotencyTTLEnvVar, envVal: "0s"},
|
||||
{name: "delivery ttl", envName: deliveryTTLEnvVar, envVal: "0s"},
|
||||
{name: "attempt ttl", envName: attemptTTLEnvVar, envVal: "0s"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
|
||||
t.Setenv(tt.envName, tt.envVal)
|
||||
if tt.envName == smtpTimeoutEnvVar {
|
||||
t.Setenv(smtpModeEnvVar, SMTPModeSMTP)
|
||||
t.Setenv(smtpAddrEnvVar, "127.0.0.1:2525")
|
||||
t.Setenv(smtpFromEmailEnvVar, "noreply@example.com")
|
||||
}
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoadFromEnv builds Config from environment variables and validates the
|
||||
// resulting configuration.
|
||||
func LoadFromEnv() (Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
var err error
|
||||
|
||||
cfg.ShutdownTimeout, err = durationEnv(shutdownTimeoutEnvVar, cfg.ShutdownTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Logging.Level = stringEnv(logLevelEnvVar, cfg.Logging.Level)
|
||||
|
||||
cfg.InternalHTTP.Addr = stringEnv(internalHTTPAddrEnvVar, cfg.InternalHTTP.Addr)
|
||||
cfg.InternalHTTP.ReadHeaderTimeout, err = durationEnv(internalHTTPReadHeaderTimeoutEnvVar, cfg.InternalHTTP.ReadHeaderTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.InternalHTTP.ReadTimeout, err = durationEnv(internalHTTPReadTimeoutEnvVar, cfg.InternalHTTP.ReadTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.InternalHTTP.IdleTimeout, err = durationEnv(internalHTTPIdleTimeoutEnvVar, cfg.InternalHTTP.IdleTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Redis.Addr = stringEnv(redisAddrEnvVar, cfg.Redis.Addr)
|
||||
cfg.Redis.Username = stringEnv(redisUsernameEnvVar, cfg.Redis.Username)
|
||||
cfg.Redis.Password = stringEnv(redisPasswordEnvVar, cfg.Redis.Password)
|
||||
cfg.Redis.DB, err = intEnv(redisDBEnvVar, cfg.Redis.DB)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Redis.TLSEnabled, err = boolEnv(redisTLSEnabledEnvVar, cfg.Redis.TLSEnabled)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Redis.OperationTimeout, err = durationEnv(redisOperationTimeoutEnvVar, cfg.Redis.OperationTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Redis.CommandStream = stringEnv(redisCommandStreamEnvVar, cfg.Redis.CommandStream)
|
||||
cfg.Redis.AttemptScheduleKey = stringEnv(redisAttemptScheduleEnvVar, cfg.Redis.AttemptScheduleKey)
|
||||
cfg.Redis.DeadLetterPrefix = stringEnv(redisDeadLetterPrefixEnvVar, cfg.Redis.DeadLetterPrefix)
|
||||
|
||||
cfg.SMTP.Mode = stringEnv(smtpModeEnvVar, cfg.SMTP.Mode)
|
||||
cfg.SMTP.Addr = stringEnv(smtpAddrEnvVar, cfg.SMTP.Addr)
|
||||
cfg.SMTP.Username = stringEnv(smtpUsernameEnvVar, cfg.SMTP.Username)
|
||||
cfg.SMTP.Password = stringEnv(smtpPasswordEnvVar, cfg.SMTP.Password)
|
||||
cfg.SMTP.FromEmail = stringEnv(smtpFromEmailEnvVar, cfg.SMTP.FromEmail)
|
||||
cfg.SMTP.FromName = stringEnv(smtpFromNameEnvVar, cfg.SMTP.FromName)
|
||||
cfg.SMTP.Timeout, err = durationEnv(smtpTimeoutEnvVar, cfg.SMTP.Timeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.SMTP.InsecureSkipVerify, err = boolEnv(smtpInsecureSkipVerifyEnvVar, cfg.SMTP.InsecureSkipVerify)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Templates.Dir = stringEnv(templateDirEnvVar, cfg.Templates.Dir)
|
||||
|
||||
cfg.AttemptWorkerConcurrency, err = intEnv(attemptWorkerConcurrencyEnvVar, cfg.AttemptWorkerConcurrency)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.StreamBlockTimeout, err = durationEnv(streamBlockTimeoutEnvVar, cfg.StreamBlockTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.OperatorRequestTimeout, err = durationEnv(operatorRequestTimeoutEnvVar, cfg.OperatorRequestTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.IdempotencyTTL, err = durationEnv(idempotencyTTLEnvVar, cfg.IdempotencyTTL)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.DeliveryTTL, err = durationEnv(deliveryTTLEnvVar, cfg.DeliveryTTL)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.AttemptTTL, err = durationEnv(attemptTTLEnvVar, cfg.AttemptTTL)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Telemetry.ServiceName = stringEnv(otelServiceNameEnvVar, cfg.Telemetry.ServiceName)
|
||||
cfg.Telemetry.TracesExporter = normalizeExporterValue(stringEnv(otelTracesExporterEnvVar, cfg.Telemetry.TracesExporter))
|
||||
cfg.Telemetry.MetricsExporter = normalizeExporterValue(stringEnv(otelMetricsExporterEnvVar, cfg.Telemetry.MetricsExporter))
|
||||
cfg.Telemetry.TracesProtocol = normalizeProtocolValue(
|
||||
os.Getenv(otelExporterOTLPTracesProtocolEnvVar),
|
||||
os.Getenv(otelExporterOTLPProtocolEnvVar),
|
||||
cfg.Telemetry.TracesProtocol,
|
||||
)
|
||||
cfg.Telemetry.MetricsProtocol = normalizeProtocolValue(
|
||||
os.Getenv(otelExporterOTLPMetricsProtocolEnvVar),
|
||||
os.Getenv(otelExporterOTLPProtocolEnvVar),
|
||||
cfg.Telemetry.MetricsProtocol,
|
||||
)
|
||||
cfg.Telemetry.StdoutTracesEnabled, err = boolEnv(otelStdoutTracesEnabledEnvVar, cfg.Telemetry.StdoutTracesEnabled)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Telemetry.StdoutMetricsEnabled, err = boolEnv(otelStdoutMetricsEnabledEnvVar, cfg.Telemetry.StdoutMetricsEnabled)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if err := validateSlogLevel(cfg.Logging.Level); err != nil {
|
||||
return Config{}, fmt.Errorf("%s: %w", logLevelEnvVar, err)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func stringEnv(name string, fallback string) string {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
parsed, err := time.ParseDuration(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: parse duration: %w", name, err)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func intEnv(name string, fallback int) (int, error) {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: parse int: %w", name, err)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func boolEnv(name string, fallback bool) (bool, error) {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%s: parse bool: %w", name, err)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func normalizeExporterValue(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
switch trimmed {
|
||||
case "", "none":
|
||||
return "none"
|
||||
default:
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProtocolValue(primary string, fallback string, defaultValue string) string {
|
||||
primary = strings.TrimSpace(primary)
|
||||
if primary != "" {
|
||||
return primary
|
||||
}
|
||||
|
||||
fallback = strings.TrimSpace(fallback)
|
||||
if fallback != "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return strings.TrimSpace(defaultValue)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/mail"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Validate reports whether cfg stores a usable Mail Service process
|
||||
// configuration.
|
||||
func (cfg Config) Validate() error {
|
||||
switch {
|
||||
case cfg.ShutdownTimeout <= 0:
|
||||
return fmt.Errorf("%s must be positive", shutdownTimeoutEnvVar)
|
||||
case cfg.AttemptWorkerConcurrency <= 0:
|
||||
return fmt.Errorf("%s must be positive", attemptWorkerConcurrencyEnvVar)
|
||||
case cfg.StreamBlockTimeout <= 0:
|
||||
return fmt.Errorf("%s must be positive", streamBlockTimeoutEnvVar)
|
||||
case cfg.OperatorRequestTimeout <= 0:
|
||||
return fmt.Errorf("%s must be positive", operatorRequestTimeoutEnvVar)
|
||||
case cfg.IdempotencyTTL <= 0:
|
||||
return fmt.Errorf("%s must be positive", idempotencyTTLEnvVar)
|
||||
case cfg.DeliveryTTL <= 0:
|
||||
return fmt.Errorf("%s must be positive", deliveryTTLEnvVar)
|
||||
case cfg.AttemptTTL <= 0:
|
||||
return fmt.Errorf("%s must be positive", attemptTTLEnvVar)
|
||||
}
|
||||
|
||||
if err := cfg.InternalHTTP.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.Redis.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.SMTP.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.Templates.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.Telemetry.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSlogLevel(level string) error {
|
||||
var slogLevel slog.Level
|
||||
if err := slogLevel.UnmarshalText([]byte(strings.TrimSpace(level))); err != nil {
|
||||
return fmt.Errorf("invalid slog level %q: %w", level, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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 !strings.Contains(host, " ")
|
||||
}
|
||||
|
||||
func validateMailbox(name string, value string) error {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("%s must not be empty", name)
|
||||
}
|
||||
|
||||
parsed, err := mail.ParseAddress(trimmed)
|
||||
if err != nil || parsed == nil || parsed.Name != "" || parsed.Address != trimmed {
|
||||
return fmt.Errorf("%s %q must be a single valid email address", name, value)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user