// Package mail implements the durable mail outbox documented in // `backend/PLAN.md` §5.6 and `backend/README.md` §9. Producers call // EnqueueLoginCode or EnqueueTemplate; the rows land in // `backend.mail_deliveries` together with their payload and recipients. // A single Worker goroutine claims due rows with // `SELECT … FOR UPDATE SKIP LOCKED`, sends them through SMTP via the // `wneessen/go-mail` library, records every attempt, and dead-letters // rows that exceed the configured maximum. // // Until The implementation lands the notification module, the AdminNotifier // dependency is satisfied by NewNoopAdminNotifier — same pattern auth // uses for LoginCodeMailer and SessionInvalidator. package mail import ( "context" "galaxy/backend/internal/config" "time" "go.uber.org/zap" ) // Service is the mail-domain entry point. It wires the persistence // store, the SMTP sender, the admin-notification publisher used on // dead-letter, the runtime configuration, and a structured logger. type Service struct { deps Deps } // NewService constructs a Service from deps. A nil Now defaults to // time.Now; a nil Logger defaults to zap.NewNop. Store and SMTP must be // supplied — calling Service methods with either nil panics on first // use, matching how the rest of `internal/*` signals missing wiring. func NewService(deps Deps) *Service { if deps.Now == nil { deps.Now = time.Now } if deps.Logger == nil { deps.Logger = zap.NewNop() } deps.Logger = deps.Logger.Named("mail") if deps.Admin == nil { deps.Admin = NewNoopAdminNotifier(deps.Logger) } return &Service{deps: deps} } // Backoff parameters for the worker retry schedule. The values match // the trade-off documented in `backend/README.md` and `backend/docs/`: a 5 // second base, ×2 growth, capped at one hour, with ±25% jitter. const ( backoffBase = 5 * time.Second backoffFactor = 2.0 backoffMax = time.Hour backoffJitter = 0.25 ) // Status values stored in `mail_deliveries.status`. Mirrored by the // CHECK constraint added in migration 00001. const ( StatusPending = "pending" StatusRetrying = "retrying" StatusSent = "sent" StatusDeadLettered = "dead_lettered" ) // Outcome values stored in `mail_attempts.outcome`. Mirrored by the // CHECK constraint added in migration 00001. const ( OutcomeSuccess = "success" OutcomeTransientError = "transient_error" OutcomePermanentError = "permanent_error" ) // Recipient kinds stored in `mail_recipients.kind`. The 5.6 // implementation only emits 'to'; cc/bcc/reply_to remain available // for future producers. const ( RecipientKindTo = "to" ) // Config returns the runtime mail configuration. Worker uses it to // schedule the scan loop and bound retries. func (s *Service) Config() config.MailConfig { return s.deps.Config } // Stats returns the live count of `mail_deliveries` rows grouped by // status. The metricsapi server reads this through the Service so // `mail_outbox_depth{state}` (README §15) does not require the worker // to publish gauges from inside its hot path. func (s *Service) Stats(ctx context.Context) (map[string]int64, error) { return s.deps.Store.CountByStatus(ctx) }