feat: backend service

This commit is contained in:
Ilia Denisov
2026-05-06 10:14:55 +03:00
committed by GitHub
parent 3e2622757e
commit f446c6a2ac
1486 changed files with 49720 additions and 266401 deletions
+94
View File
@@ -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)
}