Files
2026-05-06 10:14:55 +03:00

95 lines
3.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}