package app import ( "context" "errors" "fmt" "log/slog" "time" "galaxy/mail/internal/adapters/id" "galaxy/mail/internal/adapters/postgres/mailstore" "galaxy/mail/internal/adapters/postgres/migrations" "galaxy/mail/internal/adapters/redisstate" templatedir "galaxy/mail/internal/adapters/templates" "galaxy/mail/internal/api/internalhttp" "galaxy/mail/internal/config" "galaxy/mail/internal/ports" "galaxy/mail/internal/service/acceptauthdelivery" "galaxy/mail/internal/service/acceptgenericdelivery" "galaxy/mail/internal/service/executeattempt" "galaxy/mail/internal/service/getdelivery" "galaxy/mail/internal/service/listattempts" "galaxy/mail/internal/service/listdeliveries" "galaxy/mail/internal/service/renderdelivery" "galaxy/mail/internal/service/resenddelivery" "galaxy/mail/internal/telemetry" "galaxy/mail/internal/worker" "galaxy/postgres" "github.com/redis/go-redis/v9" ) // Runtime owns the runnable Mail Service process plus the cleanup functions // that release runtime resources after shutdown. type Runtime struct { cfg config.Config app *App templateCatalog *templatedir.Catalog renderDeliveryService *renderdelivery.Service cleanupFns []func() error } type runtimeClock interface { Now() time.Time } type runtimeProviderFactory func(config.SMTPConfig, *slog.Logger) (ports.Provider, error) type runtimeDependencies struct { clock runtimeClock providerFactory runtimeProviderFactory schedulerPoll time.Duration schedulerRecovery time.Duration schedulerGrace time.Duration } func (deps runtimeDependencies) withDefaults() runtimeDependencies { if deps.clock == nil { deps.clock = systemClock{} } if deps.providerFactory == nil { deps.providerFactory = newProvider } return deps } // NewRuntime constructs the runnable Mail Service process from cfg. func NewRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger) (*Runtime, error) { return newRuntime(ctx, cfg, logger, runtimeDependencies{}) } func newRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger, deps runtimeDependencies) (*Runtime, error) { if ctx == nil { return nil, fmt.Errorf("new mail runtime: nil context") } if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("new mail runtime: %w", err) } if logger == nil { logger = slog.Default() } deps = deps.withDefaults() runtime := &Runtime{ cfg: cfg, } cleanupOnError := func(err error) (*Runtime, error) { if cleanupErr := runtime.Close(); cleanupErr != nil { return nil, fmt.Errorf("%w; cleanup: %w", err, cleanupErr) } return nil, err } telemetryRuntime, err := telemetry.NewProcess(ctx, telemetry.ProcessConfig{ ServiceName: cfg.Telemetry.ServiceName, TracesExporter: cfg.Telemetry.TracesExporter, MetricsExporter: cfg.Telemetry.MetricsExporter, TracesProtocol: cfg.Telemetry.TracesProtocol, MetricsProtocol: cfg.Telemetry.MetricsProtocol, StdoutTracesEnabled: cfg.Telemetry.StdoutTracesEnabled, StdoutMetricsEnabled: cfg.Telemetry.StdoutMetricsEnabled, }, logger) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: telemetry: %w", err)) } runtime.cleanupFns = append(runtime.cleanupFns, func() error { shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) defer cancel() return telemetryRuntime.Shutdown(shutdownCtx) }) // Open one shared Redis master client. The command consumer, the stream // offset store, and the malformed-command recorder all borrow it. redisClient := newRedisClient(cfg.Redis) if err := instrumentRedisClient(redisClient, telemetryRuntime); err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: %w", err)) } runtime.cleanupFns = append(runtime.cleanupFns, func() error { if err := redisClient.Close(); err != nil && !errors.Is(err, redis.ErrClosed) { return err } return nil }) if err := pingRedis(ctx, cfg.Redis, redisClient); err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: %w", err)) } // Open the PostgreSQL pool, attach instrumentation, ping it, run embedded // migrations strictly before any HTTP listener opens. A failure at any of // these steps is fatal. pgPool, err := postgres.OpenPrimary(ctx, cfg.Postgres.Conn, postgres.WithTracerProvider(telemetryRuntime.TracerProvider()), postgres.WithMeterProvider(telemetryRuntime.MeterProvider()), ) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: open postgres primary: %w", err)) } runtime.cleanupFns = append(runtime.cleanupFns, pgPool.Close) unregisterDBStats, err := postgres.InstrumentDBStats(pgPool, postgres.WithMeterProvider(telemetryRuntime.MeterProvider()), ) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: instrument postgres db stats: %w", err)) } runtime.cleanupFns = append(runtime.cleanupFns, unregisterDBStats) if err := postgres.Ping(ctx, pgPool, cfg.Postgres.Conn.OperationTimeout); err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: %w", err)) } if err := postgres.RunMigrations(ctx, pgPool, migrations.FS(), "."); err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: run postgres migrations: %w", err)) } store, err := mailstore.New(mailstore.Config{ DB: pgPool, OperationTimeout: cfg.Postgres.Conn.OperationTimeout, }) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: postgres mail store: %w", err)) } if err := store.Ping(ctx); err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: ping postgres mail store: %w", err)) } templateCatalog, err := newTemplateCatalog(cfg.Templates) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: %w", err)) } runtime.templateCatalog = templateCatalog provider, err := deps.providerFactory(cfg.SMTP, logger.With("component", "provider")) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: %w", err)) } runtime.cleanupFns = append(runtime.cleanupFns, provider.Close) authAcceptanceService, err := acceptauthdelivery.New(acceptauthdelivery.Config{ Store: store, DeliveryIDGenerator: id.Generator{}, Clock: deps.clock, Telemetry: telemetryRuntime, TracerProvider: telemetryRuntime.TracerProvider(), Logger: logger, IdempotencyTTL: cfg.IdempotencyTTL, SuppressOutbound: cfg.SMTP.Mode == config.SMTPModeStub, }) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: auth acceptance service: %w", err)) } genericAcceptanceService, err := acceptgenericdelivery.New(acceptgenericdelivery.Config{ Store: store.GenericAcceptance(), Clock: deps.clock, Telemetry: telemetryRuntime, TracerProvider: telemetryRuntime.TracerProvider(), Logger: logger, IdempotencyTTL: cfg.IdempotencyTTL, }) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: generic acceptance service: %w", err)) } renderDeliveryService, err := renderdelivery.New(renderdelivery.Config{ Catalog: templateCatalog, Store: store.RenderDelivery(), Clock: deps.clock, Telemetry: telemetryRuntime, TracerProvider: telemetryRuntime.TracerProvider(), Logger: logger, }) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: render delivery service: %w", err)) } runtime.renderDeliveryService = renderDeliveryService streamOffsetStore, err := redisstate.NewStreamOffsetStore(redisClient) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: stream offset store: %w", err)) } attemptExecutionStore := store.AttemptExecution() telemetryRuntime.SetAttemptScheduleSnapshotReader(attemptExecutionStore) attemptExecutionService, err := executeattempt.New(executeattempt.Config{ Renderer: renderDeliveryService, Provider: provider, PayloadLoader: store, Store: attemptExecutionStore, Clock: deps.clock, Telemetry: telemetryRuntime, TracerProvider: telemetryRuntime.TracerProvider(), Logger: logger, AttemptTimeout: cfg.SMTP.Timeout, }) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: attempt execution service: %w", err)) } listDeliveriesService, err := listdeliveries.New(listdeliveries.Config{ Store: store, }) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: list deliveries service: %w", err)) } getDeliveryService, err := getdelivery.New(getdelivery.Config{ Store: store, }) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: get delivery service: %w", err)) } listAttemptsService, err := listattempts.New(listattempts.Config{ Store: store, }) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: list attempts service: %w", err)) } resendDeliveryService, err := resenddelivery.New(resenddelivery.Config{ Store: store, DeliveryIDGenerator: id.Generator{}, Clock: deps.clock, Telemetry: telemetryRuntime, TracerProvider: telemetryRuntime.TracerProvider(), Logger: logger, }) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: resend delivery service: %w", err)) } httpServer, err := internalhttp.NewServer(internalhttp.Config{ Addr: cfg.InternalHTTP.Addr, ReadHeaderTimeout: cfg.InternalHTTP.ReadHeaderTimeout, ReadTimeout: cfg.InternalHTTP.ReadTimeout, IdleTimeout: cfg.InternalHTTP.IdleTimeout, }, internalhttp.Dependencies{ Logger: logger, Telemetry: telemetryRuntime, AcceptLoginCodeDelivery: authAcceptanceService, ListDeliveries: listDeliveriesService, GetDelivery: getDeliveryService, ListAttempts: listAttemptsService, ResendDelivery: resendDeliveryService, OperatorRequestTimeout: cfg.OperatorRequestTimeout, }) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: internal HTTP server: %w", err)) } commandConsumer, err := worker.NewCommandConsumer(worker.CommandConsumerConfig{ Client: redisClient, Stream: cfg.Redis.CommandStream, BlockTimeout: cfg.StreamBlockTimeout, Acceptor: genericAcceptanceService, MalformedRecorder: store, OffsetStore: streamOffsetStore, Telemetry: telemetryRuntime, Clock: deps.clock, }, logger) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: command consumer: %w", err)) } attemptWorkQueue := make(chan executeattempt.WorkItem, cfg.AttemptWorkerConcurrency) scheduler, err := worker.NewScheduler(worker.SchedulerConfig{ Store: attemptExecutionStore, Service: attemptExecutionService, WorkQueue: attemptWorkQueue, Clock: deps.clock, AttemptTimeout: cfg.SMTP.Timeout, Telemetry: telemetryRuntime, PollInterval: deps.schedulerPoll, RecoveryInterval: deps.schedulerRecovery, RecoveryGrace: deps.schedulerGrace, }, logger) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: scheduler: %w", err)) } attemptWorkers, err := worker.NewAttemptWorkerPool(worker.AttemptWorkerPoolConfig{ Concurrency: cfg.AttemptWorkerConcurrency, WorkQueue: attemptWorkQueue, Service: attemptExecutionService, }, logger) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: attempt worker pool: %w", err)) } retentionWorker, err := worker.NewSQLRetentionWorker(worker.SQLRetentionConfig{ Store: store, DeliveryRetention: cfg.Retention.DeliveryRetention, MalformedCommandRetention: cfg.Retention.MalformedCommandRetention, CleanupInterval: cfg.Retention.CleanupInterval, Clock: deps.clock, }, logger) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: sql retention worker: %w", err)) } runtime.app = New(cfg, httpServer, commandConsumer, scheduler, attemptWorkers, retentionWorker) return runtime, nil } type systemClock struct{} func (systemClock) Now() time.Time { return time.Now() } // Run serves the internal HTTP listener and background workers until ctx is // canceled or one component fails. func (runtime *Runtime) Run(ctx context.Context) error { if ctx == nil { return errors.New("run mail runtime: nil context") } if runtime == nil { return errors.New("run mail runtime: nil runtime") } if runtime.app == nil { return errors.New("run mail runtime: nil app") } return runtime.app.Run(ctx) } // Close releases every runtime dependency in reverse construction order. func (runtime *Runtime) Close() error { if runtime == nil { return nil } var joined error for index := len(runtime.cleanupFns) - 1; index >= 0; index-- { if err := runtime.cleanupFns[index](); err != nil { joined = errors.Join(joined, err) } } return joined }