feat: backend service
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user