package app import ( "context" "errors" "fmt" "log/slog" "time" "galaxy/mail/internal/adapters/id" "galaxy/mail/internal/adapters/redisstate" templatedir "galaxy/mail/internal/adapters/templates" "galaxy/mail/internal/api/internalhttp" "galaxy/mail/internal/config" "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/mail/internal/ports" "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) }) 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 { return redisClient.Close() }) if err := pingRedis(ctx, cfg.Redis, redisClient); err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: %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) acceptanceStore, err := redisstate.NewAcceptanceStore(redisClient) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: auth acceptance store: %w", err)) } authAcceptanceService, err := acceptauthdelivery.New(acceptauthdelivery.Config{ Store: acceptanceStore, DeliveryIDGenerator: id.Generator{}, Clock: deps.clock, Telemetry: telemetryRuntime, TracerProvider: telemetryRuntime.TracerProvider(), Logger: logger, IdempotencyTTL: redisstate.IdempotencyTTL, SuppressOutbound: cfg.SMTP.Mode == config.SMTPModeStub, }) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: auth acceptance service: %w", err)) } genericAcceptanceStore, err := redisstate.NewGenericAcceptanceStore(redisClient) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: generic acceptance store: %w", err)) } genericAcceptanceService, err := acceptgenericdelivery.New(acceptgenericdelivery.Config{ Store: genericAcceptanceStore, Clock: deps.clock, Telemetry: telemetryRuntime, TracerProvider: telemetryRuntime.TracerProvider(), Logger: logger, IdempotencyTTL: redisstate.IdempotencyTTL, }) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: generic acceptance service: %w", err)) } renderStore, err := redisstate.NewRenderStore(redisClient) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: render store: %w", err)) } renderDeliveryService, err := renderdelivery.New(renderdelivery.Config{ Catalog: templateCatalog, Store: renderStore, 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 malformedCommandStore, err := redisstate.NewMalformedCommandStore(redisClient) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: malformed command store: %w", err)) } streamOffsetStore, err := redisstate.NewStreamOffsetStore(redisClient) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: stream offset store: %w", err)) } attemptExecutionStore, err := redisstate.NewAttemptExecutionStore(redisClient) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: attempt execution store: %w", err)) } telemetryRuntime.SetAttemptScheduleSnapshotReader(attemptExecutionStore) operatorStore, err := redisstate.NewOperatorStore(redisClient) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: operator store: %w", err)) } attemptExecutionService, err := executeattempt.New(executeattempt.Config{ Renderer: renderDeliveryService, Provider: provider, PayloadLoader: attemptExecutionStore, 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: operatorStore, }) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: list deliveries service: %w", err)) } getDeliveryService, err := getdelivery.New(getdelivery.Config{ Store: operatorStore, }) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: get delivery service: %w", err)) } listAttemptsService, err := listattempts.New(listattempts.Config{ Store: operatorStore, }) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: list attempts service: %w", err)) } resendDeliveryService, err := resenddelivery.New(resenddelivery.Config{ Store: operatorStore, 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)) } commandConsumerRedisClient := newRedisClient(cfg.Redis) if err := instrumentRedisClient(commandConsumerRedisClient, telemetryRuntime); err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: %w", err)) } runtime.cleanupFns = append(runtime.cleanupFns, func() error { err := commandConsumerRedisClient.Close() if errors.Is(err, redis.ErrClosed) { return nil } return err }) if err := pingRedis(ctx, cfg.Redis, commandConsumerRedisClient); err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: %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: commandConsumerRedisClient, Stream: cfg.Redis.CommandStream, BlockTimeout: cfg.StreamBlockTimeout, Acceptor: genericAcceptanceService, MalformedRecorder: malformedCommandStore, 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)) } indexCleaner, err := redisstate.NewIndexCleaner(redisClient) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: cleanup index cleaner: %w", err)) } cleanupWorker, err := worker.NewCleanupWorker(indexCleaner, logger) if err != nil { return cleanupOnError(fmt.Errorf("new mail runtime: cleanup worker: %w", err)) } runtime.app = New(cfg, httpServer, commandConsumer, scheduler, attemptWorkers, cleanupWorker) 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 }