feat: mail service

This commit is contained in:
Ilia Denisov
2026-04-17 18:39:16 +02:00
committed by GitHub
parent 23ffcb7535
commit 5b7593e6f6
183 changed files with 31215 additions and 248 deletions
+370
View File
@@ -0,0 +1,370 @@
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
}