feat: mail service
This commit is contained in:
@@ -0,0 +1,544 @@
|
||||
// Package acceptauthdelivery implements synchronous durable acceptance of auth
|
||||
// login-code deliveries.
|
||||
package acceptauthdelivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/domain/idempotency"
|
||||
"galaxy/mail/internal/logging"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
oteltrace "go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrConflict reports that the idempotency scope already belongs to a
|
||||
// different normalized auth request.
|
||||
ErrConflict = errors.New("accept auth delivery conflict")
|
||||
|
||||
// ErrServiceUnavailable reports that durable acceptance could not be
|
||||
// completed or recovered safely.
|
||||
ErrServiceUnavailable = errors.New("accept auth delivery service unavailable")
|
||||
)
|
||||
|
||||
const (
|
||||
// AuthTemplateID is the dedicated template family used for auth login-code
|
||||
// deliveries.
|
||||
AuthTemplateID common.TemplateID = "auth.login_code"
|
||||
|
||||
maxCreateRetries = 3
|
||||
tracerName = "galaxy/mail/acceptauthdelivery"
|
||||
)
|
||||
|
||||
// Outcome identifies the stable auth-delivery acceptance outcome.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
// OutcomeSent reports that the delivery was accepted into the durable
|
||||
// internal pipeline.
|
||||
OutcomeSent Outcome = "sent"
|
||||
|
||||
// OutcomeSuppressed reports that outward delivery was intentionally skipped
|
||||
// while the auth flow remained success-shaped.
|
||||
OutcomeSuppressed Outcome = "suppressed"
|
||||
)
|
||||
|
||||
// IsKnown reports whether outcome belongs to the stable auth-delivery surface.
|
||||
func (outcome Outcome) IsKnown() bool {
|
||||
switch outcome {
|
||||
case OutcomeSent, OutcomeSuppressed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Result stores the coarse auth-delivery acceptance outcome.
|
||||
type Result struct {
|
||||
// Outcome stores the stable auth-delivery result.
|
||||
Outcome Outcome
|
||||
}
|
||||
|
||||
// Validate reports whether result contains a supported auth-delivery outcome.
|
||||
func (result Result) Validate() error {
|
||||
if !result.Outcome.IsKnown() {
|
||||
return fmt.Errorf("accept auth delivery outcome %q is unsupported", result.Outcome)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Input stores one normalized auth-delivery acceptance command.
|
||||
type Input struct {
|
||||
// IdempotencyKey stores the caller-owned stable deduplication key.
|
||||
IdempotencyKey common.IdempotencyKey
|
||||
|
||||
// Email stores the normalized recipient mailbox.
|
||||
Email common.Email
|
||||
|
||||
// Code stores the exact login code.
|
||||
Code string
|
||||
|
||||
// Locale stores the canonical BCP 47 language tag selected upstream.
|
||||
Locale common.Locale
|
||||
}
|
||||
|
||||
// Validate reports whether input contains one valid auth-delivery command.
|
||||
func (input Input) Validate() error {
|
||||
if err := input.IdempotencyKey.Validate(); err != nil {
|
||||
return fmt.Errorf("idempotency key: %w", err)
|
||||
}
|
||||
if err := input.Email.Validate(); err != nil {
|
||||
return fmt.Errorf("email: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(input.Code) == "" {
|
||||
return errors.New("code must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(input.Code) != input.Code {
|
||||
return errors.New("code must not contain surrounding whitespace")
|
||||
}
|
||||
if err := input.Locale.Validate(); err != nil {
|
||||
return fmt.Errorf("locale: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fingerprint returns the stable idempotency fingerprint of input.
|
||||
func (input Input) Fingerprint() (string, error) {
|
||||
if err := input.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
normalized := struct {
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
Locale string `json:"locale"`
|
||||
}{
|
||||
IdempotencyKey: input.IdempotencyKey.String(),
|
||||
Email: input.Email.String(),
|
||||
Code: input.Code,
|
||||
Locale: input.Locale.String(),
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal auth-delivery fingerprint: %w", err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(payload)
|
||||
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// CreateAcceptanceInput stores the durable write set required for one
|
||||
// auth-delivery acceptance attempt.
|
||||
type CreateAcceptanceInput struct {
|
||||
// Delivery stores the accepted delivery record.
|
||||
Delivery deliverydomain.Delivery
|
||||
|
||||
// FirstAttempt stores the optional first scheduled attempt.
|
||||
FirstAttempt *attempt.Attempt
|
||||
|
||||
// Idempotency stores the idempotency reservation bound to Delivery.
|
||||
Idempotency idempotency.Record
|
||||
}
|
||||
|
||||
// Validate reports whether input contains a consistent durable write set.
|
||||
func (input CreateAcceptanceInput) Validate() error {
|
||||
if err := input.Delivery.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery: %w", err)
|
||||
}
|
||||
if err := input.Idempotency.Validate(); err != nil {
|
||||
return fmt.Errorf("idempotency: %w", err)
|
||||
}
|
||||
if input.Idempotency.DeliveryID != input.Delivery.DeliveryID {
|
||||
return errors.New("idempotency delivery id must match delivery id")
|
||||
}
|
||||
if input.Idempotency.Source != input.Delivery.Source {
|
||||
return errors.New("idempotency source must match delivery source")
|
||||
}
|
||||
if input.Idempotency.IdempotencyKey != input.Delivery.IdempotencyKey {
|
||||
return errors.New("idempotency key must match delivery idempotency key")
|
||||
}
|
||||
|
||||
switch {
|
||||
case input.FirstAttempt == nil:
|
||||
if input.Delivery.Status != deliverydomain.StatusSuppressed {
|
||||
return errors.New("first attempt must not be nil unless delivery is suppressed")
|
||||
}
|
||||
case input.Delivery.Status == deliverydomain.StatusSuppressed:
|
||||
return errors.New("suppressed delivery must not create first attempt")
|
||||
default:
|
||||
if err := input.FirstAttempt.Validate(); err != nil {
|
||||
return fmt.Errorf("first attempt: %w", err)
|
||||
}
|
||||
if input.FirstAttempt.DeliveryID != input.Delivery.DeliveryID {
|
||||
return errors.New("first attempt delivery id must match delivery id")
|
||||
}
|
||||
if input.FirstAttempt.Status != attempt.StatusScheduled {
|
||||
return fmt.Errorf("first attempt status must be %q", attempt.StatusScheduled)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Store describes the durable storage required by the auth-delivery use case.
|
||||
type Store interface {
|
||||
// CreateAcceptance stores the complete durable write set for one auth
|
||||
// acceptance attempt. Implementations must wrap ErrConflict when the write
|
||||
// set races with an already accepted idempotency scope.
|
||||
CreateAcceptance(context.Context, CreateAcceptanceInput) error
|
||||
|
||||
// GetIdempotency loads the idempotency reservation for one auth-delivery
|
||||
// scope.
|
||||
GetIdempotency(context.Context, deliverydomain.Source, common.IdempotencyKey) (idempotency.Record, bool, error)
|
||||
|
||||
// GetDelivery loads one accepted delivery by its internal identifier.
|
||||
GetDelivery(context.Context, common.DeliveryID) (deliverydomain.Delivery, bool, error)
|
||||
}
|
||||
|
||||
// DeliveryIDGenerator describes the source of new internal delivery
|
||||
// identifiers.
|
||||
type DeliveryIDGenerator interface {
|
||||
// NewDeliveryID returns one new internal delivery identifier.
|
||||
NewDeliveryID() (common.DeliveryID, error)
|
||||
}
|
||||
|
||||
// Clock provides the current wall-clock time.
|
||||
type Clock interface {
|
||||
// Now returns the current time.
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
// Telemetry records low-cardinality auth-delivery outcomes.
|
||||
type Telemetry interface {
|
||||
// RecordAuthDeliveryOutcome records one coarse auth-delivery outcome.
|
||||
RecordAuthDeliveryOutcome(context.Context, string)
|
||||
|
||||
// RecordAcceptedAuthDelivery records one newly accepted auth delivery.
|
||||
RecordAcceptedAuthDelivery(context.Context)
|
||||
|
||||
// RecordDeliveryStatusTransition records one durable delivery status
|
||||
// transition.
|
||||
RecordDeliveryStatusTransition(context.Context, string, string)
|
||||
}
|
||||
|
||||
// Config stores the dependencies and policy switches used by Service.
|
||||
type Config struct {
|
||||
// Store owns the durable accepted state.
|
||||
Store Store
|
||||
|
||||
// DeliveryIDGenerator builds internal delivery identifiers.
|
||||
DeliveryIDGenerator DeliveryIDGenerator
|
||||
|
||||
// Clock provides wall-clock timestamps.
|
||||
Clock Clock
|
||||
|
||||
// Telemetry records low-cardinality acceptance outcomes.
|
||||
Telemetry Telemetry
|
||||
|
||||
// TracerProvider constructs the application span recorder used by the auth
|
||||
// acceptance flow.
|
||||
TracerProvider oteltrace.TracerProvider
|
||||
|
||||
// Logger writes structured auth acceptance logs.
|
||||
Logger *slog.Logger
|
||||
|
||||
// IdempotencyTTL stores how long accepted idempotency scopes remain valid.
|
||||
IdempotencyTTL time.Duration
|
||||
|
||||
// SuppressOutbound reports whether new auth-deliveries should be accepted
|
||||
// directly as suppressed.
|
||||
SuppressOutbound bool
|
||||
}
|
||||
|
||||
// Service accepts auth login-code deliveries synchronously and durably.
|
||||
type Service struct {
|
||||
store Store
|
||||
deliveryIDGenerator DeliveryIDGenerator
|
||||
clock Clock
|
||||
telemetry Telemetry
|
||||
tracerProvider oteltrace.TracerProvider
|
||||
logger *slog.Logger
|
||||
idempotencyTTL time.Duration
|
||||
suppressOutbound bool
|
||||
}
|
||||
|
||||
// New constructs Service from cfg.
|
||||
func New(cfg Config) (*Service, error) {
|
||||
switch {
|
||||
case cfg.Store == nil:
|
||||
return nil, errors.New("new accept auth delivery service: nil store")
|
||||
case cfg.DeliveryIDGenerator == nil:
|
||||
return nil, errors.New("new accept auth delivery service: nil delivery id generator")
|
||||
case cfg.Clock == nil:
|
||||
return nil, errors.New("new accept auth delivery service: nil clock")
|
||||
case cfg.IdempotencyTTL <= 0:
|
||||
return nil, errors.New("new accept auth delivery service: non-positive idempotency ttl")
|
||||
default:
|
||||
tracerProvider := cfg.TracerProvider
|
||||
if tracerProvider == nil {
|
||||
tracerProvider = otel.GetTracerProvider()
|
||||
}
|
||||
logger := cfg.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &Service{
|
||||
store: cfg.Store,
|
||||
deliveryIDGenerator: cfg.DeliveryIDGenerator,
|
||||
clock: cfg.Clock,
|
||||
telemetry: cfg.Telemetry,
|
||||
tracerProvider: tracerProvider,
|
||||
logger: logger.With("component", "accept_auth_delivery"),
|
||||
idempotencyTTL: cfg.IdempotencyTTL,
|
||||
suppressOutbound: cfg.SuppressOutbound,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Execute accepts one auth login-code delivery command.
|
||||
func (service *Service) Execute(ctx context.Context, input Input) (Result, error) {
|
||||
if ctx == nil {
|
||||
return Result{}, errors.New("accept auth delivery: nil context")
|
||||
}
|
||||
if service == nil {
|
||||
return Result{}, errors.New("accept auth delivery: nil service")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return Result{}, fmt.Errorf("accept auth delivery: %w", err)
|
||||
}
|
||||
|
||||
ctx, span := service.tracerProvider.Tracer(tracerName).Start(ctx, "mail.accept_auth_delivery")
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.String("mail.locale", input.Locale.String()),
|
||||
)
|
||||
|
||||
fingerprint, err := input.Fingerprint()
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("accept auth delivery: %w", err)
|
||||
}
|
||||
|
||||
if result, handled, err := service.resolveReplay(ctx, input.IdempotencyKey, fingerprint); handled {
|
||||
if err != nil {
|
||||
service.recordOutcome(ctx, replayOutcomeForError(err))
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
service.recordOutcome(ctx, "duplicate")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
for range maxCreateRetries {
|
||||
createInput, result, err := service.buildCreateInput(input, fingerprint)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("accept auth delivery: %w", err)
|
||||
}
|
||||
|
||||
if err := service.store.CreateAcceptance(ctx, createInput); err != nil {
|
||||
if !errors.Is(err, ErrConflict) {
|
||||
service.recordOutcome(ctx, "service_unavailable")
|
||||
return Result{}, fmt.Errorf("%w: create acceptance: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
|
||||
if replayResult, handled, replayErr := service.resolveReplay(ctx, input.IdempotencyKey, fingerprint); handled {
|
||||
if replayErr != nil {
|
||||
service.recordOutcome(ctx, replayOutcomeForError(replayErr))
|
||||
return Result{}, replayErr
|
||||
}
|
||||
|
||||
service.recordOutcome(ctx, "duplicate")
|
||||
return replayResult, nil
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
service.recordOutcome(ctx, string(result.Outcome))
|
||||
service.recordAcceptedDelivery(ctx)
|
||||
service.recordStatusTransition(ctx, createInput.Delivery)
|
||||
span.SetAttributes(
|
||||
attribute.String("mail.delivery_id", createInput.Delivery.DeliveryID.String()),
|
||||
attribute.String("mail.source", string(createInput.Delivery.Source)),
|
||||
attribute.String("mail.status", string(createInput.Delivery.Status)),
|
||||
)
|
||||
logArgs := logging.DeliveryAttrs(createInput.Delivery)
|
||||
logArgs = append(logArgs,
|
||||
"status", string(createInput.Delivery.Status),
|
||||
"outcome", string(result.Outcome),
|
||||
"locale", input.Locale.String(),
|
||||
)
|
||||
logArgs = append(logArgs, logging.TraceAttrsFromContext(ctx)...)
|
||||
service.logger.Info("auth delivery accepted", logArgs...)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
service.recordOutcome(ctx, "service_unavailable")
|
||||
return Result{}, fmt.Errorf("%w: delivery id conflict retry limit exceeded", ErrServiceUnavailable)
|
||||
}
|
||||
|
||||
func (service *Service) buildCreateInput(input Input, fingerprint string) (CreateAcceptanceInput, Result, error) {
|
||||
now := service.clock.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
deliveryID, err := service.deliveryIDGenerator.NewDeliveryID()
|
||||
if err != nil {
|
||||
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("%w: generate delivery id: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
|
||||
deliveryRecord := deliverydomain.Delivery{
|
||||
DeliveryID: deliveryID,
|
||||
Source: deliverydomain.SourceAuthSession,
|
||||
PayloadMode: deliverydomain.PayloadModeTemplate,
|
||||
TemplateID: AuthTemplateID,
|
||||
Envelope: deliverydomain.Envelope{To: []common.Email{input.Email}},
|
||||
Locale: input.Locale,
|
||||
TemplateVariables: map[string]any{
|
||||
"code": input.Code,
|
||||
},
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
result := Result{}
|
||||
var firstAttempt *attempt.Attempt
|
||||
|
||||
if service.suppressOutbound {
|
||||
deliveryRecord.Status = deliverydomain.StatusSuppressed
|
||||
deliveryRecord.SuppressedAt = ptrTime(now)
|
||||
result.Outcome = OutcomeSuppressed
|
||||
} else {
|
||||
deliveryRecord.Status = deliverydomain.StatusQueued
|
||||
deliveryRecord.AttemptCount = 1
|
||||
scheduledAttempt := attempt.Attempt{
|
||||
DeliveryID: deliveryID,
|
||||
AttemptNo: 1,
|
||||
ScheduledFor: now,
|
||||
Status: attempt.StatusScheduled,
|
||||
}
|
||||
firstAttempt = &scheduledAttempt
|
||||
result.Outcome = OutcomeSent
|
||||
}
|
||||
|
||||
if err := deliveryRecord.Validate(); err != nil {
|
||||
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build auth delivery record: %w", err)
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build auth delivery result: %w", err)
|
||||
}
|
||||
|
||||
createInput := CreateAcceptanceInput{
|
||||
Delivery: deliveryRecord,
|
||||
FirstAttempt: firstAttempt,
|
||||
Idempotency: idempotency.Record{
|
||||
Source: deliverydomain.SourceAuthSession,
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
DeliveryID: deliveryID,
|
||||
RequestFingerprint: fingerprint,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(service.idempotencyTTL),
|
||||
},
|
||||
}
|
||||
if err := createInput.Validate(); err != nil {
|
||||
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build auth create input: %w", err)
|
||||
}
|
||||
|
||||
return createInput, result, nil
|
||||
}
|
||||
|
||||
func (service *Service) recordAcceptedDelivery(ctx context.Context) {
|
||||
if service == nil || service.telemetry == nil {
|
||||
return
|
||||
}
|
||||
|
||||
service.telemetry.RecordAcceptedAuthDelivery(ctx)
|
||||
}
|
||||
|
||||
func (service *Service) recordStatusTransition(ctx context.Context, record deliverydomain.Delivery) {
|
||||
if service == nil || service.telemetry == nil {
|
||||
return
|
||||
}
|
||||
|
||||
service.telemetry.RecordDeliveryStatusTransition(ctx, string(record.Status), string(record.Source))
|
||||
}
|
||||
|
||||
func (service *Service) resolveReplay(ctx context.Context, key common.IdempotencyKey, fingerprint string) (Result, bool, error) {
|
||||
record, found, err := service.store.GetIdempotency(ctx, deliverydomain.SourceAuthSession, key)
|
||||
if err != nil {
|
||||
return Result{}, true, fmt.Errorf("%w: load idempotency: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
if !found {
|
||||
return Result{}, false, nil
|
||||
}
|
||||
if record.RequestFingerprint != fingerprint {
|
||||
return Result{}, true, fmt.Errorf("%w: request conflicts with current state", ErrConflict)
|
||||
}
|
||||
|
||||
deliveryRecord, found, err := service.store.GetDelivery(ctx, record.DeliveryID)
|
||||
if err != nil {
|
||||
return Result{}, true, fmt.Errorf("%w: load delivery: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
if !found {
|
||||
return Result{}, true, fmt.Errorf("%w: delivery %q is missing for idempotency scope", ErrServiceUnavailable, record.DeliveryID)
|
||||
}
|
||||
|
||||
return deriveReplayResult(deliveryRecord)
|
||||
}
|
||||
|
||||
func deriveReplayResult(record deliverydomain.Delivery) (Result, bool, error) {
|
||||
switch record.Status {
|
||||
case deliverydomain.StatusSuppressed:
|
||||
return Result{Outcome: OutcomeSuppressed}, true, nil
|
||||
case deliverydomain.StatusAccepted,
|
||||
deliverydomain.StatusQueued,
|
||||
deliverydomain.StatusRendered,
|
||||
deliverydomain.StatusSending,
|
||||
deliverydomain.StatusSent,
|
||||
deliverydomain.StatusFailed,
|
||||
deliverydomain.StatusDeadLetter:
|
||||
return Result{Outcome: OutcomeSent}, true, nil
|
||||
default:
|
||||
return Result{}, true, fmt.Errorf("%w: unsupported replay delivery status %q", ErrServiceUnavailable, record.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) recordOutcome(ctx context.Context, outcome string) {
|
||||
if service == nil || service.telemetry == nil || strings.TrimSpace(outcome) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
service.telemetry.RecordAuthDeliveryOutcome(ctx, outcome)
|
||||
}
|
||||
|
||||
func replayOutcomeForError(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, ErrConflict):
|
||||
return "conflict"
|
||||
case errors.Is(err, ErrServiceUnavailable):
|
||||
return "service_unavailable"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func ptrTime(value time.Time) *time.Time {
|
||||
return &value
|
||||
}
|
||||
Reference in New Issue
Block a user