feat: mail service
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
// Package id provides internal identifier generators used by Mail Service.
|
||||
package id
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Generator builds UUID-backed internal delivery identifiers.
|
||||
type Generator struct{}
|
||||
|
||||
// NewDeliveryID returns one new UUID v4 delivery identifier.
|
||||
func (Generator) NewDeliveryID() (common.DeliveryID, error) {
|
||||
value, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("new delivery id: %w", err)
|
||||
}
|
||||
|
||||
return common.DeliveryID(value.String()), nil
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/domain/idempotency"
|
||||
"galaxy/mail/internal/service/acceptgenericdelivery"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// AtomicWriter performs the minimal multi-key Redis mutations that later Mail
|
||||
// Service acceptance flows will need.
|
||||
type AtomicWriter struct {
|
||||
client *redis.Client
|
||||
keyspace Keyspace
|
||||
}
|
||||
|
||||
// CreateAcceptanceInput describes the frozen write set required to durably
|
||||
// accept one delivery into Redis-backed state.
|
||||
type CreateAcceptanceInput struct {
|
||||
// Delivery stores the accepted delivery record.
|
||||
Delivery deliverydomain.Delivery
|
||||
|
||||
// FirstAttempt stores the optional first scheduled attempt record.
|
||||
FirstAttempt *attempt.Attempt
|
||||
|
||||
// DeliveryPayload stores the optional raw attachment payload bundle.
|
||||
DeliveryPayload *acceptgenericdelivery.DeliveryPayload
|
||||
|
||||
// Idempotency stores the optional idempotency reservation to create
|
||||
// together with the delivery. Resend clone creation can omit it.
|
||||
Idempotency *idempotency.Record
|
||||
}
|
||||
|
||||
// MarkRenderedInput describes the durable mutation applied after successful
|
||||
// template materialization.
|
||||
type MarkRenderedInput struct {
|
||||
// Delivery stores the rendered delivery record.
|
||||
Delivery deliverydomain.Delivery
|
||||
}
|
||||
|
||||
// Validate reports whether input contains one rendered template delivery.
|
||||
func (input MarkRenderedInput) Validate() error {
|
||||
if err := input.Delivery.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery: %w", err)
|
||||
}
|
||||
if input.Delivery.PayloadMode != deliverydomain.PayloadModeTemplate {
|
||||
return fmt.Errorf("delivery payload mode must be %q", deliverydomain.PayloadModeTemplate)
|
||||
}
|
||||
if input.Delivery.Status != deliverydomain.StatusRendered {
|
||||
return fmt.Errorf("delivery status must be %q", deliverydomain.StatusRendered)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkRenderFailedInput describes the durable mutation applied after one
|
||||
// classified render failure.
|
||||
type MarkRenderFailedInput struct {
|
||||
// Delivery stores the failed delivery record.
|
||||
Delivery deliverydomain.Delivery
|
||||
|
||||
// Attempt stores the terminal render-failed attempt.
|
||||
Attempt attempt.Attempt
|
||||
}
|
||||
|
||||
// Validate reports whether input contains one failed delivery and its
|
||||
// terminal render-failed attempt.
|
||||
func (input MarkRenderFailedInput) Validate() error {
|
||||
if err := input.Delivery.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery: %w", err)
|
||||
}
|
||||
if err := input.Attempt.Validate(); err != nil {
|
||||
return fmt.Errorf("attempt: %w", err)
|
||||
}
|
||||
if input.Delivery.PayloadMode != deliverydomain.PayloadModeTemplate {
|
||||
return fmt.Errorf("delivery payload mode must be %q", deliverydomain.PayloadModeTemplate)
|
||||
}
|
||||
if input.Delivery.Status != deliverydomain.StatusFailed {
|
||||
return fmt.Errorf("delivery status must be %q", deliverydomain.StatusFailed)
|
||||
}
|
||||
if input.Attempt.Status != attempt.StatusRenderFailed {
|
||||
return fmt.Errorf("attempt status must be %q", attempt.StatusRenderFailed)
|
||||
}
|
||||
if input.Attempt.DeliveryID != input.Delivery.DeliveryID {
|
||||
return errors.New("attempt delivery id must match delivery id")
|
||||
}
|
||||
if input.Delivery.LastAttemptStatus != attempt.StatusRenderFailed {
|
||||
return fmt.Errorf("delivery last attempt status must be %q", attempt.StatusRenderFailed)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate reports whether CreateAcceptanceInput is internally consistent.
|
||||
func (input CreateAcceptanceInput) Validate() error {
|
||||
if err := input.Delivery.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery: %w", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case input.FirstAttempt == nil:
|
||||
if input.Delivery.Status != deliverydomain.StatusSuppressed {
|
||||
return errors.New("first attempt must not be nil unless delivery status 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)
|
||||
}
|
||||
}
|
||||
|
||||
if input.DeliveryPayload != nil {
|
||||
if err := input.DeliveryPayload.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery payload: %w", err)
|
||||
}
|
||||
if input.DeliveryPayload.DeliveryID != input.Delivery.DeliveryID {
|
||||
return errors.New("delivery payload delivery id must match delivery id")
|
||||
}
|
||||
}
|
||||
|
||||
if input.Idempotency == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
if input.Idempotency.ExpiresAt.Sub(input.Idempotency.CreatedAt) != IdempotencyTTL {
|
||||
return fmt.Errorf("idempotency retention must equal %s", IdempotencyTTL)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewAtomicWriter constructs a low-level Redis mutation helper.
|
||||
func NewAtomicWriter(client *redis.Client) (*AtomicWriter, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new redis atomic writer: nil client")
|
||||
}
|
||||
|
||||
return &AtomicWriter{
|
||||
client: client,
|
||||
keyspace: Keyspace{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateAcceptance stores one delivery, the optional first scheduled attempt,
|
||||
// the optional first schedule entry, the delivery-level secondary indexes, and
|
||||
// an optional idempotency record in one optimistic Redis transaction.
|
||||
func (writer *AtomicWriter) CreateAcceptance(ctx context.Context, input CreateAcceptanceInput) error {
|
||||
if writer == nil || writer.client == nil {
|
||||
return errors.New("create acceptance in redis: nil writer")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("create acceptance in redis: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("create acceptance in redis: %w", err)
|
||||
}
|
||||
|
||||
deliveryPayload, err := MarshalDelivery(input.Delivery)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create acceptance in redis: %w", err)
|
||||
}
|
||||
var (
|
||||
attemptKey string
|
||||
attemptPayload []byte
|
||||
deliveryPayloadKey string
|
||||
deliveryPayloadBytes []byte
|
||||
scheduleScore float64
|
||||
idempotencyKey string
|
||||
idempotencyPayload []byte
|
||||
idempotencyTTL time.Duration
|
||||
)
|
||||
if input.FirstAttempt != nil {
|
||||
attemptPayload, err = MarshalAttempt(*input.FirstAttempt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create acceptance in redis: %w", err)
|
||||
}
|
||||
attemptKey = writer.keyspace.Attempt(input.FirstAttempt.DeliveryID, input.FirstAttempt.AttemptNo)
|
||||
scheduleScore = ScheduledForScore(input.FirstAttempt.ScheduledFor)
|
||||
}
|
||||
if input.DeliveryPayload != nil {
|
||||
deliveryPayloadBytes, err = MarshalDeliveryPayload(*input.DeliveryPayload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create acceptance in redis: %w", err)
|
||||
}
|
||||
deliveryPayloadKey = writer.keyspace.DeliveryPayload(input.DeliveryPayload.DeliveryID)
|
||||
}
|
||||
if input.Idempotency != nil {
|
||||
idempotencyPayload, err = MarshalIdempotency(*input.Idempotency)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create acceptance in redis: %w", err)
|
||||
}
|
||||
idempotencyTTL, err = ttlUntil(input.Idempotency.ExpiresAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create acceptance in redis: %w", err)
|
||||
}
|
||||
idempotencyKey = writer.keyspace.Idempotency(input.Idempotency.Source, input.Idempotency.IdempotencyKey)
|
||||
}
|
||||
|
||||
deliveryKey := writer.keyspace.Delivery(input.Delivery.DeliveryID)
|
||||
watchKeys := []string{deliveryKey}
|
||||
if attemptKey != "" {
|
||||
watchKeys = append(watchKeys, attemptKey)
|
||||
}
|
||||
if deliveryPayloadKey != "" {
|
||||
watchKeys = append(watchKeys, deliveryPayloadKey)
|
||||
}
|
||||
if idempotencyKey != "" {
|
||||
watchKeys = append(watchKeys, idempotencyKey)
|
||||
}
|
||||
|
||||
indexKeys := writer.keyspace.DeliveryIndexKeys(input.Delivery)
|
||||
createdAtScore := CreatedAtScore(input.Delivery.CreatedAt)
|
||||
deliveryMember := input.Delivery.DeliveryID.String()
|
||||
|
||||
watchErr := writer.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
for _, key := range watchKeys {
|
||||
if err := ensureKeyAbsent(ctx, tx, key); err != nil {
|
||||
return fmt.Errorf("create acceptance in redis: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, deliveryKey, deliveryPayload, DeliveryTTL)
|
||||
if attemptKey != "" {
|
||||
pipe.Set(ctx, attemptKey, attemptPayload, AttemptTTL)
|
||||
}
|
||||
if deliveryPayloadKey != "" {
|
||||
pipe.Set(ctx, deliveryPayloadKey, deliveryPayloadBytes, DeliveryTTL)
|
||||
}
|
||||
if idempotencyKey != "" {
|
||||
pipe.Set(ctx, idempotencyKey, idempotencyPayload, idempotencyTTL)
|
||||
}
|
||||
if attemptKey != "" {
|
||||
pipe.ZAdd(ctx, writer.keyspace.AttemptSchedule(), redis.Z{
|
||||
Score: scheduleScore,
|
||||
Member: deliveryMember,
|
||||
})
|
||||
}
|
||||
for _, indexKey := range indexKeys {
|
||||
pipe.ZAdd(ctx, indexKey, redis.Z{
|
||||
Score: createdAtScore,
|
||||
Member: deliveryMember,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create acceptance in redis: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, watchKeys...)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("create acceptance in redis: %w", ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MarkRendered stores the successful materialization result for one queued
|
||||
// template delivery and updates the delivery-status secondary index
|
||||
// atomically.
|
||||
func (writer *AtomicWriter) MarkRendered(ctx context.Context, input MarkRenderedInput) error {
|
||||
if writer == nil || writer.client == nil {
|
||||
return errors.New("mark rendered in redis: nil writer")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("mark rendered in redis: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("mark rendered in redis: %w", err)
|
||||
}
|
||||
|
||||
deliveryKey := writer.keyspace.Delivery(input.Delivery.DeliveryID)
|
||||
deliveryPayload, err := MarshalDelivery(input.Delivery)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark rendered in redis: %w", err)
|
||||
}
|
||||
|
||||
watchErr := writer.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
currentDelivery, err := loadDeliveryFromTx(ctx, tx, deliveryKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark rendered in redis: %w", err)
|
||||
}
|
||||
if currentDelivery.Status != deliverydomain.StatusQueued {
|
||||
return fmt.Errorf("mark rendered in redis: %w", ErrConflict)
|
||||
}
|
||||
|
||||
deliveryTTL, err := ttlForExistingKey(ctx, tx, deliveryKey, DeliveryTTL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark rendered in redis: %w", err)
|
||||
}
|
||||
|
||||
createdAtScore := CreatedAtScore(currentDelivery.CreatedAt)
|
||||
deliveryMember := input.Delivery.DeliveryID.String()
|
||||
|
||||
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, deliveryKey, deliveryPayload, deliveryTTL)
|
||||
pipe.ZRem(ctx, writer.keyspace.StatusIndex(currentDelivery.Status), deliveryMember)
|
||||
pipe.ZAdd(ctx, writer.keyspace.StatusIndex(input.Delivery.Status), redis.Z{
|
||||
Score: createdAtScore,
|
||||
Member: deliveryMember,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark rendered in redis: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, deliveryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("mark rendered in redis: %w", ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MarkRenderFailed stores one terminal render-failed attempt together with
|
||||
// the owning failed delivery and updates the delivery-status secondary index
|
||||
// atomically.
|
||||
func (writer *AtomicWriter) MarkRenderFailed(ctx context.Context, input MarkRenderFailedInput) error {
|
||||
if writer == nil || writer.client == nil {
|
||||
return errors.New("mark render failed in redis: nil writer")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("mark render failed in redis: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("mark render failed in redis: %w", err)
|
||||
}
|
||||
|
||||
deliveryKey := writer.keyspace.Delivery(input.Delivery.DeliveryID)
|
||||
attemptKey := writer.keyspace.Attempt(input.Attempt.DeliveryID, input.Attempt.AttemptNo)
|
||||
|
||||
deliveryPayload, err := MarshalDelivery(input.Delivery)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark render failed in redis: %w", err)
|
||||
}
|
||||
attemptPayload, err := MarshalAttempt(input.Attempt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark render failed in redis: %w", err)
|
||||
}
|
||||
|
||||
watchErr := writer.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
currentDelivery, err := loadDeliveryFromTx(ctx, tx, deliveryKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark render failed in redis: %w", err)
|
||||
}
|
||||
currentAttempt, err := loadAttemptFromTx(ctx, tx, attemptKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark render failed in redis: %w", err)
|
||||
}
|
||||
if currentDelivery.Status != deliverydomain.StatusQueued {
|
||||
return fmt.Errorf("mark render failed in redis: %w", ErrConflict)
|
||||
}
|
||||
if currentAttempt.Status != attempt.StatusScheduled {
|
||||
return fmt.Errorf("mark render failed in redis: %w", ErrConflict)
|
||||
}
|
||||
|
||||
deliveryTTL, err := ttlForExistingKey(ctx, tx, deliveryKey, DeliveryTTL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark render failed in redis: %w", err)
|
||||
}
|
||||
attemptTTL, err := ttlForExistingKey(ctx, tx, attemptKey, AttemptTTL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark render failed in redis: %w", err)
|
||||
}
|
||||
|
||||
createdAtScore := CreatedAtScore(currentDelivery.CreatedAt)
|
||||
deliveryMember := input.Delivery.DeliveryID.String()
|
||||
|
||||
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, deliveryKey, deliveryPayload, deliveryTTL)
|
||||
pipe.Set(ctx, attemptKey, attemptPayload, attemptTTL)
|
||||
pipe.ZRem(ctx, writer.keyspace.StatusIndex(currentDelivery.Status), deliveryMember)
|
||||
pipe.ZAdd(ctx, writer.keyspace.StatusIndex(input.Delivery.Status), redis.Z{
|
||||
Score: createdAtScore,
|
||||
Member: deliveryMember,
|
||||
})
|
||||
pipe.ZRem(ctx, writer.keyspace.AttemptSchedule(), deliveryMember)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark render failed in redis: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, deliveryKey, attemptKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("mark render failed in redis: %w", ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func ensureKeyAbsent(ctx context.Context, tx *redis.Tx, key string) error {
|
||||
exists, err := tx.Exists(ctx, key).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists > 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadDeliveryFromTx(ctx context.Context, tx *redis.Tx, key string) (deliverydomain.Delivery, error) {
|
||||
payload, err := tx.Get(ctx, key).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return deliverydomain.Delivery{}, ErrConflict
|
||||
case err != nil:
|
||||
return deliverydomain.Delivery{}, err
|
||||
}
|
||||
|
||||
record, err := UnmarshalDelivery(payload)
|
||||
if err != nil {
|
||||
return deliverydomain.Delivery{}, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func loadAttemptFromTx(ctx context.Context, tx *redis.Tx, key string) (attempt.Attempt, error) {
|
||||
payload, err := tx.Get(ctx, key).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return attempt.Attempt{}, ErrConflict
|
||||
case err != nil:
|
||||
return attempt.Attempt{}, err
|
||||
}
|
||||
|
||||
record, err := UnmarshalAttempt(payload)
|
||||
if err != nil {
|
||||
return attempt.Attempt{}, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func ttlForExistingKey(ctx context.Context, tx *redis.Tx, key string, fallback time.Duration) (time.Duration, error) {
|
||||
ttl, err := tx.PTTL(ctx, key).Result()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if ttl <= 0 {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
return ttl, nil
|
||||
}
|
||||
|
||||
func ttlUntil(expiresAt time.Time) (time.Duration, error) {
|
||||
ttl := time.Until(expiresAt)
|
||||
if ttl <= 0 {
|
||||
return 0, errors.New("idempotency expires at must be in the future")
|
||||
}
|
||||
|
||||
return ttl, nil
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAtomicWriterCreateAcceptanceStoresStateWithoutIdempotencyRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
writer, err := NewAtomicWriter(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := validDelivery(t)
|
||||
record.Source = deliverydomain.SourceNotification
|
||||
record.ResendParentDeliveryID = ""
|
||||
record.Status = deliverydomain.StatusQueued
|
||||
record.SentAt = nil
|
||||
record.LocaleFallbackUsed = false
|
||||
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
firstAttempt := validScheduledAttempt(t, record.DeliveryID)
|
||||
input := CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: ptr(firstAttempt),
|
||||
DeliveryPayload: ptr(validDeliveryPayload(t, record.DeliveryID)),
|
||||
}
|
||||
|
||||
require.NoError(t, writer.CreateAcceptance(context.Background(), input))
|
||||
|
||||
storedDelivery, err := client.Get(context.Background(), Keyspace{}.Delivery(record.DeliveryID)).Bytes()
|
||||
require.NoError(t, err)
|
||||
decodedDelivery, err := UnmarshalDelivery(storedDelivery)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, decodedDelivery)
|
||||
|
||||
storedAttempt, err := client.Get(context.Background(), Keyspace{}.Attempt(record.DeliveryID, firstAttempt.AttemptNo)).Bytes()
|
||||
require.NoError(t, err)
|
||||
decodedAttempt, err := UnmarshalAttempt(storedAttempt)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, firstAttempt, decodedAttempt)
|
||||
|
||||
storedDeliveryPayload, err := client.Get(context.Background(), Keyspace{}.DeliveryPayload(record.DeliveryID)).Bytes()
|
||||
require.NoError(t, err)
|
||||
decodedDeliveryPayload, err := UnmarshalDeliveryPayload(storedDeliveryPayload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, *input.DeliveryPayload, decodedDeliveryPayload)
|
||||
|
||||
scheduledDeliveries, err := client.ZRange(context.Background(), Keyspace{}.AttemptSchedule(), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{record.DeliveryID.String()}, scheduledDeliveries)
|
||||
|
||||
recipientMembers, err := client.ZRange(context.Background(), Keyspace{}.RecipientIndex(record.Envelope.To[0]), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{record.DeliveryID.String()}, recipientMembers)
|
||||
|
||||
idempotencyMembers, err := client.ZRange(context.Background(), Keyspace{}.IdempotencyIndex(record.Source, record.IdempotencyKey), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{record.DeliveryID.String()}, idempotencyMembers)
|
||||
}
|
||||
|
||||
func TestAtomicWriterCreateAcceptanceDetectsDuplicateIdempotencyRace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
writer, err := NewAtomicWriter(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := validDelivery(t)
|
||||
record.Source = deliverydomain.SourceNotification
|
||||
record.ResendParentDeliveryID = ""
|
||||
record.Status = deliverydomain.StatusQueued
|
||||
record.SentAt = nil
|
||||
record.LocaleFallbackUsed = false
|
||||
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
input := CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: ptr(validScheduledAttempt(t, record.DeliveryID)),
|
||||
DeliveryPayload: ptr(validDeliveryPayload(t, record.DeliveryID)),
|
||||
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
|
||||
}
|
||||
|
||||
const contenders = 8
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
successes int
|
||||
conflicts int
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
for range contenders {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
err := writer.CreateAcceptance(context.Background(), input)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch {
|
||||
case err == nil:
|
||||
successes++
|
||||
case errors.Is(err, ErrConflict):
|
||||
conflicts++
|
||||
default:
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
require.Equal(t, 1, successes)
|
||||
require.Equal(t, contenders-1, conflicts)
|
||||
|
||||
require.True(t, server.Exists(Keyspace{}.Delivery(record.DeliveryID)))
|
||||
require.NotNil(t, input.FirstAttempt)
|
||||
require.True(t, server.Exists(Keyspace{}.Attempt(record.DeliveryID, input.FirstAttempt.AttemptNo)))
|
||||
require.True(t, server.Exists(Keyspace{}.DeliveryPayload(record.DeliveryID)))
|
||||
require.True(t, server.Exists(Keyspace{}.Idempotency(record.Source, record.IdempotencyKey)))
|
||||
|
||||
scheduleCard, err := client.ZCard(context.Background(), Keyspace{}.AttemptSchedule()).Result()
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, scheduleCard)
|
||||
|
||||
createdAtCard, err := client.ZCard(context.Background(), Keyspace{}.CreatedAtIndex()).Result()
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, createdAtCard)
|
||||
|
||||
idempotencyCard, err := client.ZCard(context.Background(), Keyspace{}.IdempotencyIndex(record.Source, record.IdempotencyKey)).Result()
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, idempotencyCard)
|
||||
}
|
||||
|
||||
func TestCreateAcceptanceInputValidateRejectsMismatchedDeliveryPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := validDelivery(t)
|
||||
record.Source = deliverydomain.SourceNotification
|
||||
record.ResendParentDeliveryID = ""
|
||||
record.Status = deliverydomain.StatusQueued
|
||||
record.SentAt = nil
|
||||
record.LocaleFallbackUsed = false
|
||||
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
payload := validDeliveryPayload(t, common.DeliveryID("delivery-other"))
|
||||
input := CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: ptr(validScheduledAttempt(t, record.DeliveryID)),
|
||||
DeliveryPayload: &payload,
|
||||
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
|
||||
}
|
||||
|
||||
err := input.Validate()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "delivery payload delivery id must match delivery id")
|
||||
}
|
||||
|
||||
func TestCreateAcceptanceInputValidateRejectsMismatchedIdempotency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := validDelivery(t)
|
||||
record.Source = deliverydomain.SourceNotification
|
||||
record.ResendParentDeliveryID = ""
|
||||
record.Status = deliverydomain.StatusQueued
|
||||
record.SentAt = nil
|
||||
record.LocaleFallbackUsed = false
|
||||
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
input := CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: ptr(validScheduledAttempt(t, record.DeliveryID)),
|
||||
Idempotency: ptr(validIdempotencyRecord(t, deliverydomain.SourceAuthSession, record.DeliveryID, record.IdempotencyKey)),
|
||||
}
|
||||
|
||||
err := input.Validate()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "idempotency source must match delivery source")
|
||||
}
|
||||
|
||||
func TestCreateAcceptanceInputValidateRejectsUnexpectedIdempotencyRetention(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := validDelivery(t)
|
||||
record.Source = deliverydomain.SourceNotification
|
||||
record.ResendParentDeliveryID = ""
|
||||
record.Status = deliverydomain.StatusQueued
|
||||
record.SentAt = nil
|
||||
record.LocaleFallbackUsed = false
|
||||
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
idempotencyRecord := validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)
|
||||
idempotencyRecord.ExpiresAt = idempotencyRecord.CreatedAt.Add(time.Hour)
|
||||
|
||||
input := CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: ptr(validScheduledAttempt(t, record.DeliveryID)),
|
||||
Idempotency: ptr(idempotencyRecord),
|
||||
}
|
||||
|
||||
err := input.Validate()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "idempotency retention must equal")
|
||||
}
|
||||
|
||||
func TestAtomicWriterCreateAcceptanceStoresSuppressedStateWithoutAttempt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
writer, err := NewAtomicWriter(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := validDelivery(t)
|
||||
record.Source = deliverydomain.SourceAuthSession
|
||||
record.ResendParentDeliveryID = ""
|
||||
record.Status = deliverydomain.StatusSuppressed
|
||||
record.AttemptCount = 0
|
||||
record.LastAttemptStatus = ""
|
||||
record.ProviderSummary = ""
|
||||
record.LocaleFallbackUsed = false
|
||||
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
|
||||
record.SentAt = nil
|
||||
record.SuppressedAt = ptr(record.UpdatedAt)
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
input := CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
|
||||
}
|
||||
|
||||
require.NoError(t, writer.CreateAcceptance(context.Background(), input))
|
||||
|
||||
storedDelivery, err := client.Get(context.Background(), Keyspace{}.Delivery(record.DeliveryID)).Bytes()
|
||||
require.NoError(t, err)
|
||||
decodedDelivery, err := UnmarshalDelivery(storedDelivery)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, decodedDelivery)
|
||||
|
||||
require.False(t, server.Exists(Keyspace{}.Attempt(record.DeliveryID, 1)))
|
||||
|
||||
scheduleCard, err := client.ZCard(context.Background(), Keyspace{}.AttemptSchedule()).Result()
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, scheduleCard)
|
||||
}
|
||||
|
||||
func TestAtomicWriterMarkRenderedUpdatesDeliveryAndStatusIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
writer, err := NewAtomicWriter(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := validQueuedTemplateDelivery(t)
|
||||
firstAttempt := validScheduledAttempt(t, record.DeliveryID)
|
||||
createInput := CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: ptr(firstAttempt),
|
||||
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
|
||||
}
|
||||
require.NoError(t, writer.CreateAcceptance(context.Background(), createInput))
|
||||
|
||||
rendered := record
|
||||
rendered.Status = deliverydomain.StatusRendered
|
||||
rendered.Content = deliverydomain.Content{
|
||||
Subject: "Turn 54",
|
||||
TextBody: "Hello Pilot",
|
||||
HTMLBody: "<p>Hello Pilot</p>",
|
||||
}
|
||||
rendered.LocaleFallbackUsed = true
|
||||
rendered.UpdatedAt = rendered.CreatedAt.Add(time.Minute)
|
||||
require.NoError(t, rendered.Validate())
|
||||
|
||||
require.NoError(t, writer.MarkRendered(context.Background(), MarkRenderedInput{
|
||||
Delivery: rendered,
|
||||
}))
|
||||
|
||||
storedDelivery, err := client.Get(context.Background(), Keyspace{}.Delivery(record.DeliveryID)).Bytes()
|
||||
require.NoError(t, err)
|
||||
decodedDelivery, err := UnmarshalDelivery(storedDelivery)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, rendered, decodedDelivery)
|
||||
|
||||
queuedMembers, err := client.ZRange(context.Background(), Keyspace{}.StatusIndex(deliverydomain.StatusQueued), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, queuedMembers)
|
||||
|
||||
renderedMembers, err := client.ZRange(context.Background(), Keyspace{}.StatusIndex(deliverydomain.StatusRendered), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{record.DeliveryID.String()}, renderedMembers)
|
||||
}
|
||||
|
||||
func TestAtomicWriterMarkRenderFailedUpdatesDeliveryAttemptAndStatusIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
writer, err := NewAtomicWriter(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := validQueuedTemplateDelivery(t)
|
||||
firstAttempt := validScheduledAttempt(t, record.DeliveryID)
|
||||
createInput := CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: ptr(firstAttempt),
|
||||
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
|
||||
}
|
||||
require.NoError(t, writer.CreateAcceptance(context.Background(), createInput))
|
||||
|
||||
failed := record
|
||||
failed.Status = deliverydomain.StatusFailed
|
||||
failed.LastAttemptStatus = attempt.StatusRenderFailed
|
||||
failed.ProviderSummary = "missing required variables: player.name"
|
||||
failed.UpdatedAt = failed.CreatedAt.Add(time.Minute)
|
||||
failed.FailedAt = ptr(failed.UpdatedAt)
|
||||
require.NoError(t, failed.Validate())
|
||||
|
||||
renderFailedAttempt := validRenderFailedAttempt(t, record.DeliveryID)
|
||||
|
||||
require.NoError(t, writer.MarkRenderFailed(context.Background(), MarkRenderFailedInput{
|
||||
Delivery: failed,
|
||||
Attempt: renderFailedAttempt,
|
||||
}))
|
||||
|
||||
storedDelivery, err := client.Get(context.Background(), Keyspace{}.Delivery(record.DeliveryID)).Bytes()
|
||||
require.NoError(t, err)
|
||||
decodedDelivery, err := UnmarshalDelivery(storedDelivery)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, failed, decodedDelivery)
|
||||
|
||||
storedAttempt, err := client.Get(context.Background(), Keyspace{}.Attempt(record.DeliveryID, 1)).Bytes()
|
||||
require.NoError(t, err)
|
||||
decodedAttempt, err := UnmarshalAttempt(storedAttempt)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, renderFailedAttempt, decodedAttempt)
|
||||
|
||||
queuedMembers, err := client.ZRange(context.Background(), Keyspace{}.StatusIndex(deliverydomain.StatusQueued), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, queuedMembers)
|
||||
|
||||
failedMembers, err := client.ZRange(context.Background(), Keyspace{}.StatusIndex(deliverydomain.StatusFailed), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{record.DeliveryID.String()}, failedMembers)
|
||||
|
||||
scheduledMembers, err := client.ZRange(context.Background(), Keyspace{}.AttemptSchedule(), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, scheduledMembers)
|
||||
}
|
||||
|
||||
func TestAtomicWriterMarkRenderedRejectsUnexpectedCurrentState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
writer, err := NewAtomicWriter(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := validQueuedTemplateDelivery(t)
|
||||
firstAttempt := validScheduledAttempt(t, record.DeliveryID)
|
||||
require.NoError(t, writer.CreateAcceptance(context.Background(), CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: ptr(firstAttempt),
|
||||
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
|
||||
}))
|
||||
|
||||
failed := record
|
||||
failed.Status = deliverydomain.StatusFailed
|
||||
failed.LastAttemptStatus = attempt.StatusRenderFailed
|
||||
failed.ProviderSummary = "missing required variables: player.name"
|
||||
failed.UpdatedAt = failed.CreatedAt.Add(time.Minute)
|
||||
failed.FailedAt = ptr(failed.UpdatedAt)
|
||||
require.NoError(t, failed.Validate())
|
||||
require.NoError(t, writer.MarkRenderFailed(context.Background(), MarkRenderFailedInput{
|
||||
Delivery: failed,
|
||||
Attempt: validRenderFailedAttempt(t, record.DeliveryID),
|
||||
}))
|
||||
|
||||
rendered := record
|
||||
rendered.Status = deliverydomain.StatusRendered
|
||||
rendered.Content = deliverydomain.Content{
|
||||
Subject: "Turn 54",
|
||||
TextBody: "Hello Pilot",
|
||||
}
|
||||
rendered.UpdatedAt = rendered.CreatedAt.Add(2 * time.Minute)
|
||||
require.NoError(t, rendered.Validate())
|
||||
|
||||
err = writer.MarkRendered(context.Background(), MarkRenderedInput{Delivery: rendered})
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrConflict)
|
||||
}
|
||||
|
||||
func ptr[T any](value T) *T {
|
||||
return &value
|
||||
}
|
||||
|
||||
var _ = attempt.Attempt{}
|
||||
@@ -0,0 +1,502 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/service/acceptgenericdelivery"
|
||||
"galaxy/mail/internal/service/executeattempt"
|
||||
"galaxy/mail/internal/telemetry"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var errNotClaimable = errors.New("attempt is not claimable")
|
||||
|
||||
// AttemptExecutionStore provides the Redis-backed durable storage used by the
|
||||
// attempt scheduler and attempt execution service.
|
||||
type AttemptExecutionStore struct {
|
||||
client *redis.Client
|
||||
keys Keyspace
|
||||
}
|
||||
|
||||
// NewAttemptExecutionStore constructs one Redis-backed attempt execution
|
||||
// store.
|
||||
func NewAttemptExecutionStore(client *redis.Client) (*AttemptExecutionStore, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new attempt execution store: nil redis client")
|
||||
}
|
||||
|
||||
return &AttemptExecutionStore{
|
||||
client: client,
|
||||
keys: Keyspace{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NextDueDeliveryIDs returns up to limit due delivery identifiers ordered by
|
||||
// the attempt schedule score.
|
||||
func (store *AttemptExecutionStore) NextDueDeliveryIDs(ctx context.Context, now time.Time, limit int64) ([]common.DeliveryID, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("next due delivery ids: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("next due delivery ids: nil context")
|
||||
}
|
||||
if limit <= 0 {
|
||||
return nil, errors.New("next due delivery ids: non-positive limit")
|
||||
}
|
||||
|
||||
values, err := store.client.ZRangeByScore(ctx, store.keys.AttemptSchedule(), &redis.ZRangeBy{
|
||||
Min: "-inf",
|
||||
Max: fmt.Sprintf("%d", now.UTC().UnixMilli()),
|
||||
Count: limit,
|
||||
}).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("next due delivery ids: %w", err)
|
||||
}
|
||||
|
||||
ids := make([]common.DeliveryID, len(values))
|
||||
for index, value := range values {
|
||||
ids[index] = common.DeliveryID(value)
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// ReadAttemptScheduleSnapshot returns the current depth of the durable attempt
|
||||
// schedule together with its oldest scheduled timestamp when one exists.
|
||||
func (store *AttemptExecutionStore) ReadAttemptScheduleSnapshot(ctx context.Context) (telemetry.AttemptScheduleSnapshot, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return telemetry.AttemptScheduleSnapshot{}, errors.New("read attempt schedule snapshot: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return telemetry.AttemptScheduleSnapshot{}, errors.New("read attempt schedule snapshot: nil context")
|
||||
}
|
||||
|
||||
depth, err := store.client.ZCard(ctx, store.keys.AttemptSchedule()).Result()
|
||||
if err != nil {
|
||||
return telemetry.AttemptScheduleSnapshot{}, fmt.Errorf("read attempt schedule snapshot: depth: %w", err)
|
||||
}
|
||||
|
||||
snapshot := telemetry.AttemptScheduleSnapshot{
|
||||
Depth: depth,
|
||||
}
|
||||
if depth == 0 {
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
values, err := store.client.ZRangeWithScores(ctx, store.keys.AttemptSchedule(), 0, 0).Result()
|
||||
if err != nil {
|
||||
return telemetry.AttemptScheduleSnapshot{}, fmt.Errorf("read attempt schedule snapshot: oldest scheduled entry: %w", err)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
oldestScheduledFor := time.UnixMilli(int64(values[0].Score)).UTC()
|
||||
snapshot.OldestScheduledFor = &oldestScheduledFor
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
// SendingDeliveryIDs returns every delivery id currently indexed as
|
||||
// `mail_delivery.status=sending`.
|
||||
func (store *AttemptExecutionStore) SendingDeliveryIDs(ctx context.Context) ([]common.DeliveryID, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("sending delivery ids: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("sending delivery ids: nil context")
|
||||
}
|
||||
|
||||
values, err := store.client.ZRange(ctx, store.keys.StatusIndex(deliverydomain.StatusSending), 0, -1).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sending delivery ids: %w", err)
|
||||
}
|
||||
|
||||
ids := make([]common.DeliveryID, len(values))
|
||||
for index, value := range values {
|
||||
ids[index] = common.DeliveryID(value)
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// RemoveScheduledDelivery removes deliveryID from the attempt schedule set.
|
||||
func (store *AttemptExecutionStore) RemoveScheduledDelivery(ctx context.Context, deliveryID common.DeliveryID) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("remove scheduled delivery: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("remove scheduled delivery: nil context")
|
||||
}
|
||||
if err := deliveryID.Validate(); err != nil {
|
||||
return fmt.Errorf("remove scheduled delivery: %w", err)
|
||||
}
|
||||
|
||||
if err := store.client.ZRem(ctx, store.keys.AttemptSchedule(), deliveryID.String()).Err(); err != nil {
|
||||
return fmt.Errorf("remove scheduled delivery: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadWorkItem loads the current delivery and its latest attempt when both are
|
||||
// present.
|
||||
func (store *AttemptExecutionStore) LoadWorkItem(ctx context.Context, deliveryID common.DeliveryID) (executeattempt.WorkItem, bool, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return executeattempt.WorkItem{}, false, errors.New("load attempt work item: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return executeattempt.WorkItem{}, false, errors.New("load attempt work item: nil context")
|
||||
}
|
||||
if err := deliveryID.Validate(); err != nil {
|
||||
return executeattempt.WorkItem{}, false, fmt.Errorf("load attempt work item: %w", err)
|
||||
}
|
||||
|
||||
deliveryRecord, found, err := store.loadDelivery(ctx, deliveryID)
|
||||
if err != nil || !found {
|
||||
return executeattempt.WorkItem{}, found, err
|
||||
}
|
||||
if deliveryRecord.AttemptCount < 1 {
|
||||
return executeattempt.WorkItem{}, false, nil
|
||||
}
|
||||
|
||||
attemptRecord, found, err := store.loadAttempt(ctx, deliveryID, deliveryRecord.AttemptCount)
|
||||
if err != nil || !found {
|
||||
return executeattempt.WorkItem{}, found, err
|
||||
}
|
||||
|
||||
return executeattempt.WorkItem{
|
||||
Delivery: deliveryRecord,
|
||||
Attempt: attemptRecord,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// LoadPayload loads one stored raw attachment payload bundle.
|
||||
func (store *AttemptExecutionStore) LoadPayload(ctx context.Context, deliveryID common.DeliveryID) (acceptgenericdelivery.DeliveryPayload, bool, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return acceptgenericdelivery.DeliveryPayload{}, false, errors.New("load attempt payload: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return acceptgenericdelivery.DeliveryPayload{}, false, errors.New("load attempt payload: nil context")
|
||||
}
|
||||
if err := deliveryID.Validate(); err != nil {
|
||||
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("load attempt payload: %w", err)
|
||||
}
|
||||
|
||||
payload, err := store.client.Get(ctx, store.keys.DeliveryPayload(deliveryID)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return acceptgenericdelivery.DeliveryPayload{}, false, nil
|
||||
case err != nil:
|
||||
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("load attempt payload: %w", err)
|
||||
}
|
||||
|
||||
record, err := UnmarshalDeliveryPayload(payload)
|
||||
if err != nil {
|
||||
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("load attempt payload: %w", err)
|
||||
}
|
||||
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
// ClaimDueAttempt transitions one due scheduled attempt into `in_progress`
|
||||
// ownership and returns the claimed work item.
|
||||
func (store *AttemptExecutionStore) ClaimDueAttempt(ctx context.Context, deliveryID common.DeliveryID, now time.Time) (executeattempt.WorkItem, bool, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return executeattempt.WorkItem{}, false, errors.New("claim due attempt: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return executeattempt.WorkItem{}, false, errors.New("claim due attempt: nil context")
|
||||
}
|
||||
if err := deliveryID.Validate(); err != nil {
|
||||
return executeattempt.WorkItem{}, false, fmt.Errorf("claim due attempt: %w", err)
|
||||
}
|
||||
|
||||
claimedAt := now.UTC().Truncate(time.Millisecond)
|
||||
if claimedAt.IsZero() {
|
||||
return executeattempt.WorkItem{}, false, errors.New("claim due attempt: zero claim time")
|
||||
}
|
||||
|
||||
deliveryKey := store.keys.Delivery(deliveryID)
|
||||
|
||||
var claimed executeattempt.WorkItem
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
deliveryRecord, err := loadDeliveryFromTx(ctx, tx, deliveryKey)
|
||||
switch {
|
||||
case errors.Is(err, ErrConflict):
|
||||
return errNotClaimable
|
||||
case err != nil:
|
||||
return fmt.Errorf("claim due attempt: %w", err)
|
||||
}
|
||||
if deliveryRecord.AttemptCount < 1 {
|
||||
return errNotClaimable
|
||||
}
|
||||
|
||||
attemptKey := store.keys.Attempt(deliveryID, deliveryRecord.AttemptCount)
|
||||
attemptRecord, err := loadAttemptFromTx(ctx, tx, attemptKey)
|
||||
switch {
|
||||
case errors.Is(err, ErrConflict):
|
||||
return errNotClaimable
|
||||
case err != nil:
|
||||
return fmt.Errorf("claim due attempt: %w", err)
|
||||
}
|
||||
|
||||
score, err := tx.ZScore(ctx, store.keys.AttemptSchedule(), deliveryID.String()).Result()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return errNotClaimable
|
||||
case err != nil:
|
||||
return fmt.Errorf("claim due attempt: read attempt schedule: %w", err)
|
||||
}
|
||||
|
||||
switch deliveryRecord.Status {
|
||||
case deliverydomain.StatusQueued, deliverydomain.StatusRendered:
|
||||
default:
|
||||
return errNotClaimable
|
||||
}
|
||||
if attemptRecord.Status != attempt.StatusScheduled {
|
||||
return errNotClaimable
|
||||
}
|
||||
if score > ScheduledForScore(claimedAt) || attemptRecord.ScheduledFor.After(claimedAt) {
|
||||
return errNotClaimable
|
||||
}
|
||||
|
||||
claimedDelivery := deliveryRecord
|
||||
claimedDelivery.Status = deliverydomain.StatusSending
|
||||
claimedDelivery.UpdatedAt = claimedAt
|
||||
if err := claimedDelivery.Validate(); err != nil {
|
||||
return fmt.Errorf("claim due attempt: build claimed delivery: %w", err)
|
||||
}
|
||||
|
||||
claimedAttempt := attemptRecord
|
||||
claimedAttempt.Status = attempt.StatusInProgress
|
||||
claimedAttempt.StartedAt = ptrTime(claimedAt)
|
||||
if err := claimedAttempt.Validate(); err != nil {
|
||||
return fmt.Errorf("claim due attempt: build claimed attempt: %w", err)
|
||||
}
|
||||
|
||||
deliveryPayload, err := MarshalDelivery(claimedDelivery)
|
||||
if err != nil {
|
||||
return fmt.Errorf("claim due attempt: %w", err)
|
||||
}
|
||||
attemptPayload, err := MarshalAttempt(claimedAttempt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("claim due attempt: %w", err)
|
||||
}
|
||||
|
||||
deliveryTTL, err := ttlForExistingKey(ctx, tx, deliveryKey, DeliveryTTL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("claim due attempt: delivery ttl: %w", err)
|
||||
}
|
||||
attemptTTL, err := ttlForExistingKey(ctx, tx, attemptKey, AttemptTTL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("claim due attempt: attempt ttl: %w", err)
|
||||
}
|
||||
|
||||
createdAtScore := CreatedAtScore(deliveryRecord.CreatedAt)
|
||||
|
||||
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, deliveryKey, deliveryPayload, deliveryTTL)
|
||||
pipe.Set(ctx, attemptKey, attemptPayload, attemptTTL)
|
||||
pipe.ZRem(ctx, store.keys.StatusIndex(deliveryRecord.Status), deliveryID.String())
|
||||
pipe.ZAdd(ctx, store.keys.StatusIndex(deliverydomain.StatusSending), redis.Z{
|
||||
Score: createdAtScore,
|
||||
Member: deliveryID.String(),
|
||||
})
|
||||
pipe.ZRem(ctx, store.keys.AttemptSchedule(), deliveryID.String())
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("claim due attempt: %w", err)
|
||||
}
|
||||
|
||||
claimed = executeattempt.WorkItem{
|
||||
Delivery: claimedDelivery,
|
||||
Attempt: claimedAttempt,
|
||||
}
|
||||
return nil
|
||||
}, deliveryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, errNotClaimable), errors.Is(watchErr, redis.TxFailedErr):
|
||||
return executeattempt.WorkItem{}, false, nil
|
||||
case watchErr != nil:
|
||||
return executeattempt.WorkItem{}, false, watchErr
|
||||
default:
|
||||
return claimed, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Commit atomically stores one complete attempt execution outcome.
|
||||
func (store *AttemptExecutionStore) Commit(ctx context.Context, input executeattempt.CommitStateInput) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("commit attempt outcome: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("commit attempt outcome: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("commit attempt outcome: %w", err)
|
||||
}
|
||||
|
||||
deliveryKey := store.keys.Delivery(input.Delivery.DeliveryID)
|
||||
currentAttemptKey := store.keys.Attempt(input.Attempt.DeliveryID, input.Attempt.AttemptNo)
|
||||
|
||||
deliveryPayload, err := MarshalDelivery(input.Delivery)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commit attempt outcome: %w", err)
|
||||
}
|
||||
attemptPayload, err := MarshalAttempt(input.Attempt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commit attempt outcome: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
nextAttemptKey string
|
||||
nextAttemptPayload []byte
|
||||
nextAttemptScore float64
|
||||
deadLetterKey string
|
||||
deadLetterPayload []byte
|
||||
)
|
||||
if input.NextAttempt != nil {
|
||||
nextAttemptKey = store.keys.Attempt(input.NextAttempt.DeliveryID, input.NextAttempt.AttemptNo)
|
||||
nextAttemptPayload, err = MarshalAttempt(*input.NextAttempt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commit attempt outcome: %w", err)
|
||||
}
|
||||
nextAttemptScore = ScheduledForScore(input.NextAttempt.ScheduledFor)
|
||||
}
|
||||
if input.DeadLetter != nil {
|
||||
deadLetterKey = store.keys.DeadLetter(input.DeadLetter.DeliveryID)
|
||||
deadLetterPayload, err = MarshalDeadLetter(*input.DeadLetter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commit attempt outcome: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
watchKeys := []string{deliveryKey, currentAttemptKey}
|
||||
if nextAttemptKey != "" {
|
||||
watchKeys = append(watchKeys, nextAttemptKey)
|
||||
}
|
||||
if deadLetterKey != "" {
|
||||
watchKeys = append(watchKeys, deadLetterKey)
|
||||
}
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
currentDelivery, err := loadDeliveryFromTx(ctx, tx, deliveryKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commit attempt outcome: %w", err)
|
||||
}
|
||||
currentAttempt, err := loadAttemptFromTx(ctx, tx, currentAttemptKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commit attempt outcome: %w", err)
|
||||
}
|
||||
if currentDelivery.Status != deliverydomain.StatusSending {
|
||||
return fmt.Errorf("commit attempt outcome: %w", ErrConflict)
|
||||
}
|
||||
if currentAttempt.Status != attempt.StatusInProgress {
|
||||
return fmt.Errorf("commit attempt outcome: %w", ErrConflict)
|
||||
}
|
||||
if nextAttemptKey != "" {
|
||||
if err := ensureKeyAbsent(ctx, tx, nextAttemptKey); err != nil {
|
||||
return fmt.Errorf("commit attempt outcome: %w", err)
|
||||
}
|
||||
}
|
||||
if deadLetterKey != "" {
|
||||
if err := ensureKeyAbsent(ctx, tx, deadLetterKey); err != nil {
|
||||
return fmt.Errorf("commit attempt outcome: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
deliveryTTL, err := ttlForExistingKey(ctx, tx, deliveryKey, DeliveryTTL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commit attempt outcome: delivery ttl: %w", err)
|
||||
}
|
||||
attemptTTL, err := ttlForExistingKey(ctx, tx, currentAttemptKey, AttemptTTL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commit attempt outcome: attempt ttl: %w", err)
|
||||
}
|
||||
createdAtScore := CreatedAtScore(currentDelivery.CreatedAt)
|
||||
|
||||
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, deliveryKey, deliveryPayload, deliveryTTL)
|
||||
pipe.Set(ctx, currentAttemptKey, attemptPayload, attemptTTL)
|
||||
pipe.ZRem(ctx, store.keys.StatusIndex(currentDelivery.Status), input.Delivery.DeliveryID.String())
|
||||
pipe.ZAdd(ctx, store.keys.StatusIndex(input.Delivery.Status), redis.Z{
|
||||
Score: createdAtScore,
|
||||
Member: input.Delivery.DeliveryID.String(),
|
||||
})
|
||||
pipe.ZRem(ctx, store.keys.AttemptSchedule(), input.Delivery.DeliveryID.String())
|
||||
if nextAttemptKey != "" {
|
||||
pipe.Set(ctx, nextAttemptKey, nextAttemptPayload, AttemptTTL)
|
||||
pipe.ZAdd(ctx, store.keys.AttemptSchedule(), redis.Z{
|
||||
Score: nextAttemptScore,
|
||||
Member: input.Delivery.DeliveryID.String(),
|
||||
})
|
||||
}
|
||||
if deadLetterKey != "" {
|
||||
pipe.Set(ctx, deadLetterKey, deadLetterPayload, DeadLetterTTL)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("commit attempt outcome: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, watchKeys...)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("commit attempt outcome: %w", ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (store *AttemptExecutionStore) loadDelivery(ctx context.Context, deliveryID common.DeliveryID) (deliverydomain.Delivery, bool, error) {
|
||||
payload, err := store.client.Get(ctx, store.keys.Delivery(deliveryID)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return deliverydomain.Delivery{}, false, nil
|
||||
case err != nil:
|
||||
return deliverydomain.Delivery{}, false, fmt.Errorf("load attempt delivery: %w", err)
|
||||
}
|
||||
|
||||
record, err := UnmarshalDelivery(payload)
|
||||
if err != nil {
|
||||
return deliverydomain.Delivery{}, false, fmt.Errorf("load attempt delivery: %w", err)
|
||||
}
|
||||
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func (store *AttemptExecutionStore) loadAttempt(ctx context.Context, deliveryID common.DeliveryID, attemptNo int) (attempt.Attempt, bool, error) {
|
||||
payload, err := store.client.Get(ctx, store.keys.Attempt(deliveryID, attemptNo)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return attempt.Attempt{}, false, nil
|
||||
case err != nil:
|
||||
return attempt.Attempt{}, false, fmt.Errorf("load attempt record: %w", err)
|
||||
}
|
||||
|
||||
record, err := UnmarshalAttempt(payload)
|
||||
if err != nil {
|
||||
return attempt.Attempt{}, false, fmt.Errorf("load attempt record: %w", err)
|
||||
}
|
||||
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func ptrTime(value time.Time) *time.Time {
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/service/executeattempt"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAttemptExecutionStoreClaimDueAttemptTransitionsState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server, client, store := newAttemptExecutionFixture(t)
|
||||
record := queuedRenderedDelivery(t, common.DeliveryID("delivery-claim"))
|
||||
createAcceptedDelivery(t, store, record)
|
||||
|
||||
claimed, found, err := store.ClaimDueAttempt(context.Background(), record.DeliveryID, record.CreatedAt.Add(time.Minute))
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, deliverydomain.StatusSending, claimed.Delivery.Status)
|
||||
require.Equal(t, attempt.StatusInProgress, claimed.Attempt.Status)
|
||||
require.NotNil(t, claimed.Attempt.StartedAt)
|
||||
|
||||
require.False(t, server.Exists(Keyspace{}.AttemptSchedule()))
|
||||
|
||||
storedDelivery, err := client.Get(context.Background(), Keyspace{}.Delivery(record.DeliveryID)).Bytes()
|
||||
require.NoError(t, err)
|
||||
decodedDelivery, err := UnmarshalDelivery(storedDelivery)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, claimed.Delivery, decodedDelivery)
|
||||
|
||||
sendingMembers, err := client.ZRange(context.Background(), Keyspace{}.StatusIndex(deliverydomain.StatusSending), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{record.DeliveryID.String()}, sendingMembers)
|
||||
}
|
||||
|
||||
func TestAttemptExecutionStoreClaimDueAttemptAllowsOnlyOneOwner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, _, store := newAttemptExecutionFixture(t)
|
||||
record := queuedRenderedDelivery(t, common.DeliveryID("delivery-race"))
|
||||
createAcceptedDelivery(t, store, record)
|
||||
|
||||
const contenders = 8
|
||||
|
||||
var (
|
||||
waitGroup sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
successes int
|
||||
)
|
||||
|
||||
for range contenders {
|
||||
waitGroup.Add(1)
|
||||
go func() {
|
||||
defer waitGroup.Done()
|
||||
|
||||
_, found, err := store.ClaimDueAttempt(context.Background(), record.DeliveryID, record.CreatedAt.Add(time.Minute))
|
||||
require.NoError(t, err)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if found {
|
||||
successes++
|
||||
}
|
||||
}()
|
||||
}
|
||||
waitGroup.Wait()
|
||||
|
||||
require.Equal(t, 1, successes)
|
||||
}
|
||||
|
||||
func TestAttemptExecutionStoreCommitSchedulesRetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, client, store := newAttemptExecutionFixture(t)
|
||||
workItem := inProgressWorkItem(t, common.DeliveryID("delivery-retry"), 1)
|
||||
seedWorkItemState(t, client, workItem)
|
||||
|
||||
finishedAt := workItem.Attempt.StartedAt.Add(30 * time.Second)
|
||||
currentAttempt := workItem.Attempt
|
||||
currentAttempt.Status = attempt.StatusTransportFailed
|
||||
currentAttempt.FinishedAt = ptrTimeAttemptStore(finishedAt)
|
||||
currentAttempt.ProviderClassification = "transient_failure"
|
||||
currentAttempt.ProviderSummary = "provider=smtp result=transient_failure phase=data smtp_code=451"
|
||||
require.NoError(t, currentAttempt.Validate())
|
||||
|
||||
nextAttempt := attempt.Attempt{
|
||||
DeliveryID: workItem.Delivery.DeliveryID,
|
||||
AttemptNo: 2,
|
||||
ScheduledFor: finishedAt.Add(time.Minute),
|
||||
Status: attempt.StatusScheduled,
|
||||
}
|
||||
require.NoError(t, nextAttempt.Validate())
|
||||
|
||||
deliveryRecord := workItem.Delivery
|
||||
deliveryRecord.Status = deliverydomain.StatusQueued
|
||||
deliveryRecord.AttemptCount = nextAttempt.AttemptNo
|
||||
deliveryRecord.LastAttemptStatus = currentAttempt.Status
|
||||
deliveryRecord.ProviderSummary = currentAttempt.ProviderSummary
|
||||
deliveryRecord.UpdatedAt = finishedAt
|
||||
require.NoError(t, deliveryRecord.Validate())
|
||||
|
||||
input := executeattempt.CommitStateInput{
|
||||
Delivery: deliveryRecord,
|
||||
Attempt: currentAttempt,
|
||||
NextAttempt: &nextAttempt,
|
||||
}
|
||||
require.NoError(t, input.Validate())
|
||||
require.NoError(t, store.Commit(context.Background(), input))
|
||||
|
||||
reloaded, found, err := store.LoadWorkItem(context.Background(), workItem.Delivery.DeliveryID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, deliveryRecord, reloaded.Delivery)
|
||||
require.Equal(t, nextAttempt, reloaded.Attempt)
|
||||
|
||||
firstAttemptPayload, err := client.Get(context.Background(), Keyspace{}.Attempt(workItem.Delivery.DeliveryID, 1)).Bytes()
|
||||
require.NoError(t, err)
|
||||
firstAttemptRecord, err := UnmarshalAttempt(firstAttemptPayload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, currentAttempt, firstAttemptRecord)
|
||||
|
||||
scheduledMembers, err := client.ZRange(context.Background(), Keyspace{}.AttemptSchedule(), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{workItem.Delivery.DeliveryID.String()}, scheduledMembers)
|
||||
}
|
||||
|
||||
func TestAttemptExecutionStoreCommitCreatesDeadLetter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, client, store := newAttemptExecutionFixture(t)
|
||||
workItem := inProgressWorkItem(t, common.DeliveryID("delivery-dead-letter"), 4)
|
||||
seedWorkItemState(t, client, workItem)
|
||||
|
||||
finishedAt := workItem.Attempt.StartedAt.Add(30 * time.Second)
|
||||
currentAttempt := workItem.Attempt
|
||||
currentAttempt.Status = attempt.StatusTimedOut
|
||||
currentAttempt.FinishedAt = ptrTimeAttemptStore(finishedAt)
|
||||
currentAttempt.ProviderClassification = "deadline_exceeded"
|
||||
currentAttempt.ProviderSummary = "attempt claim TTL expired"
|
||||
require.NoError(t, currentAttempt.Validate())
|
||||
|
||||
deliveryRecord := workItem.Delivery
|
||||
deliveryRecord.Status = deliverydomain.StatusDeadLetter
|
||||
deliveryRecord.LastAttemptStatus = currentAttempt.Status
|
||||
deliveryRecord.ProviderSummary = currentAttempt.ProviderSummary
|
||||
deliveryRecord.UpdatedAt = finishedAt
|
||||
deliveryRecord.DeadLetteredAt = ptrTimeAttemptStore(finishedAt)
|
||||
require.NoError(t, deliveryRecord.Validate())
|
||||
|
||||
deadLetter := &deliverydomain.DeadLetterEntry{
|
||||
DeliveryID: deliveryRecord.DeliveryID,
|
||||
FinalAttemptNo: currentAttempt.AttemptNo,
|
||||
FailureClassification: "retry_exhausted",
|
||||
ProviderSummary: currentAttempt.ProviderSummary,
|
||||
CreatedAt: finishedAt,
|
||||
RecoveryHint: "check SMTP connectivity",
|
||||
}
|
||||
require.NoError(t, deadLetter.ValidateFor(deliveryRecord))
|
||||
|
||||
input := executeattempt.CommitStateInput{
|
||||
Delivery: deliveryRecord,
|
||||
Attempt: currentAttempt,
|
||||
DeadLetter: deadLetter,
|
||||
}
|
||||
require.NoError(t, input.Validate())
|
||||
require.NoError(t, store.Commit(context.Background(), input))
|
||||
|
||||
storedDelivery, found, err := store.LoadWorkItem(context.Background(), workItem.Delivery.DeliveryID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, deliveryRecord, storedDelivery.Delivery)
|
||||
require.Equal(t, currentAttempt, storedDelivery.Attempt)
|
||||
|
||||
deadLetterPayload, err := client.Get(context.Background(), Keyspace{}.DeadLetter(workItem.Delivery.DeliveryID)).Bytes()
|
||||
require.NoError(t, err)
|
||||
decodedDeadLetter, err := UnmarshalDeadLetter(deadLetterPayload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, *deadLetter, decodedDeadLetter)
|
||||
}
|
||||
|
||||
func newAttemptExecutionFixture(t *testing.T) (*miniredis.Miniredis, *redis.Client, *AttemptExecutionStore) {
|
||||
t.Helper()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
store, err := NewAttemptExecutionStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
return server, client, store
|
||||
}
|
||||
|
||||
func createAcceptedDelivery(t *testing.T, store *AttemptExecutionStore, record deliverydomain.Delivery) {
|
||||
t.Helper()
|
||||
|
||||
client := store.client
|
||||
writer, err := NewAtomicWriter(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
firstAttempt := attempt.Attempt{
|
||||
DeliveryID: record.DeliveryID,
|
||||
AttemptNo: 1,
|
||||
ScheduledFor: record.CreatedAt,
|
||||
Status: attempt.StatusScheduled,
|
||||
}
|
||||
require.NoError(t, firstAttempt.Validate())
|
||||
|
||||
require.NoError(t, writer.CreateAcceptance(context.Background(), CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: &firstAttempt,
|
||||
}))
|
||||
}
|
||||
|
||||
func queuedRenderedDelivery(t *testing.T, deliveryID common.DeliveryID) deliverydomain.Delivery {
|
||||
t.Helper()
|
||||
|
||||
record := validDelivery(t)
|
||||
record.DeliveryID = deliveryID
|
||||
record.ResendParentDeliveryID = ""
|
||||
record.Source = deliverydomain.SourceNotification
|
||||
record.PayloadMode = deliverydomain.PayloadModeRendered
|
||||
record.TemplateID = ""
|
||||
record.Locale = ""
|
||||
record.TemplateVariables = nil
|
||||
record.LocaleFallbackUsed = false
|
||||
record.Attachments = nil
|
||||
record.Status = deliverydomain.StatusQueued
|
||||
record.AttemptCount = 1
|
||||
record.LastAttemptStatus = ""
|
||||
record.ProviderSummary = ""
|
||||
record.CreatedAt = time.Unix(1_775_121_700, 0).UTC()
|
||||
record.UpdatedAt = record.CreatedAt
|
||||
record.SentAt = nil
|
||||
record.SuppressedAt = nil
|
||||
record.FailedAt = nil
|
||||
record.DeadLetteredAt = nil
|
||||
record.IdempotencyKey = common.IdempotencyKey("notification:" + deliveryID.String())
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func inProgressWorkItem(t *testing.T, deliveryID common.DeliveryID, attemptNo int) executeattempt.WorkItem {
|
||||
t.Helper()
|
||||
|
||||
deliveryRecord := queuedRenderedDelivery(t, deliveryID)
|
||||
deliveryRecord.Status = deliverydomain.StatusSending
|
||||
deliveryRecord.AttemptCount = attemptNo
|
||||
deliveryRecord.UpdatedAt = deliveryRecord.CreatedAt.Add(time.Duration(attemptNo) * time.Minute)
|
||||
require.NoError(t, deliveryRecord.Validate())
|
||||
|
||||
scheduledFor := deliveryRecord.CreatedAt.Add(time.Duration(attemptNo-1) * time.Minute)
|
||||
startedAt := scheduledFor.Add(5 * time.Second)
|
||||
attemptRecord := attempt.Attempt{
|
||||
DeliveryID: deliveryID,
|
||||
AttemptNo: attemptNo,
|
||||
ScheduledFor: scheduledFor,
|
||||
StartedAt: &startedAt,
|
||||
Status: attempt.StatusInProgress,
|
||||
}
|
||||
require.NoError(t, attemptRecord.Validate())
|
||||
|
||||
return executeattempt.WorkItem{
|
||||
Delivery: deliveryRecord,
|
||||
Attempt: attemptRecord,
|
||||
}
|
||||
}
|
||||
|
||||
func seedWorkItemState(t *testing.T, client *redis.Client, item executeattempt.WorkItem) {
|
||||
t.Helper()
|
||||
|
||||
deliveryPayload, err := MarshalDelivery(item.Delivery)
|
||||
require.NoError(t, err)
|
||||
attemptPayload, err := MarshalAttempt(item.Attempt)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Set(context.Background(), Keyspace{}.Delivery(item.Delivery.DeliveryID), deliveryPayload, DeliveryTTL).Err()
|
||||
require.NoError(t, err)
|
||||
err = client.Set(context.Background(), Keyspace{}.Attempt(item.Attempt.DeliveryID, item.Attempt.AttemptNo), attemptPayload, AttemptTTL).Err()
|
||||
require.NoError(t, err)
|
||||
err = client.ZAdd(context.Background(), Keyspace{}.StatusIndex(deliverydomain.StatusSending), redis.Z{
|
||||
Score: CreatedAtScore(item.Delivery.CreatedAt),
|
||||
Member: item.Delivery.DeliveryID.String(),
|
||||
}).Err()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func ptrTimeAttemptStore(value time.Time) *time.Time {
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/domain/idempotency"
|
||||
"galaxy/mail/internal/service/acceptauthdelivery"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// AcceptanceStore provides the Redis-backed durable storage used by the
|
||||
// auth-delivery acceptance use case.
|
||||
type AcceptanceStore struct {
|
||||
client *redis.Client
|
||||
writer *AtomicWriter
|
||||
keys Keyspace
|
||||
}
|
||||
|
||||
// NewAcceptanceStore constructs one Redis-backed auth acceptance store.
|
||||
func NewAcceptanceStore(client *redis.Client) (*AcceptanceStore, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new auth acceptance store: nil redis client")
|
||||
}
|
||||
|
||||
writer, err := NewAtomicWriter(client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new auth acceptance store: %w", err)
|
||||
}
|
||||
|
||||
return &AcceptanceStore{
|
||||
client: client,
|
||||
writer: writer,
|
||||
keys: Keyspace{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateAcceptance stores one auth-delivery acceptance write set in Redis.
|
||||
func (store *AcceptanceStore) CreateAcceptance(ctx context.Context, input acceptauthdelivery.CreateAcceptanceInput) error {
|
||||
if store == nil || store.client == nil || store.writer == nil {
|
||||
return errors.New("create auth acceptance: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("create auth acceptance: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("create auth acceptance: %w", err)
|
||||
}
|
||||
|
||||
err := store.writer.CreateAcceptance(ctx, CreateAcceptanceInput{
|
||||
Delivery: input.Delivery,
|
||||
FirstAttempt: input.FirstAttempt,
|
||||
Idempotency: &input.Idempotency,
|
||||
})
|
||||
if errors.Is(err, ErrConflict) {
|
||||
return fmt.Errorf("create auth acceptance: %w", acceptauthdelivery.ErrConflict)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("create auth acceptance: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetIdempotency loads one accepted idempotency scope from Redis.
|
||||
func (store *AcceptanceStore) GetIdempotency(ctx context.Context, source deliverydomain.Source, key common.IdempotencyKey) (idempotency.Record, bool, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return idempotency.Record{}, false, errors.New("get auth acceptance idempotency: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return idempotency.Record{}, false, errors.New("get auth acceptance idempotency: nil context")
|
||||
}
|
||||
|
||||
payload, err := store.client.Get(ctx, store.keys.Idempotency(source, key)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return idempotency.Record{}, false, nil
|
||||
case err != nil:
|
||||
return idempotency.Record{}, false, fmt.Errorf("get auth acceptance idempotency: %w", err)
|
||||
}
|
||||
|
||||
record, err := UnmarshalIdempotency(payload)
|
||||
if err != nil {
|
||||
return idempotency.Record{}, false, fmt.Errorf("get auth acceptance idempotency: %w", err)
|
||||
}
|
||||
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
// GetDelivery loads one accepted delivery from Redis.
|
||||
func (store *AcceptanceStore) GetDelivery(ctx context.Context, deliveryID common.DeliveryID) (deliverydomain.Delivery, bool, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return deliverydomain.Delivery{}, false, errors.New("get auth acceptance delivery: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return deliverydomain.Delivery{}, false, errors.New("get auth acceptance delivery: nil context")
|
||||
}
|
||||
|
||||
payload, err := store.client.Get(ctx, store.keys.Delivery(deliveryID)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return deliverydomain.Delivery{}, false, nil
|
||||
case err != nil:
|
||||
return deliverydomain.Delivery{}, false, fmt.Errorf("get auth acceptance delivery: %w", err)
|
||||
}
|
||||
|
||||
record, err := UnmarshalDelivery(payload)
|
||||
if err != nil {
|
||||
return deliverydomain.Delivery{}, false, fmt.Errorf("get auth acceptance delivery: %w", err)
|
||||
}
|
||||
|
||||
return record, true, nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/domain/idempotency"
|
||||
"galaxy/mail/internal/service/acceptauthdelivery"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAcceptanceStoreCreateAndReadQueuedDelivery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
store, err := NewAcceptanceStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := validDelivery(t)
|
||||
record.Source = deliverydomain.SourceAuthSession
|
||||
record.ResendParentDeliveryID = ""
|
||||
record.Status = deliverydomain.StatusQueued
|
||||
record.AttemptCount = 1
|
||||
record.LastAttemptStatus = ""
|
||||
record.ProviderSummary = ""
|
||||
record.LocaleFallbackUsed = false
|
||||
record.UpdatedAt = record.CreatedAt
|
||||
record.SentAt = nil
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
input := acceptauthdelivery.CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: ptr(validScheduledAttempt(t, record.DeliveryID)),
|
||||
Idempotency: validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey),
|
||||
}
|
||||
|
||||
require.NoError(t, store.CreateAcceptance(context.Background(), input))
|
||||
|
||||
storedDelivery, found, err := store.GetDelivery(context.Background(), record.DeliveryID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, record, storedDelivery)
|
||||
|
||||
storedIdempotency, found, err := store.GetIdempotency(context.Background(), record.Source, record.IdempotencyKey)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, input.Idempotency, storedIdempotency)
|
||||
}
|
||||
|
||||
func TestAcceptanceStoreCreateAndReadSuppressedDelivery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
store, err := NewAcceptanceStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := validDelivery(t)
|
||||
record.Source = deliverydomain.SourceAuthSession
|
||||
record.ResendParentDeliveryID = ""
|
||||
record.Status = deliverydomain.StatusSuppressed
|
||||
record.AttemptCount = 0
|
||||
record.LastAttemptStatus = ""
|
||||
record.ProviderSummary = ""
|
||||
record.LocaleFallbackUsed = false
|
||||
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
|
||||
record.SentAt = nil
|
||||
record.SuppressedAt = ptr(record.UpdatedAt)
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
input := acceptauthdelivery.CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
Idempotency: validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey),
|
||||
}
|
||||
|
||||
require.NoError(t, store.CreateAcceptance(context.Background(), input))
|
||||
|
||||
storedDelivery, found, err := store.GetDelivery(context.Background(), record.DeliveryID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, record, storedDelivery)
|
||||
|
||||
attemptExists := server.Exists(Keyspace{}.Attempt(record.DeliveryID, 1))
|
||||
require.False(t, attemptExists)
|
||||
}
|
||||
|
||||
func TestAcceptanceStoreReturnsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
store, err := NewAcceptanceStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
deliveryRecord, found, err := store.GetDelivery(context.Background(), common.DeliveryID("missing"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, found)
|
||||
require.Equal(t, deliverydomain.Delivery{}, deliveryRecord)
|
||||
|
||||
idempotencyRecord, found, err := store.GetIdempotency(context.Background(), deliverydomain.SourceAuthSession, common.IdempotencyKey("missing"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, found)
|
||||
require.Equal(t, idempotency.Record{}, idempotencyRecord)
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"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/domain/malformedcommand"
|
||||
"galaxy/mail/internal/service/acceptgenericdelivery"
|
||||
)
|
||||
|
||||
type deliveryRecord struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
ResendParentDeliveryID string `json:"resend_parent_delivery_id,omitempty"`
|
||||
Source deliverydomain.Source `json:"source"`
|
||||
PayloadMode deliverydomain.PayloadMode `json:"payload_mode"`
|
||||
TemplateID string `json:"template_id,omitempty"`
|
||||
TemplateVariables *map[string]any `json:"template_variables,omitempty"`
|
||||
To []string `json:"to"`
|
||||
Cc []string `json:"cc"`
|
||||
Bcc []string `json:"bcc"`
|
||||
ReplyTo []string `json:"reply_to"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
TextBody string `json:"text_body,omitempty"`
|
||||
HTMLBody string `json:"html_body,omitempty"`
|
||||
Attachments []attachmentRecord `json:"attachments"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
LocaleFallbackUsed bool `json:"locale_fallback_used"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
Status deliverydomain.Status `json:"status"`
|
||||
AttemptCount int `json:"attempt_count"`
|
||||
LastAttemptStatus attempt.Status `json:"last_attempt_status,omitempty"`
|
||||
ProviderSummary string `json:"provider_summary,omitempty"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
SentAtMS *int64 `json:"sent_at_ms,omitempty"`
|
||||
SuppressedAtMS *int64 `json:"suppressed_at_ms,omitempty"`
|
||||
FailedAtMS *int64 `json:"failed_at_ms,omitempty"`
|
||||
DeadLetteredAtMS *int64 `json:"dead_lettered_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
type attemptRecord struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
AttemptNo int `json:"attempt_no"`
|
||||
ScheduledForMS int64 `json:"scheduled_for_ms"`
|
||||
StartedAtMS *int64 `json:"started_at_ms,omitempty"`
|
||||
FinishedAtMS *int64 `json:"finished_at_ms,omitempty"`
|
||||
Status attempt.Status `json:"status"`
|
||||
ProviderClassification string `json:"provider_classification,omitempty"`
|
||||
ProviderSummary string `json:"provider_summary,omitempty"`
|
||||
}
|
||||
|
||||
type idempotencyRecord struct {
|
||||
Source deliverydomain.Source `json:"source"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
RequestFingerprint string `json:"request_fingerprint"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
ExpiresAtMS int64 `json:"expires_at_ms"`
|
||||
}
|
||||
|
||||
type deadLetterRecord struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
FinalAttemptNo int `json:"final_attempt_no"`
|
||||
FailureClassification string `json:"failure_classification"`
|
||||
ProviderSummary string `json:"provider_summary,omitempty"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
RecoveryHint string `json:"recovery_hint,omitempty"`
|
||||
}
|
||||
|
||||
type deliveryPayloadRecord struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
Attachments []deliveryPayloadAttachmentRecord `json:"attachments"`
|
||||
}
|
||||
|
||||
type deliveryPayloadAttachmentRecord struct {
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
ContentBase64 string `json:"content_base64"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
type malformedCommandRecord struct {
|
||||
StreamEntryID string `json:"stream_entry_id"`
|
||||
DeliveryID string `json:"delivery_id,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
FailureCode malformedcommand.FailureCode `json:"failure_code"`
|
||||
FailureMessage string `json:"failure_message"`
|
||||
RawFieldsJSON map[string]any `json:"raw_fields_json"`
|
||||
RecordedAtMS int64 `json:"recorded_at_ms"`
|
||||
}
|
||||
|
||||
type streamOffsetRecord struct {
|
||||
Stream string `json:"stream"`
|
||||
LastProcessedEntryID string `json:"last_processed_entry_id"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
// StreamOffset stores the persisted progress of one plain-XREAD consumer.
|
||||
type StreamOffset struct {
|
||||
// Stream stores the Redis Stream name.
|
||||
Stream string
|
||||
|
||||
// LastProcessedEntryID stores the last durably processed entry id.
|
||||
LastProcessedEntryID string
|
||||
|
||||
// UpdatedAt stores when the offset was updated.
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether offset contains a complete persisted progress
|
||||
// record.
|
||||
func (offset StreamOffset) Validate() error {
|
||||
if strings.TrimSpace(offset.Stream) == "" {
|
||||
return fmt.Errorf("stream offset stream must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(offset.LastProcessedEntryID) == "" {
|
||||
return fmt.Errorf("stream offset last processed entry id must not be empty")
|
||||
}
|
||||
if err := common.ValidateTimestamp("stream offset updated at", offset.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type attachmentRecord struct {
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
// MarshalDelivery encodes record into the strict Redis JSON shape used for
|
||||
// mail_delivery records.
|
||||
func MarshalDelivery(record deliverydomain.Delivery) ([]byte, error) {
|
||||
if err := record.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal redis delivery record: %w", err)
|
||||
}
|
||||
|
||||
stored := deliveryRecord{
|
||||
DeliveryID: record.DeliveryID.String(),
|
||||
ResendParentDeliveryID: record.ResendParentDeliveryID.String(),
|
||||
Source: record.Source,
|
||||
PayloadMode: record.PayloadMode,
|
||||
TemplateID: record.TemplateID.String(),
|
||||
TemplateVariables: optionalJSONObject(record.TemplateVariables),
|
||||
To: cloneEmailStrings(record.Envelope.To),
|
||||
Cc: cloneEmailStrings(record.Envelope.Cc),
|
||||
Bcc: cloneEmailStrings(record.Envelope.Bcc),
|
||||
ReplyTo: cloneEmailStrings(record.Envelope.ReplyTo),
|
||||
Subject: record.Content.Subject,
|
||||
TextBody: record.Content.TextBody,
|
||||
HTMLBody: record.Content.HTMLBody,
|
||||
Attachments: cloneAttachments(record.Attachments),
|
||||
Locale: record.Locale.String(),
|
||||
LocaleFallbackUsed: record.LocaleFallbackUsed,
|
||||
IdempotencyKey: record.IdempotencyKey.String(),
|
||||
Status: record.Status,
|
||||
AttemptCount: record.AttemptCount,
|
||||
LastAttemptStatus: record.LastAttemptStatus,
|
||||
ProviderSummary: record.ProviderSummary,
|
||||
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
|
||||
UpdatedAtMS: record.UpdatedAt.UTC().UnixMilli(),
|
||||
SentAtMS: optionalUnixMilli(record.SentAt),
|
||||
SuppressedAtMS: optionalUnixMilli(record.SuppressedAt),
|
||||
FailedAtMS: optionalUnixMilli(record.FailedAt),
|
||||
DeadLetteredAtMS: optionalUnixMilli(record.DeadLetteredAt),
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis delivery record: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// UnmarshalDelivery decodes payload from the strict Redis JSON shape used for
|
||||
// mail_delivery records.
|
||||
func UnmarshalDelivery(payload []byte) (deliverydomain.Delivery, error) {
|
||||
var stored deliveryRecord
|
||||
if err := decodeStrictJSON("decode redis delivery record", payload, &stored); err != nil {
|
||||
return deliverydomain.Delivery{}, err
|
||||
}
|
||||
|
||||
record := deliverydomain.Delivery{
|
||||
DeliveryID: common.DeliveryID(stored.DeliveryID),
|
||||
ResendParentDeliveryID: common.DeliveryID(stored.ResendParentDeliveryID),
|
||||
Source: stored.Source,
|
||||
PayloadMode: stored.PayloadMode,
|
||||
TemplateID: common.TemplateID(stored.TemplateID),
|
||||
TemplateVariables: cloneJSONObjectPtr(stored.TemplateVariables),
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: cloneEmails(stored.To),
|
||||
Cc: cloneEmails(stored.Cc),
|
||||
Bcc: cloneEmails(stored.Bcc),
|
||||
ReplyTo: cloneEmails(stored.ReplyTo),
|
||||
},
|
||||
Content: deliverydomain.Content{
|
||||
Subject: stored.Subject,
|
||||
TextBody: stored.TextBody,
|
||||
HTMLBody: stored.HTMLBody,
|
||||
},
|
||||
Attachments: inflateAttachments(stored.Attachments),
|
||||
Locale: common.Locale(stored.Locale),
|
||||
LocaleFallbackUsed: stored.LocaleFallbackUsed,
|
||||
IdempotencyKey: common.IdempotencyKey(stored.IdempotencyKey),
|
||||
Status: stored.Status,
|
||||
AttemptCount: stored.AttemptCount,
|
||||
LastAttemptStatus: stored.LastAttemptStatus,
|
||||
ProviderSummary: stored.ProviderSummary,
|
||||
CreatedAt: time.UnixMilli(stored.CreatedAtMS).UTC(),
|
||||
UpdatedAt: time.UnixMilli(stored.UpdatedAtMS).UTC(),
|
||||
SentAt: inflateOptionalTime(stored.SentAtMS),
|
||||
SuppressedAt: inflateOptionalTime(stored.SuppressedAtMS),
|
||||
FailedAt: inflateOptionalTime(stored.FailedAtMS),
|
||||
DeadLetteredAt: inflateOptionalTime(stored.DeadLetteredAtMS),
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return deliverydomain.Delivery{}, fmt.Errorf("decode redis delivery record: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// MarshalAttempt encodes record into the strict Redis JSON shape used for
|
||||
// mail_attempt records.
|
||||
func MarshalAttempt(record attempt.Attempt) ([]byte, error) {
|
||||
if err := record.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal redis attempt record: %w", err)
|
||||
}
|
||||
|
||||
stored := attemptRecord{
|
||||
DeliveryID: record.DeliveryID.String(),
|
||||
AttemptNo: record.AttemptNo,
|
||||
ScheduledForMS: record.ScheduledFor.UTC().UnixMilli(),
|
||||
StartedAtMS: optionalUnixMilli(record.StartedAt),
|
||||
FinishedAtMS: optionalUnixMilli(record.FinishedAt),
|
||||
Status: record.Status,
|
||||
ProviderClassification: record.ProviderClassification,
|
||||
ProviderSummary: record.ProviderSummary,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis attempt record: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// UnmarshalAttempt decodes payload from the strict Redis JSON shape used for
|
||||
// mail_attempt records.
|
||||
func UnmarshalAttempt(payload []byte) (attempt.Attempt, error) {
|
||||
var stored attemptRecord
|
||||
if err := decodeStrictJSON("decode redis attempt record", payload, &stored); err != nil {
|
||||
return attempt.Attempt{}, err
|
||||
}
|
||||
|
||||
record := attempt.Attempt{
|
||||
DeliveryID: common.DeliveryID(stored.DeliveryID),
|
||||
AttemptNo: stored.AttemptNo,
|
||||
ScheduledFor: time.UnixMilli(stored.ScheduledForMS).UTC(),
|
||||
StartedAt: inflateOptionalTime(stored.StartedAtMS),
|
||||
FinishedAt: inflateOptionalTime(stored.FinishedAtMS),
|
||||
Status: stored.Status,
|
||||
ProviderClassification: stored.ProviderClassification,
|
||||
ProviderSummary: stored.ProviderSummary,
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return attempt.Attempt{}, fmt.Errorf("decode redis attempt record: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// MarshalIdempotency encodes record into the strict Redis JSON shape used for
|
||||
// mail_idempotency_record values.
|
||||
func MarshalIdempotency(record idempotency.Record) ([]byte, error) {
|
||||
if err := record.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal redis idempotency record: %w", err)
|
||||
}
|
||||
|
||||
stored := idempotencyRecord{
|
||||
Source: record.Source,
|
||||
IdempotencyKey: record.IdempotencyKey.String(),
|
||||
DeliveryID: record.DeliveryID.String(),
|
||||
RequestFingerprint: record.RequestFingerprint,
|
||||
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
|
||||
ExpiresAtMS: record.ExpiresAt.UTC().UnixMilli(),
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis idempotency record: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// UnmarshalIdempotency decodes payload from the strict Redis JSON shape used
|
||||
// for mail_idempotency_record values.
|
||||
func UnmarshalIdempotency(payload []byte) (idempotency.Record, error) {
|
||||
var stored idempotencyRecord
|
||||
if err := decodeStrictJSON("decode redis idempotency record", payload, &stored); err != nil {
|
||||
return idempotency.Record{}, err
|
||||
}
|
||||
|
||||
record := idempotency.Record{
|
||||
Source: stored.Source,
|
||||
IdempotencyKey: common.IdempotencyKey(stored.IdempotencyKey),
|
||||
DeliveryID: common.DeliveryID(stored.DeliveryID),
|
||||
RequestFingerprint: stored.RequestFingerprint,
|
||||
CreatedAt: time.UnixMilli(stored.CreatedAtMS).UTC(),
|
||||
ExpiresAt: time.UnixMilli(stored.ExpiresAtMS).UTC(),
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return idempotency.Record{}, fmt.Errorf("decode redis idempotency record: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// MarshalDeadLetter encodes entry into the strict Redis JSON shape used for
|
||||
// mail_dead_letter_entry values.
|
||||
func MarshalDeadLetter(entry deliverydomain.DeadLetterEntry) ([]byte, error) {
|
||||
if err := entry.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal redis dead-letter record: %w", err)
|
||||
}
|
||||
|
||||
stored := deadLetterRecord{
|
||||
DeliveryID: entry.DeliveryID.String(),
|
||||
FinalAttemptNo: entry.FinalAttemptNo,
|
||||
FailureClassification: entry.FailureClassification,
|
||||
ProviderSummary: entry.ProviderSummary,
|
||||
CreatedAtMS: entry.CreatedAt.UTC().UnixMilli(),
|
||||
RecoveryHint: entry.RecoveryHint,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis dead-letter record: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// UnmarshalDeadLetter decodes payload from the strict Redis JSON shape used
|
||||
// for mail_dead_letter_entry values.
|
||||
func UnmarshalDeadLetter(payload []byte) (deliverydomain.DeadLetterEntry, error) {
|
||||
var stored deadLetterRecord
|
||||
if err := decodeStrictJSON("decode redis dead-letter record", payload, &stored); err != nil {
|
||||
return deliverydomain.DeadLetterEntry{}, err
|
||||
}
|
||||
|
||||
entry := deliverydomain.DeadLetterEntry{
|
||||
DeliveryID: common.DeliveryID(stored.DeliveryID),
|
||||
FinalAttemptNo: stored.FinalAttemptNo,
|
||||
FailureClassification: stored.FailureClassification,
|
||||
ProviderSummary: stored.ProviderSummary,
|
||||
CreatedAt: time.UnixMilli(stored.CreatedAtMS).UTC(),
|
||||
RecoveryHint: stored.RecoveryHint,
|
||||
}
|
||||
if err := entry.Validate(); err != nil {
|
||||
return deliverydomain.DeadLetterEntry{}, fmt.Errorf("decode redis dead-letter record: %w", err)
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// MarshalDeliveryPayload encodes payload into the strict Redis JSON shape used
|
||||
// for raw generic-delivery attachment bundles.
|
||||
func MarshalDeliveryPayload(payload acceptgenericdelivery.DeliveryPayload) ([]byte, error) {
|
||||
if err := payload.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal redis delivery payload record: %w", err)
|
||||
}
|
||||
|
||||
stored := deliveryPayloadRecord{
|
||||
DeliveryID: payload.DeliveryID.String(),
|
||||
Attachments: cloneDeliveryPayloadAttachments(payload.Attachments),
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis delivery payload record: %w", err)
|
||||
}
|
||||
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
// UnmarshalDeliveryPayload decodes payload from the strict Redis JSON shape
|
||||
// used for raw generic-delivery attachment bundles.
|
||||
func UnmarshalDeliveryPayload(payload []byte) (acceptgenericdelivery.DeliveryPayload, error) {
|
||||
var stored deliveryPayloadRecord
|
||||
if err := decodeStrictJSON("decode redis delivery payload record", payload, &stored); err != nil {
|
||||
return acceptgenericdelivery.DeliveryPayload{}, err
|
||||
}
|
||||
|
||||
record := acceptgenericdelivery.DeliveryPayload{
|
||||
DeliveryID: common.DeliveryID(stored.DeliveryID),
|
||||
Attachments: inflateDeliveryPayloadAttachments(stored.Attachments),
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return acceptgenericdelivery.DeliveryPayload{}, fmt.Errorf("decode redis delivery payload record: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// MarshalMalformedCommand encodes entry into the strict Redis JSON shape used
|
||||
// for operator-visible malformed async command records.
|
||||
func MarshalMalformedCommand(entry malformedcommand.Entry) ([]byte, error) {
|
||||
if err := entry.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal redis malformed command record: %w", err)
|
||||
}
|
||||
|
||||
stored := malformedCommandRecord{
|
||||
StreamEntryID: entry.StreamEntryID,
|
||||
DeliveryID: entry.DeliveryID,
|
||||
Source: entry.Source,
|
||||
IdempotencyKey: entry.IdempotencyKey,
|
||||
FailureCode: entry.FailureCode,
|
||||
FailureMessage: entry.FailureMessage,
|
||||
RawFieldsJSON: cloneJSONObject(entry.RawFields),
|
||||
RecordedAtMS: entry.RecordedAt.UTC().UnixMilli(),
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis malformed command record: %w", err)
|
||||
}
|
||||
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
// UnmarshalMalformedCommand decodes payload from the strict Redis JSON shape
|
||||
// used for operator-visible malformed async command records.
|
||||
func UnmarshalMalformedCommand(payload []byte) (malformedcommand.Entry, error) {
|
||||
var stored malformedCommandRecord
|
||||
if err := decodeStrictJSON("decode redis malformed command record", payload, &stored); err != nil {
|
||||
return malformedcommand.Entry{}, err
|
||||
}
|
||||
|
||||
entry := malformedcommand.Entry{
|
||||
StreamEntryID: stored.StreamEntryID,
|
||||
DeliveryID: stored.DeliveryID,
|
||||
Source: stored.Source,
|
||||
IdempotencyKey: stored.IdempotencyKey,
|
||||
FailureCode: stored.FailureCode,
|
||||
FailureMessage: stored.FailureMessage,
|
||||
RawFields: cloneJSONObject(stored.RawFieldsJSON),
|
||||
RecordedAt: time.UnixMilli(stored.RecordedAtMS).UTC(),
|
||||
}
|
||||
if err := entry.Validate(); err != nil {
|
||||
return malformedcommand.Entry{}, fmt.Errorf("decode redis malformed command record: %w", err)
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// MarshalStreamOffset encodes offset into the strict Redis JSON shape used for
|
||||
// persisted consumer progress.
|
||||
func MarshalStreamOffset(offset StreamOffset) ([]byte, error) {
|
||||
if err := offset.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal redis stream offset record: %w", err)
|
||||
}
|
||||
|
||||
stored := streamOffsetRecord{
|
||||
Stream: offset.Stream,
|
||||
LastProcessedEntryID: offset.LastProcessedEntryID,
|
||||
UpdatedAtMS: offset.UpdatedAt.UTC().UnixMilli(),
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis stream offset record: %w", err)
|
||||
}
|
||||
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
// UnmarshalStreamOffset decodes payload from the strict Redis JSON shape used
|
||||
// for persisted consumer progress.
|
||||
func UnmarshalStreamOffset(payload []byte) (StreamOffset, error) {
|
||||
var stored streamOffsetRecord
|
||||
if err := decodeStrictJSON("decode redis stream offset record", payload, &stored); err != nil {
|
||||
return StreamOffset{}, err
|
||||
}
|
||||
|
||||
offset := StreamOffset{
|
||||
Stream: stored.Stream,
|
||||
LastProcessedEntryID: stored.LastProcessedEntryID,
|
||||
UpdatedAt: time.UnixMilli(stored.UpdatedAtMS).UTC(),
|
||||
}
|
||||
if err := offset.Validate(); err != nil {
|
||||
return StreamOffset{}, fmt.Errorf("decode redis stream offset record: %w", err)
|
||||
}
|
||||
|
||||
return offset, nil
|
||||
}
|
||||
|
||||
func decodeStrictJSON(operation string, payload []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("%s: unexpected trailing JSON input", operation)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneEmailStrings(values []common.Email) []string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
cloned[index] = value.String()
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneEmails(values []string) []common.Email {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make([]common.Email, len(values))
|
||||
for index, value := range values {
|
||||
cloned[index] = common.Email(value)
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneAttachments(values []common.AttachmentMetadata) []attachmentRecord {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make([]attachmentRecord, len(values))
|
||||
for index, value := range values {
|
||||
cloned[index] = attachmentRecord{
|
||||
Filename: value.Filename,
|
||||
ContentType: value.ContentType,
|
||||
SizeBytes: value.SizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
func inflateAttachments(values []attachmentRecord) []common.AttachmentMetadata {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make([]common.AttachmentMetadata, len(values))
|
||||
for index, value := range values {
|
||||
cloned[index] = common.AttachmentMetadata{
|
||||
Filename: value.Filename,
|
||||
ContentType: value.ContentType,
|
||||
SizeBytes: value.SizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
func optionalJSONObject(value map[string]any) *map[string]any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make(map[string]any, len(value))
|
||||
for key, item := range value {
|
||||
cloned[key] = cloneJSONValue(item)
|
||||
}
|
||||
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneJSONObjectPtr(value *map[string]any) map[string]any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make(map[string]any, len(*value))
|
||||
for key, item := range *value {
|
||||
cloned[key] = cloneJSONValue(item)
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneJSONObject(value map[string]any) map[string]any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make(map[string]any, len(value))
|
||||
for key, item := range value {
|
||||
cloned[key] = cloneJSONValue(item)
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneJSONValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
cloned := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
cloned[key] = cloneJSONValue(item)
|
||||
}
|
||||
return cloned
|
||||
case []any:
|
||||
cloned := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
cloned[index] = cloneJSONValue(item)
|
||||
}
|
||||
return cloned
|
||||
default:
|
||||
return typed
|
||||
}
|
||||
}
|
||||
|
||||
func cloneDeliveryPayloadAttachments(values []acceptgenericdelivery.AttachmentPayload) []deliveryPayloadAttachmentRecord {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make([]deliveryPayloadAttachmentRecord, len(values))
|
||||
for index, value := range values {
|
||||
cloned[index] = deliveryPayloadAttachmentRecord{
|
||||
Filename: value.Filename,
|
||||
ContentType: value.ContentType,
|
||||
ContentBase64: value.ContentBase64,
|
||||
SizeBytes: value.SizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
func inflateDeliveryPayloadAttachments(values []deliveryPayloadAttachmentRecord) []acceptgenericdelivery.AttachmentPayload {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make([]acceptgenericdelivery.AttachmentPayload, len(values))
|
||||
for index, value := range values {
|
||||
cloned[index] = acceptgenericdelivery.AttachmentPayload{
|
||||
Filename: value.Filename,
|
||||
ContentType: value.ContentType,
|
||||
ContentBase64: value.ContentBase64,
|
||||
SizeBytes: value.SizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
func optionalUnixMilli(value *time.Time) *int64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
milliseconds := value.UTC().UnixMilli()
|
||||
return &milliseconds
|
||||
}
|
||||
|
||||
func inflateOptionalTime(value *int64) *time.Time {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
converted := time.UnixMilli(*value).UTC()
|
||||
return &converted
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/domain/idempotency"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDeliveryCodecRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := validDelivery(t)
|
||||
|
||||
payload, err := MarshalDelivery(record)
|
||||
require.NoError(t, err)
|
||||
|
||||
decoded, err := UnmarshalDelivery(payload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, decoded)
|
||||
}
|
||||
|
||||
func TestAttemptCodecRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := validTerminalAttempt(t, validDelivery(t).DeliveryID)
|
||||
|
||||
payload, err := MarshalAttempt(record)
|
||||
require.NoError(t, err)
|
||||
|
||||
decoded, err := UnmarshalAttempt(payload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, decoded)
|
||||
}
|
||||
|
||||
func TestIdempotencyCodecRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deliveryRecord := validDelivery(t)
|
||||
record := validIdempotencyRecord(t, deliveryRecord.Source, deliveryRecord.DeliveryID, deliveryRecord.IdempotencyKey)
|
||||
|
||||
payload, err := MarshalIdempotency(record)
|
||||
require.NoError(t, err)
|
||||
|
||||
decoded, err := UnmarshalIdempotency(payload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, decoded)
|
||||
}
|
||||
|
||||
func TestDeadLetterCodecRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := validDeadLetterEntry(t, validDelivery(t).DeliveryID)
|
||||
|
||||
payload, err := MarshalDeadLetter(record)
|
||||
require.NoError(t, err)
|
||||
|
||||
decoded, err := UnmarshalDeadLetter(payload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, decoded)
|
||||
}
|
||||
|
||||
func TestDeliveryCodecRejectsUnknownField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload, err := MarshalDelivery(validDelivery(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
payload = append(payload[:len(payload)-1], []byte(`,"extra":true}`)...)
|
||||
|
||||
_, err = UnmarshalDelivery(payload)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "unknown field")
|
||||
}
|
||||
|
||||
func TestAttemptCodecRejectsWrongType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload, err := MarshalAttempt(validTerminalAttempt(t, validDelivery(t).DeliveryID))
|
||||
require.NoError(t, err)
|
||||
|
||||
payload = bytes.Replace(payload, []byte(`"attempt_no":2`), []byte(`"attempt_no":"2"`), 1)
|
||||
|
||||
_, err = UnmarshalAttempt(payload)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "cannot unmarshal")
|
||||
}
|
||||
|
||||
func TestIdempotencyCodecRejectsTrailingJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deliveryRecord := validDelivery(t)
|
||||
payload, err := MarshalIdempotency(validIdempotencyRecord(t, deliveryRecord.Source, deliveryRecord.DeliveryID, deliveryRecord.IdempotencyKey))
|
||||
require.NoError(t, err)
|
||||
|
||||
payload = append(payload, []byte(` {}`)...)
|
||||
|
||||
_, err = UnmarshalIdempotency(payload)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "unexpected trailing JSON input")
|
||||
}
|
||||
|
||||
func TestDeadLetterCodecRejectsUnknownField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload, err := MarshalDeadLetter(validDeadLetterEntry(t, validDelivery(t).DeliveryID))
|
||||
require.NoError(t, err)
|
||||
|
||||
payload = append(payload[:len(payload)-1], []byte(`,"unexpected":"value"}`)...)
|
||||
|
||||
_, err = UnmarshalDeadLetter(payload)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "unknown field")
|
||||
}
|
||||
|
||||
var (
|
||||
_ = attempt.Attempt{}
|
||||
_ = deliverydomain.DeadLetterEntry{}
|
||||
_ = idempotency.Record{}
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
// Package redisstate defines the frozen Redis keyspace, strict JSON records,
|
||||
// and low-level mutation helpers used by future Mail Service Redis adapters.
|
||||
package redisstate
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// ErrConflict reports that a Redis mutation could not be applied because
|
||||
// one of the watched or newly created keys already existed or changed
|
||||
// concurrently.
|
||||
ErrConflict = errors.New("redis state conflict")
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"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/domain/malformedcommand"
|
||||
"galaxy/mail/internal/service/acceptgenericdelivery"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func validDelivery(t require.TestingT) deliverydomain.Delivery {
|
||||
locale, err := common.ParseLocale("fr-fr")
|
||||
require.NoError(t, err)
|
||||
|
||||
createdAt := time.Unix(1_775_121_700, 0).UTC()
|
||||
updatedAt := createdAt.Add(2 * time.Minute)
|
||||
sentAt := updatedAt.Add(15 * time.Second)
|
||||
|
||||
record := deliverydomain.Delivery{
|
||||
DeliveryID: common.DeliveryID("delivery-123"),
|
||||
ResendParentDeliveryID: common.DeliveryID("delivery-parent-001"),
|
||||
Source: deliverydomain.SourceOperatorResend,
|
||||
PayloadMode: deliverydomain.PayloadModeTemplate,
|
||||
TemplateID: common.TemplateID("auth.login_code"),
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: []common.Email{common.Email("pilot@example.com")},
|
||||
Cc: []common.Email{common.Email("copilot@example.com")},
|
||||
Bcc: []common.Email{common.Email("ops@example.com")},
|
||||
ReplyTo: []common.Email{common.Email("noreply@example.com")},
|
||||
},
|
||||
Content: deliverydomain.Content{
|
||||
Subject: "Your login code",
|
||||
TextBody: "Code: 123456",
|
||||
HTMLBody: "<p>Code: <strong>123456</strong></p>",
|
||||
},
|
||||
Attachments: []common.AttachmentMetadata{
|
||||
{Filename: "instructions.txt", ContentType: "text/plain; charset=utf-8", SizeBytes: 128},
|
||||
},
|
||||
Locale: locale,
|
||||
TemplateVariables: map[string]any{
|
||||
"code": "123456",
|
||||
},
|
||||
LocaleFallbackUsed: true,
|
||||
IdempotencyKey: common.IdempotencyKey("operator:resend:delivery-123"),
|
||||
Status: deliverydomain.StatusSent,
|
||||
AttemptCount: 2,
|
||||
LastAttemptStatus: attempt.StatusProviderAccepted,
|
||||
ProviderSummary: "queued by provider",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
SentAt: &sentAt,
|
||||
}
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func validScheduledAttempt(t require.TestingT, deliveryID common.DeliveryID) attempt.Attempt {
|
||||
scheduledFor := time.Unix(1_775_121_820, 0).UTC()
|
||||
|
||||
record := attempt.Attempt{
|
||||
DeliveryID: deliveryID,
|
||||
AttemptNo: 1,
|
||||
ScheduledFor: scheduledFor,
|
||||
Status: attempt.StatusScheduled,
|
||||
}
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func validQueuedTemplateDelivery(t require.TestingT) deliverydomain.Delivery {
|
||||
record := validDelivery(t)
|
||||
record.DeliveryID = common.DeliveryID("delivery-queued")
|
||||
record.ResendParentDeliveryID = ""
|
||||
record.Source = deliverydomain.SourceNotification
|
||||
record.Status = deliverydomain.StatusQueued
|
||||
record.AttemptCount = 1
|
||||
record.LastAttemptStatus = ""
|
||||
record.ProviderSummary = ""
|
||||
record.LocaleFallbackUsed = false
|
||||
record.Content = deliverydomain.Content{}
|
||||
record.CreatedAt = time.Unix(1_775_121_700, 0).UTC()
|
||||
record.UpdatedAt = record.CreatedAt
|
||||
record.SentAt = nil
|
||||
record.SuppressedAt = nil
|
||||
record.FailedAt = nil
|
||||
record.DeadLetteredAt = nil
|
||||
record.IdempotencyKey = common.IdempotencyKey("notification:delivery-queued")
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func validTerminalAttempt(t require.TestingT, deliveryID common.DeliveryID) attempt.Attempt {
|
||||
scheduledFor := time.Unix(1_775_121_820, 0).UTC()
|
||||
startedAt := scheduledFor.Add(5 * time.Second)
|
||||
finishedAt := startedAt.Add(2 * time.Second)
|
||||
|
||||
record := attempt.Attempt{
|
||||
DeliveryID: deliveryID,
|
||||
AttemptNo: 2,
|
||||
ScheduledFor: scheduledFor,
|
||||
StartedAt: &startedAt,
|
||||
FinishedAt: &finishedAt,
|
||||
Status: attempt.StatusProviderAccepted,
|
||||
ProviderClassification: "accepted",
|
||||
ProviderSummary: "queued by provider",
|
||||
}
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func validRenderFailedAttempt(t require.TestingT, deliveryID common.DeliveryID) attempt.Attempt {
|
||||
record := validScheduledAttempt(t, deliveryID)
|
||||
startedAt := record.ScheduledFor.Add(time.Second)
|
||||
finishedAt := startedAt
|
||||
record.StartedAt = &startedAt
|
||||
record.FinishedAt = &finishedAt
|
||||
record.Status = attempt.StatusRenderFailed
|
||||
record.ProviderClassification = "missing_required_variable"
|
||||
record.ProviderSummary = "missing required variables: player.name"
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func validIdempotencyRecord(t require.TestingT, source deliverydomain.Source, deliveryID common.DeliveryID, key common.IdempotencyKey) idempotency.Record {
|
||||
createdAt := time.Now().UTC().Truncate(time.Millisecond).Add(-time.Minute)
|
||||
|
||||
record := idempotency.Record{
|
||||
Source: source,
|
||||
IdempotencyKey: key,
|
||||
DeliveryID: deliveryID,
|
||||
RequestFingerprint: "sha256:abcdef123456",
|
||||
CreatedAt: createdAt,
|
||||
ExpiresAt: createdAt.Add(IdempotencyTTL),
|
||||
}
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func validDeadLetterEntry(t require.TestingT, deliveryID common.DeliveryID) deliverydomain.DeadLetterEntry {
|
||||
entry := deliverydomain.DeadLetterEntry{
|
||||
DeliveryID: deliveryID,
|
||||
FinalAttemptNo: 3,
|
||||
FailureClassification: "retry_exhausted",
|
||||
ProviderSummary: "smtp timeout",
|
||||
CreatedAt: time.Unix(1_775_122_000, 0).UTC(),
|
||||
RecoveryHint: "check SMTP connectivity",
|
||||
}
|
||||
require.NoError(t, entry.Validate())
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
func validDeliveryPayload(t require.TestingT, deliveryID common.DeliveryID) acceptgenericdelivery.DeliveryPayload {
|
||||
payload := acceptgenericdelivery.DeliveryPayload{
|
||||
DeliveryID: deliveryID,
|
||||
Attachments: []acceptgenericdelivery.AttachmentPayload{
|
||||
{
|
||||
Filename: "instructions.txt",
|
||||
ContentType: "text/plain; charset=utf-8",
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte("read me")),
|
||||
SizeBytes: int64(len([]byte("read me"))),
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, payload.Validate())
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
func validMalformedCommandEntry(t require.TestingT) malformedcommand.Entry {
|
||||
entry := malformedcommand.Entry{
|
||||
StreamEntryID: "1775121700000-0",
|
||||
DeliveryID: "mail-123",
|
||||
Source: "notification",
|
||||
IdempotencyKey: "notification:mail-123",
|
||||
FailureCode: malformedcommand.FailureCodeInvalidPayload,
|
||||
FailureMessage: "payload_json.subject is required",
|
||||
RawFields: map[string]any{
|
||||
"delivery_id": "mail-123",
|
||||
"source": "notification",
|
||||
"payload_mode": "rendered",
|
||||
"idempotency_key": "notification:mail-123",
|
||||
},
|
||||
RecordedAt: time.Unix(1_775_121_700, 0).UTC(),
|
||||
}
|
||||
require.NoError(t, entry.Validate())
|
||||
|
||||
return entry
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/domain/idempotency"
|
||||
"galaxy/mail/internal/service/acceptgenericdelivery"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// GenericAcceptanceStore provides the Redis-backed durable storage used by the
|
||||
// generic-delivery acceptance use case.
|
||||
type GenericAcceptanceStore struct {
|
||||
client *redis.Client
|
||||
writer *AtomicWriter
|
||||
keys Keyspace
|
||||
}
|
||||
|
||||
// NewGenericAcceptanceStore constructs one Redis-backed generic acceptance
|
||||
// store.
|
||||
func NewGenericAcceptanceStore(client *redis.Client) (*GenericAcceptanceStore, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new generic acceptance store: nil redis client")
|
||||
}
|
||||
|
||||
writer, err := NewAtomicWriter(client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new generic acceptance store: %w", err)
|
||||
}
|
||||
|
||||
return &GenericAcceptanceStore{
|
||||
client: client,
|
||||
writer: writer,
|
||||
keys: Keyspace{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateAcceptance stores one generic-delivery acceptance write set in Redis.
|
||||
func (store *GenericAcceptanceStore) CreateAcceptance(ctx context.Context, input acceptgenericdelivery.CreateAcceptanceInput) error {
|
||||
if store == nil || store.client == nil || store.writer == nil {
|
||||
return errors.New("create generic acceptance: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("create generic acceptance: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("create generic acceptance: %w", err)
|
||||
}
|
||||
|
||||
writerInput := CreateAcceptanceInput{
|
||||
Delivery: input.Delivery,
|
||||
FirstAttempt: &input.FirstAttempt,
|
||||
Idempotency: &input.Idempotency,
|
||||
}
|
||||
if input.DeliveryPayload != nil {
|
||||
writerInput.DeliveryPayload = input.DeliveryPayload
|
||||
}
|
||||
|
||||
err := store.writer.CreateAcceptance(ctx, writerInput)
|
||||
if errors.Is(err, ErrConflict) {
|
||||
return fmt.Errorf("create generic acceptance: %w", acceptgenericdelivery.ErrConflict)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("create generic acceptance: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetIdempotency loads one accepted idempotency scope from Redis.
|
||||
func (store *GenericAcceptanceStore) GetIdempotency(ctx context.Context, source deliverydomain.Source, key common.IdempotencyKey) (idempotency.Record, bool, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return idempotency.Record{}, false, errors.New("get generic acceptance idempotency: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return idempotency.Record{}, false, errors.New("get generic acceptance idempotency: nil context")
|
||||
}
|
||||
|
||||
payload, err := store.client.Get(ctx, store.keys.Idempotency(source, key)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return idempotency.Record{}, false, nil
|
||||
case err != nil:
|
||||
return idempotency.Record{}, false, fmt.Errorf("get generic acceptance idempotency: %w", err)
|
||||
}
|
||||
|
||||
record, err := UnmarshalIdempotency(payload)
|
||||
if err != nil {
|
||||
return idempotency.Record{}, false, fmt.Errorf("get generic acceptance idempotency: %w", err)
|
||||
}
|
||||
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
// GetDelivery loads one accepted delivery by its identifier.
|
||||
func (store *GenericAcceptanceStore) GetDelivery(ctx context.Context, deliveryID common.DeliveryID) (deliverydomain.Delivery, bool, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return deliverydomain.Delivery{}, false, errors.New("get generic acceptance delivery: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return deliverydomain.Delivery{}, false, errors.New("get generic acceptance delivery: nil context")
|
||||
}
|
||||
|
||||
payload, err := store.client.Get(ctx, store.keys.Delivery(deliveryID)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return deliverydomain.Delivery{}, false, nil
|
||||
case err != nil:
|
||||
return deliverydomain.Delivery{}, false, fmt.Errorf("get generic acceptance delivery: %w", err)
|
||||
}
|
||||
|
||||
record, err := UnmarshalDelivery(payload)
|
||||
if err != nil {
|
||||
return deliverydomain.Delivery{}, false, fmt.Errorf("get generic acceptance delivery: %w", err)
|
||||
}
|
||||
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
// GetDeliveryPayload loads one raw accepted attachment bundle by delivery id.
|
||||
func (store *GenericAcceptanceStore) GetDeliveryPayload(ctx context.Context, deliveryID common.DeliveryID) (acceptgenericdelivery.DeliveryPayload, bool, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return acceptgenericdelivery.DeliveryPayload{}, false, errors.New("get generic acceptance delivery payload: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return acceptgenericdelivery.DeliveryPayload{}, false, errors.New("get generic acceptance delivery payload: nil context")
|
||||
}
|
||||
|
||||
payload, err := store.client.Get(ctx, store.keys.DeliveryPayload(deliveryID)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return acceptgenericdelivery.DeliveryPayload{}, false, nil
|
||||
case err != nil:
|
||||
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("get generic acceptance delivery payload: %w", err)
|
||||
}
|
||||
|
||||
record, err := UnmarshalDeliveryPayload(payload)
|
||||
if err != nil {
|
||||
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("get generic acceptance delivery payload: %w", err)
|
||||
}
|
||||
|
||||
return record, true, nil
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/service/acceptgenericdelivery"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenericAcceptanceStoreCreateAndReadRenderedDelivery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
store, err := NewGenericAcceptanceStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := validDelivery(t)
|
||||
record.Source = deliverydomain.SourceNotification
|
||||
record.ResendParentDeliveryID = ""
|
||||
record.PayloadMode = deliverydomain.PayloadModeRendered
|
||||
record.TemplateID = ""
|
||||
record.TemplateVariables = nil
|
||||
record.Locale = ""
|
||||
record.LocaleFallbackUsed = false
|
||||
record.Status = deliverydomain.StatusQueued
|
||||
record.AttemptCount = 1
|
||||
record.LastAttemptStatus = ""
|
||||
record.ProviderSummary = ""
|
||||
record.SentAt = nil
|
||||
record.UpdatedAt = record.CreatedAt
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
input := acceptgenericdelivery.CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: validScheduledAttempt(t, record.DeliveryID),
|
||||
DeliveryPayload: ptr(validDeliveryPayload(t, record.DeliveryID)),
|
||||
Idempotency: validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey),
|
||||
}
|
||||
|
||||
require.NoError(t, store.CreateAcceptance(context.Background(), input))
|
||||
|
||||
storedDelivery, found, err := store.GetDelivery(context.Background(), record.DeliveryID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, record, storedDelivery)
|
||||
|
||||
storedPayload, found, err := store.GetDeliveryPayload(context.Background(), record.DeliveryID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, *input.DeliveryPayload, storedPayload)
|
||||
}
|
||||
|
||||
func TestGenericAcceptanceStoreReturnsMissingPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
store, err := NewGenericAcceptanceStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
payload, found, err := store.GetDeliveryPayload(context.Background(), common.DeliveryID("missing"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, found)
|
||||
require.Equal(t, acceptgenericdelivery.DeliveryPayload{}, payload)
|
||||
}
|
||||
|
||||
func TestMalformedCommandStoreRecordIsIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
store, err := NewMalformedCommandStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
entry := validMalformedCommandEntry(t)
|
||||
|
||||
require.NoError(t, store.Record(context.Background(), entry))
|
||||
require.NoError(t, store.Record(context.Background(), entry))
|
||||
|
||||
storedEntry, found, err := store.Get(context.Background(), entry.StreamEntryID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, entry, storedEntry)
|
||||
|
||||
indexCard, err := client.ZCard(context.Background(), Keyspace{}.MalformedCommandCreatedAtIndex()).Result()
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, indexCard)
|
||||
}
|
||||
|
||||
func TestMalformedCommandStoreAppliesRetention(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
store, err := NewMalformedCommandStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
entry := validMalformedCommandEntry(t)
|
||||
require.NoError(t, store.Record(context.Background(), entry))
|
||||
|
||||
ttl := server.TTL(Keyspace{}.MalformedCommand(entry.StreamEntryID))
|
||||
require.InDelta(t, DeadLetterTTL.Seconds(), ttl.Seconds(), 1)
|
||||
}
|
||||
|
||||
func TestStreamOffsetStoreSaveAndLoad(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
store, err := NewStreamOffsetStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, store.Save(context.Background(), "mail:delivery_commands", "1775121700000-0"))
|
||||
|
||||
entryID, found, err := store.Load(context.Background(), "mail:delivery_commands")
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, "1775121700000-0", entryID)
|
||||
|
||||
payload, err := client.Get(context.Background(), Keyspace{}.StreamOffset("mail:delivery_commands")).Bytes()
|
||||
require.NoError(t, err)
|
||||
offset, err := UnmarshalStreamOffset(payload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "mail:delivery_commands", offset.Stream)
|
||||
require.Equal(t, "1775121700000-0", offset.LastProcessedEntryID)
|
||||
require.WithinDuration(t, time.Now().UTC(), offset.UpdatedAt, time.Second)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// CleanupReport describes the work done by IndexCleaner.
|
||||
type CleanupReport struct {
|
||||
// ScannedIndexes stores how many secondary index keys were inspected.
|
||||
ScannedIndexes int
|
||||
|
||||
// ScannedMembers stores how many index members were examined.
|
||||
ScannedMembers int
|
||||
|
||||
// RemovedMembers stores how many stale members were removed.
|
||||
RemovedMembers int
|
||||
}
|
||||
|
||||
// IndexCleaner removes stale delivery references from the Mail Service
|
||||
// secondary indexes after primary delivery keys expire by TTL.
|
||||
type IndexCleaner struct {
|
||||
client *redis.Client
|
||||
keyspace Keyspace
|
||||
}
|
||||
|
||||
// NewIndexCleaner constructs one delivery-index cleanup helper.
|
||||
func NewIndexCleaner(client *redis.Client) (*IndexCleaner, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new redis index cleaner: nil client")
|
||||
}
|
||||
|
||||
return &IndexCleaner{
|
||||
client: client,
|
||||
keyspace: Keyspace{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CleanDeliveryIndexes scans every `mail:idx:*` key and removes members that
|
||||
// no longer have a primary delivery record.
|
||||
func (cleaner *IndexCleaner) CleanDeliveryIndexes(ctx context.Context) (CleanupReport, error) {
|
||||
if cleaner == nil || cleaner.client == nil {
|
||||
return CleanupReport{}, errors.New("clean delivery indexes in redis: nil cleaner")
|
||||
}
|
||||
if ctx == nil {
|
||||
return CleanupReport{}, errors.New("clean delivery indexes in redis: nil context")
|
||||
}
|
||||
|
||||
var (
|
||||
report CleanupReport
|
||||
cursor uint64
|
||||
)
|
||||
|
||||
for {
|
||||
keys, nextCursor, err := cleaner.client.Scan(ctx, cursor, cleaner.keyspace.SecondaryIndexPattern(), 0).Result()
|
||||
if err != nil {
|
||||
return CleanupReport{}, fmt.Errorf("clean delivery indexes in redis: %w", err)
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if key == cleaner.keyspace.MalformedCommandCreatedAtIndex() {
|
||||
continue
|
||||
}
|
||||
|
||||
report.ScannedIndexes++
|
||||
|
||||
members, err := cleaner.client.ZRange(ctx, key, 0, -1).Result()
|
||||
if err != nil {
|
||||
return CleanupReport{}, fmt.Errorf("clean delivery indexes in redis: read index %q: %w", key, err)
|
||||
}
|
||||
|
||||
report.ScannedMembers += len(members)
|
||||
for _, member := range members {
|
||||
remove, err := cleaner.shouldRemoveMember(ctx, member)
|
||||
if err != nil {
|
||||
return CleanupReport{}, fmt.Errorf("clean delivery indexes in redis: inspect index %q member %q: %w", key, member, err)
|
||||
}
|
||||
if !remove {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := cleaner.client.ZRem(ctx, key, member).Err(); err != nil {
|
||||
return CleanupReport{}, fmt.Errorf("clean delivery indexes in redis: remove index %q member %q: %w", key, member, err)
|
||||
}
|
||||
report.RemovedMembers++
|
||||
}
|
||||
}
|
||||
|
||||
if nextCursor == 0 {
|
||||
return report, nil
|
||||
}
|
||||
cursor = nextCursor
|
||||
}
|
||||
}
|
||||
|
||||
func (cleaner *IndexCleaner) shouldRemoveMember(ctx context.Context, member string) (bool, error) {
|
||||
if strings.TrimSpace(member) == "" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
deliveryID := common.DeliveryID(member)
|
||||
if err := deliveryID.Validate(); err != nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
exists, err := cleaner.client.Exists(ctx, cleaner.keyspace.Delivery(deliveryID)).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return exists == 0, nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIndexCleanerRemovesStaleMembersAfterDeliveryExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
writer, err := NewAtomicWriter(client)
|
||||
require.NoError(t, err)
|
||||
cleaner, err := NewIndexCleaner(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := validDelivery(t)
|
||||
record.Source = deliverydomain.SourceNotification
|
||||
record.ResendParentDeliveryID = ""
|
||||
record.Status = deliverydomain.StatusQueued
|
||||
record.SentAt = nil
|
||||
record.LocaleFallbackUsed = false
|
||||
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
input := CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: ptr(validScheduledAttempt(t, record.DeliveryID)),
|
||||
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
|
||||
}
|
||||
require.NoError(t, writer.CreateAcceptance(context.Background(), input))
|
||||
|
||||
deadLetterEntry := validDeadLetterEntry(t, record.DeliveryID)
|
||||
deadLetterPayload, err := MarshalDeadLetter(deadLetterEntry)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.Set(context.Background(), Keyspace{}.DeadLetter(record.DeliveryID), deadLetterPayload, DeadLetterTTL).Err())
|
||||
|
||||
server.FastForward(DeliveryTTL + time.Second)
|
||||
|
||||
require.False(t, server.Exists(Keyspace{}.Delivery(record.DeliveryID)))
|
||||
require.True(t, server.Exists(Keyspace{}.Attempt(record.DeliveryID, input.FirstAttempt.AttemptNo)))
|
||||
require.True(t, server.Exists(Keyspace{}.DeadLetter(record.DeliveryID)))
|
||||
|
||||
report, err := cleaner.CleanDeliveryIndexes(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Positive(t, report.ScannedIndexes)
|
||||
require.Positive(t, report.ScannedMembers)
|
||||
require.Positive(t, report.RemovedMembers)
|
||||
|
||||
assertZCard := func(key string, want int64) {
|
||||
t.Helper()
|
||||
|
||||
got, err := client.ZCard(context.Background(), key).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
|
||||
assertZCard(Keyspace{}.CreatedAtIndex(), 0)
|
||||
assertZCard(Keyspace{}.SourceIndex(record.Source), 0)
|
||||
assertZCard(Keyspace{}.StatusIndex(record.Status), 0)
|
||||
assertZCard(Keyspace{}.RecipientIndex(record.Envelope.To[0]), 0)
|
||||
assertZCard(Keyspace{}.RecipientIndex(record.Envelope.Cc[0]), 0)
|
||||
assertZCard(Keyspace{}.RecipientIndex(record.Envelope.Bcc[0]), 0)
|
||||
assertZCard(Keyspace{}.TemplateIndex(record.TemplateID), 0)
|
||||
assertZCard(Keyspace{}.IdempotencyIndex(record.Source, record.IdempotencyKey), 0)
|
||||
|
||||
require.True(t, server.Exists(Keyspace{}.Attempt(record.DeliveryID, input.FirstAttempt.AttemptNo)))
|
||||
require.True(t, server.Exists(Keyspace{}.DeadLetter(record.DeliveryID)))
|
||||
scheduleCard, err := client.ZCard(context.Background(), Keyspace{}.AttemptSchedule()).Result()
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, scheduleCard)
|
||||
}
|
||||
|
||||
func TestIndexCleanerSkipsMalformedCommandIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
cleaner, err := NewIndexCleaner(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
entry := validMalformedCommandEntry(t)
|
||||
require.NoError(t, client.ZAdd(context.Background(), Keyspace{}.MalformedCommandCreatedAtIndex(), redis.Z{
|
||||
Score: float64(entry.RecordedAt.UTC().UnixMilli()),
|
||||
Member: entry.StreamEntryID,
|
||||
}).Err())
|
||||
|
||||
report, err := cleaner.CleanDeliveryIndexes(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, report.ScannedIndexes)
|
||||
require.Zero(t, report.ScannedMembers)
|
||||
require.Zero(t, report.RemovedMembers)
|
||||
|
||||
indexMembers, err := client.ZRange(context.Background(), Keyspace{}.MalformedCommandCreatedAtIndex(), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{entry.StreamEntryID}, indexMembers)
|
||||
}
|
||||
|
||||
var _ = attempt.Attempt{}
|
||||
@@ -0,0 +1,172 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
)
|
||||
|
||||
const defaultPrefix = "mail:"
|
||||
|
||||
const (
|
||||
// IdempotencyTTL is the frozen Redis retention for idempotency records.
|
||||
IdempotencyTTL = 7 * 24 * time.Hour
|
||||
|
||||
// DeliveryTTL is the frozen Redis retention for accepted delivery records.
|
||||
DeliveryTTL = 30 * 24 * time.Hour
|
||||
|
||||
// AttemptTTL is the frozen Redis retention for attempt records.
|
||||
AttemptTTL = 90 * 24 * time.Hour
|
||||
|
||||
// DeadLetterTTL is the frozen Redis retention for dead-letter entries.
|
||||
DeadLetterTTL = 90 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// Keyspace builds the frozen Mail Service Redis keys. All dynamic key
|
||||
// segments are encoded with base64url so raw key structure does not depend on
|
||||
// user-provided or caller-provided characters.
|
||||
type Keyspace struct{}
|
||||
|
||||
// Delivery returns the primary Redis key for one mail_delivery record.
|
||||
func (Keyspace) Delivery(deliveryID common.DeliveryID) string {
|
||||
return defaultPrefix + "deliveries:" + encodeKeyComponent(deliveryID.String())
|
||||
}
|
||||
|
||||
// Attempt returns the primary Redis key for one mail_attempt record.
|
||||
func (Keyspace) Attempt(deliveryID common.DeliveryID, attemptNo int) string {
|
||||
return defaultPrefix + "attempts:" + encodeKeyComponent(deliveryID.String()) + ":" + encodeKeyComponent(strconv.Itoa(attemptNo))
|
||||
}
|
||||
|
||||
// Idempotency returns the primary Redis key for one mail_idempotency_record.
|
||||
func (Keyspace) Idempotency(source deliverydomain.Source, key common.IdempotencyKey) string {
|
||||
return defaultPrefix + "idempotency:" + encodeKeyComponent(string(source)) + ":" + encodeKeyComponent(key.String())
|
||||
}
|
||||
|
||||
// DeadLetter returns the primary Redis key for one mail_dead_letter_entry.
|
||||
func (Keyspace) DeadLetter(deliveryID common.DeliveryID) string {
|
||||
return defaultPrefix + "dead_letters:" + encodeKeyComponent(deliveryID.String())
|
||||
}
|
||||
|
||||
// DeliveryPayload returns the primary Redis key for one raw generic-delivery
|
||||
// payload bundle.
|
||||
func (Keyspace) DeliveryPayload(deliveryID common.DeliveryID) string {
|
||||
return defaultPrefix + "delivery_payloads:" + encodeKeyComponent(deliveryID.String())
|
||||
}
|
||||
|
||||
// MalformedCommand returns the primary Redis key for one operator-visible
|
||||
// malformed async command record.
|
||||
func (Keyspace) MalformedCommand(streamEntryID string) string {
|
||||
return defaultPrefix + "malformed_commands:" + encodeKeyComponent(streamEntryID)
|
||||
}
|
||||
|
||||
// StreamOffset returns the primary Redis key for one persisted stream-consumer
|
||||
// offset.
|
||||
func (Keyspace) StreamOffset(stream string) string {
|
||||
return defaultPrefix + "stream_offsets:" + encodeKeyComponent(stream)
|
||||
}
|
||||
|
||||
// DeliveryCommands returns the frozen async ingress Redis Stream key.
|
||||
func (Keyspace) DeliveryCommands() string {
|
||||
return defaultPrefix + "delivery_commands"
|
||||
}
|
||||
|
||||
// AttemptSchedule returns the frozen attempt schedule sorted-set key.
|
||||
func (Keyspace) AttemptSchedule() string {
|
||||
return defaultPrefix + "attempt_schedule"
|
||||
}
|
||||
|
||||
// RecipientIndex returns the secondary index key for one effective recipient.
|
||||
func (Keyspace) RecipientIndex(email common.Email) string {
|
||||
return defaultPrefix + "idx:recipient:" + encodeKeyComponent(email.String())
|
||||
}
|
||||
|
||||
// StatusIndex returns the secondary index key for one delivery status.
|
||||
func (Keyspace) StatusIndex(status deliverydomain.Status) string {
|
||||
return defaultPrefix + "idx:status:" + encodeKeyComponent(string(status))
|
||||
}
|
||||
|
||||
// SourceIndex returns the secondary index key for one delivery source.
|
||||
func (Keyspace) SourceIndex(source deliverydomain.Source) string {
|
||||
return defaultPrefix + "idx:source:" + encodeKeyComponent(string(source))
|
||||
}
|
||||
|
||||
// TemplateIndex returns the secondary index key for one template id.
|
||||
func (Keyspace) TemplateIndex(templateID common.TemplateID) string {
|
||||
return defaultPrefix + "idx:template:" + encodeKeyComponent(templateID.String())
|
||||
}
|
||||
|
||||
// IdempotencyIndex returns the secondary lookup key for one `(source,
|
||||
// idempotency_key)` scope.
|
||||
func (Keyspace) IdempotencyIndex(source deliverydomain.Source, key common.IdempotencyKey) string {
|
||||
return defaultPrefix + "idx:idempotency:" + encodeKeyComponent(string(source)) + ":" + encodeKeyComponent(key.String())
|
||||
}
|
||||
|
||||
// CreatedAtIndex returns the newest-first delivery ordering index key.
|
||||
func (Keyspace) CreatedAtIndex() string {
|
||||
return defaultPrefix + "idx:created_at"
|
||||
}
|
||||
|
||||
// MalformedCommandCreatedAtIndex returns the newest-first malformed-command
|
||||
// ordering index key.
|
||||
func (Keyspace) MalformedCommandCreatedAtIndex() string {
|
||||
return defaultPrefix + "idx:malformed_command:created_at"
|
||||
}
|
||||
|
||||
// SecondaryIndexPattern returns the key-scan pattern that matches every
|
||||
// delivery-level secondary index owned by Mail Service.
|
||||
func (Keyspace) SecondaryIndexPattern() string {
|
||||
return defaultPrefix + "idx:*"
|
||||
}
|
||||
|
||||
// DeliveryIndexKeys returns the full set of secondary index keys that must
|
||||
// reference record at creation time. Recipient indexing covers `to`, `cc`, and
|
||||
// `bcc`, but intentionally excludes `reply_to`.
|
||||
func (keyspace Keyspace) DeliveryIndexKeys(record deliverydomain.Delivery) []string {
|
||||
keys := []string{
|
||||
keyspace.StatusIndex(record.Status),
|
||||
keyspace.SourceIndex(record.Source),
|
||||
keyspace.IdempotencyIndex(record.Source, record.IdempotencyKey),
|
||||
keyspace.CreatedAtIndex(),
|
||||
}
|
||||
if !record.TemplateID.IsZero() {
|
||||
keys = append(keys, keyspace.TemplateIndex(record.TemplateID))
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(keys)+len(record.Envelope.To)+len(record.Envelope.Cc)+len(record.Envelope.Bcc))
|
||||
for _, key := range keys {
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
for _, group := range [][]common.Email{record.Envelope.To, record.Envelope.Cc, record.Envelope.Bcc} {
|
||||
for _, email := range group {
|
||||
seen[keyspace.RecipientIndex(email)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
keys = keys[:0]
|
||||
for key := range seen {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
// CreatedAtScore returns the frozen sorted-set score representation for
|
||||
// delivery creation timestamps.
|
||||
func CreatedAtScore(createdAt time.Time) float64 {
|
||||
return float64(createdAt.UTC().UnixMilli())
|
||||
}
|
||||
|
||||
// ScheduledForScore returns the frozen sorted-set score representation for
|
||||
// attempt schedule timestamps.
|
||||
func ScheduledForScore(scheduledFor time.Time) float64 {
|
||||
return float64(scheduledFor.UTC().UnixMilli())
|
||||
}
|
||||
|
||||
func encodeKeyComponent(value string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(value))
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestKeyspaceBuildsStableKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
keyspace := Keyspace{}
|
||||
|
||||
require.Equal(t, "mail:deliveries:ZGVsaXZlcnktMTIz", keyspace.Delivery(common.DeliveryID("delivery-123")))
|
||||
require.Equal(t, "mail:attempts:ZGVsaXZlcnktMTIz:MQ", keyspace.Attempt(common.DeliveryID("delivery-123"), 1))
|
||||
require.Equal(t, "mail:idempotency:bm90aWZpY2F0aW9u:bm90aWZpY2F0aW9uOm1haWwtMTIz", keyspace.Idempotency(deliverydomain.SourceNotification, common.IdempotencyKey("notification:mail-123")))
|
||||
require.Equal(t, "mail:dead_letters:ZGVsaXZlcnktMTIz", keyspace.DeadLetter(common.DeliveryID("delivery-123")))
|
||||
require.Equal(t, "mail:delivery_commands", keyspace.DeliveryCommands())
|
||||
require.Equal(t, "mail:attempt_schedule", keyspace.AttemptSchedule())
|
||||
require.Equal(t, "mail:idx:recipient:cGlsb3RAZXhhbXBsZS5jb20", keyspace.RecipientIndex(common.Email("pilot@example.com")))
|
||||
require.Equal(t, "mail:idx:status:c2VudA", keyspace.StatusIndex(deliverydomain.StatusSent))
|
||||
require.Equal(t, "mail:idx:source:bm90aWZpY2F0aW9u", keyspace.SourceIndex(deliverydomain.SourceNotification))
|
||||
require.Equal(t, "mail:idx:template:YXV0aC5sb2dpbl9jb2Rl", keyspace.TemplateIndex(common.TemplateID("auth.login_code")))
|
||||
require.Equal(t, "mail:idx:idempotency:bm90aWZpY2F0aW9u:bm90aWZpY2F0aW9uOm1haWwtMTIz", keyspace.IdempotencyIndex(deliverydomain.SourceNotification, common.IdempotencyKey("notification:mail-123")))
|
||||
require.Equal(t, "mail:idx:created_at", keyspace.CreatedAtIndex())
|
||||
require.Equal(t, "mail:idx:*", keyspace.SecondaryIndexPattern())
|
||||
}
|
||||
|
||||
func TestDeliveryIndexKeysDedupeRecipientsAndIgnoreReplyTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := validDelivery(t)
|
||||
record.Source = deliverydomain.SourceNotification
|
||||
record.ResendParentDeliveryID = ""
|
||||
record.Status = deliverydomain.StatusQueued
|
||||
record.SentAt = nil
|
||||
record.LocaleFallbackUsed = false
|
||||
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
|
||||
record.Envelope.Cc = []common.Email{common.Email("pilot@example.com")}
|
||||
record.Envelope.ReplyTo = []common.Email{common.Email("reply@example.com")}
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
require.Equal(t, []string{
|
||||
"mail:idx:created_at",
|
||||
"mail:idx:idempotency:bm90aWZpY2F0aW9u:b3BlcmF0b3I6cmVzZW5kOmRlbGl2ZXJ5LTEyMw",
|
||||
"mail:idx:recipient:b3BzQGV4YW1wbGUuY29t",
|
||||
"mail:idx:recipient:cGlsb3RAZXhhbXBsZS5jb20",
|
||||
"mail:idx:source:bm90aWZpY2F0aW9u",
|
||||
"mail:idx:status:cXVldWVk",
|
||||
"mail:idx:template:YXV0aC5sb2dpbl9jb2Rl",
|
||||
}, Keyspace{}.DeliveryIndexKeys(record))
|
||||
}
|
||||
|
||||
func TestScoresAndRetentionConstants(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
value := time.Unix(1_775_240_000, 123_000_000).UTC()
|
||||
require.Equal(t, float64(value.UnixMilli()), CreatedAtScore(value))
|
||||
require.Equal(t, float64(value.UnixMilli()), ScheduledForScore(value))
|
||||
require.Equal(t, 7*24*time.Hour, IdempotencyTTL)
|
||||
require.Equal(t, 30*24*time.Hour, DeliveryTTL)
|
||||
require.Equal(t, 90*24*time.Hour, AttemptTTL)
|
||||
require.Equal(t, 90*24*time.Hour, DeadLetterTTL)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"galaxy/mail/internal/domain/malformedcommand"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// MalformedCommandStore provides the Redis-backed storage used for
|
||||
// operator-visible malformed async command records.
|
||||
type MalformedCommandStore struct {
|
||||
client *redis.Client
|
||||
keys Keyspace
|
||||
}
|
||||
|
||||
// NewMalformedCommandStore constructs one Redis-backed malformed-command
|
||||
// store.
|
||||
func NewMalformedCommandStore(client *redis.Client) (*MalformedCommandStore, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new malformed command store: nil redis client")
|
||||
}
|
||||
|
||||
return &MalformedCommandStore{
|
||||
client: client,
|
||||
keys: Keyspace{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Record stores entry idempotently by stream entry id.
|
||||
func (store *MalformedCommandStore) Record(ctx context.Context, entry malformedcommand.Entry) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("record malformed command: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("record malformed command: nil context")
|
||||
}
|
||||
if err := entry.Validate(); err != nil {
|
||||
return fmt.Errorf("record malformed command: %w", err)
|
||||
}
|
||||
|
||||
payload, err := MarshalMalformedCommand(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("record malformed command: %w", err)
|
||||
}
|
||||
|
||||
key := store.keys.MalformedCommand(entry.StreamEntryID)
|
||||
indexKey := store.keys.MalformedCommandCreatedAtIndex()
|
||||
score := float64(entry.RecordedAt.UTC().UnixMilli())
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
exists, err := tx.Exists(ctx, key).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("record malformed command: %w", err)
|
||||
}
|
||||
if exists > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, key, payload, DeadLetterTTL)
|
||||
pipe.ZAdd(ctx, indexKey, redis.Z{
|
||||
Score: score,
|
||||
Member: entry.StreamEntryID,
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("record malformed command: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, key)
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return nil
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get loads one malformed-command entry by stream entry id.
|
||||
func (store *MalformedCommandStore) Get(ctx context.Context, streamEntryID string) (malformedcommand.Entry, bool, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return malformedcommand.Entry{}, false, errors.New("get malformed command: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return malformedcommand.Entry{}, false, errors.New("get malformed command: nil context")
|
||||
}
|
||||
|
||||
payload, err := store.client.Get(ctx, store.keys.MalformedCommand(streamEntryID)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return malformedcommand.Entry{}, false, nil
|
||||
case err != nil:
|
||||
return malformedcommand.Entry{}, false, fmt.Errorf("get malformed command: %w", err)
|
||||
}
|
||||
|
||||
entry, err := UnmarshalMalformedCommand(payload)
|
||||
if err != nil {
|
||||
return malformedcommand.Entry{}, false, fmt.Errorf("get malformed command: %w", err)
|
||||
}
|
||||
|
||||
return entry, true, nil
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/service/acceptgenericdelivery"
|
||||
"galaxy/mail/internal/service/listattempts"
|
||||
"galaxy/mail/internal/service/listdeliveries"
|
||||
"galaxy/mail/internal/service/resenddelivery"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// OperatorStore provides the Redis-backed durable storage used by the
|
||||
// operator read and resend workflows.
|
||||
type OperatorStore struct {
|
||||
client *redis.Client
|
||||
writer *AtomicWriter
|
||||
keys Keyspace
|
||||
}
|
||||
|
||||
// NewOperatorStore constructs one Redis-backed operator store.
|
||||
func NewOperatorStore(client *redis.Client) (*OperatorStore, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new operator store: nil redis client")
|
||||
}
|
||||
|
||||
writer, err := NewAtomicWriter(client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new operator store: %w", err)
|
||||
}
|
||||
|
||||
return &OperatorStore{
|
||||
client: client,
|
||||
writer: writer,
|
||||
keys: Keyspace{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDelivery loads one accepted delivery by its identifier.
|
||||
func (store *OperatorStore) GetDelivery(ctx context.Context, deliveryID common.DeliveryID) (deliverydomain.Delivery, bool, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return deliverydomain.Delivery{}, false, errors.New("get operator delivery: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return deliverydomain.Delivery{}, false, errors.New("get operator delivery: nil context")
|
||||
}
|
||||
if err := deliveryID.Validate(); err != nil {
|
||||
return deliverydomain.Delivery{}, false, fmt.Errorf("get operator delivery: %w", err)
|
||||
}
|
||||
|
||||
payload, err := store.client.Get(ctx, store.keys.Delivery(deliveryID)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return deliverydomain.Delivery{}, false, nil
|
||||
case err != nil:
|
||||
return deliverydomain.Delivery{}, false, fmt.Errorf("get operator delivery: %w", err)
|
||||
}
|
||||
|
||||
record, err := UnmarshalDelivery(payload)
|
||||
if err != nil {
|
||||
return deliverydomain.Delivery{}, false, fmt.Errorf("get operator delivery: %w", err)
|
||||
}
|
||||
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
// GetDeadLetter loads the dead-letter entry associated with deliveryID when
|
||||
// one exists.
|
||||
func (store *OperatorStore) GetDeadLetter(ctx context.Context, deliveryID common.DeliveryID) (deliverydomain.DeadLetterEntry, bool, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return deliverydomain.DeadLetterEntry{}, false, errors.New("get operator dead-letter entry: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return deliverydomain.DeadLetterEntry{}, false, errors.New("get operator dead-letter entry: nil context")
|
||||
}
|
||||
if err := deliveryID.Validate(); err != nil {
|
||||
return deliverydomain.DeadLetterEntry{}, false, fmt.Errorf("get operator dead-letter entry: %w", err)
|
||||
}
|
||||
|
||||
payload, err := store.client.Get(ctx, store.keys.DeadLetter(deliveryID)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return deliverydomain.DeadLetterEntry{}, false, nil
|
||||
case err != nil:
|
||||
return deliverydomain.DeadLetterEntry{}, false, fmt.Errorf("get operator dead-letter entry: %w", err)
|
||||
}
|
||||
|
||||
entry, err := UnmarshalDeadLetter(payload)
|
||||
if err != nil {
|
||||
return deliverydomain.DeadLetterEntry{}, false, fmt.Errorf("get operator dead-letter entry: %w", err)
|
||||
}
|
||||
|
||||
return entry, true, nil
|
||||
}
|
||||
|
||||
// GetDeliveryPayload loads one raw accepted attachment bundle by delivery id.
|
||||
func (store *OperatorStore) GetDeliveryPayload(ctx context.Context, deliveryID common.DeliveryID) (acceptgenericdelivery.DeliveryPayload, bool, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return acceptgenericdelivery.DeliveryPayload{}, false, errors.New("get operator delivery payload: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return acceptgenericdelivery.DeliveryPayload{}, false, errors.New("get operator delivery payload: nil context")
|
||||
}
|
||||
if err := deliveryID.Validate(); err != nil {
|
||||
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("get operator delivery payload: %w", err)
|
||||
}
|
||||
|
||||
payload, err := store.client.Get(ctx, store.keys.DeliveryPayload(deliveryID)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return acceptgenericdelivery.DeliveryPayload{}, false, nil
|
||||
case err != nil:
|
||||
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("get operator delivery payload: %w", err)
|
||||
}
|
||||
|
||||
record, err := UnmarshalDeliveryPayload(payload)
|
||||
if err != nil {
|
||||
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("get operator delivery payload: %w", err)
|
||||
}
|
||||
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
// ListAttempts loads exactly expectedCount attempts in ascending attempt
|
||||
// number order. Missing attempts are treated as durable-state corruption.
|
||||
func (store *OperatorStore) ListAttempts(ctx context.Context, deliveryID common.DeliveryID, expectedCount int) ([]attempt.Attempt, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("list operator attempts: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("list operator attempts: nil context")
|
||||
}
|
||||
if err := deliveryID.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("list operator attempts: %w", err)
|
||||
}
|
||||
if expectedCount < 0 {
|
||||
return nil, errors.New("list operator attempts: negative expected count")
|
||||
}
|
||||
if expectedCount == 0 {
|
||||
return []attempt.Attempt{}, nil
|
||||
}
|
||||
|
||||
result := make([]attempt.Attempt, 0, expectedCount)
|
||||
for attemptNo := 1; attemptNo <= expectedCount; attemptNo++ {
|
||||
payload, err := store.client.Get(ctx, store.keys.Attempt(deliveryID, attemptNo)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return nil, fmt.Errorf("list operator attempts: missing attempt %d for delivery %q", attemptNo, deliveryID)
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("list operator attempts: %w", err)
|
||||
}
|
||||
|
||||
record, err := UnmarshalAttempt(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list operator attempts: %w", err)
|
||||
}
|
||||
result = append(result, record)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// List loads one filtered ordered page of delivery records.
|
||||
func (store *OperatorStore) List(ctx context.Context, input listdeliveries.Input) (listdeliveries.Result, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return listdeliveries.Result{}, errors.New("list operator deliveries: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return listdeliveries.Result{}, errors.New("list operator deliveries: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return listdeliveries.Result{}, fmt.Errorf("list operator deliveries: %w", err)
|
||||
}
|
||||
|
||||
selection := chooseListIndex(store.keys, input.Filters)
|
||||
if selection.mergeIDempotency {
|
||||
return store.listMergedIdempotency(ctx, input, selection.keys)
|
||||
}
|
||||
|
||||
return store.listSingleIndex(ctx, input, selection.keys[0])
|
||||
}
|
||||
|
||||
// CreateResend atomically creates the cloned delivery, its first attempt, and
|
||||
// the optional cloned raw payload bundle.
|
||||
func (store *OperatorStore) CreateResend(ctx context.Context, input resenddelivery.CreateResendInput) error {
|
||||
if store == nil || store.client == nil || store.writer == nil {
|
||||
return errors.New("create operator resend: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("create operator resend: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("create operator resend: %w", err)
|
||||
}
|
||||
|
||||
writerInput := CreateAcceptanceInput{
|
||||
Delivery: input.Delivery,
|
||||
FirstAttempt: &input.FirstAttempt,
|
||||
}
|
||||
if input.DeliveryPayload != nil {
|
||||
writerInput.DeliveryPayload = input.DeliveryPayload
|
||||
}
|
||||
|
||||
if err := store.writer.CreateAcceptance(ctx, writerInput); err != nil {
|
||||
return fmt.Errorf("create operator resend: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type listSelection struct {
|
||||
keys []string
|
||||
mergeIDempotency bool
|
||||
}
|
||||
|
||||
func chooseListIndex(keyspace Keyspace, filters listdeliveries.Filters) listSelection {
|
||||
switch {
|
||||
case filters.IdempotencyKey != "" && filters.Source != "":
|
||||
return listSelection{
|
||||
keys: []string{keyspace.IdempotencyIndex(filters.Source, filters.IdempotencyKey)},
|
||||
}
|
||||
case filters.IdempotencyKey != "":
|
||||
return listSelection{
|
||||
keys: []string{
|
||||
keyspace.IdempotencyIndex(deliverydomain.SourceAuthSession, filters.IdempotencyKey),
|
||||
keyspace.IdempotencyIndex(deliverydomain.SourceNotification, filters.IdempotencyKey),
|
||||
keyspace.IdempotencyIndex(deliverydomain.SourceOperatorResend, filters.IdempotencyKey),
|
||||
},
|
||||
mergeIDempotency: true,
|
||||
}
|
||||
case filters.Recipient != "":
|
||||
return listSelection{keys: []string{keyspace.RecipientIndex(filters.Recipient)}}
|
||||
case filters.TemplateID != "":
|
||||
return listSelection{keys: []string{keyspace.TemplateIndex(filters.TemplateID)}}
|
||||
case filters.Status != "":
|
||||
return listSelection{keys: []string{keyspace.StatusIndex(filters.Status)}}
|
||||
case filters.Source != "":
|
||||
return listSelection{keys: []string{keyspace.SourceIndex(filters.Source)}}
|
||||
default:
|
||||
return listSelection{keys: []string{keyspace.CreatedAtIndex()}}
|
||||
}
|
||||
}
|
||||
|
||||
func (store *OperatorStore) listSingleIndex(ctx context.Context, input listdeliveries.Input, indexKey string) (listdeliveries.Result, error) {
|
||||
startIndex := int64(0)
|
||||
if input.Cursor != nil {
|
||||
cursorIndex, err := cursorStartIndex(ctx, store.client, indexKey, *input.Cursor)
|
||||
if err != nil {
|
||||
return listdeliveries.Result{}, err
|
||||
}
|
||||
startIndex = cursorIndex
|
||||
}
|
||||
|
||||
items, nextCursor, err := store.collectFromIndex(ctx, indexKey, startIndex, input.Limit, input.Filters)
|
||||
if err != nil {
|
||||
return listdeliveries.Result{}, err
|
||||
}
|
||||
|
||||
return listdeliveries.Result{
|
||||
Items: items,
|
||||
NextCursor: nextCursor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (store *OperatorStore) listMergedIdempotency(ctx context.Context, input listdeliveries.Input, indexKeys []string) (listdeliveries.Result, error) {
|
||||
iterators := make([]*redisIndexIterator, 0, len(indexKeys))
|
||||
for _, key := range indexKeys {
|
||||
iterators = append(iterators, &redisIndexIterator{
|
||||
client: store.client,
|
||||
indexKey: key,
|
||||
batchSize: listBatchSize(input.Limit),
|
||||
cursor: input.Cursor,
|
||||
})
|
||||
}
|
||||
|
||||
heads := make([]indexedRef, 0, len(iterators))
|
||||
for index, iterator := range iterators {
|
||||
ref, err := iterator.Next(ctx)
|
||||
if err != nil {
|
||||
return listdeliveries.Result{}, err
|
||||
}
|
||||
if ref != nil {
|
||||
heads = append(heads, indexedRef{streamIndex: index, ref: *ref})
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]deliverydomain.Delivery, 0, input.Limit+1)
|
||||
for len(heads) > 0 && len(items) <= input.Limit {
|
||||
bestIndex := 0
|
||||
for index := 1; index < len(heads); index++ {
|
||||
if compareDeliveryOrder(heads[index].ref, heads[bestIndex].ref) < 0 {
|
||||
bestIndex = index
|
||||
}
|
||||
}
|
||||
|
||||
selected := heads[bestIndex]
|
||||
heads = slices.Delete(heads, bestIndex, bestIndex+1)
|
||||
|
||||
record, found, err := store.GetDelivery(ctx, selected.ref.DeliveryID)
|
||||
if err != nil {
|
||||
return listdeliveries.Result{}, err
|
||||
}
|
||||
if found && input.Filters.Matches(record) {
|
||||
items = append(items, record)
|
||||
}
|
||||
|
||||
nextRef, err := iterators[selected.streamIndex].Next(ctx)
|
||||
if err != nil {
|
||||
return listdeliveries.Result{}, err
|
||||
}
|
||||
if nextRef != nil {
|
||||
heads = append(heads, indexedRef{streamIndex: selected.streamIndex, ref: *nextRef})
|
||||
}
|
||||
}
|
||||
|
||||
result := listdeliveries.Result{}
|
||||
if len(items) > input.Limit {
|
||||
next := cursorFromDelivery(items[input.Limit-1])
|
||||
result.NextCursor = &next
|
||||
items = items[:input.Limit]
|
||||
}
|
||||
result.Items = items
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (store *OperatorStore) collectFromIndex(
|
||||
ctx context.Context,
|
||||
indexKey string,
|
||||
startIndex int64,
|
||||
limit int,
|
||||
filters listdeliveries.Filters,
|
||||
) ([]deliverydomain.Delivery, *listdeliveries.Cursor, error) {
|
||||
items := make([]deliverydomain.Delivery, 0, limit+1)
|
||||
batchSize := listBatchSize(limit)
|
||||
|
||||
for len(items) <= limit {
|
||||
batch, err := store.client.ZRevRangeWithScores(ctx, indexKey, startIndex, startIndex+int64(batchSize)-1).Result()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("list operator deliveries: %w", err)
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
startIndex += int64(len(batch))
|
||||
for _, member := range batch {
|
||||
deliveryID, err := memberDeliveryID(member.Member)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("list operator deliveries: %w", err)
|
||||
}
|
||||
|
||||
record, found, err := store.GetDelivery(ctx, deliveryID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !found || !filters.Matches(record) {
|
||||
continue
|
||||
}
|
||||
|
||||
items = append(items, record)
|
||||
if len(items) > limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var nextCursor *listdeliveries.Cursor
|
||||
if len(items) > limit {
|
||||
next := cursorFromDelivery(items[limit-1])
|
||||
nextCursor = &next
|
||||
items = items[:limit]
|
||||
}
|
||||
|
||||
return items, nextCursor, nil
|
||||
}
|
||||
|
||||
type indexedRef struct {
|
||||
streamIndex int
|
||||
ref deliveryRef
|
||||
}
|
||||
|
||||
type deliveryRef struct {
|
||||
CreatedAt time.Time
|
||||
DeliveryID common.DeliveryID
|
||||
}
|
||||
|
||||
type redisIndexIterator struct {
|
||||
client *redis.Client
|
||||
indexKey string
|
||||
batchSize int
|
||||
offset int64
|
||||
cursor *listdeliveries.Cursor
|
||||
batch []redis.Z
|
||||
position int
|
||||
}
|
||||
|
||||
func (iterator *redisIndexIterator) Next(ctx context.Context) (*deliveryRef, error) {
|
||||
for {
|
||||
if iterator.position >= len(iterator.batch) {
|
||||
batch, err := iterator.client.ZRevRangeWithScores(
|
||||
ctx,
|
||||
iterator.indexKey,
|
||||
iterator.offset,
|
||||
iterator.offset+int64(iterator.batchSize)-1,
|
||||
).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list operator deliveries: %w", err)
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
iterator.batch = batch
|
||||
iterator.position = 0
|
||||
iterator.offset += int64(len(batch))
|
||||
}
|
||||
|
||||
ref, err := deliveryRefFromSortedSet(iterator.batch[iterator.position])
|
||||
iterator.position++
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list operator deliveries: %w", err)
|
||||
}
|
||||
if iterator.cursor != nil && !isAfterCursor(ref, *iterator.cursor) {
|
||||
continue
|
||||
}
|
||||
|
||||
return &ref, nil
|
||||
}
|
||||
}
|
||||
|
||||
func cursorStartIndex(ctx context.Context, client *redis.Client, indexKey string, cursor listdeliveries.Cursor) (int64, error) {
|
||||
score, err := client.ZScore(ctx, indexKey, cursor.DeliveryID.String()).Result()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return 0, listdeliveries.ErrInvalidCursor
|
||||
case err != nil:
|
||||
return 0, fmt.Errorf("list operator deliveries: %w", err)
|
||||
}
|
||||
if !time.UnixMilli(int64(score)).UTC().Equal(cursor.CreatedAt.UTC()) {
|
||||
return 0, listdeliveries.ErrInvalidCursor
|
||||
}
|
||||
|
||||
rank, err := client.ZRevRank(ctx, indexKey, cursor.DeliveryID.String()).Result()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return 0, listdeliveries.ErrInvalidCursor
|
||||
case err != nil:
|
||||
return 0, fmt.Errorf("list operator deliveries: %w", err)
|
||||
default:
|
||||
return rank + 1, nil
|
||||
}
|
||||
}
|
||||
|
||||
func compareDeliveryOrder(left deliveryRef, right deliveryRef) int {
|
||||
switch {
|
||||
case left.CreatedAt.After(right.CreatedAt):
|
||||
return -1
|
||||
case left.CreatedAt.Before(right.CreatedAt):
|
||||
return 1
|
||||
case left.DeliveryID.String() > right.DeliveryID.String():
|
||||
return -1
|
||||
case left.DeliveryID.String() < right.DeliveryID.String():
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func isAfterCursor(ref deliveryRef, cursor listdeliveries.Cursor) bool {
|
||||
return compareDeliveryOrder(ref, deliveryRef{
|
||||
CreatedAt: cursor.CreatedAt.UTC(),
|
||||
DeliveryID: cursor.DeliveryID,
|
||||
}) > 0
|
||||
}
|
||||
|
||||
func cursorFromDelivery(record deliverydomain.Delivery) listdeliveries.Cursor {
|
||||
return listdeliveries.Cursor{
|
||||
CreatedAt: record.CreatedAt.UTC(),
|
||||
DeliveryID: record.DeliveryID,
|
||||
}
|
||||
}
|
||||
|
||||
func deliveryRefFromSortedSet(member redis.Z) (deliveryRef, error) {
|
||||
deliveryID, err := memberDeliveryID(member.Member)
|
||||
if err != nil {
|
||||
return deliveryRef{}, err
|
||||
}
|
||||
|
||||
return deliveryRef{
|
||||
CreatedAt: time.UnixMilli(int64(member.Score)).UTC(),
|
||||
DeliveryID: deliveryID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func memberDeliveryID(member any) (common.DeliveryID, error) {
|
||||
value, ok := member.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unexpected delivery index member type %T", member)
|
||||
}
|
||||
|
||||
deliveryID := common.DeliveryID(value)
|
||||
if err := deliveryID.Validate(); err != nil {
|
||||
return "", fmt.Errorf("delivery index member delivery id: %w", err)
|
||||
}
|
||||
|
||||
return deliveryID, nil
|
||||
}
|
||||
|
||||
func listBatchSize(limit int) int {
|
||||
size := limit * 4
|
||||
if size < limit+1 {
|
||||
size = limit + 1
|
||||
}
|
||||
if size < 100 {
|
||||
size = 100
|
||||
}
|
||||
|
||||
return size
|
||||
}
|
||||
|
||||
var _ listdeliveries.Store = (*OperatorStore)(nil)
|
||||
var _ listattempts.Store = (*OperatorStore)(nil)
|
||||
var _ resenddelivery.Store = (*OperatorStore)(nil)
|
||||
@@ -0,0 +1,346 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/service/listdeliveries"
|
||||
"galaxy/mail/internal/service/resenddelivery"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOperatorStoreListFilters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
filters listdeliveries.Filters
|
||||
wantIDs []common.DeliveryID
|
||||
}
|
||||
|
||||
cases := []testCase{
|
||||
{
|
||||
name: "recipient",
|
||||
filters: listdeliveries.Filters{Recipient: common.Email("recipient-filter@example.com")},
|
||||
wantIDs: []common.DeliveryID{"delivery-recipient"},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
filters: listdeliveries.Filters{Status: deliverydomain.StatusSuppressed},
|
||||
wantIDs: []common.DeliveryID{"delivery-status"},
|
||||
},
|
||||
{
|
||||
name: "source",
|
||||
filters: listdeliveries.Filters{Source: deliverydomain.SourceOperatorResend},
|
||||
wantIDs: []common.DeliveryID{"delivery-source"},
|
||||
},
|
||||
{
|
||||
name: "template",
|
||||
filters: listdeliveries.Filters{TemplateID: common.TemplateID("template.filter")},
|
||||
wantIDs: []common.DeliveryID{"delivery-template"},
|
||||
},
|
||||
{
|
||||
name: "idempotency",
|
||||
filters: listdeliveries.Filters{IdempotencyKey: common.IdempotencyKey("idempotency-filter")},
|
||||
wantIDs: []common.DeliveryID{"delivery-idempotency"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, client := newOperatorStoreForTest(t)
|
||||
seedOperatorFilterDataset(t, client)
|
||||
|
||||
result, err := store.List(context.Background(), listdeliveries.Input{
|
||||
Limit: 10,
|
||||
Filters: tt.filters,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantIDs, deliveryIDs(result.Items))
|
||||
require.Nil(t, result.NextCursor)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorStoreListCursorPaginationUsesCreatedAtDescDeliveryIDDesc(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, client := newOperatorStoreForTest(t)
|
||||
|
||||
createdAt := time.Unix(1_775_122_500, 0).UTC()
|
||||
seedDeliveryRecord(t, client, buildStoredDelivery("delivery-a", createdAt, deliverydomain.SourceNotification, common.IdempotencyKey("notification:delivery-a"), deliverydomain.StatusSent))
|
||||
seedDeliveryRecord(t, client, buildStoredDelivery("delivery-c", createdAt, deliverydomain.SourceNotification, common.IdempotencyKey("notification:delivery-c"), deliverydomain.StatusSent))
|
||||
seedDeliveryRecord(t, client, buildStoredDelivery("delivery-b", createdAt, deliverydomain.SourceNotification, common.IdempotencyKey("notification:delivery-b"), deliverydomain.StatusSent))
|
||||
|
||||
firstPage, err := store.List(context.Background(), listdeliveries.Input{Limit: 2})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []common.DeliveryID{"delivery-c", "delivery-b"}, deliveryIDs(firstPage.Items))
|
||||
require.NotNil(t, firstPage.NextCursor)
|
||||
|
||||
secondPage, err := store.List(context.Background(), listdeliveries.Input{
|
||||
Limit: 2,
|
||||
Cursor: firstPage.NextCursor,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []common.DeliveryID{"delivery-a"}, deliveryIDs(secondPage.Items))
|
||||
require.Nil(t, secondPage.NextCursor)
|
||||
}
|
||||
|
||||
func TestOperatorStoreListMergesIdempotencyAcrossSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, client := newOperatorStoreForTest(t)
|
||||
sharedKey := common.IdempotencyKey("shared-idempotency")
|
||||
seedDeliveryRecord(t, client, buildStoredDelivery("delivery-auth", time.Unix(1_775_122_100, 0).UTC(), deliverydomain.SourceAuthSession, sharedKey, deliverydomain.StatusSuppressed))
|
||||
seedDeliveryRecord(t, client, buildStoredDelivery("delivery-notification", time.Unix(1_775_122_200, 0).UTC(), deliverydomain.SourceNotification, sharedKey, deliverydomain.StatusSent))
|
||||
seedDeliveryRecord(t, client, buildStoredDelivery("delivery-resend", time.Unix(1_775_122_300, 0).UTC(), deliverydomain.SourceOperatorResend, sharedKey, deliverydomain.StatusSent))
|
||||
|
||||
result, err := store.List(context.Background(), listdeliveries.Input{
|
||||
Limit: 10,
|
||||
Filters: listdeliveries.Filters{
|
||||
IdempotencyKey: sharedKey,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []common.DeliveryID{"delivery-resend", "delivery-notification", "delivery-auth"}, deliveryIDs(result.Items))
|
||||
}
|
||||
|
||||
func TestOperatorStoreGetDeadLetter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, client := newOperatorStoreForTest(t)
|
||||
record := buildStoredDelivery("delivery-dead-letter", time.Unix(1_775_122_400, 0).UTC(), deliverydomain.SourceNotification, common.IdempotencyKey("notification:delivery-dead-letter"), deliverydomain.StatusDeadLetter)
|
||||
seedDeliveryRecord(t, client, record)
|
||||
|
||||
entry := validDeadLetterEntry(t, record.DeliveryID)
|
||||
payload, err := MarshalDeadLetter(entry)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.Set(context.Background(), Keyspace{}.DeadLetter(record.DeliveryID), payload, DeadLetterTTL).Err())
|
||||
|
||||
got, found, err := store.GetDeadLetter(context.Background(), record.DeliveryID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, entry, got)
|
||||
}
|
||||
|
||||
func TestOperatorStoreListAttempts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, client := newOperatorStoreForTest(t)
|
||||
record := buildStoredDelivery("delivery-attempts", time.Unix(1_775_122_410, 0).UTC(), deliverydomain.SourceNotification, common.IdempotencyKey("notification:delivery-attempts"), deliverydomain.StatusFailed)
|
||||
record.AttemptCount = 2
|
||||
failedAt := record.UpdatedAt
|
||||
record.FailedAt = &failedAt
|
||||
require.NoError(t, record.Validate())
|
||||
seedDeliveryRecord(t, client, record)
|
||||
|
||||
firstAttempt := validTerminalAttempt(t, record.DeliveryID)
|
||||
firstAttempt.AttemptNo = 1
|
||||
secondAttempt := validTerminalAttempt(t, record.DeliveryID)
|
||||
secondAttempt.AttemptNo = 2
|
||||
secondAttempt.Status = attempt.StatusProviderRejected
|
||||
payload, err := MarshalAttempt(firstAttempt)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.Set(context.Background(), Keyspace{}.Attempt(record.DeliveryID, 1), payload, AttemptTTL).Err())
|
||||
payload, err = MarshalAttempt(secondAttempt)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.Set(context.Background(), Keyspace{}.Attempt(record.DeliveryID, 2), payload, AttemptTTL).Err())
|
||||
|
||||
got, err := store.ListAttempts(context.Background(), record.DeliveryID, 2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []attempt.Attempt{firstAttempt, secondAttempt}, got)
|
||||
}
|
||||
|
||||
func TestOperatorStoreCreateResendAtomicallyCreatesCloneState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, client := newOperatorStoreForTest(t)
|
||||
|
||||
createdAt := time.Unix(1_775_122_600, 0).UTC()
|
||||
clone := buildStoredDelivery("delivery-clone", createdAt, deliverydomain.SourceOperatorResend, common.IdempotencyKey("operator:resend:delivery-parent"), deliverydomain.StatusQueued)
|
||||
clone.ResendParentDeliveryID = common.DeliveryID("delivery-parent")
|
||||
clone.AttemptCount = 1
|
||||
require.NoError(t, clone.Validate())
|
||||
|
||||
firstAttempt := validScheduledAttempt(t, clone.DeliveryID)
|
||||
firstAttempt.AttemptNo = 1
|
||||
firstAttempt.ScheduledFor = createdAt
|
||||
require.NoError(t, firstAttempt.Validate())
|
||||
|
||||
deliveryPayload := validDeliveryPayload(t, clone.DeliveryID)
|
||||
input := resenddelivery.CreateResendInput{
|
||||
Delivery: clone,
|
||||
FirstAttempt: firstAttempt,
|
||||
DeliveryPayload: &deliveryPayload,
|
||||
}
|
||||
|
||||
require.NoError(t, store.CreateResend(context.Background(), input))
|
||||
|
||||
storedDelivery, found, err := store.GetDelivery(context.Background(), clone.DeliveryID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, clone, storedDelivery)
|
||||
|
||||
storedPayload, found, err := store.GetDeliveryPayload(context.Background(), clone.DeliveryID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, deliveryPayload, storedPayload)
|
||||
|
||||
attemptPayload, err := client.Get(context.Background(), Keyspace{}.Attempt(clone.DeliveryID, 1)).Bytes()
|
||||
require.NoError(t, err)
|
||||
decodedAttempt, err := UnmarshalAttempt(attemptPayload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, firstAttempt, decodedAttempt)
|
||||
|
||||
scheduledMembers, err := client.ZRange(context.Background(), Keyspace{}.AttemptSchedule(), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{clone.DeliveryID.String()}, scheduledMembers)
|
||||
|
||||
indexMembers, err := client.ZRange(context.Background(), Keyspace{}.IdempotencyIndex(clone.Source, clone.IdempotencyKey), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{clone.DeliveryID.String()}, indexMembers)
|
||||
|
||||
_, err = client.Get(context.Background(), Keyspace{}.Idempotency(clone.Source, clone.IdempotencyKey)).Bytes()
|
||||
require.ErrorIs(t, err, redis.Nil)
|
||||
}
|
||||
|
||||
func newOperatorStoreForTest(t *testing.T) (*OperatorStore, *redis.Client) {
|
||||
t.Helper()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
store, err := NewOperatorStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
return store, client
|
||||
}
|
||||
|
||||
func seedOperatorFilterDataset(t *testing.T, client *redis.Client) {
|
||||
t.Helper()
|
||||
|
||||
seedDeliveryRecord(t, client, func() deliverydomain.Delivery {
|
||||
record := buildStoredDelivery("delivery-recipient", time.Unix(1_775_122_001, 0).UTC(), deliverydomain.SourceNotification, common.IdempotencyKey("notification:delivery-recipient"), deliverydomain.StatusSent)
|
||||
record.Envelope.To = []common.Email{common.Email("recipient-filter@example.com")}
|
||||
require.NoError(t, record.Validate())
|
||||
return record
|
||||
}())
|
||||
|
||||
seedDeliveryRecord(t, client, func() deliverydomain.Delivery {
|
||||
record := buildStoredDelivery("delivery-status", time.Unix(1_775_122_002, 0).UTC(), deliverydomain.SourceAuthSession, common.IdempotencyKey("authsession:delivery-status"), deliverydomain.StatusSuppressed)
|
||||
record.SentAt = nil
|
||||
suppressedAt := record.UpdatedAt
|
||||
record.SuppressedAt = &suppressedAt
|
||||
require.NoError(t, record.Validate())
|
||||
return record
|
||||
}())
|
||||
|
||||
seedDeliveryRecord(t, client, buildStoredDelivery("delivery-source", time.Unix(1_775_122_003, 0).UTC(), deliverydomain.SourceOperatorResend, common.IdempotencyKey("operator:resend:delivery-source"), deliverydomain.StatusSent))
|
||||
|
||||
seedDeliveryRecord(t, client, func() deliverydomain.Delivery {
|
||||
record := buildStoredDelivery("delivery-template", time.Unix(1_775_122_004, 0).UTC(), deliverydomain.SourceNotification, common.IdempotencyKey("notification:delivery-template"), deliverydomain.StatusSent)
|
||||
record.TemplateID = common.TemplateID("template.filter")
|
||||
record.PayloadMode = deliverydomain.PayloadModeTemplate
|
||||
record.Locale = common.Locale("en")
|
||||
record.TemplateVariables = map[string]any{"name": "Pilot"}
|
||||
require.NoError(t, record.Validate())
|
||||
return record
|
||||
}())
|
||||
|
||||
seedDeliveryRecord(t, client, buildStoredDelivery("delivery-idempotency", time.Unix(1_775_122_005, 0).UTC(), deliverydomain.SourceNotification, common.IdempotencyKey("idempotency-filter"), deliverydomain.StatusSent))
|
||||
}
|
||||
|
||||
func seedDeliveryRecord(t *testing.T, client *redis.Client, record deliverydomain.Delivery) {
|
||||
t.Helper()
|
||||
|
||||
keyspace := Keyspace{}
|
||||
payload, err := MarshalDelivery(record)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.Set(context.Background(), keyspace.Delivery(record.DeliveryID), payload, DeliveryTTL).Err())
|
||||
|
||||
score := CreatedAtScore(record.CreatedAt)
|
||||
for _, indexKey := range keyspace.DeliveryIndexKeys(record) {
|
||||
require.NoError(t, client.ZAdd(context.Background(), indexKey, redis.Z{
|
||||
Score: score,
|
||||
Member: record.DeliveryID.String(),
|
||||
}).Err())
|
||||
}
|
||||
}
|
||||
|
||||
func buildStoredDelivery(
|
||||
deliveryID string,
|
||||
createdAt time.Time,
|
||||
source deliverydomain.Source,
|
||||
idempotencyKey common.IdempotencyKey,
|
||||
status deliverydomain.Status,
|
||||
) deliverydomain.Delivery {
|
||||
updatedAt := createdAt.Add(time.Minute)
|
||||
record := deliverydomain.Delivery{
|
||||
DeliveryID: common.DeliveryID(deliveryID),
|
||||
Source: source,
|
||||
PayloadMode: deliverydomain.PayloadModeRendered,
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: []common.Email{common.Email("pilot@example.com")},
|
||||
},
|
||||
Content: deliverydomain.Content{
|
||||
Subject: "Test subject",
|
||||
TextBody: "Test body",
|
||||
},
|
||||
IdempotencyKey: idempotencyKey,
|
||||
Status: status,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
|
||||
switch status {
|
||||
case deliverydomain.StatusSent:
|
||||
record.AttemptCount = 1
|
||||
record.LastAttemptStatus = attempt.StatusProviderAccepted
|
||||
sentAt := updatedAt
|
||||
record.SentAt = &sentAt
|
||||
case deliverydomain.StatusSuppressed:
|
||||
suppressedAt := updatedAt
|
||||
record.SuppressedAt = &suppressedAt
|
||||
case deliverydomain.StatusFailed:
|
||||
record.AttemptCount = 1
|
||||
record.LastAttemptStatus = attempt.StatusProviderRejected
|
||||
failedAt := updatedAt
|
||||
record.FailedAt = &failedAt
|
||||
case deliverydomain.StatusDeadLetter:
|
||||
record.AttemptCount = 1
|
||||
record.LastAttemptStatus = attempt.StatusTimedOut
|
||||
deadLetteredAt := updatedAt
|
||||
record.DeadLetteredAt = &deadLetteredAt
|
||||
default:
|
||||
record.AttemptCount = 1
|
||||
}
|
||||
if source == deliverydomain.SourceOperatorResend {
|
||||
record.ResendParentDeliveryID = common.DeliveryID("parent-" + deliveryID)
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func deliveryIDs(records []deliverydomain.Delivery) []common.DeliveryID {
|
||||
result := make([]common.DeliveryID, len(records))
|
||||
for index, record := range records {
|
||||
result[index] = record.DeliveryID
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"galaxy/mail/internal/service/renderdelivery"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RenderStore provides the Redis-backed durable storage used by the
|
||||
// render-delivery use case.
|
||||
type RenderStore struct {
|
||||
writer *AtomicWriter
|
||||
}
|
||||
|
||||
// NewRenderStore constructs one Redis-backed render-delivery store.
|
||||
func NewRenderStore(client *redis.Client) (*RenderStore, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new render store: nil redis client")
|
||||
}
|
||||
|
||||
writer, err := NewAtomicWriter(client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new render store: %w", err)
|
||||
}
|
||||
|
||||
return &RenderStore{writer: writer}, nil
|
||||
}
|
||||
|
||||
// MarkRendered stores one successfully materialized template delivery.
|
||||
func (store *RenderStore) MarkRendered(ctx context.Context, input renderdelivery.MarkRenderedInput) error {
|
||||
if store == nil || store.writer == nil {
|
||||
return errors.New("mark rendered in render store: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("mark rendered in render store: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("mark rendered in render store: %w", err)
|
||||
}
|
||||
|
||||
if err := store.writer.MarkRendered(ctx, MarkRenderedInput{
|
||||
Delivery: input.Delivery,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("mark rendered in render store: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkRenderFailed stores one classified terminal render failure.
|
||||
func (store *RenderStore) MarkRenderFailed(ctx context.Context, input renderdelivery.MarkRenderFailedInput) error {
|
||||
if store == nil || store.writer == nil {
|
||||
return errors.New("mark render failed in render store: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("mark render failed in render store: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("mark render failed in render store: %w", err)
|
||||
}
|
||||
|
||||
if err := store.writer.MarkRenderFailed(ctx, MarkRenderFailedInput{
|
||||
Delivery: input.Delivery,
|
||||
Attempt: input.Attempt,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("mark render failed in render store: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// StreamOffsetStore provides the Redis-backed storage used for persisted
|
||||
// plain-XREAD consumer progress.
|
||||
type StreamOffsetStore struct {
|
||||
client *redis.Client
|
||||
keys Keyspace
|
||||
}
|
||||
|
||||
// NewStreamOffsetStore constructs one Redis-backed stream-offset store.
|
||||
func NewStreamOffsetStore(client *redis.Client) (*StreamOffsetStore, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new stream offset store: nil redis client")
|
||||
}
|
||||
|
||||
return &StreamOffsetStore{
|
||||
client: client,
|
||||
keys: Keyspace{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Load returns the last processed entry id for stream when one is stored.
|
||||
func (store *StreamOffsetStore) Load(ctx context.Context, stream string) (string, bool, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return "", false, errors.New("load stream offset: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return "", false, errors.New("load stream offset: nil context")
|
||||
}
|
||||
|
||||
payload, err := store.client.Get(ctx, store.keys.StreamOffset(stream)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return "", false, nil
|
||||
case err != nil:
|
||||
return "", false, fmt.Errorf("load stream offset: %w", err)
|
||||
}
|
||||
|
||||
offset, err := UnmarshalStreamOffset(payload)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("load stream offset: %w", err)
|
||||
}
|
||||
|
||||
return offset.LastProcessedEntryID, true, nil
|
||||
}
|
||||
|
||||
// Save stores the last processed entry id for stream.
|
||||
func (store *StreamOffsetStore) Save(ctx context.Context, stream string, entryID string) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("save stream offset: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("save stream offset: nil context")
|
||||
}
|
||||
|
||||
offset := StreamOffset{
|
||||
Stream: stream,
|
||||
LastProcessedEntryID: entryID,
|
||||
UpdatedAt: time.Now().UTC().Truncate(time.Millisecond),
|
||||
}
|
||||
payload, err := MarshalStreamOffset(offset)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save stream offset: %w", err)
|
||||
}
|
||||
if err := store.client.Set(ctx, store.keys.StreamOffset(stream), payload, 0).Err(); err != nil {
|
||||
return fmt.Errorf("save stream offset: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
// Package smtp provides the SMTP-backed provider adapter used by Mail
|
||||
// Service.
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
stdmail "net/mail"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/ports"
|
||||
|
||||
gomail "github.com/wneessen/go-mail"
|
||||
)
|
||||
|
||||
const providerName = "smtp"
|
||||
|
||||
// Config stores the SMTP provider connection settings.
|
||||
type Config struct {
|
||||
// Addr stores the SMTP server network address.
|
||||
Addr string
|
||||
|
||||
// Username stores the optional SMTP authentication username.
|
||||
Username string
|
||||
|
||||
// Password stores the optional SMTP authentication password.
|
||||
Password string
|
||||
|
||||
// FromEmail stores the envelope sender mailbox.
|
||||
FromEmail string
|
||||
|
||||
// FromName stores the optional display name of the sender.
|
||||
FromName string
|
||||
|
||||
// Timeout stores the maximum SMTP dial-and-send window enforced by the
|
||||
// adapter when the caller does not provide an earlier deadline.
|
||||
Timeout time.Duration
|
||||
|
||||
// InsecureSkipVerify disables SMTP certificate verification. This is meant
|
||||
// only for local development and black-box tests with self-signed capture
|
||||
// servers.
|
||||
InsecureSkipVerify bool
|
||||
|
||||
// TLSConfig stores the optional TLS client configuration override used by
|
||||
// tests. Production wiring leaves it nil and uses secure defaults.
|
||||
TLSConfig *tls.Config
|
||||
}
|
||||
|
||||
// Provider stores the SMTP-backed delivery adapter.
|
||||
type Provider struct {
|
||||
client *gomail.Client
|
||||
fromEmail string
|
||||
fromName string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// New constructs one SMTP-backed provider and validates cfg.
|
||||
func New(cfg Config) (*Provider, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("new smtp provider: %w", err)
|
||||
}
|
||||
|
||||
host, portText, err := net.SplitHostPort(strings.TrimSpace(cfg.Addr))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new smtp provider: split smtp addr: %w", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new smtp provider: parse smtp port: %w", err)
|
||||
}
|
||||
|
||||
options := []gomail.Option{
|
||||
gomail.WithPort(port),
|
||||
gomail.WithTimeout(cfg.Timeout),
|
||||
gomail.WithTLSPolicy(gomail.TLSMandatory),
|
||||
}
|
||||
if cfg.TLSConfig != nil {
|
||||
options = append(options, gomail.WithTLSConfig(cfg.TLSConfig))
|
||||
} else if cfg.InsecureSkipVerify {
|
||||
options = append(options, gomail.WithTLSConfig(&tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: true, //nolint:gosec // Explicit opt-in for local integration scenarios only.
|
||||
}))
|
||||
} else {
|
||||
options = append(options, gomail.WithTLSConfig(&tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
ServerName: host,
|
||||
}))
|
||||
}
|
||||
if cfg.Username != "" {
|
||||
options = append(options,
|
||||
gomail.WithUsername(cfg.Username),
|
||||
gomail.WithPassword(cfg.Password),
|
||||
gomail.WithSMTPAuth(gomail.SMTPAuthAutoDiscover),
|
||||
)
|
||||
}
|
||||
|
||||
client, err := gomail.NewClient(host, options...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new smtp provider: %w", err)
|
||||
}
|
||||
|
||||
return &Provider{
|
||||
client: client,
|
||||
fromEmail: cfg.FromEmail,
|
||||
fromName: cfg.FromName,
|
||||
timeout: cfg.Timeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Send attempts one outbound SMTP delivery and returns a classified provider
|
||||
// outcome whenever the interaction reached a stable SMTP result.
|
||||
func (provider *Provider) Send(ctx context.Context, message ports.Message) (ports.Result, error) {
|
||||
switch {
|
||||
case ctx == nil:
|
||||
return ports.Result{}, errors.New("send with smtp provider: nil context")
|
||||
case provider == nil || provider.client == nil:
|
||||
return ports.Result{}, errors.New("send with smtp provider: nil provider")
|
||||
}
|
||||
if err := message.Validate(); err != nil {
|
||||
return ports.Result{}, fmt.Errorf("send with smtp provider: %w", err)
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return newResult(ports.ClassificationTransientFailure, summaryFields{
|
||||
Phase: "context",
|
||||
}, map[string]string{
|
||||
"phase": "context",
|
||||
"error": "deadline_exceeded",
|
||||
})
|
||||
}
|
||||
|
||||
return ports.Result{}, fmt.Errorf("send with smtp provider: %w", err)
|
||||
}
|
||||
|
||||
msg, err := provider.buildMessage(message)
|
||||
if err != nil {
|
||||
return newResult(ports.ClassificationPermanentFailure, summaryFields{
|
||||
Phase: "build",
|
||||
}, map[string]string{
|
||||
"phase": "build",
|
||||
"error": classifyLocalBuildError(err),
|
||||
})
|
||||
}
|
||||
|
||||
sendCtx, cancel := provider.sendContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
err = provider.client.DialAndSendWithContext(sendCtx, msg)
|
||||
if err == nil {
|
||||
return newResult(ports.ClassificationAccepted, summaryFields{}, nil)
|
||||
}
|
||||
|
||||
return provider.classifySendError(err)
|
||||
}
|
||||
|
||||
// Close releases SMTP client resources.
|
||||
func (provider *Provider) Close() error {
|
||||
if provider == nil || provider.client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
provider.client.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a complete SMTP provider configuration.
|
||||
func (cfg Config) Validate() error {
|
||||
host, port, err := net.SplitHostPort(strings.TrimSpace(cfg.Addr))
|
||||
switch {
|
||||
case err != nil || port == "":
|
||||
return fmt.Errorf("smtp addr %q must use host:port form", cfg.Addr)
|
||||
case host != "" && strings.Contains(host, " "):
|
||||
return fmt.Errorf("smtp addr %q must use host:port form", cfg.Addr)
|
||||
case cfg.Timeout <= 0:
|
||||
return fmt.Errorf("smtp timeout must be positive")
|
||||
case strings.TrimSpace(cfg.Username) == "" && strings.TrimSpace(cfg.Password) != "":
|
||||
return fmt.Errorf("smtp username and password must be configured together")
|
||||
case strings.TrimSpace(cfg.Username) != "" && strings.TrimSpace(cfg.Password) == "":
|
||||
return fmt.Errorf("smtp username and password must be configured together")
|
||||
}
|
||||
|
||||
parsed, err := stdmail.ParseAddress(strings.TrimSpace(cfg.FromEmail))
|
||||
if err != nil || parsed == nil || parsed.Name != "" || parsed.Address != strings.TrimSpace(cfg.FromEmail) {
|
||||
return fmt.Errorf("smtp from email %q must be a single valid email address", cfg.FromEmail)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *Provider) buildMessage(message ports.Message) (*gomail.Msg, error) {
|
||||
msg := gomail.NewMsg()
|
||||
msg.EnvelopeFrom(provider.fromEmail)
|
||||
|
||||
switch strings.TrimSpace(provider.fromName) {
|
||||
case "":
|
||||
if err := msg.From(provider.fromEmail); err != nil {
|
||||
return nil, fmt.Errorf("set from header: %w", err)
|
||||
}
|
||||
default:
|
||||
if err := msg.FromFormat(provider.fromName, provider.fromEmail); err != nil {
|
||||
return nil, fmt.Errorf("set from header: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
msg.SetBodyString(gomail.TypeTextPlain, message.Content.TextBody)
|
||||
if message.Content.HTMLBody != "" {
|
||||
msg.AddAlternativeString(gomail.TypeTextHTML, message.Content.HTMLBody)
|
||||
}
|
||||
msg.Subject(message.Content.Subject)
|
||||
|
||||
for _, address := range message.Envelope.To {
|
||||
if err := msg.AddTo(address.String()); err != nil {
|
||||
return nil, fmt.Errorf("add to recipient: %w", err)
|
||||
}
|
||||
}
|
||||
for _, address := range message.Envelope.Cc {
|
||||
if err := msg.AddCc(address.String()); err != nil {
|
||||
return nil, fmt.Errorf("add cc recipient: %w", err)
|
||||
}
|
||||
}
|
||||
for _, address := range message.Envelope.Bcc {
|
||||
if err := msg.AddBcc(address.String()); err != nil {
|
||||
return nil, fmt.Errorf("add bcc recipient: %w", err)
|
||||
}
|
||||
}
|
||||
for _, address := range message.Envelope.ReplyTo {
|
||||
if err := msg.ReplyTo(address.String()); err != nil {
|
||||
return nil, fmt.Errorf("add reply-to recipient: %w", err)
|
||||
}
|
||||
}
|
||||
for _, attachment := range message.Attachments {
|
||||
if err := attachment.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("attach file %q: %w", attachment.Metadata.Filename, err)
|
||||
}
|
||||
if err := msg.AttachReader(
|
||||
attachment.Metadata.Filename,
|
||||
bytes.NewReader(attachment.Content),
|
||||
gomail.WithFileContentType(gomail.ContentType(attachment.Metadata.ContentType)),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("attach file %q: %w", attachment.Metadata.Filename, err)
|
||||
}
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) classifySendError(err error) (ports.Result, error) {
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return newResult(ports.ClassificationTransientFailure, summaryFields{
|
||||
Phase: "send",
|
||||
}, map[string]string{
|
||||
"phase": "send",
|
||||
"error": "deadline_exceeded",
|
||||
})
|
||||
case strings.Contains(strings.ToLower(err.Error()), "starttls"):
|
||||
return newResult(ports.ClassificationPermanentFailure, summaryFields{
|
||||
Phase: "tls",
|
||||
}, map[string]string{
|
||||
"phase": "tls",
|
||||
"error": "starttls_required",
|
||||
})
|
||||
}
|
||||
|
||||
var sendErr *gomail.SendError
|
||||
if errors.As(err, &sendErr) {
|
||||
codeText := ""
|
||||
if code := sendErr.ErrorCode(); code > 0 {
|
||||
codeText = strconv.Itoa(code)
|
||||
}
|
||||
phase := smtpReasonPhase(sendErr, err)
|
||||
|
||||
details := map[string]string{
|
||||
"phase": phase,
|
||||
"error": sanitizeDetailValue(strings.ToLower(sendErr.Reason.String())),
|
||||
}
|
||||
if codeText != "" {
|
||||
details["smtp_code"] = codeText
|
||||
}
|
||||
|
||||
switch {
|
||||
case sendErr.ErrorCode() >= 500:
|
||||
return newResult(ports.ClassificationPermanentFailure, summaryFields{
|
||||
Phase: phase,
|
||||
SMTPCode: codeText,
|
||||
}, details)
|
||||
case sendErr.ErrorCode() >= 400:
|
||||
return newResult(ports.ClassificationTransientFailure, summaryFields{
|
||||
Phase: phase,
|
||||
SMTPCode: codeText,
|
||||
}, details)
|
||||
case sendErr.IsTemp():
|
||||
return newResult(ports.ClassificationTransientFailure, summaryFields{
|
||||
Phase: phase,
|
||||
}, details)
|
||||
default:
|
||||
return newResult(ports.ClassificationPermanentFailure, summaryFields{
|
||||
Phase: phase,
|
||||
}, details)
|
||||
}
|
||||
}
|
||||
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
return newResult(ports.ClassificationTransientFailure, summaryFields{
|
||||
Phase: "dial",
|
||||
}, map[string]string{
|
||||
"phase": "dial",
|
||||
"net_op": "smtp",
|
||||
"net_err": sanitizeDetailValue(strings.ToLower(netErr.Error())),
|
||||
})
|
||||
}
|
||||
|
||||
return newResult(ports.ClassificationPermanentFailure, summaryFields{
|
||||
Phase: "send",
|
||||
}, map[string]string{
|
||||
"phase": "send",
|
||||
"error": sanitizeDetailValue(strings.ToLower(err.Error())),
|
||||
})
|
||||
}
|
||||
|
||||
func (provider *Provider) sendContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= provider.timeout {
|
||||
return ctx, func() {}
|
||||
}
|
||||
}
|
||||
|
||||
return context.WithTimeout(ctx, provider.timeout)
|
||||
}
|
||||
|
||||
type summaryFields struct {
|
||||
Phase string
|
||||
SMTPCode string
|
||||
}
|
||||
|
||||
func newResult(classification ports.Classification, fields summaryFields, details map[string]string) (ports.Result, error) {
|
||||
summary, err := ports.BuildSafeSummary(ports.SummaryFields{
|
||||
Provider: providerName,
|
||||
Result: string(classification),
|
||||
Phase: fields.Phase,
|
||||
SMTPCode: fields.SMTPCode,
|
||||
})
|
||||
if err != nil {
|
||||
return ports.Result{}, fmt.Errorf("build smtp provider summary: %w", err)
|
||||
}
|
||||
|
||||
result := ports.Result{
|
||||
Classification: classification,
|
||||
Summary: summary,
|
||||
Details: ports.CloneDetails(details),
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return ports.Result{}, fmt.Errorf("build smtp provider result: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func classifyLocalBuildError(err error) string {
|
||||
return sanitizeDetailValue(strings.ToLower(err.Error()))
|
||||
}
|
||||
|
||||
func smtpReasonPhase(sendErr *gomail.SendError, err error) string {
|
||||
if sendErr == nil {
|
||||
return "send"
|
||||
}
|
||||
|
||||
switch sendErr.Reason {
|
||||
case gomail.ErrConnCheck:
|
||||
return "dial"
|
||||
case gomail.ErrSMTPMailFrom:
|
||||
return "mail_from"
|
||||
case gomail.ErrSMTPRcptTo:
|
||||
return "rcpt_to"
|
||||
case gomail.ErrSMTPData:
|
||||
return "data"
|
||||
case gomail.ErrSMTPDataClose:
|
||||
return "data"
|
||||
case gomail.ErrSMTPReset:
|
||||
return "reset"
|
||||
case gomail.ErrWriteContent:
|
||||
return "build"
|
||||
case gomail.ErrGetSender, gomail.ErrGetRcpts:
|
||||
return "build"
|
||||
case gomail.ErrNoUnencoded:
|
||||
return "build"
|
||||
default:
|
||||
lower := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(lower, "starttls"):
|
||||
return "tls"
|
||||
case strings.Contains(lower, "auth"):
|
||||
return "auth"
|
||||
default:
|
||||
return "send"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeDetailValue(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
for _, r := range value {
|
||||
if r > 0x7f {
|
||||
builder.WriteByte('_')
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
builder.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
builder.WriteRune(r)
|
||||
case r == '.', r == '_', r == '-':
|
||||
builder.WriteRune(r)
|
||||
default:
|
||||
builder.WriteByte('_')
|
||||
}
|
||||
}
|
||||
|
||||
if builder.Len() == 0 {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProviderBuildMessageIncludesHeadersBodiesAndAttachments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := newTestProvider(t)
|
||||
message := testMessage(t)
|
||||
|
||||
msg, err := provider.buildMessage(message)
|
||||
require.NoError(t, err)
|
||||
|
||||
var buffer bytes.Buffer
|
||||
_, err = msg.WriteTo(&buffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
payload := buffer.String()
|
||||
require.Contains(t, payload, "From: \"Galaxy Mail\" <noreply@example.com>")
|
||||
require.Contains(t, payload, "To: <pilot@example.com>")
|
||||
require.Contains(t, payload, "Cc: <copilot@example.com>")
|
||||
require.Contains(t, payload, "Reply-To: <reply@example.com>")
|
||||
require.Contains(t, payload, "Subject: Turn update")
|
||||
require.Contains(t, payload, "multipart/mixed")
|
||||
require.Contains(t, payload, "multipart/alternative")
|
||||
require.Contains(t, payload, "text/plain")
|
||||
require.Contains(t, payload, "text/html")
|
||||
require.Contains(t, payload, "guide.txt")
|
||||
require.Contains(t, payload, "charset=utf-8")
|
||||
require.NotContains(t, payload, "\nBcc:")
|
||||
}
|
||||
|
||||
func TestProviderSendClassifiesAccepted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := startSMTPTestServer(t, smtpTestServerConfig{
|
||||
supportsSTARTTLS: true,
|
||||
finalDataReply: "250 2.0.0 accepted",
|
||||
})
|
||||
|
||||
provider := newLiveProvider(t, server.addr)
|
||||
result, err := provider.Send(context.Background(), testMessage(t))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.ClassificationAccepted, result.Classification)
|
||||
require.Equal(t, "provider=smtp result=accepted", result.Summary)
|
||||
require.Contains(t, server.data(), "Subject: Turn update")
|
||||
require.NotContains(t, server.data(), "\nBcc:")
|
||||
}
|
||||
|
||||
func TestProviderSendClassifiesTransientSMTPFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := startSMTPTestServer(t, smtpTestServerConfig{
|
||||
supportsSTARTTLS: true,
|
||||
finalDataReply: "451 4.3.0 temporary_failure",
|
||||
})
|
||||
|
||||
provider := newLiveProvider(t, server.addr)
|
||||
result, err := provider.Send(context.Background(), testMessage(t))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.ClassificationTransientFailure, result.Classification)
|
||||
require.Contains(t, result.Summary, "provider=smtp")
|
||||
require.Contains(t, result.Summary, "result=transient_failure")
|
||||
require.Contains(t, result.Summary, "phase=data")
|
||||
require.Contains(t, result.Summary, "smtp_code=451")
|
||||
}
|
||||
|
||||
func TestProviderSendClassifiesPermanentSMTPFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := startSMTPTestServer(t, smtpTestServerConfig{
|
||||
supportsSTARTTLS: true,
|
||||
finalDataReply: "550 5.7.1 permanent_failure",
|
||||
})
|
||||
|
||||
provider := newLiveProvider(t, server.addr)
|
||||
result, err := provider.Send(context.Background(), testMessage(t))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.ClassificationPermanentFailure, result.Classification)
|
||||
require.Contains(t, result.Summary, "provider=smtp")
|
||||
require.Contains(t, result.Summary, "result=permanent_failure")
|
||||
require.Contains(t, result.Summary, "phase=data")
|
||||
require.Contains(t, result.Summary, "smtp_code=550")
|
||||
}
|
||||
|
||||
func TestProviderSendClassifiesMissingSTARTTLSAsPermanentFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := startSMTPTestServer(t, smtpTestServerConfig{
|
||||
supportsSTARTTLS: false,
|
||||
finalDataReply: "250 2.0.0 accepted",
|
||||
})
|
||||
|
||||
provider := newLiveProvider(t, server.addr)
|
||||
result, err := provider.Send(context.Background(), testMessage(t))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.ClassificationPermanentFailure, result.Classification)
|
||||
require.Contains(t, result.Summary, "provider=smtp")
|
||||
require.Contains(t, result.Summary, "result=permanent_failure")
|
||||
require.Contains(t, result.Summary, "phase=tls")
|
||||
}
|
||||
|
||||
func TestProviderSendClassifiesExpiredDeadlineAsTransientFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := newTestProvider(t)
|
||||
|
||||
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
||||
defer cancel()
|
||||
|
||||
result, err := provider.Send(ctx, testMessage(t))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.ClassificationTransientFailure, result.Classification)
|
||||
require.Contains(t, result.Summary, "result=transient_failure")
|
||||
require.Contains(t, result.Summary, "phase=context")
|
||||
}
|
||||
|
||||
func TestNewRejectsUnpairedAuthConfiguration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := New(Config{
|
||||
Addr: "127.0.0.1:2525",
|
||||
Username: "mailer",
|
||||
FromEmail: "noreply@example.com",
|
||||
Timeout: time.Second,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "smtp username and password")
|
||||
}
|
||||
|
||||
func newTestProvider(t *testing.T) *Provider {
|
||||
t.Helper()
|
||||
|
||||
provider, err := New(Config{
|
||||
Addr: "127.0.0.1:2525",
|
||||
FromEmail: "noreply@example.com",
|
||||
FromName: "Galaxy Mail",
|
||||
Timeout: 15 * time.Second,
|
||||
TLSConfig: &tls.Config{
|
||||
ServerName: "localhost",
|
||||
InsecureSkipVerify: true, //nolint:gosec // test-only self-signed SMTP server.
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, provider.Close())
|
||||
})
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
func newLiveProvider(t *testing.T, addr string) *Provider {
|
||||
t.Helper()
|
||||
|
||||
provider, err := New(Config{
|
||||
Addr: addr,
|
||||
FromEmail: "noreply@example.com",
|
||||
FromName: "Galaxy Mail",
|
||||
Timeout: 5 * time.Second,
|
||||
TLSConfig: &tls.Config{
|
||||
ServerName: "localhost",
|
||||
InsecureSkipVerify: true, //nolint:gosec // test-only self-signed SMTP server.
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, provider.Close())
|
||||
})
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
func testMessage(t *testing.T) ports.Message {
|
||||
t.Helper()
|
||||
|
||||
message := ports.Message{
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: []common.Email{common.Email("pilot@example.com")},
|
||||
Cc: []common.Email{common.Email("copilot@example.com")},
|
||||
Bcc: []common.Email{common.Email("ops@example.com")},
|
||||
ReplyTo: []common.Email{common.Email("reply@example.com")},
|
||||
},
|
||||
Content: deliverydomain.Content{
|
||||
Subject: "Turn update",
|
||||
TextBody: "Turn 54 is ready.",
|
||||
HTMLBody: "<p>Turn <strong>54</strong> is ready.</p>",
|
||||
},
|
||||
Attachments: []ports.Attachment{
|
||||
{
|
||||
Metadata: common.AttachmentMetadata{
|
||||
Filename: "guide.txt",
|
||||
ContentType: "text/plain; charset=utf-8",
|
||||
SizeBytes: int64(len([]byte("read me"))),
|
||||
},
|
||||
Content: []byte("read me"),
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, message.Validate())
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
type smtpTestServerConfig struct {
|
||||
supportsSTARTTLS bool
|
||||
finalDataReply string
|
||||
}
|
||||
|
||||
type smtpTestServer struct {
|
||||
addr string
|
||||
listener net.Listener
|
||||
tlsConfig *tls.Config
|
||||
|
||||
mu sync.Mutex
|
||||
conn net.Conn
|
||||
payload strings.Builder
|
||||
}
|
||||
|
||||
func startSMTPTestServer(t *testing.T, cfg smtpTestServerConfig) *smtpTestServer {
|
||||
t.Helper()
|
||||
|
||||
certificate := newTestCertificate(t)
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
server := &smtpTestServer{
|
||||
addr: listener.Addr().String(),
|
||||
listener: listener,
|
||||
tlsConfig: &tls.Config{
|
||||
Certificates: []tls.Certificate{certificate},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
server.mu.Lock()
|
||||
server.conn = conn
|
||||
server.mu.Unlock()
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
server.serveConnection(conn, cfg)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
server.mu.Lock()
|
||||
if server.conn != nil {
|
||||
_ = server.conn.Close()
|
||||
}
|
||||
server.mu.Unlock()
|
||||
_ = listener.Close()
|
||||
<-done
|
||||
})
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
func (server *smtpTestServer) data() string {
|
||||
server.mu.Lock()
|
||||
defer server.mu.Unlock()
|
||||
return server.payload.String()
|
||||
}
|
||||
|
||||
func (server *smtpTestServer) serveConnection(conn net.Conn, cfg smtpTestServerConfig) {
|
||||
reader := newSMTPLineReader(conn)
|
||||
writer := newSMTPLineWriter(conn)
|
||||
writer.writeLine("220 localhost ESMTP")
|
||||
|
||||
tlsActive := false
|
||||
for {
|
||||
line, err := reader.readLine()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
command := strings.ToUpper(line)
|
||||
switch {
|
||||
case strings.HasPrefix(command, "EHLO "), strings.HasPrefix(command, "HELO "):
|
||||
if cfg.supportsSTARTTLS && !tlsActive {
|
||||
writer.writeLines(
|
||||
"250-localhost",
|
||||
"250-8BITMIME",
|
||||
"250-STARTTLS",
|
||||
"250 SMTPUTF8",
|
||||
)
|
||||
continue
|
||||
}
|
||||
writer.writeLines(
|
||||
"250-localhost",
|
||||
"250-8BITMIME",
|
||||
"250 SMTPUTF8",
|
||||
)
|
||||
case command == "STARTTLS":
|
||||
writer.writeLine("220 Ready to start TLS")
|
||||
tlsConn := tls.Server(conn, server.tlsConfig)
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
return
|
||||
}
|
||||
conn = tlsConn
|
||||
server.mu.Lock()
|
||||
server.conn = conn
|
||||
server.mu.Unlock()
|
||||
reader = newSMTPLineReader(conn)
|
||||
writer = newSMTPLineWriter(conn)
|
||||
tlsActive = true
|
||||
case strings.HasPrefix(command, "MAIL FROM:"):
|
||||
writer.writeLine("250 2.1.0 Ok")
|
||||
case strings.HasPrefix(command, "RCPT TO:"):
|
||||
writer.writeLine("250 2.1.5 Ok")
|
||||
case command == "DATA":
|
||||
writer.writeLine("354 End data with <CR><LF>.<CR><LF>")
|
||||
|
||||
var builder strings.Builder
|
||||
for {
|
||||
dataLine, err := reader.readRawLine()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if dataLine == ".\r\n" {
|
||||
break
|
||||
}
|
||||
builder.WriteString(dataLine)
|
||||
}
|
||||
|
||||
server.mu.Lock()
|
||||
server.payload.WriteString(builder.String())
|
||||
server.mu.Unlock()
|
||||
|
||||
writer.writeLine(cfg.finalDataReply)
|
||||
case command == "RSET":
|
||||
writer.writeLine("250 2.0.0 Ok")
|
||||
case command == "QUIT":
|
||||
writer.writeLine("221 2.0.0 Bye")
|
||||
return
|
||||
default:
|
||||
writer.writeLine("250 2.0.0 Ok")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type smtpLineReader struct {
|
||||
reader *bytes.Buffer
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
func newSMTPLineReader(conn net.Conn) *smtpLineReader {
|
||||
return &smtpLineReader{conn: conn}
|
||||
}
|
||||
|
||||
func (reader *smtpLineReader) readLine() (string, error) {
|
||||
line, err := reader.readRawLine()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"), nil
|
||||
}
|
||||
|
||||
func (reader *smtpLineReader) readRawLine() (string, error) {
|
||||
var buffer bytes.Buffer
|
||||
tmp := make([]byte, 1)
|
||||
for {
|
||||
_, err := reader.conn.Read(tmp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
buffer.WriteByte(tmp[0])
|
||||
if tmp[0] == '\n' {
|
||||
return buffer.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type smtpLineWriter struct {
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
func newSMTPLineWriter(conn net.Conn) *smtpLineWriter {
|
||||
return &smtpLineWriter{conn: conn}
|
||||
}
|
||||
|
||||
func (writer *smtpLineWriter) writeLine(line string) {
|
||||
_, _ = io.WriteString(writer.conn, line+"\r\n")
|
||||
}
|
||||
|
||||
func (writer *smtpLineWriter) writeLines(lines ...string) {
|
||||
for _, line := range lines {
|
||||
writer.writeLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestCertificate(t *testing.T) tls.Certificate {
|
||||
t.Helper()
|
||||
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: "localhost",
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: []string{"localhost"},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
|
||||
})
|
||||
|
||||
certificate, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
require.NoError(t, err)
|
||||
return certificate
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// Package stubprovider provides the deterministic local provider used by Mail
|
||||
// Service tests and local bootstrap flows.
|
||||
package stubprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/ports"
|
||||
)
|
||||
|
||||
const providerName = "stub"
|
||||
|
||||
// ScriptedOutcome stores one queued stub-provider result consumed by the next
|
||||
// Send call.
|
||||
type ScriptedOutcome struct {
|
||||
// Classification stores the stable provider result classification.
|
||||
Classification ports.Classification
|
||||
|
||||
// Script stores the optional stable script label included in the redacted
|
||||
// provider summary.
|
||||
Script string
|
||||
|
||||
// Details stores optional in-memory-only diagnostic fields associated with
|
||||
// the scripted result.
|
||||
Details map[string]string
|
||||
}
|
||||
|
||||
// Validate reports whether outcome contains one supported queued stub result.
|
||||
func (outcome ScriptedOutcome) Validate() error {
|
||||
if !outcome.Classification.IsKnown() {
|
||||
return fmt.Errorf("stub scripted classification %q is unsupported", outcome.Classification)
|
||||
}
|
||||
if outcome.Script != "" {
|
||||
if _, err := ports.BuildSafeSummary(ports.SummaryFields{
|
||||
Provider: providerName,
|
||||
Result: string(outcome.Classification),
|
||||
Script: outcome.Script,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("stub scripted outcome: %w", err)
|
||||
}
|
||||
}
|
||||
for key, value := range outcome.Details {
|
||||
result := ports.Result{
|
||||
Classification: outcome.Classification,
|
||||
Summary: "provider=stub result=accepted",
|
||||
Details: map[string]string{
|
||||
key: value,
|
||||
},
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return fmt.Errorf("stub scripted details: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Provider stores one deterministic in-memory provider implementation.
|
||||
type Provider struct {
|
||||
mu sync.Mutex
|
||||
queue []ScriptedOutcome
|
||||
inputs []ports.Message
|
||||
closed bool
|
||||
}
|
||||
|
||||
// New constructs the deterministic stub provider.
|
||||
func New(initial ...ScriptedOutcome) (*Provider, error) {
|
||||
provider := &Provider{}
|
||||
if err := provider.Enqueue(initial...); err != nil {
|
||||
return nil, fmt.Errorf("new stub provider: %w", err)
|
||||
}
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// Send records message and returns the next scripted outcome, or a stable
|
||||
// accepted outcome when no script remains.
|
||||
func (provider *Provider) Send(ctx context.Context, message ports.Message) (ports.Result, error) {
|
||||
switch {
|
||||
case ctx == nil:
|
||||
return ports.Result{}, errors.New("send with stub provider: nil context")
|
||||
case provider == nil:
|
||||
return ports.Result{}, errors.New("send with stub provider: nil provider")
|
||||
}
|
||||
if err := message.Validate(); err != nil {
|
||||
return ports.Result{}, fmt.Errorf("send with stub provider: %w", err)
|
||||
}
|
||||
|
||||
provider.mu.Lock()
|
||||
defer provider.mu.Unlock()
|
||||
|
||||
if provider.closed {
|
||||
return ports.Result{}, errors.New("send with stub provider: provider is closed")
|
||||
}
|
||||
|
||||
provider.inputs = append(provider.inputs, cloneMessage(message))
|
||||
|
||||
if len(provider.queue) == 0 {
|
||||
return scriptedResult(ScriptedOutcome{
|
||||
Classification: ports.ClassificationAccepted,
|
||||
})
|
||||
}
|
||||
|
||||
next := provider.queue[0]
|
||||
provider.queue = provider.queue[1:]
|
||||
return scriptedResult(next)
|
||||
}
|
||||
|
||||
// Close marks the provider as closed. Future Send calls fail fast.
|
||||
func (provider *Provider) Close() error {
|
||||
if provider == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
provider.mu.Lock()
|
||||
defer provider.mu.Unlock()
|
||||
provider.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enqueue appends scripted outcomes to the stub queue.
|
||||
func (provider *Provider) Enqueue(outcomes ...ScriptedOutcome) error {
|
||||
if provider == nil {
|
||||
return errors.New("enqueue stub provider outcomes: nil provider")
|
||||
}
|
||||
|
||||
provider.mu.Lock()
|
||||
defer provider.mu.Unlock()
|
||||
|
||||
for index, outcome := range outcomes {
|
||||
if err := outcome.Validate(); err != nil {
|
||||
return fmt.Errorf("enqueue stub provider outcomes[%d]: %w", index, err)
|
||||
}
|
||||
provider.queue = append(provider.queue, ScriptedOutcome{
|
||||
Classification: outcome.Classification,
|
||||
Script: outcome.Script,
|
||||
Details: ports.CloneDetails(outcome.Details),
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Inputs returns a detached snapshot of the accepted Send inputs.
|
||||
func (provider *Provider) Inputs() []ports.Message {
|
||||
if provider == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
provider.mu.Lock()
|
||||
defer provider.mu.Unlock()
|
||||
|
||||
inputs := make([]ports.Message, len(provider.inputs))
|
||||
for index, input := range provider.inputs {
|
||||
inputs[index] = cloneMessage(input)
|
||||
}
|
||||
|
||||
return inputs
|
||||
}
|
||||
|
||||
func scriptedResult(outcome ScriptedOutcome) (ports.Result, error) {
|
||||
summary, err := ports.BuildSafeSummary(ports.SummaryFields{
|
||||
Provider: providerName,
|
||||
Result: string(outcome.Classification),
|
||||
Script: outcome.Script,
|
||||
})
|
||||
if err != nil {
|
||||
return ports.Result{}, fmt.Errorf("build stub provider summary: %w", err)
|
||||
}
|
||||
|
||||
result := ports.Result{
|
||||
Classification: outcome.Classification,
|
||||
Summary: summary,
|
||||
Details: ports.CloneDetails(outcome.Details),
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return ports.Result{}, fmt.Errorf("build stub provider result: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func cloneMessage(message ports.Message) ports.Message {
|
||||
cloned := ports.Message{
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: append([]common.Email(nil), message.Envelope.To...),
|
||||
Cc: append([]common.Email(nil), message.Envelope.Cc...),
|
||||
Bcc: append([]common.Email(nil), message.Envelope.Bcc...),
|
||||
ReplyTo: append([]common.Email(nil), message.Envelope.ReplyTo...),
|
||||
},
|
||||
Content: message.Content,
|
||||
}
|
||||
if len(message.Attachments) > 0 {
|
||||
cloned.Attachments = make([]ports.Attachment, len(message.Attachments))
|
||||
for index, attachment := range message.Attachments {
|
||||
content := make([]byte, len(attachment.Content))
|
||||
copy(content, attachment.Content)
|
||||
cloned.Attachments[index] = ports.Attachment{
|
||||
Metadata: attachment.Metadata,
|
||||
Content: content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package stubprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProviderSendUsesAcceptedDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider, err := New()
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := provider.Send(context.Background(), testMessage(t))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.ClassificationAccepted, result.Classification)
|
||||
require.Equal(t, "provider=stub result=accepted", result.Summary)
|
||||
require.Len(t, provider.Inputs(), 1)
|
||||
}
|
||||
|
||||
func TestProviderSendConsumesScriptedOutcomesInOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider, err := New(
|
||||
ScriptedOutcome{
|
||||
Classification: ports.ClassificationTransientFailure,
|
||||
Script: "retry_later",
|
||||
},
|
||||
ScriptedOutcome{
|
||||
Classification: ports.ClassificationSuppressed,
|
||||
Script: "policy_skip",
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
first, err := provider.Send(context.Background(), testMessage(t))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.ClassificationTransientFailure, first.Classification)
|
||||
require.Equal(t, "provider=stub result=transient_failure script=retry_later", first.Summary)
|
||||
|
||||
second, err := provider.Send(context.Background(), testMessage(t))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.ClassificationSuppressed, second.Classification)
|
||||
require.Equal(t, "provider=stub result=suppressed script=policy_skip", second.Summary)
|
||||
|
||||
third, err := provider.Send(context.Background(), testMessage(t))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.ClassificationAccepted, third.Classification)
|
||||
}
|
||||
|
||||
func TestProviderSendConsumesQueueSafelyAcrossGoroutines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const sendCount = 24
|
||||
|
||||
initial := make([]ScriptedOutcome, 0, sendCount)
|
||||
for index := 0; index < sendCount; index++ {
|
||||
initial = append(initial, ScriptedOutcome{
|
||||
Classification: ports.ClassificationAccepted,
|
||||
Script: fmt.Sprintf("case_%02d", index),
|
||||
})
|
||||
}
|
||||
|
||||
provider, err := New(initial...)
|
||||
require.NoError(t, err)
|
||||
|
||||
message := testMessage(t)
|
||||
summaries := make(chan string, sendCount)
|
||||
errs := make(chan error, sendCount)
|
||||
var waitGroup sync.WaitGroup
|
||||
for index := 0; index < sendCount; index++ {
|
||||
waitGroup.Add(1)
|
||||
go func() {
|
||||
defer waitGroup.Done()
|
||||
result, sendErr := provider.Send(context.Background(), message)
|
||||
if sendErr != nil {
|
||||
errs <- sendErr
|
||||
return
|
||||
}
|
||||
summaries <- result.Summary
|
||||
}()
|
||||
}
|
||||
waitGroup.Wait()
|
||||
close(summaries)
|
||||
close(errs)
|
||||
|
||||
for err := range errs {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, sendCount)
|
||||
for summary := range summaries {
|
||||
seen[summary] = struct{}{}
|
||||
}
|
||||
|
||||
require.Len(t, seen, sendCount)
|
||||
require.Len(t, provider.Inputs(), sendCount)
|
||||
}
|
||||
|
||||
func testMessage(t *testing.T) ports.Message {
|
||||
t.Helper()
|
||||
|
||||
message := ports.Message{
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: []common.Email{common.Email("pilot@example.com")},
|
||||
},
|
||||
Content: deliverydomain.Content{
|
||||
Subject: "Turn update",
|
||||
TextBody: "Turn 54 is ready.",
|
||||
},
|
||||
}
|
||||
require.NoError(t, message.Validate())
|
||||
|
||||
return message
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
// Package templates provides the filesystem-backed template catalog used by
|
||||
// Mail Service.
|
||||
package templates
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
htmltemplate "html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
texttemplate "text/template"
|
||||
"text/template/parse"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
templatedomain "galaxy/mail/internal/domain/template"
|
||||
)
|
||||
|
||||
const (
|
||||
subjectTemplateFile = "subject.tmpl"
|
||||
textTemplateFile = "text.tmpl"
|
||||
htmlTemplateFile = "html.tmpl"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrTemplateNotFound reports that no template family exists for the
|
||||
// requested template identifier.
|
||||
ErrTemplateNotFound = errors.New("template catalog template not found")
|
||||
|
||||
// ErrFallbackMissing reports that the requested locale is unavailable and
|
||||
// the mandatory `en` fallback variant is also missing.
|
||||
ErrFallbackMissing = errors.New("template catalog fallback locale missing")
|
||||
|
||||
// ErrTemplateParseFailed reports that one filesystem template file could
|
||||
// not be parsed into the in-memory registry.
|
||||
ErrTemplateParseFailed = errors.New("template catalog template parse failed")
|
||||
|
||||
requiredStartupTemplate = templateKey{
|
||||
TemplateID: common.TemplateID("auth.login_code"),
|
||||
Locale: common.Locale("en"),
|
||||
}
|
||||
)
|
||||
|
||||
// Catalog stores the immutable in-memory template registry built at process
|
||||
// startup.
|
||||
type Catalog struct {
|
||||
rootDir string
|
||||
templates map[templateKey]*compiledTemplate
|
||||
availableLocales map[common.TemplateID][]common.Locale
|
||||
}
|
||||
|
||||
// ResolvedTemplate stores one resolved template variant together with lookup
|
||||
// metadata such as locale fallback usage and required variable paths.
|
||||
type ResolvedTemplate struct {
|
||||
record templatedomain.Template
|
||||
resolvedLocale common.Locale
|
||||
localeFallbackUsed bool
|
||||
requiredVariablePaths []string
|
||||
subject *texttemplate.Template
|
||||
text *texttemplate.Template
|
||||
html *htmltemplate.Template
|
||||
}
|
||||
|
||||
type templateKey struct {
|
||||
TemplateID common.TemplateID
|
||||
Locale common.Locale
|
||||
}
|
||||
|
||||
type compiledTemplate struct {
|
||||
record templatedomain.Template
|
||||
requiredVariablePaths []string
|
||||
subject *texttemplate.Template
|
||||
text *texttemplate.Template
|
||||
html *htmltemplate.Template
|
||||
}
|
||||
|
||||
type templateSources struct {
|
||||
TemplateID common.TemplateID
|
||||
Locale common.Locale
|
||||
Subject string
|
||||
Text string
|
||||
HTML string
|
||||
}
|
||||
|
||||
// NewCatalog constructs Catalog for rootDir, parses the full template
|
||||
// registry, and validates the mandatory auth login-code fallback template.
|
||||
func NewCatalog(rootDir string) (*Catalog, error) {
|
||||
if strings.TrimSpace(rootDir) == "" {
|
||||
return nil, fmt.Errorf("new template catalog: root dir must not be empty")
|
||||
}
|
||||
|
||||
cleanRootDir := filepath.Clean(rootDir)
|
||||
info, err := os.Stat(cleanRootDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new template catalog: stat root dir %q: %w", cleanRootDir, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("new template catalog: root dir %q must be a directory", cleanRootDir)
|
||||
}
|
||||
|
||||
registry, availableLocales, err := loadRegistry(cleanRootDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new template catalog: %w", err)
|
||||
}
|
||||
if _, ok := registry[requiredStartupTemplate]; !ok {
|
||||
return nil, fmt.Errorf(
|
||||
"new template catalog: required template %q locale %q is missing",
|
||||
requiredStartupTemplate.TemplateID,
|
||||
requiredStartupTemplate.Locale,
|
||||
)
|
||||
}
|
||||
|
||||
return &Catalog{
|
||||
rootDir: cleanRootDir,
|
||||
templates: registry,
|
||||
availableLocales: availableLocales,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RootDir returns the configured template catalog root directory.
|
||||
func (catalog *Catalog) RootDir() string {
|
||||
if catalog == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return catalog.rootDir
|
||||
}
|
||||
|
||||
// Lookup resolves one template family for locale, applying the frozen exact
|
||||
// match followed by `en` fallback rule.
|
||||
func (catalog *Catalog) Lookup(templateID common.TemplateID, locale common.Locale) (ResolvedTemplate, error) {
|
||||
if catalog == nil {
|
||||
return ResolvedTemplate{}, errors.New("lookup template: nil catalog")
|
||||
}
|
||||
if err := templateID.Validate(); err != nil {
|
||||
return ResolvedTemplate{}, fmt.Errorf("lookup template: template id: %w", err)
|
||||
}
|
||||
if err := locale.Validate(); err != nil {
|
||||
return ResolvedTemplate{}, fmt.Errorf("lookup template: locale: %w", err)
|
||||
}
|
||||
|
||||
exactKey := templateKey{TemplateID: templateID, Locale: locale}
|
||||
if compiled, ok := catalog.templates[exactKey]; ok {
|
||||
return compiled.resolve(false), nil
|
||||
}
|
||||
|
||||
fallbackKey := templateKey{TemplateID: templateID, Locale: common.Locale("en")}
|
||||
if compiled, ok := catalog.templates[fallbackKey]; ok {
|
||||
return compiled.resolve(true), nil
|
||||
}
|
||||
|
||||
if _, ok := catalog.availableLocales[templateID]; ok {
|
||||
return ResolvedTemplate{}, fmt.Errorf(
|
||||
"lookup template %q locale %q: %w",
|
||||
templateID,
|
||||
locale,
|
||||
ErrFallbackMissing,
|
||||
)
|
||||
}
|
||||
|
||||
return ResolvedTemplate{}, fmt.Errorf(
|
||||
"lookup template %q locale %q: %w",
|
||||
templateID,
|
||||
locale,
|
||||
ErrTemplateNotFound,
|
||||
)
|
||||
}
|
||||
|
||||
// Template returns the resolved logical template record.
|
||||
func (resolved ResolvedTemplate) Template() templatedomain.Template {
|
||||
return resolved.record
|
||||
}
|
||||
|
||||
// ResolvedLocale returns the filesystem locale variant that will actually be
|
||||
// executed.
|
||||
func (resolved ResolvedTemplate) ResolvedLocale() common.Locale {
|
||||
return resolved.resolvedLocale
|
||||
}
|
||||
|
||||
// LocaleFallbackUsed reports whether lookup fell back from the requested
|
||||
// locale to `en`.
|
||||
func (resolved ResolvedTemplate) LocaleFallbackUsed() bool {
|
||||
return resolved.localeFallbackUsed
|
||||
}
|
||||
|
||||
// RequiredVariablePaths returns the sorted list of dot-path variables used by
|
||||
// the resolved template variant.
|
||||
func (resolved ResolvedTemplate) RequiredVariablePaths() []string {
|
||||
return append([]string(nil), resolved.requiredVariablePaths...)
|
||||
}
|
||||
|
||||
// ExecuteSubject executes the resolved subject template with data.
|
||||
func (resolved ResolvedTemplate) ExecuteSubject(data any) (string, error) {
|
||||
return executeTextTemplate("subject", resolved.subject, data)
|
||||
}
|
||||
|
||||
// ExecuteText executes the resolved plaintext body template with data.
|
||||
func (resolved ResolvedTemplate) ExecuteText(data any) (string, error) {
|
||||
return executeTextTemplate("text", resolved.text, data)
|
||||
}
|
||||
|
||||
// ExecuteHTML executes the resolved HTML body template with data. The second
|
||||
// return value reports whether the resolved variant contains HTML content.
|
||||
func (resolved ResolvedTemplate) ExecuteHTML(data any) (string, bool, error) {
|
||||
if resolved.html == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
rendered, err := executeHTMLTemplate("html", resolved.html, data)
|
||||
if err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
|
||||
return rendered, true, nil
|
||||
}
|
||||
|
||||
func loadRegistry(rootDir string) (map[templateKey]*compiledTemplate, map[common.TemplateID][]common.Locale, error) {
|
||||
sourceBundles := make(map[templateKey]*templateSources)
|
||||
|
||||
if err := filepath.WalkDir(rootDir, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
|
||||
relativePath, err := filepath.Rel(rootDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if relativePath == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
relativePath = filepath.ToSlash(relativePath)
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(relativePath, "/")
|
||||
if len(parts) != 3 {
|
||||
return fmt.Errorf("invalid template path %q: expected <template_id>/<locale>/<file>", relativePath)
|
||||
}
|
||||
|
||||
templateID := common.TemplateID(parts[0])
|
||||
if err := templateID.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid template path %q: %w", relativePath, err)
|
||||
}
|
||||
|
||||
locale, err := common.ParseLocale(parts[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid template path %q: %w", relativePath, err)
|
||||
}
|
||||
|
||||
contentsBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read template file %q: %w", path, err)
|
||||
}
|
||||
|
||||
key := templateKey{TemplateID: templateID, Locale: locale}
|
||||
bundle := sourceBundles[key]
|
||||
if bundle == nil {
|
||||
bundle = &templateSources{
|
||||
TemplateID: templateID,
|
||||
Locale: locale,
|
||||
}
|
||||
sourceBundles[key] = bundle
|
||||
}
|
||||
|
||||
switch parts[2] {
|
||||
case subjectTemplateFile:
|
||||
if bundle.Subject != "" {
|
||||
return fmt.Errorf("duplicate template subject for %q locale %q", templateID, locale)
|
||||
}
|
||||
bundle.Subject = string(contentsBytes)
|
||||
case textTemplateFile:
|
||||
if bundle.Text != "" {
|
||||
return fmt.Errorf("duplicate template text body for %q locale %q", templateID, locale)
|
||||
}
|
||||
bundle.Text = string(contentsBytes)
|
||||
case htmlTemplateFile:
|
||||
if bundle.HTML != "" {
|
||||
return fmt.Errorf("duplicate template html body for %q locale %q", templateID, locale)
|
||||
}
|
||||
bundle.HTML = string(contentsBytes)
|
||||
default:
|
||||
return fmt.Errorf("invalid template path %q: unsupported file name %q", relativePath, parts[2])
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
registry := make(map[templateKey]*compiledTemplate, len(sourceBundles))
|
||||
availableLocales := make(map[common.TemplateID][]common.Locale)
|
||||
|
||||
for key, bundle := range sourceBundles {
|
||||
compiled, err := compileTemplate(*bundle)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
registry[key] = compiled
|
||||
availableLocales[key.TemplateID] = append(availableLocales[key.TemplateID], key.Locale)
|
||||
}
|
||||
|
||||
for templateID := range availableLocales {
|
||||
sort.Slice(availableLocales[templateID], func(left int, right int) bool {
|
||||
return availableLocales[templateID][left].String() < availableLocales[templateID][right].String()
|
||||
})
|
||||
}
|
||||
|
||||
return registry, availableLocales, nil
|
||||
}
|
||||
|
||||
func compileTemplate(source templateSources) (*compiledTemplate, error) {
|
||||
if source.Subject == "" {
|
||||
return nil, fmt.Errorf("template %q locale %q is missing %s", source.TemplateID, source.Locale, subjectTemplateFile)
|
||||
}
|
||||
if source.Text == "" {
|
||||
return nil, fmt.Errorf("template %q locale %q is missing %s", source.TemplateID, source.Locale, textTemplateFile)
|
||||
}
|
||||
|
||||
subject, err := parseText(source.TemplateID, source.Locale, "subject", source.Subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
textBody, err := parseText(source.TemplateID, source.Locale, "text", source.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var htmlBody *htmltemplate.Template
|
||||
if source.HTML != "" {
|
||||
htmlBody, err = parseHTML(source.TemplateID, source.Locale, "html", source.HTML)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
record := templatedomain.Template{
|
||||
TemplateID: source.TemplateID,
|
||||
Locale: source.Locale,
|
||||
SubjectTemplate: source.Subject,
|
||||
TextTemplate: source.Text,
|
||||
HTMLTemplate: source.HTML,
|
||||
Version: computeVersion(source),
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("compile template %q locale %q: %w", source.TemplateID, source.Locale, err)
|
||||
}
|
||||
|
||||
requiredVariablePaths := collectRequiredVariablePaths(subject.Tree, textBody.Tree)
|
||||
if htmlBody != nil {
|
||||
requiredVariablePaths = mergeRequiredVariablePaths(requiredVariablePaths, collectRequiredVariablePaths(htmlBody.Tree))
|
||||
}
|
||||
|
||||
return &compiledTemplate{
|
||||
record: record,
|
||||
requiredVariablePaths: requiredVariablePaths,
|
||||
subject: subject,
|
||||
text: textBody,
|
||||
html: htmlBody,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseText(templateID common.TemplateID, locale common.Locale, part string, source string) (*texttemplate.Template, error) {
|
||||
parsed, err := texttemplate.New(part).Option("missingkey=error").Parse(source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"parse template %q locale %q part %q: %w: %v",
|
||||
templateID,
|
||||
locale,
|
||||
part,
|
||||
ErrTemplateParseFailed,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseHTML(templateID common.TemplateID, locale common.Locale, part string, source string) (*htmltemplate.Template, error) {
|
||||
parsed, err := htmltemplate.New(part).Option("missingkey=error").Parse(source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"parse template %q locale %q part %q: %w: %v",
|
||||
templateID,
|
||||
locale,
|
||||
part,
|
||||
ErrTemplateParseFailed,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func computeVersion(source templateSources) string {
|
||||
sum := sha256.New()
|
||||
for _, part := range []string{
|
||||
source.TemplateID.String(),
|
||||
source.Locale.String(),
|
||||
source.Subject,
|
||||
source.Text,
|
||||
source.HTML,
|
||||
} {
|
||||
_, _ = sum.Write([]byte(part))
|
||||
_, _ = sum.Write([]byte{0})
|
||||
}
|
||||
|
||||
return "sha256:" + hex.EncodeToString(sum.Sum(nil))
|
||||
}
|
||||
|
||||
func (compiled *compiledTemplate) resolve(localeFallbackUsed bool) ResolvedTemplate {
|
||||
return ResolvedTemplate{
|
||||
record: compiled.record,
|
||||
resolvedLocale: compiled.record.Locale,
|
||||
localeFallbackUsed: localeFallbackUsed,
|
||||
requiredVariablePaths: append([]string(nil), compiled.requiredVariablePaths...),
|
||||
subject: compiled.subject,
|
||||
text: compiled.text,
|
||||
html: compiled.html,
|
||||
}
|
||||
}
|
||||
|
||||
func executeTextTemplate(name string, tmpl *texttemplate.Template, data any) (string, error) {
|
||||
if tmpl == nil {
|
||||
return "", fmt.Errorf("execute %s template: nil template", name)
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
if err := tmpl.Execute(&builder, data); err != nil {
|
||||
return "", fmt.Errorf("execute %s template: %w", name, err)
|
||||
}
|
||||
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
func executeHTMLTemplate(name string, tmpl *htmltemplate.Template, data any) (string, error) {
|
||||
if tmpl == nil {
|
||||
return "", fmt.Errorf("execute %s template: nil template", name)
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
if err := tmpl.Execute(&builder, data); err != nil {
|
||||
return "", fmt.Errorf("execute %s template: %w", name, err)
|
||||
}
|
||||
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
func collectRequiredVariablePaths(trees ...*parse.Tree) []string {
|
||||
paths := make(map[string]struct{})
|
||||
|
||||
for _, tree := range trees {
|
||||
if tree == nil || tree.Root == nil {
|
||||
continue
|
||||
}
|
||||
collectNodePaths(tree.Root, nil, paths)
|
||||
}
|
||||
|
||||
collected := make([]string, 0, len(paths))
|
||||
for path := range paths {
|
||||
collected = append(collected, path)
|
||||
}
|
||||
sort.Strings(collected)
|
||||
|
||||
return collected
|
||||
}
|
||||
|
||||
func mergeRequiredVariablePaths(existing []string, additional []string) []string {
|
||||
merged := make(map[string]struct{}, len(existing)+len(additional))
|
||||
for _, path := range existing {
|
||||
merged[path] = struct{}{}
|
||||
}
|
||||
for _, path := range additional {
|
||||
merged[path] = struct{}{}
|
||||
}
|
||||
|
||||
combined := make([]string, 0, len(merged))
|
||||
for path := range merged {
|
||||
combined = append(combined, path)
|
||||
}
|
||||
sort.Strings(combined)
|
||||
|
||||
return combined
|
||||
}
|
||||
|
||||
func collectNodePaths(node parse.Node, scope []string, paths map[string]struct{}) {
|
||||
switch typed := node.(type) {
|
||||
case *parse.ListNode:
|
||||
if typed == nil {
|
||||
return
|
||||
}
|
||||
for _, child := range typed.Nodes {
|
||||
collectNodePaths(child, scope, paths)
|
||||
}
|
||||
case *parse.ActionNode:
|
||||
collectPipePaths(typed.Pipe, scope, paths)
|
||||
case *parse.IfNode:
|
||||
collectPipePaths(typed.Pipe, scope, paths)
|
||||
collectNodePaths(typed.List, scope, paths)
|
||||
collectNodePaths(typed.ElseList, scope, paths)
|
||||
case *parse.RangeNode:
|
||||
collectPipePaths(typed.Pipe, scope, paths)
|
||||
collectNodePaths(typed.List, scopeForPipe(typed.Pipe, scope), paths)
|
||||
collectNodePaths(typed.ElseList, scope, paths)
|
||||
case *parse.WithNode:
|
||||
collectPipePaths(typed.Pipe, scope, paths)
|
||||
collectNodePaths(typed.List, scopeForPipe(typed.Pipe, scope), paths)
|
||||
collectNodePaths(typed.ElseList, scope, paths)
|
||||
case *parse.TemplateNode:
|
||||
collectPipePaths(typed.Pipe, scope, paths)
|
||||
}
|
||||
}
|
||||
|
||||
func collectPipePaths(pipe *parse.PipeNode, scope []string, paths map[string]struct{}) {
|
||||
if pipe == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, command := range pipe.Cmds {
|
||||
for _, arg := range command.Args {
|
||||
path, ok := nodePath(arg, scope)
|
||||
if !ok || len(path) == 0 {
|
||||
continue
|
||||
}
|
||||
paths[strings.Join(path, ".")] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scopeForPipe(pipe *parse.PipeNode, scope []string) []string {
|
||||
if pipe == nil || len(pipe.Cmds) != 1 || len(pipe.Cmds[0].Args) != 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
path, ok := nodePath(pipe.Cmds[0].Args[0], scope)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
func nodePath(node parse.Node, scope []string) ([]string, bool) {
|
||||
switch typed := node.(type) {
|
||||
case *parse.FieldNode:
|
||||
return appendPath(scope, typed.Ident), true
|
||||
case *parse.ChainNode:
|
||||
prefix, ok := nodePath(typed.Node, scope)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return appendPath(prefix, typed.Field), true
|
||||
case *parse.DotNode:
|
||||
if len(scope) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return append([]string(nil), scope...), true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func appendPath(prefix []string, suffix []string) []string {
|
||||
combined := make([]string, 0, len(prefix)+len(suffix))
|
||||
combined = append(combined, prefix...)
|
||||
combined = append(combined, suffix...)
|
||||
return combined
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewCatalogBuildsImmutableRegistry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "subject.tmpl"), "Your login code")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "text.tmpl"), "Code: {{.code}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "fr-fr", "subject.tmpl"), "Tour {{.turn_number}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "fr-fr", "text.tmpl"), "Bonjour {{with .player}}{{.name}}{{end}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "fr-fr", "html.tmpl"), "<p>{{.player.name}}</p>")
|
||||
|
||||
catalog, err := NewCatalog(rootDir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, filepath.Clean(rootDir), catalog.RootDir())
|
||||
|
||||
locale, err := common.ParseLocale("fr-FR")
|
||||
require.NoError(t, err)
|
||||
resolved, err := catalog.Lookup(common.TemplateID("game.turn_ready"), locale)
|
||||
require.NoError(t, err)
|
||||
require.False(t, resolved.LocaleFallbackUsed())
|
||||
require.Equal(t, common.Locale("fr-FR"), resolved.ResolvedLocale())
|
||||
require.Equal(t, []string{"player", "player.name", "turn_number"}, resolved.RequiredVariablePaths())
|
||||
|
||||
subject, err := resolved.ExecuteSubject(map[string]any{
|
||||
"turn_number": 54,
|
||||
"player": map[string]any{
|
||||
"name": "Pilot",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Tour 54", subject)
|
||||
|
||||
textBody, err := resolved.ExecuteText(map[string]any{
|
||||
"player": map[string]any{
|
||||
"name": "Pilot",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Bonjour Pilot", textBody)
|
||||
|
||||
htmlBody, ok, err := resolved.ExecuteHTML(map[string]any{
|
||||
"player": map[string]any{
|
||||
"name": "Pilot",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "<p>Pilot</p>", htmlBody)
|
||||
}
|
||||
|
||||
func TestCatalogLookupFallsBackToEnglish(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "subject.tmpl"), "Your login code")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "text.tmpl"), "Code: {{.code}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "en", "subject.tmpl"), "Turn {{.turn_number}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "en", "text.tmpl"), "Hello {{.player.name}}")
|
||||
|
||||
catalog, err := NewCatalog(rootDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
locale, err := common.ParseLocale("fr-FR")
|
||||
require.NoError(t, err)
|
||||
resolved, err := catalog.Lookup(common.TemplateID("game.turn_ready"), locale)
|
||||
require.NoError(t, err)
|
||||
require.True(t, resolved.LocaleFallbackUsed())
|
||||
require.Equal(t, common.Locale("en"), resolved.ResolvedLocale())
|
||||
}
|
||||
|
||||
func TestCatalogLookupRejectsMissingEnglishFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "subject.tmpl"), "Your login code")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "text.tmpl"), "Code: {{.code}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "fr-FR", "subject.tmpl"), "Tour {{.turn_number}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "fr-FR", "text.tmpl"), "Bonjour {{.player.name}}")
|
||||
|
||||
catalog, err := NewCatalog(rootDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
locale, err := common.ParseLocale("de-DE")
|
||||
require.NoError(t, err)
|
||||
_, err = catalog.Lookup(common.TemplateID("game.turn_ready"), locale)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, ErrFallbackMissing))
|
||||
}
|
||||
|
||||
func TestCatalogLookupRejectsUnknownTemplateFamily(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "subject.tmpl"), "Your login code")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "text.tmpl"), "Code: {{.code}}")
|
||||
|
||||
catalog, err := NewCatalog(rootDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
locale, err := common.ParseLocale("en")
|
||||
require.NoError(t, err)
|
||||
_, err = catalog.Lookup(common.TemplateID("game.turn_ready"), locale)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, ErrTemplateNotFound))
|
||||
}
|
||||
|
||||
func TestCatalogAllowsTemplateWithoutHTML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "subject.tmpl"), "Your login code")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "text.tmpl"), "Code: {{.code}}")
|
||||
|
||||
catalog, err := NewCatalog(rootDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
locale, err := common.ParseLocale("en")
|
||||
require.NoError(t, err)
|
||||
resolved, err := catalog.Lookup(common.TemplateID("auth.login_code"), locale)
|
||||
require.NoError(t, err)
|
||||
|
||||
htmlBody, ok, err := resolved.ExecuteHTML(map[string]any{"code": "123456"})
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
require.Empty(t, htmlBody)
|
||||
}
|
||||
|
||||
func TestCatalogVersionIsDeterministic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "subject.tmpl"), "Your login code")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "text.tmpl"), "Code: {{.code}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "en", "subject.tmpl"), "Turn {{.turn_number}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "en", "text.tmpl"), "Hello {{.player.name}}")
|
||||
|
||||
firstCatalog, err := NewCatalog(rootDir)
|
||||
require.NoError(t, err)
|
||||
secondCatalog, err := NewCatalog(rootDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
locale, err := common.ParseLocale("en")
|
||||
require.NoError(t, err)
|
||||
firstResolved, err := firstCatalog.Lookup(common.TemplateID("game.turn_ready"), locale)
|
||||
require.NoError(t, err)
|
||||
secondResolved, err := secondCatalog.Lookup(common.TemplateID("game.turn_ready"), locale)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, firstResolved.Template().Version, secondResolved.Template().Version)
|
||||
}
|
||||
|
||||
func TestNewCatalogRejectsMissingDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := NewCatalog(filepath.Join(t.TempDir(), "missing"))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "stat root dir")
|
||||
}
|
||||
|
||||
func TestNewCatalogRejectsMissingRequiredStartupTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "en", "subject.tmpl"), "Turn {{.turn_number}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "en", "text.tmpl"), "Hello {{.player.name}}")
|
||||
|
||||
_, err := NewCatalog(rootDir)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), `required template "auth.login_code" locale "en" is missing`)
|
||||
}
|
||||
|
||||
func TestNewCatalogRejectsBrokenTemplateParse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "subject.tmpl"), "Your login code")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "text.tmpl"), "Code: {{.code}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "en", "subject.tmpl"), "{{if .turn_number}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "en", "text.tmpl"), "Hello {{.player.name}}")
|
||||
|
||||
_, err := NewCatalog(rootDir)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, ErrTemplateParseFailed))
|
||||
}
|
||||
|
||||
func writeTemplateFile(t *testing.T, rootDir string, relativePath string, contents string) {
|
||||
t.Helper()
|
||||
|
||||
absolutePath := filepath.Join(rootDir, relativePath)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(absolutePath), 0o755))
|
||||
require.NoError(t, os.WriteFile(absolutePath, []byte(contents), 0o644))
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
// Package internalhttp defines the frozen trusted internal HTTP contract used
|
||||
// by Mail Service.
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
)
|
||||
|
||||
const (
|
||||
// LoginCodeDeliveriesPath is the dedicated trusted route used by
|
||||
// Auth / Session Service for auth login-code delivery intake.
|
||||
LoginCodeDeliveriesPath = "/api/v1/internal/login-code-deliveries"
|
||||
|
||||
// IdempotencyKeyHeader is the required header that scopes auth-delivery
|
||||
// deduplication.
|
||||
IdempotencyKeyHeader = "Idempotency-Key"
|
||||
|
||||
// ErrorCodeInvalidRequest identifies trusted validation failures.
|
||||
ErrorCodeInvalidRequest = "invalid_request"
|
||||
|
||||
// ErrorCodeInternalError identifies trusted invariant failures.
|
||||
ErrorCodeInternalError = "internal_error"
|
||||
|
||||
// ErrorCodeServiceUnavailable identifies trusted availability failures.
|
||||
ErrorCodeServiceUnavailable = "service_unavailable"
|
||||
|
||||
// ErrorCodeConflict identifies conflicting idempotency replays.
|
||||
ErrorCodeConflict = "conflict"
|
||||
|
||||
jsonMediaType = "application/json"
|
||||
)
|
||||
|
||||
// LoginCodeDeliveryRequest stores the strict JSON body accepted on the frozen
|
||||
// auth-delivery route before normalization.
|
||||
type LoginCodeDeliveryRequest struct {
|
||||
// Email stores the destination e-mail address.
|
||||
Email string `json:"email"`
|
||||
|
||||
// Code stores the exact login code generated by Auth / Session Service.
|
||||
Code string `json:"code"`
|
||||
|
||||
// Locale stores the caller-selected BCP 47 language tag.
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
|
||||
// LoginCodeDeliveryCommand stores the normalized auth-delivery request shape
|
||||
// that later Mail Service handlers and services can consume directly.
|
||||
type LoginCodeDeliveryCommand struct {
|
||||
// IdempotencyKey stores the caller-owned stable deduplication key.
|
||||
IdempotencyKey common.IdempotencyKey
|
||||
|
||||
// Email stores the normalized recipient address.
|
||||
Email common.Email
|
||||
|
||||
// Code stores the exact login code after boundary validation.
|
||||
Code string
|
||||
|
||||
// Locale stores the canonical BCP 47 language tag.
|
||||
Locale common.Locale
|
||||
}
|
||||
|
||||
// Validate reports whether command satisfies the frozen auth-delivery
|
||||
// contract.
|
||||
func (command LoginCodeDeliveryCommand) Validate() error {
|
||||
if err := command.IdempotencyKey.Validate(); err != nil {
|
||||
return fmt.Errorf("idempotency key: %w", err)
|
||||
}
|
||||
if err := command.Email.Validate(); err != nil {
|
||||
return fmt.Errorf("email: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(command.Code) == "" {
|
||||
return errors.New("code must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(command.Code) != command.Code {
|
||||
return errors.New("code must not contain surrounding whitespace")
|
||||
}
|
||||
if err := command.Locale.Validate(); err != nil {
|
||||
return fmt.Errorf("locale: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fingerprint returns the stable auth-delivery idempotency fingerprint of
|
||||
// command.
|
||||
func (command LoginCodeDeliveryCommand) Fingerprint() (string, error) {
|
||||
if err := command.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: command.IdempotencyKey.String(),
|
||||
Email: command.Email.String(),
|
||||
Code: command.Code,
|
||||
Locale: command.Locale.String(),
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal login code delivery fingerprint: %w", err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(payload)
|
||||
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// LoginCodeDeliveryOutcome identifies the stable successful auth-delivery
|
||||
// intake outcomes.
|
||||
type LoginCodeDeliveryOutcome string
|
||||
|
||||
const (
|
||||
// LoginCodeDeliveryOutcomeSent reports durable acceptance into the internal
|
||||
// mail-delivery pipeline.
|
||||
LoginCodeDeliveryOutcomeSent LoginCodeDeliveryOutcome = "sent"
|
||||
|
||||
// LoginCodeDeliveryOutcomeSuppressed reports intentional outward delivery
|
||||
// suppression while keeping the auth flow success-shaped.
|
||||
LoginCodeDeliveryOutcomeSuppressed LoginCodeDeliveryOutcome = "suppressed"
|
||||
)
|
||||
|
||||
// IsKnown reports whether outcome belongs to the frozen auth success surface.
|
||||
func (outcome LoginCodeDeliveryOutcome) IsKnown() bool {
|
||||
switch outcome {
|
||||
case LoginCodeDeliveryOutcomeSent, LoginCodeDeliveryOutcomeSuppressed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// LoginCodeDeliveryResponse stores the stable successful auth-delivery
|
||||
// response body.
|
||||
type LoginCodeDeliveryResponse struct {
|
||||
// Outcome stores the stable coarse acceptance result.
|
||||
Outcome LoginCodeDeliveryOutcome `json:"outcome"`
|
||||
}
|
||||
|
||||
// Validate reports whether response satisfies the frozen success contract.
|
||||
func (response LoginCodeDeliveryResponse) Validate() error {
|
||||
if !response.Outcome.IsKnown() {
|
||||
return fmt.Errorf("login code delivery outcome %q is unsupported", response.Outcome)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrorResponse stores the stable trusted error envelope used by Mail Service.
|
||||
type ErrorResponse struct {
|
||||
// Error stores the stable trusted error body.
|
||||
Error ErrorBody `json:"error"`
|
||||
}
|
||||
|
||||
// Validate reports whether response satisfies the frozen trusted error
|
||||
// envelope contract.
|
||||
func (response ErrorResponse) Validate() error {
|
||||
return response.Error.Validate()
|
||||
}
|
||||
|
||||
// ErrorBody stores the stable trusted error shape returned by Mail Service.
|
||||
type ErrorBody struct {
|
||||
// Code stores the stable machine-readable error code.
|
||||
Code string `json:"code"`
|
||||
|
||||
// Message stores the trusted human-readable error message.
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Validate reports whether body contains a complete trusted error payload.
|
||||
func (body ErrorBody) Validate() error {
|
||||
switch {
|
||||
case strings.TrimSpace(body.Code) == "":
|
||||
return errors.New("error code must not be empty")
|
||||
case strings.TrimSpace(body.Code) != body.Code:
|
||||
return errors.New("error code must not contain surrounding whitespace")
|
||||
case strings.TrimSpace(body.Message) == "":
|
||||
return errors.New("error message must not be empty")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// DecodeLoginCodeDeliveryCommand validates one trusted HTTP request and
|
||||
// returns the normalized auth-delivery command shape frozen by Stage 04.
|
||||
func DecodeLoginCodeDeliveryCommand(request *http.Request) (LoginCodeDeliveryCommand, error) {
|
||||
if request == nil {
|
||||
return LoginCodeDeliveryCommand{}, errors.New("login code delivery request must not be nil")
|
||||
}
|
||||
|
||||
if err := validateJSONContentType(request.Header.Get("Content-Type")); err != nil {
|
||||
return LoginCodeDeliveryCommand{}, err
|
||||
}
|
||||
|
||||
idempotencyKey, err := parseIdempotencyKey(request.Header.Get(IdempotencyKeyHeader))
|
||||
if err != nil {
|
||||
return LoginCodeDeliveryCommand{}, err
|
||||
}
|
||||
|
||||
body, err := decodeLoginCodeDeliveryRequest(request.Body)
|
||||
if err != nil {
|
||||
return LoginCodeDeliveryCommand{}, err
|
||||
}
|
||||
|
||||
command := LoginCodeDeliveryCommand{
|
||||
IdempotencyKey: idempotencyKey,
|
||||
Email: common.Email(strings.TrimSpace(body.Email)),
|
||||
Code: body.Code,
|
||||
}
|
||||
|
||||
locale, err := common.ParseLocale(strings.TrimSpace(body.Locale))
|
||||
if err != nil {
|
||||
return LoginCodeDeliveryCommand{}, fmt.Errorf("locale: %w", err)
|
||||
}
|
||||
command.Locale = locale
|
||||
|
||||
if err := command.Validate(); err != nil {
|
||||
return LoginCodeDeliveryCommand{}, err
|
||||
}
|
||||
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func decodeLoginCodeDeliveryRequest(body io.ReadCloser) (LoginCodeDeliveryRequest, error) {
|
||||
if body == nil {
|
||||
return LoginCodeDeliveryRequest{}, errors.New("request body must not be nil")
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
payload, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
return LoginCodeDeliveryRequest{}, fmt.Errorf("read request body: %w", err)
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
var request LoginCodeDeliveryRequest
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
return LoginCodeDeliveryRequest{}, fmt.Errorf("decode request body: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return LoginCodeDeliveryRequest{}, errors.New("decode request body: unexpected trailing JSON input")
|
||||
}
|
||||
|
||||
return LoginCodeDeliveryRequest{}, fmt.Errorf("decode request body: %w", err)
|
||||
}
|
||||
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func parseIdempotencyKey(value string) (common.IdempotencyKey, error) {
|
||||
switch {
|
||||
case strings.TrimSpace(value) == "":
|
||||
return "", errors.New("Idempotency-Key header must not be empty")
|
||||
case strings.TrimSpace(value) != value:
|
||||
return "", errors.New("Idempotency-Key header must not contain surrounding whitespace")
|
||||
default:
|
||||
key := common.IdempotencyKey(value)
|
||||
if err := key.Validate(); err != nil {
|
||||
return "", fmt.Errorf("idempotency key: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateJSONContentType(value string) error {
|
||||
mediaType, _, err := mime.ParseMediaType(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Content-Type must be %s", jsonMediaType)
|
||||
}
|
||||
if mediaType != jsonMediaType {
|
||||
return fmt.Errorf("Content-Type must be %s", jsonMediaType)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDecodeLoginCodeDeliveryCommandSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, LoginCodeDeliveriesPath, strings.NewReader(`{"email":" pilot@example.com ","code":"123456","locale":" en "}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set(IdempotencyKeyHeader, "challenge-1")
|
||||
|
||||
command, err := DecodeLoginCodeDeliveryCommand(request)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, LoginCodeDeliveryCommand{
|
||||
IdempotencyKey: common.IdempotencyKey("challenge-1"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
Code: "123456",
|
||||
Locale: common.Locale("en"),
|
||||
}, command)
|
||||
}
|
||||
|
||||
func TestDecodeLoginCodeDeliveryCommandRejectsInvalidRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
contentType string
|
||||
headerValue string
|
||||
body string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing content type",
|
||||
headerValue: "challenge-1",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"en"}`,
|
||||
wantErr: "Content-Type must be application/json",
|
||||
},
|
||||
{
|
||||
name: "missing idempotency key",
|
||||
contentType: "application/json",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"en"}`,
|
||||
wantErr: "Idempotency-Key header must not be empty",
|
||||
},
|
||||
{
|
||||
name: "idempotency key surrounding whitespace",
|
||||
contentType: "application/json",
|
||||
headerValue: " challenge-1 ",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"en"}`,
|
||||
wantErr: "Idempotency-Key header must not contain surrounding whitespace",
|
||||
},
|
||||
{
|
||||
name: "unknown field",
|
||||
contentType: "application/json",
|
||||
headerValue: "challenge-1",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"en","extra":true}`,
|
||||
wantErr: "decode request body",
|
||||
},
|
||||
{
|
||||
name: "trailing json",
|
||||
contentType: "application/json",
|
||||
headerValue: "challenge-1",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"en"}{}`,
|
||||
wantErr: "unexpected trailing JSON input",
|
||||
},
|
||||
{
|
||||
name: "code surrounding whitespace",
|
||||
contentType: "application/json",
|
||||
headerValue: "challenge-1",
|
||||
body: `{"email":"pilot@example.com","code":" 123456 ","locale":"en"}`,
|
||||
wantErr: "code must not contain surrounding whitespace",
|
||||
},
|
||||
{
|
||||
name: "invalid locale",
|
||||
contentType: "application/json",
|
||||
headerValue: "challenge-1",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"english"}`,
|
||||
wantErr: "locale:",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, LoginCodeDeliveriesPath, strings.NewReader(tt.body))
|
||||
if tt.contentType != "" {
|
||||
request.Header.Set("Content-Type", tt.contentType)
|
||||
}
|
||||
if tt.headerValue != "" {
|
||||
request.Header.Set(IdempotencyKeyHeader, tt.headerValue)
|
||||
}
|
||||
|
||||
_, err := DecodeLoginCodeDeliveryCommand(request)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeLoginCodeDeliveryCommandRepeatedEquivalentRequestsMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first := httptest.NewRequest(http.MethodPost, LoginCodeDeliveriesPath, strings.NewReader(`{"email":"pilot@example.com","code":"123456","locale":"en"}`))
|
||||
first.Header.Set("Content-Type", "application/json")
|
||||
first.Header.Set(IdempotencyKeyHeader, "challenge-1")
|
||||
|
||||
second := httptest.NewRequest(http.MethodPost, LoginCodeDeliveriesPath, strings.NewReader(`{"email":" pilot@example.com ","code":"123456","locale":" en "}`))
|
||||
second.Header.Set("Content-Type", "application/json")
|
||||
second.Header.Set(IdempotencyKeyHeader, "challenge-1")
|
||||
|
||||
firstCommand, err := DecodeLoginCodeDeliveryCommand(first)
|
||||
require.NoError(t, err)
|
||||
secondCommand, err := DecodeLoginCodeDeliveryCommand(second)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, firstCommand, secondCommand)
|
||||
}
|
||||
|
||||
func TestLoginCodeDeliveryCommandFingerprintStableForEquivalentRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first := LoginCodeDeliveryCommand{
|
||||
IdempotencyKey: common.IdempotencyKey("challenge-1"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
Code: "123456",
|
||||
Locale: common.Locale("en"),
|
||||
}
|
||||
second := LoginCodeDeliveryCommand{
|
||||
IdempotencyKey: common.IdempotencyKey("challenge-1"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
Code: "123456",
|
||||
Locale: common.Locale("en"),
|
||||
}
|
||||
|
||||
firstFingerprint, err := first.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
secondFingerprint, err := second.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, firstFingerprint, secondFingerprint)
|
||||
}
|
||||
|
||||
func TestLoginCodeDeliveryResponseValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.NoError(t, LoginCodeDeliveryResponse{Outcome: LoginCodeDeliveryOutcomeSent}.Validate())
|
||||
require.NoError(t, LoginCodeDeliveryResponse{Outcome: LoginCodeDeliveryOutcomeSuppressed}.Validate())
|
||||
|
||||
err := LoginCodeDeliveryResponse{Outcome: LoginCodeDeliveryOutcome("queued")}.Validate()
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "unsupported")
|
||||
}
|
||||
|
||||
func TestErrorResponseValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.NoError(t, ErrorResponse{
|
||||
Error: ErrorBody{
|
||||
Code: ErrorCodeInvalidRequest,
|
||||
Message: "field-specific validation detail",
|
||||
},
|
||||
}.Validate())
|
||||
|
||||
err := ErrorResponse{
|
||||
Error: ErrorBody{
|
||||
Code: " invalid_request ",
|
||||
Message: "",
|
||||
},
|
||||
}.Validate()
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "error code")
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"galaxy/mail/internal/service/acceptauthdelivery"
|
||||
)
|
||||
|
||||
// AcceptLoginCodeDeliveryUseCase accepts one auth login-code delivery request.
|
||||
type AcceptLoginCodeDeliveryUseCase interface {
|
||||
// Execute durably accepts one normalized auth login-code delivery command.
|
||||
Execute(context.Context, acceptauthdelivery.Input) (acceptauthdelivery.Result, error)
|
||||
}
|
||||
|
||||
func newAcceptLoginCodeDeliveryHandler(useCase AcceptLoginCodeDeliveryUseCase) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
ctx := request.Context()
|
||||
|
||||
command, err := DecodeLoginCodeDeliveryCommand(request)
|
||||
if err != nil {
|
||||
writeErrorResponse(writer, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := useCase.Execute(ctx, acceptauthdelivery.Input{
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
Email: command.Email,
|
||||
Code: command.Code,
|
||||
Locale: command.Locale,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, acceptauthdelivery.ErrConflict):
|
||||
writeErrorResponse(writer, http.StatusConflict, ErrorCodeConflict, "request conflicts with current state")
|
||||
case errors.Is(err, acceptauthdelivery.ErrServiceUnavailable):
|
||||
writeErrorResponse(writer, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "service is unavailable")
|
||||
default:
|
||||
writeErrorResponse(writer, http.StatusInternalServerError, ErrorCodeInternalError, "internal server error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := result.Validate(); err != nil {
|
||||
writeErrorResponse(writer, http.StatusInternalServerError, ErrorCodeInternalError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
response := LoginCodeDeliveryResponse{
|
||||
Outcome: LoginCodeDeliveryOutcome(result.Outcome),
|
||||
}
|
||||
if err := response.Validate(); err != nil {
|
||||
writeErrorResponse(writer, http.StatusInternalServerError, ErrorCodeInternalError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(writer).Encode(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"galaxy/mail/internal/service/acceptauthdelivery"
|
||||
mailtelemetry "galaxy/mail/internal/telemetry"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
)
|
||||
|
||||
func TestLoginCodeDeliveryHandlerReturnsSuccessOutcomes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
result acceptauthdelivery.Result
|
||||
wantOutcome string
|
||||
}{
|
||||
{name: "sent", result: acceptauthdelivery.Result{Outcome: acceptauthdelivery.OutcomeSent}, wantOutcome: "sent"},
|
||||
{name: "suppressed", result: acceptauthdelivery.Result{Outcome: acceptauthdelivery.OutcomeSuppressed}, wantOutcome: "suppressed"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newHandler(Dependencies{
|
||||
Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)),
|
||||
AcceptLoginCodeDelivery: acceptLoginCodeDeliveryFunc(func(context.Context, acceptauthdelivery.Input) (acceptauthdelivery.Result, error) {
|
||||
return tt.result, nil
|
||||
}),
|
||||
})
|
||||
|
||||
response := doLoginCodeDeliveryRequest(t, handler, `{"email":"pilot@example.com","code":"123456","locale":"en"}`, "challenge-1")
|
||||
defer response.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusOK, response.StatusCode)
|
||||
require.Equal(t, "application/json", response.Header.Get("Content-Type"))
|
||||
|
||||
var payload LoginCodeDeliveryResponse
|
||||
require.NoError(t, decodeJSONBody(response, &payload))
|
||||
require.Equal(t, LoginCodeDeliveryOutcome(tt.wantOutcome), payload.Outcome)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCodeDeliveryHandlerMapsErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
header string
|
||||
useCaseErr error
|
||||
wantCode int
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "invalid request",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"en"}`,
|
||||
wantCode: http.StatusBadRequest,
|
||||
wantErr: ErrorCodeInvalidRequest,
|
||||
},
|
||||
{
|
||||
name: "conflict",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"en"}`,
|
||||
header: "challenge-1",
|
||||
useCaseErr: acceptauthdelivery.ErrConflict,
|
||||
wantCode: http.StatusConflict,
|
||||
wantErr: ErrorCodeConflict,
|
||||
},
|
||||
{
|
||||
name: "service unavailable",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"en"}`,
|
||||
header: "challenge-1",
|
||||
useCaseErr: acceptauthdelivery.ErrServiceUnavailable,
|
||||
wantCode: http.StatusServiceUnavailable,
|
||||
wantErr: ErrorCodeServiceUnavailable,
|
||||
},
|
||||
{
|
||||
name: "internal error",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"en"}`,
|
||||
header: "challenge-1",
|
||||
useCaseErr: context.DeadlineExceeded,
|
||||
wantCode: http.StatusInternalServerError,
|
||||
wantErr: ErrorCodeInternalError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newHandler(Dependencies{
|
||||
Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)),
|
||||
AcceptLoginCodeDelivery: acceptLoginCodeDeliveryFunc(func(context.Context, acceptauthdelivery.Input) (acceptauthdelivery.Result, error) {
|
||||
if tt.useCaseErr != nil {
|
||||
return acceptauthdelivery.Result{}, tt.useCaseErr
|
||||
}
|
||||
|
||||
return acceptauthdelivery.Result{Outcome: acceptauthdelivery.OutcomeSent}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
response := doLoginCodeDeliveryRequest(t, handler, tt.body, tt.header)
|
||||
defer response.Body.Close()
|
||||
|
||||
require.Equal(t, tt.wantCode, response.StatusCode)
|
||||
|
||||
var payload ErrorResponse
|
||||
require.NoError(t, decodeJSONBody(response, &payload))
|
||||
require.Equal(t, tt.wantErr, payload.Error.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCodeDeliveryHandlerEmitsMetricsAndSpan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reader := sdkmetric.NewManualReader()
|
||||
meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
||||
|
||||
telemetryRuntime, err := mailtelemetry.NewWithProviders(meterProvider, tracerProvider)
|
||||
require.NoError(t, err)
|
||||
|
||||
loggerBuffer := &bytes.Buffer{}
|
||||
logger := slog.New(slog.NewJSONHandler(loggerBuffer, nil))
|
||||
handler := newHandler(Dependencies{
|
||||
Logger: logger,
|
||||
Telemetry: telemetryRuntime,
|
||||
AcceptLoginCodeDelivery: acceptLoginCodeDeliveryFunc(func(context.Context, acceptauthdelivery.Input) (acceptauthdelivery.Result, error) {
|
||||
return acceptauthdelivery.Result{Outcome: acceptauthdelivery.OutcomeSent}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
response := doLoginCodeDeliveryRequest(t, handler, `{"email":"pilot@example.com","code":"123456","locale":"en"}`, "challenge-1")
|
||||
defer response.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusOK, response.StatusCode)
|
||||
require.Len(t, recorder.Ended(), 1)
|
||||
assert.Equal(t, LoginCodeDeliveriesPath, recorder.Ended()[0].Name())
|
||||
assert.Contains(t, loggerBuffer.String(), "otel_trace_id")
|
||||
assert.Contains(t, loggerBuffer.String(), "otel_span_id")
|
||||
|
||||
assertMetricCount(t, reader, "mail.internal_http.requests", map[string]string{
|
||||
"route": LoginCodeDeliveriesPath,
|
||||
"method": http.MethodPost,
|
||||
"edge_outcome": "success",
|
||||
}, 1)
|
||||
}
|
||||
|
||||
type acceptLoginCodeDeliveryFunc func(context.Context, acceptauthdelivery.Input) (acceptauthdelivery.Result, error)
|
||||
|
||||
func (fn acceptLoginCodeDeliveryFunc) Execute(ctx context.Context, input acceptauthdelivery.Input) (acceptauthdelivery.Result, error) {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
|
||||
func doLoginCodeDeliveryRequest(t *testing.T, handler http.Handler, body string, idempotencyKey string) *http.Response {
|
||||
t.Helper()
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, LoginCodeDeliveriesPath, bytes.NewBufferString(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if idempotencyKey != "" {
|
||||
request.Header.Set(IdempotencyKeyHeader, idempotencyKey)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
return recorder.Result()
|
||||
}
|
||||
|
||||
func decodeJSONBody(response *http.Response, target any) error {
|
||||
return json.NewDecoder(response.Body).Decode(target)
|
||||
}
|
||||
|
||||
func assertMetricCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantValue int64) {
|
||||
t.Helper()
|
||||
|
||||
var resourceMetrics metricdata.ResourceMetrics
|
||||
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
|
||||
|
||||
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
|
||||
for _, metric := range scopeMetrics.Metrics {
|
||||
if metric.Name != metricName {
|
||||
continue
|
||||
}
|
||||
|
||||
sum, ok := metric.Data.(metricdata.Sum[int64])
|
||||
require.True(t, ok)
|
||||
|
||||
for _, point := range sum.DataPoints {
|
||||
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
|
||||
assert.Equal(t, wantValue, point.Value)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "test failed", "metric %q with attrs %v not found", metricName, wantAttrs)
|
||||
}
|
||||
|
||||
func hasMetricAttributes(values []attribute.KeyValue, want map[string]string) bool {
|
||||
if len(values) != len(want) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
if want[string(value.Key)] != value.Value.AsString() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/logging"
|
||||
"galaxy/mail/internal/telemetry"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
)
|
||||
|
||||
type edgeOutcome string
|
||||
|
||||
const (
|
||||
edgeOutcomeSuccess edgeOutcome = "success"
|
||||
edgeOutcomeRejected edgeOutcome = "rejected"
|
||||
edgeOutcomeFailed edgeOutcome = "failed"
|
||||
)
|
||||
|
||||
func instrumentRoute(route string, logger *slog.Logger, telemetryRuntime *telemetry.Runtime, next http.Handler) http.Handler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
startedAt := time.Now()
|
||||
recorder := &observedResponseWriter{
|
||||
ResponseWriter: writer,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
|
||||
next.ServeHTTP(recorder, request)
|
||||
|
||||
duration := time.Since(startedAt)
|
||||
outcome := outcomeFromStatusCode(recorder.statusCode)
|
||||
attrs := []attribute.KeyValue{
|
||||
attribute.String("route", route),
|
||||
attribute.String("method", request.Method),
|
||||
attribute.String("edge_outcome", string(outcome)),
|
||||
}
|
||||
if recorder.errorCode != "" {
|
||||
attrs = append(attrs, attribute.String("error_code", recorder.errorCode))
|
||||
}
|
||||
if telemetryRuntime != nil {
|
||||
telemetryRuntime.RecordInternalHTTPRequest(request.Context(), attrs, duration)
|
||||
}
|
||||
|
||||
logArgs := []any{
|
||||
"component", "internal_http",
|
||||
"transport", "http",
|
||||
"route", route,
|
||||
"method", request.Method,
|
||||
"status_code", recorder.statusCode,
|
||||
"duration_ms", float64(duration.Microseconds()) / 1000,
|
||||
"edge_outcome", string(outcome),
|
||||
}
|
||||
if recorder.errorCode != "" {
|
||||
logArgs = append(logArgs, "error_code", recorder.errorCode)
|
||||
}
|
||||
logArgs = append(logArgs, logging.TraceAttrsFromContext(request.Context())...)
|
||||
|
||||
switch outcome {
|
||||
case edgeOutcomeSuccess:
|
||||
logger.Info("internal request completed", logArgs...)
|
||||
case edgeOutcomeFailed:
|
||||
logger.Error("internal request failed", logArgs...)
|
||||
default:
|
||||
logger.Warn("internal request rejected", logArgs...)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type observedResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
||||
statusCode int
|
||||
errorCode string
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (writer *observedResponseWriter) WriteHeader(statusCode int) {
|
||||
if writer.wroteHeader {
|
||||
return
|
||||
}
|
||||
|
||||
writer.statusCode = statusCode
|
||||
writer.wroteHeader = true
|
||||
writer.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (writer *observedResponseWriter) Write(payload []byte) (int, error) {
|
||||
if !writer.wroteHeader {
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
return writer.ResponseWriter.Write(payload)
|
||||
}
|
||||
|
||||
func (writer *observedResponseWriter) SetErrorCode(code string) {
|
||||
writer.errorCode = code
|
||||
}
|
||||
|
||||
func outcomeFromStatusCode(statusCode int) edgeOutcome {
|
||||
switch {
|
||||
case statusCode >= 500:
|
||||
return edgeOutcomeFailed
|
||||
case statusCode >= 400:
|
||||
return edgeOutcomeRejected
|
||||
default:
|
||||
return edgeOutcomeSuccess
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/service/listdeliveries"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrorCodeDeliveryNotFound identifies a missing trusted delivery lookup
|
||||
// target.
|
||||
ErrorCodeDeliveryNotFound = "delivery_not_found"
|
||||
|
||||
// ErrorCodeResendNotAllowed identifies resend requests against non-terminal
|
||||
// deliveries.
|
||||
ErrorCodeResendNotAllowed = "resend_not_allowed"
|
||||
|
||||
deliveryIDPathValue = "delivery_id"
|
||||
)
|
||||
|
||||
// DeliveryListQuery stores the raw trusted query-string values accepted by the
|
||||
// operator delivery-list route before normalization.
|
||||
type DeliveryListQuery struct {
|
||||
// Recipient stores the optional recipient filter covering `to`, `cc`, and
|
||||
// `bcc`.
|
||||
Recipient string
|
||||
|
||||
// Status stores the optional delivery-status filter.
|
||||
Status string
|
||||
|
||||
// Source stores the optional delivery-source filter.
|
||||
Source string
|
||||
|
||||
// TemplateID stores the optional template-family filter.
|
||||
TemplateID string
|
||||
|
||||
// IdempotencyKey stores the optional idempotency-key filter.
|
||||
IdempotencyKey string
|
||||
|
||||
// FromCreatedAtMS stores the optional inclusive lower creation-time bound.
|
||||
FromCreatedAtMS string
|
||||
|
||||
// ToCreatedAtMS stores the optional inclusive upper creation-time bound.
|
||||
ToCreatedAtMS string
|
||||
|
||||
// Limit stores the optional page size.
|
||||
Limit string
|
||||
|
||||
// Cursor stores the optional opaque continuation cursor.
|
||||
Cursor string
|
||||
}
|
||||
|
||||
// DeliverySummaryResponse stores one brief operator-facing delivery record.
|
||||
type DeliverySummaryResponse struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
ResendParentDeliveryID string `json:"resend_parent_delivery_id,omitempty"`
|
||||
Source string `json:"source"`
|
||||
PayloadMode string `json:"payload_mode"`
|
||||
TemplateID string `json:"template_id,omitempty"`
|
||||
To []string `json:"to"`
|
||||
Cc []string `json:"cc"`
|
||||
Bcc []string `json:"bcc"`
|
||||
ReplyTo []string `json:"reply_to"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
LocaleFallbackUsed bool `json:"locale_fallback_used"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
Status string `json:"status"`
|
||||
AttemptCount int `json:"attempt_count"`
|
||||
LastAttemptStatus string `json:"last_attempt_status,omitempty"`
|
||||
ProviderSummary string `json:"provider_summary,omitempty"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
SentAtMS *int64 `json:"sent_at_ms,omitempty"`
|
||||
SuppressedAtMS *int64 `json:"suppressed_at_ms,omitempty"`
|
||||
FailedAtMS *int64 `json:"failed_at_ms,omitempty"`
|
||||
DeadLetteredAtMS *int64 `json:"dead_lettered_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
// DeliveryListResponse stores one deterministic page of brief delivery
|
||||
// summaries.
|
||||
type DeliveryListResponse struct {
|
||||
Items []DeliverySummaryResponse `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
}
|
||||
|
||||
// AttachmentResponse stores one durable attachment audit record.
|
||||
type AttachmentResponse struct {
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
// DeadLetterResponse stores one operator-visible dead-letter entry.
|
||||
type DeadLetterResponse struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
FinalAttemptNo int `json:"final_attempt_no"`
|
||||
FailureClassification string `json:"failure_classification"`
|
||||
ProviderSummary string `json:"provider_summary,omitempty"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
RecoveryHint string `json:"recovery_hint,omitempty"`
|
||||
}
|
||||
|
||||
// DeliveryDetailResponse stores one full operator-facing delivery view.
|
||||
type DeliveryDetailResponse struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
ResendParentDeliveryID string `json:"resend_parent_delivery_id,omitempty"`
|
||||
Source string `json:"source"`
|
||||
PayloadMode string `json:"payload_mode"`
|
||||
TemplateID string `json:"template_id,omitempty"`
|
||||
TemplateVariables map[string]any `json:"template_variables,omitempty"`
|
||||
To []string `json:"to"`
|
||||
Cc []string `json:"cc"`
|
||||
Bcc []string `json:"bcc"`
|
||||
ReplyTo []string `json:"reply_to"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
TextBody string `json:"text_body,omitempty"`
|
||||
HTMLBody string `json:"html_body,omitempty"`
|
||||
Attachments []AttachmentResponse `json:"attachments"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
LocaleFallbackUsed bool `json:"locale_fallback_used"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
Status string `json:"status"`
|
||||
AttemptCount int `json:"attempt_count"`
|
||||
LastAttemptStatus string `json:"last_attempt_status,omitempty"`
|
||||
ProviderSummary string `json:"provider_summary,omitempty"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
SentAtMS *int64 `json:"sent_at_ms,omitempty"`
|
||||
SuppressedAtMS *int64 `json:"suppressed_at_ms,omitempty"`
|
||||
FailedAtMS *int64 `json:"failed_at_ms,omitempty"`
|
||||
DeadLetteredAtMS *int64 `json:"dead_lettered_at_ms,omitempty"`
|
||||
DeadLetter *DeadLetterResponse `json:"dead_letter,omitempty"`
|
||||
}
|
||||
|
||||
// AttemptResponse stores one operator-facing delivery-attempt record.
|
||||
type AttemptResponse struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
AttemptNo int `json:"attempt_no"`
|
||||
ScheduledForMS int64 `json:"scheduled_for_ms"`
|
||||
StartedAtMS *int64 `json:"started_at_ms,omitempty"`
|
||||
FinishedAtMS *int64 `json:"finished_at_ms,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ProviderClassification string `json:"provider_classification,omitempty"`
|
||||
ProviderSummary string `json:"provider_summary,omitempty"`
|
||||
}
|
||||
|
||||
// DeliveryAttemptsResponse stores the attempt history of one accepted
|
||||
// delivery.
|
||||
type DeliveryAttemptsResponse struct {
|
||||
Items []AttemptResponse `json:"items"`
|
||||
}
|
||||
|
||||
// DeliveryResendResponse stores the identifier of the clone delivery created
|
||||
// by one resend request.
|
||||
type DeliveryResendResponse struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
}
|
||||
|
||||
// DecodeDeliveryListInput validates one trusted operator delivery-list
|
||||
// request and returns the normalized list input.
|
||||
func DecodeDeliveryListInput(request *http.Request) (listdeliveries.Input, error) {
|
||||
if request == nil {
|
||||
return listdeliveries.Input{}, errors.New("delivery list request must not be nil")
|
||||
}
|
||||
|
||||
query, err := decodeDeliveryListQuery(request)
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
|
||||
input, err := query.Normalize()
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// DecodeDeliveryIDFromPath validates one trusted path delivery identifier.
|
||||
func DecodeDeliveryIDFromPath(request *http.Request) (common.DeliveryID, error) {
|
||||
if request == nil {
|
||||
return "", errors.New("delivery lookup request must not be nil")
|
||||
}
|
||||
|
||||
return parseDeliveryID(request.PathValue(deliveryIDPathValue))
|
||||
}
|
||||
|
||||
// EncodeDeliveryListCursor encodes cursor into the frozen opaque base64url
|
||||
// format `created_at_ms:delivery_id`.
|
||||
func EncodeDeliveryListCursor(cursor listdeliveries.Cursor) (string, error) {
|
||||
if err := cursor.Validate(); err != nil {
|
||||
return "", fmt.Errorf("encode delivery list cursor: %w", err)
|
||||
}
|
||||
|
||||
payload := fmt.Sprintf("%d:%s", cursor.CreatedAt.UTC().UnixMilli(), cursor.DeliveryID.String())
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(payload)), nil
|
||||
}
|
||||
|
||||
// DecodeDeliveryListCursor decodes raw from the frozen opaque cursor format.
|
||||
func DecodeDeliveryListCursor(raw string) (listdeliveries.Cursor, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return listdeliveries.Cursor{}, errors.New("cursor must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(raw) != raw {
|
||||
return listdeliveries.Cursor{}, errors.New("cursor must not contain surrounding whitespace")
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return listdeliveries.Cursor{}, fmt.Errorf("decode cursor: %w", err)
|
||||
}
|
||||
|
||||
createdAtRaw, deliveryIDRaw, ok := strings.Cut(string(payload), ":")
|
||||
if !ok {
|
||||
return listdeliveries.Cursor{}, errors.New("decode cursor: invalid cursor payload")
|
||||
}
|
||||
|
||||
createdAtMS, err := strconv.ParseInt(createdAtRaw, 10, 64)
|
||||
if err != nil {
|
||||
return listdeliveries.Cursor{}, fmt.Errorf("decode cursor created_at_ms: %w", err)
|
||||
}
|
||||
|
||||
cursor := listdeliveries.Cursor{
|
||||
CreatedAt: time.UnixMilli(createdAtMS).UTC(),
|
||||
DeliveryID: common.DeliveryID(deliveryIDRaw),
|
||||
}
|
||||
if err := cursor.Validate(); err != nil {
|
||||
return listdeliveries.Cursor{}, fmt.Errorf("decode cursor: %w", err)
|
||||
}
|
||||
|
||||
return cursor, nil
|
||||
}
|
||||
|
||||
// Normalize converts the raw trusted query-string shape into the operator list
|
||||
// input consumed by the service layer.
|
||||
func (query DeliveryListQuery) Normalize() (listdeliveries.Input, error) {
|
||||
var input listdeliveries.Input
|
||||
|
||||
recipient, err := parseOptionalEmail(query.Recipient, "recipient")
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
status, err := parseOptionalStatus(query.Status)
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
source, err := parseOptionalSource(query.Source)
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
templateID, err := parseOptionalTemplateID(query.TemplateID)
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
idempotencyKey, err := parseOptionalIdempotencyKey(query.IdempotencyKey)
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
fromCreatedAt, err := parseOptionalUnixMilli(query.FromCreatedAtMS, "from_created_at_ms")
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
toCreatedAt, err := parseOptionalUnixMilli(query.ToCreatedAtMS, "to_created_at_ms")
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
limit, err := parseOptionalLimit(query.Limit)
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
cursor, err := parseOptionalCursor(query.Cursor)
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
|
||||
input = listdeliveries.Input{
|
||||
Limit: limit,
|
||||
Cursor: cursor,
|
||||
Filters: listdeliveries.Filters{
|
||||
Recipient: recipient,
|
||||
Status: status,
|
||||
Source: source,
|
||||
TemplateID: templateID,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
FromCreatedAt: fromCreatedAt,
|
||||
ToCreatedAt: toCreatedAt,
|
||||
},
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func decodeDeliveryListQuery(request *http.Request) (DeliveryListQuery, error) {
|
||||
values := request.URL.Query()
|
||||
|
||||
recipient, err := singleQueryValue(values, "recipient")
|
||||
if err != nil {
|
||||
return DeliveryListQuery{}, err
|
||||
}
|
||||
status, err := singleQueryValue(values, "status")
|
||||
if err != nil {
|
||||
return DeliveryListQuery{}, err
|
||||
}
|
||||
source, err := singleQueryValue(values, "source")
|
||||
if err != nil {
|
||||
return DeliveryListQuery{}, err
|
||||
}
|
||||
templateID, err := singleQueryValue(values, "template_id")
|
||||
if err != nil {
|
||||
return DeliveryListQuery{}, err
|
||||
}
|
||||
idempotencyKey, err := singleQueryValue(values, "idempotency_key")
|
||||
if err != nil {
|
||||
return DeliveryListQuery{}, err
|
||||
}
|
||||
fromCreatedAtMS, err := singleQueryValue(values, "from_created_at_ms")
|
||||
if err != nil {
|
||||
return DeliveryListQuery{}, err
|
||||
}
|
||||
toCreatedAtMS, err := singleQueryValue(values, "to_created_at_ms")
|
||||
if err != nil {
|
||||
return DeliveryListQuery{}, err
|
||||
}
|
||||
limit, err := singleQueryValue(values, "limit")
|
||||
if err != nil {
|
||||
return DeliveryListQuery{}, err
|
||||
}
|
||||
cursor, err := singleQueryValue(values, "cursor")
|
||||
if err != nil {
|
||||
return DeliveryListQuery{}, err
|
||||
}
|
||||
|
||||
return DeliveryListQuery{
|
||||
Recipient: recipient,
|
||||
Status: status,
|
||||
Source: source,
|
||||
TemplateID: templateID,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
FromCreatedAtMS: fromCreatedAtMS,
|
||||
ToCreatedAtMS: toCreatedAtMS,
|
||||
Limit: limit,
|
||||
Cursor: cursor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func singleQueryValue(values map[string][]string, key string) (string, error) {
|
||||
rawValues := values[key]
|
||||
switch len(rawValues) {
|
||||
case 0:
|
||||
return "", nil
|
||||
case 1:
|
||||
return rawValues[0], nil
|
||||
default:
|
||||
return "", fmt.Errorf("query parameter %q must appear at most once", key)
|
||||
}
|
||||
}
|
||||
|
||||
func parseDeliveryID(raw string) (common.DeliveryID, error) {
|
||||
deliveryID := common.DeliveryID(raw)
|
||||
if err := deliveryID.Validate(); err != nil {
|
||||
return "", fmt.Errorf("delivery id: %w", err)
|
||||
}
|
||||
|
||||
return deliveryID, nil
|
||||
}
|
||||
|
||||
func parseOptionalEmail(raw string, name string) (common.Email, error) {
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
email := common.Email(strings.TrimSpace(raw))
|
||||
if err := email.Validate(); err != nil {
|
||||
return "", fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func parseOptionalStatus(raw string) (deliverydomain.Status, error) {
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
status := deliverydomain.Status(strings.TrimSpace(raw))
|
||||
if !status.IsKnown() {
|
||||
return "", fmt.Errorf("status %q is unsupported", raw)
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func parseOptionalSource(raw string) (deliverydomain.Source, error) {
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
source := deliverydomain.Source(strings.TrimSpace(raw))
|
||||
if !source.IsKnown() {
|
||||
return "", fmt.Errorf("source %q is unsupported", raw)
|
||||
}
|
||||
|
||||
return source, nil
|
||||
}
|
||||
|
||||
func parseOptionalTemplateID(raw string) (common.TemplateID, error) {
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
templateID := common.TemplateID(strings.TrimSpace(raw))
|
||||
if err := templateID.Validate(); err != nil {
|
||||
return "", fmt.Errorf("template id: %w", err)
|
||||
}
|
||||
|
||||
return templateID, nil
|
||||
}
|
||||
|
||||
func parseOptionalIdempotencyKey(raw string) (common.IdempotencyKey, error) {
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
key := common.IdempotencyKey(strings.TrimSpace(raw))
|
||||
if err := key.Validate(); err != nil {
|
||||
return "", fmt.Errorf("idempotency key: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func parseOptionalUnixMilli(raw string, name string) (*time.Time, error) {
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
timestamp := time.UnixMilli(value).UTC()
|
||||
if err := common.ValidateTimestamp(name, timestamp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ×tamp, nil
|
||||
}
|
||||
|
||||
func parseOptionalLimit(raw string) (int, error) {
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("limit: %w", err)
|
||||
}
|
||||
if value < 1 {
|
||||
return 0, errors.New("limit must be at least 1")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseOptionalCursor(raw string) (*listdeliveries.Cursor, error) {
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cursor, err := DecodeDeliveryListCursor(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cursor, nil
|
||||
}
|
||||
|
||||
func summaryResponseFromDelivery(record deliverydomain.Delivery) DeliverySummaryResponse {
|
||||
return DeliverySummaryResponse{
|
||||
DeliveryID: record.DeliveryID.String(),
|
||||
ResendParentDeliveryID: record.ResendParentDeliveryID.String(),
|
||||
Source: string(record.Source),
|
||||
PayloadMode: string(record.PayloadMode),
|
||||
TemplateID: record.TemplateID.String(),
|
||||
To: emailStrings(record.Envelope.To),
|
||||
Cc: emailStrings(record.Envelope.Cc),
|
||||
Bcc: emailStrings(record.Envelope.Bcc),
|
||||
ReplyTo: emailStrings(record.Envelope.ReplyTo),
|
||||
Locale: record.Locale.String(),
|
||||
LocaleFallbackUsed: record.LocaleFallbackUsed,
|
||||
IdempotencyKey: record.IdempotencyKey.String(),
|
||||
Status: string(record.Status),
|
||||
AttemptCount: record.AttemptCount,
|
||||
LastAttemptStatus: string(record.LastAttemptStatus),
|
||||
ProviderSummary: record.ProviderSummary,
|
||||
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
|
||||
UpdatedAtMS: record.UpdatedAt.UTC().UnixMilli(),
|
||||
SentAtMS: unixMilliPtr(record.SentAt),
|
||||
SuppressedAtMS: unixMilliPtr(record.SuppressedAt),
|
||||
FailedAtMS: unixMilliPtr(record.FailedAt),
|
||||
DeadLetteredAtMS: unixMilliPtr(record.DeadLetteredAt),
|
||||
}
|
||||
}
|
||||
|
||||
func detailResponseFromDelivery(record deliverydomain.Delivery, deadLetter *deliverydomain.DeadLetterEntry) DeliveryDetailResponse {
|
||||
response := DeliveryDetailResponse{
|
||||
DeliveryID: record.DeliveryID.String(),
|
||||
ResendParentDeliveryID: record.ResendParentDeliveryID.String(),
|
||||
Source: string(record.Source),
|
||||
PayloadMode: string(record.PayloadMode),
|
||||
TemplateID: record.TemplateID.String(),
|
||||
TemplateVariables: cloneJSONObject(record.TemplateVariables),
|
||||
To: emailStrings(record.Envelope.To),
|
||||
Cc: emailStrings(record.Envelope.Cc),
|
||||
Bcc: emailStrings(record.Envelope.Bcc),
|
||||
ReplyTo: emailStrings(record.Envelope.ReplyTo),
|
||||
Subject: record.Content.Subject,
|
||||
TextBody: record.Content.TextBody,
|
||||
HTMLBody: record.Content.HTMLBody,
|
||||
Attachments: attachmentResponses(record.Attachments),
|
||||
Locale: record.Locale.String(),
|
||||
LocaleFallbackUsed: record.LocaleFallbackUsed,
|
||||
IdempotencyKey: record.IdempotencyKey.String(),
|
||||
Status: string(record.Status),
|
||||
AttemptCount: record.AttemptCount,
|
||||
LastAttemptStatus: string(record.LastAttemptStatus),
|
||||
ProviderSummary: record.ProviderSummary,
|
||||
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
|
||||
UpdatedAtMS: record.UpdatedAt.UTC().UnixMilli(),
|
||||
SentAtMS: unixMilliPtr(record.SentAt),
|
||||
SuppressedAtMS: unixMilliPtr(record.SuppressedAt),
|
||||
FailedAtMS: unixMilliPtr(record.FailedAt),
|
||||
DeadLetteredAtMS: unixMilliPtr(record.DeadLetteredAt),
|
||||
}
|
||||
if deadLetter != nil {
|
||||
response.DeadLetter = &DeadLetterResponse{
|
||||
DeliveryID: deadLetter.DeliveryID.String(),
|
||||
FinalAttemptNo: deadLetter.FinalAttemptNo,
|
||||
FailureClassification: deadLetter.FailureClassification,
|
||||
ProviderSummary: deadLetter.ProviderSummary,
|
||||
CreatedAtMS: deadLetter.CreatedAt.UTC().UnixMilli(),
|
||||
RecoveryHint: deadLetter.RecoveryHint,
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
func attemptResponseFromRecord(record attempt.Attempt) AttemptResponse {
|
||||
return AttemptResponse{
|
||||
DeliveryID: record.DeliveryID.String(),
|
||||
AttemptNo: record.AttemptNo,
|
||||
ScheduledForMS: record.ScheduledFor.UTC().UnixMilli(),
|
||||
StartedAtMS: unixMilliPtr(record.StartedAt),
|
||||
FinishedAtMS: unixMilliPtr(record.FinishedAt),
|
||||
Status: string(record.Status),
|
||||
ProviderClassification: record.ProviderClassification,
|
||||
ProviderSummary: record.ProviderSummary,
|
||||
}
|
||||
}
|
||||
|
||||
func attachmentResponses(attachments []common.AttachmentMetadata) []AttachmentResponse {
|
||||
if len(attachments) == 0 {
|
||||
return []AttachmentResponse{}
|
||||
}
|
||||
|
||||
result := make([]AttachmentResponse, len(attachments))
|
||||
for index, attachment := range attachments {
|
||||
result[index] = AttachmentResponse{
|
||||
Filename: attachment.Filename,
|
||||
ContentType: attachment.ContentType,
|
||||
SizeBytes: attachment.SizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func emailStrings(values []common.Email) []string {
|
||||
if len(values) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
result := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
result[index] = value.String()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func unixMilliPtr(value *time.Time) *int64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
encoded := value.UTC().UnixMilli()
|
||||
return &encoded
|
||||
}
|
||||
|
||||
func cloneJSONObject(value map[string]any) map[string]any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make(map[string]any, len(value))
|
||||
for key, entry := range value {
|
||||
cloned[key] = entry
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
"galaxy/mail/internal/service/listdeliveries"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDecodeDeliveryListInputSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cursor, err := EncodeDeliveryListCursor(listdeliveries.Cursor{
|
||||
CreatedAt: time.Unix(1_775_122_000, 0).UTC(),
|
||||
DeliveryID: common.DeliveryID("delivery-123"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
DeliveriesPath+"?recipient=pilot@example.com&status=sent&source=notification&template_id=template.welcome&idempotency_key=notification:delivery-123&from_created_at_ms=1775122000000&to_created_at_ms=1775122600000&limit=25&cursor="+cursor,
|
||||
nil,
|
||||
)
|
||||
|
||||
input, err := DecodeDeliveryListInput(request)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 25, input.Limit)
|
||||
require.Equal(t, common.Email("pilot@example.com"), input.Filters.Recipient)
|
||||
require.Equal(t, common.TemplateID("template.welcome"), input.Filters.TemplateID)
|
||||
require.Equal(t, common.IdempotencyKey("notification:delivery-123"), input.Filters.IdempotencyKey)
|
||||
require.NotNil(t, input.Cursor)
|
||||
require.Equal(t, common.DeliveryID("delivery-123"), input.Cursor.DeliveryID)
|
||||
}
|
||||
|
||||
func TestDecodeDeliveryListInputRejectsInvalidCursor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, DeliveriesPath+"?cursor=bad", nil)
|
||||
|
||||
_, err := DecodeDeliveryListInput(request)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "decode cursor")
|
||||
}
|
||||
|
||||
func TestDeliveryListCursorRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cursor := listdeliveries.Cursor{
|
||||
CreatedAt: time.Unix(1_775_122_500, 0).UTC(),
|
||||
DeliveryID: common.DeliveryID("delivery-xyz"),
|
||||
}
|
||||
|
||||
encoded, err := EncodeDeliveryListCursor(cursor)
|
||||
require.NoError(t, err)
|
||||
|
||||
decoded, err := DecodeDeliveryListCursor(encoded)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, cursor, decoded)
|
||||
}
|
||||
|
||||
func TestDecodeDeliveryIDFromPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/deliveries/delivery-123", nil)
|
||||
request.SetPathValue(deliveryIDPathValue, "delivery-123")
|
||||
|
||||
deliveryID, err := DecodeDeliveryIDFromPath(request)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, common.DeliveryID("delivery-123"), deliveryID)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/service/getdelivery"
|
||||
"galaxy/mail/internal/service/listattempts"
|
||||
"galaxy/mail/internal/service/listdeliveries"
|
||||
"galaxy/mail/internal/service/resenddelivery"
|
||||
)
|
||||
|
||||
const defaultOperatorRequestTimeout = 5 * time.Second
|
||||
|
||||
// ListDeliveriesUseCase lists accepted deliveries for trusted operators.
|
||||
type ListDeliveriesUseCase interface {
|
||||
// Execute returns one filtered deterministic ordered page of deliveries.
|
||||
Execute(context.Context, listdeliveries.Input) (listdeliveries.Result, error)
|
||||
}
|
||||
|
||||
// GetDeliveryUseCase resolves one accepted delivery for trusted operators.
|
||||
type GetDeliveryUseCase interface {
|
||||
// Execute returns one exact delivery view and its optional dead-letter
|
||||
// entry.
|
||||
Execute(context.Context, getdelivery.Input) (getdelivery.Result, error)
|
||||
}
|
||||
|
||||
// ListAttemptsUseCase resolves one delivery-attempt history for trusted
|
||||
// operators.
|
||||
type ListAttemptsUseCase interface {
|
||||
// Execute returns the full attempt history of one accepted delivery.
|
||||
Execute(context.Context, listattempts.Input) (listattempts.Result, error)
|
||||
}
|
||||
|
||||
// ResendDeliveryUseCase clones one accepted terminal delivery for trusted
|
||||
// operator resend.
|
||||
type ResendDeliveryUseCase interface {
|
||||
// Execute creates one new clone delivery and returns its identifier.
|
||||
Execute(context.Context, resenddelivery.Input) (resenddelivery.Result, error)
|
||||
}
|
||||
|
||||
func newListDeliveriesHandler(useCase ListDeliveriesUseCase, timeout time.Duration) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
input, err := DecodeDeliveryListInput(request)
|
||||
if err != nil {
|
||||
writeErrorResponse(writer, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(request.Context(), effectiveOperatorTimeout(timeout))
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, input)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, listdeliveries.ErrInvalidCursor):
|
||||
writeErrorResponse(writer, http.StatusBadRequest, ErrorCodeInvalidRequest, "cursor is invalid")
|
||||
case errors.Is(err, listdeliveries.ErrServiceUnavailable):
|
||||
writeErrorResponse(writer, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "service is unavailable")
|
||||
default:
|
||||
writeErrorResponse(writer, http.StatusInternalServerError, ErrorCodeInternalError, "internal server error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
response := DeliveryListResponse{
|
||||
Items: make([]DeliverySummaryResponse, len(result.Items)),
|
||||
}
|
||||
for index, record := range result.Items {
|
||||
response.Items[index] = summaryResponseFromDelivery(record)
|
||||
}
|
||||
if result.NextCursor != nil {
|
||||
encodedCursor, err := EncodeDeliveryListCursor(*result.NextCursor)
|
||||
if err != nil {
|
||||
writeErrorResponse(writer, http.StatusInternalServerError, ErrorCodeInternalError, "internal server error")
|
||||
return
|
||||
}
|
||||
response.NextCursor = encodedCursor
|
||||
}
|
||||
|
||||
writeJSONResponse(writer, http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
|
||||
func newGetDeliveryHandler(useCase GetDeliveryUseCase, timeout time.Duration) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
deliveryID, err := DecodeDeliveryIDFromPath(request)
|
||||
if err != nil {
|
||||
writeErrorResponse(writer, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(request.Context(), effectiveOperatorTimeout(timeout))
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, getdelivery.Input{DeliveryID: deliveryID})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, getdelivery.ErrNotFound):
|
||||
writeErrorResponse(writer, http.StatusNotFound, ErrorCodeDeliveryNotFound, "delivery not found")
|
||||
case errors.Is(err, getdelivery.ErrServiceUnavailable):
|
||||
writeErrorResponse(writer, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "service is unavailable")
|
||||
default:
|
||||
writeErrorResponse(writer, http.StatusInternalServerError, ErrorCodeInternalError, "internal server error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSONResponse(writer, http.StatusOK, detailResponseFromDelivery(result.Delivery, result.DeadLetter))
|
||||
}
|
||||
}
|
||||
|
||||
func newListAttemptsHandler(useCase ListAttemptsUseCase, timeout time.Duration) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
deliveryID, err := DecodeDeliveryIDFromPath(request)
|
||||
if err != nil {
|
||||
writeErrorResponse(writer, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(request.Context(), effectiveOperatorTimeout(timeout))
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, listattempts.Input{DeliveryID: deliveryID})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, listattempts.ErrNotFound):
|
||||
writeErrorResponse(writer, http.StatusNotFound, ErrorCodeDeliveryNotFound, "delivery not found")
|
||||
case errors.Is(err, listattempts.ErrServiceUnavailable):
|
||||
writeErrorResponse(writer, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "service is unavailable")
|
||||
default:
|
||||
writeErrorResponse(writer, http.StatusInternalServerError, ErrorCodeInternalError, "internal server error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
response := DeliveryAttemptsResponse{
|
||||
Items: make([]AttemptResponse, len(result.Attempts)),
|
||||
}
|
||||
for index, record := range result.Attempts {
|
||||
response.Items[index] = attemptResponseFromRecord(record)
|
||||
}
|
||||
|
||||
writeJSONResponse(writer, http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
|
||||
func newResendDeliveryHandler(useCase ResendDeliveryUseCase, timeout time.Duration) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
deliveryID, err := DecodeDeliveryIDFromPath(request)
|
||||
if err != nil {
|
||||
writeErrorResponse(writer, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(request.Context(), effectiveOperatorTimeout(timeout))
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, resenddelivery.Input{DeliveryID: deliveryID})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, resenddelivery.ErrNotFound):
|
||||
writeErrorResponse(writer, http.StatusNotFound, ErrorCodeDeliveryNotFound, "delivery not found")
|
||||
case errors.Is(err, resenddelivery.ErrNotAllowed):
|
||||
writeErrorResponse(writer, http.StatusConflict, ErrorCodeResendNotAllowed, "delivery status does not allow resend")
|
||||
case errors.Is(err, resenddelivery.ErrServiceUnavailable):
|
||||
writeErrorResponse(writer, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "service is unavailable")
|
||||
default:
|
||||
writeErrorResponse(writer, http.StatusInternalServerError, ErrorCodeInternalError, "internal server error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSONResponse(writer, http.StatusOK, DeliveryResendResponse{
|
||||
DeliveryID: result.DeliveryID.String(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func effectiveOperatorTimeout(timeout time.Duration) time.Duration {
|
||||
if timeout <= 0 {
|
||||
return defaultOperatorRequestTimeout
|
||||
}
|
||||
|
||||
return timeout
|
||||
}
|
||||
|
||||
func writeJSONResponse(writer http.ResponseWriter, statusCode int, payload any) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(writer).Encode(payload)
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/service/getdelivery"
|
||||
"galaxy/mail/internal/service/listattempts"
|
||||
"galaxy/mail/internal/service/listdeliveries"
|
||||
"galaxy/mail/internal/service/resenddelivery"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOperatorHandlersReturnSuccessResponses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listDelivery := validOperatorDelivery("delivery-list", deliverydomain.StatusSent)
|
||||
getDeliveryRecord := validOperatorDelivery("delivery-get", deliverydomain.StatusDeadLetter)
|
||||
deadLetter := validOperatorDeadLetter(getDeliveryRecord.DeliveryID)
|
||||
attemptRecord := validOperatorAttempt(getDeliveryRecord.DeliveryID, 1, attempt.StatusProviderRejected)
|
||||
|
||||
handler := newHandler(Dependencies{
|
||||
Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)),
|
||||
OperatorRequestTimeout: time.Second,
|
||||
ListDeliveries: listDeliveriesFunc(func(context.Context, listdeliveries.Input) (listdeliveries.Result, error) {
|
||||
return listdeliveries.Result{
|
||||
Items: []deliverydomain.Delivery{listDelivery},
|
||||
NextCursor: &listdeliveries.Cursor{
|
||||
CreatedAt: listDelivery.CreatedAt,
|
||||
DeliveryID: listDelivery.DeliveryID,
|
||||
},
|
||||
}, nil
|
||||
}),
|
||||
GetDelivery: getDeliveryFunc(func(context.Context, getdelivery.Input) (getdelivery.Result, error) {
|
||||
return getdelivery.Result{
|
||||
Delivery: getDeliveryRecord,
|
||||
DeadLetter: &deadLetter,
|
||||
}, nil
|
||||
}),
|
||||
ListAttempts: listAttemptsFunc(func(context.Context, listattempts.Input) (listattempts.Result, error) {
|
||||
return listattempts.Result{
|
||||
Delivery: getDeliveryRecord,
|
||||
Attempts: []attempt.Attempt{attemptRecord},
|
||||
}, nil
|
||||
}),
|
||||
ResendDelivery: resendDeliveryFunc(func(context.Context, resenddelivery.Input) (resenddelivery.Result, error) {
|
||||
return resenddelivery.Result{DeliveryID: common.DeliveryID("delivery-clone")}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
t.Run("list", func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, DeliveriesPath+"?limit=1", nil)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
var payload DeliveryListResponse
|
||||
require.NoError(t, json.NewDecoder(response.Body).Decode(&payload))
|
||||
require.Len(t, payload.Items, 1)
|
||||
require.Equal(t, "delivery-list", payload.Items[0].DeliveryID)
|
||||
require.NotEmpty(t, payload.NextCursor)
|
||||
})
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/deliveries/delivery-get", nil)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
var payload DeliveryDetailResponse
|
||||
require.NoError(t, json.NewDecoder(response.Body).Decode(&payload))
|
||||
require.Equal(t, "delivery-get", payload.DeliveryID)
|
||||
require.NotNil(t, payload.DeadLetter)
|
||||
})
|
||||
|
||||
t.Run("attempts", func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/deliveries/delivery-get/attempts", nil)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
var payload DeliveryAttemptsResponse
|
||||
require.NoError(t, json.NewDecoder(response.Body).Decode(&payload))
|
||||
require.Len(t, payload.Items, 1)
|
||||
require.Equal(t, 1, payload.Items[0].AttemptNo)
|
||||
})
|
||||
|
||||
t.Run("resend", func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/internal/deliveries/delivery-get/resend", nil)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
var payload DeliveryResendResponse
|
||||
require.NoError(t, json.NewDecoder(response.Body).Decode(&payload))
|
||||
require.Equal(t, "delivery-clone", payload.DeliveryID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOperatorHandlersMapErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
deps Dependencies
|
||||
wantStatus int
|
||||
wantCode string
|
||||
}{
|
||||
{
|
||||
name: "list bad request",
|
||||
method: http.MethodGet,
|
||||
path: DeliveriesPath + "?limit=0",
|
||||
deps: Dependencies{Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), ListDeliveries: listDeliveriesFunc(func(context.Context, listdeliveries.Input) (listdeliveries.Result, error) {
|
||||
return listdeliveries.Result{}, nil
|
||||
})},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantCode: ErrorCodeInvalidRequest,
|
||||
},
|
||||
{
|
||||
name: "get not found",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/deliveries/missing",
|
||||
deps: Dependencies{Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), GetDelivery: getDeliveryFunc(func(context.Context, getdelivery.Input) (getdelivery.Result, error) {
|
||||
return getdelivery.Result{}, getdelivery.ErrNotFound
|
||||
})},
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantCode: ErrorCodeDeliveryNotFound,
|
||||
},
|
||||
{
|
||||
name: "attempts unavailable",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/deliveries/missing/attempts",
|
||||
deps: Dependencies{Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), ListAttempts: listAttemptsFunc(func(context.Context, listattempts.Input) (listattempts.Result, error) {
|
||||
return listattempts.Result{}, listattempts.ErrServiceUnavailable
|
||||
})},
|
||||
wantStatus: http.StatusServiceUnavailable,
|
||||
wantCode: ErrorCodeServiceUnavailable,
|
||||
},
|
||||
{
|
||||
name: "resend not allowed",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/deliveries/missing/resend",
|
||||
deps: Dependencies{Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), ResendDelivery: resendDeliveryFunc(func(context.Context, resenddelivery.Input) (resenddelivery.Result, error) {
|
||||
return resenddelivery.Result{}, resenddelivery.ErrNotAllowed
|
||||
})},
|
||||
wantStatus: http.StatusConflict,
|
||||
wantCode: ErrorCodeResendNotAllowed,
|
||||
},
|
||||
{
|
||||
name: "resend internal error",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/deliveries/missing/resend",
|
||||
deps: Dependencies{Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), ResendDelivery: resendDeliveryFunc(func(context.Context, resenddelivery.Input) (resenddelivery.Result, error) {
|
||||
return resenddelivery.Result{}, errors.New("boom")
|
||||
})},
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantCode: ErrorCodeInternalError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tt.deps.OperatorRequestTimeout = time.Second
|
||||
handler := newHandler(tt.deps)
|
||||
request := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
require.Equal(t, tt.wantStatus, response.Code)
|
||||
var payload ErrorResponse
|
||||
require.NoError(t, json.NewDecoder(response.Body).Decode(&payload))
|
||||
require.Equal(t, tt.wantCode, payload.Error.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorHandlersApplyRequestTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deadlineObserved := make(chan struct{}, 1)
|
||||
handler := newHandler(Dependencies{
|
||||
Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)),
|
||||
OperatorRequestTimeout: 50 * time.Millisecond,
|
||||
ListDeliveries: listDeliveriesFunc(func(ctx context.Context, input listdeliveries.Input) (listdeliveries.Result, error) {
|
||||
_ = input
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
deadlineObserved <- struct{}{}
|
||||
}
|
||||
return listdeliveries.Result{}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, DeliveriesPath, nil)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
select {
|
||||
case <-deadlineObserved:
|
||||
default:
|
||||
t.Fatal("expected operator handler to apply request timeout")
|
||||
}
|
||||
}
|
||||
|
||||
type listDeliveriesFunc func(context.Context, listdeliveries.Input) (listdeliveries.Result, error)
|
||||
|
||||
func (fn listDeliveriesFunc) Execute(ctx context.Context, input listdeliveries.Input) (listdeliveries.Result, error) {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
|
||||
type getDeliveryFunc func(context.Context, getdelivery.Input) (getdelivery.Result, error)
|
||||
|
||||
func (fn getDeliveryFunc) Execute(ctx context.Context, input getdelivery.Input) (getdelivery.Result, error) {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
|
||||
type listAttemptsFunc func(context.Context, listattempts.Input) (listattempts.Result, error)
|
||||
|
||||
func (fn listAttemptsFunc) Execute(ctx context.Context, input listattempts.Input) (listattempts.Result, error) {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
|
||||
type resendDeliveryFunc func(context.Context, resenddelivery.Input) (resenddelivery.Result, error)
|
||||
|
||||
func (fn resendDeliveryFunc) Execute(ctx context.Context, input resenddelivery.Input) (resenddelivery.Result, error) {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
|
||||
func validOperatorDelivery(id string, status deliverydomain.Status) deliverydomain.Delivery {
|
||||
createdAt := time.Unix(1_775_122_000, 0).UTC()
|
||||
updatedAt := createdAt.Add(time.Minute)
|
||||
record := deliverydomain.Delivery{
|
||||
DeliveryID: common.DeliveryID(id),
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeRendered,
|
||||
Envelope: deliverydomain.Envelope{To: []common.Email{common.Email("pilot@example.com")}},
|
||||
Content: deliverydomain.Content{Subject: "Turn ready", TextBody: "Turn ready"},
|
||||
IdempotencyKey: common.IdempotencyKey("notification:" + id),
|
||||
Status: status,
|
||||
AttemptCount: 1,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
|
||||
switch status {
|
||||
case deliverydomain.StatusSent:
|
||||
sentAt := updatedAt
|
||||
record.SentAt = &sentAt
|
||||
record.LastAttemptStatus = attempt.StatusProviderAccepted
|
||||
case deliverydomain.StatusDeadLetter:
|
||||
deadLetteredAt := updatedAt
|
||||
record.DeadLetteredAt = &deadLetteredAt
|
||||
record.LastAttemptStatus = attempt.StatusTimedOut
|
||||
}
|
||||
|
||||
if err := record.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func validOperatorDeadLetter(deliveryID common.DeliveryID) deliverydomain.DeadLetterEntry {
|
||||
entry := deliverydomain.DeadLetterEntry{
|
||||
DeliveryID: deliveryID,
|
||||
FinalAttemptNo: 1,
|
||||
FailureClassification: "retry_exhausted",
|
||||
ProviderSummary: "smtp timeout",
|
||||
CreatedAt: time.Unix(1_775_122_100, 0).UTC(),
|
||||
RecoveryHint: "check SMTP connectivity",
|
||||
}
|
||||
if err := entry.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
func validOperatorAttempt(deliveryID common.DeliveryID, attemptNo int, status attempt.Status) attempt.Attempt {
|
||||
scheduledFor := time.Unix(1_775_122_050, 0).UTC()
|
||||
startedAt := scheduledFor.Add(time.Second)
|
||||
finishedAt := startedAt.Add(time.Second)
|
||||
record := attempt.Attempt{
|
||||
DeliveryID: deliveryID,
|
||||
AttemptNo: attemptNo,
|
||||
ScheduledFor: scheduledFor,
|
||||
StartedAt: &startedAt,
|
||||
FinishedAt: &finishedAt,
|
||||
Status: status,
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// Package internalhttp provides the trusted internal HTTP listener used by the
|
||||
// runnable Mail Service process.
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/telemetry"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
const (
|
||||
// DeliveriesPath is the trusted operator list route reserved by the Stage 6
|
||||
// runnable skeleton.
|
||||
DeliveriesPath = "/api/v1/internal/deliveries"
|
||||
|
||||
// DeliveryByIDPath is the trusted operator get-delivery route reserved by
|
||||
// the Stage 6 runnable skeleton.
|
||||
DeliveryByIDPath = "/api/v1/internal/deliveries/{delivery_id}"
|
||||
|
||||
// DeliveryAttemptsPath is the trusted operator list-attempts route reserved
|
||||
// by the Stage 6 runnable skeleton.
|
||||
DeliveryAttemptsPath = "/api/v1/internal/deliveries/{delivery_id}/attempts"
|
||||
|
||||
// DeliveryResendPath is the trusted operator resend route reserved by the
|
||||
// Stage 6 runnable skeleton.
|
||||
DeliveryResendPath = "/api/v1/internal/deliveries/{delivery_id}/resend"
|
||||
)
|
||||
|
||||
// Config describes the trusted internal HTTP listener owned by Mail Service.
|
||||
type Config struct {
|
||||
// Addr is the TCP listen address used by the trusted internal HTTP server.
|
||||
Addr string
|
||||
|
||||
// ReadHeaderTimeout bounds how long the listener may spend reading request
|
||||
// headers before the server rejects the connection.
|
||||
ReadHeaderTimeout time.Duration
|
||||
|
||||
// ReadTimeout bounds how long the listener may spend reading one trusted
|
||||
// internal request.
|
||||
ReadTimeout time.Duration
|
||||
|
||||
// IdleTimeout bounds how long the listener keeps an idle keep-alive
|
||||
// connection open.
|
||||
IdleTimeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg contains a usable internal HTTP listener
|
||||
// configuration.
|
||||
func (cfg Config) Validate() error {
|
||||
switch {
|
||||
case cfg.Addr == "":
|
||||
return errors.New("internal HTTP addr must not be empty")
|
||||
case cfg.ReadHeaderTimeout <= 0:
|
||||
return errors.New("internal HTTP read header timeout must be positive")
|
||||
case cfg.ReadTimeout <= 0:
|
||||
return errors.New("internal HTTP read timeout must be positive")
|
||||
case cfg.IdleTimeout <= 0:
|
||||
return errors.New("internal HTTP idle timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Dependencies describes the collaborators used by the trusted internal HTTP
|
||||
// transport layer.
|
||||
type Dependencies struct {
|
||||
// Logger writes structured transport logs. When nil, slog.Default is used.
|
||||
Logger *slog.Logger
|
||||
|
||||
// Telemetry records low-cardinality transport and auth-delivery metrics.
|
||||
Telemetry *telemetry.Runtime
|
||||
|
||||
// AcceptLoginCodeDelivery handles the dedicated auth-delivery route when
|
||||
// provided.
|
||||
AcceptLoginCodeDelivery AcceptLoginCodeDeliveryUseCase
|
||||
|
||||
// ListDeliveries handles the trusted operator delivery-list route when
|
||||
// provided.
|
||||
ListDeliveries ListDeliveriesUseCase
|
||||
|
||||
// GetDelivery handles the trusted operator exact delivery-read route when
|
||||
// provided.
|
||||
GetDelivery GetDeliveryUseCase
|
||||
|
||||
// ListAttempts handles the trusted operator attempt-history route when
|
||||
// provided.
|
||||
ListAttempts ListAttemptsUseCase
|
||||
|
||||
// ResendDelivery handles the trusted operator resend route when provided.
|
||||
ResendDelivery ResendDeliveryUseCase
|
||||
|
||||
// OperatorRequestTimeout bounds one trusted operator use-case execution.
|
||||
OperatorRequestTimeout time.Duration
|
||||
}
|
||||
|
||||
// Server owns the trusted internal HTTP listener exposed by Mail Service.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
|
||||
handler http.Handler
|
||||
logger *slog.Logger
|
||||
|
||||
stateMu sync.RWMutex
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// NewServer constructs one trusted internal HTTP server for cfg and deps.
|
||||
func NewServer(cfg Config, deps Dependencies) (*Server, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("new internal HTTP server: %w", err)
|
||||
}
|
||||
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
handler: newHandler(deps),
|
||||
logger: logger.With("component", "internal_http"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run binds the configured listener and serves the trusted internal HTTP
|
||||
// surface until Shutdown closes the server.
|
||||
func (server *Server) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run internal HTTP server: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", server.cfg.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run internal HTTP server: listen on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
Handler: server.handler,
|
||||
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
|
||||
ReadTimeout: server.cfg.ReadTimeout,
|
||||
IdleTimeout: server.cfg.IdleTimeout,
|
||||
}
|
||||
|
||||
server.stateMu.Lock()
|
||||
server.server = httpServer
|
||||
server.listener = listener
|
||||
server.stateMu.Unlock()
|
||||
|
||||
server.logger.Info("internal HTTP server started", "addr", listener.Addr().String())
|
||||
|
||||
defer func() {
|
||||
server.stateMu.Lock()
|
||||
server.server = nil
|
||||
server.listener = nil
|
||||
server.stateMu.Unlock()
|
||||
}()
|
||||
|
||||
err = httpServer.Serve(listener)
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, http.ErrServerClosed):
|
||||
server.logger.Info("internal HTTP server stopped")
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("run internal HTTP server: serve on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the trusted internal HTTP server within ctx.
|
||||
func (server *Server) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown internal HTTP server: nil context")
|
||||
}
|
||||
|
||||
server.stateMu.RLock()
|
||||
httpServer := server.server
|
||||
server.stateMu.RUnlock()
|
||||
|
||||
if httpServer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("shutdown internal HTTP server: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newHandler(deps Dependencies) http.Handler {
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
loginCodeHandler := http.HandlerFunc(serviceUnavailableHandler)
|
||||
if deps.AcceptLoginCodeDelivery != nil {
|
||||
loginCodeHandler = newAcceptLoginCodeDeliveryHandler(deps.AcceptLoginCodeDelivery)
|
||||
}
|
||||
listDeliveriesHandler := http.HandlerFunc(serviceUnavailableHandler)
|
||||
if deps.ListDeliveries != nil {
|
||||
listDeliveriesHandler = newListDeliveriesHandler(deps.ListDeliveries, deps.OperatorRequestTimeout)
|
||||
}
|
||||
getDeliveryHandler := http.HandlerFunc(serviceUnavailableHandler)
|
||||
if deps.GetDelivery != nil {
|
||||
getDeliveryHandler = newGetDeliveryHandler(deps.GetDelivery, deps.OperatorRequestTimeout)
|
||||
}
|
||||
listAttemptsHandler := http.HandlerFunc(serviceUnavailableHandler)
|
||||
if deps.ListAttempts != nil {
|
||||
listAttemptsHandler = newListAttemptsHandler(deps.ListAttempts, deps.OperatorRequestTimeout)
|
||||
}
|
||||
resendDeliveryHandler := http.HandlerFunc(serviceUnavailableHandler)
|
||||
if deps.ResendDelivery != nil {
|
||||
resendDeliveryHandler = newResendDeliveryHandler(deps.ResendDelivery, deps.OperatorRequestTimeout)
|
||||
}
|
||||
|
||||
mux.Handle("POST "+LoginCodeDeliveriesPath, wrapObservedRoute(LoginCodeDeliveriesPath, logger, deps.Telemetry, loginCodeHandler))
|
||||
mux.Handle("GET "+DeliveriesPath, wrapObservedRoute(DeliveriesPath, logger, deps.Telemetry, listDeliveriesHandler))
|
||||
mux.Handle("GET "+DeliveryByIDPath, wrapObservedRoute(DeliveryByIDPath, logger, deps.Telemetry, getDeliveryHandler))
|
||||
mux.Handle("GET "+DeliveryAttemptsPath, wrapObservedRoute(DeliveryAttemptsPath, logger, deps.Telemetry, listAttemptsHandler))
|
||||
mux.Handle("POST "+DeliveryResendPath, wrapObservedRoute(DeliveryResendPath, logger, deps.Telemetry, resendDeliveryHandler))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func wrapObservedRoute(route string, logger *slog.Logger, telemetryRuntime *telemetry.Runtime, next http.Handler) http.Handler {
|
||||
handler := instrumentRoute(route, logger, telemetryRuntime, next)
|
||||
|
||||
options := []otelhttp.Option{}
|
||||
if telemetryRuntime != nil {
|
||||
options = append(options,
|
||||
otelhttp.WithTracerProvider(telemetryRuntime.TracerProvider()),
|
||||
otelhttp.WithMeterProvider(telemetryRuntime.MeterProvider()),
|
||||
)
|
||||
}
|
||||
|
||||
return otelhttp.NewHandler(handler, route, options...)
|
||||
}
|
||||
|
||||
func serviceUnavailableHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
_ = request
|
||||
writeErrorResponse(writer, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "service is unavailable")
|
||||
}
|
||||
|
||||
func writeErrorResponse(writer http.ResponseWriter, statusCode int, code string, message string) {
|
||||
if recorder, ok := writer.(*observedResponseWriter); ok {
|
||||
recorder.SetErrorCode(code)
|
||||
}
|
||||
|
||||
payload := ErrorResponse{
|
||||
Error: ErrorBody{
|
||||
Code: code,
|
||||
Message: message,
|
||||
},
|
||||
}
|
||||
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(writer).Encode(payload)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewServerRejectsInvalidConfiguration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := Config{
|
||||
ReadHeaderTimeout: time.Second,
|
||||
ReadTimeout: time.Second,
|
||||
IdleTimeout: time.Second,
|
||||
}
|
||||
|
||||
_, err := NewServer(cfg, Dependencies{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "addr")
|
||||
}
|
||||
|
||||
func TestServerRunAndShutdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := testConfig(t)
|
||||
server, err := NewServer(cfg, Dependencies{})
|
||||
require.NoError(t, err)
|
||||
|
||||
runErr := make(chan error, 1)
|
||||
go func() {
|
||||
runErr <- server.Run(context.Background())
|
||||
}()
|
||||
|
||||
client := newTestHTTPClient(t)
|
||||
waitForReservedRouteReady(t, client, cfg.Addr)
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
require.NoError(t, server.Shutdown(shutdownCtx))
|
||||
waitForServerRunResult(t, runErr)
|
||||
}
|
||||
|
||||
func TestReservedRoutesReturnStableServiceUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := testConfig(t)
|
||||
server, err := NewServer(cfg, Dependencies{})
|
||||
require.NoError(t, err)
|
||||
|
||||
runErr := make(chan error, 1)
|
||||
go func() {
|
||||
runErr <- server.Run(context.Background())
|
||||
}()
|
||||
|
||||
client := newTestHTTPClient(t)
|
||||
waitForReservedRouteReady(t, client, cfg.Addr)
|
||||
|
||||
tests := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{method: http.MethodPost, path: LoginCodeDeliveriesPath},
|
||||
{method: http.MethodGet, path: DeliveriesPath},
|
||||
{method: http.MethodGet, path: "/api/v1/internal/deliveries/delivery-123"},
|
||||
{method: http.MethodGet, path: "/api/v1/internal/deliveries/delivery-123/attempts"},
|
||||
{method: http.MethodPost, path: "/api/v1/internal/deliveries/delivery-123/resend"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
||||
request, err := http.NewRequest(tt.method, "http://"+cfg.Addr+tt.path, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
response, err := client.Do(request)
|
||||
require.NoError(t, err)
|
||||
defer response.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, response.StatusCode)
|
||||
require.Equal(t, "application/json", response.Header.Get("Content-Type"))
|
||||
|
||||
var payload ErrorResponse
|
||||
require.NoError(t, json.NewDecoder(response.Body).Decode(&payload))
|
||||
require.Equal(t, ErrorCodeServiceUnavailable, payload.Error.Code)
|
||||
require.Equal(t, "service is unavailable", payload.Error.Message)
|
||||
})
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
require.NoError(t, server.Shutdown(shutdownCtx))
|
||||
waitForServerRunResult(t, runErr)
|
||||
}
|
||||
|
||||
func TestServerDoesNotExposeProbeOrUnknownRoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := testConfig(t)
|
||||
server, err := NewServer(cfg, Dependencies{})
|
||||
require.NoError(t, err)
|
||||
|
||||
runErr := make(chan error, 1)
|
||||
go func() {
|
||||
runErr <- server.Run(context.Background())
|
||||
}()
|
||||
|
||||
client := newTestHTTPClient(t)
|
||||
waitForReservedRouteReady(t, client, cfg.Addr)
|
||||
|
||||
for _, path := range []string{"/healthz", "/readyz", "/metrics", "/unknown"} {
|
||||
request, err := http.NewRequest(http.MethodGet, "http://"+cfg.Addr+path, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
response, err := client.Do(request)
|
||||
require.NoError(t, err)
|
||||
_, _ = io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
|
||||
assert.Equalf(t, http.StatusNotFound, response.StatusCode, "path %s", path)
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
require.NoError(t, server.Shutdown(shutdownCtx))
|
||||
waitForServerRunResult(t, runErr)
|
||||
}
|
||||
|
||||
func testConfig(t *testing.T) Config {
|
||||
t.Helper()
|
||||
|
||||
return Config{
|
||||
Addr: mustFreeAddr(t),
|
||||
ReadHeaderTimeout: time.Second,
|
||||
ReadTimeout: 2 * time.Second,
|
||||
IdleTimeout: time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
func newTestHTTPClient(t *testing.T) *http.Client {
|
||||
t.Helper()
|
||||
|
||||
transport := &http.Transport{DisableKeepAlives: true}
|
||||
t.Cleanup(transport.CloseIdleConnections)
|
||||
|
||||
return &http.Client{
|
||||
Timeout: 250 * time.Millisecond,
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
|
||||
func waitForReservedRouteReady(t *testing.T, client *http.Client, addr string) {
|
||||
t.Helper()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
request, err := http.NewRequest(http.MethodPost, "http://"+addr+LoginCodeDeliveriesPath, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.ReadAll(response.Body)
|
||||
|
||||
return response.StatusCode == http.StatusServiceUnavailable
|
||||
}, 5*time.Second, 25*time.Millisecond, "internal HTTP server did not become reachable")
|
||||
}
|
||||
|
||||
func waitForServerRunResult(t *testing.T, runErr <-chan error) {
|
||||
t.Helper()
|
||||
|
||||
var err error
|
||||
require.Eventually(t, func() bool {
|
||||
select {
|
||||
case err = <-runErr:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, 5*time.Second, 10*time.Millisecond, "internal HTTP server did not stop")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func mustFreeAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
assert.NoError(t, listener.Close())
|
||||
}()
|
||||
|
||||
return listener.Addr().String()
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
// Package streamcommand defines the frozen Redis Streams command contract used
|
||||
// by Mail Service for generic asynchronous delivery intake.
|
||||
package streamcommand
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/domain/malformedcommand"
|
||||
)
|
||||
|
||||
const (
|
||||
// DeliveryCommandsStream is the frozen Redis Stream name used for generic
|
||||
// asynchronous delivery commands.
|
||||
DeliveryCommandsStream = "mail:delivery_commands"
|
||||
|
||||
// MaxAttachments is the frozen attachment-count limit for one generic
|
||||
// asynchronous command.
|
||||
MaxAttachments = 5
|
||||
|
||||
// MaxEncodedAttachmentPayloadBytes is the frozen limit for the total
|
||||
// encoded attachment payload, measured as the sum of attachment
|
||||
// `content_base64` string lengths.
|
||||
MaxEncodedAttachmentPayloadBytes = 2 * 1024 * 1024
|
||||
)
|
||||
|
||||
const (
|
||||
fieldDeliveryID = "delivery_id"
|
||||
fieldSource = "source"
|
||||
fieldPayloadMode = "payload_mode"
|
||||
fieldIdempotency = "idempotency_key"
|
||||
fieldRequestedAtMS = "requested_at_ms"
|
||||
fieldPayloadJSON = "payload_json"
|
||||
fieldRequestID = "request_id"
|
||||
fieldTraceID = "trace_id"
|
||||
)
|
||||
|
||||
var (
|
||||
requiredFieldNames = map[string]struct{}{
|
||||
fieldDeliveryID: {},
|
||||
fieldSource: {},
|
||||
fieldPayloadMode: {},
|
||||
fieldIdempotency: {},
|
||||
fieldRequestedAtMS: {},
|
||||
fieldPayloadJSON: {},
|
||||
}
|
||||
optionalFieldNames = map[string]struct{}{
|
||||
fieldRequestID: {},
|
||||
fieldTraceID: {},
|
||||
}
|
||||
)
|
||||
|
||||
// ClassifyDecodeError maps one command-decoding or command-validation error to
|
||||
// the stable malformed-command failure code surface.
|
||||
func ClassifyDecodeError(err error) malformedcommand.FailureCode {
|
||||
if err == nil {
|
||||
return malformedcommand.FailureCodeInvalidCommand
|
||||
}
|
||||
|
||||
message := err.Error()
|
||||
switch {
|
||||
case strings.Contains(message, "delivery envelope"),
|
||||
strings.Contains(message, "must contain at least one recipient"):
|
||||
return malformedcommand.FailureCodeInvalidEnvelope
|
||||
case strings.Contains(message, "payload_json"),
|
||||
strings.Contains(message, "stream command attachments"),
|
||||
strings.Contains(message, "delivery content"),
|
||||
strings.Contains(message, "template id"),
|
||||
strings.Contains(message, "locale"),
|
||||
strings.Contains(message, "variables"):
|
||||
return malformedcommand.FailureCodeInvalidPayload
|
||||
default:
|
||||
return malformedcommand.FailureCodeInvalidCommand
|
||||
}
|
||||
}
|
||||
|
||||
// Command stores one normalized generic asynchronous command accepted from the
|
||||
// Redis Streams contract.
|
||||
type Command struct {
|
||||
// DeliveryID stores the publisher-owned logical delivery identifier.
|
||||
DeliveryID common.DeliveryID
|
||||
|
||||
// Source stores the frozen async source vocabulary value.
|
||||
Source deliverydomain.Source
|
||||
|
||||
// PayloadMode stores whether the command contains final rendered content or
|
||||
// template-selection data.
|
||||
PayloadMode deliverydomain.PayloadMode
|
||||
|
||||
// IdempotencyKey stores the caller-owned stable deduplication key.
|
||||
IdempotencyKey common.IdempotencyKey
|
||||
|
||||
// RequestedAt stores when the publisher originally requested the generic
|
||||
// delivery.
|
||||
RequestedAt time.Time
|
||||
|
||||
// RequestID stores the optional tracing request identifier.
|
||||
RequestID string
|
||||
|
||||
// TraceID stores the optional tracing trace identifier.
|
||||
TraceID string
|
||||
|
||||
// Envelope stores the SMTP addressing information frozen by the stream
|
||||
// payload contract.
|
||||
Envelope deliverydomain.Envelope
|
||||
|
||||
// Attachments stores the normalized attachment list including computed
|
||||
// decoded sizes.
|
||||
Attachments []Attachment
|
||||
|
||||
// Subject stores the required final subject for rendered-mode commands.
|
||||
Subject string
|
||||
|
||||
// TextBody stores the required plaintext body for rendered-mode commands.
|
||||
TextBody string
|
||||
|
||||
// HTMLBody stores the optional HTML body for rendered-mode commands.
|
||||
HTMLBody string
|
||||
|
||||
// TemplateID stores the required template family for template-mode
|
||||
// commands.
|
||||
TemplateID common.TemplateID
|
||||
|
||||
// Locale stores the required canonical BCP 47 locale for template-mode
|
||||
// commands.
|
||||
Locale common.Locale
|
||||
|
||||
// Variables stores the arbitrary template variables object for
|
||||
// template-mode commands.
|
||||
Variables map[string]any
|
||||
}
|
||||
|
||||
// Validate reports whether Command satisfies the frozen Stage 05 stream
|
||||
// contract.
|
||||
func (command Command) Validate() error {
|
||||
if err := command.DeliveryID.Validate(); err != nil {
|
||||
return fmt.Errorf("stream command delivery id: %w", err)
|
||||
}
|
||||
if command.Source != deliverydomain.SourceNotification {
|
||||
return fmt.Errorf("stream command source %q is unsupported", command.Source)
|
||||
}
|
||||
if !command.PayloadMode.IsKnown() {
|
||||
return fmt.Errorf("stream command payload mode %q is unsupported", command.PayloadMode)
|
||||
}
|
||||
if err := command.IdempotencyKey.Validate(); err != nil {
|
||||
return fmt.Errorf("stream command idempotency key: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("stream command requested at", command.RequestedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := command.Envelope.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(command.Attachments) > MaxAttachments {
|
||||
return fmt.Errorf("stream command attachments must contain at most %d entries", MaxAttachments)
|
||||
}
|
||||
|
||||
totalEncodedPayloadBytes := 0
|
||||
for index, attachment := range command.Attachments {
|
||||
if err := attachment.Validate(); err != nil {
|
||||
return fmt.Errorf("stream command attachments[%d]: %w", index, err)
|
||||
}
|
||||
totalEncodedPayloadBytes += len(attachment.ContentBase64)
|
||||
}
|
||||
if totalEncodedPayloadBytes > MaxEncodedAttachmentPayloadBytes {
|
||||
return fmt.Errorf(
|
||||
"stream command encoded attachment payload must not exceed %d bytes",
|
||||
MaxEncodedAttachmentPayloadBytes,
|
||||
)
|
||||
}
|
||||
|
||||
switch command.PayloadMode {
|
||||
case deliverydomain.PayloadModeRendered:
|
||||
if err := (deliverydomain.Content{
|
||||
Subject: command.Subject,
|
||||
TextBody: command.TextBody,
|
||||
HTMLBody: command.HTMLBody,
|
||||
}).ValidateMaterialized(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !command.TemplateID.IsZero() {
|
||||
return errors.New("rendered stream command must not contain template id")
|
||||
}
|
||||
if !command.Locale.IsZero() {
|
||||
return errors.New("rendered stream command must not contain locale")
|
||||
}
|
||||
if len(command.Variables) != 0 {
|
||||
return errors.New("rendered stream command must not contain template variables")
|
||||
}
|
||||
case deliverydomain.PayloadModeTemplate:
|
||||
if err := command.TemplateID.Validate(); err != nil {
|
||||
return fmt.Errorf("stream command template id: %w", err)
|
||||
}
|
||||
if err := command.Locale.Validate(); err != nil {
|
||||
return fmt.Errorf("stream command locale: %w", err)
|
||||
}
|
||||
if command.Variables == nil {
|
||||
return errors.New("template stream command variables must not be nil")
|
||||
}
|
||||
if command.Subject != "" {
|
||||
return errors.New("template stream command must not contain subject")
|
||||
}
|
||||
if command.TextBody != "" {
|
||||
return errors.New("template stream command must not contain text body")
|
||||
}
|
||||
if command.HTMLBody != "" {
|
||||
return errors.New("template stream command must not contain html body")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fingerprint returns the stable Stage 05 request fingerprint used by later
|
||||
// idempotency handling. The fingerprint excludes tracing-only metadata
|
||||
// (`request_id`, `trace_id`) but includes the normalized business fields of
|
||||
// the command.
|
||||
func (command Command) Fingerprint() (string, error) {
|
||||
if err := command.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
normalized := fingerprintCommand{
|
||||
DeliveryID: command.DeliveryID.String(),
|
||||
Source: command.Source,
|
||||
PayloadMode: command.PayloadMode,
|
||||
IdempotencyKey: command.IdempotencyKey.String(),
|
||||
RequestedAtMS: command.RequestedAt.UTC().UnixMilli(),
|
||||
Envelope: fingerprintEnvelope{
|
||||
To: cloneEmails(command.Envelope.To),
|
||||
Cc: cloneEmails(command.Envelope.Cc),
|
||||
Bcc: cloneEmails(command.Envelope.Bcc),
|
||||
ReplyTo: cloneEmails(command.Envelope.ReplyTo),
|
||||
},
|
||||
Attachments: cloneAttachments(command.Attachments),
|
||||
Subject: command.Subject,
|
||||
TextBody: command.TextBody,
|
||||
HTMLBody: command.HTMLBody,
|
||||
TemplateID: command.TemplateID.String(),
|
||||
Locale: command.Locale.String(),
|
||||
Variables: command.Variables,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal stream command fingerprint: %w", err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(payload)
|
||||
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// Attachment stores one inline base64 attachment accepted by the asynchronous
|
||||
// generic stream contract.
|
||||
type Attachment struct {
|
||||
// Filename stores the user-facing attachment filename.
|
||||
Filename string
|
||||
|
||||
// ContentType stores the MIME media type of the attachment.
|
||||
ContentType string
|
||||
|
||||
// ContentBase64 stores the exact inline base64 payload published on the
|
||||
// stream.
|
||||
ContentBase64 string
|
||||
|
||||
// SizeBytes stores the computed decoded attachment size in bytes.
|
||||
SizeBytes int64
|
||||
}
|
||||
|
||||
// Validate reports whether Attachment contains a valid inline base64 payload
|
||||
// and a complete metadata header.
|
||||
func (attachment Attachment) Validate() error {
|
||||
if _, err := base64.StdEncoding.DecodeString(attachment.ContentBase64); err != nil {
|
||||
return fmt.Errorf("attachment content_base64 must be valid base64: %w", err)
|
||||
}
|
||||
|
||||
metadata := common.AttachmentMetadata{
|
||||
Filename: attachment.Filename,
|
||||
ContentType: attachment.ContentType,
|
||||
SizeBytes: attachment.SizeBytes,
|
||||
}
|
||||
if err := metadata.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecodeCommand validates one raw Redis Streams entry and returns the
|
||||
// normalized asynchronous generic command frozen by Stage 05.
|
||||
func DecodeCommand(fields map[string]any) (Command, error) {
|
||||
if fields == nil {
|
||||
return Command{}, errors.New("stream command fields must not be nil")
|
||||
}
|
||||
|
||||
if err := validateFieldSet(fields); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
|
||||
deliveryIDValue, err := requiredString(fields, fieldDeliveryID)
|
||||
if err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
sourceValue, err := requiredString(fields, fieldSource)
|
||||
if err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
payloadModeValue, err := requiredString(fields, fieldPayloadMode)
|
||||
if err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
idempotencyValue, err := requiredString(fields, fieldIdempotency)
|
||||
if err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
requestedAtValue, err := requiredString(fields, fieldRequestedAtMS)
|
||||
if err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
payloadJSONValue, err := requiredString(fields, fieldPayloadJSON)
|
||||
if err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
|
||||
requestedAtMS, err := strconv.ParseInt(requestedAtValue, 10, 64)
|
||||
if err != nil {
|
||||
return Command{}, fmt.Errorf("stream field %q must be a base-10 Unix milliseconds string", fieldRequestedAtMS)
|
||||
}
|
||||
|
||||
command := Command{
|
||||
DeliveryID: common.DeliveryID(deliveryIDValue),
|
||||
Source: deliverydomain.Source(sourceValue),
|
||||
PayloadMode: deliverydomain.PayloadMode(payloadModeValue),
|
||||
IdempotencyKey: common.IdempotencyKey(idempotencyValue),
|
||||
RequestedAt: time.UnixMilli(requestedAtMS).UTC(),
|
||||
}
|
||||
|
||||
if requestIDValue, ok, err := optionalString(fields, fieldRequestID); err != nil {
|
||||
return Command{}, err
|
||||
} else if ok {
|
||||
command.RequestID = requestIDValue
|
||||
}
|
||||
if traceIDValue, ok, err := optionalString(fields, fieldTraceID); err != nil {
|
||||
return Command{}, err
|
||||
} else if ok {
|
||||
command.TraceID = traceIDValue
|
||||
}
|
||||
|
||||
switch command.PayloadMode {
|
||||
case deliverydomain.PayloadModeRendered:
|
||||
if err := decodeRenderedPayload(payloadJSONValue, &command); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
case deliverydomain.PayloadModeTemplate:
|
||||
if err := decodeTemplatePayload(payloadJSONValue, &command); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
default:
|
||||
return Command{}, fmt.Errorf("stream field %q value %q is unsupported", fieldPayloadMode, payloadModeValue)
|
||||
}
|
||||
|
||||
if err := command.Validate(); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
|
||||
return command, nil
|
||||
}
|
||||
|
||||
type renderedPayloadJSON struct {
|
||||
To *[]string `json:"to"`
|
||||
Cc *[]string `json:"cc"`
|
||||
Bcc *[]string `json:"bcc"`
|
||||
ReplyTo *[]string `json:"reply_to"`
|
||||
Subject *string `json:"subject"`
|
||||
TextBody *string `json:"text_body"`
|
||||
HTMLBody *string `json:"html_body,omitempty"`
|
||||
Attachments *[]attachmentJSON `json:"attachments"`
|
||||
}
|
||||
|
||||
type templatePayloadJSON struct {
|
||||
To *[]string `json:"to"`
|
||||
Cc *[]string `json:"cc"`
|
||||
Bcc *[]string `json:"bcc"`
|
||||
ReplyTo *[]string `json:"reply_to"`
|
||||
TemplateID *string `json:"template_id"`
|
||||
Locale *string `json:"locale"`
|
||||
Variables *json.RawMessage `json:"variables"`
|
||||
Attachments *[]attachmentJSON `json:"attachments"`
|
||||
}
|
||||
|
||||
type attachmentJSON struct {
|
||||
Filename *string `json:"filename"`
|
||||
ContentType *string `json:"content_type"`
|
||||
ContentBase64 *string `json:"content_base64"`
|
||||
}
|
||||
|
||||
type fingerprintCommand struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
Source deliverydomain.Source `json:"source"`
|
||||
PayloadMode deliverydomain.PayloadMode `json:"payload_mode"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
RequestedAtMS int64 `json:"requested_at_ms"`
|
||||
Envelope fingerprintEnvelope `json:"envelope"`
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
TextBody string `json:"text_body,omitempty"`
|
||||
HTMLBody string `json:"html_body,omitempty"`
|
||||
TemplateID string `json:"template_id,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
Variables map[string]any `json:"variables,omitempty"`
|
||||
}
|
||||
|
||||
type fingerprintEnvelope struct {
|
||||
To []string `json:"to"`
|
||||
Cc []string `json:"cc"`
|
||||
Bcc []string `json:"bcc"`
|
||||
ReplyTo []string `json:"reply_to"`
|
||||
}
|
||||
|
||||
func validateFieldSet(fields map[string]any) error {
|
||||
missing := make([]string, 0, len(requiredFieldNames))
|
||||
for name := range requiredFieldNames {
|
||||
if _, ok := fields[name]; !ok {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("stream command is missing required fields: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
unexpected := make([]string, 0)
|
||||
for name := range fields {
|
||||
if _, ok := requiredFieldNames[name]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := optionalFieldNames[name]; ok {
|
||||
continue
|
||||
}
|
||||
unexpected = append(unexpected, name)
|
||||
}
|
||||
sort.Strings(unexpected)
|
||||
if len(unexpected) > 0 {
|
||||
return fmt.Errorf("stream command contains unsupported fields: %s", strings.Join(unexpected, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func requiredString(fields map[string]any, name string) (string, error) {
|
||||
value, ok := fields[name]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("stream field %q is required", name)
|
||||
}
|
||||
|
||||
result, ok := value.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("stream field %q must be a string", name)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func optionalString(fields map[string]any, name string) (string, bool, error) {
|
||||
value, ok := fields[name]
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
result, ok := value.(string)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("stream field %q must be a string", name)
|
||||
}
|
||||
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
func decodeRenderedPayload(payload string, command *Command) error {
|
||||
var raw renderedPayloadJSON
|
||||
if err := decodeStrictJSON("decode payload_json", payload, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
envelope, attachments, err := decodeCommonPayloadFields(
|
||||
raw.To,
|
||||
raw.Cc,
|
||||
raw.Bcc,
|
||||
raw.ReplyTo,
|
||||
raw.Attachments,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if raw.Subject == nil {
|
||||
return errors.New("payload_json.subject is required")
|
||||
}
|
||||
if raw.TextBody == nil {
|
||||
return errors.New("payload_json.text_body is required")
|
||||
}
|
||||
|
||||
command.Envelope = envelope
|
||||
command.Attachments = attachments
|
||||
command.Subject = *raw.Subject
|
||||
command.TextBody = *raw.TextBody
|
||||
if raw.HTMLBody != nil {
|
||||
command.HTMLBody = *raw.HTMLBody
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeTemplatePayload(payload string, command *Command) error {
|
||||
var raw templatePayloadJSON
|
||||
if err := decodeStrictJSON("decode payload_json", payload, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
envelope, attachments, err := decodeCommonPayloadFields(
|
||||
raw.To,
|
||||
raw.Cc,
|
||||
raw.Bcc,
|
||||
raw.ReplyTo,
|
||||
raw.Attachments,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if raw.TemplateID == nil {
|
||||
return errors.New("payload_json.template_id is required")
|
||||
}
|
||||
if raw.Locale == nil {
|
||||
return errors.New("payload_json.locale is required")
|
||||
}
|
||||
if raw.Variables == nil {
|
||||
return errors.New("payload_json.variables is required")
|
||||
}
|
||||
|
||||
variables, err := decodeVariables(*raw.Variables)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
locale, err := common.ParseLocale(*raw.Locale)
|
||||
if err != nil {
|
||||
return fmt.Errorf("payload_json.locale: %w", err)
|
||||
}
|
||||
|
||||
command.Envelope = envelope
|
||||
command.Attachments = attachments
|
||||
command.TemplateID = common.TemplateID(*raw.TemplateID)
|
||||
command.Locale = locale
|
||||
command.Variables = variables
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeCommonPayloadFields(
|
||||
to *[]string,
|
||||
cc *[]string,
|
||||
bcc *[]string,
|
||||
replyTo *[]string,
|
||||
attachments *[]attachmentJSON,
|
||||
) (deliverydomain.Envelope, []Attachment, error) {
|
||||
if to == nil {
|
||||
return deliverydomain.Envelope{}, nil, errors.New("payload_json.to is required")
|
||||
}
|
||||
if cc == nil {
|
||||
return deliverydomain.Envelope{}, nil, errors.New("payload_json.cc is required")
|
||||
}
|
||||
if bcc == nil {
|
||||
return deliverydomain.Envelope{}, nil, errors.New("payload_json.bcc is required")
|
||||
}
|
||||
if replyTo == nil {
|
||||
return deliverydomain.Envelope{}, nil, errors.New("payload_json.reply_to is required")
|
||||
}
|
||||
if attachments == nil {
|
||||
return deliverydomain.Envelope{}, nil, errors.New("payload_json.attachments is required")
|
||||
}
|
||||
|
||||
envelope := deliverydomain.Envelope{
|
||||
To: inflateEmails(*to),
|
||||
Cc: inflateEmails(*cc),
|
||||
Bcc: inflateEmails(*bcc),
|
||||
ReplyTo: inflateEmails(*replyTo),
|
||||
}
|
||||
inflatedAttachments, err := inflateAttachments(*attachments)
|
||||
if err != nil {
|
||||
return deliverydomain.Envelope{}, nil, err
|
||||
}
|
||||
|
||||
return envelope, inflatedAttachments, nil
|
||||
}
|
||||
|
||||
func inflateAttachments(raw []attachmentJSON) ([]Attachment, error) {
|
||||
attachments := make([]Attachment, 0, len(raw))
|
||||
for index, entry := range raw {
|
||||
if entry.Filename == nil {
|
||||
return nil, fmt.Errorf("payload_json.attachments[%d].filename is required", index)
|
||||
}
|
||||
if entry.ContentType == nil {
|
||||
return nil, fmt.Errorf("payload_json.attachments[%d].content_type is required", index)
|
||||
}
|
||||
if entry.ContentBase64 == nil {
|
||||
return nil, fmt.Errorf("payload_json.attachments[%d].content_base64 is required", index)
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(*entry.ContentBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"payload_json.attachments[%d].content_base64 must be valid base64: %w",
|
||||
index,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
attachments = append(attachments, Attachment{
|
||||
Filename: *entry.Filename,
|
||||
ContentType: *entry.ContentType,
|
||||
ContentBase64: *entry.ContentBase64,
|
||||
SizeBytes: int64(len(decoded)),
|
||||
})
|
||||
}
|
||||
|
||||
return attachments, nil
|
||||
}
|
||||
|
||||
func inflateEmails(values []string) []common.Email {
|
||||
emails := make([]common.Email, len(values))
|
||||
for index, value := range values {
|
||||
emails[index] = common.Email(value)
|
||||
}
|
||||
|
||||
return emails
|
||||
}
|
||||
|
||||
func decodeVariables(raw json.RawMessage) (map[string]any, error) {
|
||||
var variables map[string]any
|
||||
if err := decodeStrictJSON("decode payload_json.variables", string(raw), &variables); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if variables == nil {
|
||||
return nil, errors.New("payload_json.variables must be a JSON object")
|
||||
}
|
||||
|
||||
return variables, nil
|
||||
}
|
||||
|
||||
func decodeStrictJSON(label string, raw string, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewBufferString(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("%s: unexpected trailing JSON input", label)
|
||||
}
|
||||
|
||||
return fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneEmails(values []common.Email) []string {
|
||||
result := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
result[index] = value.String()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneAttachments(values []Attachment) []Attachment {
|
||||
result := make([]Attachment, len(values))
|
||||
copy(result, values)
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package streamcommand
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDecodeCommandSuccessRendered(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
command, err := DecodeCommand(validRenderedFields(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, Command{
|
||||
DeliveryID: common.DeliveryID("mail-123"),
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeRendered,
|
||||
IdempotencyKey: common.IdempotencyKey("notification:mail-123"),
|
||||
RequestedAt: mustUnixMilli(1_775_121_700_000),
|
||||
RequestID: "req-123",
|
||||
TraceID: "trace-123",
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: []common.Email{"pilot@example.com"},
|
||||
Cc: []common.Email{},
|
||||
Bcc: []common.Email{},
|
||||
ReplyTo: []common.Email{"noreply@example.com"},
|
||||
},
|
||||
Attachments: []Attachment{
|
||||
{
|
||||
Filename: "report.txt",
|
||||
ContentType: "text/plain",
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte("report")),
|
||||
SizeBytes: 6,
|
||||
},
|
||||
},
|
||||
Subject: "Turn ready",
|
||||
TextBody: "Turn 54 is ready.",
|
||||
HTMLBody: "<p>Turn 54 is ready.</p>",
|
||||
}, command)
|
||||
}
|
||||
|
||||
func TestDecodeCommandSuccessTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
command, err := DecodeCommand(validTemplateFields(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, common.TemplateID("game.turn_ready"), command.TemplateID)
|
||||
require.Equal(t, common.Locale("fr-FR"), command.Locale)
|
||||
require.Equal(t, map[string]any{
|
||||
"turn_number": float64(54),
|
||||
"player": map[string]any{
|
||||
"name": "Pilot",
|
||||
},
|
||||
}, command.Variables)
|
||||
require.Empty(t, command.Subject)
|
||||
require.Empty(t, command.TextBody)
|
||||
}
|
||||
|
||||
func TestDecodeCommandRejectsInvalidEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fields map[string]any
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing required field",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
delete(fields, fieldDeliveryID)
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "missing required fields: delivery_id",
|
||||
},
|
||||
{
|
||||
name: "unsupported field",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields["extra"] = "value"
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "unsupported fields: extra",
|
||||
},
|
||||
{
|
||||
name: "non string field",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldDeliveryID] = 42
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: `stream field "delivery_id" must be a string`,
|
||||
},
|
||||
{
|
||||
name: "invalid requested at",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldRequestedAtMS] = "not-a-timestamp"
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: `stream field "requested_at_ms" must be a base-10 Unix milliseconds string`,
|
||||
},
|
||||
{
|
||||
name: "unsupported source",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldSource] = "operator_resend"
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: `stream command source "operator_resend" is unsupported`,
|
||||
},
|
||||
{
|
||||
name: "unsupported payload mode",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadMode] = "unknown"
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: `stream field "payload_mode" value "unknown" is unsupported`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := DecodeCommand(tt.fields)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCommandRejectsInvalidPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fields map[string]any
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "payload must be object",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = `[]`
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "decode payload_json",
|
||||
},
|
||||
{
|
||||
name: "rendered payload unknown field",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": []map[string]any{},
|
||||
"template_id": "game.turn_ready",
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "unknown field",
|
||||
},
|
||||
{
|
||||
name: "trailing json input",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = validRenderedPayloadJSON(t) + `{}`
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "unexpected trailing JSON input",
|
||||
},
|
||||
{
|
||||
name: "empty recipients",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": []map[string]any{},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "must contain at least one recipient",
|
||||
},
|
||||
{
|
||||
name: "invalid locale",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validTemplateFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"template_id": "game.turn_ready",
|
||||
"locale": "english",
|
||||
"variables": map[string]any{},
|
||||
"attachments": []map[string]any{},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "payload_json.locale:",
|
||||
},
|
||||
{
|
||||
name: "variables must be object",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validTemplateFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"template_id": "game.turn_ready",
|
||||
"locale": "fr-FR",
|
||||
"variables": []string{"not", "object"},
|
||||
"attachments": []map[string]any{},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "decode payload_json.variables",
|
||||
},
|
||||
{
|
||||
name: "invalid attachment base64",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": []map[string]any{
|
||||
{
|
||||
"filename": "report.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": "!@#",
|
||||
},
|
||||
},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "content_base64 must be valid base64",
|
||||
},
|
||||
{
|
||||
name: "too many attachments",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
attachments := make([]map[string]any, 0, MaxAttachments+1)
|
||||
for index := 0; index < MaxAttachments+1; index++ {
|
||||
attachments = append(attachments, map[string]any{
|
||||
"filename": "report.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": base64.StdEncoding.EncodeToString([]byte("a")),
|
||||
})
|
||||
}
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": attachments,
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "must contain at most 5 entries",
|
||||
},
|
||||
{
|
||||
name: "encoded attachment payload limit exceeded",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": []map[string]any{{
|
||||
"filename": "report.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": oversizedBase64(),
|
||||
}},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "encoded attachment payload must not exceed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := DecodeCommand(tt.fields)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandFingerprintIgnoresTracingFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first, err := DecodeCommand(validRenderedFields(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
secondFields := validRenderedFields(t)
|
||||
secondFields[fieldRequestID] = "req-456"
|
||||
secondFields[fieldTraceID] = "trace-456"
|
||||
second, err := DecodeCommand(secondFields)
|
||||
require.NoError(t, err)
|
||||
|
||||
firstFingerprint, err := first.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
secondFingerprint, err := second.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, firstFingerprint, secondFingerprint)
|
||||
}
|
||||
|
||||
func TestCommandFingerprintChangesForBusinessFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first, err := DecodeCommand(validRenderedFields(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
secondFields := validRenderedFields(t)
|
||||
secondFields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{"noreply@example.com"},
|
||||
"subject": "Different subject",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"html_body": "<p>Turn 54 is ready.</p>",
|
||||
"attachments": []map[string]any{{"filename": "report.txt", "content_type": "text/plain", "content_base64": base64.StdEncoding.EncodeToString([]byte("report"))}},
|
||||
})
|
||||
second, err := DecodeCommand(secondFields)
|
||||
require.NoError(t, err)
|
||||
|
||||
firstFingerprint, err := first.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
secondFingerprint, err := second.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotEqual(t, firstFingerprint, secondFingerprint)
|
||||
}
|
||||
|
||||
func validRenderedFields(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
return map[string]any{
|
||||
fieldDeliveryID: "mail-123",
|
||||
fieldSource: "notification",
|
||||
fieldPayloadMode: "rendered",
|
||||
fieldIdempotency: "notification:mail-123",
|
||||
fieldRequestedAtMS: "1775121700000",
|
||||
fieldRequestID: "req-123",
|
||||
fieldTraceID: "trace-123",
|
||||
fieldPayloadJSON: validRenderedPayloadJSON(t),
|
||||
}
|
||||
}
|
||||
|
||||
func validTemplateFields(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
return map[string]any{
|
||||
fieldDeliveryID: "mail-124",
|
||||
fieldSource: "notification",
|
||||
fieldPayloadMode: "template",
|
||||
fieldIdempotency: "notification:mail-124",
|
||||
fieldRequestedAtMS: "1775121700001",
|
||||
fieldPayloadJSON: validTemplatePayloadJSON(t),
|
||||
}
|
||||
}
|
||||
|
||||
func validRenderedPayloadJSON(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{"noreply@example.com"},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"html_body": "<p>Turn 54 is ready.</p>",
|
||||
"attachments": []map[string]any{
|
||||
{
|
||||
"filename": "report.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": base64.StdEncoding.EncodeToString([]byte("report")),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func validTemplatePayloadJSON(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"template_id": "game.turn_ready",
|
||||
"locale": "fr-FR",
|
||||
"variables": map[string]any{
|
||||
"turn_number": 54,
|
||||
"player": map[string]any{
|
||||
"name": "Pilot",
|
||||
},
|
||||
},
|
||||
"attachments": []map[string]any{},
|
||||
})
|
||||
}
|
||||
|
||||
func mustJSONString(t *testing.T, value any) string {
|
||||
t.Helper()
|
||||
|
||||
payload, err := json.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
|
||||
return string(payload)
|
||||
}
|
||||
|
||||
func oversizedBase64() string {
|
||||
return string(bytesOf('A', MaxEncodedAttachmentPayloadBytes+4))
|
||||
}
|
||||
|
||||
func bytesOf(value byte, size int) []byte {
|
||||
result := make([]byte, size)
|
||||
for index := range result {
|
||||
result[index] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mustUnixMilli(value int64) time.Time {
|
||||
return time.UnixMilli(value).UTC()
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Package app wires the Mail Service process lifecycle and coordinates
|
||||
// component startup and graceful shutdown.
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"galaxy/mail/internal/config"
|
||||
)
|
||||
|
||||
// Component is a long-lived Mail Service subsystem that participates in
|
||||
// coordinated startup and graceful shutdown.
|
||||
type Component interface {
|
||||
// Run starts the component and blocks until it stops.
|
||||
Run(context.Context) error
|
||||
|
||||
// Shutdown stops the component within the provided timeout-bounded context.
|
||||
Shutdown(context.Context) error
|
||||
}
|
||||
|
||||
// App owns the process-level lifecycle of Mail Service and its registered
|
||||
// components.
|
||||
type App struct {
|
||||
cfg config.Config
|
||||
components []Component
|
||||
}
|
||||
|
||||
// New constructs App with a defensive copy of the supplied components.
|
||||
func New(cfg config.Config, components ...Component) *App {
|
||||
clonedComponents := append([]Component(nil), components...)
|
||||
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
components: clonedComponents,
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts all configured components, waits for cancellation or the first
|
||||
// component failure, and then executes best-effort graceful shutdown.
|
||||
func (app *App) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run mail app: nil context")
|
||||
}
|
||||
if err := app.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(app.components) == 0 {
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
results := make(chan componentResult, len(app.components))
|
||||
var runWaitGroup sync.WaitGroup
|
||||
|
||||
for index, component := range app.components {
|
||||
runWaitGroup.Add(1)
|
||||
|
||||
go func(componentIndex int, component Component) {
|
||||
defer runWaitGroup.Done()
|
||||
results <- componentResult{
|
||||
index: componentIndex,
|
||||
err: component.Run(runCtx),
|
||||
}
|
||||
}(index, component)
|
||||
}
|
||||
|
||||
var runErr error
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case result := <-results:
|
||||
runErr = classifyComponentResult(ctx, result)
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
shutdownErr := app.shutdownComponents()
|
||||
waitErr := app.waitForComponents(&runWaitGroup)
|
||||
|
||||
return errors.Join(runErr, shutdownErr, waitErr)
|
||||
}
|
||||
|
||||
type componentResult struct {
|
||||
index int
|
||||
err error
|
||||
}
|
||||
|
||||
func (app *App) validate() error {
|
||||
if app.cfg.ShutdownTimeout <= 0 {
|
||||
return fmt.Errorf("run mail app: shutdown timeout must be positive, got %s", app.cfg.ShutdownTimeout)
|
||||
}
|
||||
|
||||
for index, component := range app.components {
|
||||
if component == nil {
|
||||
return fmt.Errorf("run mail app: component %d is nil", index)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func classifyComponentResult(parentCtx context.Context, result componentResult) error {
|
||||
switch {
|
||||
case result.err == nil:
|
||||
if parentCtx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("run mail app: component %d exited without error before shutdown", result.index)
|
||||
case errors.Is(result.err, context.Canceled) && parentCtx.Err() != nil:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("run mail app: component %d: %w", result.index, result.err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) shutdownComponents() error {
|
||||
var shutdownWaitGroup sync.WaitGroup
|
||||
errs := make(chan error, len(app.components))
|
||||
|
||||
for index, component := range app.components {
|
||||
shutdownWaitGroup.Add(1)
|
||||
|
||||
go func(componentIndex int, component Component) {
|
||||
defer shutdownWaitGroup.Done()
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), app.cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := component.Shutdown(shutdownCtx); err != nil {
|
||||
errs <- fmt.Errorf("shutdown mail component %d: %w", componentIndex, err)
|
||||
}
|
||||
}(index, component)
|
||||
}
|
||||
|
||||
shutdownWaitGroup.Wait()
|
||||
close(errs)
|
||||
|
||||
var joined error
|
||||
for err := range errs {
|
||||
joined = errors.Join(joined, err)
|
||||
}
|
||||
|
||||
return joined
|
||||
}
|
||||
|
||||
func (app *App) waitForComponents(runWaitGroup *sync.WaitGroup) error {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
runWaitGroup.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
waitCtx, cancel := context.WithTimeout(context.Background(), app.cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-waitCtx.Done():
|
||||
return fmt.Errorf("wait for mail components: %w", waitCtx.Err())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/config"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAppRunStopsComponentsOnContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
component := &blockingComponent{}
|
||||
app := New(config.Config{ShutdownTimeout: time.Second}, component)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- app.Run(ctx)
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
component.mu.Lock()
|
||||
defer component.mu.Unlock()
|
||||
return component.runStarted
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
cancel()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
select {
|
||||
case err := <-done:
|
||||
return err == nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
require.Equal(t, 1, component.shutdownCalls)
|
||||
}
|
||||
|
||||
func TestAppRunReportsEarlyComponentExit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := New(config.Config{ShutdownTimeout: time.Second}, componentFunc(func(context.Context) error {
|
||||
return nil
|
||||
}))
|
||||
|
||||
err := app.Run(context.Background())
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "exited without error before shutdown")
|
||||
}
|
||||
|
||||
type blockingComponent struct {
|
||||
mu sync.Mutex
|
||||
runStarted bool
|
||||
shutdownCalls int
|
||||
}
|
||||
|
||||
func (component *blockingComponent) Run(ctx context.Context) error {
|
||||
component.mu.Lock()
|
||||
component.runStarted = true
|
||||
component.mu.Unlock()
|
||||
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func (component *blockingComponent) Shutdown(context.Context) error {
|
||||
component.shutdownCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
type componentFunc func(context.Context) error
|
||||
|
||||
func (fn componentFunc) Run(ctx context.Context) error {
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
func (fn componentFunc) Shutdown(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"galaxy/mail/internal/adapters/smtp"
|
||||
"galaxy/mail/internal/adapters/stubprovider"
|
||||
templatedir "galaxy/mail/internal/adapters/templates"
|
||||
"galaxy/mail/internal/config"
|
||||
"galaxy/mail/internal/ports"
|
||||
"galaxy/mail/internal/telemetry"
|
||||
|
||||
"github.com/redis/go-redis/extra/redisotel/v9"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func newRedisClient(cfg config.RedisConfig) *redis.Client {
|
||||
return redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Addr,
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
DB: cfg.DB,
|
||||
TLSConfig: cfg.TLSConfig(),
|
||||
DialTimeout: cfg.OperationTimeout,
|
||||
ReadTimeout: cfg.OperationTimeout,
|
||||
WriteTimeout: cfg.OperationTimeout,
|
||||
})
|
||||
}
|
||||
|
||||
func instrumentRedisClient(client *redis.Client, telemetryRuntime *telemetry.Runtime) error {
|
||||
if client == nil {
|
||||
return fmt.Errorf("instrument redis client: nil client")
|
||||
}
|
||||
if telemetryRuntime == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := redisotel.InstrumentTracing(
|
||||
client,
|
||||
redisotel.WithTracerProvider(telemetryRuntime.TracerProvider()),
|
||||
redisotel.WithDBStatement(false),
|
||||
); err != nil {
|
||||
return fmt.Errorf("instrument redis client tracing: %w", err)
|
||||
}
|
||||
if err := redisotel.InstrumentMetrics(
|
||||
client,
|
||||
redisotel.WithMeterProvider(telemetryRuntime.MeterProvider()),
|
||||
); err != nil {
|
||||
return fmt.Errorf("instrument redis client metrics: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func pingRedis(ctx context.Context, cfg config.RedisConfig, client *redis.Client) error {
|
||||
if client == nil {
|
||||
return fmt.Errorf("ping redis: nil client")
|
||||
}
|
||||
|
||||
pingCtx, cancel := context.WithTimeout(ctx, cfg.OperationTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := client.Ping(pingCtx).Err(); err != nil {
|
||||
return fmt.Errorf("ping redis: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTemplateCatalog(cfg config.TemplateConfig) (*templatedir.Catalog, error) {
|
||||
catalog, err := templatedir.NewCatalog(cfg.Dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new template catalog: %w", err)
|
||||
}
|
||||
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
func newProvider(cfg config.SMTPConfig, logger *slog.Logger) (ports.Provider, error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
switch cfg.Mode {
|
||||
case config.SMTPModeStub:
|
||||
provider, err := stubprovider.New()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new stub provider: %w", err)
|
||||
}
|
||||
logger.Info("mail provider configured", "mode", cfg.Mode)
|
||||
return provider, nil
|
||||
case config.SMTPModeSMTP:
|
||||
provider, err := smtp.New(smtp.Config{
|
||||
Addr: cfg.Addr,
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
FromEmail: cfg.FromEmail,
|
||||
FromName: cfg.FromName,
|
||||
Timeout: cfg.Timeout,
|
||||
InsecureSkipVerify: cfg.InsecureSkipVerify,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new smtp provider: %w", err)
|
||||
}
|
||||
logger.Info("mail provider configured", "mode", cfg.Mode, "addr", cfg.Addr)
|
||||
return provider, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("new provider: unsupported mode %q", cfg.Mode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/config"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewProviderBuildsStubProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider, err := newProvider(config.SMTPConfig{
|
||||
Mode: config.SMTPModeStub,
|
||||
}, bootstrapTestLogger())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, provider.Close())
|
||||
}
|
||||
|
||||
func TestNewProviderBuildsSMTPProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider, err := newProvider(config.SMTPConfig{
|
||||
Mode: config.SMTPModeSMTP,
|
||||
Addr: "127.0.0.1:2525",
|
||||
FromEmail: "noreply@example.com",
|
||||
Timeout: 15 * time.Second,
|
||||
}, bootstrapTestLogger())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, provider.Close())
|
||||
}
|
||||
|
||||
func TestNewProviderRejectsInvalidSMTPAuthPair(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := newProvider(config.SMTPConfig{
|
||||
Mode: config.SMTPModeSMTP,
|
||||
Addr: "127.0.0.1:2525",
|
||||
Username: "mailer",
|
||||
FromEmail: "noreply@example.com",
|
||||
Timeout: 15 * time.Second,
|
||||
}, bootstrapTestLogger())
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "smtp username and password")
|
||||
}
|
||||
|
||||
func bootstrapTestLogger() *slog.Logger {
|
||||
return slog.New(slog.NewJSONHandler(io.Discard, nil))
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/adapters/id"
|
||||
"galaxy/mail/internal/adapters/redisstate"
|
||||
templatedir "galaxy/mail/internal/adapters/templates"
|
||||
"galaxy/mail/internal/api/internalhttp"
|
||||
"galaxy/mail/internal/config"
|
||||
"galaxy/mail/internal/service/acceptauthdelivery"
|
||||
"galaxy/mail/internal/service/acceptgenericdelivery"
|
||||
"galaxy/mail/internal/service/executeattempt"
|
||||
"galaxy/mail/internal/service/getdelivery"
|
||||
"galaxy/mail/internal/service/listattempts"
|
||||
"galaxy/mail/internal/service/listdeliveries"
|
||||
"galaxy/mail/internal/service/renderdelivery"
|
||||
"galaxy/mail/internal/service/resenddelivery"
|
||||
"galaxy/mail/internal/telemetry"
|
||||
"galaxy/mail/internal/worker"
|
||||
"galaxy/mail/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Runtime owns the runnable Mail Service process plus the cleanup functions
|
||||
// that release runtime resources after shutdown.
|
||||
type Runtime struct {
|
||||
cfg config.Config
|
||||
|
||||
app *App
|
||||
|
||||
templateCatalog *templatedir.Catalog
|
||||
renderDeliveryService *renderdelivery.Service
|
||||
|
||||
cleanupFns []func() error
|
||||
}
|
||||
|
||||
type runtimeClock interface {
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
type runtimeProviderFactory func(config.SMTPConfig, *slog.Logger) (ports.Provider, error)
|
||||
|
||||
type runtimeDependencies struct {
|
||||
clock runtimeClock
|
||||
providerFactory runtimeProviderFactory
|
||||
schedulerPoll time.Duration
|
||||
schedulerRecovery time.Duration
|
||||
schedulerGrace time.Duration
|
||||
}
|
||||
|
||||
func (deps runtimeDependencies) withDefaults() runtimeDependencies {
|
||||
if deps.clock == nil {
|
||||
deps.clock = systemClock{}
|
||||
}
|
||||
if deps.providerFactory == nil {
|
||||
deps.providerFactory = newProvider
|
||||
}
|
||||
|
||||
return deps
|
||||
}
|
||||
|
||||
// NewRuntime constructs the runnable Mail Service process from cfg.
|
||||
func NewRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger) (*Runtime, error) {
|
||||
return newRuntime(ctx, cfg, logger, runtimeDependencies{})
|
||||
}
|
||||
|
||||
func newRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger, deps runtimeDependencies) (*Runtime, error) {
|
||||
if ctx == nil {
|
||||
return nil, fmt.Errorf("new mail runtime: nil context")
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("new mail runtime: %w", err)
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
deps = deps.withDefaults()
|
||||
|
||||
runtime := &Runtime{
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
cleanupOnError := func(err error) (*Runtime, error) {
|
||||
if cleanupErr := runtime.Close(); cleanupErr != nil {
|
||||
return nil, fmt.Errorf("%w; cleanup: %w", err, cleanupErr)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
telemetryRuntime, err := telemetry.NewProcess(ctx, telemetry.ProcessConfig{
|
||||
ServiceName: cfg.Telemetry.ServiceName,
|
||||
TracesExporter: cfg.Telemetry.TracesExporter,
|
||||
MetricsExporter: cfg.Telemetry.MetricsExporter,
|
||||
TracesProtocol: cfg.Telemetry.TracesProtocol,
|
||||
MetricsProtocol: cfg.Telemetry.MetricsProtocol,
|
||||
StdoutTracesEnabled: cfg.Telemetry.StdoutTracesEnabled,
|
||||
StdoutMetricsEnabled: cfg.Telemetry.StdoutMetricsEnabled,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: telemetry: %w", err))
|
||||
}
|
||||
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
return telemetryRuntime.Shutdown(shutdownCtx)
|
||||
})
|
||||
|
||||
redisClient := newRedisClient(cfg.Redis)
|
||||
if err := instrumentRedisClient(redisClient, telemetryRuntime); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: %w", err))
|
||||
}
|
||||
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
|
||||
return redisClient.Close()
|
||||
})
|
||||
if err := pingRedis(ctx, cfg.Redis, redisClient); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: %w", err))
|
||||
}
|
||||
|
||||
templateCatalog, err := newTemplateCatalog(cfg.Templates)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: %w", err))
|
||||
}
|
||||
runtime.templateCatalog = templateCatalog
|
||||
|
||||
provider, err := deps.providerFactory(cfg.SMTP, logger.With("component", "provider"))
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: %w", err))
|
||||
}
|
||||
runtime.cleanupFns = append(runtime.cleanupFns, provider.Close)
|
||||
|
||||
acceptanceStore, err := redisstate.NewAcceptanceStore(redisClient)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: auth acceptance store: %w", err))
|
||||
}
|
||||
authAcceptanceService, err := acceptauthdelivery.New(acceptauthdelivery.Config{
|
||||
Store: acceptanceStore,
|
||||
DeliveryIDGenerator: id.Generator{},
|
||||
Clock: deps.clock,
|
||||
Telemetry: telemetryRuntime,
|
||||
TracerProvider: telemetryRuntime.TracerProvider(),
|
||||
Logger: logger,
|
||||
IdempotencyTTL: redisstate.IdempotencyTTL,
|
||||
SuppressOutbound: cfg.SMTP.Mode == config.SMTPModeStub,
|
||||
})
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: auth acceptance service: %w", err))
|
||||
}
|
||||
|
||||
genericAcceptanceStore, err := redisstate.NewGenericAcceptanceStore(redisClient)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: generic acceptance store: %w", err))
|
||||
}
|
||||
genericAcceptanceService, err := acceptgenericdelivery.New(acceptgenericdelivery.Config{
|
||||
Store: genericAcceptanceStore,
|
||||
Clock: deps.clock,
|
||||
Telemetry: telemetryRuntime,
|
||||
TracerProvider: telemetryRuntime.TracerProvider(),
|
||||
Logger: logger,
|
||||
IdempotencyTTL: redisstate.IdempotencyTTL,
|
||||
})
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: generic acceptance service: %w", err))
|
||||
}
|
||||
|
||||
renderStore, err := redisstate.NewRenderStore(redisClient)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: render store: %w", err))
|
||||
}
|
||||
renderDeliveryService, err := renderdelivery.New(renderdelivery.Config{
|
||||
Catalog: templateCatalog,
|
||||
Store: renderStore,
|
||||
Clock: deps.clock,
|
||||
Telemetry: telemetryRuntime,
|
||||
TracerProvider: telemetryRuntime.TracerProvider(),
|
||||
Logger: logger,
|
||||
})
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: render delivery service: %w", err))
|
||||
}
|
||||
runtime.renderDeliveryService = renderDeliveryService
|
||||
|
||||
malformedCommandStore, err := redisstate.NewMalformedCommandStore(redisClient)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: malformed command store: %w", err))
|
||||
}
|
||||
streamOffsetStore, err := redisstate.NewStreamOffsetStore(redisClient)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: stream offset store: %w", err))
|
||||
}
|
||||
attemptExecutionStore, err := redisstate.NewAttemptExecutionStore(redisClient)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: attempt execution store: %w", err))
|
||||
}
|
||||
telemetryRuntime.SetAttemptScheduleSnapshotReader(attemptExecutionStore)
|
||||
operatorStore, err := redisstate.NewOperatorStore(redisClient)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: operator store: %w", err))
|
||||
}
|
||||
attemptExecutionService, err := executeattempt.New(executeattempt.Config{
|
||||
Renderer: renderDeliveryService,
|
||||
Provider: provider,
|
||||
PayloadLoader: attemptExecutionStore,
|
||||
Store: attemptExecutionStore,
|
||||
Clock: deps.clock,
|
||||
Telemetry: telemetryRuntime,
|
||||
TracerProvider: telemetryRuntime.TracerProvider(),
|
||||
Logger: logger,
|
||||
AttemptTimeout: cfg.SMTP.Timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: attempt execution service: %w", err))
|
||||
}
|
||||
listDeliveriesService, err := listdeliveries.New(listdeliveries.Config{
|
||||
Store: operatorStore,
|
||||
})
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: list deliveries service: %w", err))
|
||||
}
|
||||
getDeliveryService, err := getdelivery.New(getdelivery.Config{
|
||||
Store: operatorStore,
|
||||
})
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: get delivery service: %w", err))
|
||||
}
|
||||
listAttemptsService, err := listattempts.New(listattempts.Config{
|
||||
Store: operatorStore,
|
||||
})
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: list attempts service: %w", err))
|
||||
}
|
||||
resendDeliveryService, err := resenddelivery.New(resenddelivery.Config{
|
||||
Store: operatorStore,
|
||||
DeliveryIDGenerator: id.Generator{},
|
||||
Clock: deps.clock,
|
||||
Telemetry: telemetryRuntime,
|
||||
TracerProvider: telemetryRuntime.TracerProvider(),
|
||||
Logger: logger,
|
||||
})
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: resend delivery service: %w", err))
|
||||
}
|
||||
|
||||
commandConsumerRedisClient := newRedisClient(cfg.Redis)
|
||||
if err := instrumentRedisClient(commandConsumerRedisClient, telemetryRuntime); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: %w", err))
|
||||
}
|
||||
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
|
||||
err := commandConsumerRedisClient.Close()
|
||||
if errors.Is(err, redis.ErrClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err := pingRedis(ctx, cfg.Redis, commandConsumerRedisClient); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: %w", err))
|
||||
}
|
||||
|
||||
httpServer, err := internalhttp.NewServer(internalhttp.Config{
|
||||
Addr: cfg.InternalHTTP.Addr,
|
||||
ReadHeaderTimeout: cfg.InternalHTTP.ReadHeaderTimeout,
|
||||
ReadTimeout: cfg.InternalHTTP.ReadTimeout,
|
||||
IdleTimeout: cfg.InternalHTTP.IdleTimeout,
|
||||
}, internalhttp.Dependencies{
|
||||
Logger: logger,
|
||||
Telemetry: telemetryRuntime,
|
||||
AcceptLoginCodeDelivery: authAcceptanceService,
|
||||
ListDeliveries: listDeliveriesService,
|
||||
GetDelivery: getDeliveryService,
|
||||
ListAttempts: listAttemptsService,
|
||||
ResendDelivery: resendDeliveryService,
|
||||
OperatorRequestTimeout: cfg.OperatorRequestTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: internal HTTP server: %w", err))
|
||||
}
|
||||
|
||||
commandConsumer, err := worker.NewCommandConsumer(worker.CommandConsumerConfig{
|
||||
Client: commandConsumerRedisClient,
|
||||
Stream: cfg.Redis.CommandStream,
|
||||
BlockTimeout: cfg.StreamBlockTimeout,
|
||||
Acceptor: genericAcceptanceService,
|
||||
MalformedRecorder: malformedCommandStore,
|
||||
OffsetStore: streamOffsetStore,
|
||||
Telemetry: telemetryRuntime,
|
||||
Clock: deps.clock,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: command consumer: %w", err))
|
||||
}
|
||||
attemptWorkQueue := make(chan executeattempt.WorkItem, cfg.AttemptWorkerConcurrency)
|
||||
scheduler, err := worker.NewScheduler(worker.SchedulerConfig{
|
||||
Store: attemptExecutionStore,
|
||||
Service: attemptExecutionService,
|
||||
WorkQueue: attemptWorkQueue,
|
||||
Clock: deps.clock,
|
||||
AttemptTimeout: cfg.SMTP.Timeout,
|
||||
Telemetry: telemetryRuntime,
|
||||
PollInterval: deps.schedulerPoll,
|
||||
RecoveryInterval: deps.schedulerRecovery,
|
||||
RecoveryGrace: deps.schedulerGrace,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: scheduler: %w", err))
|
||||
}
|
||||
attemptWorkers, err := worker.NewAttemptWorkerPool(worker.AttemptWorkerPoolConfig{
|
||||
Concurrency: cfg.AttemptWorkerConcurrency,
|
||||
WorkQueue: attemptWorkQueue,
|
||||
Service: attemptExecutionService,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: attempt worker pool: %w", err))
|
||||
}
|
||||
indexCleaner, err := redisstate.NewIndexCleaner(redisClient)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: cleanup index cleaner: %w", err))
|
||||
}
|
||||
cleanupWorker, err := worker.NewCleanupWorker(indexCleaner, logger)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new mail runtime: cleanup worker: %w", err))
|
||||
}
|
||||
|
||||
runtime.app = New(cfg, httpServer, commandConsumer, scheduler, attemptWorkers, cleanupWorker)
|
||||
|
||||
return runtime, nil
|
||||
}
|
||||
|
||||
type systemClock struct{}
|
||||
|
||||
func (systemClock) Now() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// Run serves the internal HTTP listener and background workers until ctx is
|
||||
// canceled or one component fails.
|
||||
func (runtime *Runtime) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run mail runtime: nil context")
|
||||
}
|
||||
if runtime == nil {
|
||||
return errors.New("run mail runtime: nil runtime")
|
||||
}
|
||||
if runtime.app == nil {
|
||||
return errors.New("run mail runtime: nil app")
|
||||
}
|
||||
|
||||
return runtime.app.Run(ctx)
|
||||
}
|
||||
|
||||
// Close releases every runtime dependency in reverse construction order.
|
||||
func (runtime *Runtime) Close() error {
|
||||
if runtime == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var joined error
|
||||
for index := len(runtime.cleanupFns) - 1; index >= 0; index-- {
|
||||
if err := runtime.cleanupFns[index](); err != nil {
|
||||
joined = errors.Join(joined, err)
|
||||
}
|
||||
}
|
||||
|
||||
return joined
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
smtpadapter "galaxy/mail/internal/adapters/smtp"
|
||||
"galaxy/mail/internal/config"
|
||||
"galaxy/mail/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
testcontainers "github.com/testcontainers/testcontainers-go"
|
||||
rediscontainer "github.com/testcontainers/testcontainers-go/modules/redis"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
const (
|
||||
realRuntimeSmokeEnv = "MAIL_REAL_RUNTIME_SMOKE"
|
||||
realRuntimeRedisImage = "redis:7"
|
||||
realRuntimeMailpitImage = "axllent/mailpit:v1.28.2"
|
||||
realRuntimeMailpitCert = "/tmp/mailpit/server.crt"
|
||||
realRuntimeMailpitKey = "/tmp/mailpit/server.key"
|
||||
)
|
||||
|
||||
func TestRealRuntimeCompatibility(t *testing.T) {
|
||||
if os.Getenv(realRuntimeSmokeEnv) != "1" {
|
||||
t.Skipf("set %s=1 to run the real runtime smoke suite", realRuntimeSmokeEnv)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
redisContainer, err := rediscontainer.Run(ctx, realRuntimeRedisImage)
|
||||
require.NoError(t, err)
|
||||
testcontainers.CleanupContainer(t, redisContainer)
|
||||
|
||||
redisAddr, err := redisContainer.Endpoint(ctx, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
certFiles := writeMailpitTLSFiles(t)
|
||||
mailpitContainer, err := testcontainers.Run(
|
||||
ctx,
|
||||
realRuntimeMailpitImage,
|
||||
testcontainers.WithExposedPorts("1025/tcp", "8025/tcp"),
|
||||
testcontainers.WithFiles(
|
||||
testcontainers.ContainerFile{
|
||||
HostFilePath: certFiles.certPath,
|
||||
ContainerFilePath: realRuntimeMailpitCert,
|
||||
FileMode: 0o644,
|
||||
},
|
||||
testcontainers.ContainerFile{
|
||||
HostFilePath: certFiles.keyPath,
|
||||
ContainerFilePath: realRuntimeMailpitKey,
|
||||
FileMode: 0o600,
|
||||
},
|
||||
),
|
||||
testcontainers.WithEnv(map[string]string{
|
||||
"MP_SMTP_TLS_CERT": realRuntimeMailpitCert,
|
||||
"MP_SMTP_TLS_KEY": realRuntimeMailpitKey,
|
||||
"MP_SMTP_REQUIRE_STARTTLS": "true",
|
||||
}),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForAll(
|
||||
wait.ForListeningPort("1025/tcp"),
|
||||
wait.ForListeningPort("8025/tcp"),
|
||||
).WithDeadline(30*time.Second),
|
||||
),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
testcontainers.CleanupContainer(t, mailpitContainer)
|
||||
|
||||
smtpAddr, err := mailpitContainer.PortEndpoint(ctx, "1025/tcp", "")
|
||||
require.NoError(t, err)
|
||||
mailpitHTTPBaseURL, err := mailpitContainer.PortEndpoint(ctx, "8025/tcp", "http")
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Redis.Addr = redisAddr
|
||||
cfg.Templates.Dir = writeRuntimeTemplates(t)
|
||||
cfg.InternalHTTP.Addr = mustFreeAddr(t)
|
||||
cfg.ShutdownTimeout = time.Second
|
||||
cfg.StreamBlockTimeout = 100 * time.Millisecond
|
||||
cfg.AttemptWorkerConcurrency = 1
|
||||
cfg.OperatorRequestTimeout = time.Second
|
||||
cfg.SMTP.Mode = config.SMTPModeSMTP
|
||||
cfg.SMTP.Addr = smtpAddr
|
||||
cfg.SMTP.FromEmail = "noreply@example.com"
|
||||
cfg.SMTP.Timeout = 2 * time.Second
|
||||
|
||||
instance := startSmokeRuntime(t, cfg, runtimeDependencies{
|
||||
providerFactory: func(cfg config.SMTPConfig, _ *slog.Logger) (ports.Provider, error) {
|
||||
return smtpadapter.New(smtpadapter.Config{
|
||||
Addr: cfg.Addr,
|
||||
FromEmail: cfg.FromEmail,
|
||||
FromName: cfg.FromName,
|
||||
Timeout: cfg.Timeout,
|
||||
TLSConfig: certFiles.clientTLSConfig,
|
||||
})
|
||||
},
|
||||
schedulerPoll: 25 * time.Millisecond,
|
||||
})
|
||||
|
||||
response := postLoginCodeDelivery(t, instance.baseURL, loginCodeDeliveryRequest{
|
||||
idempotencyKey: "real-runtime-smoke",
|
||||
email: "pilot@example.com",
|
||||
code: "246810",
|
||||
locale: "fr-FR",
|
||||
})
|
||||
require.Equal(t, "sent", string(response.Outcome))
|
||||
|
||||
list := eventuallyListDeliveries(t, instance.baseURL, url.Values{
|
||||
"source": []string{"authsession"},
|
||||
"idempotency_key": []string{"real-runtime-smoke"},
|
||||
})
|
||||
require.Len(t, list.Items, 1)
|
||||
|
||||
detail := eventuallyDeliveryStatus(t, instance.baseURL, list.Items[0].DeliveryID, "sent")
|
||||
require.Equal(t, "authsession", detail.Source)
|
||||
require.Equal(t, "auth.login_code", detail.TemplateID)
|
||||
require.Equal(t, "fr-FR", detail.Locale)
|
||||
require.True(t, detail.LocaleFallbackUsed)
|
||||
require.Equal(t, []string{"pilot@example.com"}, detail.To)
|
||||
|
||||
attempts := getDeliveryAttempts(t, instance.baseURL, detail.DeliveryID)
|
||||
require.Len(t, attempts.Items, 1)
|
||||
require.Equal(t, "provider_accepted", attempts.Items[0].Status)
|
||||
|
||||
messageText := waitForMailpitLatestText(t, mailpitHTTPBaseURL)
|
||||
require.Contains(t, messageText, "246810")
|
||||
}
|
||||
|
||||
type smokeTLSFiles struct {
|
||||
certPath string
|
||||
keyPath string
|
||||
clientTLSConfig *tls.Config
|
||||
}
|
||||
|
||||
func writeMailpitTLSFiles(t *testing.T) smokeTLSFiles {
|
||||
t.Helper()
|
||||
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: "localhost",
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: []string{"localhost"},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
|
||||
})
|
||||
|
||||
root := t.TempDir()
|
||||
certPath := filepath.Join(root, "server.crt")
|
||||
keyPath := filepath.Join(root, "server.key")
|
||||
require.NoError(t, os.WriteFile(certPath, certPEM, 0o644))
|
||||
require.NoError(t, os.WriteFile(keyPath, keyPEM, 0o600))
|
||||
|
||||
rootCAs := x509.NewCertPool()
|
||||
require.True(t, rootCAs.AppendCertsFromPEM(certPEM))
|
||||
|
||||
return smokeTLSFiles{
|
||||
certPath: certPath,
|
||||
keyPath: keyPath,
|
||||
clientTLSConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
RootCAs: rootCAs,
|
||||
ServerName: "localhost",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func startSmokeRuntime(t *testing.T, cfg config.Config, deps runtimeDependencies) *runtimeInstance {
|
||||
t.Helper()
|
||||
|
||||
runtime, err := newRuntime(context.Background(), cfg, testLogger(), deps)
|
||||
require.NoError(t, err)
|
||||
|
||||
instance := &runtimeInstance{
|
||||
baseURL: "http://" + cfg.InternalHTTP.Addr,
|
||||
runtime: runtime,
|
||||
done: make(chan error, 1),
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithCancel(context.Background())
|
||||
instance.cancel = cancel
|
||||
go func() {
|
||||
instance.done <- runtime.Run(runCtx)
|
||||
}()
|
||||
|
||||
waitForRuntimeReady(t, instance.baseURL)
|
||||
t.Cleanup(func() {
|
||||
instance.stop(t)
|
||||
})
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
func waitForMailpitLatestText(t *testing.T, baseURL string) string {
|
||||
t.Helper()
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 500 * time.Millisecond,
|
||||
Transport: &http.Transport{
|
||||
DisableKeepAlives: true,
|
||||
},
|
||||
}
|
||||
t.Cleanup(client.CloseIdleConnections)
|
||||
|
||||
var payload string
|
||||
require.Eventually(t, func() bool {
|
||||
request, err := http.NewRequest(http.MethodGet, baseURL+"/view/latest.txt", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
|
||||
payload = string(body)
|
||||
return strings.TrimSpace(payload) != ""
|
||||
}, 20*time.Second, 100*time.Millisecond)
|
||||
|
||||
return payload
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/adapters/stubprovider"
|
||||
"galaxy/mail/internal/api/internalhttp"
|
||||
"galaxy/mail/internal/api/streamcommand"
|
||||
"galaxy/mail/internal/config"
|
||||
"galaxy/mail/internal/ports"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRuntimeAuthDeliverySentWithLocaleFallbackAndDuplicateIdempotency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newRuntimeTestEnvironment(t)
|
||||
clock := newRuntimeTestClock(runtimeClockStart())
|
||||
instance := env.start(t, runtimeInstanceOptions{
|
||||
clock: clock,
|
||||
smtpMode: config.SMTPModeSMTP,
|
||||
scriptedOutcomes: []stubprovider.ScriptedOutcome{
|
||||
{Classification: ports.ClassificationAccepted, Script: "accepted"},
|
||||
},
|
||||
})
|
||||
|
||||
first := postLoginCodeDelivery(t, instance.baseURL, loginCodeDeliveryRequest{
|
||||
idempotencyKey: "challenge-1",
|
||||
email: "pilot@example.com",
|
||||
code: "123456",
|
||||
locale: "fr-FR",
|
||||
})
|
||||
require.Equal(t, internalhttp.LoginCodeDeliveryOutcomeSent, first.Outcome)
|
||||
|
||||
second := postLoginCodeDelivery(t, instance.baseURL, loginCodeDeliveryRequest{
|
||||
idempotencyKey: "challenge-1",
|
||||
email: "pilot@example.com",
|
||||
code: "123456",
|
||||
locale: "fr-FR",
|
||||
})
|
||||
require.Equal(t, internalhttp.LoginCodeDeliveryOutcomeSent, second.Outcome)
|
||||
|
||||
list := eventuallyListDeliveries(t, instance.baseURL, url.Values{
|
||||
"source": []string{"authsession"},
|
||||
"idempotency_key": []string{"challenge-1"},
|
||||
})
|
||||
require.Len(t, list.Items, 1)
|
||||
|
||||
detail := eventuallyDeliveryStatus(t, instance.baseURL, list.Items[0].DeliveryID, "sent")
|
||||
require.Equal(t, "authsession", detail.Source)
|
||||
require.Equal(t, "auth.login_code", detail.TemplateID)
|
||||
require.Equal(t, "fr-FR", detail.Locale)
|
||||
require.True(t, detail.LocaleFallbackUsed)
|
||||
require.Equal(t, "challenge-1", detail.IdempotencyKey)
|
||||
require.Len(t, detail.To, 1)
|
||||
require.Equal(t, "pilot@example.com", detail.To[0])
|
||||
|
||||
attempts := getDeliveryAttempts(t, instance.baseURL, detail.DeliveryID)
|
||||
require.Len(t, attempts.Items, 1)
|
||||
require.Equal(t, "provider_accepted", attempts.Items[0].Status)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return len(instance.stubProvider.Inputs()) == 1
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
inputs := instance.stubProvider.Inputs()
|
||||
require.Len(t, inputs, 1)
|
||||
require.Equal(t, "Your login code", inputs[0].Content.Subject)
|
||||
require.Contains(t, inputs[0].Content.TextBody, "123456")
|
||||
}
|
||||
|
||||
func TestRuntimeAuthDeliverySuppressedInStubMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newRuntimeTestEnvironment(t)
|
||||
clock := newRuntimeTestClock(runtimeClockStart())
|
||||
instance := env.start(t, runtimeInstanceOptions{
|
||||
clock: clock,
|
||||
smtpMode: config.SMTPModeStub,
|
||||
})
|
||||
|
||||
response := postLoginCodeDelivery(t, instance.baseURL, loginCodeDeliveryRequest{
|
||||
idempotencyKey: "challenge-suppressed",
|
||||
email: "pilot@example.com",
|
||||
code: "654321",
|
||||
locale: "en",
|
||||
})
|
||||
require.Equal(t, internalhttp.LoginCodeDeliveryOutcomeSuppressed, response.Outcome)
|
||||
|
||||
list := eventuallyListDeliveries(t, instance.baseURL, url.Values{
|
||||
"source": []string{"authsession"},
|
||||
"idempotency_key": []string{"challenge-suppressed"},
|
||||
})
|
||||
require.Len(t, list.Items, 1)
|
||||
require.Equal(t, "suppressed", list.Items[0].Status)
|
||||
|
||||
detail := getDelivery(t, instance.baseURL, list.Items[0].DeliveryID)
|
||||
require.Equal(t, "suppressed", detail.Status)
|
||||
|
||||
attempts := getDeliveryAttempts(t, instance.baseURL, detail.DeliveryID)
|
||||
require.Empty(t, attempts.Items)
|
||||
}
|
||||
|
||||
func TestRuntimeGenericCommandAndOperatorRoutesSupportResendClone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newRuntimeTestEnvironment(t)
|
||||
clock := newRuntimeTestClock(runtimeClockStart())
|
||||
instance := env.start(t, runtimeInstanceOptions{
|
||||
clock: clock,
|
||||
smtpMode: config.SMTPModeSMTP,
|
||||
scriptedOutcomes: []stubprovider.ScriptedOutcome{
|
||||
{Classification: ports.ClassificationAccepted, Script: "original"},
|
||||
{Classification: ports.ClassificationAccepted, Script: "resend"},
|
||||
},
|
||||
})
|
||||
|
||||
publishRenderedCommand(t, env.redisClient, "delivery-generic", "notification:delivery-generic", "Turn ready")
|
||||
|
||||
detail := eventuallyDeliveryStatus(t, instance.baseURL, "delivery-generic", "sent")
|
||||
require.Equal(t, "notification", detail.Source)
|
||||
require.Equal(t, "rendered", detail.PayloadMode)
|
||||
require.Equal(t, "Turn ready", detail.Subject)
|
||||
|
||||
list := eventuallyListDeliveries(t, instance.baseURL, url.Values{
|
||||
"source": []string{"notification"},
|
||||
"idempotency_key": []string{"notification:delivery-generic"},
|
||||
"status": []string{"sent"},
|
||||
"recipient": []string{"pilot@example.com"},
|
||||
"from_created_at_ms": []string{formatUnixMilli(clock.Now().Add(-time.Second))},
|
||||
})
|
||||
require.Len(t, list.Items, 1)
|
||||
require.Equal(t, detail.DeliveryID, list.Items[0].DeliveryID)
|
||||
|
||||
attempts := getDeliveryAttempts(t, instance.baseURL, detail.DeliveryID)
|
||||
require.Len(t, attempts.Items, 1)
|
||||
require.Equal(t, "provider_accepted", attempts.Items[0].Status)
|
||||
|
||||
cloneID := resendDelivery(t, instance.baseURL, detail.DeliveryID)
|
||||
clone := eventuallyDeliveryStatus(t, instance.baseURL, cloneID, "sent")
|
||||
require.Equal(t, "operator_resend", clone.Source)
|
||||
require.Equal(t, detail.DeliveryID, clone.ResendParentDeliveryID)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return len(instance.stubProvider.Inputs()) == 2
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestRuntimeRetriesTransientFailureUntilSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newRuntimeTestEnvironment(t)
|
||||
clock := newRuntimeTestClock(runtimeClockStart())
|
||||
instance := env.start(t, runtimeInstanceOptions{
|
||||
clock: clock,
|
||||
smtpMode: config.SMTPModeSMTP,
|
||||
scriptedOutcomes: []stubprovider.ScriptedOutcome{
|
||||
{Classification: ports.ClassificationTransientFailure, Script: "retry-1"},
|
||||
{Classification: ports.ClassificationAccepted, Script: "accepted"},
|
||||
},
|
||||
})
|
||||
|
||||
publishRenderedCommand(t, env.redisClient, "delivery-retry", "notification:delivery-retry", "Retry success")
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
detail, found := tryGetDelivery(t, instance.baseURL, "delivery-retry")
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
return detail.Status == "queued" && detail.AttemptCount == 2
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
clock.Advance(time.Minute)
|
||||
|
||||
detail := eventuallyDeliveryStatus(t, instance.baseURL, "delivery-retry", "sent")
|
||||
require.Equal(t, 2, detail.AttemptCount)
|
||||
|
||||
attempts := getDeliveryAttempts(t, instance.baseURL, detail.DeliveryID)
|
||||
require.Len(t, attempts.Items, 2)
|
||||
require.Equal(t, "transport_failed", attempts.Items[0].Status)
|
||||
require.Equal(t, "provider_accepted", attempts.Items[1].Status)
|
||||
}
|
||||
|
||||
func TestRuntimeMovesDeliveryToDeadLetterAfterRetryExhaustion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newRuntimeTestEnvironment(t)
|
||||
clock := newRuntimeTestClock(runtimeClockStart())
|
||||
instance := env.start(t, runtimeInstanceOptions{
|
||||
clock: clock,
|
||||
smtpMode: config.SMTPModeSMTP,
|
||||
scriptedOutcomes: []stubprovider.ScriptedOutcome{
|
||||
{Classification: ports.ClassificationTransientFailure, Script: "retry-1"},
|
||||
{Classification: ports.ClassificationTransientFailure, Script: "retry-2"},
|
||||
{Classification: ports.ClassificationTransientFailure, Script: "retry-3"},
|
||||
{Classification: ports.ClassificationTransientFailure, Script: "retry-4"},
|
||||
},
|
||||
})
|
||||
|
||||
publishRenderedCommand(t, env.redisClient, "delivery-dead-letter", "notification:delivery-dead-letter", "Dead letter")
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
detail, found := tryGetDelivery(t, instance.baseURL, "delivery-dead-letter")
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
return detail.Status == "queued" && detail.AttemptCount == 2
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
clock.Advance(time.Minute)
|
||||
require.Eventually(t, func() bool {
|
||||
detail, found := tryGetDelivery(t, instance.baseURL, "delivery-dead-letter")
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
return detail.Status == "queued" && detail.AttemptCount == 3
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
clock.Advance(5 * time.Minute)
|
||||
require.Eventually(t, func() bool {
|
||||
detail, found := tryGetDelivery(t, instance.baseURL, "delivery-dead-letter")
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
return detail.Status == "queued" && detail.AttemptCount == 4
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
clock.Advance(30 * time.Minute)
|
||||
detail := eventuallyDeliveryStatus(t, instance.baseURL, "delivery-dead-letter", "dead_letter")
|
||||
require.NotNil(t, detail.DeadLetter)
|
||||
require.Equal(t, "retry_exhausted", detail.DeadLetter.FailureClassification)
|
||||
}
|
||||
|
||||
func TestRuntimeRecoversPendingAttemptAfterGracefulShutdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newRuntimeTestEnvironment(t)
|
||||
clock := newRuntimeTestClock(runtimeClockStart())
|
||||
blocking := &blockingProvider{startedCh: make(chan struct{})}
|
||||
first := env.start(t, runtimeInstanceOptions{
|
||||
clock: clock,
|
||||
smtpMode: config.SMTPModeSMTP,
|
||||
smtpTimeout: 20 * time.Millisecond,
|
||||
providerFactory: func(config.SMTPConfig, *slog.Logger) (ports.Provider, error) {
|
||||
return blocking, nil
|
||||
},
|
||||
})
|
||||
|
||||
publishRenderedCommand(t, env.redisClient, "delivery-recover", "notification:delivery-recover", "Recover")
|
||||
|
||||
require.Eventually(t, blocking.started, 5*time.Second, 20*time.Millisecond)
|
||||
require.Eventually(t, func() bool {
|
||||
detail, found := tryGetDelivery(t, first.baseURL, "delivery-recover")
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
return detail.Status == "sending"
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
first.stop(t)
|
||||
|
||||
clock.Advance(30 * time.Millisecond)
|
||||
|
||||
second := env.start(t, runtimeInstanceOptions{
|
||||
clock: clock,
|
||||
smtpMode: config.SMTPModeSMTP,
|
||||
smtpTimeout: 20 * time.Millisecond,
|
||||
scriptedOutcomes: []stubprovider.ScriptedOutcome{
|
||||
{Classification: ports.ClassificationAccepted, Script: "recovered"},
|
||||
},
|
||||
})
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
detail, found := tryGetDelivery(t, second.baseURL, "delivery-recover")
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
return detail.Status == "queued" && detail.AttemptCount == 2 && detail.LastAttemptStatus == "timed_out"
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
clock.Advance(time.Minute)
|
||||
|
||||
detail := eventuallyDeliveryStatus(t, second.baseURL, "delivery-recover", "sent")
|
||||
require.Equal(t, 2, detail.AttemptCount)
|
||||
|
||||
attempts := getDeliveryAttempts(t, second.baseURL, detail.DeliveryID)
|
||||
require.Len(t, attempts.Items, 2)
|
||||
require.Equal(t, "timed_out", attempts.Items[0].Status)
|
||||
require.Equal(t, "provider_accepted", attempts.Items[1].Status)
|
||||
}
|
||||
|
||||
type runtimeTestEnvironment struct {
|
||||
redisServer *miniredis.Miniredis
|
||||
redisClient *redis.Client
|
||||
templateDir string
|
||||
}
|
||||
|
||||
func newRuntimeTestEnvironment(t *testing.T) *runtimeTestEnvironment {
|
||||
t.Helper()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, client.Close())
|
||||
})
|
||||
|
||||
return &runtimeTestEnvironment{
|
||||
redisServer: server,
|
||||
redisClient: client,
|
||||
templateDir: writeRuntimeTemplates(t),
|
||||
}
|
||||
}
|
||||
|
||||
type runtimeInstanceOptions struct {
|
||||
clock *runtimeTestClock
|
||||
smtpMode string
|
||||
smtpTimeout time.Duration
|
||||
scriptedOutcomes []stubprovider.ScriptedOutcome
|
||||
providerFactory runtimeProviderFactory
|
||||
}
|
||||
|
||||
type runtimeInstance struct {
|
||||
baseURL string
|
||||
runtime *Runtime
|
||||
cancel context.CancelFunc
|
||||
done chan error
|
||||
closeOnce sync.Once
|
||||
stubProvider *stubprovider.Provider
|
||||
}
|
||||
|
||||
func (env *runtimeTestEnvironment) start(t *testing.T, opts runtimeInstanceOptions) *runtimeInstance {
|
||||
t.Helper()
|
||||
|
||||
if opts.clock == nil {
|
||||
opts.clock = newRuntimeTestClock(runtimeClockStart())
|
||||
}
|
||||
if opts.smtpMode == "" {
|
||||
opts.smtpMode = config.SMTPModeSMTP
|
||||
}
|
||||
if opts.smtpTimeout <= 0 {
|
||||
opts.smtpTimeout = 20 * time.Millisecond
|
||||
}
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Redis.Addr = env.redisServer.Addr()
|
||||
cfg.Templates.Dir = env.templateDir
|
||||
cfg.InternalHTTP.Addr = mustFreeAddr(t)
|
||||
cfg.ShutdownTimeout = time.Second
|
||||
cfg.StreamBlockTimeout = 20 * time.Millisecond
|
||||
cfg.AttemptWorkerConcurrency = 1
|
||||
cfg.SMTP.Mode = opts.smtpMode
|
||||
cfg.SMTP.Timeout = opts.smtpTimeout
|
||||
if opts.smtpMode == config.SMTPModeSMTP {
|
||||
cfg.SMTP.Addr = "127.0.0.1:2525"
|
||||
cfg.SMTP.FromEmail = "noreply@example.com"
|
||||
}
|
||||
|
||||
instance := &runtimeInstance{
|
||||
baseURL: "http://" + cfg.InternalHTTP.Addr,
|
||||
done: make(chan error, 1),
|
||||
}
|
||||
|
||||
deps := runtimeDependencies{
|
||||
clock: opts.clock,
|
||||
schedulerPoll: 10 * time.Millisecond,
|
||||
schedulerRecovery: 10 * time.Millisecond,
|
||||
schedulerGrace: 5 * time.Millisecond,
|
||||
}
|
||||
if opts.providerFactory != nil {
|
||||
deps.providerFactory = opts.providerFactory
|
||||
} else if opts.smtpMode == config.SMTPModeSMTP {
|
||||
deps.providerFactory = func(config.SMTPConfig, *slog.Logger) (ports.Provider, error) {
|
||||
provider, err := stubprovider.New(opts.scriptedOutcomes...)
|
||||
if err == nil {
|
||||
instance.stubProvider = provider
|
||||
}
|
||||
return provider, err
|
||||
}
|
||||
}
|
||||
|
||||
runtime, err := newRuntime(context.Background(), cfg, testLogger(), deps)
|
||||
require.NoError(t, err)
|
||||
instance.runtime = runtime
|
||||
|
||||
runCtx, cancel := context.WithCancel(context.Background())
|
||||
instance.cancel = cancel
|
||||
go func() {
|
||||
instance.done <- runtime.Run(runCtx)
|
||||
}()
|
||||
|
||||
waitForRuntimeReady(t, instance.baseURL)
|
||||
t.Cleanup(func() {
|
||||
instance.stop(t)
|
||||
})
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
func (instance *runtimeInstance) stop(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
instance.closeOnce.Do(func() {
|
||||
if instance.cancel != nil {
|
||||
instance.cancel()
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-instance.done:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(5 * time.Second):
|
||||
require.FailNow(t, "runtime did not stop before timeout")
|
||||
}
|
||||
|
||||
require.NoError(t, instance.runtime.Close())
|
||||
})
|
||||
}
|
||||
|
||||
type runtimeTestClock struct {
|
||||
mu sync.RWMutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func newRuntimeTestClock(now time.Time) *runtimeTestClock {
|
||||
return &runtimeTestClock{now: now.UTC().Truncate(time.Millisecond)}
|
||||
}
|
||||
|
||||
func runtimeClockStart() time.Time {
|
||||
return time.Now().UTC().Truncate(time.Millisecond)
|
||||
}
|
||||
|
||||
func (clock *runtimeTestClock) Now() time.Time {
|
||||
clock.mu.RLock()
|
||||
defer clock.mu.RUnlock()
|
||||
|
||||
return clock.now
|
||||
}
|
||||
|
||||
func (clock *runtimeTestClock) Advance(step time.Duration) {
|
||||
clock.mu.Lock()
|
||||
defer clock.mu.Unlock()
|
||||
|
||||
clock.now = clock.now.Add(step).UTC().Truncate(time.Millisecond)
|
||||
}
|
||||
|
||||
type blockingProvider struct {
|
||||
mu sync.RWMutex
|
||||
startedOnce sync.Once
|
||||
startedCh chan struct{}
|
||||
}
|
||||
|
||||
func (provider *blockingProvider) started() bool {
|
||||
if provider == nil {
|
||||
return false
|
||||
}
|
||||
provider.mu.RLock()
|
||||
startedCh := provider.startedCh
|
||||
provider.mu.RUnlock()
|
||||
if startedCh == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
select {
|
||||
case <-startedCh:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *blockingProvider) Send(ctx context.Context, message ports.Message) (ports.Result, error) {
|
||||
provider.startedOnce.Do(func() {
|
||||
provider.mu.Lock()
|
||||
if provider.startedCh == nil {
|
||||
provider.startedCh = make(chan struct{})
|
||||
}
|
||||
startedCh := provider.startedCh
|
||||
provider.mu.Unlock()
|
||||
close(startedCh)
|
||||
})
|
||||
if err := message.Validate(); err != nil {
|
||||
return ports.Result{}, err
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
return ports.Result{}, ctx.Err()
|
||||
}
|
||||
|
||||
func (provider *blockingProvider) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeRuntimeTemplates(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
templateDir := filepath.Join(rootDir, "auth.login_code", "en")
|
||||
require.NoError(t, os.MkdirAll(templateDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(templateDir, "subject.tmpl"), []byte("Your login code"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(templateDir, "text.tmpl"), []byte("Code: {{.code}}"), 0o644))
|
||||
|
||||
return rootDir
|
||||
}
|
||||
|
||||
type loginCodeDeliveryRequest struct {
|
||||
idempotencyKey string
|
||||
email string
|
||||
code string
|
||||
locale string
|
||||
}
|
||||
|
||||
func postLoginCodeDelivery(t *testing.T, baseURL string, request loginCodeDeliveryRequest) internalhttp.LoginCodeDeliveryResponse {
|
||||
t.Helper()
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"email": request.email,
|
||||
"code": request.code,
|
||||
"locale": request.locale,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
httpRequest, err := http.NewRequest(http.MethodPost, baseURL+internalhttp.LoginCodeDeliveriesPath, bytes.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
httpRequest.Header.Set(internalhttp.IdempotencyKeyHeader, request.idempotencyKey)
|
||||
|
||||
response := doJSONRequest[internalhttp.LoginCodeDeliveryResponse](t, httpRequest, http.StatusOK)
|
||||
require.NoError(t, response.Validate())
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
func publishRenderedCommand(t *testing.T, client *redis.Client, deliveryID string, idempotencyKey string, subject string) {
|
||||
t.Helper()
|
||||
|
||||
_, err := client.XAdd(context.Background(), &redis.XAddArgs{
|
||||
Stream: streamcommand.DeliveryCommandsStream,
|
||||
Values: map[string]any{
|
||||
"delivery_id": deliveryID,
|
||||
"source": "notification",
|
||||
"payload_mode": "rendered",
|
||||
"idempotency_key": idempotencyKey,
|
||||
"requested_at_ms": "1775121700000",
|
||||
"payload_json": `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":["noreply@example.com"],"subject":"` + subject + `","text_body":"Turn 54 is ready.","html_body":"<p>Turn 54 is ready.</p>","attachments":[]}`,
|
||||
},
|
||||
}).Result()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func waitForRuntimeReady(t *testing.T, baseURL string) {
|
||||
t.Helper()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
request, err := http.NewRequest(http.MethodGet, baseURL+internalhttp.DeliveriesPath, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
return response.StatusCode == http.StatusOK
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
}
|
||||
|
||||
func eventuallyListDeliveries(t *testing.T, baseURL string, query url.Values) internalhttp.DeliveryListResponse {
|
||||
t.Helper()
|
||||
|
||||
var response internalhttp.DeliveryListResponse
|
||||
require.Eventually(t, func() bool {
|
||||
response = listDeliveries(t, baseURL, query)
|
||||
return len(response.Items) > 0
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
func listDeliveries(t *testing.T, baseURL string, query url.Values) internalhttp.DeliveryListResponse {
|
||||
t.Helper()
|
||||
|
||||
target := baseURL + internalhttp.DeliveriesPath
|
||||
if encoded := query.Encode(); encoded != "" {
|
||||
target += "?" + encoded
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodGet, target, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
return doJSONRequest[internalhttp.DeliveryListResponse](t, request, http.StatusOK)
|
||||
}
|
||||
|
||||
func eventuallyDeliveryStatus(t *testing.T, baseURL string, deliveryID string, status string) internalhttp.DeliveryDetailResponse {
|
||||
t.Helper()
|
||||
|
||||
var response internalhttp.DeliveryDetailResponse
|
||||
require.Eventually(t, func() bool {
|
||||
var found bool
|
||||
response, found = tryGetDelivery(t, baseURL, deliveryID)
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
return response.Status == status
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
func getDelivery(t *testing.T, baseURL string, deliveryID string) internalhttp.DeliveryDetailResponse {
|
||||
t.Helper()
|
||||
|
||||
response, found := tryGetDelivery(t, baseURL, deliveryID)
|
||||
require.True(t, found, "delivery %s not found", deliveryID)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
func tryGetDelivery(t *testing.T, baseURL string, deliveryID string) (internalhttp.DeliveryDetailResponse, bool) {
|
||||
t.Helper()
|
||||
|
||||
request, err := http.NewRequest(http.MethodGet, baseURL+internalhttp.DeliveriesPath+"/"+url.PathEscape(deliveryID), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
response, payload := doRequest(t, request)
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
var notFound internalhttp.ErrorResponse
|
||||
require.NoError(t, json.Unmarshal(payload, ¬Found), string(payload))
|
||||
require.NoError(t, notFound.Validate())
|
||||
require.Equal(t, internalhttp.ErrorCodeDeliveryNotFound, notFound.Error.Code)
|
||||
return internalhttp.DeliveryDetailResponse{}, false
|
||||
}
|
||||
|
||||
return decodeBody[internalhttp.DeliveryDetailResponse](t, response.StatusCode, payload, http.StatusOK), true
|
||||
}
|
||||
|
||||
func getDeliveryAttempts(t *testing.T, baseURL string, deliveryID string) internalhttp.DeliveryAttemptsResponse {
|
||||
t.Helper()
|
||||
|
||||
request, err := http.NewRequest(http.MethodGet, baseURL+internalhttp.DeliveriesPath+"/"+url.PathEscape(deliveryID)+"/attempts", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
return doJSONRequest[internalhttp.DeliveryAttemptsResponse](t, request, http.StatusOK)
|
||||
}
|
||||
|
||||
func resendDelivery(t *testing.T, baseURL string, deliveryID string) string {
|
||||
t.Helper()
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, baseURL+internalhttp.DeliveriesPath+"/"+url.PathEscape(deliveryID)+"/resend", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
response := doJSONRequest[internalhttp.DeliveryResendResponse](t, request, http.StatusOK)
|
||||
require.NotEmpty(t, response.DeliveryID)
|
||||
|
||||
return response.DeliveryID
|
||||
}
|
||||
|
||||
func doJSONRequest[T any](t *testing.T, request *http.Request, wantStatus int) T {
|
||||
t.Helper()
|
||||
|
||||
response, payload := doRequest(t, request)
|
||||
return decodeBody[T](t, response.StatusCode, payload, wantStatus)
|
||||
}
|
||||
|
||||
func doRequest(t *testing.T, request *http.Request) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
require.NoError(t, err)
|
||||
defer response.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(response.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
return response, payload
|
||||
}
|
||||
|
||||
func decodeBody[T any](t *testing.T, gotStatus int, payload []byte, wantStatus int) T {
|
||||
t.Helper()
|
||||
|
||||
require.Equal(t, wantStatus, gotStatus, string(payload))
|
||||
|
||||
var decoded T
|
||||
require.NoError(t, json.Unmarshal(payload, &decoded), string(payload))
|
||||
|
||||
return decoded
|
||||
}
|
||||
|
||||
func formatUnixMilli(value time.Time) string {
|
||||
return strconv.FormatInt(value.UTC().Truncate(time.Millisecond).UnixMilli(), 10)
|
||||
}
|
||||
|
||||
var _ ports.Provider = (*blockingProvider)(nil)
|
||||
@@ -0,0 +1,184 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/config"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewRuntimeStartsWithStubMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
redisServer := miniredis.RunT(t)
|
||||
templateDir := writeStage6Templates(t)
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Redis.Addr = redisServer.Addr()
|
||||
cfg.Templates.Dir = templateDir
|
||||
cfg.InternalHTTP.Addr = mustFreeAddr(t)
|
||||
|
||||
runtime, err := NewRuntime(context.Background(), cfg, testLogger())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, runtime.Close())
|
||||
}
|
||||
|
||||
func TestNewRuntimeRejectsInvalidRedisConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
templateDir := writeStage6Templates(t)
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Redis.Addr = "127.0.0.1"
|
||||
cfg.Templates.Dir = templateDir
|
||||
cfg.InternalHTTP.Addr = mustFreeAddr(t)
|
||||
|
||||
_, err := NewRuntime(context.Background(), cfg, testLogger())
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "redis addr")
|
||||
}
|
||||
|
||||
func TestNewRuntimeRejectsUnavailableRedis(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
templateDir := writeStage6Templates(t)
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Redis.Addr = "127.0.0.1:6399"
|
||||
cfg.Redis.OperationTimeout = 100 * time.Millisecond
|
||||
cfg.Templates.Dir = templateDir
|
||||
cfg.InternalHTTP.Addr = mustFreeAddr(t)
|
||||
|
||||
_, err := NewRuntime(context.Background(), cfg, testLogger())
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "ping redis")
|
||||
}
|
||||
|
||||
func TestNewRuntimeRejectsMissingTemplateDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
redisServer := miniredis.RunT(t)
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Redis.Addr = redisServer.Addr()
|
||||
cfg.Templates.Dir = filepath.Join(t.TempDir(), "missing")
|
||||
cfg.InternalHTTP.Addr = mustFreeAddr(t)
|
||||
|
||||
_, err := NewRuntime(context.Background(), cfg, testLogger())
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "template catalog")
|
||||
}
|
||||
|
||||
func TestNewRuntimeRejectsMissingRequiredTemplateFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
redisServer := miniredis.RunT(t)
|
||||
rootDir := t.TempDir()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(rootDir, "auth.login_code", "en"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "auth.login_code", "en", "subject.tmpl"), []byte("Subject"), 0o644))
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Redis.Addr = redisServer.Addr()
|
||||
cfg.Templates.Dir = rootDir
|
||||
cfg.InternalHTTP.Addr = mustFreeAddr(t)
|
||||
|
||||
_, err := NewRuntime(context.Background(), cfg, testLogger())
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "text.tmpl")
|
||||
}
|
||||
|
||||
func TestNewRuntimeRejectsBrokenTemplateCatalog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
redisServer := miniredis.RunT(t)
|
||||
rootDir := t.TempDir()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(rootDir, "auth.login_code", "en"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "auth.login_code", "en", "subject.tmpl"), []byte("Your login code"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "auth.login_code", "en", "text.tmpl"), []byte("Code: {{.code}}"), 0o644))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(rootDir, "game.turn_ready", "en"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "game.turn_ready", "en", "subject.tmpl"), []byte("{{if .turn_number}"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "game.turn_ready", "en", "text.tmpl"), []byte("Turn ready"), 0o644))
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Redis.Addr = redisServer.Addr()
|
||||
cfg.Templates.Dir = rootDir
|
||||
cfg.InternalHTTP.Addr = mustFreeAddr(t)
|
||||
|
||||
_, err := NewRuntime(context.Background(), cfg, testLogger())
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "template parse failed")
|
||||
}
|
||||
|
||||
func TestRuntimeRunStopsOnContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
redisServer := miniredis.RunT(t)
|
||||
templateDir := writeStage6Templates(t)
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Redis.Addr = redisServer.Addr()
|
||||
cfg.Templates.Dir = templateDir
|
||||
cfg.InternalHTTP.Addr = mustFreeAddr(t)
|
||||
cfg.ShutdownTimeout = time.Second
|
||||
|
||||
runtime, err := NewRuntime(context.Background(), cfg, testLogger())
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
require.NoError(t, runtime.Close())
|
||||
}()
|
||||
|
||||
runCtx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- runtime.Run(runCtx)
|
||||
}()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
select {
|
||||
case err := <-done:
|
||||
return err == nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, 5*time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
func writeStage6Templates(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
templateDir := filepath.Join(rootDir, "auth.login_code", "en")
|
||||
require.NoError(t, os.MkdirAll(templateDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(templateDir, "subject.tmpl"), []byte("Your login code"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(templateDir, "text.tmpl"), []byte("Code: {{.code}}"), 0o644))
|
||||
|
||||
return rootDir
|
||||
}
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewJSONHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func mustFreeAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
require.NoError(t, listener.Close())
|
||||
}()
|
||||
|
||||
return listener.Addr().String()
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
// Package config loads the Mail Service process configuration from environment
|
||||
// variables.
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/telemetry"
|
||||
)
|
||||
|
||||
const (
|
||||
shutdownTimeoutEnvVar = "MAIL_SHUTDOWN_TIMEOUT"
|
||||
logLevelEnvVar = "MAIL_LOG_LEVEL"
|
||||
|
||||
internalHTTPAddrEnvVar = "MAIL_INTERNAL_HTTP_ADDR"
|
||||
internalHTTPReadHeaderTimeoutEnvVar = "MAIL_INTERNAL_HTTP_READ_HEADER_TIMEOUT"
|
||||
internalHTTPReadTimeoutEnvVar = "MAIL_INTERNAL_HTTP_READ_TIMEOUT"
|
||||
internalHTTPIdleTimeoutEnvVar = "MAIL_INTERNAL_HTTP_IDLE_TIMEOUT"
|
||||
|
||||
redisAddrEnvVar = "MAIL_REDIS_ADDR"
|
||||
redisUsernameEnvVar = "MAIL_REDIS_USERNAME"
|
||||
redisPasswordEnvVar = "MAIL_REDIS_PASSWORD"
|
||||
redisDBEnvVar = "MAIL_REDIS_DB"
|
||||
redisTLSEnabledEnvVar = "MAIL_REDIS_TLS_ENABLED"
|
||||
redisOperationTimeoutEnvVar = "MAIL_REDIS_OPERATION_TIMEOUT"
|
||||
redisCommandStreamEnvVar = "MAIL_REDIS_COMMAND_STREAM"
|
||||
redisAttemptScheduleEnvVar = "MAIL_REDIS_ATTEMPT_SCHEDULE_KEY"
|
||||
redisDeadLetterPrefixEnvVar = "MAIL_REDIS_DEAD_LETTER_PREFIX"
|
||||
|
||||
smtpModeEnvVar = "MAIL_SMTP_MODE"
|
||||
smtpAddrEnvVar = "MAIL_SMTP_ADDR"
|
||||
smtpUsernameEnvVar = "MAIL_SMTP_USERNAME"
|
||||
smtpPasswordEnvVar = "MAIL_SMTP_PASSWORD"
|
||||
smtpFromEmailEnvVar = "MAIL_SMTP_FROM_EMAIL"
|
||||
smtpFromNameEnvVar = "MAIL_SMTP_FROM_NAME"
|
||||
smtpTimeoutEnvVar = "MAIL_SMTP_TIMEOUT"
|
||||
smtpInsecureSkipVerifyEnvVar = "MAIL_SMTP_INSECURE_SKIP_VERIFY"
|
||||
|
||||
templateDirEnvVar = "MAIL_TEMPLATE_DIR"
|
||||
|
||||
attemptWorkerConcurrencyEnvVar = "MAIL_ATTEMPT_WORKER_CONCURRENCY"
|
||||
streamBlockTimeoutEnvVar = "MAIL_STREAM_BLOCK_TIMEOUT"
|
||||
operatorRequestTimeoutEnvVar = "MAIL_OPERATOR_REQUEST_TIMEOUT"
|
||||
idempotencyTTLEnvVar = "MAIL_IDEMPOTENCY_TTL"
|
||||
deliveryTTLEnvVar = "MAIL_DELIVERY_TTL"
|
||||
attemptTTLEnvVar = "MAIL_ATTEMPT_TTL"
|
||||
|
||||
otelServiceNameEnvVar = "OTEL_SERVICE_NAME"
|
||||
otelTracesExporterEnvVar = "OTEL_TRACES_EXPORTER"
|
||||
otelMetricsExporterEnvVar = "OTEL_METRICS_EXPORTER"
|
||||
otelExporterOTLPProtocolEnvVar = "OTEL_EXPORTER_OTLP_PROTOCOL"
|
||||
otelExporterOTLPTracesProtocolEnvVar = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"
|
||||
otelExporterOTLPMetricsProtocolEnvVar = "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL"
|
||||
otelStdoutTracesEnabledEnvVar = "MAIL_OTEL_STDOUT_TRACES_ENABLED"
|
||||
otelStdoutMetricsEnabledEnvVar = "MAIL_OTEL_STDOUT_METRICS_ENABLED"
|
||||
|
||||
defaultShutdownTimeout = 5 * time.Second
|
||||
defaultLogLevel = "info"
|
||||
defaultInternalHTTPAddr = ":8080"
|
||||
defaultReadHeaderTimeout = 2 * time.Second
|
||||
defaultReadTimeout = 10 * time.Second
|
||||
defaultIdleTimeout = time.Minute
|
||||
defaultRedisDB = 0
|
||||
defaultRedisOperationTimeout = 250 * time.Millisecond
|
||||
defaultRedisCommandStream = "mail:delivery_commands"
|
||||
defaultRedisAttemptScheduleKey = "mail:attempt_schedule"
|
||||
defaultRedisDeadLetterPrefix = "mail:dead_letters:"
|
||||
defaultSMTPMode = SMTPModeStub
|
||||
defaultSMTPTimeout = 15 * time.Second
|
||||
defaultTemplateDir = "templates"
|
||||
defaultAttemptWorkerCount = 4
|
||||
defaultStreamBlockTimeout = 2 * time.Second
|
||||
defaultOperatorRequestTimeout = 5 * time.Second
|
||||
defaultIdempotencyTTL = 7 * 24 * time.Hour
|
||||
defaultDeliveryTTL = 30 * 24 * time.Hour
|
||||
defaultAttemptTTL = 90 * 24 * time.Hour
|
||||
defaultOTelServiceName = "galaxy-mail"
|
||||
)
|
||||
|
||||
const (
|
||||
// SMTPModeStub configures the deterministic in-process stub provider.
|
||||
SMTPModeStub = "stub"
|
||||
|
||||
// SMTPModeSMTP configures the real SMTP-backed provider adapter.
|
||||
SMTPModeSMTP = "smtp"
|
||||
)
|
||||
|
||||
// Config stores the full Mail Service process configuration.
|
||||
type Config struct {
|
||||
// ShutdownTimeout bounds graceful shutdown of every long-lived component.
|
||||
ShutdownTimeout time.Duration
|
||||
|
||||
// Logging configures the process-wide structured logger.
|
||||
Logging LoggingConfig
|
||||
|
||||
// InternalHTTP configures the trusted internal HTTP listener.
|
||||
InternalHTTP InternalHTTPConfig
|
||||
|
||||
// Redis configures the shared Redis client and Redis-owned keys used by the
|
||||
// runnable service skeleton.
|
||||
Redis RedisConfig
|
||||
|
||||
// SMTP configures the runtime mail provider mode and provider-specific
|
||||
// connection details.
|
||||
SMTP SMTPConfig
|
||||
|
||||
// Templates configures the filesystem-backed template catalog root.
|
||||
Templates TemplateConfig
|
||||
|
||||
// AttemptWorkerConcurrency stores how many idle attempt workers the process
|
||||
// starts.
|
||||
AttemptWorkerConcurrency int
|
||||
|
||||
// StreamBlockTimeout stores the maximum Redis Streams blocking read window
|
||||
// used by the future command consumer.
|
||||
StreamBlockTimeout time.Duration
|
||||
|
||||
// OperatorRequestTimeout stores the future application-layer request budget
|
||||
// for trusted operator handlers.
|
||||
OperatorRequestTimeout time.Duration
|
||||
|
||||
// IdempotencyTTL stores the configured retention for idempotency records.
|
||||
IdempotencyTTL time.Duration
|
||||
|
||||
// DeliveryTTL stores the configured retention for delivery records.
|
||||
DeliveryTTL time.Duration
|
||||
|
||||
// AttemptTTL stores the configured retention for attempt and dead-letter
|
||||
// records.
|
||||
AttemptTTL time.Duration
|
||||
|
||||
// Telemetry configures the process-wide OpenTelemetry runtime.
|
||||
Telemetry TelemetryConfig
|
||||
}
|
||||
|
||||
// LoggingConfig configures the process-wide structured logger.
|
||||
type LoggingConfig struct {
|
||||
// Level stores the process log level accepted by log/slog.
|
||||
Level string
|
||||
}
|
||||
|
||||
// InternalHTTPConfig configures the trusted internal HTTP listener.
|
||||
type InternalHTTPConfig struct {
|
||||
// Addr stores the TCP listen address.
|
||||
Addr string
|
||||
|
||||
// ReadHeaderTimeout bounds request-header reading.
|
||||
ReadHeaderTimeout time.Duration
|
||||
|
||||
// ReadTimeout bounds reading one request.
|
||||
ReadTimeout time.Duration
|
||||
|
||||
// IdleTimeout bounds how long keep-alive connections stay open.
|
||||
IdleTimeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable internal HTTP listener
|
||||
// configuration.
|
||||
func (cfg InternalHTTPConfig) Validate() error {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.Addr) == "":
|
||||
return fmt.Errorf("internal HTTP addr must not be empty")
|
||||
case !isTCPAddr(cfg.Addr):
|
||||
return fmt.Errorf("internal HTTP addr %q must use host:port form", cfg.Addr)
|
||||
case cfg.ReadHeaderTimeout <= 0:
|
||||
return fmt.Errorf("internal HTTP read header timeout must be positive")
|
||||
case cfg.ReadTimeout <= 0:
|
||||
return fmt.Errorf("internal HTTP read timeout must be positive")
|
||||
case cfg.IdleTimeout <= 0:
|
||||
return fmt.Errorf("internal HTTP idle timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RedisConfig configures the shared Redis client used by the runnable process.
|
||||
type RedisConfig struct {
|
||||
// Addr stores the Redis network address.
|
||||
Addr string
|
||||
|
||||
// Username stores the optional Redis ACL username.
|
||||
Username string
|
||||
|
||||
// Password stores the optional Redis ACL password.
|
||||
Password string
|
||||
|
||||
// DB stores the Redis logical database index.
|
||||
DB int
|
||||
|
||||
// TLSEnabled reports whether TLS must be used for Redis connections.
|
||||
TLSEnabled bool
|
||||
|
||||
// OperationTimeout bounds one Redis round trip including the startup PING.
|
||||
OperationTimeout time.Duration
|
||||
|
||||
// CommandStream stores the configured Redis Streams key for async command
|
||||
// intake.
|
||||
CommandStream string
|
||||
|
||||
// AttemptScheduleKey stores the configured sorted-set key of scheduled
|
||||
// attempts.
|
||||
AttemptScheduleKey string
|
||||
|
||||
// DeadLetterPrefix stores the configured Redis key prefix of dead-letter
|
||||
// entries.
|
||||
DeadLetterPrefix string
|
||||
}
|
||||
|
||||
// TLSConfig returns the conservative TLS configuration used by the Redis
|
||||
// client when TLSEnabled is true.
|
||||
func (cfg RedisConfig) TLSConfig() *tls.Config {
|
||||
if !cfg.TLSEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable Redis configuration.
|
||||
func (cfg RedisConfig) Validate() error {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.Addr) == "":
|
||||
return fmt.Errorf("redis addr must not be empty")
|
||||
case !isTCPAddr(cfg.Addr):
|
||||
return fmt.Errorf("redis addr %q must use host:port form", cfg.Addr)
|
||||
case cfg.DB < 0:
|
||||
return fmt.Errorf("redis db must not be negative")
|
||||
case cfg.OperationTimeout <= 0:
|
||||
return fmt.Errorf("redis operation timeout must be positive")
|
||||
case strings.TrimSpace(cfg.CommandStream) == "":
|
||||
return fmt.Errorf("redis command stream must not be empty")
|
||||
case strings.TrimSpace(cfg.AttemptScheduleKey) == "":
|
||||
return fmt.Errorf("redis attempt schedule key must not be empty")
|
||||
case strings.TrimSpace(cfg.DeadLetterPrefix) == "":
|
||||
return fmt.Errorf("redis dead-letter prefix must not be empty")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// SMTPConfig configures the selected provider adapter.
|
||||
type SMTPConfig struct {
|
||||
// Mode selects the runtime provider implementation. Supported values are
|
||||
// `stub` and `smtp`.
|
||||
Mode string
|
||||
|
||||
// Addr stores the SMTP server address when Mode is `smtp`.
|
||||
Addr string
|
||||
|
||||
// Username stores the optional SMTP authentication username.
|
||||
Username string
|
||||
|
||||
// Password stores the optional SMTP authentication password.
|
||||
Password string
|
||||
|
||||
// FromEmail stores the RFC 5322 single mailbox used as the envelope sender
|
||||
// when Mode is `smtp`.
|
||||
FromEmail string
|
||||
|
||||
// FromName stores the optional display name attached to FromEmail.
|
||||
FromName string
|
||||
|
||||
// Timeout stores the maximum SMTP dial-and-send window.
|
||||
Timeout time.Duration
|
||||
|
||||
// InsecureSkipVerify disables SMTP certificate verification. This is meant
|
||||
// only for local development and black-box tests with self-signed capture
|
||||
// servers.
|
||||
InsecureSkipVerify bool
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable provider configuration.
|
||||
func (cfg SMTPConfig) Validate() error {
|
||||
switch cfg.Mode {
|
||||
case SMTPModeStub:
|
||||
return nil
|
||||
case SMTPModeSMTP:
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.Addr) == "":
|
||||
return fmt.Errorf("smtp addr must not be empty")
|
||||
case !isTCPAddr(cfg.Addr):
|
||||
return fmt.Errorf("smtp addr %q must use host:port form", cfg.Addr)
|
||||
case cfg.Timeout <= 0:
|
||||
return fmt.Errorf("smtp timeout must be positive")
|
||||
case strings.TrimSpace(cfg.Username) == "" && strings.TrimSpace(cfg.Password) != "":
|
||||
return fmt.Errorf("smtp username and password must be configured together")
|
||||
case strings.TrimSpace(cfg.Username) != "" && strings.TrimSpace(cfg.Password) == "":
|
||||
return fmt.Errorf("smtp username and password must be configured together")
|
||||
default:
|
||||
return validateMailbox("smtp from email", cfg.FromEmail)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("smtp mode %q is unsupported", cfg.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// TemplateConfig configures the filesystem-backed template catalog.
|
||||
type TemplateConfig struct {
|
||||
// Dir stores the root directory of the template catalog.
|
||||
Dir string
|
||||
}
|
||||
|
||||
// TelemetryConfig configures the Mail Service OpenTelemetry runtime.
|
||||
type TelemetryConfig struct {
|
||||
// ServiceName overrides the default OpenTelemetry service name.
|
||||
ServiceName string
|
||||
|
||||
// TracesExporter selects the external traces exporter. Supported values are
|
||||
// `none` and `otlp`.
|
||||
TracesExporter string
|
||||
|
||||
// MetricsExporter selects the external metrics exporter. Supported values
|
||||
// are `none` and `otlp`.
|
||||
MetricsExporter string
|
||||
|
||||
// TracesProtocol selects the OTLP traces protocol when TracesExporter is
|
||||
// `otlp`.
|
||||
TracesProtocol string
|
||||
|
||||
// MetricsProtocol selects the OTLP metrics protocol when MetricsExporter is
|
||||
// `otlp`.
|
||||
MetricsProtocol string
|
||||
|
||||
// StdoutTracesEnabled enables the additional stdout trace exporter used for
|
||||
// local development and debugging.
|
||||
StdoutTracesEnabled bool
|
||||
|
||||
// StdoutMetricsEnabled enables the additional stdout metric exporter used
|
||||
// for local development and debugging.
|
||||
StdoutMetricsEnabled bool
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable template catalog root.
|
||||
func (cfg TemplateConfig) Validate() error {
|
||||
if strings.TrimSpace(cfg.Dir) == "" {
|
||||
return fmt.Errorf("template dir must not be empty")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultConfig returns the default Mail Service process configuration.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
ShutdownTimeout: defaultShutdownTimeout,
|
||||
Logging: LoggingConfig{
|
||||
Level: defaultLogLevel,
|
||||
},
|
||||
InternalHTTP: InternalHTTPConfig{
|
||||
Addr: defaultInternalHTTPAddr,
|
||||
ReadHeaderTimeout: defaultReadHeaderTimeout,
|
||||
ReadTimeout: defaultReadTimeout,
|
||||
IdleTimeout: defaultIdleTimeout,
|
||||
},
|
||||
Redis: RedisConfig{
|
||||
DB: defaultRedisDB,
|
||||
OperationTimeout: defaultRedisOperationTimeout,
|
||||
CommandStream: defaultRedisCommandStream,
|
||||
AttemptScheduleKey: defaultRedisAttemptScheduleKey,
|
||||
DeadLetterPrefix: defaultRedisDeadLetterPrefix,
|
||||
},
|
||||
SMTP: SMTPConfig{
|
||||
Mode: defaultSMTPMode,
|
||||
Timeout: defaultSMTPTimeout,
|
||||
},
|
||||
Templates: TemplateConfig{
|
||||
Dir: defaultTemplateDir,
|
||||
},
|
||||
AttemptWorkerConcurrency: defaultAttemptWorkerCount,
|
||||
StreamBlockTimeout: defaultStreamBlockTimeout,
|
||||
OperatorRequestTimeout: defaultOperatorRequestTimeout,
|
||||
IdempotencyTTL: defaultIdempotencyTTL,
|
||||
DeliveryTTL: defaultDeliveryTTL,
|
||||
AttemptTTL: defaultAttemptTTL,
|
||||
Telemetry: TelemetryConfig{
|
||||
ServiceName: defaultOTelServiceName,
|
||||
TracesExporter: "none",
|
||||
MetricsExporter: "none",
|
||||
TracesProtocol: "",
|
||||
MetricsProtocol: "",
|
||||
StdoutTracesEnabled: false,
|
||||
StdoutMetricsEnabled: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Validate reports whether cfg contains a supported OpenTelemetry
|
||||
// configuration.
|
||||
func (cfg TelemetryConfig) Validate() error {
|
||||
return telemetry.ProcessConfig{
|
||||
ServiceName: cfg.ServiceName,
|
||||
TracesExporter: cfg.TracesExporter,
|
||||
MetricsExporter: cfg.MetricsExporter,
|
||||
TracesProtocol: cfg.TracesProtocol,
|
||||
MetricsProtocol: cfg.MetricsProtocol,
|
||||
StdoutTracesEnabled: cfg.StdoutTracesEnabled,
|
||||
StdoutMetricsEnabled: cfg.StdoutMetricsEnabled,
|
||||
}.Validate()
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadFromEnvUsesDefaults(t *testing.T) {
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
require.NoError(t, err)
|
||||
|
||||
defaults := DefaultConfig()
|
||||
require.Equal(t, defaults.ShutdownTimeout, cfg.ShutdownTimeout)
|
||||
require.Equal(t, defaults.Logging, cfg.Logging)
|
||||
require.Equal(t, defaults.InternalHTTP, cfg.InternalHTTP)
|
||||
require.Equal(t, "127.0.0.1:6379", cfg.Redis.Addr)
|
||||
require.Equal(t, defaults.Redis.DB, cfg.Redis.DB)
|
||||
require.Equal(t, defaults.Redis.OperationTimeout, cfg.Redis.OperationTimeout)
|
||||
require.Equal(t, defaults.Redis.CommandStream, cfg.Redis.CommandStream)
|
||||
require.Equal(t, defaults.Redis.AttemptScheduleKey, cfg.Redis.AttemptScheduleKey)
|
||||
require.Equal(t, defaults.Redis.DeadLetterPrefix, cfg.Redis.DeadLetterPrefix)
|
||||
require.Equal(t, defaults.SMTP, cfg.SMTP)
|
||||
require.Equal(t, defaults.Templates, cfg.Templates)
|
||||
require.Equal(t, defaults.AttemptWorkerConcurrency, cfg.AttemptWorkerConcurrency)
|
||||
require.Equal(t, defaults.StreamBlockTimeout, cfg.StreamBlockTimeout)
|
||||
require.Equal(t, defaults.OperatorRequestTimeout, cfg.OperatorRequestTimeout)
|
||||
require.Equal(t, defaults.IdempotencyTTL, cfg.IdempotencyTTL)
|
||||
require.Equal(t, defaults.DeliveryTTL, cfg.DeliveryTTL)
|
||||
require.Equal(t, defaults.AttemptTTL, cfg.AttemptTTL)
|
||||
require.Equal(t, defaults.Telemetry, cfg.Telemetry)
|
||||
}
|
||||
|
||||
func TestLoadFromEnvAppliesOverrides(t *testing.T) {
|
||||
t.Setenv(shutdownTimeoutEnvVar, "9s")
|
||||
t.Setenv(logLevelEnvVar, "debug")
|
||||
t.Setenv(internalHTTPAddrEnvVar, "127.0.0.1:18080")
|
||||
t.Setenv(internalHTTPReadHeaderTimeoutEnvVar, "3s")
|
||||
t.Setenv(internalHTTPReadTimeoutEnvVar, "11s")
|
||||
t.Setenv(internalHTTPIdleTimeoutEnvVar, "61s")
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6380")
|
||||
t.Setenv(redisUsernameEnvVar, "alice")
|
||||
t.Setenv(redisPasswordEnvVar, "secret")
|
||||
t.Setenv(redisDBEnvVar, "3")
|
||||
t.Setenv(redisTLSEnabledEnvVar, "true")
|
||||
t.Setenv(redisOperationTimeoutEnvVar, "750ms")
|
||||
t.Setenv(redisCommandStreamEnvVar, "mail:test_commands")
|
||||
t.Setenv(redisAttemptScheduleEnvVar, "mail:test_schedule")
|
||||
t.Setenv(redisDeadLetterPrefixEnvVar, "mail:test_dead_letters:")
|
||||
t.Setenv(smtpModeEnvVar, SMTPModeSMTP)
|
||||
t.Setenv(smtpAddrEnvVar, "127.0.0.1:2525")
|
||||
t.Setenv(smtpUsernameEnvVar, "mailer")
|
||||
t.Setenv(smtpPasswordEnvVar, "smtp-secret")
|
||||
t.Setenv(smtpFromEmailEnvVar, "noreply@example.com")
|
||||
t.Setenv(smtpFromNameEnvVar, "Galaxy Mail")
|
||||
t.Setenv(smtpTimeoutEnvVar, "19s")
|
||||
t.Setenv(smtpInsecureSkipVerifyEnvVar, "true")
|
||||
t.Setenv(templateDirEnvVar, "/tmp/templates")
|
||||
t.Setenv(attemptWorkerConcurrencyEnvVar, "8")
|
||||
t.Setenv(streamBlockTimeoutEnvVar, "5s")
|
||||
t.Setenv(operatorRequestTimeoutEnvVar, "6s")
|
||||
t.Setenv(idempotencyTTLEnvVar, "48h")
|
||||
t.Setenv(deliveryTTLEnvVar, "96h")
|
||||
t.Setenv(attemptTTLEnvVar, "240h")
|
||||
t.Setenv(otelServiceNameEnvVar, "custom-mail")
|
||||
t.Setenv(otelTracesExporterEnvVar, "otlp")
|
||||
t.Setenv(otelMetricsExporterEnvVar, "otlp")
|
||||
t.Setenv(otelExporterOTLPProtocolEnvVar, "grpc")
|
||||
t.Setenv(otelStdoutTracesEnabledEnvVar, "true")
|
||||
t.Setenv(otelStdoutMetricsEnabledEnvVar, "true")
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 9*time.Second, cfg.ShutdownTimeout)
|
||||
require.Equal(t, "debug", cfg.Logging.Level)
|
||||
require.Equal(t, InternalHTTPConfig{
|
||||
Addr: "127.0.0.1:18080",
|
||||
ReadHeaderTimeout: 3 * time.Second,
|
||||
ReadTimeout: 11 * time.Second,
|
||||
IdleTimeout: 61 * time.Second,
|
||||
}, cfg.InternalHTTP)
|
||||
require.Equal(t, RedisConfig{
|
||||
Addr: "127.0.0.1:6380",
|
||||
Username: "alice",
|
||||
Password: "secret",
|
||||
DB: 3,
|
||||
TLSEnabled: true,
|
||||
OperationTimeout: 750 * time.Millisecond,
|
||||
CommandStream: "mail:test_commands",
|
||||
AttemptScheduleKey: "mail:test_schedule",
|
||||
DeadLetterPrefix: "mail:test_dead_letters:",
|
||||
}, cfg.Redis)
|
||||
require.Equal(t, SMTPConfig{
|
||||
Mode: SMTPModeSMTP,
|
||||
Addr: "127.0.0.1:2525",
|
||||
Username: "mailer",
|
||||
Password: "smtp-secret",
|
||||
FromEmail: "noreply@example.com",
|
||||
FromName: "Galaxy Mail",
|
||||
Timeout: 19 * time.Second,
|
||||
InsecureSkipVerify: true,
|
||||
}, cfg.SMTP)
|
||||
require.Equal(t, TemplateConfig{Dir: "/tmp/templates"}, cfg.Templates)
|
||||
require.Equal(t, 8, cfg.AttemptWorkerConcurrency)
|
||||
require.Equal(t, 5*time.Second, cfg.StreamBlockTimeout)
|
||||
require.Equal(t, 6*time.Second, cfg.OperatorRequestTimeout)
|
||||
require.Equal(t, 48*time.Hour, cfg.IdempotencyTTL)
|
||||
require.Equal(t, 96*time.Hour, cfg.DeliveryTTL)
|
||||
require.Equal(t, 240*time.Hour, cfg.AttemptTTL)
|
||||
require.Equal(t, TelemetryConfig{
|
||||
ServiceName: "custom-mail",
|
||||
TracesExporter: "otlp",
|
||||
MetricsExporter: "otlp",
|
||||
TracesProtocol: "grpc",
|
||||
MetricsProtocol: "grpc",
|
||||
StdoutTracesEnabled: true,
|
||||
StdoutMetricsEnabled: true,
|
||||
}, cfg.Telemetry)
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envName string
|
||||
envVal string
|
||||
}{
|
||||
{name: "invalid duration", envName: shutdownTimeoutEnvVar, envVal: "later"},
|
||||
{name: "invalid log level", envName: logLevelEnvVar, envVal: "verbose"},
|
||||
{name: "invalid redis db", envName: redisDBEnvVar, envVal: "db-three"},
|
||||
{name: "invalid redis tls", envName: redisTLSEnabledEnvVar, envVal: "sometimes"},
|
||||
{name: "invalid redis timeout", envName: redisOperationTimeoutEnvVar, envVal: "never"},
|
||||
{name: "invalid smtp mode", envName: smtpModeEnvVar, envVal: "ses"},
|
||||
{name: "invalid smtp timeout", envName: smtpTimeoutEnvVar, envVal: "fast"},
|
||||
{name: "invalid smtp insecure skip verify", envName: smtpInsecureSkipVerifyEnvVar, envVal: "sometimes"},
|
||||
{name: "invalid worker count", envName: attemptWorkerConcurrencyEnvVar, envVal: "many"},
|
||||
{name: "invalid otel traces exporter", envName: otelTracesExporterEnvVar, envVal: "stdout"},
|
||||
{name: "invalid otel metrics exporter", envName: otelMetricsExporterEnvVar, envVal: "stdout"},
|
||||
{name: "invalid otel traces protocol", envName: otelExporterOTLPTracesProtocolEnvVar, envVal: "udp"},
|
||||
{name: "invalid otel metrics protocol", envName: otelExporterOTLPMetricsProtocolEnvVar, envVal: "udp"},
|
||||
{name: "invalid otel stdout traces", envName: otelStdoutTracesEnabledEnvVar, envVal: "sometimes"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
|
||||
t.Setenv(tt.envName, tt.envVal)
|
||||
if tt.envName == smtpTimeoutEnvVar {
|
||||
t.Setenv(smtpModeEnvVar, SMTPModeSMTP)
|
||||
t.Setenv(smtpAddrEnvVar, "127.0.0.1:2525")
|
||||
t.Setenv(smtpFromEmailEnvVar, "noreply@example.com")
|
||||
}
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsMissingRequiredRedisAddr(t *testing.T) {
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "redis addr")
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsInvalidRedisAddr(t *testing.T) {
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "redis addr")
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsInvalidSMTPConfiguration(t *testing.T) {
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
|
||||
t.Setenv(smtpModeEnvVar, SMTPModeSMTP)
|
||||
|
||||
t.Run("missing addr", func(t *testing.T) {
|
||||
t.Setenv(smtpFromEmailEnvVar, "noreply@example.com")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "smtp addr")
|
||||
})
|
||||
|
||||
t.Run("missing from email", func(t *testing.T) {
|
||||
t.Setenv(smtpAddrEnvVar, "127.0.0.1:2525")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "smtp from email")
|
||||
})
|
||||
|
||||
t.Run("username without password", func(t *testing.T) {
|
||||
t.Setenv(smtpAddrEnvVar, "127.0.0.1:2525")
|
||||
t.Setenv(smtpFromEmailEnvVar, "noreply@example.com")
|
||||
t.Setenv(smtpUsernameEnvVar, "mailer")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "smtp username and password")
|
||||
})
|
||||
|
||||
t.Run("password without username", func(t *testing.T) {
|
||||
t.Setenv(smtpAddrEnvVar, "127.0.0.1:2525")
|
||||
t.Setenv(smtpFromEmailEnvVar, "noreply@example.com")
|
||||
t.Setenv(smtpPasswordEnvVar, "secret")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "smtp username and password")
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsNonPositiveDurationsAndCounts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envName string
|
||||
envVal string
|
||||
}{
|
||||
{name: "shutdown timeout", envName: shutdownTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "read header timeout", envName: internalHTTPReadHeaderTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "read timeout", envName: internalHTTPReadTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "idle timeout", envName: internalHTTPIdleTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "redis operation timeout", envName: redisOperationTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "smtp timeout", envName: smtpTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "attempt worker concurrency", envName: attemptWorkerConcurrencyEnvVar, envVal: "0"},
|
||||
{name: "stream block timeout", envName: streamBlockTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "operator request timeout", envName: operatorRequestTimeoutEnvVar, envVal: "0s"},
|
||||
{name: "idempotency ttl", envName: idempotencyTTLEnvVar, envVal: "0s"},
|
||||
{name: "delivery ttl", envName: deliveryTTLEnvVar, envVal: "0s"},
|
||||
{name: "attempt ttl", envName: attemptTTLEnvVar, envVal: "0s"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
|
||||
t.Setenv(tt.envName, tt.envVal)
|
||||
if tt.envName == smtpTimeoutEnvVar {
|
||||
t.Setenv(smtpModeEnvVar, SMTPModeSMTP)
|
||||
t.Setenv(smtpAddrEnvVar, "127.0.0.1:2525")
|
||||
t.Setenv(smtpFromEmailEnvVar, "noreply@example.com")
|
||||
}
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoadFromEnv builds Config from environment variables and validates the
|
||||
// resulting configuration.
|
||||
func LoadFromEnv() (Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
var err error
|
||||
|
||||
cfg.ShutdownTimeout, err = durationEnv(shutdownTimeoutEnvVar, cfg.ShutdownTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Logging.Level = stringEnv(logLevelEnvVar, cfg.Logging.Level)
|
||||
|
||||
cfg.InternalHTTP.Addr = stringEnv(internalHTTPAddrEnvVar, cfg.InternalHTTP.Addr)
|
||||
cfg.InternalHTTP.ReadHeaderTimeout, err = durationEnv(internalHTTPReadHeaderTimeoutEnvVar, cfg.InternalHTTP.ReadHeaderTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.InternalHTTP.ReadTimeout, err = durationEnv(internalHTTPReadTimeoutEnvVar, cfg.InternalHTTP.ReadTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.InternalHTTP.IdleTimeout, err = durationEnv(internalHTTPIdleTimeoutEnvVar, cfg.InternalHTTP.IdleTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Redis.Addr = stringEnv(redisAddrEnvVar, cfg.Redis.Addr)
|
||||
cfg.Redis.Username = stringEnv(redisUsernameEnvVar, cfg.Redis.Username)
|
||||
cfg.Redis.Password = stringEnv(redisPasswordEnvVar, cfg.Redis.Password)
|
||||
cfg.Redis.DB, err = intEnv(redisDBEnvVar, cfg.Redis.DB)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Redis.TLSEnabled, err = boolEnv(redisTLSEnabledEnvVar, cfg.Redis.TLSEnabled)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Redis.OperationTimeout, err = durationEnv(redisOperationTimeoutEnvVar, cfg.Redis.OperationTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Redis.CommandStream = stringEnv(redisCommandStreamEnvVar, cfg.Redis.CommandStream)
|
||||
cfg.Redis.AttemptScheduleKey = stringEnv(redisAttemptScheduleEnvVar, cfg.Redis.AttemptScheduleKey)
|
||||
cfg.Redis.DeadLetterPrefix = stringEnv(redisDeadLetterPrefixEnvVar, cfg.Redis.DeadLetterPrefix)
|
||||
|
||||
cfg.SMTP.Mode = stringEnv(smtpModeEnvVar, cfg.SMTP.Mode)
|
||||
cfg.SMTP.Addr = stringEnv(smtpAddrEnvVar, cfg.SMTP.Addr)
|
||||
cfg.SMTP.Username = stringEnv(smtpUsernameEnvVar, cfg.SMTP.Username)
|
||||
cfg.SMTP.Password = stringEnv(smtpPasswordEnvVar, cfg.SMTP.Password)
|
||||
cfg.SMTP.FromEmail = stringEnv(smtpFromEmailEnvVar, cfg.SMTP.FromEmail)
|
||||
cfg.SMTP.FromName = stringEnv(smtpFromNameEnvVar, cfg.SMTP.FromName)
|
||||
cfg.SMTP.Timeout, err = durationEnv(smtpTimeoutEnvVar, cfg.SMTP.Timeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.SMTP.InsecureSkipVerify, err = boolEnv(smtpInsecureSkipVerifyEnvVar, cfg.SMTP.InsecureSkipVerify)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Templates.Dir = stringEnv(templateDirEnvVar, cfg.Templates.Dir)
|
||||
|
||||
cfg.AttemptWorkerConcurrency, err = intEnv(attemptWorkerConcurrencyEnvVar, cfg.AttemptWorkerConcurrency)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.StreamBlockTimeout, err = durationEnv(streamBlockTimeoutEnvVar, cfg.StreamBlockTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.OperatorRequestTimeout, err = durationEnv(operatorRequestTimeoutEnvVar, cfg.OperatorRequestTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.IdempotencyTTL, err = durationEnv(idempotencyTTLEnvVar, cfg.IdempotencyTTL)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.DeliveryTTL, err = durationEnv(deliveryTTLEnvVar, cfg.DeliveryTTL)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.AttemptTTL, err = durationEnv(attemptTTLEnvVar, cfg.AttemptTTL)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Telemetry.ServiceName = stringEnv(otelServiceNameEnvVar, cfg.Telemetry.ServiceName)
|
||||
cfg.Telemetry.TracesExporter = normalizeExporterValue(stringEnv(otelTracesExporterEnvVar, cfg.Telemetry.TracesExporter))
|
||||
cfg.Telemetry.MetricsExporter = normalizeExporterValue(stringEnv(otelMetricsExporterEnvVar, cfg.Telemetry.MetricsExporter))
|
||||
cfg.Telemetry.TracesProtocol = normalizeProtocolValue(
|
||||
os.Getenv(otelExporterOTLPTracesProtocolEnvVar),
|
||||
os.Getenv(otelExporterOTLPProtocolEnvVar),
|
||||
cfg.Telemetry.TracesProtocol,
|
||||
)
|
||||
cfg.Telemetry.MetricsProtocol = normalizeProtocolValue(
|
||||
os.Getenv(otelExporterOTLPMetricsProtocolEnvVar),
|
||||
os.Getenv(otelExporterOTLPProtocolEnvVar),
|
||||
cfg.Telemetry.MetricsProtocol,
|
||||
)
|
||||
cfg.Telemetry.StdoutTracesEnabled, err = boolEnv(otelStdoutTracesEnabledEnvVar, cfg.Telemetry.StdoutTracesEnabled)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Telemetry.StdoutMetricsEnabled, err = boolEnv(otelStdoutMetricsEnabledEnvVar, cfg.Telemetry.StdoutMetricsEnabled)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if err := validateSlogLevel(cfg.Logging.Level); err != nil {
|
||||
return Config{}, fmt.Errorf("%s: %w", logLevelEnvVar, err)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func stringEnv(name string, fallback string) string {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
parsed, err := time.ParseDuration(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: parse duration: %w", name, err)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func intEnv(name string, fallback int) (int, error) {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: parse int: %w", name, err)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func boolEnv(name string, fallback bool) (bool, error) {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%s: parse bool: %w", name, err)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func normalizeExporterValue(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
switch trimmed {
|
||||
case "", "none":
|
||||
return "none"
|
||||
default:
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProtocolValue(primary string, fallback string, defaultValue string) string {
|
||||
primary = strings.TrimSpace(primary)
|
||||
if primary != "" {
|
||||
return primary
|
||||
}
|
||||
|
||||
fallback = strings.TrimSpace(fallback)
|
||||
if fallback != "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return strings.TrimSpace(defaultValue)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/mail"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Validate reports whether cfg stores a usable Mail Service process
|
||||
// configuration.
|
||||
func (cfg Config) Validate() error {
|
||||
switch {
|
||||
case cfg.ShutdownTimeout <= 0:
|
||||
return fmt.Errorf("%s must be positive", shutdownTimeoutEnvVar)
|
||||
case cfg.AttemptWorkerConcurrency <= 0:
|
||||
return fmt.Errorf("%s must be positive", attemptWorkerConcurrencyEnvVar)
|
||||
case cfg.StreamBlockTimeout <= 0:
|
||||
return fmt.Errorf("%s must be positive", streamBlockTimeoutEnvVar)
|
||||
case cfg.OperatorRequestTimeout <= 0:
|
||||
return fmt.Errorf("%s must be positive", operatorRequestTimeoutEnvVar)
|
||||
case cfg.IdempotencyTTL <= 0:
|
||||
return fmt.Errorf("%s must be positive", idempotencyTTLEnvVar)
|
||||
case cfg.DeliveryTTL <= 0:
|
||||
return fmt.Errorf("%s must be positive", deliveryTTLEnvVar)
|
||||
case cfg.AttemptTTL <= 0:
|
||||
return fmt.Errorf("%s must be positive", attemptTTLEnvVar)
|
||||
}
|
||||
|
||||
if err := cfg.InternalHTTP.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.Redis.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.SMTP.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.Templates.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.Telemetry.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSlogLevel(level string) error {
|
||||
var slogLevel slog.Level
|
||||
if err := slogLevel.UnmarshalText([]byte(strings.TrimSpace(level))); err != nil {
|
||||
return fmt.Errorf("invalid slog level %q: %w", level, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isTCPAddr(value string) bool {
|
||||
host, port, err := net.SplitHostPort(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if port == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if host == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return !strings.Contains(host, " ")
|
||||
}
|
||||
|
||||
func validateMailbox(name string, value string) error {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("%s must not be empty", name)
|
||||
}
|
||||
|
||||
parsed, err := mail.ParseAddress(trimmed)
|
||||
if err != nil || parsed == nil || parsed.Name != "" || parsed.Address != trimmed {
|
||||
return fmt.Errorf("%s %q must be a single valid email address", name, value)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// Package attempt defines the logical delivery-attempt entity owned by Mail
|
||||
// Service.
|
||||
package attempt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
)
|
||||
|
||||
// Status identifies the lifecycle state of one concrete delivery attempt.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
// StatusScheduled reports that the attempt is durably planned but has not
|
||||
// started execution yet.
|
||||
StatusScheduled Status = "scheduled"
|
||||
|
||||
// StatusInProgress reports that one worker currently owns the attempt.
|
||||
StatusInProgress Status = "in_progress"
|
||||
|
||||
// StatusProviderAccepted reports that the provider accepted the SMTP
|
||||
// envelope.
|
||||
StatusProviderAccepted Status = "provider_accepted"
|
||||
|
||||
// StatusProviderRejected reports that the provider rejected the SMTP
|
||||
// envelope.
|
||||
StatusProviderRejected Status = "provider_rejected"
|
||||
|
||||
// StatusTransportFailed reports that the attempt failed before a stable
|
||||
// provider accept or reject result was obtained.
|
||||
StatusTransportFailed Status = "transport_failed"
|
||||
|
||||
// StatusTimedOut reports that the provider call exceeded the configured
|
||||
// execution deadline.
|
||||
StatusTimedOut Status = "timed_out"
|
||||
|
||||
// StatusRenderFailed reports that template rendering failed before any
|
||||
// provider interaction was attempted.
|
||||
StatusRenderFailed Status = "render_failed"
|
||||
)
|
||||
|
||||
// IsKnown reports whether Status is supported by the current Mail Service
|
||||
// attempt state machine.
|
||||
func (status Status) IsKnown() bool {
|
||||
switch status {
|
||||
case StatusScheduled,
|
||||
StatusInProgress,
|
||||
StatusProviderAccepted,
|
||||
StatusProviderRejected,
|
||||
StatusTransportFailed,
|
||||
StatusTimedOut,
|
||||
StatusRenderFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsTerminal reports whether Status can no longer accept a lifecycle
|
||||
// transition.
|
||||
func (status Status) IsTerminal() bool {
|
||||
switch status {
|
||||
case StatusProviderAccepted,
|
||||
StatusProviderRejected,
|
||||
StatusTransportFailed,
|
||||
StatusTimedOut,
|
||||
StatusRenderFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CanTransitionTo reports whether the current Status may move to next under
|
||||
// the frozen Stage 2 attempt lifecycle rules.
|
||||
func (status Status) CanTransitionTo(next Status) bool {
|
||||
switch status {
|
||||
case StatusScheduled:
|
||||
switch next {
|
||||
case StatusInProgress, StatusRenderFailed:
|
||||
return true
|
||||
}
|
||||
case StatusInProgress:
|
||||
switch next {
|
||||
case StatusProviderAccepted, StatusProviderRejected, StatusTransportFailed, StatusTimedOut:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Attempt stores one durable execution record for a delivery attempt.
|
||||
type Attempt struct {
|
||||
// DeliveryID identifies the owning logical delivery.
|
||||
DeliveryID common.DeliveryID
|
||||
|
||||
// AttemptNo stores the monotonically increasing attempt sequence number.
|
||||
AttemptNo int
|
||||
|
||||
// ScheduledFor stores when the attempt becomes due.
|
||||
ScheduledFor time.Time
|
||||
|
||||
// StartedAt stores when a worker claimed the attempt for execution.
|
||||
StartedAt *time.Time
|
||||
|
||||
// FinishedAt stores when the attempt reached a terminal outcome.
|
||||
FinishedAt *time.Time
|
||||
|
||||
// Status stores the current attempt lifecycle state.
|
||||
Status Status
|
||||
|
||||
// ProviderClassification stores provider-specific or adapter-specific
|
||||
// result classification details when available.
|
||||
ProviderClassification string
|
||||
|
||||
// ProviderSummary stores redacted provider outcome details when available.
|
||||
ProviderSummary string
|
||||
}
|
||||
|
||||
// Validate reports whether Attempt satisfies the frozen Stage 2 structural and
|
||||
// lifecycle invariants.
|
||||
func (record Attempt) Validate() error {
|
||||
if err := record.DeliveryID.Validate(); err != nil {
|
||||
return fmt.Errorf("attempt delivery id: %w", err)
|
||||
}
|
||||
if record.AttemptNo < 1 {
|
||||
return fmt.Errorf("attempt number must be at least 1")
|
||||
}
|
||||
if err := common.ValidateTimestamp("attempt scheduled for", record.ScheduledFor); err != nil {
|
||||
return err
|
||||
}
|
||||
if !record.Status.IsKnown() {
|
||||
return fmt.Errorf("attempt status %q is unsupported", record.Status)
|
||||
}
|
||||
if err := validateOptionalToken("attempt provider classification", record.ProviderClassification); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalToken("attempt provider summary", record.ProviderSummary); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch record.Status {
|
||||
case StatusScheduled:
|
||||
if record.StartedAt != nil {
|
||||
return fmt.Errorf("scheduled attempt must not contain started at")
|
||||
}
|
||||
if record.FinishedAt != nil {
|
||||
return fmt.Errorf("scheduled attempt must not contain finished at")
|
||||
}
|
||||
case StatusInProgress:
|
||||
if record.StartedAt == nil {
|
||||
return fmt.Errorf("in-progress attempt must contain started at")
|
||||
}
|
||||
if err := common.ValidateTimestamp("attempt started at", *record.StartedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.StartedAt.Before(record.ScheduledFor) {
|
||||
return fmt.Errorf("attempt started at must not be before scheduled for")
|
||||
}
|
||||
if record.FinishedAt != nil {
|
||||
return fmt.Errorf("in-progress attempt must not contain finished at")
|
||||
}
|
||||
default:
|
||||
if record.StartedAt == nil {
|
||||
return fmt.Errorf("terminal attempt must contain started at")
|
||||
}
|
||||
if err := common.ValidateTimestamp("attempt started at", *record.StartedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.StartedAt.Before(record.ScheduledFor) {
|
||||
return fmt.Errorf("attempt started at must not be before scheduled for")
|
||||
}
|
||||
if record.FinishedAt == nil {
|
||||
return fmt.Errorf("terminal attempt must contain finished at")
|
||||
}
|
||||
if err := common.ValidateTimestamp("attempt finished at", *record.FinishedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.FinishedAt.Before(*record.StartedAt) {
|
||||
return fmt.Errorf("attempt finished at must not be before started at")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOptionalToken(name string, value string) error {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(value) != value {
|
||||
return fmt.Errorf("%s must not contain surrounding whitespace", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package attempt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStatusCanTransitionTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
from Status
|
||||
to Status
|
||||
want bool
|
||||
}{
|
||||
{name: "scheduled to in progress", from: StatusScheduled, to: StatusInProgress, want: true},
|
||||
{name: "scheduled to render failed", from: StatusScheduled, to: StatusRenderFailed, want: true},
|
||||
{name: "scheduled to accepted", from: StatusScheduled, to: StatusProviderAccepted, want: false},
|
||||
{name: "in progress to accepted", from: StatusInProgress, to: StatusProviderAccepted, want: true},
|
||||
{name: "in progress to rejected", from: StatusInProgress, to: StatusProviderRejected, want: true},
|
||||
{name: "in progress to transport failed", from: StatusInProgress, to: StatusTransportFailed, want: true},
|
||||
{name: "in progress to timed out", from: StatusInProgress, to: StatusTimedOut, want: true},
|
||||
{name: "accepted terminal", from: StatusProviderAccepted, to: StatusTimedOut, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, tt.want, tt.from.CanTransitionTo(tt.to))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusIsTerminal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.False(t, StatusScheduled.IsTerminal())
|
||||
require.False(t, StatusInProgress.IsTerminal())
|
||||
require.True(t, StatusProviderAccepted.IsTerminal())
|
||||
require.True(t, StatusProviderRejected.IsTerminal())
|
||||
require.True(t, StatusTransportFailed.IsTerminal())
|
||||
require.True(t, StatusTimedOut.IsTerminal())
|
||||
require.True(t, StatusRenderFailed.IsTerminal())
|
||||
}
|
||||
|
||||
func TestAttemptValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scheduledFor := time.Unix(1_775_121_700, 0).UTC()
|
||||
startedAt := scheduledFor.Add(time.Minute)
|
||||
finishedAt := startedAt.Add(2 * time.Second)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record Attempt
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid scheduled",
|
||||
record: Attempt{
|
||||
DeliveryID: common.DeliveryID("delivery-123"),
|
||||
AttemptNo: 1,
|
||||
ScheduledFor: scheduledFor,
|
||||
Status: StatusScheduled,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid in progress",
|
||||
record: Attempt{
|
||||
DeliveryID: common.DeliveryID("delivery-123"),
|
||||
AttemptNo: 2,
|
||||
ScheduledFor: scheduledFor,
|
||||
StartedAt: &startedAt,
|
||||
Status: StatusInProgress,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid terminal",
|
||||
record: Attempt{
|
||||
DeliveryID: common.DeliveryID("delivery-123"),
|
||||
AttemptNo: 3,
|
||||
ScheduledFor: scheduledFor,
|
||||
StartedAt: &startedAt,
|
||||
FinishedAt: &finishedAt,
|
||||
Status: StatusProviderAccepted,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid render failed",
|
||||
record: Attempt{
|
||||
DeliveryID: common.DeliveryID("delivery-123"),
|
||||
AttemptNo: 4,
|
||||
ScheduledFor: scheduledFor,
|
||||
StartedAt: &startedAt,
|
||||
FinishedAt: &finishedAt,
|
||||
Status: StatusRenderFailed,
|
||||
ProviderClassification: "missing_required_variable",
|
||||
ProviderSummary: "missing required variables: player.name",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "attempt number must be positive",
|
||||
record: Attempt{
|
||||
DeliveryID: common.DeliveryID("delivery-123"),
|
||||
ScheduledFor: scheduledFor,
|
||||
Status: StatusScheduled,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "in progress missing started at",
|
||||
record: Attempt{
|
||||
DeliveryID: common.DeliveryID("delivery-123"),
|
||||
AttemptNo: 1,
|
||||
ScheduledFor: scheduledFor,
|
||||
Status: StatusInProgress,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "terminal missing finished at",
|
||||
record: Attempt{
|
||||
DeliveryID: common.DeliveryID("delivery-123"),
|
||||
AttemptNo: 1,
|
||||
ScheduledFor: scheduledFor,
|
||||
StartedAt: &startedAt,
|
||||
Status: StatusProviderRejected,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "finished before started",
|
||||
record: Attempt{
|
||||
DeliveryID: common.DeliveryID("delivery-123"),
|
||||
AttemptNo: 1,
|
||||
ScheduledFor: scheduledFor,
|
||||
StartedAt: &startedAt,
|
||||
FinishedAt: &scheduledFor,
|
||||
Status: StatusTimedOut,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.record.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// Package common defines shared value objects used across the Mail Service
|
||||
// domain model.
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// DeliveryID identifies one logical mail delivery accepted by Mail Service.
|
||||
type DeliveryID string
|
||||
|
||||
// String returns DeliveryID as its stored identifier string.
|
||||
func (id DeliveryID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero reports whether DeliveryID does not contain a usable value.
|
||||
func (id DeliveryID) IsZero() bool {
|
||||
return strings.TrimSpace(string(id)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether DeliveryID is non-empty and already normalized for
|
||||
// domain use.
|
||||
func (id DeliveryID) Validate() error {
|
||||
return validateToken("delivery id", string(id))
|
||||
}
|
||||
|
||||
// TemplateID identifies one template family owned by the filesystem-backed
|
||||
// Mail Service template catalog.
|
||||
type TemplateID string
|
||||
|
||||
// String returns TemplateID as its stored identifier string.
|
||||
func (id TemplateID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero reports whether TemplateID does not contain a usable value.
|
||||
func (id TemplateID) IsZero() bool {
|
||||
return strings.TrimSpace(string(id)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether TemplateID is non-empty and already normalized for
|
||||
// domain use.
|
||||
func (id TemplateID) Validate() error {
|
||||
return validateToken("template id", string(id))
|
||||
}
|
||||
|
||||
// IdempotencyKey stores the caller-owned key used to deduplicate accepted
|
||||
// delivery commands.
|
||||
type IdempotencyKey string
|
||||
|
||||
// String returns IdempotencyKey as its stored string.
|
||||
func (key IdempotencyKey) String() string {
|
||||
return string(key)
|
||||
}
|
||||
|
||||
// IsZero reports whether IdempotencyKey does not contain a usable value.
|
||||
func (key IdempotencyKey) IsZero() bool {
|
||||
return strings.TrimSpace(string(key)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether IdempotencyKey is non-empty and already normalized
|
||||
// for domain use.
|
||||
func (key IdempotencyKey) Validate() error {
|
||||
return validateToken("idempotency key", string(key))
|
||||
}
|
||||
|
||||
// Email stores one normalized recipient or reply-to address.
|
||||
type Email string
|
||||
|
||||
// String returns Email as its stored canonical string.
|
||||
func (email Email) String() string {
|
||||
return string(email)
|
||||
}
|
||||
|
||||
// IsZero reports whether Email does not contain a usable address.
|
||||
func (email Email) IsZero() bool {
|
||||
return strings.TrimSpace(string(email)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether Email is non-empty, trimmed, and matches the same
|
||||
// single-address syntax expected by the trusted Mail Service contracts.
|
||||
func (email Email) Validate() error {
|
||||
raw := string(email)
|
||||
if err := validateToken("email", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsedAddress, err := mail.ParseAddress(raw)
|
||||
if err != nil || parsedAddress.Name != "" || parsedAddress.Address != raw {
|
||||
return fmt.Errorf("email %q must be a single valid email address", raw)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Locale stores one canonical BCP 47 language tag used by template selection
|
||||
// and rendering.
|
||||
type Locale string
|
||||
|
||||
// ParseLocale validates value as a BCP 47 language tag and returns the
|
||||
// canonical stored representation used by the Mail Service domain model.
|
||||
func ParseLocale(value string) (Locale, error) {
|
||||
if err := validateToken("locale", value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tag, err := language.Parse(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("locale %q must be a valid BCP 47 language tag: %w", value, err)
|
||||
}
|
||||
|
||||
return Locale(tag.String()), nil
|
||||
}
|
||||
|
||||
// String returns Locale as its stored canonical string.
|
||||
func (locale Locale) String() string {
|
||||
return string(locale)
|
||||
}
|
||||
|
||||
// IsZero reports whether Locale does not contain a usable value.
|
||||
func (locale Locale) IsZero() bool {
|
||||
return strings.TrimSpace(string(locale)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether Locale stores a canonical BCP 47 language tag.
|
||||
func (locale Locale) Validate() error {
|
||||
raw := string(locale)
|
||||
if err := validateToken("locale", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tag, err := language.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("locale %q must be a valid BCP 47 language tag: %w", raw, err)
|
||||
}
|
||||
|
||||
canonical := tag.String()
|
||||
if raw != canonical {
|
||||
return fmt.Errorf("locale %q must use canonical BCP 47 form %q", raw, canonical)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttachmentMetadata stores only the durable audit metadata kept for one
|
||||
// accepted attachment. Raw bytes remain outside the long-lived domain model.
|
||||
type AttachmentMetadata struct {
|
||||
// Filename stores the user-facing attachment filename.
|
||||
Filename string
|
||||
|
||||
// ContentType stores the MIME media type used for SMTP body construction.
|
||||
ContentType string
|
||||
|
||||
// SizeBytes stores the decoded payload size in bytes.
|
||||
SizeBytes int64
|
||||
}
|
||||
|
||||
// Validate reports whether AttachmentMetadata contains a complete attachment
|
||||
// audit entry.
|
||||
func (metadata AttachmentMetadata) Validate() error {
|
||||
if err := validateToken("attachment filename", metadata.Filename); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateToken("attachment content type", metadata.ContentType); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := mime.ParseMediaType(metadata.ContentType); err != nil {
|
||||
return fmt.Errorf("attachment content type %q must be a valid MIME media type: %w", metadata.ContentType, err)
|
||||
}
|
||||
if metadata.SizeBytes < 0 {
|
||||
return fmt.Errorf("attachment size bytes must not be negative")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateTimestamp reports whether value is present.
|
||||
func ValidateTimestamp(name string, value time.Time) error {
|
||||
if value.IsZero() {
|
||||
return fmt.Errorf("%s must not be zero", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateToken(name string, value string) error {
|
||||
switch {
|
||||
case strings.TrimSpace(value) == "":
|
||||
return fmt.Errorf("%s must not be empty", name)
|
||||
case strings.TrimSpace(value) != value:
|
||||
return fmt.Errorf("%s must not contain surrounding whitespace", name)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIdentifierValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
run func() error
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid delivery id",
|
||||
run: func() error {
|
||||
return DeliveryID("delivery-123").Validate()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid template id",
|
||||
run: func() error {
|
||||
return TemplateID("auth.login_code").Validate()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid idempotency key",
|
||||
run: func() error {
|
||||
return IdempotencyKey("notification:delivery-123").Validate()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty delivery id",
|
||||
run: func() error {
|
||||
return DeliveryID("").Validate()
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "template id with whitespace",
|
||||
run: func() error {
|
||||
return TemplateID(" auth.login_code ").Validate()
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.run()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value Email
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", value: Email("pilot@example.com")},
|
||||
{name: "empty", value: Email(""), wantErr: true},
|
||||
{name: "display name forbidden", value: Email("Pilot <pilot@example.com>"), wantErr: true},
|
||||
{name: "whitespace forbidden", value: Email(" pilot@example.com "), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLocale(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
value, err := ParseLocale("fr-fr")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Locale("fr-FR"), value)
|
||||
require.NoError(t, value.Validate())
|
||||
}
|
||||
|
||||
func TestLocaleValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value Locale
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "canonical language", value: Locale("en")},
|
||||
{name: "canonical regional", value: Locale("fr-FR")},
|
||||
{name: "non canonical", value: Locale("fr-fr"), wantErr: true},
|
||||
{name: "invalid syntax", value: Locale("not a locale"), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentMetadataValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value AttachmentMetadata
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
value: AttachmentMetadata{
|
||||
Filename: "report.txt",
|
||||
ContentType: "text/plain; charset=utf-8",
|
||||
SizeBytes: 512,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid content type",
|
||||
value: AttachmentMetadata{
|
||||
Filename: "report.txt",
|
||||
ContentType: "plain text",
|
||||
SizeBytes: 512,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "negative size",
|
||||
value: AttachmentMetadata{
|
||||
Filename: "report.txt",
|
||||
ContentType: "text/plain",
|
||||
SizeBytes: -1,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
// Package delivery defines the logical delivery and dead-letter entities owned
|
||||
// directly by Mail Service.
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
)
|
||||
|
||||
// Source identifies the trusted caller or workflow that created one delivery.
|
||||
type Source string
|
||||
|
||||
const (
|
||||
// SourceAuthSession reports deliveries accepted from Auth / Session Service.
|
||||
SourceAuthSession Source = "authsession"
|
||||
|
||||
// SourceNotification reports deliveries accepted from Notification Service.
|
||||
SourceNotification Source = "notification"
|
||||
|
||||
// SourceOperatorResend reports clone deliveries created by the operator
|
||||
// resend workflow.
|
||||
SourceOperatorResend Source = "operator_resend"
|
||||
)
|
||||
|
||||
// IsKnown reports whether Source belongs to the frozen v1 source vocabulary.
|
||||
func (source Source) IsKnown() bool {
|
||||
switch source {
|
||||
case SourceAuthSession, SourceNotification, SourceOperatorResend:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// PayloadMode identifies whether the delivery carries pre-rendered content or
|
||||
// template-selection metadata.
|
||||
type PayloadMode string
|
||||
|
||||
const (
|
||||
// PayloadModeRendered reports that the delivery already stores final
|
||||
// rendered content.
|
||||
PayloadModeRendered PayloadMode = "rendered"
|
||||
|
||||
// PayloadModeTemplate reports that final content is produced later from a
|
||||
// template and locale.
|
||||
PayloadModeTemplate PayloadMode = "template"
|
||||
)
|
||||
|
||||
// IsKnown reports whether PayloadMode is supported by the current domain
|
||||
// model.
|
||||
func (mode PayloadMode) IsKnown() bool {
|
||||
switch mode {
|
||||
case PayloadModeRendered, PayloadModeTemplate:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Status identifies the lifecycle state of one logical mail delivery.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
// StatusAccepted reports that intake validation succeeded and a durable
|
||||
// delivery record exists.
|
||||
StatusAccepted Status = "accepted"
|
||||
|
||||
// StatusQueued reports that the next attempt is durably scheduled.
|
||||
StatusQueued Status = "queued"
|
||||
|
||||
// StatusRendered reports that template-mode content has been materialized.
|
||||
StatusRendered Status = "rendered"
|
||||
|
||||
// StatusSending reports that one worker currently owns the active attempt.
|
||||
StatusSending Status = "sending"
|
||||
|
||||
// StatusSent reports that the provider accepted the SMTP envelope.
|
||||
StatusSent Status = "sent"
|
||||
|
||||
// StatusSuppressed reports that delivery was intentionally skipped as a
|
||||
// successful business outcome.
|
||||
StatusSuppressed Status = "suppressed"
|
||||
|
||||
// StatusFailed reports that delivery ended in a terminal failure without a
|
||||
// dead-letter entry.
|
||||
StatusFailed Status = "failed"
|
||||
|
||||
// StatusDeadLetter reports that delivery reached an operator-visible
|
||||
// dead-letter state.
|
||||
StatusDeadLetter Status = "dead_letter"
|
||||
)
|
||||
|
||||
// IsKnown reports whether Status belongs to the frozen v1 delivery lifecycle.
|
||||
func (status Status) IsKnown() bool {
|
||||
switch status {
|
||||
case StatusAccepted,
|
||||
StatusQueued,
|
||||
StatusRendered,
|
||||
StatusSending,
|
||||
StatusSent,
|
||||
StatusSuppressed,
|
||||
StatusFailed,
|
||||
StatusDeadLetter:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsTerminal reports whether Status can no longer accept lifecycle
|
||||
// transitions.
|
||||
func (status Status) IsTerminal() bool {
|
||||
switch status {
|
||||
case StatusSent, StatusSuppressed, StatusFailed, StatusDeadLetter:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CanTransitionTo reports whether the current Status may move to next under
|
||||
// the frozen Stage 2 delivery lifecycle rules.
|
||||
func (status Status) CanTransitionTo(next Status) bool {
|
||||
switch status {
|
||||
case StatusAccepted:
|
||||
switch next {
|
||||
case StatusQueued, StatusSuppressed:
|
||||
return true
|
||||
}
|
||||
case StatusQueued:
|
||||
switch next {
|
||||
case StatusRendered, StatusSending, StatusFailed:
|
||||
return true
|
||||
}
|
||||
case StatusRendered:
|
||||
switch next {
|
||||
case StatusSending, StatusFailed:
|
||||
return true
|
||||
}
|
||||
case StatusSending:
|
||||
switch next {
|
||||
case StatusSent, StatusSuppressed, StatusQueued, StatusFailed, StatusDeadLetter:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// AllowsResend reports whether deliveries in Status may be cloned through the
|
||||
// trusted resend workflow.
|
||||
func (status Status) AllowsResend() bool {
|
||||
switch status {
|
||||
case StatusSent, StatusSuppressed, StatusFailed, StatusDeadLetter:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Envelope stores the SMTP-addressing fields of one logical delivery.
|
||||
type Envelope struct {
|
||||
// To stores the primary recipients.
|
||||
To []common.Email
|
||||
|
||||
// Cc stores the carbon-copy recipients.
|
||||
Cc []common.Email
|
||||
|
||||
// Bcc stores the blind-carbon-copy recipients.
|
||||
Bcc []common.Email
|
||||
|
||||
// ReplyTo stores the reply-to addresses attached to the message headers.
|
||||
ReplyTo []common.Email
|
||||
}
|
||||
|
||||
// Validate reports whether Envelope contains only valid addresses and at
|
||||
// least one effective recipient.
|
||||
func (envelope Envelope) Validate() error {
|
||||
recipientCount := 0
|
||||
|
||||
validateGroup := func(name string, values []common.Email) error {
|
||||
for index, value := range values {
|
||||
if err := value.Validate(); err != nil {
|
||||
return fmt.Errorf("%s[%d]: %w", name, index, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := validateGroup("delivery envelope to", envelope.To); err != nil {
|
||||
return err
|
||||
}
|
||||
recipientCount += len(envelope.To)
|
||||
|
||||
if err := validateGroup("delivery envelope cc", envelope.Cc); err != nil {
|
||||
return err
|
||||
}
|
||||
recipientCount += len(envelope.Cc)
|
||||
|
||||
if err := validateGroup("delivery envelope bcc", envelope.Bcc); err != nil {
|
||||
return err
|
||||
}
|
||||
recipientCount += len(envelope.Bcc)
|
||||
|
||||
if err := validateGroup("delivery envelope reply to", envelope.ReplyTo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if recipientCount == 0 {
|
||||
return fmt.Errorf("delivery envelope must contain at least one recipient")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Content stores the materialized subject and body parts of one delivery.
|
||||
type Content struct {
|
||||
// Subject stores the final subject line.
|
||||
Subject string
|
||||
|
||||
// TextBody stores the final plaintext body.
|
||||
TextBody string
|
||||
|
||||
// HTMLBody stores the optional final HTML body.
|
||||
HTMLBody string
|
||||
}
|
||||
|
||||
// ValidateMaterialized reports whether Content contains the minimum subject
|
||||
// and plaintext body required for a concrete outbound message.
|
||||
func (content Content) ValidateMaterialized() error {
|
||||
if content.Subject == "" {
|
||||
return fmt.Errorf("delivery content subject must not be empty")
|
||||
}
|
||||
if content.TextBody == "" {
|
||||
return fmt.Errorf("delivery content text body must not be empty")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delivery stores one durable logical mail delivery record.
|
||||
type Delivery struct {
|
||||
// DeliveryID identifies the delivery.
|
||||
DeliveryID common.DeliveryID
|
||||
|
||||
// ResendParentDeliveryID identifies the original delivery when the current
|
||||
// record was created by the resend workflow.
|
||||
ResendParentDeliveryID common.DeliveryID
|
||||
|
||||
// Source stores the frozen source vocabulary value.
|
||||
Source Source
|
||||
|
||||
// PayloadMode stores whether the delivery uses pre-rendered content or
|
||||
// deferred template rendering.
|
||||
PayloadMode PayloadMode
|
||||
|
||||
// TemplateID stores the template family used by template-mode deliveries.
|
||||
TemplateID common.TemplateID
|
||||
|
||||
// Envelope stores the SMTP addressing information.
|
||||
Envelope Envelope
|
||||
|
||||
// Content stores the final rendered subject and bodies when materialized.
|
||||
Content Content
|
||||
|
||||
// Attachments stores long-lived attachment metadata only.
|
||||
Attachments []common.AttachmentMetadata
|
||||
|
||||
// Locale stores the canonical locale used for template selection when
|
||||
// applicable.
|
||||
Locale common.Locale
|
||||
|
||||
// LocaleFallbackUsed reports whether rendering fell back from the requested
|
||||
// locale to `en`.
|
||||
LocaleFallbackUsed bool
|
||||
|
||||
// TemplateVariables stores the JSON object used for later template
|
||||
// rendering when PayloadMode is `template`.
|
||||
TemplateVariables map[string]any
|
||||
|
||||
// IdempotencyKey stores the caller-owned deduplication key.
|
||||
IdempotencyKey common.IdempotencyKey
|
||||
|
||||
// Status stores the current delivery lifecycle state.
|
||||
Status Status
|
||||
|
||||
// AttemptCount stores how many attempts have been created for the delivery.
|
||||
AttemptCount int
|
||||
|
||||
// LastAttemptStatus stores the latest recorded attempt outcome when one is
|
||||
// available.
|
||||
LastAttemptStatus attempt.Status
|
||||
|
||||
// ProviderSummary stores redacted provider outcome details when available.
|
||||
ProviderSummary string
|
||||
|
||||
// CreatedAt stores when the delivery was created.
|
||||
CreatedAt time.Time
|
||||
|
||||
// UpdatedAt stores when the delivery was last mutated.
|
||||
UpdatedAt time.Time
|
||||
|
||||
// SentAt stores when the delivery entered the sent terminal state.
|
||||
SentAt *time.Time
|
||||
|
||||
// SuppressedAt stores when the delivery entered the suppressed terminal
|
||||
// state.
|
||||
SuppressedAt *time.Time
|
||||
|
||||
// FailedAt stores when the delivery entered the failed terminal state.
|
||||
FailedAt *time.Time
|
||||
|
||||
// DeadLetteredAt stores when the delivery entered the dead-letter terminal
|
||||
// state.
|
||||
DeadLetteredAt *time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether Delivery satisfies the frozen Stage 2 structural
|
||||
// and lifecycle invariants.
|
||||
func (record Delivery) Validate() error {
|
||||
if err := record.DeliveryID.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery id: %w", err)
|
||||
}
|
||||
if !record.Source.IsKnown() {
|
||||
return fmt.Errorf("delivery source %q is unsupported", record.Source)
|
||||
}
|
||||
if !record.PayloadMode.IsKnown() {
|
||||
return fmt.Errorf("delivery payload mode %q is unsupported", record.PayloadMode)
|
||||
}
|
||||
if err := record.Envelope.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
for index, attachment := range record.Attachments {
|
||||
if err := attachment.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery attachments[%d]: %w", index, err)
|
||||
}
|
||||
}
|
||||
if err := record.IdempotencyKey.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery idempotency key: %w", err)
|
||||
}
|
||||
if !record.Status.IsKnown() {
|
||||
return fmt.Errorf("delivery status %q is unsupported", record.Status)
|
||||
}
|
||||
if record.AttemptCount < 0 {
|
||||
return fmt.Errorf("delivery attempt count must not be negative")
|
||||
}
|
||||
if record.LastAttemptStatus != "" && !record.LastAttemptStatus.IsKnown() {
|
||||
return fmt.Errorf("delivery last attempt status %q is unsupported", record.LastAttemptStatus)
|
||||
}
|
||||
if err := validateOptionalToken("delivery provider summary", record.ProviderSummary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ValidateTimestamp("delivery created at", record.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ValidateTimestamp("delivery updated at", record.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.UpdatedAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("delivery updated at must not be before created at")
|
||||
}
|
||||
|
||||
switch record.Source {
|
||||
case SourceOperatorResend:
|
||||
if err := record.ResendParentDeliveryID.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery resend parent delivery id: %w", err)
|
||||
}
|
||||
if record.ResendParentDeliveryID == record.DeliveryID {
|
||||
return fmt.Errorf("delivery resend parent delivery id must differ from delivery id")
|
||||
}
|
||||
default:
|
||||
if !record.ResendParentDeliveryID.IsZero() {
|
||||
return fmt.Errorf("delivery resend parent delivery id must be empty unless source is %q", SourceOperatorResend)
|
||||
}
|
||||
}
|
||||
|
||||
switch record.PayloadMode {
|
||||
case PayloadModeRendered:
|
||||
if !record.TemplateID.IsZero() {
|
||||
return fmt.Errorf("rendered delivery must not contain template id")
|
||||
}
|
||||
if !record.Locale.IsZero() {
|
||||
return fmt.Errorf("rendered delivery must not contain locale")
|
||||
}
|
||||
if record.LocaleFallbackUsed {
|
||||
return fmt.Errorf("rendered delivery must not mark locale fallback")
|
||||
}
|
||||
if len(record.TemplateVariables) != 0 {
|
||||
return fmt.Errorf("rendered delivery must not contain template variables")
|
||||
}
|
||||
if err := record.Content.ValidateMaterialized(); err != nil {
|
||||
return err
|
||||
}
|
||||
case PayloadModeTemplate:
|
||||
if err := record.TemplateID.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery template id: %w", err)
|
||||
}
|
||||
if err := record.Locale.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery locale: %w", err)
|
||||
}
|
||||
if err := validateJSONObject("delivery template variables", record.TemplateVariables); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.Status == StatusRendered || record.Status == StatusSending || record.Status == StatusSent {
|
||||
if err := record.Content.ValidateMaterialized(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if record.Status == StatusRendered && record.PayloadMode != PayloadModeTemplate {
|
||||
return fmt.Errorf("delivery status %q requires payload mode %q", StatusRendered, PayloadModeTemplate)
|
||||
}
|
||||
|
||||
if err := validateTerminalTimestamps(record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeadLetterEntry stores the operator-visible dead-letter record for one
|
||||
// delivery that exhausted normal automated handling.
|
||||
type DeadLetterEntry struct {
|
||||
// DeliveryID identifies the dead-lettered delivery.
|
||||
DeliveryID common.DeliveryID
|
||||
|
||||
// FinalAttemptNo stores the last attempt number associated with the
|
||||
// dead-letter transition.
|
||||
FinalAttemptNo int
|
||||
|
||||
// FailureClassification stores the final machine-readable failure class.
|
||||
FailureClassification string
|
||||
|
||||
// ProviderSummary stores redacted provider outcome details when available.
|
||||
ProviderSummary string
|
||||
|
||||
// CreatedAt stores when the dead-letter entry was created.
|
||||
CreatedAt time.Time
|
||||
|
||||
// RecoveryHint stores an optional operator-facing recovery note.
|
||||
RecoveryHint string
|
||||
}
|
||||
|
||||
// Validate reports whether DeadLetterEntry contains a complete dead-letter
|
||||
// record.
|
||||
func (entry DeadLetterEntry) Validate() error {
|
||||
if err := entry.DeliveryID.Validate(); err != nil {
|
||||
return fmt.Errorf("dead-letter delivery id: %w", err)
|
||||
}
|
||||
if entry.FinalAttemptNo < 1 {
|
||||
return fmt.Errorf("dead-letter final attempt number must be at least 1")
|
||||
}
|
||||
if err := validateToken("dead-letter failure classification", entry.FailureClassification); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalToken("dead-letter provider summary", entry.ProviderSummary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalToken("dead-letter recovery hint", entry.RecoveryHint); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ValidateTimestamp("dead-letter created at", entry.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateFor reports whether entry is the required dead-letter record for
|
||||
// record.
|
||||
func (entry DeadLetterEntry) ValidateFor(record Delivery) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := entry.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.Status != StatusDeadLetter {
|
||||
return fmt.Errorf("dead-letter entry requires delivery status %q", StatusDeadLetter)
|
||||
}
|
||||
if entry.DeliveryID != record.DeliveryID {
|
||||
return fmt.Errorf("dead-letter delivery id must match delivery id")
|
||||
}
|
||||
if record.AttemptCount < entry.FinalAttemptNo {
|
||||
return fmt.Errorf("dead-letter final attempt number must not exceed delivery attempt count")
|
||||
}
|
||||
if record.DeadLetteredAt == nil {
|
||||
return fmt.Errorf("dead-letter delivery must contain dead-lettered at")
|
||||
}
|
||||
if entry.CreatedAt.Before(*record.DeadLetteredAt) {
|
||||
return fmt.Errorf("dead-letter created at must not be before delivery dead-lettered at")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateDeadLetterState reports whether record and entry satisfy the frozen
|
||||
// rule that only dead-lettered deliveries may own a dead-letter entry.
|
||||
func ValidateDeadLetterState(record Delivery, entry *DeadLetterEntry) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if record.Status == StatusDeadLetter {
|
||||
if entry == nil {
|
||||
return fmt.Errorf("dead-letter delivery requires dead-letter entry")
|
||||
}
|
||||
return entry.ValidateFor(record)
|
||||
}
|
||||
|
||||
if entry != nil {
|
||||
return fmt.Errorf("dead-letter entry is not allowed for delivery status %q", record.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTerminalTimestamps(record Delivery) error {
|
||||
if record.SentAt != nil {
|
||||
if err := common.ValidateTimestamp("delivery sent at", *record.SentAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.SentAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("delivery sent at must not be before created at")
|
||||
}
|
||||
}
|
||||
if record.SuppressedAt != nil {
|
||||
if err := common.ValidateTimestamp("delivery suppressed at", *record.SuppressedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.SuppressedAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("delivery suppressed at must not be before created at")
|
||||
}
|
||||
}
|
||||
if record.FailedAt != nil {
|
||||
if err := common.ValidateTimestamp("delivery failed at", *record.FailedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.FailedAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("delivery failed at must not be before created at")
|
||||
}
|
||||
}
|
||||
if record.DeadLetteredAt != nil {
|
||||
if err := common.ValidateTimestamp("delivery dead-lettered at", *record.DeadLetteredAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.DeadLetteredAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("delivery dead-lettered at must not be before created at")
|
||||
}
|
||||
}
|
||||
|
||||
switch record.Status {
|
||||
case StatusAccepted, StatusQueued, StatusRendered, StatusSending:
|
||||
if record.SentAt != nil || record.SuppressedAt != nil || record.FailedAt != nil || record.DeadLetteredAt != nil {
|
||||
return fmt.Errorf("non-terminal delivery must not contain terminal timestamp fields")
|
||||
}
|
||||
case StatusSent:
|
||||
if record.SentAt == nil {
|
||||
return fmt.Errorf("sent delivery must contain sent at")
|
||||
}
|
||||
if record.SuppressedAt != nil || record.FailedAt != nil || record.DeadLetteredAt != nil {
|
||||
return fmt.Errorf("sent delivery must not contain other terminal timestamp fields")
|
||||
}
|
||||
case StatusSuppressed:
|
||||
if record.SuppressedAt == nil {
|
||||
return fmt.Errorf("suppressed delivery must contain suppressed at")
|
||||
}
|
||||
if record.SentAt != nil || record.FailedAt != nil || record.DeadLetteredAt != nil {
|
||||
return fmt.Errorf("suppressed delivery must not contain other terminal timestamp fields")
|
||||
}
|
||||
case StatusFailed:
|
||||
if record.FailedAt == nil {
|
||||
return fmt.Errorf("failed delivery must contain failed at")
|
||||
}
|
||||
if record.SentAt != nil || record.SuppressedAt != nil || record.DeadLetteredAt != nil {
|
||||
return fmt.Errorf("failed delivery must not contain other terminal timestamp fields")
|
||||
}
|
||||
case StatusDeadLetter:
|
||||
if record.DeadLetteredAt == nil {
|
||||
return fmt.Errorf("dead-letter delivery must contain dead-lettered at")
|
||||
}
|
||||
if record.SentAt != nil || record.SuppressedAt != nil || record.FailedAt != nil {
|
||||
return fmt.Errorf("dead-letter delivery must not contain other terminal timestamp fields")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateToken(name string, value string) error {
|
||||
switch {
|
||||
case strings.TrimSpace(value) == "":
|
||||
return fmt.Errorf("%s must not be empty", name)
|
||||
case strings.TrimSpace(value) != value:
|
||||
return fmt.Errorf("%s must not contain surrounding whitespace", name)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateOptionalToken(name string, value string) error {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return validateToken(name, value)
|
||||
}
|
||||
|
||||
func validateJSONObject(name string, value map[string]any) error {
|
||||
if value == nil {
|
||||
return fmt.Errorf("%s must not be nil", name)
|
||||
}
|
||||
|
||||
if _, err := json.Marshal(value); err != nil {
|
||||
return fmt.Errorf("%s must be JSON-serializable: %w", name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStatusCanTransitionTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
from Status
|
||||
to Status
|
||||
want bool
|
||||
}{
|
||||
{name: "accepted to queued", from: StatusAccepted, to: StatusQueued, want: true},
|
||||
{name: "accepted to suppressed", from: StatusAccepted, to: StatusSuppressed, want: true},
|
||||
{name: "accepted to sent", from: StatusAccepted, to: StatusSent, want: false},
|
||||
{name: "queued to rendered", from: StatusQueued, to: StatusRendered, want: true},
|
||||
{name: "queued to sending", from: StatusQueued, to: StatusSending, want: true},
|
||||
{name: "queued to failed", from: StatusQueued, to: StatusFailed, want: true},
|
||||
{name: "rendered to sending", from: StatusRendered, to: StatusSending, want: true},
|
||||
{name: "rendered to failed", from: StatusRendered, to: StatusFailed, want: true},
|
||||
{name: "sending to sent", from: StatusSending, to: StatusSent, want: true},
|
||||
{name: "sending to dead letter", from: StatusSending, to: StatusDeadLetter, want: true},
|
||||
{name: "failed terminal", from: StatusFailed, to: StatusDeadLetter, want: false},
|
||||
{name: "dead letter terminal", from: StatusDeadLetter, to: StatusQueued, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, tt.want, tt.from.CanTransitionTo(tt.to))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusTerminalAndResend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.False(t, StatusAccepted.IsTerminal())
|
||||
require.False(t, StatusQueued.AllowsResend())
|
||||
require.True(t, StatusSent.IsTerminal())
|
||||
require.True(t, StatusSent.AllowsResend())
|
||||
require.True(t, StatusSuppressed.AllowsResend())
|
||||
require.True(t, StatusFailed.AllowsResend())
|
||||
require.True(t, StatusDeadLetter.AllowsResend())
|
||||
}
|
||||
|
||||
func TestDeliveryValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := validRenderedDelivery(t)
|
||||
templateQueued := validTemplateQueuedDelivery(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record Delivery
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid rendered delivery", record: base},
|
||||
{name: "valid template queued delivery", record: templateQueued},
|
||||
{
|
||||
name: "operator resend requires parent id",
|
||||
record: func() Delivery {
|
||||
record := base
|
||||
record.Source = SourceOperatorResend
|
||||
record.ResendParentDeliveryID = ""
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "non resend must not carry parent id",
|
||||
record: func() Delivery {
|
||||
record := base
|
||||
record.ResendParentDeliveryID = common.DeliveryID("delivery-parent")
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "rendered status requires template mode",
|
||||
record: func() Delivery {
|
||||
record := base
|
||||
record.Status = StatusRendered
|
||||
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
|
||||
record.SentAt = nil
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "rendered payload requires materialized content",
|
||||
record: func() Delivery {
|
||||
record := base
|
||||
record.Content = Content{}
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "template mode requires template id",
|
||||
record: func() Delivery {
|
||||
record := templateQueued
|
||||
record.TemplateID = ""
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "template mode requires locale",
|
||||
record: func() Delivery {
|
||||
record := templateQueued
|
||||
record.Locale = ""
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "template mode requires template variables",
|
||||
record: func() Delivery {
|
||||
record := templateQueued
|
||||
record.TemplateVariables = nil
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "template rendered requires content",
|
||||
record: func() Delivery {
|
||||
record := templateQueued
|
||||
record.Status = StatusRendered
|
||||
record.UpdatedAt = record.CreatedAt.Add(2 * time.Minute)
|
||||
record.Content = Content{}
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal must not carry terminal timestamps",
|
||||
record: func() Delivery {
|
||||
record := templateQueued
|
||||
record.FailedAt = ptrTime(record.CreatedAt.Add(time.Minute))
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "rendered delivery must not contain template variables",
|
||||
record: func() Delivery {
|
||||
record := base
|
||||
record.TemplateVariables = map[string]any{"code": "123456"}
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "template variables must be json serializable",
|
||||
record: func() Delivery {
|
||||
record := templateQueued
|
||||
record.TemplateVariables = map[string]any{"invalid": func() {}}
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "failed requires failed at",
|
||||
record: func() Delivery {
|
||||
record := templateQueued
|
||||
record.Status = StatusFailed
|
||||
record.UpdatedAt = record.CreatedAt.Add(2 * time.Minute)
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.record.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeadLetterState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := validDeadLetterDelivery(t)
|
||||
entry := validDeadLetterEntry(t, record)
|
||||
|
||||
require.NoError(t, ValidateDeadLetterState(record, &entry))
|
||||
|
||||
err := ValidateDeadLetterState(record, nil)
|
||||
require.Error(t, err)
|
||||
|
||||
failed := validTemplateQueuedDelivery(t)
|
||||
failed.Status = StatusFailed
|
||||
failed.UpdatedAt = failed.CreatedAt.Add(2 * time.Minute)
|
||||
failed.FailedAt = ptrTime(failed.CreatedAt.Add(2 * time.Minute))
|
||||
require.NoError(t, ValidateDeadLetterState(failed, nil))
|
||||
require.Error(t, ValidateDeadLetterState(failed, &entry))
|
||||
|
||||
mismatched := entry
|
||||
mismatched.DeliveryID = common.DeliveryID("delivery-other")
|
||||
require.Error(t, ValidateDeadLetterState(record, &mismatched))
|
||||
}
|
||||
|
||||
func validRenderedDelivery(t *testing.T) Delivery {
|
||||
t.Helper()
|
||||
|
||||
createdAt := time.Unix(1_775_121_700, 0).UTC()
|
||||
sentAt := createdAt.Add(5 * time.Minute)
|
||||
|
||||
record := Delivery{
|
||||
DeliveryID: common.DeliveryID("delivery-123"),
|
||||
Source: SourceNotification,
|
||||
PayloadMode: PayloadModeRendered,
|
||||
Envelope: validEnvelope(),
|
||||
Content: Content{Subject: "Turn ready", TextBody: "Turn 54 is ready."},
|
||||
Attachments: []common.AttachmentMetadata{{Filename: "report.txt", ContentType: "text/plain", SizeBytes: 64}},
|
||||
TemplateVariables: nil,
|
||||
IdempotencyKey: common.IdempotencyKey("notification:delivery-123"),
|
||||
Status: StatusSent,
|
||||
AttemptCount: 1,
|
||||
LastAttemptStatus: attempt.StatusProviderAccepted,
|
||||
ProviderSummary: "queued by provider",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: sentAt,
|
||||
SentAt: &sentAt,
|
||||
}
|
||||
|
||||
require.NoError(t, record.Validate())
|
||||
return record
|
||||
}
|
||||
|
||||
func validTemplateQueuedDelivery(t *testing.T) Delivery {
|
||||
t.Helper()
|
||||
|
||||
createdAt := time.Unix(1_775_121_700, 0).UTC()
|
||||
locale, err := common.ParseLocale("fr-fr")
|
||||
require.NoError(t, err)
|
||||
|
||||
record := Delivery{
|
||||
DeliveryID: common.DeliveryID("delivery-124"),
|
||||
Source: SourceNotification,
|
||||
PayloadMode: PayloadModeTemplate,
|
||||
TemplateID: common.TemplateID("game.turn_ready"),
|
||||
Envelope: validEnvelope(),
|
||||
Locale: locale,
|
||||
TemplateVariables: map[string]any{
|
||||
"turn_number": float64(54),
|
||||
},
|
||||
IdempotencyKey: common.IdempotencyKey("notification:delivery-124"),
|
||||
Status: StatusQueued,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt.Add(time.Minute),
|
||||
}
|
||||
|
||||
require.NoError(t, record.Validate())
|
||||
return record
|
||||
}
|
||||
|
||||
func validDeadLetterDelivery(t *testing.T) Delivery {
|
||||
t.Helper()
|
||||
|
||||
record := validTemplateQueuedDelivery(t)
|
||||
record.Status = StatusDeadLetter
|
||||
record.AttemptCount = 3
|
||||
record.LastAttemptStatus = attempt.StatusTimedOut
|
||||
record.UpdatedAt = record.CreatedAt.Add(10 * time.Minute)
|
||||
record.DeadLetteredAt = ptrTime(record.CreatedAt.Add(10 * time.Minute))
|
||||
|
||||
require.NoError(t, record.Validate())
|
||||
return record
|
||||
}
|
||||
|
||||
func validDeadLetterEntry(t *testing.T, record Delivery) DeadLetterEntry {
|
||||
t.Helper()
|
||||
|
||||
entry := DeadLetterEntry{
|
||||
DeliveryID: record.DeliveryID,
|
||||
FinalAttemptNo: 3,
|
||||
FailureClassification: "retry_exhausted",
|
||||
ProviderSummary: "smtp timeout",
|
||||
CreatedAt: record.DeadLetteredAt.Add(time.Second),
|
||||
RecoveryHint: "check SMTP connectivity",
|
||||
}
|
||||
|
||||
require.NoError(t, entry.ValidateFor(record))
|
||||
return entry
|
||||
}
|
||||
|
||||
func validEnvelope() Envelope {
|
||||
return Envelope{
|
||||
To: []common.Email{"pilot@example.com"},
|
||||
}
|
||||
}
|
||||
|
||||
func ptrTime(value time.Time) *time.Time {
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Package idempotency defines the deduplication record used by Mail Service
|
||||
// acceptance flows.
|
||||
package idempotency
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
"galaxy/mail/internal/domain/delivery"
|
||||
)
|
||||
|
||||
// Record stores the first accepted fingerprint bound to one `(source,
|
||||
// idempotency_key)` scope.
|
||||
type Record struct {
|
||||
// Source stores the frozen delivery source vocabulary value.
|
||||
Source delivery.Source
|
||||
|
||||
// IdempotencyKey stores the caller-owned deduplication key.
|
||||
IdempotencyKey common.IdempotencyKey
|
||||
|
||||
// DeliveryID stores the accepted delivery linked to the scope.
|
||||
DeliveryID common.DeliveryID
|
||||
|
||||
// RequestFingerprint stores the stable fingerprint of the first accepted
|
||||
// request.
|
||||
RequestFingerprint string
|
||||
|
||||
// CreatedAt stores when the deduplication record was created.
|
||||
CreatedAt time.Time
|
||||
|
||||
// ExpiresAt stores when the deduplication record becomes invalid.
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether Record satisfies the frozen Stage 2 structural
|
||||
// invariants.
|
||||
func (record Record) Validate() error {
|
||||
if !record.Source.IsKnown() {
|
||||
return fmt.Errorf("idempotency source %q is unsupported", record.Source)
|
||||
}
|
||||
if err := record.IdempotencyKey.Validate(); err != nil {
|
||||
return fmt.Errorf("idempotency key: %w", err)
|
||||
}
|
||||
if err := record.DeliveryID.Validate(); err != nil {
|
||||
return fmt.Errorf("idempotency delivery id: %w", err)
|
||||
}
|
||||
if err := validateToken("idempotency request fingerprint", record.RequestFingerprint); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ValidateTimestamp("idempotency created at", record.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ValidateTimestamp("idempotency expires at", record.ExpiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if !record.ExpiresAt.After(record.CreatedAt) {
|
||||
return fmt.Errorf("idempotency expires at must be after created at")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateToken(name string, value string) error {
|
||||
switch {
|
||||
case strings.TrimSpace(value) == "":
|
||||
return fmt.Errorf("%s must not be empty", name)
|
||||
case strings.TrimSpace(value) != value:
|
||||
return fmt.Errorf("%s must not contain surrounding whitespace", name)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package idempotency
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
"galaxy/mail/internal/domain/delivery"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRecordValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
createdAt := time.Unix(1_775_121_700, 0).UTC()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record Record
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
record: Record{
|
||||
Source: delivery.SourceNotification,
|
||||
IdempotencyKey: common.IdempotencyKey("notification:delivery-123"),
|
||||
DeliveryID: common.DeliveryID("delivery-123"),
|
||||
RequestFingerprint: "sha256:abcdef",
|
||||
CreatedAt: createdAt,
|
||||
ExpiresAt: createdAt.Add(7 * 24 * time.Hour),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "expires at must be after created at",
|
||||
record: Record{
|
||||
Source: delivery.SourceNotification,
|
||||
IdempotencyKey: common.IdempotencyKey("notification:delivery-123"),
|
||||
DeliveryID: common.DeliveryID("delivery-123"),
|
||||
RequestFingerprint: "sha256:abcdef",
|
||||
CreatedAt: createdAt,
|
||||
ExpiresAt: createdAt,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fingerprint required",
|
||||
record: Record{
|
||||
Source: delivery.SourceNotification,
|
||||
IdempotencyKey: common.IdempotencyKey("notification:delivery-123"),
|
||||
DeliveryID: common.DeliveryID("delivery-123"),
|
||||
CreatedAt: createdAt,
|
||||
ExpiresAt: createdAt.Add(time.Hour),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.record.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Package malformedcommand defines the operator-visible record used for
|
||||
// malformed asynchronous generic delivery commands.
|
||||
package malformedcommand
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
)
|
||||
|
||||
// FailureCode identifies the stable malformed-command rejection reason.
|
||||
type FailureCode string
|
||||
|
||||
const (
|
||||
// FailureCodeInvalidEnvelope reports that the command could not be accepted
|
||||
// because the recipient envelope was invalid.
|
||||
FailureCodeInvalidEnvelope FailureCode = "invalid_envelope"
|
||||
|
||||
// FailureCodeInvalidPayload reports that the command payload could not be
|
||||
// decoded or validated.
|
||||
FailureCodeInvalidPayload FailureCode = "invalid_payload"
|
||||
|
||||
// FailureCodeInvalidCommand reports that the top-level stream envelope was
|
||||
// malformed or unsupported.
|
||||
FailureCodeInvalidCommand FailureCode = "invalid_command"
|
||||
|
||||
// FailureCodeIdempotencyConflict reports that the stream command reused an
|
||||
// existing idempotency scope with a different request fingerprint.
|
||||
FailureCodeIdempotencyConflict FailureCode = "idempotency_conflict"
|
||||
)
|
||||
|
||||
// IsKnown reports whether code belongs to the frozen malformed-command
|
||||
// rejection surface.
|
||||
func (code FailureCode) IsKnown() bool {
|
||||
switch code {
|
||||
case FailureCodeInvalidEnvelope,
|
||||
FailureCodeInvalidPayload,
|
||||
FailureCodeInvalidCommand,
|
||||
FailureCodeIdempotencyConflict:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Entry stores one operator-visible malformed asynchronous command record.
|
||||
type Entry struct {
|
||||
// StreamEntryID stores the Redis Stream entry identifier of the malformed
|
||||
// command.
|
||||
StreamEntryID string
|
||||
|
||||
// DeliveryID stores the optional raw delivery identifier extracted from the
|
||||
// stream entry when available.
|
||||
DeliveryID string
|
||||
|
||||
// Source stores the optional raw source value extracted from the stream
|
||||
// entry when available.
|
||||
Source string
|
||||
|
||||
// IdempotencyKey stores the optional raw idempotency key extracted from the
|
||||
// stream entry when available.
|
||||
IdempotencyKey string
|
||||
|
||||
// FailureCode stores the stable malformed-command rejection reason.
|
||||
FailureCode FailureCode
|
||||
|
||||
// FailureMessage stores the detailed validation or decoding failure.
|
||||
FailureMessage string
|
||||
|
||||
// RawFields stores the raw top-level stream fields captured for later
|
||||
// operator inspection.
|
||||
RawFields map[string]any
|
||||
|
||||
// RecordedAt stores when the malformed command was durably recorded.
|
||||
RecordedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether entry contains a complete malformed-command record.
|
||||
func (entry Entry) Validate() error {
|
||||
if strings.TrimSpace(entry.StreamEntryID) == "" {
|
||||
return fmt.Errorf("malformed command stream entry id must not be empty")
|
||||
}
|
||||
if !entry.FailureCode.IsKnown() {
|
||||
return fmt.Errorf("malformed command failure code %q is unsupported", entry.FailureCode)
|
||||
}
|
||||
if strings.TrimSpace(entry.FailureMessage) == "" {
|
||||
return fmt.Errorf("malformed command failure message must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(entry.FailureMessage) != entry.FailureMessage {
|
||||
return fmt.Errorf("malformed command failure message must not contain surrounding whitespace")
|
||||
}
|
||||
if entry.RawFields == nil {
|
||||
return fmt.Errorf("malformed command raw fields must not be nil")
|
||||
}
|
||||
if err := validateJSONObject("malformed command raw fields", entry.RawFields); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ValidateTimestamp("malformed command recorded at", entry.RecordedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONObject(name string, value map[string]any) error {
|
||||
if value == nil {
|
||||
return fmt.Errorf("%s must not be nil", name)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
if string(payload) == "null" {
|
||||
return fmt.Errorf("%s must encode as a JSON object", name)
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
if decoded == nil {
|
||||
return fmt.Errorf("%s must encode as a JSON object", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package malformedcommand
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEntryValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entry := Entry{
|
||||
StreamEntryID: "1775121700000-0",
|
||||
DeliveryID: "mail-123",
|
||||
Source: "notification",
|
||||
IdempotencyKey: "notification:mail-123",
|
||||
FailureCode: FailureCodeInvalidPayload,
|
||||
FailureMessage: "payload_json.subject is required",
|
||||
RawFields: map[string]any{
|
||||
"delivery_id": "mail-123",
|
||||
"source": "notification",
|
||||
"payload_mode": "rendered",
|
||||
"idempotency_key": "notification:mail-123",
|
||||
},
|
||||
RecordedAt: time.Unix(1_775_121_700, 0).UTC(),
|
||||
}
|
||||
|
||||
require.NoError(t, entry.Validate())
|
||||
}
|
||||
|
||||
func TestEntryValidateRejectsInvalidValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entry := Entry{
|
||||
StreamEntryID: "1775121700000-0",
|
||||
FailureCode: FailureCode("unsupported"),
|
||||
FailureMessage: "failure",
|
||||
RawFields: map[string]any{},
|
||||
RecordedAt: time.Unix(1_775_121_700, 0).UTC(),
|
||||
}
|
||||
|
||||
err := entry.Validate()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "failure code")
|
||||
}
|
||||
|
||||
func TestEntryValidateRejectsNilRawFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entry := Entry{
|
||||
StreamEntryID: "1775121700000-0",
|
||||
FailureCode: FailureCodeInvalidCommand,
|
||||
FailureMessage: "missing required fields",
|
||||
RecordedAt: time.Unix(1_775_121_700, 0).UTC(),
|
||||
}
|
||||
|
||||
err := entry.Validate()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "raw fields")
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Package template defines the logical template entity used by the
|
||||
// filesystem-backed Mail Service template catalog.
|
||||
package template
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
)
|
||||
|
||||
// Template stores one locale-specific template bundle.
|
||||
type Template struct {
|
||||
// TemplateID identifies the template family.
|
||||
TemplateID common.TemplateID
|
||||
|
||||
// Locale stores the canonical locale of the template variant.
|
||||
Locale common.Locale
|
||||
|
||||
// SubjectTemplate stores the subject template source.
|
||||
SubjectTemplate string
|
||||
|
||||
// TextTemplate stores the plaintext body template source.
|
||||
TextTemplate string
|
||||
|
||||
// HTMLTemplate stores the optional HTML body template source.
|
||||
HTMLTemplate string
|
||||
|
||||
// Version stores the template version marker projected into the domain
|
||||
// model.
|
||||
Version string
|
||||
}
|
||||
|
||||
// Validate reports whether Template satisfies the frozen Stage 2 structural
|
||||
// invariants.
|
||||
func (record Template) Validate() error {
|
||||
if err := record.TemplateID.Validate(); err != nil {
|
||||
return fmt.Errorf("template id: %w", err)
|
||||
}
|
||||
if err := record.Locale.Validate(); err != nil {
|
||||
return fmt.Errorf("template locale: %w", err)
|
||||
}
|
||||
if record.SubjectTemplate == "" {
|
||||
return fmt.Errorf("template subject template must not be empty")
|
||||
}
|
||||
if record.TextTemplate == "" {
|
||||
return fmt.Errorf("template text template must not be empty")
|
||||
}
|
||||
if err := validateToken("template version", record.Version); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateToken(name string, value string) error {
|
||||
switch {
|
||||
case strings.TrimSpace(value) == "":
|
||||
return fmt.Errorf("%s must not be empty", name)
|
||||
case strings.TrimSpace(value) != value:
|
||||
return fmt.Errorf("%s must not contain surrounding whitespace", name)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTemplateValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
locale, err := common.ParseLocale("en-us")
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record Template
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
record: Template{
|
||||
TemplateID: common.TemplateID("auth.login_code"),
|
||||
Locale: locale,
|
||||
SubjectTemplate: "Your code",
|
||||
TextTemplate: "Code: {{.Code}}",
|
||||
HTMLTemplate: "<p>Code: {{.Code}}</p>",
|
||||
Version: "sha256:abcd",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non canonical locale rejected",
|
||||
record: Template{
|
||||
TemplateID: common.TemplateID("auth.login_code"),
|
||||
Locale: common.Locale("en-us"),
|
||||
SubjectTemplate: "Your code",
|
||||
TextTemplate: "Code: {{.Code}}",
|
||||
Version: "sha256:abcd",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing subject template",
|
||||
record: Template{
|
||||
TemplateID: common.TemplateID("auth.login_code"),
|
||||
Locale: locale,
|
||||
TextTemplate: "Code: {{.Code}}",
|
||||
Version: "sha256:abcd",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.record.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Package logging configures the Mail Service process logger and provides
|
||||
// context-aware helpers for trace, delivery, attempt, and command fields.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"galaxy/mail/internal/api/streamcommand"
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// New constructs the process-wide JSON logger from level.
|
||||
func New(level string) (*slog.Logger, error) {
|
||||
var slogLevel slog.Level
|
||||
if err := slogLevel.UnmarshalText([]byte(strings.TrimSpace(level))); err != nil {
|
||||
return nil, fmt.Errorf("build logger: %w", err)
|
||||
}
|
||||
|
||||
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slogLevel,
|
||||
})), nil
|
||||
}
|
||||
|
||||
// TraceAttrsFromContext returns slog key-value pairs for the active
|
||||
// OpenTelemetry span when ctx carries a valid span context.
|
||||
func TraceAttrsFromContext(ctx context.Context) []any {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
spanContext := trace.SpanContextFromContext(ctx)
|
||||
if !spanContext.IsValid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []any{
|
||||
"otel_trace_id", spanContext.TraceID().String(),
|
||||
"otel_span_id", spanContext.SpanID().String(),
|
||||
}
|
||||
}
|
||||
|
||||
// DeliveryAttrs returns structured delivery-identifying log fields.
|
||||
func DeliveryAttrs(record deliverydomain.Delivery) []any {
|
||||
attrs := []any{
|
||||
"delivery_id", record.DeliveryID.String(),
|
||||
"source", string(record.Source),
|
||||
}
|
||||
if !record.TemplateID.IsZero() {
|
||||
attrs = append(attrs, "template_id", record.TemplateID.String())
|
||||
}
|
||||
|
||||
return attrs
|
||||
}
|
||||
|
||||
// AttemptAttrs returns structured attempt-identifying log fields.
|
||||
func AttemptAttrs(record attempt.Attempt) []any {
|
||||
return []any{
|
||||
"delivery_id", record.DeliveryID.String(),
|
||||
"attempt_no", record.AttemptNo,
|
||||
}
|
||||
}
|
||||
|
||||
// DeliveryAttemptAttrs returns structured delivery and attempt fields.
|
||||
func DeliveryAttemptAttrs(deliveryRecord deliverydomain.Delivery, attemptRecord attempt.Attempt) []any {
|
||||
attrs := DeliveryAttrs(deliveryRecord)
|
||||
attrs = append(attrs, "attempt_no", attemptRecord.AttemptNo)
|
||||
return attrs
|
||||
}
|
||||
|
||||
// CommandAttrs returns structured generic-command log fields.
|
||||
func CommandAttrs(command streamcommand.Command) []any {
|
||||
attrs := []any{
|
||||
"delivery_id", command.DeliveryID.String(),
|
||||
"source", string(command.Source),
|
||||
}
|
||||
if !command.TemplateID.IsZero() {
|
||||
attrs = append(attrs, "template_id", command.TemplateID.String())
|
||||
}
|
||||
if strings.TrimSpace(command.TraceID) != "" {
|
||||
attrs = append(attrs, "trace_id", command.TraceID)
|
||||
}
|
||||
|
||||
return attrs
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
// Package ports defines the stable interfaces that connect Mail Service use
|
||||
// cases to external delivery infrastructure.
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
)
|
||||
|
||||
// Provider executes one materialized outbound message against a concrete
|
||||
// delivery backend such as SMTP or a deterministic local stub.
|
||||
type Provider interface {
|
||||
// Send attempts one outbound message delivery and returns a classified
|
||||
// provider result when the operation reached a stable backend outcome.
|
||||
Send(context.Context, Message) (Result, error)
|
||||
|
||||
// Close releases provider-owned resources. Implementations must allow
|
||||
// repeated calls.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Classification identifies the stable provider-level outcome surface frozen
|
||||
// for Stage 10.
|
||||
type Classification string
|
||||
|
||||
const (
|
||||
// ClassificationAccepted reports that the provider accepted the SMTP
|
||||
// envelope after the final DATA exchange.
|
||||
ClassificationAccepted Classification = "accepted"
|
||||
|
||||
// ClassificationSuppressed reports that delivery was intentionally skipped
|
||||
// by provider-local policy.
|
||||
ClassificationSuppressed Classification = "suppressed"
|
||||
|
||||
// ClassificationTransientFailure reports that the provider interaction
|
||||
// failed in a retryable way.
|
||||
ClassificationTransientFailure Classification = "transient_failure"
|
||||
|
||||
// ClassificationPermanentFailure reports that the provider interaction
|
||||
// failed in a terminal non-retryable way.
|
||||
ClassificationPermanentFailure Classification = "permanent_failure"
|
||||
)
|
||||
|
||||
// IsKnown reports whether classification belongs to the frozen provider
|
||||
// result surface.
|
||||
func (classification Classification) IsKnown() bool {
|
||||
switch classification {
|
||||
case ClassificationAccepted,
|
||||
ClassificationSuppressed,
|
||||
ClassificationTransientFailure,
|
||||
ClassificationPermanentFailure:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Attachment stores one fully decoded outbound attachment together with the
|
||||
// durable metadata that remains in the delivery audit.
|
||||
type Attachment struct {
|
||||
// Metadata stores the attachment audit fields used by the delivery domain.
|
||||
Metadata common.AttachmentMetadata
|
||||
|
||||
// Content stores the decoded attachment payload bytes used for MIME body
|
||||
// construction.
|
||||
Content []byte
|
||||
}
|
||||
|
||||
// Validate reports whether attachment contains a consistent decoded outbound
|
||||
// payload.
|
||||
func (attachment Attachment) Validate() error {
|
||||
if err := attachment.Metadata.Validate(); err != nil {
|
||||
return fmt.Errorf("attachment metadata: %w", err)
|
||||
}
|
||||
if int64(len(attachment.Content)) != attachment.Metadata.SizeBytes {
|
||||
return fmt.Errorf(
|
||||
"attachment content length must match size bytes: got %d, want %d",
|
||||
len(attachment.Content),
|
||||
attachment.Metadata.SizeBytes,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Message stores one fully materialized outbound message ready for provider
|
||||
// handoff.
|
||||
type Message struct {
|
||||
// Envelope stores the SMTP routing information.
|
||||
Envelope deliverydomain.Envelope
|
||||
|
||||
// Content stores the materialized subject and body parts.
|
||||
Content deliverydomain.Content
|
||||
|
||||
// Attachments stores the decoded outbound attachments.
|
||||
Attachments []Attachment
|
||||
}
|
||||
|
||||
// Validate reports whether message is ready for provider execution.
|
||||
func (message Message) Validate() error {
|
||||
if err := message.Envelope.Validate(); err != nil {
|
||||
return fmt.Errorf("message envelope: %w", err)
|
||||
}
|
||||
if err := message.Content.ValidateMaterialized(); err != nil {
|
||||
return fmt.Errorf("message content: %w", err)
|
||||
}
|
||||
for index, attachment := range message.Attachments {
|
||||
if err := attachment.Validate(); err != nil {
|
||||
return fmt.Errorf("message attachments[%d]: %w", index, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SummaryFields stores the tokenized safe-summary fields allowed in provider
|
||||
// audit strings.
|
||||
type SummaryFields struct {
|
||||
// Provider stores the provider implementation identifier.
|
||||
Provider string
|
||||
|
||||
// Result stores the stable provider classification.
|
||||
Result string
|
||||
|
||||
// Phase stores the optional backend stage that produced the outcome.
|
||||
Phase string
|
||||
|
||||
// SMTPCode stores the optional SMTP response code.
|
||||
SMTPCode string
|
||||
|
||||
// Script stores the optional stub-script outcome label.
|
||||
Script string
|
||||
}
|
||||
|
||||
// BuildSafeSummary renders one stable ASCII summary string for provider audit
|
||||
// fields.
|
||||
func BuildSafeSummary(fields SummaryFields) (string, error) {
|
||||
switch {
|
||||
case !isSafeSummaryValue(fields.Provider):
|
||||
return "", fmt.Errorf("provider summary field provider must be a non-empty ASCII token")
|
||||
case !isSafeSummaryValue(fields.Result):
|
||||
return "", fmt.Errorf("provider summary field result must be a non-empty ASCII token")
|
||||
case fields.Phase != "" && !isSafeSummaryValue(fields.Phase):
|
||||
return "", fmt.Errorf("provider summary field phase must be an ASCII token")
|
||||
case fields.SMTPCode != "" && !isSafeSummaryValue(fields.SMTPCode):
|
||||
return "", fmt.Errorf("provider summary field smtp_code must be an ASCII token")
|
||||
case fields.Script != "" && !isSafeSummaryValue(fields.Script):
|
||||
return "", fmt.Errorf("provider summary field script must be an ASCII token")
|
||||
}
|
||||
|
||||
parts := []string{
|
||||
"provider=" + fields.Provider,
|
||||
"result=" + fields.Result,
|
||||
}
|
||||
if fields.Phase != "" {
|
||||
parts = append(parts, "phase="+fields.Phase)
|
||||
}
|
||||
if fields.SMTPCode != "" {
|
||||
parts = append(parts, "smtp_code="+fields.SMTPCode)
|
||||
}
|
||||
if fields.Script != "" {
|
||||
parts = append(parts, "script="+fields.Script)
|
||||
}
|
||||
|
||||
return strings.Join(parts, " "), nil
|
||||
}
|
||||
|
||||
// Result stores the stable provider-layer outcome together with the redacted
|
||||
// summary that can be persisted in delivery audit records.
|
||||
type Result struct {
|
||||
// Classification stores the stable provider result classification.
|
||||
Classification Classification
|
||||
|
||||
// Summary stores the stable persisted provider summary.
|
||||
Summary string
|
||||
|
||||
// Details stores optional in-memory-only provider details for structured
|
||||
// logs and diagnostics. Callers must not persist this map directly.
|
||||
Details map[string]string
|
||||
}
|
||||
|
||||
// Validate reports whether result contains a supported provider outcome and a
|
||||
// valid safe summary.
|
||||
func (result Result) Validate() error {
|
||||
if !result.Classification.IsKnown() {
|
||||
return fmt.Errorf("provider result classification %q is unsupported", result.Classification)
|
||||
}
|
||||
if err := validateSafeSummary(result.Summary); err != nil {
|
||||
return err
|
||||
}
|
||||
for key, value := range result.Details {
|
||||
if !isSafeSummaryValue(key) {
|
||||
return fmt.Errorf("provider result detail key %q must be an ASCII token", key)
|
||||
}
|
||||
if !isSafeDetailValue(value) {
|
||||
return fmt.Errorf("provider result detail value for %q must use printable ASCII without line breaks", key)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloneDetails returns a detached copy of details suitable for in-memory
|
||||
// logging.
|
||||
func CloneDetails(details map[string]string) map[string]string {
|
||||
if details == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make(map[string]string, len(details))
|
||||
for key, value := range details {
|
||||
cloned[key] = value
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
func validateSafeSummary(summary string) error {
|
||||
if strings.TrimSpace(summary) == "" {
|
||||
return fmt.Errorf("provider result summary must not be empty")
|
||||
}
|
||||
if !utf8.ValidString(summary) {
|
||||
return fmt.Errorf("provider result summary must be valid UTF-8")
|
||||
}
|
||||
|
||||
tokens := strings.Split(summary, " ")
|
||||
if len(tokens) < 2 {
|
||||
return fmt.Errorf("provider result summary must contain provider and result tokens")
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(tokens))
|
||||
for _, token := range tokens {
|
||||
key, value, ok := strings.Cut(token, "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("provider result summary token %q must use key=value form", token)
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
return fmt.Errorf("provider result summary token %q must not repeat", key)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
|
||||
if !slices.Contains([]string{"provider", "result", "phase", "smtp_code", "script"}, key) {
|
||||
return fmt.Errorf("provider result summary token %q is unsupported", key)
|
||||
}
|
||||
if !isSafeSummaryValue(value) {
|
||||
return fmt.Errorf("provider result summary token %q must use a non-empty ASCII value", key)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := seen["provider"]; !ok {
|
||||
return fmt.Errorf("provider result summary must include provider token")
|
||||
}
|
||||
if _, ok := seen["result"]; !ok {
|
||||
return fmt.Errorf("provider result summary must include result token")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isSafeSummaryValue(value string) bool {
|
||||
if strings.TrimSpace(value) == "" || strings.TrimSpace(value) != value {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, r := range value {
|
||||
if r > utf8.RuneSelf {
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9':
|
||||
case r == '.', r == '_', r == '-':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func isSafeDetailValue(value string) bool {
|
||||
if strings.TrimSpace(value) != value {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if r > utf8.RuneSelf || r < 0x20 || r == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildSafeSummaryBuildsStableTokenOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
summary, err := BuildSafeSummary(SummaryFields{
|
||||
Provider: "smtp",
|
||||
Result: "transient_failure",
|
||||
Phase: "data",
|
||||
SMTPCode: "451",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "provider=smtp result=transient_failure phase=data smtp_code=451", summary)
|
||||
}
|
||||
|
||||
func TestResultValidateRejectsUnsafeSummary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := Result{
|
||||
Classification: ClassificationAccepted,
|
||||
Summary: "provider=smtp result=accepted extra=value",
|
||||
}
|
||||
require.Error(t, result.Validate())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package acceptauthdelivery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/domain/idempotency"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
)
|
||||
|
||||
func TestServiceExecuteAcceptsQueuedDelivery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &stubStore{}
|
||||
telemetry := &stubTelemetry{}
|
||||
service := newTestService(t, Config{
|
||||
Store: store,
|
||||
DeliveryIDGenerator: stubIDGenerator{ids: []common.DeliveryID{"delivery-queued"}},
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
Telemetry: telemetry,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
|
||||
result, err := service.Execute(context.Background(), validInput())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Result{Outcome: OutcomeSent}, result)
|
||||
require.Len(t, store.createInputs, 1)
|
||||
require.NotNil(t, store.createInputs[0].FirstAttempt)
|
||||
require.Equal(t, deliverydomain.StatusQueued, store.createInputs[0].Delivery.Status)
|
||||
require.Equal(t, []string{"sent"}, telemetry.outcomes)
|
||||
require.Equal(t, 1, telemetry.accepted)
|
||||
require.Equal(t, []string{"authsession:queued"}, telemetry.statuses)
|
||||
}
|
||||
|
||||
func TestServiceExecuteAcceptsSuppressedDelivery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &stubStore{}
|
||||
telemetry := &stubTelemetry{}
|
||||
service := newTestService(t, Config{
|
||||
Store: store,
|
||||
DeliveryIDGenerator: stubIDGenerator{ids: []common.DeliveryID{"delivery-suppressed"}},
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
Telemetry: telemetry,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
SuppressOutbound: true,
|
||||
})
|
||||
|
||||
result, err := service.Execute(context.Background(), validInput())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Result{Outcome: OutcomeSuppressed}, result)
|
||||
require.Len(t, store.createInputs, 1)
|
||||
require.Nil(t, store.createInputs[0].FirstAttempt)
|
||||
require.Equal(t, deliverydomain.StatusSuppressed, store.createInputs[0].Delivery.Status)
|
||||
require.Equal(t, []string{"suppressed"}, telemetry.outcomes)
|
||||
require.Equal(t, 1, telemetry.accepted)
|
||||
require.Equal(t, []string{"authsession:suppressed"}, telemetry.statuses)
|
||||
}
|
||||
|
||||
func TestServiceExecuteReturnsStableDuplicateResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := validInput()
|
||||
fingerprint, err := input.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
|
||||
store := &stubStore{
|
||||
idempotencyRecord: &idempotency.Record{
|
||||
Source: deliverydomain.SourceAuthSession,
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
DeliveryID: common.DeliveryID("delivery-existing"),
|
||||
RequestFingerprint: fingerprint,
|
||||
CreatedAt: fixedNow(),
|
||||
ExpiresAt: fixedNow().Add(7 * 24 * time.Hour),
|
||||
},
|
||||
deliveryRecord: &deliverydomain.Delivery{
|
||||
DeliveryID: common.DeliveryID("delivery-existing"),
|
||||
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,
|
||||
Status: deliverydomain.StatusSuppressed,
|
||||
CreatedAt: fixedNow(),
|
||||
UpdatedAt: fixedNow(),
|
||||
SuppressedAt: ptrTime(fixedNow()),
|
||||
},
|
||||
}
|
||||
require.NoError(t, store.idempotencyRecord.Validate())
|
||||
require.NoError(t, store.deliveryRecord.Validate())
|
||||
|
||||
telemetry := &stubTelemetry{}
|
||||
service := newTestService(t, Config{
|
||||
Store: store,
|
||||
DeliveryIDGenerator: stubIDGenerator{},
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
Telemetry: telemetry,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
|
||||
result, err := service.Execute(context.Background(), input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Result{Outcome: OutcomeSuppressed}, result)
|
||||
require.Empty(t, store.createInputs)
|
||||
require.Equal(t, []string{"duplicate"}, telemetry.outcomes)
|
||||
}
|
||||
|
||||
func TestServiceExecuteRejectsConflictingReplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := validInput()
|
||||
store := &stubStore{
|
||||
idempotencyRecord: &idempotency.Record{
|
||||
Source: deliverydomain.SourceAuthSession,
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
DeliveryID: common.DeliveryID("delivery-existing"),
|
||||
RequestFingerprint: "sha256:other",
|
||||
CreatedAt: fixedNow(),
|
||||
ExpiresAt: fixedNow().Add(7 * 24 * time.Hour),
|
||||
},
|
||||
}
|
||||
require.NoError(t, store.idempotencyRecord.Validate())
|
||||
|
||||
telemetry := &stubTelemetry{}
|
||||
service := newTestService(t, Config{
|
||||
Store: store,
|
||||
DeliveryIDGenerator: stubIDGenerator{},
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
Telemetry: telemetry,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
|
||||
_, err := service.Execute(context.Background(), input)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrConflict)
|
||||
require.Equal(t, []string{"conflict"}, telemetry.outcomes)
|
||||
}
|
||||
|
||||
func TestServiceExecuteReturnsServiceUnavailableOnCreateFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
telemetry := &stubTelemetry{}
|
||||
service := newTestService(t, Config{
|
||||
Store: &stubStore{
|
||||
createErr: errors.New("redis unavailable"),
|
||||
},
|
||||
DeliveryIDGenerator: stubIDGenerator{ids: []common.DeliveryID{"delivery-queued"}},
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
Telemetry: telemetry,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
|
||||
_, err := service.Execute(context.Background(), validInput())
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrServiceUnavailable)
|
||||
require.Equal(t, []string{"service_unavailable"}, telemetry.outcomes)
|
||||
}
|
||||
|
||||
func TestServiceExecuteLogsAcceptedDeliveryAndCreatesSpan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &stubStore{}
|
||||
telemetry := &stubTelemetry{}
|
||||
loggerBuffer := &bytes.Buffer{}
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
||||
|
||||
service := newTestService(t, Config{
|
||||
Store: store,
|
||||
DeliveryIDGenerator: stubIDGenerator{ids: []common.DeliveryID{"delivery-queued"}},
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
Telemetry: telemetry,
|
||||
TracerProvider: tracerProvider,
|
||||
Logger: slog.New(slog.NewJSONHandler(loggerBuffer, nil)),
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
|
||||
_, err := service.Execute(context.Background(), validInput())
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, loggerBuffer.String(), "\"delivery_id\":\"delivery-queued\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"source\":\"authsession\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"template_id\":\"auth.login_code\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"otel_trace_id\":")
|
||||
require.True(t, hasSpanNamed(recorder.Ended(), "mail.accept_auth_delivery"))
|
||||
}
|
||||
|
||||
func TestInputFingerprintStableForEquivalentInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first := validInput()
|
||||
second := validInput()
|
||||
|
||||
firstFingerprint, err := first.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
secondFingerprint, err := second.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, firstFingerprint, secondFingerprint)
|
||||
}
|
||||
|
||||
type stubStore struct {
|
||||
createInputs []CreateAcceptanceInput
|
||||
createErr error
|
||||
idempotencyRecord *idempotency.Record
|
||||
deliveryRecord *deliverydomain.Delivery
|
||||
}
|
||||
|
||||
func (store *stubStore) CreateAcceptance(_ context.Context, input CreateAcceptanceInput) error {
|
||||
store.createInputs = append(store.createInputs, input)
|
||||
return store.createErr
|
||||
}
|
||||
|
||||
func (store *stubStore) GetIdempotency(_ context.Context, _ deliverydomain.Source, _ common.IdempotencyKey) (idempotency.Record, bool, error) {
|
||||
if store.idempotencyRecord == nil {
|
||||
return idempotency.Record{}, false, nil
|
||||
}
|
||||
|
||||
return *store.idempotencyRecord, true, nil
|
||||
}
|
||||
|
||||
func (store *stubStore) GetDelivery(_ context.Context, _ common.DeliveryID) (deliverydomain.Delivery, bool, error) {
|
||||
if store.deliveryRecord == nil {
|
||||
return deliverydomain.Delivery{}, false, nil
|
||||
}
|
||||
|
||||
return *store.deliveryRecord, true, nil
|
||||
}
|
||||
|
||||
type stubIDGenerator struct {
|
||||
ids []common.DeliveryID
|
||||
}
|
||||
|
||||
func (generator stubIDGenerator) NewDeliveryID() (common.DeliveryID, error) {
|
||||
if len(generator.ids) == 0 {
|
||||
return "", errors.New("no delivery ids left")
|
||||
}
|
||||
|
||||
return generator.ids[0], nil
|
||||
}
|
||||
|
||||
type stubClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock stubClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
|
||||
type stubTelemetry struct {
|
||||
outcomes []string
|
||||
accepted int
|
||||
statuses []string
|
||||
}
|
||||
|
||||
func (telemetry *stubTelemetry) RecordAuthDeliveryOutcome(_ context.Context, outcome string) {
|
||||
telemetry.outcomes = append(telemetry.outcomes, outcome)
|
||||
}
|
||||
|
||||
func (telemetry *stubTelemetry) RecordAcceptedAuthDelivery(context.Context) {
|
||||
telemetry.accepted++
|
||||
}
|
||||
|
||||
func (telemetry *stubTelemetry) RecordDeliveryStatusTransition(_ context.Context, status string, source string) {
|
||||
telemetry.statuses = append(telemetry.statuses, source+":"+status)
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T, cfg Config) *Service {
|
||||
t.Helper()
|
||||
|
||||
service, err := New(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func validInput() Input {
|
||||
locale, err := common.ParseLocale("en")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return Input{
|
||||
IdempotencyKey: common.IdempotencyKey("challenge-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
Code: "123456",
|
||||
Locale: locale,
|
||||
}
|
||||
}
|
||||
|
||||
func fixedNow() time.Time {
|
||||
return time.Unix(1_775_121_700, 0).UTC()
|
||||
}
|
||||
|
||||
func hasSpanNamed(spans []sdktrace.ReadOnlySpan, name string) bool {
|
||||
for _, span := range spans {
|
||||
if span.Name() == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
var _ = attempt.Attempt{}
|
||||
@@ -0,0 +1,598 @@
|
||||
// Package acceptgenericdelivery implements durable asynchronous acceptance of
|
||||
// generic delivery commands consumed from Redis Streams.
|
||||
package acceptgenericdelivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/api/streamcommand"
|
||||
"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 generic request.
|
||||
ErrConflict = errors.New("accept generic delivery conflict")
|
||||
|
||||
// ErrServiceUnavailable reports that durable generic acceptance could not
|
||||
// be completed or recovered safely.
|
||||
ErrServiceUnavailable = errors.New("accept generic delivery service unavailable")
|
||||
)
|
||||
|
||||
const tracerName = "galaxy/mail/acceptgenericdelivery"
|
||||
|
||||
// Outcome identifies the coarse generic-delivery acceptance outcome.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
// OutcomeAccepted reports that the command was durably accepted into the
|
||||
// internal delivery pipeline.
|
||||
OutcomeAccepted Outcome = "accepted"
|
||||
|
||||
// OutcomeDuplicate reports that the command matched an already accepted
|
||||
// idempotent request and therefore became a no-op replay.
|
||||
OutcomeDuplicate Outcome = "duplicate"
|
||||
)
|
||||
|
||||
// IsKnown reports whether outcome belongs to the supported generic-acceptance
|
||||
// outcome surface.
|
||||
func (outcome Outcome) IsKnown() bool {
|
||||
switch outcome {
|
||||
case OutcomeAccepted, OutcomeDuplicate:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Result stores the coarse generic-delivery acceptance outcome.
|
||||
type Result struct {
|
||||
// Outcome stores the stable generic-acceptance result.
|
||||
Outcome Outcome
|
||||
}
|
||||
|
||||
// Validate reports whether result contains a supported generic-acceptance
|
||||
// outcome.
|
||||
func (result Result) Validate() error {
|
||||
if !result.Outcome.IsKnown() {
|
||||
return fmt.Errorf("accept generic delivery outcome %q is unsupported", result.Outcome)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttachmentPayload stores one durably persisted raw attachment payload owned
|
||||
// by a generic delivery.
|
||||
type AttachmentPayload struct {
|
||||
// Filename stores the user-facing attachment filename.
|
||||
Filename string
|
||||
|
||||
// ContentType stores the MIME media type used for SMTP body construction.
|
||||
ContentType string
|
||||
|
||||
// ContentBase64 stores the exact accepted inline base64 payload.
|
||||
ContentBase64 string
|
||||
|
||||
// SizeBytes stores the decoded attachment size in bytes.
|
||||
SizeBytes int64
|
||||
}
|
||||
|
||||
// Validate reports whether payload contains a complete attachment body.
|
||||
func (payload AttachmentPayload) Validate() error {
|
||||
metadata := common.AttachmentMetadata{
|
||||
Filename: payload.Filename,
|
||||
ContentType: payload.ContentType,
|
||||
SizeBytes: payload.SizeBytes,
|
||||
}
|
||||
if err := metadata.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(payload.ContentBase64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("attachment content_base64 must be valid base64: %w", err)
|
||||
}
|
||||
if int64(len(decoded)) != payload.SizeBytes {
|
||||
return fmt.Errorf(
|
||||
"attachment size bytes must match decoded content size: got %d, want %d",
|
||||
payload.SizeBytes,
|
||||
len(decoded),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeliveryPayload stores the raw attachment payloads that must survive stream
|
||||
// offset advancement.
|
||||
type DeliveryPayload struct {
|
||||
// DeliveryID identifies the owning accepted delivery.
|
||||
DeliveryID common.DeliveryID
|
||||
|
||||
// Attachments stores the raw inline attachment payloads.
|
||||
Attachments []AttachmentPayload
|
||||
}
|
||||
|
||||
// Validate reports whether payload contains a complete attachment bundle.
|
||||
func (payload DeliveryPayload) Validate() error {
|
||||
if err := payload.DeliveryID.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery payload delivery id: %w", err)
|
||||
}
|
||||
if len(payload.Attachments) == 0 {
|
||||
return fmt.Errorf("delivery payload attachments must not be empty")
|
||||
}
|
||||
for index, attachment := range payload.Attachments {
|
||||
if err := attachment.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery payload attachments[%d]: %w", index, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateAcceptanceInput stores the durable write set required for one
|
||||
// generic-delivery acceptance attempt.
|
||||
type CreateAcceptanceInput struct {
|
||||
// Delivery stores the accepted delivery record.
|
||||
Delivery deliverydomain.Delivery
|
||||
|
||||
// FirstAttempt stores the first scheduled attempt.
|
||||
FirstAttempt attempt.Attempt
|
||||
|
||||
// DeliveryPayload stores the optional raw attachment payload bundle.
|
||||
DeliveryPayload *DeliveryPayload
|
||||
|
||||
// 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.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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
if input.DeliveryPayload != nil {
|
||||
if err := input.DeliveryPayload.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery payload: %w", err)
|
||||
}
|
||||
if input.DeliveryPayload.DeliveryID != input.Delivery.DeliveryID {
|
||||
return errors.New("delivery payload delivery id must match delivery id")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Store describes the durable storage required by the generic-delivery use
|
||||
// case.
|
||||
type Store interface {
|
||||
// CreateAcceptance stores the complete durable write set for one generic
|
||||
// acceptance attempt. Implementations must wrap ErrConflict when the write
|
||||
// set races with an already accepted idempotency scope or delivery key.
|
||||
CreateAcceptance(context.Context, CreateAcceptanceInput) error
|
||||
|
||||
// GetIdempotency loads the idempotency reservation for one generic-delivery
|
||||
// scope.
|
||||
GetIdempotency(context.Context, deliverydomain.Source, common.IdempotencyKey) (idempotency.Record, bool, error)
|
||||
|
||||
// GetDelivery loads one accepted delivery by its identifier.
|
||||
GetDelivery(context.Context, common.DeliveryID) (deliverydomain.Delivery, bool, error)
|
||||
}
|
||||
|
||||
// Clock provides the current wall-clock time.
|
||||
type Clock interface {
|
||||
// Now returns the current time.
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
// Telemetry records low-cardinality generic-delivery outcomes.
|
||||
type Telemetry interface {
|
||||
// RecordGenericDeliveryOutcome records one coarse generic-acceptance
|
||||
// outcome.
|
||||
RecordGenericDeliveryOutcome(context.Context, string)
|
||||
|
||||
// RecordAcceptedGenericDelivery records one newly accepted generic
|
||||
// delivery.
|
||||
RecordAcceptedGenericDelivery(context.Context)
|
||||
|
||||
// RecordDeliveryStatusTransition records one durable delivery status
|
||||
// transition.
|
||||
RecordDeliveryStatusTransition(context.Context, string, string)
|
||||
}
|
||||
|
||||
// Config stores the dependencies and policy used by Service.
|
||||
type Config struct {
|
||||
// Store owns the durable accepted state.
|
||||
Store Store
|
||||
|
||||
// Clock provides wall-clock timestamps.
|
||||
Clock Clock
|
||||
|
||||
// Telemetry records low-cardinality acceptance outcomes.
|
||||
Telemetry Telemetry
|
||||
|
||||
// TracerProvider constructs the application span recorder used by the
|
||||
// generic acceptance flow.
|
||||
TracerProvider oteltrace.TracerProvider
|
||||
|
||||
// Logger writes structured generic acceptance logs.
|
||||
Logger *slog.Logger
|
||||
|
||||
// IdempotencyTTL stores how long accepted idempotency scopes remain valid.
|
||||
IdempotencyTTL time.Duration
|
||||
}
|
||||
|
||||
// Service durably accepts generic asynchronous delivery commands.
|
||||
type Service struct {
|
||||
store Store
|
||||
clock Clock
|
||||
telemetry Telemetry
|
||||
tracerProvider oteltrace.TracerProvider
|
||||
logger *slog.Logger
|
||||
idempotencyTTL time.Duration
|
||||
}
|
||||
|
||||
// New constructs Service from cfg.
|
||||
func New(cfg Config) (*Service, error) {
|
||||
switch {
|
||||
case cfg.Store == nil:
|
||||
return nil, errors.New("new accept generic delivery service: nil store")
|
||||
case cfg.Clock == nil:
|
||||
return nil, errors.New("new accept generic delivery service: nil clock")
|
||||
case cfg.IdempotencyTTL <= 0:
|
||||
return nil, errors.New("new accept generic 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,
|
||||
clock: cfg.Clock,
|
||||
telemetry: cfg.Telemetry,
|
||||
tracerProvider: tracerProvider,
|
||||
logger: logger.With("component", "accept_generic_delivery"),
|
||||
idempotencyTTL: cfg.IdempotencyTTL,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Execute accepts one normalized generic-delivery command.
|
||||
func (service *Service) Execute(ctx context.Context, command streamcommand.Command) (Result, error) {
|
||||
if ctx == nil {
|
||||
return Result{}, errors.New("accept generic delivery: nil context")
|
||||
}
|
||||
if service == nil {
|
||||
return Result{}, errors.New("accept generic delivery: nil service")
|
||||
}
|
||||
if err := command.Validate(); err != nil {
|
||||
return Result{}, fmt.Errorf("accept generic delivery: %w", err)
|
||||
}
|
||||
|
||||
ctx, span := service.tracerProvider.Tracer(tracerName).Start(ctx, "mail.accept_generic_delivery")
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.String("mail.delivery_id", command.DeliveryID.String()),
|
||||
attribute.String("mail.source", string(command.Source)),
|
||||
attribute.String("mail.payload_mode", string(command.PayloadMode)),
|
||||
)
|
||||
if strings.TrimSpace(command.TraceID) != "" {
|
||||
span.SetAttributes(attribute.String("mail.command_trace_id", command.TraceID))
|
||||
}
|
||||
if !command.TemplateID.IsZero() {
|
||||
span.SetAttributes(attribute.String("mail.template_id", command.TemplateID.String()))
|
||||
}
|
||||
|
||||
fingerprint, err := command.Fingerprint()
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("accept generic delivery: %w", err)
|
||||
}
|
||||
|
||||
if result, handled, err := service.resolveReplay(ctx, command, fingerprint); handled {
|
||||
if err != nil {
|
||||
service.recordOutcome(ctx, replayOutcomeForError(err))
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
service.recordOutcome(ctx, string(result.Outcome))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
createInput, result, err := service.buildCreateInput(command, fingerprint)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("accept generic 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, command, fingerprint); handled {
|
||||
if replayErr != nil {
|
||||
service.recordOutcome(ctx, replayOutcomeForError(replayErr))
|
||||
return Result{}, replayErr
|
||||
}
|
||||
|
||||
service.recordOutcome(ctx, string(replayResult.Outcome))
|
||||
return replayResult, nil
|
||||
}
|
||||
|
||||
service.recordOutcome(ctx, "service_unavailable")
|
||||
return Result{}, fmt.Errorf("%w: create acceptance conflict without replay state", ErrServiceUnavailable)
|
||||
}
|
||||
|
||||
service.recordOutcome(ctx, string(result.Outcome))
|
||||
service.recordAcceptedDelivery(ctx)
|
||||
service.recordStatusTransition(ctx, createInput.Delivery)
|
||||
span.SetAttributes(
|
||||
attribute.String("mail.status", string(createInput.Delivery.Status)),
|
||||
attribute.String("mail.outcome", string(result.Outcome)),
|
||||
)
|
||||
logArgs := logging.CommandAttrs(command)
|
||||
logArgs = append(logArgs,
|
||||
"status", string(createInput.Delivery.Status),
|
||||
"outcome", string(result.Outcome),
|
||||
"payload_mode", string(command.PayloadMode),
|
||||
)
|
||||
logArgs = append(logArgs, logging.TraceAttrsFromContext(ctx)...)
|
||||
service.logger.Info("generic delivery accepted", logArgs...)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (service *Service) buildCreateInput(command streamcommand.Command, fingerprint string) (CreateAcceptanceInput, Result, error) {
|
||||
now := service.clock.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
deliveryRecord := deliverydomain.Delivery{
|
||||
DeliveryID: command.DeliveryID,
|
||||
Source: command.Source,
|
||||
PayloadMode: command.PayloadMode,
|
||||
Envelope: command.Envelope,
|
||||
Attachments: attachmentMetadata(command.Attachments),
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
Status: deliverydomain.StatusQueued,
|
||||
AttemptCount: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
switch command.PayloadMode {
|
||||
case deliverydomain.PayloadModeRendered:
|
||||
deliveryRecord.Content = deliverydomain.Content{
|
||||
Subject: command.Subject,
|
||||
TextBody: command.TextBody,
|
||||
HTMLBody: command.HTMLBody,
|
||||
}
|
||||
case deliverydomain.PayloadModeTemplate:
|
||||
deliveryRecord.TemplateID = command.TemplateID
|
||||
deliveryRecord.Locale = command.Locale
|
||||
deliveryRecord.TemplateVariables = cloneJSONObject(command.Variables)
|
||||
default:
|
||||
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build generic delivery record: unsupported payload mode %q", command.PayloadMode)
|
||||
}
|
||||
|
||||
if err := deliveryRecord.Validate(); err != nil {
|
||||
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build generic delivery record: %w", err)
|
||||
}
|
||||
|
||||
firstAttempt := attempt.Attempt{
|
||||
DeliveryID: command.DeliveryID,
|
||||
AttemptNo: 1,
|
||||
ScheduledFor: now,
|
||||
Status: attempt.StatusScheduled,
|
||||
}
|
||||
if err := firstAttempt.Validate(); err != nil {
|
||||
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build generic first attempt: %w", err)
|
||||
}
|
||||
|
||||
createInput := CreateAcceptanceInput{
|
||||
Delivery: deliveryRecord,
|
||||
FirstAttempt: firstAttempt,
|
||||
Idempotency: idempotency.Record{
|
||||
Source: command.Source,
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
DeliveryID: command.DeliveryID,
|
||||
RequestFingerprint: fingerprint,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(service.idempotencyTTL),
|
||||
},
|
||||
}
|
||||
if len(command.Attachments) > 0 {
|
||||
createInput.DeliveryPayload = &DeliveryPayload{
|
||||
DeliveryID: command.DeliveryID,
|
||||
Attachments: attachmentPayloads(command.Attachments),
|
||||
}
|
||||
}
|
||||
if err := createInput.Validate(); err != nil {
|
||||
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build generic create input: %w", err)
|
||||
}
|
||||
|
||||
result := Result{Outcome: OutcomeAccepted}
|
||||
if err := result.Validate(); err != nil {
|
||||
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build generic delivery result: %w", err)
|
||||
}
|
||||
|
||||
return createInput, result, nil
|
||||
}
|
||||
|
||||
func (service *Service) resolveReplay(ctx context.Context, command streamcommand.Command, fingerprint string) (Result, bool, error) {
|
||||
record, found, err := service.store.GetIdempotency(ctx, command.Source, command.IdempotencyKey)
|
||||
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)
|
||||
}
|
||||
|
||||
if deliveryRecord.DeliveryID != command.DeliveryID {
|
||||
return Result{}, true, fmt.Errorf("%w: idempotency delivery %q mismatches command delivery %q", ErrServiceUnavailable, deliveryRecord.DeliveryID, command.DeliveryID)
|
||||
}
|
||||
|
||||
return deriveReplayResult(deliveryRecord)
|
||||
}
|
||||
|
||||
func deriveReplayResult(record deliverydomain.Delivery) (Result, bool, error) {
|
||||
switch record.Status {
|
||||
case deliverydomain.StatusAccepted,
|
||||
deliverydomain.StatusQueued,
|
||||
deliverydomain.StatusRendered,
|
||||
deliverydomain.StatusSending,
|
||||
deliverydomain.StatusSent,
|
||||
deliverydomain.StatusSuppressed,
|
||||
deliverydomain.StatusFailed,
|
||||
deliverydomain.StatusDeadLetter:
|
||||
return Result{Outcome: OutcomeDuplicate}, true, nil
|
||||
default:
|
||||
return Result{}, true, fmt.Errorf("%w: unsupported replay delivery status %q", ErrServiceUnavailable, record.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) recordAcceptedDelivery(ctx context.Context) {
|
||||
if service == nil || service.telemetry == nil {
|
||||
return
|
||||
}
|
||||
|
||||
service.telemetry.RecordAcceptedGenericDelivery(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) recordOutcome(ctx context.Context, outcome string) {
|
||||
if service == nil || service.telemetry == nil || strings.TrimSpace(outcome) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
service.telemetry.RecordGenericDeliveryOutcome(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 attachmentMetadata(values []streamcommand.Attachment) []common.AttachmentMetadata {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]common.AttachmentMetadata, len(values))
|
||||
for index, value := range values {
|
||||
result[index] = common.AttachmentMetadata{
|
||||
Filename: value.Filename,
|
||||
ContentType: value.ContentType,
|
||||
SizeBytes: value.SizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func attachmentPayloads(values []streamcommand.Attachment) []AttachmentPayload {
|
||||
result := make([]AttachmentPayload, len(values))
|
||||
for index, value := range values {
|
||||
result[index] = AttachmentPayload{
|
||||
Filename: value.Filename,
|
||||
ContentType: value.ContentType,
|
||||
ContentBase64: value.ContentBase64,
|
||||
SizeBytes: value.SizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneJSONObject(value map[string]any) map[string]any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make(map[string]any, len(value))
|
||||
for key, item := range value {
|
||||
cloned[key] = cloneJSONValue(item)
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneJSONValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
cloned := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
cloned[key] = cloneJSONValue(item)
|
||||
}
|
||||
return cloned
|
||||
case []any:
|
||||
cloned := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
cloned[index] = cloneJSONValue(item)
|
||||
}
|
||||
return cloned
|
||||
default:
|
||||
return typed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package acceptgenericdelivery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/api/streamcommand"
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/domain/idempotency"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
)
|
||||
|
||||
func TestServiceExecuteAcceptsRenderedDelivery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &stubStore{}
|
||||
telemetry := &stubTelemetry{}
|
||||
service := newTestService(t, Config{
|
||||
Store: store,
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
Telemetry: telemetry,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
|
||||
result, err := service.Execute(context.Background(), validRenderedCommand(t))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Result{Outcome: OutcomeAccepted}, result)
|
||||
require.Len(t, store.createInputs, 1)
|
||||
require.Equal(t, deliverydomain.StatusQueued, store.createInputs[0].Delivery.Status)
|
||||
require.Equal(t, deliverydomain.PayloadModeRendered, store.createInputs[0].Delivery.PayloadMode)
|
||||
require.Equal(t, "Turn ready", store.createInputs[0].Delivery.Content.Subject)
|
||||
require.NotNil(t, store.createInputs[0].DeliveryPayload)
|
||||
require.Equal(t, []string{"accepted"}, telemetry.outcomes)
|
||||
require.Equal(t, 1, telemetry.accepted)
|
||||
require.Equal(t, []string{"notification:queued"}, telemetry.statuses)
|
||||
}
|
||||
|
||||
func TestServiceExecuteAcceptsTemplateDelivery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &stubStore{}
|
||||
telemetry := &stubTelemetry{}
|
||||
service := newTestService(t, Config{
|
||||
Store: store,
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
Telemetry: telemetry,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
|
||||
result, err := service.Execute(context.Background(), validTemplateCommand(t))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Result{Outcome: OutcomeAccepted}, result)
|
||||
require.Len(t, store.createInputs, 1)
|
||||
require.Nil(t, store.createInputs[0].DeliveryPayload)
|
||||
require.Equal(t, common.TemplateID("game.turn_ready"), store.createInputs[0].Delivery.TemplateID)
|
||||
require.Equal(t, map[string]any{
|
||||
"turn_number": float64(54),
|
||||
"player": map[string]any{
|
||||
"name": "Pilot",
|
||||
},
|
||||
}, store.createInputs[0].Delivery.TemplateVariables)
|
||||
require.Equal(t, []string{"accepted"}, telemetry.outcomes)
|
||||
require.Equal(t, 1, telemetry.accepted)
|
||||
require.Equal(t, []string{"notification:queued"}, telemetry.statuses)
|
||||
}
|
||||
|
||||
func TestServiceExecuteReturnsStableDuplicateResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
command := validTemplateCommand(t)
|
||||
fingerprint, err := command.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
|
||||
store := &stubStore{
|
||||
idempotencyRecord: &idempotency.Record{
|
||||
Source: deliverydomain.SourceNotification,
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
DeliveryID: command.DeliveryID,
|
||||
RequestFingerprint: fingerprint,
|
||||
CreatedAt: fixedNow(),
|
||||
ExpiresAt: fixedNow().Add(7 * 24 * time.Hour),
|
||||
},
|
||||
deliveryRecord: &deliverydomain.Delivery{
|
||||
DeliveryID: command.DeliveryID,
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeTemplate,
|
||||
TemplateID: command.TemplateID,
|
||||
Envelope: command.Envelope,
|
||||
Locale: command.Locale,
|
||||
TemplateVariables: map[string]any{
|
||||
"turn_number": float64(54),
|
||||
"player": map[string]any{
|
||||
"name": "Pilot",
|
||||
},
|
||||
},
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
Status: deliverydomain.StatusQueued,
|
||||
AttemptCount: 1,
|
||||
CreatedAt: fixedNow(),
|
||||
UpdatedAt: fixedNow(),
|
||||
},
|
||||
}
|
||||
require.NoError(t, store.idempotencyRecord.Validate())
|
||||
require.NoError(t, store.deliveryRecord.Validate())
|
||||
|
||||
telemetry := &stubTelemetry{}
|
||||
service := newTestService(t, Config{
|
||||
Store: store,
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
Telemetry: telemetry,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
|
||||
result, err := service.Execute(context.Background(), command)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Result{Outcome: OutcomeDuplicate}, result)
|
||||
require.Empty(t, store.createInputs)
|
||||
require.Equal(t, []string{"duplicate"}, telemetry.outcomes)
|
||||
}
|
||||
|
||||
func TestServiceExecuteRejectsConflictingReplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
command := validRenderedCommand(t)
|
||||
store := &stubStore{
|
||||
idempotencyRecord: &idempotency.Record{
|
||||
Source: deliverydomain.SourceNotification,
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
DeliveryID: command.DeliveryID,
|
||||
RequestFingerprint: "sha256:other",
|
||||
CreatedAt: fixedNow(),
|
||||
ExpiresAt: fixedNow().Add(7 * 24 * time.Hour),
|
||||
},
|
||||
}
|
||||
require.NoError(t, store.idempotencyRecord.Validate())
|
||||
|
||||
telemetry := &stubTelemetry{}
|
||||
service := newTestService(t, Config{
|
||||
Store: store,
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
Telemetry: telemetry,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
|
||||
_, err := service.Execute(context.Background(), command)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrConflict)
|
||||
require.Equal(t, []string{"conflict"}, telemetry.outcomes)
|
||||
}
|
||||
|
||||
func TestServiceExecuteReturnsServiceUnavailableOnCreateFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
telemetry := &stubTelemetry{}
|
||||
service := newTestService(t, Config{
|
||||
Store: &stubStore{
|
||||
createErr: errors.New("redis unavailable"),
|
||||
},
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
Telemetry: telemetry,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
|
||||
_, err := service.Execute(context.Background(), validRenderedCommand(t))
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrServiceUnavailable)
|
||||
require.Equal(t, []string{"service_unavailable"}, telemetry.outcomes)
|
||||
}
|
||||
|
||||
func TestServiceExecuteLogsAcceptedDeliveryAndCreatesSpan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &stubStore{}
|
||||
telemetry := &stubTelemetry{}
|
||||
loggerBuffer := &bytes.Buffer{}
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
||||
command := validTemplateCommand(t)
|
||||
command.TraceID = "trace-123"
|
||||
|
||||
service := newTestService(t, Config{
|
||||
Store: store,
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
Telemetry: telemetry,
|
||||
TracerProvider: tracerProvider,
|
||||
Logger: slog.New(slog.NewJSONHandler(loggerBuffer, nil)),
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
|
||||
_, err := service.Execute(context.Background(), command)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, loggerBuffer.String(), "\"delivery_id\":\"mail-124\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"source\":\"notification\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"template_id\":\"game.turn_ready\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"trace_id\":\"trace-123\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"otel_trace_id\":")
|
||||
require.True(t, hasSpanNamed(recorder.Ended(), "mail.accept_generic_delivery"))
|
||||
}
|
||||
|
||||
type stubStore struct {
|
||||
createInputs []CreateAcceptanceInput
|
||||
createErr error
|
||||
idempotencyRecord *idempotency.Record
|
||||
deliveryRecord *deliverydomain.Delivery
|
||||
}
|
||||
|
||||
func (store *stubStore) CreateAcceptance(_ context.Context, input CreateAcceptanceInput) error {
|
||||
store.createInputs = append(store.createInputs, input)
|
||||
return store.createErr
|
||||
}
|
||||
|
||||
func (store *stubStore) GetIdempotency(_ context.Context, _ deliverydomain.Source, _ common.IdempotencyKey) (idempotency.Record, bool, error) {
|
||||
if store.idempotencyRecord == nil {
|
||||
return idempotency.Record{}, false, nil
|
||||
}
|
||||
|
||||
return *store.idempotencyRecord, true, nil
|
||||
}
|
||||
|
||||
func (store *stubStore) GetDelivery(_ context.Context, _ common.DeliveryID) (deliverydomain.Delivery, bool, error) {
|
||||
if store.deliveryRecord == nil {
|
||||
return deliverydomain.Delivery{}, false, nil
|
||||
}
|
||||
|
||||
return *store.deliveryRecord, true, nil
|
||||
}
|
||||
|
||||
type stubClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock stubClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
|
||||
type stubTelemetry struct {
|
||||
outcomes []string
|
||||
accepted int
|
||||
statuses []string
|
||||
}
|
||||
|
||||
func (telemetry *stubTelemetry) RecordGenericDeliveryOutcome(_ context.Context, outcome string) {
|
||||
telemetry.outcomes = append(telemetry.outcomes, outcome)
|
||||
}
|
||||
|
||||
func (telemetry *stubTelemetry) RecordAcceptedGenericDelivery(context.Context) {
|
||||
telemetry.accepted++
|
||||
}
|
||||
|
||||
func (telemetry *stubTelemetry) RecordDeliveryStatusTransition(_ context.Context, status string, source string) {
|
||||
telemetry.statuses = append(telemetry.statuses, source+":"+status)
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T, cfg Config) *Service {
|
||||
t.Helper()
|
||||
|
||||
service, err := New(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func validRenderedCommand(t *testing.T) streamcommand.Command {
|
||||
t.Helper()
|
||||
|
||||
command, err := streamcommand.DecodeCommand(map[string]any{
|
||||
"delivery_id": "mail-123",
|
||||
"source": "notification",
|
||||
"payload_mode": "rendered",
|
||||
"idempotency_key": "notification:mail-123",
|
||||
"requested_at_ms": "1775121700000",
|
||||
"payload_json": `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":["noreply@example.com"],"subject":"Turn ready","text_body":"Turn 54 is ready.","html_body":"<p>Turn 54 is ready.</p>","attachments":[{"filename":"report.txt","content_type":"text/plain","content_base64":"` + base64.StdEncoding.EncodeToString([]byte("report")) + `"}]}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func validTemplateCommand(t *testing.T) streamcommand.Command {
|
||||
t.Helper()
|
||||
|
||||
command, err := streamcommand.DecodeCommand(map[string]any{
|
||||
"delivery_id": "mail-124",
|
||||
"source": "notification",
|
||||
"payload_mode": "template",
|
||||
"idempotency_key": "notification:mail-124",
|
||||
"requested_at_ms": "1775121700001",
|
||||
"payload_json": `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn_ready","locale":"fr-FR","variables":{"turn_number":54,"player":{"name":"Pilot"}},"attachments":[]}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func fixedNow() time.Time {
|
||||
return time.Unix(1_775_121_700, 0).UTC()
|
||||
}
|
||||
|
||||
func hasSpanNamed(spans []sdktrace.ReadOnlySpan, name string) bool {
|
||||
for _, span := range spans {
|
||||
if span.Name() == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
var _ = attempt.Attempt{}
|
||||
@@ -0,0 +1,781 @@
|
||||
// Package executeattempt implements provider execution, retry planning, and
|
||||
// terminal state handling for claimed delivery attempts.
|
||||
package executeattempt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"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/logging"
|
||||
"galaxy/mail/internal/ports"
|
||||
"galaxy/mail/internal/service/acceptgenericdelivery"
|
||||
"galaxy/mail/internal/service/renderdelivery"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
oteltrace "go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrServiceUnavailable reports that attempt execution could not safely
|
||||
// load or persist durable state.
|
||||
ErrServiceUnavailable = errors.New("execute attempt service unavailable")
|
||||
)
|
||||
|
||||
var retryDelays = [...]time.Duration{
|
||||
time.Minute,
|
||||
5 * time.Minute,
|
||||
30 * time.Minute,
|
||||
}
|
||||
|
||||
const (
|
||||
retryExhaustedClassification = "retry_exhausted"
|
||||
retryRecoveryHint = "check SMTP connectivity"
|
||||
claimTTLClassification = "claim_ttl_expired"
|
||||
claimTTLSummary = "attempt claim TTL expired"
|
||||
deadlineExceededDetail = "deadline_exceeded"
|
||||
tracerName = "galaxy/mail/executeattempt"
|
||||
)
|
||||
|
||||
// WorkItem stores one delivery together with the concrete attempt that should
|
||||
// be prepared, executed, or recovered.
|
||||
type WorkItem struct {
|
||||
// Delivery stores the owning logical delivery record.
|
||||
Delivery deliverydomain.Delivery
|
||||
|
||||
// Attempt stores the concrete delivery attempt record.
|
||||
Attempt attempt.Attempt
|
||||
}
|
||||
|
||||
// ValidateForPreparation reports whether item can be prepared for claim-time
|
||||
// rendering decisions.
|
||||
func (item WorkItem) ValidateForPreparation() error {
|
||||
if err := item.validateCommon(); err != nil {
|
||||
return err
|
||||
}
|
||||
if item.Attempt.Status != attempt.StatusScheduled {
|
||||
return fmt.Errorf("work attempt status must be %q", attempt.StatusScheduled)
|
||||
}
|
||||
switch item.Delivery.Status {
|
||||
case deliverydomain.StatusQueued, deliverydomain.StatusRendered:
|
||||
default:
|
||||
return fmt.Errorf(
|
||||
"work delivery status must be %q or %q",
|
||||
deliverydomain.StatusQueued,
|
||||
deliverydomain.StatusRendered,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateForExecution reports whether item represents one claimed in-flight
|
||||
// provider execution.
|
||||
func (item WorkItem) ValidateForExecution() error {
|
||||
if err := item.validateCommon(); err != nil {
|
||||
return err
|
||||
}
|
||||
if item.Delivery.Status != deliverydomain.StatusSending {
|
||||
return fmt.Errorf("work delivery status must be %q", deliverydomain.StatusSending)
|
||||
}
|
||||
if item.Attempt.Status != attempt.StatusInProgress {
|
||||
return fmt.Errorf("work attempt status must be %q", attempt.StatusInProgress)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (item WorkItem) validateCommon() error {
|
||||
if err := item.Delivery.Validate(); err != nil {
|
||||
return fmt.Errorf("work delivery: %w", err)
|
||||
}
|
||||
if err := item.Attempt.Validate(); err != nil {
|
||||
return fmt.Errorf("work attempt: %w", err)
|
||||
}
|
||||
if item.Attempt.DeliveryID != item.Delivery.DeliveryID {
|
||||
return errors.New("work attempt delivery id must match delivery id")
|
||||
}
|
||||
if item.Delivery.AttemptCount != item.Attempt.AttemptNo {
|
||||
return errors.New("work delivery attempt count must match attempt number")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CommitStateInput stores one complete durable attempt outcome mutation.
|
||||
type CommitStateInput struct {
|
||||
// Delivery stores the mutated delivery record.
|
||||
Delivery deliverydomain.Delivery
|
||||
|
||||
// Attempt stores the terminal current attempt record.
|
||||
Attempt attempt.Attempt
|
||||
|
||||
// NextAttempt stores the optional next scheduled retry attempt.
|
||||
NextAttempt *attempt.Attempt
|
||||
|
||||
// DeadLetter stores the optional dead-letter record when Delivery becomes
|
||||
// `dead_letter`.
|
||||
DeadLetter *deliverydomain.DeadLetterEntry
|
||||
}
|
||||
|
||||
// Validate reports whether input stores one complete and internally
|
||||
// consistent durable mutation.
|
||||
func (input CommitStateInput) Validate() error {
|
||||
if err := input.Delivery.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery: %w", err)
|
||||
}
|
||||
if err := input.Attempt.Validate(); err != nil {
|
||||
return fmt.Errorf("attempt: %w", err)
|
||||
}
|
||||
if !input.Attempt.Status.IsTerminal() {
|
||||
return errors.New("attempt status must be terminal")
|
||||
}
|
||||
if input.Attempt.DeliveryID != input.Delivery.DeliveryID {
|
||||
return errors.New("attempt delivery id must match delivery id")
|
||||
}
|
||||
if input.Delivery.LastAttemptStatus != input.Attempt.Status {
|
||||
return errors.New("delivery last attempt status must match attempt status")
|
||||
}
|
||||
|
||||
if input.NextAttempt != nil {
|
||||
if err := input.NextAttempt.Validate(); err != nil {
|
||||
return fmt.Errorf("next attempt: %w", err)
|
||||
}
|
||||
if input.NextAttempt.DeliveryID != input.Delivery.DeliveryID {
|
||||
return errors.New("next attempt delivery id must match delivery id")
|
||||
}
|
||||
if input.NextAttempt.Status != attempt.StatusScheduled {
|
||||
return fmt.Errorf("next attempt status must be %q", attempt.StatusScheduled)
|
||||
}
|
||||
if input.Delivery.Status != deliverydomain.StatusQueued {
|
||||
return fmt.Errorf("delivery status with next attempt must be %q", deliverydomain.StatusQueued)
|
||||
}
|
||||
if input.Delivery.AttemptCount != input.NextAttempt.AttemptNo {
|
||||
return errors.New("delivery attempt count must match next attempt number")
|
||||
}
|
||||
if input.NextAttempt.AttemptNo != input.Attempt.AttemptNo+1 {
|
||||
return errors.New("next attempt number must increment current attempt number")
|
||||
}
|
||||
if input.DeadLetter != nil {
|
||||
return errors.New("next attempt and dead-letter entry are mutually exclusive")
|
||||
}
|
||||
} else if input.Delivery.AttemptCount != input.Attempt.AttemptNo {
|
||||
return errors.New("delivery attempt count must match current attempt number without next attempt")
|
||||
}
|
||||
|
||||
if err := deliverydomain.ValidateDeadLetterState(input.Delivery, input.DeadLetter); err != nil {
|
||||
return fmt.Errorf("dead-letter state: %w", err)
|
||||
}
|
||||
|
||||
switch input.Delivery.Status {
|
||||
case deliverydomain.StatusSent:
|
||||
if input.Attempt.Status != attempt.StatusProviderAccepted {
|
||||
return fmt.Errorf("sent delivery requires attempt status %q", attempt.StatusProviderAccepted)
|
||||
}
|
||||
case deliverydomain.StatusSuppressed, deliverydomain.StatusFailed:
|
||||
if input.Attempt.Status != attempt.StatusProviderRejected {
|
||||
return fmt.Errorf(
|
||||
"%s delivery requires attempt status %q",
|
||||
input.Delivery.Status,
|
||||
attempt.StatusProviderRejected,
|
||||
)
|
||||
}
|
||||
case deliverydomain.StatusQueued:
|
||||
if input.NextAttempt == nil {
|
||||
return errors.New("queued delivery requires next attempt")
|
||||
}
|
||||
switch input.Attempt.Status {
|
||||
case attempt.StatusTransportFailed, attempt.StatusTimedOut:
|
||||
default:
|
||||
return fmt.Errorf(
|
||||
"queued delivery requires attempt status %q or %q",
|
||||
attempt.StatusTransportFailed,
|
||||
attempt.StatusTimedOut,
|
||||
)
|
||||
}
|
||||
case deliverydomain.StatusDeadLetter:
|
||||
switch input.Attempt.Status {
|
||||
case attempt.StatusTransportFailed, attempt.StatusTimedOut:
|
||||
default:
|
||||
return fmt.Errorf(
|
||||
"dead-letter delivery requires attempt status %q or %q",
|
||||
attempt.StatusTransportFailed,
|
||||
attempt.StatusTimedOut,
|
||||
)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported delivery status %q for commit input", input.Delivery.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Renderer materializes template-mode deliveries before a scheduler claims an
|
||||
// attempt for outbound execution.
|
||||
type Renderer interface {
|
||||
// Execute renders or terminally fails one queued template-mode delivery.
|
||||
Execute(context.Context, renderdelivery.Input) (renderdelivery.Result, error)
|
||||
}
|
||||
|
||||
// PayloadLoader loads raw attachment payloads for a delivery.
|
||||
type PayloadLoader interface {
|
||||
// LoadPayload returns the stored attachment payload bundle when one exists.
|
||||
LoadPayload(context.Context, common.DeliveryID) (acceptgenericdelivery.DeliveryPayload, bool, error)
|
||||
}
|
||||
|
||||
// Store persists durable attempt execution outcomes.
|
||||
type Store interface {
|
||||
// Commit applies one complete durable attempt outcome mutation.
|
||||
Commit(context.Context, CommitStateInput) error
|
||||
}
|
||||
|
||||
// Clock provides wall-clock time.
|
||||
type Clock interface {
|
||||
// Now returns the current time.
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
// Telemetry records low-cardinality attempt-execution metrics.
|
||||
type Telemetry interface {
|
||||
// RecordDeliveryStatusTransition records one durable delivery status
|
||||
// transition.
|
||||
RecordDeliveryStatusTransition(context.Context, string, string)
|
||||
|
||||
// RecordAttemptOutcome records one durable terminal attempt outcome.
|
||||
RecordAttemptOutcome(context.Context, string, string)
|
||||
|
||||
// RecordProviderSendDuration records one provider-send latency sample.
|
||||
RecordProviderSendDuration(context.Context, string, string, time.Duration)
|
||||
}
|
||||
|
||||
// Config stores the dependencies used by Service.
|
||||
type Config struct {
|
||||
// Renderer stores the template renderer used during pre-claim preparation.
|
||||
Renderer Renderer
|
||||
|
||||
// Provider stores the outbound provider adapter.
|
||||
Provider ports.Provider
|
||||
|
||||
// PayloadLoader loads raw attachment payloads for SMTP construction.
|
||||
PayloadLoader PayloadLoader
|
||||
|
||||
// Store persists durable attempt execution outcomes.
|
||||
Store Store
|
||||
|
||||
// Clock provides wall-clock timestamps.
|
||||
Clock Clock
|
||||
|
||||
// Telemetry records low-cardinality attempt-execution metrics.
|
||||
Telemetry Telemetry
|
||||
|
||||
// TracerProvider constructs the application span recorder used by provider
|
||||
// sends.
|
||||
TracerProvider oteltrace.TracerProvider
|
||||
|
||||
// Logger writes structured attempt-execution logs.
|
||||
Logger *slog.Logger
|
||||
|
||||
// AttemptTimeout bounds one provider execution budget.
|
||||
AttemptTimeout time.Duration
|
||||
}
|
||||
|
||||
// Service prepares template deliveries, executes claimed attempts, and
|
||||
// applies retry policy.
|
||||
type Service struct {
|
||||
renderer Renderer
|
||||
provider ports.Provider
|
||||
payloadLoader PayloadLoader
|
||||
store Store
|
||||
clock Clock
|
||||
telemetry Telemetry
|
||||
tracerProvider oteltrace.TracerProvider
|
||||
logger *slog.Logger
|
||||
attemptTimeout time.Duration
|
||||
}
|
||||
|
||||
// New constructs Service from cfg.
|
||||
func New(cfg Config) (*Service, error) {
|
||||
switch {
|
||||
case cfg.Renderer == nil:
|
||||
return nil, errors.New("new execute attempt service: nil renderer")
|
||||
case cfg.Provider == nil:
|
||||
return nil, errors.New("new execute attempt service: nil provider")
|
||||
case cfg.PayloadLoader == nil:
|
||||
return nil, errors.New("new execute attempt service: nil payload loader")
|
||||
case cfg.Store == nil:
|
||||
return nil, errors.New("new execute attempt service: nil store")
|
||||
case cfg.Clock == nil:
|
||||
return nil, errors.New("new execute attempt service: nil clock")
|
||||
case cfg.AttemptTimeout <= 0:
|
||||
return nil, errors.New("new execute attempt service: non-positive attempt timeout")
|
||||
default:
|
||||
tracerProvider := cfg.TracerProvider
|
||||
if tracerProvider == nil {
|
||||
tracerProvider = otel.GetTracerProvider()
|
||||
}
|
||||
logger := cfg.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &Service{
|
||||
renderer: cfg.Renderer,
|
||||
provider: cfg.Provider,
|
||||
payloadLoader: cfg.PayloadLoader,
|
||||
store: cfg.Store,
|
||||
clock: cfg.Clock,
|
||||
telemetry: cfg.Telemetry,
|
||||
tracerProvider: tracerProvider,
|
||||
logger: logger.With("component", "execute_attempt"),
|
||||
attemptTimeout: cfg.AttemptTimeout,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare renders one template-mode queued delivery when its content has not
|
||||
// been materialized yet. The boolean result reports whether the scheduler may
|
||||
// proceed to claim the attempt.
|
||||
func (service *Service) Prepare(ctx context.Context, item WorkItem) (bool, error) {
|
||||
if ctx == nil {
|
||||
return false, errors.New("prepare execute attempt: nil context")
|
||||
}
|
||||
if service == nil {
|
||||
return false, errors.New("prepare execute attempt: nil service")
|
||||
}
|
||||
if err := item.ValidateForPreparation(); err != nil {
|
||||
return false, fmt.Errorf("prepare execute attempt: %w", err)
|
||||
}
|
||||
if item.Delivery.PayloadMode != deliverydomain.PayloadModeTemplate {
|
||||
return true, nil
|
||||
}
|
||||
if item.Delivery.Status == deliverydomain.StatusRendered {
|
||||
return true, nil
|
||||
}
|
||||
if err := item.Delivery.Content.ValidateMaterialized(); err == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
result, err := service.renderer.Execute(ctx, renderdelivery.Input{
|
||||
Delivery: item.Delivery,
|
||||
Attempt: item.Attempt,
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("prepare execute attempt: %w", err)
|
||||
}
|
||||
if result.Outcome == renderdelivery.OutcomeFailed {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Execute runs one claimed in-progress attempt through the provider and
|
||||
// durably records the resulting outcome.
|
||||
func (service *Service) Execute(ctx context.Context, item WorkItem) error {
|
||||
if ctx == nil {
|
||||
return errors.New("execute attempt: nil context")
|
||||
}
|
||||
if service == nil {
|
||||
return errors.New("execute attempt: nil service")
|
||||
}
|
||||
if err := item.ValidateForExecution(); err != nil {
|
||||
return fmt.Errorf("execute attempt: %w", err)
|
||||
}
|
||||
|
||||
message, err := service.buildMessage(ctx, item.Delivery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sendStartedAt := time.Now()
|
||||
sendCtx, span := service.tracerProvider.Tracer(tracerName).Start(
|
||||
ctx,
|
||||
"mail.provider_send",
|
||||
oteltrace.WithAttributes(
|
||||
attribute.String("mail.delivery_id", item.Delivery.DeliveryID.String()),
|
||||
attribute.String("mail.source", string(item.Delivery.Source)),
|
||||
attribute.Int("mail.attempt_no", item.Attempt.AttemptNo),
|
||||
),
|
||||
)
|
||||
if !item.Delivery.TemplateID.IsZero() {
|
||||
span.SetAttributes(attribute.String("mail.template_id", item.Delivery.TemplateID.String()))
|
||||
}
|
||||
providerCtx, cancel := context.WithTimeout(sendCtx, service.attemptTimeout)
|
||||
defer cancel()
|
||||
defer span.End()
|
||||
|
||||
result, err := service.provider.Send(providerCtx, message)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return fmt.Errorf("execute attempt: send provider message: %w", err)
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
span.RecordError(err)
|
||||
return fmt.Errorf("execute attempt: provider result: %w", err)
|
||||
}
|
||||
providerName := providerNameFromSummary(result.Summary)
|
||||
sendDuration := time.Since(sendStartedAt)
|
||||
service.recordProviderSendDuration(sendCtx, providerName, string(result.Classification), sendDuration)
|
||||
span.SetAttributes(
|
||||
attribute.String("mail.provider", providerName),
|
||||
attribute.String("mail.provider_outcome", string(result.Classification)),
|
||||
attribute.String("mail.provider_summary", result.Summary),
|
||||
)
|
||||
|
||||
commit, err := service.commitForProviderResult(item, result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.store.Commit(ctx, commit); err != nil {
|
||||
return fmt.Errorf("%w: commit attempt outcome: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
service.recordCommitMetrics(sendCtx, commit, item.Delivery.Source)
|
||||
service.logProviderResult(sendCtx, item, result, commit, providerName, sendDuration)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecoverExpired marks one stale in-progress attempt as expired and applies
|
||||
// the same retry policy used for runtime timeouts.
|
||||
func (service *Service) RecoverExpired(ctx context.Context, item WorkItem) error {
|
||||
if ctx == nil {
|
||||
return errors.New("recover expired attempt: nil context")
|
||||
}
|
||||
if service == nil {
|
||||
return errors.New("recover expired attempt: nil service")
|
||||
}
|
||||
if err := item.ValidateForExecution(); err != nil {
|
||||
return fmt.Errorf("recover expired attempt: %w", err)
|
||||
}
|
||||
|
||||
commit, err := service.commitForTimeout(item, claimTTLClassification, claimTTLSummary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.store.Commit(ctx, commit); err != nil {
|
||||
return fmt.Errorf("%w: commit recovered attempt outcome: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
service.recordCommitMetrics(ctx, commit, item.Delivery.Source)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) buildMessage(ctx context.Context, deliveryRecord deliverydomain.Delivery) (ports.Message, error) {
|
||||
message := ports.Message{
|
||||
Envelope: deliveryRecord.Envelope,
|
||||
Content: deliveryRecord.Content,
|
||||
}
|
||||
if err := message.Content.ValidateMaterialized(); err != nil {
|
||||
return ports.Message{}, fmt.Errorf("execute attempt: delivery content: %w", err)
|
||||
}
|
||||
if len(deliveryRecord.Attachments) == 0 {
|
||||
if err := message.Validate(); err != nil {
|
||||
return ports.Message{}, fmt.Errorf("execute attempt: provider message: %w", err)
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
payload, found, err := service.payloadLoader.LoadPayload(ctx, deliveryRecord.DeliveryID)
|
||||
if err != nil {
|
||||
return ports.Message{}, fmt.Errorf("%w: load delivery payload: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
if !found {
|
||||
return ports.Message{}, fmt.Errorf("%w: delivery payload %q is missing", ErrServiceUnavailable, deliveryRecord.DeliveryID)
|
||||
}
|
||||
if len(payload.Attachments) != len(deliveryRecord.Attachments) {
|
||||
return ports.Message{}, fmt.Errorf(
|
||||
"%w: delivery payload attachment count %d mismatches delivery attachment count %d",
|
||||
ErrServiceUnavailable,
|
||||
len(payload.Attachments),
|
||||
len(deliveryRecord.Attachments),
|
||||
)
|
||||
}
|
||||
|
||||
message.Attachments = make([]ports.Attachment, len(payload.Attachments))
|
||||
for index, attachmentPayload := range payload.Attachments {
|
||||
metadata := deliveryRecord.Attachments[index]
|
||||
if metadata.Filename != attachmentPayload.Filename ||
|
||||
metadata.ContentType != attachmentPayload.ContentType ||
|
||||
metadata.SizeBytes != attachmentPayload.SizeBytes {
|
||||
return ports.Message{}, fmt.Errorf(
|
||||
"%w: delivery payload attachment %d metadata mismatches delivery audit metadata",
|
||||
ErrServiceUnavailable,
|
||||
index,
|
||||
)
|
||||
}
|
||||
|
||||
content, err := base64.StdEncoding.DecodeString(attachmentPayload.ContentBase64)
|
||||
if err != nil {
|
||||
return ports.Message{}, fmt.Errorf(
|
||||
"%w: decode delivery payload attachment %d: %v",
|
||||
ErrServiceUnavailable,
|
||||
index,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
message.Attachments[index] = ports.Attachment{
|
||||
Metadata: metadata,
|
||||
Content: content,
|
||||
}
|
||||
}
|
||||
if err := message.Validate(); err != nil {
|
||||
return ports.Message{}, fmt.Errorf("execute attempt: provider message: %w", err)
|
||||
}
|
||||
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func (service *Service) commitForProviderResult(item WorkItem, result ports.Result) (CommitStateInput, error) {
|
||||
switch result.Classification {
|
||||
case ports.ClassificationAccepted:
|
||||
return service.commitTerminal(item, attempt.StatusProviderAccepted, deliverydomain.StatusSent, result.Summary, "")
|
||||
case ports.ClassificationSuppressed:
|
||||
return service.commitTerminal(item, attempt.StatusProviderRejected, deliverydomain.StatusSuppressed, result.Summary, "suppressed")
|
||||
case ports.ClassificationPermanentFailure:
|
||||
return service.commitTerminal(item, attempt.StatusProviderRejected, deliverydomain.StatusFailed, result.Summary, "permanent_failure")
|
||||
case ports.ClassificationTransientFailure:
|
||||
classification := attempt.StatusTransportFailed
|
||||
providerClassification := "transient_failure"
|
||||
if result.Details["error"] == deadlineExceededDetail {
|
||||
classification = attempt.StatusTimedOut
|
||||
providerClassification = deadlineExceededDetail
|
||||
}
|
||||
return service.commitForRetryableResult(item, classification, providerClassification, result.Summary)
|
||||
default:
|
||||
return CommitStateInput{}, fmt.Errorf("execute attempt: unsupported provider classification %q", result.Classification)
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) commitForTimeout(item WorkItem, providerClassification string, providerSummary string) (CommitStateInput, error) {
|
||||
return service.commitForRetryableResult(item, attempt.StatusTimedOut, providerClassification, providerSummary)
|
||||
}
|
||||
|
||||
func (service *Service) commitForRetryableResult(
|
||||
item WorkItem,
|
||||
attemptStatus attempt.Status,
|
||||
providerClassification string,
|
||||
providerSummary string,
|
||||
) (CommitStateInput, error) {
|
||||
finishedAt := normalizedFinishedAt(service.clock.Now(), item.Attempt)
|
||||
|
||||
currentAttempt := item.Attempt
|
||||
currentAttempt.Status = attemptStatus
|
||||
currentAttempt.FinishedAt = ptrTime(finishedAt)
|
||||
currentAttempt.ProviderClassification = providerClassification
|
||||
currentAttempt.ProviderSummary = providerSummary
|
||||
if err := currentAttempt.Validate(); err != nil {
|
||||
return CommitStateInput{}, fmt.Errorf("execute attempt: build terminal attempt: %w", err)
|
||||
}
|
||||
|
||||
nextDelay, ok := retryDelayForAttempt(currentAttempt.AttemptNo)
|
||||
if ok {
|
||||
nextScheduledFor := finishedAt.Add(nextDelay)
|
||||
nextAttempt := attempt.Attempt{
|
||||
DeliveryID: item.Delivery.DeliveryID,
|
||||
AttemptNo: currentAttempt.AttemptNo + 1,
|
||||
ScheduledFor: nextScheduledFor,
|
||||
Status: attempt.StatusScheduled,
|
||||
}
|
||||
if err := nextAttempt.Validate(); err != nil {
|
||||
return CommitStateInput{}, fmt.Errorf("execute attempt: build next attempt: %w", err)
|
||||
}
|
||||
|
||||
deliveryRecord := item.Delivery
|
||||
deliveryRecord.Status = deliverydomain.StatusQueued
|
||||
deliveryRecord.AttemptCount = nextAttempt.AttemptNo
|
||||
deliveryRecord.LastAttemptStatus = currentAttempt.Status
|
||||
deliveryRecord.ProviderSummary = providerSummary
|
||||
deliveryRecord.UpdatedAt = finishedAt
|
||||
if err := deliveryRecord.Validate(); err != nil {
|
||||
return CommitStateInput{}, fmt.Errorf("execute attempt: build queued delivery: %w", err)
|
||||
}
|
||||
|
||||
input := CommitStateInput{
|
||||
Delivery: deliveryRecord,
|
||||
Attempt: currentAttempt,
|
||||
NextAttempt: &nextAttempt,
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return CommitStateInput{}, fmt.Errorf("execute attempt: build queued commit: %w", err)
|
||||
}
|
||||
|
||||
return input, nil
|
||||
}
|
||||
|
||||
deliveryRecord := item.Delivery
|
||||
deliveryRecord.Status = deliverydomain.StatusDeadLetter
|
||||
deliveryRecord.LastAttemptStatus = currentAttempt.Status
|
||||
deliveryRecord.ProviderSummary = providerSummary
|
||||
deliveryRecord.UpdatedAt = finishedAt
|
||||
deliveryRecord.DeadLetteredAt = ptrTime(finishedAt)
|
||||
if err := deliveryRecord.Validate(); err != nil {
|
||||
return CommitStateInput{}, fmt.Errorf("execute attempt: build dead-letter delivery: %w", err)
|
||||
}
|
||||
|
||||
deadLetter := &deliverydomain.DeadLetterEntry{
|
||||
DeliveryID: deliveryRecord.DeliveryID,
|
||||
FinalAttemptNo: currentAttempt.AttemptNo,
|
||||
FailureClassification: retryExhaustedClassification,
|
||||
ProviderSummary: providerSummary,
|
||||
CreatedAt: finishedAt,
|
||||
RecoveryHint: retryRecoveryHint,
|
||||
}
|
||||
|
||||
input := CommitStateInput{
|
||||
Delivery: deliveryRecord,
|
||||
Attempt: currentAttempt,
|
||||
DeadLetter: deadLetter,
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return CommitStateInput{}, fmt.Errorf("execute attempt: build dead-letter commit: %w", err)
|
||||
}
|
||||
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func (service *Service) commitTerminal(
|
||||
item WorkItem,
|
||||
attemptStatus attempt.Status,
|
||||
deliveryStatus deliverydomain.Status,
|
||||
providerSummary string,
|
||||
providerClassification string,
|
||||
) (CommitStateInput, error) {
|
||||
finishedAt := normalizedFinishedAt(service.clock.Now(), item.Attempt)
|
||||
|
||||
currentAttempt := item.Attempt
|
||||
currentAttempt.Status = attemptStatus
|
||||
currentAttempt.FinishedAt = ptrTime(finishedAt)
|
||||
currentAttempt.ProviderClassification = providerClassification
|
||||
currentAttempt.ProviderSummary = providerSummary
|
||||
if err := currentAttempt.Validate(); err != nil {
|
||||
return CommitStateInput{}, fmt.Errorf("execute attempt: build terminal attempt: %w", err)
|
||||
}
|
||||
|
||||
deliveryRecord := item.Delivery
|
||||
deliveryRecord.Status = deliveryStatus
|
||||
deliveryRecord.LastAttemptStatus = currentAttempt.Status
|
||||
deliveryRecord.ProviderSummary = providerSummary
|
||||
deliveryRecord.UpdatedAt = finishedAt
|
||||
switch deliveryStatus {
|
||||
case deliverydomain.StatusSent:
|
||||
deliveryRecord.SentAt = ptrTime(finishedAt)
|
||||
case deliverydomain.StatusSuppressed:
|
||||
deliveryRecord.SuppressedAt = ptrTime(finishedAt)
|
||||
case deliverydomain.StatusFailed:
|
||||
deliveryRecord.FailedAt = ptrTime(finishedAt)
|
||||
}
|
||||
if err := deliveryRecord.Validate(); err != nil {
|
||||
return CommitStateInput{}, fmt.Errorf("execute attempt: build terminal delivery: %w", err)
|
||||
}
|
||||
|
||||
input := CommitStateInput{
|
||||
Delivery: deliveryRecord,
|
||||
Attempt: currentAttempt,
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return CommitStateInput{}, fmt.Errorf("execute attempt: build terminal commit: %w", err)
|
||||
}
|
||||
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func retryDelayForAttempt(attemptNo int) (time.Duration, bool) {
|
||||
if attemptNo < 1 || attemptNo > len(retryDelays) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return retryDelays[attemptNo-1], true
|
||||
}
|
||||
|
||||
func normalizedFinishedAt(now time.Time, record attempt.Attempt) time.Time {
|
||||
finishedAt := now.UTC().Truncate(time.Millisecond)
|
||||
if record.StartedAt != nil && finishedAt.Before(*record.StartedAt) {
|
||||
return *record.StartedAt
|
||||
}
|
||||
|
||||
return finishedAt
|
||||
}
|
||||
|
||||
func ptrTime(value time.Time) *time.Time {
|
||||
return &value
|
||||
}
|
||||
|
||||
func (service *Service) recordCommitMetrics(ctx context.Context, commit CommitStateInput, source deliverydomain.Source) {
|
||||
if service == nil || service.telemetry == nil {
|
||||
return
|
||||
}
|
||||
|
||||
service.telemetry.RecordDeliveryStatusTransition(ctx, string(commit.Delivery.Status), string(source))
|
||||
service.telemetry.RecordAttemptOutcome(ctx, string(commit.Attempt.Status), string(source))
|
||||
}
|
||||
|
||||
func (service *Service) recordProviderSendDuration(ctx context.Context, provider string, outcome string, duration time.Duration) {
|
||||
if service == nil || service.telemetry == nil {
|
||||
return
|
||||
}
|
||||
|
||||
service.telemetry.RecordProviderSendDuration(ctx, provider, outcome, duration)
|
||||
}
|
||||
|
||||
func (service *Service) logProviderResult(
|
||||
ctx context.Context,
|
||||
item WorkItem,
|
||||
result ports.Result,
|
||||
commit CommitStateInput,
|
||||
providerName string,
|
||||
sendDuration time.Duration,
|
||||
) {
|
||||
logArgs := logging.DeliveryAttemptAttrs(item.Delivery, item.Attempt)
|
||||
logArgs = append(logArgs,
|
||||
"provider", providerName,
|
||||
"provider_outcome", string(result.Classification),
|
||||
"provider_summary", result.Summary,
|
||||
"delivery_status", string(commit.Delivery.Status),
|
||||
"attempt_status", string(commit.Attempt.Status),
|
||||
"duration_ms", float64(sendDuration.Microseconds())/1000,
|
||||
)
|
||||
logArgs = append(logArgs, logging.TraceAttrsFromContext(ctx)...)
|
||||
service.logger.Info("provider send completed", logArgs...)
|
||||
|
||||
if commit.NextAttempt != nil {
|
||||
retryArgs := logging.DeliveryAttemptAttrs(item.Delivery, item.Attempt)
|
||||
retryArgs = append(retryArgs,
|
||||
"next_attempt_no", commit.NextAttempt.AttemptNo,
|
||||
"next_scheduled_for", commit.NextAttempt.ScheduledFor,
|
||||
"provider_summary", result.Summary,
|
||||
)
|
||||
retryArgs = append(retryArgs, logging.TraceAttrsFromContext(ctx)...)
|
||||
service.logger.Info("delivery retry scheduled", retryArgs...)
|
||||
}
|
||||
|
||||
if commit.DeadLetter != nil {
|
||||
deadLetterArgs := logging.DeliveryAttemptAttrs(item.Delivery, item.Attempt)
|
||||
deadLetterArgs = append(deadLetterArgs,
|
||||
"failure_classification", commit.DeadLetter.FailureClassification,
|
||||
"recovery_hint", commit.DeadLetter.RecoveryHint,
|
||||
"provider_summary", commit.DeadLetter.ProviderSummary,
|
||||
)
|
||||
deadLetterArgs = append(deadLetterArgs, logging.TraceAttrsFromContext(ctx)...)
|
||||
service.logger.Warn("delivery moved to dead letter", deadLetterArgs...)
|
||||
}
|
||||
}
|
||||
|
||||
func providerNameFromSummary(summary string) string {
|
||||
for _, token := range strings.Split(strings.TrimSpace(summary), " ") {
|
||||
key, value, ok := strings.Cut(token, "=")
|
||||
if ok && key == "provider" && strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
package executeattempt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/ports"
|
||||
"galaxy/mail/internal/service/acceptgenericdelivery"
|
||||
"galaxy/mail/internal/service/renderdelivery"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
)
|
||||
|
||||
func TestServicePrepareRendersQueuedTemplateDelivery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
renderedDelivery := queuedTemplateWorkItem(t).Delivery
|
||||
renderedDelivery.Status = deliverydomain.StatusRendered
|
||||
renderedDelivery.Content = deliverydomain.Content{
|
||||
Subject: "Turn 54",
|
||||
TextBody: "Hello Pilot",
|
||||
}
|
||||
renderedDelivery.UpdatedAt = renderedDelivery.CreatedAt.Add(time.Minute)
|
||||
require.NoError(t, renderedDelivery.Validate())
|
||||
|
||||
renderer := &stubRenderer{
|
||||
result: renderdelivery.Result{
|
||||
Outcome: renderdelivery.OutcomeRendered,
|
||||
Delivery: renderedDelivery,
|
||||
ResolvedLocale: common.Locale("en"),
|
||||
TemplateVersion: "sha256:template",
|
||||
LocaleFallbackUsed: false,
|
||||
},
|
||||
}
|
||||
|
||||
service := newTestService(t, Config{
|
||||
Renderer: renderer,
|
||||
Provider: stubProvider{},
|
||||
PayloadLoader: stubPayloadLoader{},
|
||||
Store: &stubStore{},
|
||||
Clock: stubClock{now: renderedDelivery.UpdatedAt},
|
||||
AttemptTimeout: 15 * time.Second,
|
||||
})
|
||||
|
||||
ready, err := service.Prepare(context.Background(), queuedTemplateWorkItem(t))
|
||||
require.NoError(t, err)
|
||||
require.True(t, ready)
|
||||
require.Len(t, renderer.inputs, 1)
|
||||
}
|
||||
|
||||
func TestServiceExecuteAcceptedRenderedDelivery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &stubStore{}
|
||||
service := newTestService(t, Config{
|
||||
Renderer: &stubRenderer{},
|
||||
Provider: stubProvider{
|
||||
result: ports.Result{
|
||||
Classification: ports.ClassificationAccepted,
|
||||
Summary: "provider=smtp result=accepted",
|
||||
},
|
||||
},
|
||||
PayloadLoader: stubPayloadLoader{},
|
||||
Store: store,
|
||||
Clock: stubClock{now: fixedNow().Add(time.Minute)},
|
||||
AttemptTimeout: 15 * time.Second,
|
||||
})
|
||||
|
||||
err := service.Execute(context.Background(), renderedWorkItem(t, 1))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, store.inputs, 1)
|
||||
require.Equal(t, deliverydomain.StatusSent, store.inputs[0].Delivery.Status)
|
||||
require.Equal(t, attempt.StatusProviderAccepted, store.inputs[0].Attempt.Status)
|
||||
require.Nil(t, store.inputs[0].NextAttempt)
|
||||
require.Nil(t, store.inputs[0].DeadLetter)
|
||||
}
|
||||
|
||||
func TestServiceExecuteMapsSuppressedToProviderRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &stubStore{}
|
||||
service := newTestService(t, Config{
|
||||
Renderer: &stubRenderer{},
|
||||
Provider: stubProvider{
|
||||
result: ports.Result{
|
||||
Classification: ports.ClassificationSuppressed,
|
||||
Summary: "provider=stub result=suppressed script=policy_skip",
|
||||
},
|
||||
},
|
||||
PayloadLoader: stubPayloadLoader{},
|
||||
Store: store,
|
||||
Clock: stubClock{now: fixedNow().Add(time.Minute)},
|
||||
AttemptTimeout: 15 * time.Second,
|
||||
})
|
||||
|
||||
err := service.Execute(context.Background(), renderedWorkItem(t, 1))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, store.inputs, 1)
|
||||
require.Equal(t, deliverydomain.StatusSuppressed, store.inputs[0].Delivery.Status)
|
||||
require.Equal(t, attempt.StatusProviderRejected, store.inputs[0].Attempt.Status)
|
||||
}
|
||||
|
||||
func TestServiceExecuteMapsPermanentFailureToFailed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &stubStore{}
|
||||
service := newTestService(t, Config{
|
||||
Renderer: &stubRenderer{},
|
||||
Provider: stubProvider{
|
||||
result: ports.Result{
|
||||
Classification: ports.ClassificationPermanentFailure,
|
||||
Summary: "provider=smtp result=permanent_failure phase=data smtp_code=550",
|
||||
},
|
||||
},
|
||||
PayloadLoader: stubPayloadLoader{},
|
||||
Store: store,
|
||||
Clock: stubClock{now: fixedNow().Add(time.Minute)},
|
||||
AttemptTimeout: 15 * time.Second,
|
||||
})
|
||||
|
||||
err := service.Execute(context.Background(), renderedWorkItem(t, 1))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, store.inputs, 1)
|
||||
require.Equal(t, deliverydomain.StatusFailed, store.inputs[0].Delivery.Status)
|
||||
require.Equal(t, attempt.StatusProviderRejected, store.inputs[0].Attempt.Status)
|
||||
require.Nil(t, store.inputs[0].DeadLetter)
|
||||
}
|
||||
|
||||
func TestServiceExecuteBuildsRetryChainAndDeadLetter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
attemptNo int
|
||||
wantStatus deliverydomain.Status
|
||||
wantAttemptStatus attempt.Status
|
||||
wantNextAttemptNo int
|
||||
wantNextDelay time.Duration
|
||||
wantDeadLetterEntry bool
|
||||
}{
|
||||
{
|
||||
name: "attempt one schedules retry after one minute",
|
||||
attemptNo: 1,
|
||||
wantStatus: deliverydomain.StatusQueued,
|
||||
wantAttemptStatus: attempt.StatusTransportFailed,
|
||||
wantNextAttemptNo: 2,
|
||||
wantNextDelay: time.Minute,
|
||||
},
|
||||
{
|
||||
name: "attempt two schedules retry after five minutes",
|
||||
attemptNo: 2,
|
||||
wantStatus: deliverydomain.StatusQueued,
|
||||
wantAttemptStatus: attempt.StatusTransportFailed,
|
||||
wantNextAttemptNo: 3,
|
||||
wantNextDelay: 5 * time.Minute,
|
||||
},
|
||||
{
|
||||
name: "attempt three schedules retry after thirty minutes",
|
||||
attemptNo: 3,
|
||||
wantStatus: deliverydomain.StatusQueued,
|
||||
wantAttemptStatus: attempt.StatusTransportFailed,
|
||||
wantNextAttemptNo: 4,
|
||||
wantNextDelay: 30 * time.Minute,
|
||||
},
|
||||
{
|
||||
name: "attempt four becomes dead letter",
|
||||
attemptNo: 4,
|
||||
wantStatus: deliverydomain.StatusDeadLetter,
|
||||
wantAttemptStatus: attempt.StatusTransportFailed,
|
||||
wantDeadLetterEntry: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &stubStore{}
|
||||
service := newTestService(t, Config{
|
||||
Renderer: &stubRenderer{},
|
||||
Provider: stubProvider{
|
||||
result: ports.Result{
|
||||
Classification: ports.ClassificationTransientFailure,
|
||||
Summary: "provider=smtp result=transient_failure phase=data smtp_code=451",
|
||||
Details: map[string]string{
|
||||
"phase": "data",
|
||||
},
|
||||
},
|
||||
},
|
||||
PayloadLoader: stubPayloadLoader{},
|
||||
Store: store,
|
||||
Clock: stubClock{now: fixedNow().Add(time.Minute)},
|
||||
AttemptTimeout: 15 * time.Second,
|
||||
})
|
||||
|
||||
workItem := renderedWorkItem(t, tt.attemptNo)
|
||||
err := service.Execute(context.Background(), workItem)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, store.inputs, 1)
|
||||
|
||||
input := store.inputs[0]
|
||||
require.Equal(t, tt.wantStatus, input.Delivery.Status)
|
||||
require.Equal(t, tt.wantAttemptStatus, input.Attempt.Status)
|
||||
|
||||
if tt.wantDeadLetterEntry {
|
||||
require.NotNil(t, input.DeadLetter)
|
||||
require.Nil(t, input.NextAttempt)
|
||||
require.Equal(t, "retry_exhausted", input.DeadLetter.FailureClassification)
|
||||
return
|
||||
}
|
||||
|
||||
require.NotNil(t, input.NextAttempt)
|
||||
require.Nil(t, input.DeadLetter)
|
||||
require.Equal(t, tt.wantNextAttemptNo, input.NextAttempt.AttemptNo)
|
||||
require.Equal(t, input.Attempt.FinishedAt.Add(tt.wantNextDelay), input.NextAttempt.ScheduledFor)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceExecuteClassifiesDeadlineExceededAsTimedOut(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &stubStore{}
|
||||
service := newTestService(t, Config{
|
||||
Renderer: &stubRenderer{},
|
||||
Provider: stubProvider{
|
||||
result: ports.Result{
|
||||
Classification: ports.ClassificationTransientFailure,
|
||||
Summary: "provider=smtp result=transient_failure phase=context",
|
||||
Details: map[string]string{
|
||||
"error": "deadline_exceeded",
|
||||
},
|
||||
},
|
||||
},
|
||||
PayloadLoader: stubPayloadLoader{},
|
||||
Store: store,
|
||||
Clock: stubClock{now: fixedNow().Add(time.Minute)},
|
||||
AttemptTimeout: 15 * time.Second,
|
||||
})
|
||||
|
||||
err := service.Execute(context.Background(), renderedWorkItem(t, 1))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, store.inputs, 1)
|
||||
require.Equal(t, attempt.StatusTimedOut, store.inputs[0].Attempt.Status)
|
||||
require.Equal(t, "deadline_exceeded", store.inputs[0].Attempt.ProviderClassification)
|
||||
}
|
||||
|
||||
func TestServiceRecoverExpiredSchedulesTimedOutRetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &stubStore{}
|
||||
service := newTestService(t, Config{
|
||||
Renderer: &stubRenderer{},
|
||||
Provider: stubProvider{},
|
||||
PayloadLoader: stubPayloadLoader{},
|
||||
Store: store,
|
||||
Clock: stubClock{now: fixedNow().Add(time.Minute)},
|
||||
AttemptTimeout: 15 * time.Second,
|
||||
})
|
||||
|
||||
err := service.RecoverExpired(context.Background(), renderedWorkItem(t, 1))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, store.inputs, 1)
|
||||
require.Equal(t, attempt.StatusTimedOut, store.inputs[0].Attempt.Status)
|
||||
require.Equal(t, "claim_ttl_expired", store.inputs[0].Attempt.ProviderClassification)
|
||||
require.Equal(t, "attempt claim TTL expired", store.inputs[0].Attempt.ProviderSummary)
|
||||
require.NotNil(t, store.inputs[0].NextAttempt)
|
||||
}
|
||||
|
||||
func TestServiceExecuteRecordsMetricsAndLogsProviderResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &stubStore{}
|
||||
telemetry := &stubTelemetry{}
|
||||
loggerBuffer := &bytes.Buffer{}
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
||||
|
||||
service := newTestService(t, Config{
|
||||
Renderer: &stubRenderer{},
|
||||
Provider: stubProvider{
|
||||
result: ports.Result{
|
||||
Classification: ports.ClassificationAccepted,
|
||||
Summary: "provider=smtp result=accepted",
|
||||
},
|
||||
},
|
||||
PayloadLoader: stubPayloadLoader{},
|
||||
Store: store,
|
||||
Clock: stubClock{now: fixedNow().Add(time.Minute)},
|
||||
Telemetry: telemetry,
|
||||
TracerProvider: tracerProvider,
|
||||
Logger: slog.New(slog.NewJSONHandler(loggerBuffer, nil)),
|
||||
AttemptTimeout: 15 * time.Second,
|
||||
})
|
||||
|
||||
err := service.Execute(context.Background(), sendingTemplateWorkItem(t, 1))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"notification:sent"}, telemetry.statuses)
|
||||
require.Equal(t, []string{"notification:provider_accepted"}, telemetry.attempts)
|
||||
require.Equal(t, []string{"smtp:accepted"}, telemetry.providerDurations)
|
||||
require.Contains(t, loggerBuffer.String(), "\"delivery_id\":\"delivery-template-sending\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"source\":\"notification\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"template_id\":\"game.turn_ready\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"attempt_no\":1")
|
||||
require.Contains(t, loggerBuffer.String(), "\"otel_trace_id\":")
|
||||
require.True(t, hasExecuteSpanNamed(recorder.Ended(), "mail.provider_send"))
|
||||
}
|
||||
|
||||
func TestServiceExecuteReturnsServiceUnavailableOnMissingPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service := newTestService(t, Config{
|
||||
Renderer: &stubRenderer{},
|
||||
Provider: stubProvider{
|
||||
result: ports.Result{
|
||||
Classification: ports.ClassificationAccepted,
|
||||
Summary: "provider=smtp result=accepted",
|
||||
},
|
||||
},
|
||||
PayloadLoader: stubPayloadLoader{},
|
||||
Store: &stubStore{},
|
||||
Clock: stubClock{now: fixedNow().Add(time.Minute)},
|
||||
AttemptTimeout: 15 * time.Second,
|
||||
})
|
||||
|
||||
workItem := renderedWorkItem(t, 1)
|
||||
workItem.Delivery.Attachments = []common.AttachmentMetadata{
|
||||
{Filename: "guide.txt", ContentType: "text/plain; charset=utf-8", SizeBytes: int64(len([]byte("read me")))},
|
||||
}
|
||||
require.NoError(t, workItem.Delivery.Validate())
|
||||
|
||||
err := service.Execute(context.Background(), workItem)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrServiceUnavailable)
|
||||
}
|
||||
|
||||
type stubRenderer struct {
|
||||
result renderdelivery.Result
|
||||
err error
|
||||
inputs []renderdelivery.Input
|
||||
}
|
||||
|
||||
func (renderer *stubRenderer) Execute(_ context.Context, input renderdelivery.Input) (renderdelivery.Result, error) {
|
||||
renderer.inputs = append(renderer.inputs, input)
|
||||
return renderer.result, renderer.err
|
||||
}
|
||||
|
||||
type stubProvider struct {
|
||||
result ports.Result
|
||||
err error
|
||||
inputs []ports.Message
|
||||
}
|
||||
|
||||
func (provider stubProvider) Send(_ context.Context, message ports.Message) (ports.Result, error) {
|
||||
provider.inputs = append(provider.inputs, message)
|
||||
return provider.result, provider.err
|
||||
}
|
||||
|
||||
func (provider stubProvider) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type stubPayloadLoader struct {
|
||||
payload acceptgenericdelivery.DeliveryPayload
|
||||
found bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (loader stubPayloadLoader) LoadPayload(context.Context, common.DeliveryID) (acceptgenericdelivery.DeliveryPayload, bool, error) {
|
||||
return loader.payload, loader.found, loader.err
|
||||
}
|
||||
|
||||
type stubStore struct {
|
||||
inputs []CommitStateInput
|
||||
err error
|
||||
}
|
||||
|
||||
func (store *stubStore) Commit(_ context.Context, input CommitStateInput) error {
|
||||
store.inputs = append(store.inputs, input)
|
||||
return store.err
|
||||
}
|
||||
|
||||
type stubClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock stubClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
|
||||
type stubTelemetry struct {
|
||||
statuses []string
|
||||
attempts []string
|
||||
providerDurations []string
|
||||
}
|
||||
|
||||
func (telemetry *stubTelemetry) RecordDeliveryStatusTransition(_ context.Context, status string, source string) {
|
||||
telemetry.statuses = append(telemetry.statuses, source+":"+status)
|
||||
}
|
||||
|
||||
func (telemetry *stubTelemetry) RecordAttemptOutcome(_ context.Context, status string, source string) {
|
||||
telemetry.attempts = append(telemetry.attempts, source+":"+status)
|
||||
}
|
||||
|
||||
func (telemetry *stubTelemetry) RecordProviderSendDuration(_ context.Context, provider string, outcome string, _ time.Duration) {
|
||||
telemetry.providerDurations = append(telemetry.providerDurations, provider+":"+outcome)
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T, cfg Config) *Service {
|
||||
t.Helper()
|
||||
|
||||
service, err := New(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func queuedTemplateWorkItem(t *testing.T) WorkItem {
|
||||
t.Helper()
|
||||
|
||||
createdAt := fixedNow().Add(-time.Minute)
|
||||
deliveryRecord := deliverydomain.Delivery{
|
||||
DeliveryID: common.DeliveryID("delivery-template"),
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeTemplate,
|
||||
TemplateID: common.TemplateID("game.turn_ready"),
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: []common.Email{common.Email("pilot@example.com")},
|
||||
},
|
||||
Locale: common.Locale("en"),
|
||||
TemplateVariables: map[string]any{
|
||||
"player": map[string]any{
|
||||
"name": "Pilot",
|
||||
},
|
||||
"turn_number": float64(54),
|
||||
},
|
||||
IdempotencyKey: common.IdempotencyKey("notification:delivery-template"),
|
||||
Status: deliverydomain.StatusQueued,
|
||||
AttemptCount: 1,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}
|
||||
require.NoError(t, deliveryRecord.Validate())
|
||||
|
||||
attemptRecord := attempt.Attempt{
|
||||
DeliveryID: deliveryRecord.DeliveryID,
|
||||
AttemptNo: 1,
|
||||
ScheduledFor: createdAt,
|
||||
Status: attempt.StatusScheduled,
|
||||
}
|
||||
require.NoError(t, attemptRecord.Validate())
|
||||
|
||||
return WorkItem{
|
||||
Delivery: deliveryRecord,
|
||||
Attempt: attemptRecord,
|
||||
}
|
||||
}
|
||||
|
||||
func renderedWorkItem(t *testing.T, attemptNo int) WorkItem {
|
||||
t.Helper()
|
||||
|
||||
createdAt := fixedNow().Add(-time.Duration(attemptNo) * time.Minute)
|
||||
deliveryRecord := deliverydomain.Delivery{
|
||||
DeliveryID: common.DeliveryID("delivery-rendered"),
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeRendered,
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: []common.Email{common.Email("pilot@example.com")},
|
||||
},
|
||||
Content: deliverydomain.Content{
|
||||
Subject: "Turn ready",
|
||||
TextBody: "Turn 54 is ready.",
|
||||
},
|
||||
IdempotencyKey: common.IdempotencyKey("notification:delivery-rendered"),
|
||||
Status: deliverydomain.StatusSending,
|
||||
AttemptCount: attemptNo,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt.Add(time.Second),
|
||||
}
|
||||
require.NoError(t, deliveryRecord.Validate())
|
||||
|
||||
scheduledFor := createdAt
|
||||
startedAt := scheduledFor.Add(5 * time.Second)
|
||||
attemptRecord := attempt.Attempt{
|
||||
DeliveryID: deliveryRecord.DeliveryID,
|
||||
AttemptNo: attemptNo,
|
||||
ScheduledFor: scheduledFor,
|
||||
StartedAt: &startedAt,
|
||||
Status: attempt.StatusInProgress,
|
||||
}
|
||||
require.NoError(t, attemptRecord.Validate())
|
||||
|
||||
return WorkItem{
|
||||
Delivery: deliveryRecord,
|
||||
Attempt: attemptRecord,
|
||||
}
|
||||
}
|
||||
|
||||
func sendingTemplateWorkItem(t *testing.T, attemptNo int) WorkItem {
|
||||
t.Helper()
|
||||
|
||||
createdAt := fixedNow().Add(-time.Duration(attemptNo) * time.Minute)
|
||||
deliveryRecord := deliverydomain.Delivery{
|
||||
DeliveryID: common.DeliveryID("delivery-template-sending"),
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeTemplate,
|
||||
TemplateID: common.TemplateID("game.turn_ready"),
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: []common.Email{common.Email("pilot@example.com")},
|
||||
},
|
||||
Content: deliverydomain.Content{
|
||||
Subject: "Turn ready",
|
||||
TextBody: "Turn 54 is ready.",
|
||||
},
|
||||
Locale: common.Locale("en"),
|
||||
TemplateVariables: map[string]any{
|
||||
"turn_number": float64(54),
|
||||
},
|
||||
IdempotencyKey: common.IdempotencyKey("notification:delivery-template-sending"),
|
||||
Status: deliverydomain.StatusSending,
|
||||
AttemptCount: attemptNo,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt.Add(time.Second),
|
||||
}
|
||||
require.NoError(t, deliveryRecord.Validate())
|
||||
|
||||
scheduledFor := createdAt
|
||||
startedAt := scheduledFor.Add(5 * time.Second)
|
||||
attemptRecord := attempt.Attempt{
|
||||
DeliveryID: deliveryRecord.DeliveryID,
|
||||
AttemptNo: attemptNo,
|
||||
ScheduledFor: scheduledFor,
|
||||
StartedAt: &startedAt,
|
||||
Status: attempt.StatusInProgress,
|
||||
}
|
||||
require.NoError(t, attemptRecord.Validate())
|
||||
|
||||
return WorkItem{
|
||||
Delivery: deliveryRecord,
|
||||
Attempt: attemptRecord,
|
||||
}
|
||||
}
|
||||
|
||||
func fixedNow() time.Time {
|
||||
return time.Unix(1_775_121_700, 0).UTC()
|
||||
}
|
||||
|
||||
var _ Renderer = (*stubRenderer)(nil)
|
||||
var _ ports.Provider = stubProvider{}
|
||||
var _ PayloadLoader = stubPayloadLoader{}
|
||||
var _ Store = (*stubStore)(nil)
|
||||
var _ Telemetry = (*stubTelemetry)(nil)
|
||||
|
||||
func hasExecuteSpanNamed(spans []sdktrace.ReadOnlySpan, name string) bool {
|
||||
for _, span := range spans {
|
||||
if span.Name() == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Package getdelivery implements trusted operator lookup of one accepted mail
|
||||
// delivery.
|
||||
package getdelivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotFound reports that the requested delivery does not exist.
|
||||
ErrNotFound = errors.New("get delivery not found")
|
||||
|
||||
// ErrServiceUnavailable reports that trusted lookup could not load durable
|
||||
// state safely.
|
||||
ErrServiceUnavailable = errors.New("get delivery service unavailable")
|
||||
)
|
||||
|
||||
// Input stores one exact trusted lookup by delivery identifier.
|
||||
type Input struct {
|
||||
// DeliveryID stores the exact accepted delivery identifier to resolve.
|
||||
DeliveryID common.DeliveryID
|
||||
}
|
||||
|
||||
// Validate reports whether input contains a complete lookup key.
|
||||
func (input Input) Validate() error {
|
||||
if err := input.DeliveryID.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery id: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Result stores one full delivery record and its optional dead-letter entry.
|
||||
type Result struct {
|
||||
// Delivery stores the resolved accepted delivery record.
|
||||
Delivery deliverydomain.Delivery
|
||||
|
||||
// DeadLetter stores the optional dead-letter entry when Delivery is in the
|
||||
// `dead_letter` terminal state.
|
||||
DeadLetter *deliverydomain.DeadLetterEntry
|
||||
}
|
||||
|
||||
// Validate reports whether result contains a consistent delivery view.
|
||||
func (result Result) Validate() error {
|
||||
if err := result.Delivery.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery: %w", err)
|
||||
}
|
||||
if err := deliverydomain.ValidateDeadLetterState(result.Delivery, result.DeadLetter); err != nil {
|
||||
return fmt.Errorf("dead-letter state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Store provides exact lookup of one accepted delivery and its dead-letter
|
||||
// entry.
|
||||
type Store interface {
|
||||
// GetDelivery loads one accepted delivery by its identifier.
|
||||
GetDelivery(context.Context, common.DeliveryID) (deliverydomain.Delivery, bool, error)
|
||||
|
||||
// GetDeadLetter loads the dead-letter entry associated with deliveryID when
|
||||
// one exists.
|
||||
GetDeadLetter(context.Context, common.DeliveryID) (deliverydomain.DeadLetterEntry, bool, error)
|
||||
}
|
||||
|
||||
// Config stores the dependencies used by Service.
|
||||
type Config struct {
|
||||
// Store owns durable delivery and dead-letter state.
|
||||
Store Store
|
||||
}
|
||||
|
||||
// Service executes trusted exact delivery lookups.
|
||||
type Service struct {
|
||||
store Store
|
||||
}
|
||||
|
||||
// New constructs Service from cfg.
|
||||
func New(cfg Config) (*Service, error) {
|
||||
if cfg.Store == nil {
|
||||
return nil, errors.New("new get delivery service: nil store")
|
||||
}
|
||||
|
||||
return &Service{store: cfg.Store}, nil
|
||||
}
|
||||
|
||||
// Execute loads one accepted delivery and its optional dead-letter entry.
|
||||
func (service *Service) Execute(ctx context.Context, input Input) (Result, error) {
|
||||
if ctx == nil {
|
||||
return Result{}, errors.New("execute get delivery: nil context")
|
||||
}
|
||||
if service == nil {
|
||||
return Result{}, errors.New("execute get delivery: nil service")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return Result{}, fmt.Errorf("execute get delivery: %w", err)
|
||||
}
|
||||
|
||||
record, found, err := service.store.GetDelivery(ctx, input.DeliveryID)
|
||||
switch {
|
||||
case err != nil:
|
||||
return Result{}, fmt.Errorf("%w: load delivery: %v", ErrServiceUnavailable, err)
|
||||
case !found:
|
||||
return Result{}, ErrNotFound
|
||||
}
|
||||
|
||||
result := Result{Delivery: record}
|
||||
if record.Status == deliverydomain.StatusDeadLetter {
|
||||
entry, found, err := service.store.GetDeadLetter(ctx, input.DeliveryID)
|
||||
switch {
|
||||
case err != nil:
|
||||
return Result{}, fmt.Errorf("%w: load dead-letter entry: %v", ErrServiceUnavailable, err)
|
||||
case !found:
|
||||
return Result{}, fmt.Errorf("%w: missing dead-letter entry for delivery %q", ErrServiceUnavailable, input.DeliveryID)
|
||||
default:
|
||||
result.DeadLetter = &entry
|
||||
}
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return Result{}, fmt.Errorf("%w: invalid result: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package getdelivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServiceExecuteReturnsDeliveryWithoutDeadLetter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &stubStore{
|
||||
delivery: ptrDelivery(validSentDelivery()),
|
||||
}
|
||||
service := newTestService(t, Config{Store: store})
|
||||
|
||||
result, err := service.Execute(context.Background(), Input{DeliveryID: store.delivery.DeliveryID})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, *store.delivery, result.Delivery)
|
||||
require.Nil(t, result.DeadLetter)
|
||||
}
|
||||
|
||||
func TestServiceExecuteReturnsDeadLetterEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := validDeadLetterDelivery()
|
||||
entry := validDeadLetterEntry(record.DeliveryID)
|
||||
store := &stubStore{
|
||||
delivery: &record,
|
||||
deadLetter: &entry,
|
||||
}
|
||||
service := newTestService(t, Config{Store: store})
|
||||
|
||||
result, err := service.Execute(context.Background(), Input{DeliveryID: record.DeliveryID})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, result.Delivery)
|
||||
require.NotNil(t, result.DeadLetter)
|
||||
require.Equal(t, entry, *result.DeadLetter)
|
||||
}
|
||||
|
||||
func TestServiceExecuteReturnsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service := newTestService(t, Config{Store: &stubStore{}})
|
||||
|
||||
_, err := service.Execute(context.Background(), Input{DeliveryID: common.DeliveryID("missing")})
|
||||
require.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
type stubStore struct {
|
||||
delivery *deliverydomain.Delivery
|
||||
deadLetter *deliverydomain.DeadLetterEntry
|
||||
getDeliveryErr error
|
||||
getDeadErr error
|
||||
}
|
||||
|
||||
func (store *stubStore) GetDelivery(context.Context, common.DeliveryID) (deliverydomain.Delivery, bool, error) {
|
||||
if store.getDeliveryErr != nil {
|
||||
return deliverydomain.Delivery{}, false, store.getDeliveryErr
|
||||
}
|
||||
if store.delivery == nil {
|
||||
return deliverydomain.Delivery{}, false, nil
|
||||
}
|
||||
return *store.delivery, true, nil
|
||||
}
|
||||
|
||||
func (store *stubStore) GetDeadLetter(context.Context, common.DeliveryID) (deliverydomain.DeadLetterEntry, bool, error) {
|
||||
if store.getDeadErr != nil {
|
||||
return deliverydomain.DeadLetterEntry{}, false, store.getDeadErr
|
||||
}
|
||||
if store.deadLetter == nil {
|
||||
return deliverydomain.DeadLetterEntry{}, false, nil
|
||||
}
|
||||
return *store.deadLetter, true, nil
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T, cfg Config) *Service {
|
||||
t.Helper()
|
||||
|
||||
service, err := New(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func validSentDelivery() deliverydomain.Delivery {
|
||||
createdAt := time.Unix(1_775_121_700, 0).UTC()
|
||||
updatedAt := createdAt.Add(time.Minute)
|
||||
sentAt := updatedAt.Add(time.Second)
|
||||
|
||||
record := deliverydomain.Delivery{
|
||||
DeliveryID: common.DeliveryID("delivery-sent"),
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeRendered,
|
||||
Envelope: deliverydomain.Envelope{To: []common.Email{common.Email("pilot@example.com")}},
|
||||
Content: deliverydomain.Content{Subject: "Ready", TextBody: "Turn ready"},
|
||||
IdempotencyKey: common.IdempotencyKey("notification:delivery-sent"),
|
||||
Status: deliverydomain.StatusSent,
|
||||
AttemptCount: 1,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
SentAt: &sentAt,
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func validDeadLetterDelivery() deliverydomain.Delivery {
|
||||
record := validSentDelivery()
|
||||
record.DeliveryID = common.DeliveryID("delivery-dead-letter")
|
||||
record.IdempotencyKey = common.IdempotencyKey("notification:delivery-dead-letter")
|
||||
record.Status = deliverydomain.StatusDeadLetter
|
||||
record.UpdatedAt = record.CreatedAt.Add(2 * time.Minute)
|
||||
record.SentAt = nil
|
||||
deadLetteredAt := record.UpdatedAt
|
||||
record.DeadLetteredAt = &deadLetteredAt
|
||||
if err := record.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func validDeadLetterEntry(deliveryID common.DeliveryID) deliverydomain.DeadLetterEntry {
|
||||
entry := deliverydomain.DeadLetterEntry{
|
||||
DeliveryID: deliveryID,
|
||||
FinalAttemptNo: 1,
|
||||
FailureClassification: "retry_exhausted",
|
||||
ProviderSummary: "smtp timeout",
|
||||
CreatedAt: time.Unix(1_775_121_900, 0).UTC(),
|
||||
RecoveryHint: "check SMTP connectivity",
|
||||
}
|
||||
if err := entry.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
func ptrDelivery(record deliverydomain.Delivery) *deliverydomain.Delivery {
|
||||
return &record
|
||||
}
|
||||
|
||||
var _ Store = (*stubStore)(nil)
|
||||
var _ = errors.New
|
||||
@@ -0,0 +1,137 @@
|
||||
// Package listattempts implements trusted operator reads of delivery-attempt
|
||||
// history.
|
||||
package listattempts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotFound reports that the requested delivery does not exist.
|
||||
ErrNotFound = errors.New("list attempts delivery not found")
|
||||
|
||||
// ErrServiceUnavailable reports that attempt history could not load durable
|
||||
// state safely.
|
||||
ErrServiceUnavailable = errors.New("list attempts service unavailable")
|
||||
)
|
||||
|
||||
// Input stores one trusted attempt-history lookup request.
|
||||
type Input struct {
|
||||
// DeliveryID stores the exact accepted delivery identifier to inspect.
|
||||
DeliveryID common.DeliveryID
|
||||
}
|
||||
|
||||
// Validate reports whether input contains a complete lookup key.
|
||||
func (input Input) Validate() error {
|
||||
if err := input.DeliveryID.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery id: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Result stores the ordered attempt history of one accepted delivery.
|
||||
type Result struct {
|
||||
// Delivery stores the owning accepted delivery record.
|
||||
Delivery deliverydomain.Delivery
|
||||
|
||||
// Attempts stores the concrete attempt history in `attempt_no ASC` order.
|
||||
Attempts []attempt.Attempt
|
||||
}
|
||||
|
||||
// Validate reports whether result contains a structurally valid attempt
|
||||
// history.
|
||||
func (result Result) Validate() error {
|
||||
if err := result.Delivery.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery: %w", err)
|
||||
}
|
||||
if len(result.Attempts) != result.Delivery.AttemptCount {
|
||||
return fmt.Errorf("attempt count %d mismatches delivery attempt count %d", len(result.Attempts), result.Delivery.AttemptCount)
|
||||
}
|
||||
for index, record := range result.Attempts {
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("attempts[%d]: %w", index, err)
|
||||
}
|
||||
if record.DeliveryID != result.Delivery.DeliveryID {
|
||||
return fmt.Errorf("attempts[%d]: delivery id mismatch", index)
|
||||
}
|
||||
if record.AttemptNo != index+1 {
|
||||
return fmt.Errorf("attempts[%d]: expected attempt number %d, got %d", index, index+1, record.AttemptNo)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Store provides exact delivery lookup and ordered attempt-history reads.
|
||||
type Store interface {
|
||||
// GetDelivery loads one accepted delivery by its identifier.
|
||||
GetDelivery(context.Context, common.DeliveryID) (deliverydomain.Delivery, bool, error)
|
||||
|
||||
// ListAttempts loads exactly expectedCount attempts in ascending attempt
|
||||
// number order. Implementations must fail closed when the stored sequence
|
||||
// contains a gap.
|
||||
ListAttempts(context.Context, common.DeliveryID, int) ([]attempt.Attempt, error)
|
||||
}
|
||||
|
||||
// Config stores the dependencies used by Service.
|
||||
type Config struct {
|
||||
// Store owns durable delivery and attempt state.
|
||||
Store Store
|
||||
}
|
||||
|
||||
// Service executes trusted attempt-history reads.
|
||||
type Service struct {
|
||||
store Store
|
||||
}
|
||||
|
||||
// New constructs Service from cfg.
|
||||
func New(cfg Config) (*Service, error) {
|
||||
if cfg.Store == nil {
|
||||
return nil, errors.New("new list attempts service: nil store")
|
||||
}
|
||||
|
||||
return &Service{store: cfg.Store}, nil
|
||||
}
|
||||
|
||||
// Execute loads one delivery and its complete attempt history.
|
||||
func (service *Service) Execute(ctx context.Context, input Input) (Result, error) {
|
||||
if ctx == nil {
|
||||
return Result{}, errors.New("execute list attempts: nil context")
|
||||
}
|
||||
if service == nil {
|
||||
return Result{}, errors.New("execute list attempts: nil service")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return Result{}, fmt.Errorf("execute list attempts: %w", err)
|
||||
}
|
||||
|
||||
record, found, err := service.store.GetDelivery(ctx, input.DeliveryID)
|
||||
switch {
|
||||
case err != nil:
|
||||
return Result{}, fmt.Errorf("%w: load delivery: %v", ErrServiceUnavailable, err)
|
||||
case !found:
|
||||
return Result{}, ErrNotFound
|
||||
}
|
||||
|
||||
attempts, err := service.store.ListAttempts(ctx, input.DeliveryID, record.AttemptCount)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("%w: load attempts: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
|
||||
result := Result{
|
||||
Delivery: record,
|
||||
Attempts: attempts,
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return Result{}, fmt.Errorf("%w: invalid result: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package listattempts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServiceExecuteReturnsEmptyHistory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := validDelivery(0)
|
||||
store := &stubStore{delivery: &record}
|
||||
service := newTestService(t, Config{Store: store})
|
||||
|
||||
result, err := service.Execute(context.Background(), Input{DeliveryID: record.DeliveryID})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, result.Delivery)
|
||||
require.Empty(t, result.Attempts)
|
||||
}
|
||||
|
||||
func TestServiceExecuteReturnsOrderedHistory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := validDelivery(2)
|
||||
store := &stubStore{
|
||||
delivery: &record,
|
||||
attempts: []attempt.Attempt{
|
||||
validAttempt(record.DeliveryID, 1, attempt.StatusProviderRejected),
|
||||
validAttempt(record.DeliveryID, 2, attempt.StatusProviderAccepted),
|
||||
},
|
||||
}
|
||||
service := newTestService(t, Config{Store: store})
|
||||
|
||||
result, err := service.Execute(context.Background(), Input{DeliveryID: record.DeliveryID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.Attempts, 2)
|
||||
require.Equal(t, 1, result.Attempts[0].AttemptNo)
|
||||
require.Equal(t, 2, result.Attempts[1].AttemptNo)
|
||||
}
|
||||
|
||||
func TestServiceExecuteFailsClosedOnGap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := validDelivery(2)
|
||||
store := &stubStore{
|
||||
delivery: &record,
|
||||
attempts: []attempt.Attempt{
|
||||
validAttempt(record.DeliveryID, 1, attempt.StatusProviderRejected),
|
||||
validAttempt(record.DeliveryID, 3, attempt.StatusProviderAccepted),
|
||||
},
|
||||
}
|
||||
service := newTestService(t, Config{Store: store})
|
||||
|
||||
_, err := service.Execute(context.Background(), Input{DeliveryID: record.DeliveryID})
|
||||
require.ErrorIs(t, err, ErrServiceUnavailable)
|
||||
}
|
||||
|
||||
type stubStore struct {
|
||||
delivery *deliverydomain.Delivery
|
||||
attempts []attempt.Attempt
|
||||
}
|
||||
|
||||
func (store *stubStore) GetDelivery(context.Context, common.DeliveryID) (deliverydomain.Delivery, bool, error) {
|
||||
if store.delivery == nil {
|
||||
return deliverydomain.Delivery{}, false, nil
|
||||
}
|
||||
|
||||
return *store.delivery, true, nil
|
||||
}
|
||||
|
||||
func (store *stubStore) ListAttempts(context.Context, common.DeliveryID, int) ([]attempt.Attempt, error) {
|
||||
return append([]attempt.Attempt(nil), store.attempts...), nil
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T, cfg Config) *Service {
|
||||
t.Helper()
|
||||
|
||||
service, err := New(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func validDelivery(attemptCount int) deliverydomain.Delivery {
|
||||
createdAt := time.Unix(1_775_121_700, 0).UTC()
|
||||
updatedAt := createdAt.Add(time.Minute)
|
||||
failedAt := updatedAt.Add(time.Second)
|
||||
|
||||
record := deliverydomain.Delivery{
|
||||
DeliveryID: common.DeliveryID("delivery-attempts"),
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeRendered,
|
||||
Envelope: deliverydomain.Envelope{To: []common.Email{common.Email("pilot@example.com")}},
|
||||
Content: deliverydomain.Content{Subject: "Ready", TextBody: "Turn ready"},
|
||||
IdempotencyKey: common.IdempotencyKey("notification:delivery-attempts"),
|
||||
Status: deliverydomain.StatusFailed,
|
||||
AttemptCount: attemptCount,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
FailedAt: &failedAt,
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func validAttempt(deliveryID common.DeliveryID, attemptNo int, status attempt.Status) attempt.Attempt {
|
||||
scheduledFor := time.Unix(1_775_121_760+int64(attemptNo), 0).UTC()
|
||||
startedAt := scheduledFor.Add(time.Second)
|
||||
finishedAt := startedAt.Add(time.Second)
|
||||
|
||||
record := attempt.Attempt{
|
||||
DeliveryID: deliveryID,
|
||||
AttemptNo: attemptNo,
|
||||
ScheduledFor: scheduledFor,
|
||||
StartedAt: &startedAt,
|
||||
FinishedAt: &finishedAt,
|
||||
Status: status,
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
var _ Store = (*stubStore)(nil)
|
||||
@@ -0,0 +1,280 @@
|
||||
// Package listdeliveries implements trusted operator listing of accepted mail
|
||||
// deliveries.
|
||||
package listdeliveries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidCursor reports that the supplied opaque pagination cursor is
|
||||
// malformed or no longer matches durable state.
|
||||
ErrInvalidCursor = errors.New("list deliveries invalid cursor")
|
||||
|
||||
// ErrServiceUnavailable reports that trusted listing could not load durable
|
||||
// state safely.
|
||||
ErrServiceUnavailable = errors.New("list deliveries service unavailable")
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultLimit stores the frozen default page size used by the operator
|
||||
// listing surface.
|
||||
DefaultLimit = 50
|
||||
|
||||
// MaxLimit stores the frozen maximum page size accepted by the operator
|
||||
// listing surface.
|
||||
MaxLimit = 200
|
||||
)
|
||||
|
||||
// Cursor stores one deterministic continuation position in the delivery sort
|
||||
// order `created_at_ms DESC, delivery_id DESC`.
|
||||
type Cursor struct {
|
||||
// CreatedAt stores the durable creation time of the last visible delivery.
|
||||
CreatedAt time.Time
|
||||
|
||||
// DeliveryID stores the durable identifier of the last visible delivery.
|
||||
DeliveryID common.DeliveryID
|
||||
}
|
||||
|
||||
// Validate reports whether cursor contains a complete continuation tuple.
|
||||
func (cursor Cursor) Validate() error {
|
||||
if err := common.ValidateTimestamp("delivery list cursor created at", cursor.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cursor.DeliveryID.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery list cursor delivery id: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Filters stores the supported operator-listing filters.
|
||||
type Filters struct {
|
||||
// Recipient stores the optional recipient envelope filter covering `to`,
|
||||
// `cc`, and `bcc`.
|
||||
Recipient common.Email
|
||||
|
||||
// Status stores the optional delivery lifecycle filter.
|
||||
Status deliverydomain.Status
|
||||
|
||||
// Source stores the optional delivery source filter.
|
||||
Source deliverydomain.Source
|
||||
|
||||
// TemplateID stores the optional template family filter.
|
||||
TemplateID common.TemplateID
|
||||
|
||||
// IdempotencyKey stores the optional idempotency-key filter.
|
||||
IdempotencyKey common.IdempotencyKey
|
||||
|
||||
// FromCreatedAt stores the optional inclusive lower creation-time bound.
|
||||
FromCreatedAt *time.Time
|
||||
|
||||
// ToCreatedAt stores the optional inclusive upper creation-time bound.
|
||||
ToCreatedAt *time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether filters is structurally valid.
|
||||
func (filters Filters) Validate() error {
|
||||
if !filters.Recipient.IsZero() {
|
||||
if err := filters.Recipient.Validate(); err != nil {
|
||||
return fmt.Errorf("recipient: %w", err)
|
||||
}
|
||||
}
|
||||
if filters.Status != "" && !filters.Status.IsKnown() {
|
||||
return fmt.Errorf("status %q is unsupported", filters.Status)
|
||||
}
|
||||
if filters.Source != "" && !filters.Source.IsKnown() {
|
||||
return fmt.Errorf("source %q is unsupported", filters.Source)
|
||||
}
|
||||
if !filters.TemplateID.IsZero() {
|
||||
if err := filters.TemplateID.Validate(); err != nil {
|
||||
return fmt.Errorf("template id: %w", err)
|
||||
}
|
||||
}
|
||||
if !filters.IdempotencyKey.IsZero() {
|
||||
if err := filters.IdempotencyKey.Validate(); err != nil {
|
||||
return fmt.Errorf("idempotency key: %w", err)
|
||||
}
|
||||
}
|
||||
if filters.FromCreatedAt != nil {
|
||||
if err := common.ValidateTimestamp("from created at", *filters.FromCreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if filters.ToCreatedAt != nil {
|
||||
if err := common.ValidateTimestamp("to created at", *filters.ToCreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if filters.FromCreatedAt != nil && filters.ToCreatedAt != nil && filters.FromCreatedAt.After(*filters.ToCreatedAt) {
|
||||
return errors.New("from created at must not be after to created at")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Input stores one trusted operator-listing request.
|
||||
type Input struct {
|
||||
// Limit stores the maximum number of returned deliveries. The zero value
|
||||
// selects the frozen default limit.
|
||||
Limit int
|
||||
|
||||
// Cursor stores the optional continuation cursor for the next page.
|
||||
Cursor *Cursor
|
||||
|
||||
// Filters stores the normalized listing filters.
|
||||
Filters Filters
|
||||
}
|
||||
|
||||
// Validate reports whether input contains a complete supported listing
|
||||
// request.
|
||||
func (input Input) Validate() error {
|
||||
switch {
|
||||
case input.Limit < 0:
|
||||
return errors.New("limit must not be negative")
|
||||
case input.Limit > MaxLimit:
|
||||
return fmt.Errorf("limit must be at most %d", MaxLimit)
|
||||
}
|
||||
if input.Cursor != nil {
|
||||
if err := input.Cursor.Validate(); err != nil {
|
||||
return fmt.Errorf("cursor: %w", err)
|
||||
}
|
||||
}
|
||||
if err := input.Filters.Validate(); err != nil {
|
||||
return fmt.Errorf("filters: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Result stores one deterministic ordered page of delivery records.
|
||||
type Result struct {
|
||||
// Items stores the returned deliveries in `created_at DESC, delivery_id
|
||||
// DESC` order.
|
||||
Items []deliverydomain.Delivery
|
||||
|
||||
// NextCursor stores the optional cursor for the next page.
|
||||
NextCursor *Cursor
|
||||
}
|
||||
|
||||
// Validate reports whether result contains valid delivery records and an
|
||||
// optional next cursor.
|
||||
func (result Result) Validate() error {
|
||||
for index, record := range result.Items {
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("items[%d]: %w", index, err)
|
||||
}
|
||||
}
|
||||
if result.NextCursor != nil {
|
||||
if err := result.NextCursor.Validate(); err != nil {
|
||||
return fmt.Errorf("next cursor: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Store provides deterministic ordered listing over durable delivery state.
|
||||
type Store interface {
|
||||
// List returns one filtered ordered page of delivery records.
|
||||
List(context.Context, Input) (Result, error)
|
||||
}
|
||||
|
||||
// Config stores the dependencies used by Service.
|
||||
type Config struct {
|
||||
// Store loads one deterministic ordered page of durable deliveries.
|
||||
Store Store
|
||||
}
|
||||
|
||||
// Service executes trusted operator delivery-list reads.
|
||||
type Service struct {
|
||||
store Store
|
||||
}
|
||||
|
||||
// New constructs Service from cfg.
|
||||
func New(cfg Config) (*Service, error) {
|
||||
if cfg.Store == nil {
|
||||
return nil, errors.New("new list deliveries service: nil store")
|
||||
}
|
||||
|
||||
return &Service{store: cfg.Store}, nil
|
||||
}
|
||||
|
||||
// Execute validates input, applies the default limit when omitted, and loads
|
||||
// one deterministic page of deliveries.
|
||||
func (service *Service) Execute(ctx context.Context, input Input) (Result, error) {
|
||||
if ctx == nil {
|
||||
return Result{}, errors.New("execute list deliveries: nil context")
|
||||
}
|
||||
if service == nil {
|
||||
return Result{}, errors.New("execute list deliveries: nil service")
|
||||
}
|
||||
if input.Limit == 0 {
|
||||
input.Limit = DefaultLimit
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return Result{}, fmt.Errorf("execute list deliveries: %w", err)
|
||||
}
|
||||
|
||||
result, err := service.store.List(ctx, input)
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidCursor):
|
||||
return Result{}, err
|
||||
case err != nil:
|
||||
return Result{}, fmt.Errorf("%w: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return Result{}, fmt.Errorf("%w: invalid result: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
if len(result.Items) > input.Limit {
|
||||
return Result{}, fmt.Errorf("%w: invalid result: returned %d items for limit %d", ErrServiceUnavailable, len(result.Items), input.Limit)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Matches reports whether record satisfies filters.
|
||||
func (filters Filters) Matches(record deliverydomain.Delivery) bool {
|
||||
if filters.Recipient != "" && !containsRecipient(record.Envelope, filters.Recipient) {
|
||||
return false
|
||||
}
|
||||
if filters.Status != "" && record.Status != filters.Status {
|
||||
return false
|
||||
}
|
||||
if filters.Source != "" && record.Source != filters.Source {
|
||||
return false
|
||||
}
|
||||
if filters.TemplateID != "" && record.TemplateID != filters.TemplateID {
|
||||
return false
|
||||
}
|
||||
if filters.IdempotencyKey != "" && record.IdempotencyKey != filters.IdempotencyKey {
|
||||
return false
|
||||
}
|
||||
if filters.FromCreatedAt != nil && record.CreatedAt.Before(filters.FromCreatedAt.UTC()) {
|
||||
return false
|
||||
}
|
||||
if filters.ToCreatedAt != nil && record.CreatedAt.After(filters.ToCreatedAt.UTC()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func containsRecipient(envelope deliverydomain.Envelope, email common.Email) bool {
|
||||
for _, group := range [][]common.Email{envelope.To, envelope.Cc, envelope.Bcc} {
|
||||
for _, candidate := range group {
|
||||
if strings.EqualFold(candidate.String(), email.String()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package listdeliveries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServiceExecuteAppliesDefaultLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &stubStore{
|
||||
result: Result{
|
||||
Items: []deliverydomain.Delivery{validDelivery("delivery-default", "notification:delivery-default")},
|
||||
},
|
||||
}
|
||||
service := newTestService(t, Config{Store: store})
|
||||
|
||||
result, err := service.Execute(context.Background(), Input{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.Items, 1)
|
||||
require.Equal(t, DefaultLimit, store.lastInput.Limit)
|
||||
}
|
||||
|
||||
func TestInputValidateRejectsInvalidFiltersAndCursor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validCursor := Cursor{
|
||||
CreatedAt: time.Unix(1_775_121_700, 0).UTC(),
|
||||
DeliveryID: common.DeliveryID("delivery-cursor"),
|
||||
}
|
||||
validFrom := time.Unix(1_775_121_700, 0).UTC()
|
||||
validTo := validFrom.Add(time.Minute)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input Input
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "invalid recipient",
|
||||
input: Input{
|
||||
Filters: Filters{Recipient: common.Email("not-an-email")},
|
||||
},
|
||||
wantErr: "recipient:",
|
||||
},
|
||||
{
|
||||
name: "invalid status",
|
||||
input: Input{
|
||||
Filters: Filters{Status: deliverydomain.Status("bad")},
|
||||
},
|
||||
wantErr: `status "bad" is unsupported`,
|
||||
},
|
||||
{
|
||||
name: "invalid source",
|
||||
input: Input{
|
||||
Filters: Filters{Source: deliverydomain.Source("bad")},
|
||||
},
|
||||
wantErr: `source "bad" is unsupported`,
|
||||
},
|
||||
{
|
||||
name: "invalid template id",
|
||||
input: Input{
|
||||
Filters: Filters{TemplateID: common.TemplateID(" bad-template")},
|
||||
},
|
||||
wantErr: "template id:",
|
||||
},
|
||||
{
|
||||
name: "invalid idempotency key",
|
||||
input: Input{
|
||||
Filters: Filters{IdempotencyKey: common.IdempotencyKey(" bad-key")},
|
||||
},
|
||||
wantErr: "idempotency key:",
|
||||
},
|
||||
{
|
||||
name: "invalid created at range",
|
||||
input: Input{
|
||||
Filters: Filters{
|
||||
FromCreatedAt: &validTo,
|
||||
ToCreatedAt: &validFrom,
|
||||
},
|
||||
},
|
||||
wantErr: "from created at must not be after to created at",
|
||||
},
|
||||
{
|
||||
name: "invalid cursor",
|
||||
input: Input{
|
||||
Cursor: &Cursor{
|
||||
CreatedAt: time.Time{},
|
||||
DeliveryID: common.DeliveryID("delivery-cursor"),
|
||||
},
|
||||
},
|
||||
wantErr: "cursor:",
|
||||
},
|
||||
{
|
||||
name: "valid cursor and filters",
|
||||
input: Input{
|
||||
Limit: 1,
|
||||
Cursor: &Cursor{
|
||||
CreatedAt: validCursor.CreatedAt,
|
||||
DeliveryID: validCursor.DeliveryID,
|
||||
},
|
||||
Filters: Filters{
|
||||
Recipient: common.Email("pilot@example.com"),
|
||||
Status: deliverydomain.StatusSent,
|
||||
Source: deliverydomain.SourceNotification,
|
||||
TemplateID: common.TemplateID("auth.login_code"),
|
||||
IdempotencyKey: common.IdempotencyKey("notification:delivery-123"),
|
||||
FromCreatedAt: &validFrom,
|
||||
ToCreatedAt: &validTo,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.input.Validate()
|
||||
if tt.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceExecutePropagatesInvalidCursor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service := newTestService(t, Config{
|
||||
Store: &stubStore{listErr: ErrInvalidCursor},
|
||||
})
|
||||
|
||||
_, err := service.Execute(context.Background(), Input{Limit: 1})
|
||||
require.ErrorIs(t, err, ErrInvalidCursor)
|
||||
}
|
||||
|
||||
func TestServiceExecuteWrapsServiceUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service := newTestService(t, Config{
|
||||
Store: &stubStore{listErr: errors.New("redis unavailable")},
|
||||
})
|
||||
|
||||
_, err := service.Execute(context.Background(), Input{Limit: 1})
|
||||
require.ErrorIs(t, err, ErrServiceUnavailable)
|
||||
require.ErrorContains(t, err, "redis unavailable")
|
||||
}
|
||||
|
||||
func TestServiceExecuteRejectsOversizedResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service := newTestService(t, Config{
|
||||
Store: &stubStore{
|
||||
result: Result{
|
||||
Items: []deliverydomain.Delivery{
|
||||
validDelivery("delivery-one", "notification:delivery-one"),
|
||||
validDelivery("delivery-two", "notification:delivery-two"),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := service.Execute(context.Background(), Input{Limit: 1})
|
||||
require.ErrorIs(t, err, ErrServiceUnavailable)
|
||||
require.ErrorContains(t, err, "returned 2 items for limit 1")
|
||||
}
|
||||
|
||||
type stubStore struct {
|
||||
lastInput Input
|
||||
result Result
|
||||
listErr error
|
||||
}
|
||||
|
||||
func (store *stubStore) List(_ context.Context, input Input) (Result, error) {
|
||||
store.lastInput = input
|
||||
if store.listErr != nil {
|
||||
return Result{}, store.listErr
|
||||
}
|
||||
|
||||
return store.result, nil
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T, cfg Config) *Service {
|
||||
t.Helper()
|
||||
|
||||
service, err := New(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func validDelivery(deliveryID string, idempotencyKey common.IdempotencyKey) deliverydomain.Delivery {
|
||||
createdAt := time.Unix(1_775_121_700, 0).UTC()
|
||||
updatedAt := createdAt.Add(time.Minute)
|
||||
sentAt := updatedAt.Add(time.Second)
|
||||
|
||||
record := deliverydomain.Delivery{
|
||||
DeliveryID: common.DeliveryID(deliveryID),
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeRendered,
|
||||
Envelope: deliverydomain.Envelope{To: []common.Email{common.Email("pilot@example.com")}},
|
||||
Content: deliverydomain.Content{Subject: "Ready", TextBody: "Turn ready"},
|
||||
IdempotencyKey: idempotencyKey,
|
||||
Status: deliverydomain.StatusSent,
|
||||
AttemptCount: 1,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
SentAt: &sentAt,
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
var _ Store = (*stubStore)(nil)
|
||||
@@ -0,0 +1,695 @@
|
||||
// Package renderdelivery implements deterministic rendering of template-mode
|
||||
// deliveries.
|
||||
package renderdelivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
templatedir "galaxy/mail/internal/adapters/templates"
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/logging"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
oteltrace "go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrServiceUnavailable reports that rendered or failed state could not be
|
||||
// persisted durably.
|
||||
ErrServiceUnavailable = errors.New("render delivery service unavailable")
|
||||
)
|
||||
|
||||
const tracerName = "galaxy/mail/renderdelivery"
|
||||
|
||||
// FailureClassification identifies the stable render-failure classification
|
||||
// surface.
|
||||
type FailureClassification string
|
||||
|
||||
const (
|
||||
// FailureTemplateNotFound reports that the requested template family does
|
||||
// not exist in the catalog.
|
||||
FailureTemplateNotFound FailureClassification = "template_not_found"
|
||||
|
||||
// FailureFallbackMissing reports that the requested locale is unavailable
|
||||
// and the mandatory `en` fallback variant is also missing.
|
||||
FailureFallbackMissing FailureClassification = "fallback_missing"
|
||||
|
||||
// FailureTemplateParseFailed reports that a template variant could not be
|
||||
// parsed into a runnable form.
|
||||
FailureTemplateParseFailed FailureClassification = "template_parse_failed"
|
||||
|
||||
// FailureMissingRequiredVariable reports that the accepted template
|
||||
// variables do not provide one or more required dot-path values.
|
||||
FailureMissingRequiredVariable FailureClassification = "missing_required_variable"
|
||||
|
||||
// FailureTemplateExecuteFailed reports that template execution failed after
|
||||
// lookup and variable validation.
|
||||
FailureTemplateExecuteFailed FailureClassification = "template_execute_failed"
|
||||
)
|
||||
|
||||
// IsKnown reports whether classification belongs to the stable render-failure
|
||||
// surface.
|
||||
func (classification FailureClassification) IsKnown() bool {
|
||||
switch classification {
|
||||
case FailureTemplateNotFound,
|
||||
FailureFallbackMissing,
|
||||
FailureTemplateParseFailed,
|
||||
FailureMissingRequiredVariable,
|
||||
FailureTemplateExecuteFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Outcome identifies the coarse result of one render-delivery execution.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
// OutcomeRendered reports that template content was materialized and stored
|
||||
// durably as `mail_delivery.status=rendered`.
|
||||
OutcomeRendered Outcome = "rendered"
|
||||
|
||||
// OutcomeFailed reports that rendering reached a classified terminal
|
||||
// failure and stored `mail_delivery.status=failed`.
|
||||
OutcomeFailed Outcome = "failed"
|
||||
)
|
||||
|
||||
// IsKnown reports whether outcome belongs to the supported render-delivery
|
||||
// result surface.
|
||||
func (outcome Outcome) IsKnown() bool {
|
||||
switch outcome {
|
||||
case OutcomeRendered, OutcomeFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Input stores one queued template delivery together with its current
|
||||
// scheduled attempt.
|
||||
type Input struct {
|
||||
// Delivery stores the queued template-mode delivery to render.
|
||||
Delivery deliverydomain.Delivery
|
||||
|
||||
// Attempt stores the current scheduled attempt associated with Delivery.
|
||||
Attempt attempt.Attempt
|
||||
}
|
||||
|
||||
// Validate reports whether input contains one queued template delivery and
|
||||
// its scheduled attempt.
|
||||
func (input Input) Validate() error {
|
||||
if err := input.Delivery.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery: %w", err)
|
||||
}
|
||||
if err := input.Attempt.Validate(); err != nil {
|
||||
return fmt.Errorf("attempt: %w", err)
|
||||
}
|
||||
if input.Delivery.PayloadMode != deliverydomain.PayloadModeTemplate {
|
||||
return fmt.Errorf("delivery payload mode must be %q", deliverydomain.PayloadModeTemplate)
|
||||
}
|
||||
if input.Delivery.Status != deliverydomain.StatusQueued {
|
||||
return fmt.Errorf("delivery status must be %q", deliverydomain.StatusQueued)
|
||||
}
|
||||
if input.Attempt.DeliveryID != input.Delivery.DeliveryID {
|
||||
return errors.New("attempt delivery id must match delivery id")
|
||||
}
|
||||
if input.Attempt.AttemptNo < 1 {
|
||||
return errors.New("attempt number must be at least 1")
|
||||
}
|
||||
if input.Attempt.Status != attempt.StatusScheduled {
|
||||
return fmt.Errorf("attempt status must be %q", attempt.StatusScheduled)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Result stores the durable outcome of one render-delivery execution.
|
||||
type Result struct {
|
||||
// Outcome stores the coarse render-delivery result.
|
||||
Outcome Outcome
|
||||
|
||||
// Delivery stores the durably persisted delivery record after rendering or
|
||||
// render failure handling.
|
||||
Delivery deliverydomain.Delivery
|
||||
|
||||
// Attempt stores the durably persisted terminal attempt when Outcome is
|
||||
// failed. Successful rendering keeps the scheduled attempt unchanged and
|
||||
// therefore leaves Attempt nil.
|
||||
Attempt *attempt.Attempt
|
||||
|
||||
// ResolvedLocale stores the actual filesystem locale variant used by
|
||||
// template lookup when available.
|
||||
ResolvedLocale common.Locale
|
||||
|
||||
// LocaleFallbackUsed reports whether template lookup fell back from the
|
||||
// requested locale to `en`.
|
||||
LocaleFallbackUsed bool
|
||||
|
||||
// TemplateVersion stores the version marker of the resolved template
|
||||
// variant when available.
|
||||
TemplateVersion string
|
||||
|
||||
// FailureClassification stores the stable classified failure code when
|
||||
// Outcome is failed.
|
||||
FailureClassification FailureClassification
|
||||
}
|
||||
|
||||
// Validate reports whether result contains a complete supported render
|
||||
// outcome.
|
||||
func (result Result) Validate() error {
|
||||
if !result.Outcome.IsKnown() {
|
||||
return fmt.Errorf("render delivery outcome %q is unsupported", result.Outcome)
|
||||
}
|
||||
if err := result.Delivery.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery: %w", err)
|
||||
}
|
||||
|
||||
switch result.Outcome {
|
||||
case OutcomeRendered:
|
||||
if result.Attempt != nil {
|
||||
return errors.New("rendered result must not contain terminal attempt")
|
||||
}
|
||||
if result.Delivery.Status != deliverydomain.StatusRendered {
|
||||
return fmt.Errorf("rendered result delivery status must be %q", deliverydomain.StatusRendered)
|
||||
}
|
||||
if result.ResolvedLocale.IsZero() {
|
||||
return errors.New("rendered result resolved locale must not be empty")
|
||||
}
|
||||
if err := result.ResolvedLocale.Validate(); err != nil {
|
||||
return fmt.Errorf("resolved locale: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(result.TemplateVersion) == "" {
|
||||
return errors.New("rendered result template version must not be empty")
|
||||
}
|
||||
if result.FailureClassification != "" {
|
||||
return errors.New("rendered result must not contain failure classification")
|
||||
}
|
||||
case OutcomeFailed:
|
||||
if result.Attempt == nil {
|
||||
return errors.New("failed result must contain terminal attempt")
|
||||
}
|
||||
if err := result.Attempt.Validate(); err != nil {
|
||||
return fmt.Errorf("attempt: %w", err)
|
||||
}
|
||||
if result.Attempt.DeliveryID != result.Delivery.DeliveryID {
|
||||
return errors.New("attempt delivery id must match delivery id")
|
||||
}
|
||||
if result.Delivery.Status != deliverydomain.StatusFailed {
|
||||
return fmt.Errorf("failed result delivery status must be %q", deliverydomain.StatusFailed)
|
||||
}
|
||||
if result.Attempt.Status != attempt.StatusRenderFailed {
|
||||
return fmt.Errorf("failed result attempt status must be %q", attempt.StatusRenderFailed)
|
||||
}
|
||||
if !result.FailureClassification.IsKnown() {
|
||||
return fmt.Errorf("failed result classification %q is unsupported", result.FailureClassification)
|
||||
}
|
||||
if !result.ResolvedLocale.IsZero() {
|
||||
if err := result.ResolvedLocale.Validate(); err != nil {
|
||||
return fmt.Errorf("resolved locale: %w", err)
|
||||
}
|
||||
}
|
||||
if result.Delivery.LastAttemptStatus != attempt.StatusRenderFailed {
|
||||
return fmt.Errorf("failed result delivery last attempt status must be %q", attempt.StatusRenderFailed)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkRenderedInput stores the durable mutation applied after successful
|
||||
// template materialization.
|
||||
type MarkRenderedInput struct {
|
||||
// Delivery stores the rendered delivery record.
|
||||
Delivery deliverydomain.Delivery
|
||||
}
|
||||
|
||||
// Validate reports whether input contains one rendered delivery record.
|
||||
func (input MarkRenderedInput) Validate() error {
|
||||
if err := input.Delivery.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery: %w", err)
|
||||
}
|
||||
if input.Delivery.Status != deliverydomain.StatusRendered {
|
||||
return fmt.Errorf("delivery status must be %q", deliverydomain.StatusRendered)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkRenderFailedInput stores the durable mutation applied after classified
|
||||
// render failure.
|
||||
type MarkRenderFailedInput struct {
|
||||
// Delivery stores the failed delivery record.
|
||||
Delivery deliverydomain.Delivery
|
||||
|
||||
// Attempt stores the terminal render-failed attempt record.
|
||||
Attempt attempt.Attempt
|
||||
}
|
||||
|
||||
// Validate reports whether input contains one failed delivery record and its
|
||||
// terminal render-failed attempt.
|
||||
func (input MarkRenderFailedInput) Validate() error {
|
||||
if err := input.Delivery.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery: %w", err)
|
||||
}
|
||||
if err := input.Attempt.Validate(); err != nil {
|
||||
return fmt.Errorf("attempt: %w", err)
|
||||
}
|
||||
if input.Delivery.Status != deliverydomain.StatusFailed {
|
||||
return fmt.Errorf("delivery status must be %q", deliverydomain.StatusFailed)
|
||||
}
|
||||
if input.Attempt.Status != attempt.StatusRenderFailed {
|
||||
return fmt.Errorf("attempt status must be %q", attempt.StatusRenderFailed)
|
||||
}
|
||||
if input.Attempt.DeliveryID != input.Delivery.DeliveryID {
|
||||
return errors.New("attempt delivery id must match delivery id")
|
||||
}
|
||||
if input.Delivery.LastAttemptStatus != attempt.StatusRenderFailed {
|
||||
return fmt.Errorf("delivery last attempt status must be %q", attempt.StatusRenderFailed)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Store describes the durable persistence required by the render-delivery
|
||||
// use case.
|
||||
type Store interface {
|
||||
// MarkRendered stores the successful materialization result.
|
||||
MarkRendered(context.Context, MarkRenderedInput) error
|
||||
|
||||
// MarkRenderFailed stores one classified terminal render failure.
|
||||
MarkRenderFailed(context.Context, MarkRenderFailedInput) error
|
||||
}
|
||||
|
||||
// TemplateCatalog describes the immutable in-memory template registry used by
|
||||
// the renderer.
|
||||
type TemplateCatalog interface {
|
||||
// Lookup resolves one template family for locale using the frozen exact
|
||||
// match followed by `en` fallback rule.
|
||||
Lookup(common.TemplateID, common.Locale) (templatedir.ResolvedTemplate, error)
|
||||
}
|
||||
|
||||
// Clock provides the current wall-clock time.
|
||||
type Clock interface {
|
||||
// Now returns the current time.
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
// Telemetry records low-cardinality render and delivery lifecycle metrics.
|
||||
type Telemetry interface {
|
||||
// RecordDeliveryStatusTransition records one durable delivery status
|
||||
// transition.
|
||||
RecordDeliveryStatusTransition(context.Context, string, string)
|
||||
|
||||
// RecordAttemptOutcome records one durable terminal attempt outcome.
|
||||
RecordAttemptOutcome(context.Context, string, string)
|
||||
|
||||
// RecordLocaleFallback records one template locale fallback event.
|
||||
RecordLocaleFallback(context.Context, string, string, string)
|
||||
}
|
||||
|
||||
// Config stores the dependencies used by Service.
|
||||
type Config struct {
|
||||
// Catalog stores the immutable in-memory template registry.
|
||||
Catalog TemplateCatalog
|
||||
|
||||
// Store owns the durable rendered and failed delivery state.
|
||||
Store Store
|
||||
|
||||
// Clock provides the current time.
|
||||
Clock Clock
|
||||
|
||||
// Telemetry records low-cardinality render and delivery lifecycle metrics.
|
||||
Telemetry Telemetry
|
||||
|
||||
// TracerProvider constructs the application span recorder used by the
|
||||
// render flow.
|
||||
TracerProvider oteltrace.TracerProvider
|
||||
|
||||
// Logger writes structured render logs.
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// Service materializes queued template deliveries deterministically.
|
||||
type Service struct {
|
||||
catalog TemplateCatalog
|
||||
store Store
|
||||
clock Clock
|
||||
telemetry Telemetry
|
||||
tracerProvider oteltrace.TracerProvider
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New constructs Service from cfg.
|
||||
func New(cfg Config) (*Service, error) {
|
||||
switch {
|
||||
case cfg.Catalog == nil:
|
||||
return nil, errors.New("new render delivery service: nil catalog")
|
||||
case cfg.Store == nil:
|
||||
return nil, errors.New("new render delivery service: nil store")
|
||||
case cfg.Clock == nil:
|
||||
return nil, errors.New("new render delivery service: nil clock")
|
||||
default:
|
||||
tracerProvider := cfg.TracerProvider
|
||||
if tracerProvider == nil {
|
||||
tracerProvider = otel.GetTracerProvider()
|
||||
}
|
||||
logger := cfg.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &Service{
|
||||
catalog: cfg.Catalog,
|
||||
store: cfg.Store,
|
||||
clock: cfg.Clock,
|
||||
telemetry: cfg.Telemetry,
|
||||
tracerProvider: tracerProvider,
|
||||
logger: logger.With("component", "render_delivery"),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Execute resolves, validates, renders, and durably stores one template-mode
|
||||
// delivery outcome.
|
||||
func (service *Service) Execute(ctx context.Context, input Input) (Result, error) {
|
||||
if ctx == nil {
|
||||
return Result{}, errors.New("render delivery: nil context")
|
||||
}
|
||||
if service == nil {
|
||||
return Result{}, errors.New("render delivery: nil service")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return Result{}, fmt.Errorf("render delivery: %w", err)
|
||||
}
|
||||
|
||||
ctx, span := service.tracerProvider.Tracer(tracerName).Start(ctx, "mail.render_delivery")
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.String("mail.delivery_id", input.Delivery.DeliveryID.String()),
|
||||
attribute.String("mail.source", string(input.Delivery.Source)),
|
||||
attribute.String("mail.template_id", input.Delivery.TemplateID.String()),
|
||||
attribute.Int("mail.attempt_no", input.Attempt.AttemptNo),
|
||||
attribute.String("mail.requested_locale", input.Delivery.Locale.String()),
|
||||
)
|
||||
|
||||
resolved, err := service.catalog.Lookup(input.Delivery.TemplateID, input.Delivery.Locale)
|
||||
if err != nil {
|
||||
classification := classifyLookupError(err)
|
||||
return service.fail(ctx, input, classification, failureSummaryForLookup(input.Delivery, classification), nil)
|
||||
}
|
||||
|
||||
requiredPaths := resolved.RequiredVariablePaths()
|
||||
missingPaths := collectMissingPaths(input.Delivery.TemplateVariables, requiredPaths)
|
||||
if len(missingPaths) > 0 {
|
||||
result, failErr := service.fail(
|
||||
ctx,
|
||||
input,
|
||||
FailureMissingRequiredVariable,
|
||||
failureSummaryForMissingVariables(missingPaths),
|
||||
&resolved,
|
||||
)
|
||||
if failErr != nil {
|
||||
return Result{}, failErr
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
content, err := renderContent(resolved, input.Delivery.TemplateVariables)
|
||||
if err != nil {
|
||||
result, failErr := service.fail(
|
||||
ctx,
|
||||
input,
|
||||
FailureTemplateExecuteFailed,
|
||||
"template execution failed",
|
||||
&resolved,
|
||||
)
|
||||
if failErr != nil {
|
||||
return Result{}, failErr
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
renderedDelivery := input.Delivery
|
||||
renderedDelivery.Content = content
|
||||
renderedDelivery.Status = deliverydomain.StatusRendered
|
||||
renderedDelivery.LocaleFallbackUsed = resolved.LocaleFallbackUsed()
|
||||
renderedDelivery.UpdatedAt = service.clock.Now().UTC().Truncate(time.Millisecond)
|
||||
if err := renderedDelivery.Validate(); err != nil {
|
||||
return Result{}, fmt.Errorf("render delivery: build rendered delivery: %w", err)
|
||||
}
|
||||
|
||||
if err := service.store.MarkRendered(ctx, MarkRenderedInput{Delivery: renderedDelivery}); err != nil {
|
||||
return Result{}, fmt.Errorf("%w: store rendered delivery: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
service.recordStatusTransition(ctx, renderedDelivery)
|
||||
|
||||
result := Result{
|
||||
Outcome: OutcomeRendered,
|
||||
Delivery: renderedDelivery,
|
||||
ResolvedLocale: resolved.ResolvedLocale(),
|
||||
LocaleFallbackUsed: resolved.LocaleFallbackUsed(),
|
||||
TemplateVersion: resolved.Template().Version,
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return Result{}, fmt.Errorf("render delivery: build rendered result: %w", err)
|
||||
}
|
||||
span.SetAttributes(
|
||||
attribute.String("mail.resolved_locale", result.ResolvedLocale.String()),
|
||||
attribute.Bool("mail.locale_fallback_used", result.LocaleFallbackUsed),
|
||||
attribute.String("mail.status", string(renderedDelivery.Status)),
|
||||
)
|
||||
logArgs := logging.DeliveryAttemptAttrs(renderedDelivery, input.Attempt)
|
||||
logArgs = append(logArgs,
|
||||
"requested_locale", input.Delivery.Locale.String(),
|
||||
"resolved_locale", result.ResolvedLocale.String(),
|
||||
"locale_fallback_used", result.LocaleFallbackUsed,
|
||||
"template_version", result.TemplateVersion,
|
||||
)
|
||||
logArgs = append(logArgs, logging.TraceAttrsFromContext(ctx)...)
|
||||
if result.LocaleFallbackUsed {
|
||||
service.recordLocaleFallback(ctx, renderedDelivery.TemplateID.String(), input.Delivery.Locale.String(), result.ResolvedLocale.String())
|
||||
service.logger.Info("delivery rendered with locale fallback", logArgs...)
|
||||
} else {
|
||||
service.logger.Info("delivery rendered", logArgs...)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (service *Service) fail(
|
||||
ctx context.Context,
|
||||
input Input,
|
||||
classification FailureClassification,
|
||||
summary string,
|
||||
resolved *templatedir.ResolvedTemplate,
|
||||
) (Result, error) {
|
||||
failureAt := service.clock.Now().UTC().Truncate(time.Millisecond)
|
||||
if failureAt.Before(input.Attempt.ScheduledFor) {
|
||||
failureAt = input.Attempt.ScheduledFor
|
||||
}
|
||||
|
||||
failedDelivery := input.Delivery
|
||||
failedDelivery.Status = deliverydomain.StatusFailed
|
||||
failedDelivery.LastAttemptStatus = attempt.StatusRenderFailed
|
||||
failedDelivery.ProviderSummary = summary
|
||||
failedDelivery.UpdatedAt = failureAt
|
||||
failedDelivery.FailedAt = ptrTime(failureAt)
|
||||
|
||||
failedAttempt := input.Attempt
|
||||
failedAttempt.Status = attempt.StatusRenderFailed
|
||||
failedAttempt.StartedAt = ptrTime(failureAt)
|
||||
failedAttempt.FinishedAt = ptrTime(failureAt)
|
||||
failedAttempt.ProviderClassification = string(classification)
|
||||
failedAttempt.ProviderSummary = summary
|
||||
|
||||
storeInput := MarkRenderFailedInput{
|
||||
Delivery: failedDelivery,
|
||||
Attempt: failedAttempt,
|
||||
}
|
||||
if err := storeInput.Validate(); err != nil {
|
||||
return Result{}, fmt.Errorf("render delivery: build failed result: %w", err)
|
||||
}
|
||||
|
||||
if err := service.store.MarkRenderFailed(ctx, storeInput); err != nil {
|
||||
return Result{}, fmt.Errorf("%w: store failed delivery: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
service.recordStatusTransition(ctx, failedDelivery)
|
||||
service.recordAttemptOutcome(ctx, failedAttempt.Status, failedDelivery.Source)
|
||||
|
||||
result := Result{
|
||||
Outcome: OutcomeFailed,
|
||||
Delivery: failedDelivery,
|
||||
Attempt: &failedAttempt,
|
||||
FailureClassification: classification,
|
||||
}
|
||||
if resolved != nil {
|
||||
result.ResolvedLocale = resolved.ResolvedLocale()
|
||||
result.LocaleFallbackUsed = resolved.LocaleFallbackUsed()
|
||||
result.TemplateVersion = resolved.Template().Version
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return Result{}, fmt.Errorf("render delivery: build failed result: %w", err)
|
||||
}
|
||||
spanAttrs := []attribute.KeyValue{
|
||||
attribute.String("mail.status", string(failedDelivery.Status)),
|
||||
attribute.String("mail.attempt_status", string(failedAttempt.Status)),
|
||||
attribute.String("mail.failure_classification", string(classification)),
|
||||
}
|
||||
if resolved != nil {
|
||||
spanAttrs = append(spanAttrs, attribute.String("mail.resolved_locale", resolved.ResolvedLocale().String()))
|
||||
}
|
||||
oteltrace.SpanFromContext(ctx).SetAttributes(spanAttrs...)
|
||||
logArgs := logging.DeliveryAttemptAttrs(failedDelivery, failedAttempt)
|
||||
logArgs = append(logArgs,
|
||||
"failure_classification", string(classification),
|
||||
"provider_summary", summary,
|
||||
)
|
||||
if resolved != nil {
|
||||
logArgs = append(logArgs,
|
||||
"requested_locale", input.Delivery.Locale.String(),
|
||||
"resolved_locale", resolved.ResolvedLocale().String(),
|
||||
"locale_fallback_used", resolved.LocaleFallbackUsed(),
|
||||
)
|
||||
}
|
||||
logArgs = append(logArgs, logging.TraceAttrsFromContext(ctx)...)
|
||||
service.logger.Warn("delivery rendering failed", logArgs...)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
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) recordAttemptOutcome(ctx context.Context, status attempt.Status, source deliverydomain.Source) {
|
||||
if service == nil || service.telemetry == nil {
|
||||
return
|
||||
}
|
||||
|
||||
service.telemetry.RecordAttemptOutcome(ctx, string(status), string(source))
|
||||
}
|
||||
|
||||
func (service *Service) recordLocaleFallback(ctx context.Context, templateID string, requestedLocale string, resolvedLocale string) {
|
||||
if service == nil || service.telemetry == nil {
|
||||
return
|
||||
}
|
||||
|
||||
service.telemetry.RecordLocaleFallback(ctx, templateID, requestedLocale, resolvedLocale)
|
||||
}
|
||||
|
||||
func renderContent(resolved templatedir.ResolvedTemplate, variables map[string]any) (deliverydomain.Content, error) {
|
||||
subject, err := resolved.ExecuteSubject(variables)
|
||||
if err != nil {
|
||||
return deliverydomain.Content{}, err
|
||||
}
|
||||
|
||||
textBody, err := resolved.ExecuteText(variables)
|
||||
if err != nil {
|
||||
return deliverydomain.Content{}, err
|
||||
}
|
||||
|
||||
htmlBody, ok, err := resolved.ExecuteHTML(variables)
|
||||
if err != nil {
|
||||
return deliverydomain.Content{}, err
|
||||
}
|
||||
if !ok {
|
||||
htmlBody = ""
|
||||
}
|
||||
|
||||
content := deliverydomain.Content{
|
||||
Subject: subject,
|
||||
TextBody: textBody,
|
||||
HTMLBody: htmlBody,
|
||||
}
|
||||
if err := content.ValidateMaterialized(); err != nil {
|
||||
return deliverydomain.Content{}, err
|
||||
}
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func collectMissingPaths(variables map[string]any, requiredPaths []string) []string {
|
||||
missing := make([]string, 0)
|
||||
for _, path := range requiredPaths {
|
||||
if hasJSONPath(variables, path) {
|
||||
continue
|
||||
}
|
||||
missing = append(missing, path)
|
||||
}
|
||||
|
||||
return missing
|
||||
}
|
||||
|
||||
func hasJSONPath(value map[string]any, path string) bool {
|
||||
if len(value) == 0 || strings.TrimSpace(path) == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
current := any(value)
|
||||
for _, part := range strings.Split(path, ".") {
|
||||
typed, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
next, ok := typed[part]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
current = next
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func classifyLookupError(err error) FailureClassification {
|
||||
switch {
|
||||
case errors.Is(err, templatedir.ErrFallbackMissing):
|
||||
return FailureFallbackMissing
|
||||
case errors.Is(err, templatedir.ErrTemplateParseFailed):
|
||||
return FailureTemplateParseFailed
|
||||
default:
|
||||
return FailureTemplateNotFound
|
||||
}
|
||||
}
|
||||
|
||||
func failureSummaryForLookup(record deliverydomain.Delivery, classification FailureClassification) string {
|
||||
switch classification {
|
||||
case FailureFallbackMissing:
|
||||
return fmt.Sprintf(
|
||||
"template %q locale %q and fallback %q are unavailable",
|
||||
record.TemplateID,
|
||||
record.Locale,
|
||||
common.Locale("en"),
|
||||
)
|
||||
case FailureTemplateParseFailed:
|
||||
return "template parsing failed"
|
||||
default:
|
||||
return fmt.Sprintf("template %q is not available", record.TemplateID)
|
||||
}
|
||||
}
|
||||
|
||||
func failureSummaryForMissingVariables(missingPaths []string) string {
|
||||
cloned := append([]string(nil), missingPaths...)
|
||||
slices.Sort(cloned)
|
||||
|
||||
return "missing required variables: " + strings.Join(cloned, ", ")
|
||||
}
|
||||
|
||||
func ptrTime(value time.Time) *time.Time {
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package renderdelivery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
templatedir "galaxy/mail/internal/adapters/templates"
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
)
|
||||
|
||||
func TestServiceExecuteRendersExactLocale(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
catalog := newTestCatalog(t, map[string]string{
|
||||
filepath.Join("auth.login_code", "en", "subject.tmpl"): "Your login code",
|
||||
filepath.Join("auth.login_code", "en", "text.tmpl"): "Code: {{.code}}",
|
||||
filepath.Join("game.turn_ready", "fr-fr", "subject.tmpl"): "Tour {{.turn_number}}",
|
||||
filepath.Join("game.turn_ready", "fr-fr", "text.tmpl"): "Bonjour {{with .player}}{{.name}}{{end}}",
|
||||
filepath.Join("game.turn_ready", "fr-fr", "html.tmpl"): "<p>{{.player.name}}</p>",
|
||||
})
|
||||
|
||||
store := &stubStore{}
|
||||
service := newTestService(t, Config{
|
||||
Catalog: catalog,
|
||||
Store: store,
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
})
|
||||
|
||||
result, err := service.Execute(context.Background(), validInput(t, "fr-FR"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeRendered, result.Outcome)
|
||||
require.Equal(t, common.Locale("fr-FR"), result.ResolvedLocale)
|
||||
require.False(t, result.LocaleFallbackUsed)
|
||||
require.NotEmpty(t, result.TemplateVersion)
|
||||
require.Nil(t, result.Attempt)
|
||||
require.Equal(t, deliverydomain.StatusRendered, result.Delivery.Status)
|
||||
require.Equal(t, deliverydomain.Content{
|
||||
Subject: "Tour 54",
|
||||
TextBody: "Bonjour Pilot",
|
||||
HTMLBody: "<p>Pilot</p>",
|
||||
}, result.Delivery.Content)
|
||||
require.Len(t, store.renderedInputs, 1)
|
||||
require.Empty(t, store.failedInputs)
|
||||
}
|
||||
|
||||
func TestServiceExecuteFallsBackToEnglish(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
catalog := newTestCatalog(t, map[string]string{
|
||||
filepath.Join("auth.login_code", "en", "subject.tmpl"): "Your login code",
|
||||
filepath.Join("auth.login_code", "en", "text.tmpl"): "Code: {{.code}}",
|
||||
filepath.Join("game.turn_ready", "en", "subject.tmpl"): "Turn {{.turn_number}}",
|
||||
filepath.Join("game.turn_ready", "en", "text.tmpl"): "Hello {{.player.name}}",
|
||||
})
|
||||
|
||||
store := &stubStore{}
|
||||
service := newTestService(t, Config{
|
||||
Catalog: catalog,
|
||||
Store: store,
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
})
|
||||
|
||||
result, err := service.Execute(context.Background(), validInput(t, "fr-FR"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeRendered, result.Outcome)
|
||||
require.Equal(t, common.Locale("en"), result.ResolvedLocale)
|
||||
require.True(t, result.LocaleFallbackUsed)
|
||||
require.True(t, result.Delivery.LocaleFallbackUsed)
|
||||
}
|
||||
|
||||
func TestServiceExecuteRecordsLocaleFallbackAndLogsFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
catalog := newTestCatalog(t, map[string]string{
|
||||
filepath.Join("auth.login_code", "en", "subject.tmpl"): "Your login code",
|
||||
filepath.Join("auth.login_code", "en", "text.tmpl"): "Code: {{.code}}",
|
||||
filepath.Join("game.turn_ready", "en", "subject.tmpl"): "Turn {{.turn_number}}",
|
||||
filepath.Join("game.turn_ready", "en", "text.tmpl"): "Hello {{.player.name}}",
|
||||
})
|
||||
|
||||
telemetry := &stubTelemetry{}
|
||||
loggerBuffer := &bytes.Buffer{}
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
||||
|
||||
service := newTestService(t, Config{
|
||||
Catalog: catalog,
|
||||
Store: &stubStore{},
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
Telemetry: telemetry,
|
||||
TracerProvider: tracerProvider,
|
||||
Logger: slog.New(slog.NewJSONHandler(loggerBuffer, nil)),
|
||||
})
|
||||
|
||||
_, err := service.Execute(context.Background(), validInput(t, "fr-FR"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"notification:rendered"}, telemetry.statuses)
|
||||
require.Equal(t, []string{"game.turn_ready:fr-FR:en"}, telemetry.fallbacks)
|
||||
require.Contains(t, loggerBuffer.String(), "\"delivery_id\":\"delivery-123\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"source\":\"notification\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"template_id\":\"game.turn_ready\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"attempt_no\":1")
|
||||
require.Contains(t, loggerBuffer.String(), "\"otel_trace_id\":")
|
||||
require.True(t, hasRenderSpanNamed(recorder.Ended(), "mail.render_delivery"))
|
||||
}
|
||||
|
||||
func TestServiceExecuteFailsOnMissingRequiredVariable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
catalog := newTestCatalog(t, map[string]string{
|
||||
filepath.Join("auth.login_code", "en", "subject.tmpl"): "Your login code",
|
||||
filepath.Join("auth.login_code", "en", "text.tmpl"): "Code: {{.code}}",
|
||||
filepath.Join("game.turn_ready", "en", "subject.tmpl"): "Turn {{.turn_number}}",
|
||||
filepath.Join("game.turn_ready", "en", "text.tmpl"): "Hello {{.player.name}}",
|
||||
})
|
||||
|
||||
store := &stubStore{}
|
||||
service := newTestService(t, Config{
|
||||
Catalog: catalog,
|
||||
Store: store,
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
})
|
||||
|
||||
input := validInput(t, "en")
|
||||
delete(input.Delivery.TemplateVariables, "player")
|
||||
|
||||
result, err := service.Execute(context.Background(), input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeFailed, result.Outcome)
|
||||
require.Equal(t, FailureMissingRequiredVariable, result.FailureClassification)
|
||||
require.NotNil(t, result.Attempt)
|
||||
require.Equal(t, attempt.StatusRenderFailed, result.Attempt.Status)
|
||||
require.Equal(t, "missing required variables: player.name", result.Attempt.ProviderSummary)
|
||||
require.Len(t, store.failedInputs, 1)
|
||||
require.Empty(t, store.renderedInputs)
|
||||
}
|
||||
|
||||
func TestServiceExecuteFailsOnTemplateExecutionError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
catalog := newTestCatalog(t, map[string]string{
|
||||
filepath.Join("auth.login_code", "en", "subject.tmpl"): "Your login code",
|
||||
filepath.Join("auth.login_code", "en", "text.tmpl"): "Code: {{.code}}",
|
||||
filepath.Join("game.turn_ready", "en", "subject.tmpl"): "{{call .callable}}",
|
||||
filepath.Join("game.turn_ready", "en", "text.tmpl"): "Hello {{.player.name}}",
|
||||
})
|
||||
|
||||
store := &stubStore{}
|
||||
service := newTestService(t, Config{
|
||||
Catalog: catalog,
|
||||
Store: store,
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
})
|
||||
|
||||
input := validInput(t, "en")
|
||||
input.Delivery.TemplateVariables["callable"] = "not-a-func"
|
||||
|
||||
result, err := service.Execute(context.Background(), input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeFailed, result.Outcome)
|
||||
require.Equal(t, FailureTemplateExecuteFailed, result.FailureClassification)
|
||||
require.Equal(t, "template execution failed", result.Attempt.ProviderSummary)
|
||||
}
|
||||
|
||||
func TestServiceExecuteClassifiesTemplateNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service := newTestService(t, Config{
|
||||
Catalog: stubCatalog{
|
||||
lookupErr: templatedir.ErrTemplateNotFound,
|
||||
},
|
||||
Store: &stubStore{},
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
})
|
||||
|
||||
result, err := service.Execute(context.Background(), validInput(t, "en"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeFailed, result.Outcome)
|
||||
require.Equal(t, FailureTemplateNotFound, result.FailureClassification)
|
||||
}
|
||||
|
||||
func TestServiceExecuteClassifiesFallbackMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service := newTestService(t, Config{
|
||||
Catalog: stubCatalog{
|
||||
lookupErr: templatedir.ErrFallbackMissing,
|
||||
},
|
||||
Store: &stubStore{},
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
})
|
||||
|
||||
result, err := service.Execute(context.Background(), validInput(t, "fr-FR"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeFailed, result.Outcome)
|
||||
require.Equal(t, FailureFallbackMissing, result.FailureClassification)
|
||||
}
|
||||
|
||||
func TestServiceExecuteClassifiesTemplateParseFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service := newTestService(t, Config{
|
||||
Catalog: stubCatalog{
|
||||
lookupErr: templatedir.ErrTemplateParseFailed,
|
||||
},
|
||||
Store: &stubStore{},
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
})
|
||||
|
||||
result, err := service.Execute(context.Background(), validInput(t, "en"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeFailed, result.Outcome)
|
||||
require.Equal(t, FailureTemplateParseFailed, result.FailureClassification)
|
||||
}
|
||||
|
||||
func TestServiceExecuteReturnsServiceUnavailableOnStoreFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
catalog := newTestCatalog(t, map[string]string{
|
||||
filepath.Join("auth.login_code", "en", "subject.tmpl"): "Your login code",
|
||||
filepath.Join("auth.login_code", "en", "text.tmpl"): "Code: {{.code}}",
|
||||
filepath.Join("game.turn_ready", "en", "subject.tmpl"): "Turn {{.turn_number}}",
|
||||
filepath.Join("game.turn_ready", "en", "text.tmpl"): "Hello {{.player.name}}",
|
||||
})
|
||||
|
||||
service := newTestService(t, Config{
|
||||
Catalog: catalog,
|
||||
Store: &stubStore{
|
||||
markRenderedErr: errors.New("redis unavailable"),
|
||||
},
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
})
|
||||
|
||||
_, err := service.Execute(context.Background(), validInput(t, "en"))
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrServiceUnavailable)
|
||||
}
|
||||
|
||||
type stubStore struct {
|
||||
renderedInputs []MarkRenderedInput
|
||||
failedInputs []MarkRenderFailedInput
|
||||
markRenderedErr error
|
||||
markFailedErr error
|
||||
}
|
||||
|
||||
func (store *stubStore) MarkRendered(_ context.Context, input MarkRenderedInput) error {
|
||||
store.renderedInputs = append(store.renderedInputs, input)
|
||||
return store.markRenderedErr
|
||||
}
|
||||
|
||||
func (store *stubStore) MarkRenderFailed(_ context.Context, input MarkRenderFailedInput) error {
|
||||
store.failedInputs = append(store.failedInputs, input)
|
||||
return store.markFailedErr
|
||||
}
|
||||
|
||||
type stubCatalog struct {
|
||||
lookupResult templatedir.ResolvedTemplate
|
||||
lookupErr error
|
||||
}
|
||||
|
||||
func (catalog stubCatalog) Lookup(common.TemplateID, common.Locale) (templatedir.ResolvedTemplate, error) {
|
||||
return catalog.lookupResult, catalog.lookupErr
|
||||
}
|
||||
|
||||
type stubClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock stubClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T, cfg Config) *Service {
|
||||
t.Helper()
|
||||
|
||||
service, err := New(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func newTestCatalog(t *testing.T, files map[string]string) *templatedir.Catalog {
|
||||
t.Helper()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
for path, contents := range files {
|
||||
absolutePath := filepath.Join(rootDir, path)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(absolutePath), 0o755))
|
||||
require.NoError(t, os.WriteFile(absolutePath, []byte(contents), 0o644))
|
||||
}
|
||||
|
||||
catalog, err := templatedir.NewCatalog(rootDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
return catalog
|
||||
}
|
||||
|
||||
type stubTelemetry struct {
|
||||
statuses []string
|
||||
attempts []string
|
||||
fallbacks []string
|
||||
}
|
||||
|
||||
func (telemetry *stubTelemetry) RecordDeliveryStatusTransition(_ context.Context, status string, source string) {
|
||||
telemetry.statuses = append(telemetry.statuses, source+":"+status)
|
||||
}
|
||||
|
||||
func (telemetry *stubTelemetry) RecordAttemptOutcome(_ context.Context, status string, source string) {
|
||||
telemetry.attempts = append(telemetry.attempts, source+":"+status)
|
||||
}
|
||||
|
||||
func (telemetry *stubTelemetry) RecordLocaleFallback(_ context.Context, templateID string, requestedLocale string, resolvedLocale string) {
|
||||
telemetry.fallbacks = append(telemetry.fallbacks, templateID+":"+requestedLocale+":"+resolvedLocale)
|
||||
}
|
||||
|
||||
func hasRenderSpanNamed(spans []sdktrace.ReadOnlySpan, name string) bool {
|
||||
for _, span := range spans {
|
||||
if span.Name() == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func validInput(t *testing.T, localeValue string) Input {
|
||||
t.Helper()
|
||||
|
||||
locale, err := common.ParseLocale(localeValue)
|
||||
require.NoError(t, err)
|
||||
|
||||
createdAt := fixedNow().Add(-time.Minute)
|
||||
deliveryRecord := deliverydomain.Delivery{
|
||||
DeliveryID: common.DeliveryID("delivery-123"),
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeTemplate,
|
||||
TemplateID: common.TemplateID("game.turn_ready"),
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: []common.Email{common.Email("pilot@example.com")},
|
||||
},
|
||||
Locale: locale,
|
||||
TemplateVariables: map[string]any{
|
||||
"turn_number": float64(54),
|
||||
"player": map[string]any{
|
||||
"name": "Pilot",
|
||||
},
|
||||
},
|
||||
IdempotencyKey: common.IdempotencyKey("notification:delivery-123"),
|
||||
Status: deliverydomain.StatusQueued,
|
||||
AttemptCount: 1,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}
|
||||
require.NoError(t, deliveryRecord.Validate())
|
||||
|
||||
scheduledFor := createdAt
|
||||
attemptRecord := attempt.Attempt{
|
||||
DeliveryID: deliveryRecord.DeliveryID,
|
||||
AttemptNo: 1,
|
||||
ScheduledFor: scheduledFor,
|
||||
Status: attempt.StatusScheduled,
|
||||
}
|
||||
require.NoError(t, attemptRecord.Validate())
|
||||
|
||||
return Input{
|
||||
Delivery: deliveryRecord,
|
||||
Attempt: attemptRecord,
|
||||
}
|
||||
}
|
||||
|
||||
func fixedNow() time.Time {
|
||||
return time.Unix(1_775_121_700, 0).UTC()
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
// Package resenddelivery implements trusted operator resend by clone creation.
|
||||
package resenddelivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/logging"
|
||||
"galaxy/mail/internal/service/acceptgenericdelivery"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
oteltrace "go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotFound reports that the requested original delivery does not exist.
|
||||
ErrNotFound = errors.New("resend delivery not found")
|
||||
|
||||
// ErrNotAllowed reports that the original delivery is not in a terminal
|
||||
// state and therefore cannot be cloned for resend.
|
||||
ErrNotAllowed = errors.New("resend delivery not allowed")
|
||||
|
||||
// ErrServiceUnavailable reports that clone creation could not load or
|
||||
// persist durable state safely.
|
||||
ErrServiceUnavailable = errors.New("resend delivery service unavailable")
|
||||
)
|
||||
|
||||
const tracerName = "galaxy/mail/resenddelivery"
|
||||
|
||||
// Input stores one trusted resend request by original delivery identifier.
|
||||
type Input struct {
|
||||
// DeliveryID stores the original accepted delivery identifier to clone.
|
||||
DeliveryID common.DeliveryID
|
||||
}
|
||||
|
||||
// Validate reports whether input contains a complete resend target.
|
||||
func (input Input) Validate() error {
|
||||
if err := input.DeliveryID.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery id: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Result stores the new clone delivery identifier created by resend.
|
||||
type Result struct {
|
||||
// DeliveryID stores the identifier of the newly created clone delivery.
|
||||
DeliveryID common.DeliveryID
|
||||
}
|
||||
|
||||
// Validate reports whether result contains a usable clone delivery identifier.
|
||||
func (result Result) Validate() error {
|
||||
if err := result.DeliveryID.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery id: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateResendInput stores the durable write set required for one clone-only
|
||||
// resend operation.
|
||||
type CreateResendInput struct {
|
||||
// Delivery stores the new cloned delivery record.
|
||||
Delivery deliverydomain.Delivery
|
||||
|
||||
// FirstAttempt stores the initial scheduled attempt of the clone.
|
||||
FirstAttempt attempt.Attempt
|
||||
|
||||
// DeliveryPayload stores the optional cloned raw attachment payload bundle.
|
||||
DeliveryPayload *acceptgenericdelivery.DeliveryPayload
|
||||
}
|
||||
|
||||
// Validate reports whether input contains a complete resend write set.
|
||||
func (input CreateResendInput) Validate() error {
|
||||
if err := input.Delivery.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery: %w", err)
|
||||
}
|
||||
if input.Delivery.Source != deliverydomain.SourceOperatorResend {
|
||||
return fmt.Errorf("delivery source must be %q", deliverydomain.SourceOperatorResend)
|
||||
}
|
||||
if input.Delivery.Status != deliverydomain.StatusQueued {
|
||||
return fmt.Errorf("delivery status must be %q", deliverydomain.StatusQueued)
|
||||
}
|
||||
if input.Delivery.AttemptCount != 1 {
|
||||
return errors.New("delivery attempt count must equal 1")
|
||||
}
|
||||
if input.Delivery.LastAttemptStatus != "" {
|
||||
return errors.New("delivery last attempt status must be empty")
|
||||
}
|
||||
if input.Delivery.ProviderSummary != "" {
|
||||
return errors.New("delivery provider summary must be empty")
|
||||
}
|
||||
if input.Delivery.SentAt != nil || input.Delivery.SuppressedAt != nil || input.Delivery.FailedAt != nil || input.Delivery.DeadLetteredAt != nil {
|
||||
return errors.New("delivery terminal timestamps must be empty")
|
||||
}
|
||||
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.AttemptNo != 1 {
|
||||
return errors.New("first attempt number must equal 1")
|
||||
}
|
||||
if input.FirstAttempt.Status != attempt.StatusScheduled {
|
||||
return fmt.Errorf("first attempt status must be %q", attempt.StatusScheduled)
|
||||
}
|
||||
if input.DeliveryPayload != nil {
|
||||
if err := input.DeliveryPayload.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery payload: %w", err)
|
||||
}
|
||||
if input.DeliveryPayload.DeliveryID != input.Delivery.DeliveryID {
|
||||
return errors.New("delivery payload delivery id must match delivery id")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Store provides the durable delivery state required by clone-only resend.
|
||||
type Store interface {
|
||||
// GetDelivery loads one accepted delivery by its identifier.
|
||||
GetDelivery(context.Context, common.DeliveryID) (deliverydomain.Delivery, bool, error)
|
||||
|
||||
// GetDeliveryPayload loads the raw attachment payload bundle of deliveryID
|
||||
// when one exists.
|
||||
GetDeliveryPayload(context.Context, common.DeliveryID) (acceptgenericdelivery.DeliveryPayload, bool, error)
|
||||
|
||||
// CreateResend atomically creates the cloned delivery, its first attempt,
|
||||
// the optional cloned delivery payload, and the related delivery indexes.
|
||||
CreateResend(context.Context, CreateResendInput) 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 resend metrics.
|
||||
type Telemetry interface {
|
||||
// RecordDeliveryStatusTransition records one durable delivery status
|
||||
// transition.
|
||||
RecordDeliveryStatusTransition(context.Context, string, string)
|
||||
}
|
||||
|
||||
// Config stores the dependencies used by Service.
|
||||
type Config struct {
|
||||
// Store owns durable resend state.
|
||||
Store Store
|
||||
|
||||
// DeliveryIDGenerator builds internal clone identifiers.
|
||||
DeliveryIDGenerator DeliveryIDGenerator
|
||||
|
||||
// Clock provides wall-clock timestamps.
|
||||
Clock Clock
|
||||
|
||||
// Telemetry records low-cardinality resend metrics.
|
||||
Telemetry Telemetry
|
||||
|
||||
// TracerProvider constructs the application span recorder used by resend.
|
||||
TracerProvider oteltrace.TracerProvider
|
||||
|
||||
// Logger writes structured resend logs.
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// Service executes clone-only trusted resend requests.
|
||||
type Service struct {
|
||||
store Store
|
||||
deliveryIDGenerator DeliveryIDGenerator
|
||||
clock Clock
|
||||
telemetry Telemetry
|
||||
tracerProvider oteltrace.TracerProvider
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New constructs Service from cfg.
|
||||
func New(cfg Config) (*Service, error) {
|
||||
switch {
|
||||
case cfg.Store == nil:
|
||||
return nil, errors.New("new resend delivery service: nil store")
|
||||
case cfg.DeliveryIDGenerator == nil:
|
||||
return nil, errors.New("new resend delivery service: nil delivery id generator")
|
||||
case cfg.Clock == nil:
|
||||
return nil, errors.New("new resend delivery service: nil clock")
|
||||
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", "resend_delivery"),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Execute clones one terminal delivery into a new queued delivery with a
|
||||
// fresh first attempt.
|
||||
func (service *Service) Execute(ctx context.Context, input Input) (Result, error) {
|
||||
if ctx == nil {
|
||||
return Result{}, errors.New("execute resend delivery: nil context")
|
||||
}
|
||||
if service == nil {
|
||||
return Result{}, errors.New("execute resend delivery: nil service")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return Result{}, fmt.Errorf("execute resend delivery: %w", err)
|
||||
}
|
||||
|
||||
ctx, span := service.tracerProvider.Tracer(tracerName).Start(
|
||||
ctx,
|
||||
"mail.resend_delivery",
|
||||
oteltrace.WithAttributes(attribute.String("mail.parent_delivery_id", input.DeliveryID.String())),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
original, found, err := service.store.GetDelivery(ctx, input.DeliveryID)
|
||||
switch {
|
||||
case err != nil:
|
||||
return Result{}, fmt.Errorf("%w: load original delivery: %v", ErrServiceUnavailable, err)
|
||||
case !found:
|
||||
return Result{}, ErrNotFound
|
||||
case !original.Status.AllowsResend():
|
||||
return Result{}, ErrNotAllowed
|
||||
}
|
||||
|
||||
now := service.clock.Now().UTC().Truncate(time.Millisecond)
|
||||
cloneID, err := service.deliveryIDGenerator.NewDeliveryID()
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("%w: generate delivery id: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
|
||||
clone := buildClonedDelivery(original, cloneID, now)
|
||||
firstAttempt := attempt.Attempt{
|
||||
DeliveryID: cloneID,
|
||||
AttemptNo: 1,
|
||||
ScheduledFor: now,
|
||||
Status: attempt.StatusScheduled,
|
||||
}
|
||||
|
||||
var clonedPayload *acceptgenericdelivery.DeliveryPayload
|
||||
if len(original.Attachments) > 0 {
|
||||
payload, found, err := service.store.GetDeliveryPayload(ctx, original.DeliveryID)
|
||||
switch {
|
||||
case err != nil:
|
||||
return Result{}, fmt.Errorf("%w: load original delivery payload: %v", ErrServiceUnavailable, err)
|
||||
case !found:
|
||||
return Result{}, fmt.Errorf("%w: missing original delivery payload for %q", ErrServiceUnavailable, original.DeliveryID)
|
||||
default:
|
||||
cloned := cloneDeliveryPayload(payload, cloneID)
|
||||
clonedPayload = &cloned
|
||||
}
|
||||
}
|
||||
|
||||
createInput := CreateResendInput{
|
||||
Delivery: clone,
|
||||
FirstAttempt: firstAttempt,
|
||||
DeliveryPayload: clonedPayload,
|
||||
}
|
||||
if err := createInput.Validate(); err != nil {
|
||||
return Result{}, fmt.Errorf("%w: build resend input: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
if err := service.store.CreateResend(ctx, createInput); err != nil {
|
||||
return Result{}, fmt.Errorf("%w: create resend clone: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
service.recordStatusTransition(ctx, createInput.Delivery)
|
||||
|
||||
result := Result{DeliveryID: cloneID}
|
||||
if err := result.Validate(); err != nil {
|
||||
return Result{}, fmt.Errorf("%w: invalid result: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
span.SetAttributes(
|
||||
attribute.String("mail.delivery_id", cloneID.String()),
|
||||
attribute.String("mail.source", string(createInput.Delivery.Source)),
|
||||
)
|
||||
logArgs := logging.DeliveryAttrs(createInput.Delivery)
|
||||
logArgs = append(logArgs,
|
||||
"parent_delivery_id", original.DeliveryID.String(),
|
||||
"status", string(createInput.Delivery.Status),
|
||||
)
|
||||
logArgs = append(logArgs, logging.TraceAttrsFromContext(ctx)...)
|
||||
service.logger.Info("resend clone created", logArgs...)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
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 buildClonedDelivery(original deliverydomain.Delivery, cloneID common.DeliveryID, now time.Time) deliverydomain.Delivery {
|
||||
return deliverydomain.Delivery{
|
||||
DeliveryID: cloneID,
|
||||
ResendParentDeliveryID: original.DeliveryID,
|
||||
Source: deliverydomain.SourceOperatorResend,
|
||||
PayloadMode: original.PayloadMode,
|
||||
TemplateID: original.TemplateID,
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: append([]common.Email(nil), original.Envelope.To...),
|
||||
Cc: append([]common.Email(nil), original.Envelope.Cc...),
|
||||
Bcc: append([]common.Email(nil), original.Envelope.Bcc...),
|
||||
ReplyTo: append([]common.Email(nil), original.Envelope.ReplyTo...),
|
||||
},
|
||||
Content: original.Content,
|
||||
Attachments: append([]common.AttachmentMetadata(nil), original.Attachments...),
|
||||
Locale: original.Locale,
|
||||
LocaleFallbackUsed: original.LocaleFallbackUsed,
|
||||
TemplateVariables: cloneJSONObject(original.TemplateVariables),
|
||||
IdempotencyKey: common.IdempotencyKey("operator:resend:" + original.DeliveryID.String()),
|
||||
Status: deliverydomain.StatusQueued,
|
||||
AttemptCount: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func cloneDeliveryPayload(payload acceptgenericdelivery.DeliveryPayload, cloneID common.DeliveryID) acceptgenericdelivery.DeliveryPayload {
|
||||
cloned := acceptgenericdelivery.DeliveryPayload{
|
||||
DeliveryID: cloneID,
|
||||
Attachments: make([]acceptgenericdelivery.AttachmentPayload, len(payload.Attachments)),
|
||||
}
|
||||
copy(cloned.Attachments, payload.Attachments)
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneJSONObject(value map[string]any) map[string]any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make(map[string]any, len(value))
|
||||
for key, entry := range value {
|
||||
cloned[key] = entry
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package resenddelivery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/service/acceptgenericdelivery"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
)
|
||||
|
||||
func TestServiceExecuteRejectsNonTerminalStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []deliverydomain.Status{
|
||||
deliverydomain.StatusAccepted,
|
||||
deliverydomain.StatusQueued,
|
||||
deliverydomain.StatusRendered,
|
||||
deliverydomain.StatusSending,
|
||||
}
|
||||
|
||||
for _, status := range tests {
|
||||
status := status
|
||||
|
||||
t.Run(string(status), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := validOriginalDelivery()
|
||||
record.Status = status
|
||||
record.SentAt = nil
|
||||
record.FailedAt = nil
|
||||
record.DeadLetteredAt = nil
|
||||
record.SuppressedAt = nil
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
store := &stubStore{delivery: &record}
|
||||
service := newTestService(t, Config{
|
||||
Store: store,
|
||||
DeliveryIDGenerator: &stubIDGenerator{ids: []common.DeliveryID{"clone-1"}},
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
})
|
||||
|
||||
_, err := service.Execute(context.Background(), Input{DeliveryID: record.DeliveryID})
|
||||
require.ErrorIs(t, err, ErrNotAllowed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceExecuteCreatesLinkedClone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
original := validOriginalDelivery()
|
||||
originalCopy := original
|
||||
payload := validPayload(original.DeliveryID)
|
||||
store := &stubStore{
|
||||
delivery: &original,
|
||||
payload: &payload,
|
||||
}
|
||||
service := newTestService(t, Config{
|
||||
Store: store,
|
||||
DeliveryIDGenerator: &stubIDGenerator{ids: []common.DeliveryID{"clone-123"}},
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
})
|
||||
|
||||
result, err := service.Execute(context.Background(), Input{DeliveryID: original.DeliveryID})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Result{DeliveryID: common.DeliveryID("clone-123")}, result)
|
||||
require.Len(t, store.createInputs, 1)
|
||||
|
||||
createInput := store.createInputs[0]
|
||||
require.Equal(t, common.DeliveryID("clone-123"), createInput.Delivery.DeliveryID)
|
||||
require.Equal(t, original.DeliveryID, createInput.Delivery.ResendParentDeliveryID)
|
||||
require.Equal(t, deliverydomain.SourceOperatorResend, createInput.Delivery.Source)
|
||||
require.Equal(t, common.IdempotencyKey("operator:resend:"+original.DeliveryID.String()), createInput.Delivery.IdempotencyKey)
|
||||
require.Equal(t, deliverydomain.StatusQueued, createInput.Delivery.Status)
|
||||
require.Equal(t, 1, createInput.Delivery.AttemptCount)
|
||||
require.Empty(t, createInput.Delivery.LastAttemptStatus)
|
||||
require.Nil(t, createInput.Delivery.SentAt)
|
||||
require.Nil(t, createInput.Delivery.FailedAt)
|
||||
require.Equal(t, attempt.StatusScheduled, createInput.FirstAttempt.Status)
|
||||
require.Equal(t, 1, createInput.FirstAttempt.AttemptNo)
|
||||
require.NotNil(t, createInput.DeliveryPayload)
|
||||
require.Equal(t, common.DeliveryID("clone-123"), createInput.DeliveryPayload.DeliveryID)
|
||||
require.Equal(t, payload.Attachments, createInput.DeliveryPayload.Attachments)
|
||||
require.Equal(t, originalCopy, original)
|
||||
}
|
||||
|
||||
func TestServiceExecuteLogsCloneCreationAndCreatesSpan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
original := validOriginalDelivery()
|
||||
payload := validPayload(original.DeliveryID)
|
||||
loggerBuffer := &bytes.Buffer{}
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
||||
telemetry := &stubTelemetry{}
|
||||
|
||||
store := &stubStore{
|
||||
delivery: &original,
|
||||
payload: &payload,
|
||||
}
|
||||
service := newTestService(t, Config{
|
||||
Store: store,
|
||||
DeliveryIDGenerator: &stubIDGenerator{ids: []common.DeliveryID{"clone-456"}},
|
||||
Clock: stubClock{now: fixedNow()},
|
||||
Telemetry: telemetry,
|
||||
TracerProvider: tracerProvider,
|
||||
Logger: slog.New(slog.NewJSONHandler(loggerBuffer, nil)),
|
||||
})
|
||||
|
||||
_, err := service.Execute(context.Background(), Input{DeliveryID: original.DeliveryID})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"operator_resend:queued"}, telemetry.statuses)
|
||||
require.Contains(t, loggerBuffer.String(), "\"delivery_id\":\"clone-456\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"source\":\"operator_resend\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"template_id\":\"game.turn_ready\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"otel_trace_id\":")
|
||||
require.True(t, hasResendSpanNamed(recorder.Ended(), "mail.resend_delivery"))
|
||||
}
|
||||
|
||||
type stubStore struct {
|
||||
delivery *deliverydomain.Delivery
|
||||
payload *acceptgenericdelivery.DeliveryPayload
|
||||
createInputs []CreateResendInput
|
||||
}
|
||||
|
||||
func (store *stubStore) GetDelivery(context.Context, common.DeliveryID) (deliverydomain.Delivery, bool, error) {
|
||||
if store.delivery == nil {
|
||||
return deliverydomain.Delivery{}, false, nil
|
||||
}
|
||||
|
||||
return *store.delivery, true, nil
|
||||
}
|
||||
|
||||
func (store *stubStore) GetDeliveryPayload(context.Context, common.DeliveryID) (acceptgenericdelivery.DeliveryPayload, bool, error) {
|
||||
if store.payload == nil {
|
||||
return acceptgenericdelivery.DeliveryPayload{}, false, nil
|
||||
}
|
||||
|
||||
return *store.payload, true, nil
|
||||
}
|
||||
|
||||
func (store *stubStore) CreateResend(_ context.Context, input CreateResendInput) error {
|
||||
store.createInputs = append(store.createInputs, input)
|
||||
return nil
|
||||
}
|
||||
|
||||
type stubIDGenerator struct {
|
||||
ids []common.DeliveryID
|
||||
}
|
||||
|
||||
func (generator *stubIDGenerator) NewDeliveryID() (common.DeliveryID, error) {
|
||||
if len(generator.ids) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
next := generator.ids[0]
|
||||
generator.ids = generator.ids[1:]
|
||||
return next, nil
|
||||
}
|
||||
|
||||
type stubClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock stubClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
|
||||
type stubTelemetry struct {
|
||||
statuses []string
|
||||
}
|
||||
|
||||
func (telemetry *stubTelemetry) RecordDeliveryStatusTransition(_ context.Context, status string, source string) {
|
||||
telemetry.statuses = append(telemetry.statuses, source+":"+status)
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T, cfg Config) *Service {
|
||||
t.Helper()
|
||||
|
||||
service, err := New(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func fixedNow() time.Time {
|
||||
return time.Unix(1_775_122_100, 0).UTC()
|
||||
}
|
||||
|
||||
func validOriginalDelivery() deliverydomain.Delivery {
|
||||
createdAt := time.Unix(1_775_121_700, 0).UTC()
|
||||
updatedAt := createdAt.Add(time.Minute)
|
||||
sentAt := updatedAt
|
||||
|
||||
record := deliverydomain.Delivery{
|
||||
DeliveryID: common.DeliveryID("delivery-original"),
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeTemplate,
|
||||
TemplateID: common.TemplateID("game.turn_ready"),
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: []common.Email{common.Email("pilot@example.com")},
|
||||
Cc: []common.Email{common.Email("copilot@example.com")},
|
||||
Bcc: []common.Email{common.Email("ops@example.com")},
|
||||
ReplyTo: []common.Email{common.Email("noreply@example.com")},
|
||||
},
|
||||
Content: deliverydomain.Content{
|
||||
Subject: "Turn ready",
|
||||
TextBody: "Your next turn is ready",
|
||||
},
|
||||
Attachments: []common.AttachmentMetadata{
|
||||
{Filename: "instructions.txt", ContentType: "text/plain; charset=utf-8", SizeBytes: 7},
|
||||
},
|
||||
Locale: common.Locale("en"),
|
||||
TemplateVariables: map[string]any{"turn": 7},
|
||||
LocaleFallbackUsed: true,
|
||||
IdempotencyKey: common.IdempotencyKey("notification:delivery-original"),
|
||||
Status: deliverydomain.StatusSent,
|
||||
AttemptCount: 2,
|
||||
LastAttemptStatus: attempt.StatusProviderAccepted,
|
||||
ProviderSummary: "provider=smtp result=accepted",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
SentAt: &sentAt,
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func validPayload(deliveryID common.DeliveryID) acceptgenericdelivery.DeliveryPayload {
|
||||
payload := acceptgenericdelivery.DeliveryPayload{
|
||||
DeliveryID: deliveryID,
|
||||
Attachments: []acceptgenericdelivery.AttachmentPayload{
|
||||
{
|
||||
Filename: "instructions.txt",
|
||||
ContentType: "text/plain; charset=utf-8",
|
||||
ContentBase64: "cmVhZCBtZQ==",
|
||||
SizeBytes: 7,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := payload.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
var _ Store = (*stubStore)(nil)
|
||||
var _ DeliveryIDGenerator = (*stubIDGenerator)(nil)
|
||||
var _ Clock = stubClock{}
|
||||
var _ Telemetry = (*stubTelemetry)(nil)
|
||||
|
||||
func hasResendSpanNamed(spans []sdktrace.ReadOnlySpan, name string) bool {
|
||||
for _, span := range spans {
|
||||
if span.Name() == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
// Package telemetry provides lightweight OpenTelemetry helpers and
|
||||
// low-cardinality Mail Service instruments.
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
"go.opentelemetry.io/otel/exporters/stdout/stdoutmetric"
|
||||
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
oteltrace "go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
const meterName = "galaxy/mail"
|
||||
|
||||
const (
|
||||
defaultServiceName = "galaxy-mail"
|
||||
|
||||
processExporterNone = "none"
|
||||
processExporterOTLP = "otlp"
|
||||
processProtocolHTTPProtobuf = "http/protobuf"
|
||||
processProtocolGRPC = "grpc"
|
||||
)
|
||||
|
||||
// ProcessConfig configures the process-wide OpenTelemetry runtime.
|
||||
type ProcessConfig struct {
|
||||
// ServiceName overrides the default OpenTelemetry service name.
|
||||
ServiceName string
|
||||
|
||||
// TracesExporter selects the external traces exporter. Supported values are
|
||||
// `none` and `otlp`.
|
||||
TracesExporter string
|
||||
|
||||
// MetricsExporter selects the external metrics exporter. Supported values
|
||||
// are `none` and `otlp`.
|
||||
MetricsExporter string
|
||||
|
||||
// TracesProtocol selects the OTLP traces protocol when TracesExporter is
|
||||
// `otlp`.
|
||||
TracesProtocol string
|
||||
|
||||
// MetricsProtocol selects the OTLP metrics protocol when MetricsExporter is
|
||||
// `otlp`.
|
||||
MetricsProtocol string
|
||||
|
||||
// StdoutTracesEnabled enables the additional stdout trace exporter used for
|
||||
// local development and debugging.
|
||||
StdoutTracesEnabled bool
|
||||
|
||||
// StdoutMetricsEnabled enables the additional stdout metric exporter used
|
||||
// for local development and debugging.
|
||||
StdoutMetricsEnabled bool
|
||||
}
|
||||
|
||||
// Validate reports whether cfg contains a supported OpenTelemetry exporter
|
||||
// configuration.
|
||||
func (cfg ProcessConfig) Validate() error {
|
||||
switch cfg.TracesExporter {
|
||||
case processExporterNone, processExporterOTLP:
|
||||
default:
|
||||
return fmt.Errorf("unsupported traces exporter %q", cfg.TracesExporter)
|
||||
}
|
||||
|
||||
switch cfg.MetricsExporter {
|
||||
case processExporterNone, processExporterOTLP:
|
||||
default:
|
||||
return fmt.Errorf("unsupported metrics exporter %q", cfg.MetricsExporter)
|
||||
}
|
||||
|
||||
if cfg.TracesProtocol != "" && cfg.TracesProtocol != processProtocolHTTPProtobuf && cfg.TracesProtocol != processProtocolGRPC {
|
||||
return fmt.Errorf("unsupported OTLP traces protocol %q", cfg.TracesProtocol)
|
||||
}
|
||||
if cfg.MetricsProtocol != "" && cfg.MetricsProtocol != processProtocolHTTPProtobuf && cfg.MetricsProtocol != processProtocolGRPC {
|
||||
return fmt.Errorf("unsupported OTLP metrics protocol %q", cfg.MetricsProtocol)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Runtime owns the Mail Service OpenTelemetry providers and low-cardinality
|
||||
// custom instruments.
|
||||
type Runtime struct {
|
||||
tracerProvider oteltrace.TracerProvider
|
||||
meterProvider metric.MeterProvider
|
||||
|
||||
shutdownMu sync.Mutex
|
||||
shutdownDone bool
|
||||
shutdownErr error
|
||||
shutdownFns []func(context.Context) error
|
||||
|
||||
attemptScheduleReaderMu sync.RWMutex
|
||||
attemptScheduleReader AttemptScheduleSnapshotReader
|
||||
|
||||
internalHTTPRequests metric.Int64Counter
|
||||
internalHTTPDuration metric.Float64Histogram
|
||||
authDeliveryOutcomes metric.Int64Counter
|
||||
genericDeliveryOutcomes metric.Int64Counter
|
||||
malformedCommands metric.Int64Counter
|
||||
acceptedAuthDeliveries metric.Int64Counter
|
||||
acceptedGenericDeliveries metric.Int64Counter
|
||||
suppressedDeliveries metric.Int64Counter
|
||||
deliveryStatusTransitions metric.Int64Counter
|
||||
attemptOutcomes metric.Int64Counter
|
||||
deadLetters metric.Int64Counter
|
||||
localeFallbacks metric.Int64Counter
|
||||
providerSendDuration metric.Float64Histogram
|
||||
}
|
||||
|
||||
// AttemptScheduleSnapshot stores the current observable state of the durable
|
||||
// attempt schedule.
|
||||
type AttemptScheduleSnapshot struct {
|
||||
// Depth stores how many delivery ids are currently present in the attempt
|
||||
// schedule.
|
||||
Depth int64
|
||||
|
||||
// OldestScheduledFor stores the oldest currently scheduled due time when
|
||||
// one exists.
|
||||
OldestScheduledFor *time.Time
|
||||
}
|
||||
|
||||
// AttemptScheduleSnapshotReader loads one current schedule snapshot for
|
||||
// observable gauge reporting.
|
||||
type AttemptScheduleSnapshotReader interface {
|
||||
// ReadAttemptScheduleSnapshot returns the current attempt schedule depth and
|
||||
// its oldest scheduled timestamp when one exists.
|
||||
ReadAttemptScheduleSnapshot(context.Context) (AttemptScheduleSnapshot, error)
|
||||
}
|
||||
|
||||
// New constructs a lightweight telemetry runtime around meterProvider for
|
||||
// tests and embedded use cases that do not need process-level exporter wiring.
|
||||
func New(meterProvider metric.MeterProvider) (*Runtime, error) {
|
||||
return NewWithProviders(meterProvider, nil)
|
||||
}
|
||||
|
||||
// NewWithProviders constructs a telemetry runtime around explicitly supplied
|
||||
// meterProvider and tracerProvider values.
|
||||
func NewWithProviders(meterProvider metric.MeterProvider, tracerProvider oteltrace.TracerProvider) (*Runtime, error) {
|
||||
if meterProvider == nil {
|
||||
meterProvider = otel.GetMeterProvider()
|
||||
}
|
||||
if tracerProvider == nil {
|
||||
tracerProvider = otel.GetTracerProvider()
|
||||
}
|
||||
if meterProvider == nil {
|
||||
return nil, errors.New("new mail telemetry runtime: nil meter provider")
|
||||
}
|
||||
if tracerProvider == nil {
|
||||
return nil, errors.New("new mail telemetry runtime: nil tracer provider")
|
||||
}
|
||||
|
||||
return buildRuntime(meterProvider, tracerProvider, nil)
|
||||
}
|
||||
|
||||
// NewProcess constructs the process-wide Mail Service OpenTelemetry runtime
|
||||
// from cfg, installs the resulting providers globally, and returns the
|
||||
// runtime.
|
||||
func NewProcess(ctx context.Context, cfg ProcessConfig, logger *slog.Logger) (*Runtime, error) {
|
||||
if ctx == nil {
|
||||
return nil, errors.New("new mail telemetry process: nil context")
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("new mail telemetry process: %w", err)
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
serviceName := strings.TrimSpace(cfg.ServiceName)
|
||||
if serviceName == "" {
|
||||
serviceName = defaultServiceName
|
||||
}
|
||||
|
||||
res := resource.NewSchemaless(attribute.String("service.name", serviceName))
|
||||
|
||||
tracerProvider, err := newTracerProvider(ctx, res, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new mail telemetry process: tracer provider: %w", err)
|
||||
}
|
||||
meterProvider, err := newMeterProvider(ctx, res, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new mail telemetry process: meter provider: %w", err)
|
||||
}
|
||||
|
||||
otel.SetTracerProvider(tracerProvider)
|
||||
otel.SetMeterProvider(meterProvider)
|
||||
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
))
|
||||
|
||||
runtime, err := buildRuntime(meterProvider, tracerProvider, []func(context.Context) error{
|
||||
meterProvider.Shutdown,
|
||||
tracerProvider.Shutdown,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new mail telemetry process: runtime: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("mail telemetry configured",
|
||||
"service_name", serviceName,
|
||||
"traces_exporter", cfg.TracesExporter,
|
||||
"metrics_exporter", cfg.MetricsExporter,
|
||||
)
|
||||
|
||||
return runtime, nil
|
||||
}
|
||||
|
||||
// TracerProvider returns the runtime tracer provider.
|
||||
func (runtime *Runtime) TracerProvider() oteltrace.TracerProvider {
|
||||
if runtime == nil || runtime.tracerProvider == nil {
|
||||
return otel.GetTracerProvider()
|
||||
}
|
||||
|
||||
return runtime.tracerProvider
|
||||
}
|
||||
|
||||
// MeterProvider returns the runtime meter provider.
|
||||
func (runtime *Runtime) MeterProvider() metric.MeterProvider {
|
||||
if runtime == nil || runtime.meterProvider == nil {
|
||||
return otel.GetMeterProvider()
|
||||
}
|
||||
|
||||
return runtime.meterProvider
|
||||
}
|
||||
|
||||
// Shutdown flushes and stops the configured telemetry providers. Shutdown is
|
||||
// idempotent.
|
||||
func (runtime *Runtime) Shutdown(ctx context.Context) error {
|
||||
if runtime == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
runtime.shutdownMu.Lock()
|
||||
if runtime.shutdownDone {
|
||||
err := runtime.shutdownErr
|
||||
runtime.shutdownMu.Unlock()
|
||||
return err
|
||||
}
|
||||
runtime.shutdownDone = true
|
||||
runtime.shutdownMu.Unlock()
|
||||
|
||||
var shutdownErr error
|
||||
for index := len(runtime.shutdownFns) - 1; index >= 0; index-- {
|
||||
shutdownErr = errors.Join(shutdownErr, runtime.shutdownFns[index](ctx))
|
||||
}
|
||||
|
||||
runtime.shutdownMu.Lock()
|
||||
runtime.shutdownErr = shutdownErr
|
||||
runtime.shutdownMu.Unlock()
|
||||
|
||||
return shutdownErr
|
||||
}
|
||||
|
||||
// RecordInternalHTTPRequest records one internal HTTP request outcome.
|
||||
func (runtime *Runtime) RecordInternalHTTPRequest(ctx context.Context, attrs []attribute.KeyValue, duration time.Duration) {
|
||||
if runtime == nil {
|
||||
return
|
||||
}
|
||||
|
||||
options := metric.WithAttributes(attrs...)
|
||||
runtime.internalHTTPRequests.Add(normalizeContext(ctx), 1, options)
|
||||
runtime.internalHTTPDuration.Record(normalizeContext(ctx), duration.Seconds()*1000, options)
|
||||
}
|
||||
|
||||
// RecordAuthDeliveryOutcome records one auth-delivery acceptance outcome.
|
||||
func (runtime *Runtime) RecordAuthDeliveryOutcome(ctx context.Context, outcome string) {
|
||||
if runtime == nil {
|
||||
return
|
||||
}
|
||||
|
||||
runtime.authDeliveryOutcomes.Add(
|
||||
normalizeContext(ctx),
|
||||
1,
|
||||
metric.WithAttributes(attribute.String("outcome", strings.TrimSpace(outcome))),
|
||||
)
|
||||
}
|
||||
|
||||
// RecordGenericDeliveryOutcome records one generic-delivery acceptance
|
||||
// outcome.
|
||||
func (runtime *Runtime) RecordGenericDeliveryOutcome(ctx context.Context, outcome string) {
|
||||
if runtime == nil {
|
||||
return
|
||||
}
|
||||
|
||||
runtime.genericDeliveryOutcomes.Add(
|
||||
normalizeContext(ctx),
|
||||
1,
|
||||
metric.WithAttributes(attribute.String("outcome", strings.TrimSpace(outcome))),
|
||||
)
|
||||
}
|
||||
|
||||
// RecordMalformedCommand records one malformed or rejected async stream
|
||||
// command.
|
||||
func (runtime *Runtime) RecordMalformedCommand(ctx context.Context, failureCode string) {
|
||||
if runtime == nil {
|
||||
return
|
||||
}
|
||||
|
||||
runtime.malformedCommands.Add(
|
||||
normalizeContext(ctx),
|
||||
1,
|
||||
metric.WithAttributes(attribute.String("failure_code", strings.TrimSpace(failureCode))),
|
||||
)
|
||||
}
|
||||
|
||||
// RecordAcceptedAuthDelivery records one newly accepted auth delivery.
|
||||
func (runtime *Runtime) RecordAcceptedAuthDelivery(ctx context.Context) {
|
||||
if runtime == nil {
|
||||
return
|
||||
}
|
||||
|
||||
runtime.acceptedAuthDeliveries.Add(normalizeContext(ctx), 1)
|
||||
}
|
||||
|
||||
// RecordAcceptedGenericDelivery records one newly accepted generic delivery.
|
||||
func (runtime *Runtime) RecordAcceptedGenericDelivery(ctx context.Context) {
|
||||
if runtime == nil {
|
||||
return
|
||||
}
|
||||
|
||||
runtime.acceptedGenericDeliveries.Add(normalizeContext(ctx), 1)
|
||||
}
|
||||
|
||||
// RecordDeliveryStatusTransition records one durable delivery status
|
||||
// transition.
|
||||
func (runtime *Runtime) RecordDeliveryStatusTransition(ctx context.Context, status string, source string) {
|
||||
if runtime == nil {
|
||||
return
|
||||
}
|
||||
|
||||
attrs := metric.WithAttributes(
|
||||
attribute.String("status", strings.TrimSpace(status)),
|
||||
attribute.String("source", strings.TrimSpace(source)),
|
||||
)
|
||||
runtime.deliveryStatusTransitions.Add(normalizeContext(ctx), 1, attrs)
|
||||
|
||||
switch strings.TrimSpace(status) {
|
||||
case "suppressed":
|
||||
runtime.suppressedDeliveries.Add(normalizeContext(ctx), 1)
|
||||
case "dead_letter":
|
||||
runtime.deadLetters.Add(
|
||||
normalizeContext(ctx),
|
||||
1,
|
||||
metric.WithAttributes(attribute.String("source", strings.TrimSpace(source))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// RecordAttemptOutcome records one durable terminal attempt outcome.
|
||||
func (runtime *Runtime) RecordAttemptOutcome(ctx context.Context, status string, source string) {
|
||||
if runtime == nil {
|
||||
return
|
||||
}
|
||||
|
||||
runtime.attemptOutcomes.Add(
|
||||
normalizeContext(ctx),
|
||||
1,
|
||||
metric.WithAttributes(
|
||||
attribute.String("status", strings.TrimSpace(status)),
|
||||
attribute.String("source", strings.TrimSpace(source)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// RecordLocaleFallback records one template locale fallback event.
|
||||
func (runtime *Runtime) RecordLocaleFallback(ctx context.Context, templateID string, requestedLocale string, resolvedLocale string) {
|
||||
if runtime == nil {
|
||||
return
|
||||
}
|
||||
|
||||
runtime.localeFallbacks.Add(
|
||||
normalizeContext(ctx),
|
||||
1,
|
||||
metric.WithAttributes(
|
||||
attribute.String("template_id", strings.TrimSpace(templateID)),
|
||||
attribute.String("requested_locale", strings.TrimSpace(requestedLocale)),
|
||||
attribute.String("resolved_locale", strings.TrimSpace(resolvedLocale)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// RecordProviderSendDuration records one provider send duration sample.
|
||||
func (runtime *Runtime) RecordProviderSendDuration(ctx context.Context, provider string, outcome string, duration time.Duration) {
|
||||
if runtime == nil {
|
||||
return
|
||||
}
|
||||
|
||||
runtime.providerSendDuration.Record(
|
||||
normalizeContext(ctx),
|
||||
duration.Seconds()*1000,
|
||||
metric.WithAttributes(
|
||||
attribute.String("provider", strings.TrimSpace(provider)),
|
||||
attribute.String("outcome", strings.TrimSpace(outcome)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// SetAttemptScheduleSnapshotReader installs the current attempt-schedule
|
||||
// reader used by the observable schedule gauges.
|
||||
func (runtime *Runtime) SetAttemptScheduleSnapshotReader(reader AttemptScheduleSnapshotReader) {
|
||||
if runtime == nil {
|
||||
return
|
||||
}
|
||||
|
||||
runtime.attemptScheduleReaderMu.Lock()
|
||||
runtime.attemptScheduleReader = reader
|
||||
runtime.attemptScheduleReaderMu.Unlock()
|
||||
}
|
||||
|
||||
func buildRuntime(meterProvider metric.MeterProvider, tracerProvider oteltrace.TracerProvider, shutdownFns []func(context.Context) error) (*Runtime, error) {
|
||||
meter := meterProvider.Meter(meterName)
|
||||
runtime := &Runtime{
|
||||
tracerProvider: tracerProvider,
|
||||
meterProvider: meterProvider,
|
||||
shutdownFns: append([]func(context.Context) error(nil), shutdownFns...),
|
||||
}
|
||||
|
||||
internalHTTPRequests, err := meter.Int64Counter("mail.internal_http.requests")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build mail telemetry runtime: internal_http.requests: %w", err)
|
||||
}
|
||||
internalHTTPDuration, err := meter.Float64Histogram("mail.internal_http.duration", metric.WithUnit("ms"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build mail telemetry runtime: internal_http.duration: %w", err)
|
||||
}
|
||||
authDeliveryOutcomes, err := meter.Int64Counter("mail.auth_delivery.outcomes")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build mail telemetry runtime: auth_delivery.outcomes: %w", err)
|
||||
}
|
||||
genericDeliveryOutcomes, err := meter.Int64Counter("mail.generic_delivery.outcomes")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build mail telemetry runtime: generic_delivery.outcomes: %w", err)
|
||||
}
|
||||
malformedCommands, err := meter.Int64Counter("mail.stream_commands.malformed")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build mail telemetry runtime: stream_commands.malformed: %w", err)
|
||||
}
|
||||
acceptedAuthDeliveries, err := meter.Int64Counter("mail.delivery.accepted_auth")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build mail telemetry runtime: delivery.accepted_auth: %w", err)
|
||||
}
|
||||
acceptedGenericDeliveries, err := meter.Int64Counter("mail.delivery.accepted_generic")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build mail telemetry runtime: delivery.accepted_generic: %w", err)
|
||||
}
|
||||
suppressedDeliveries, err := meter.Int64Counter("mail.delivery.suppressed")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build mail telemetry runtime: delivery.suppressed: %w", err)
|
||||
}
|
||||
deliveryStatusTransitions, err := meter.Int64Counter("mail.delivery.status_transitions")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build mail telemetry runtime: delivery.status_transitions: %w", err)
|
||||
}
|
||||
attemptOutcomes, err := meter.Int64Counter("mail.attempt.outcomes")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build mail telemetry runtime: attempt.outcomes: %w", err)
|
||||
}
|
||||
deadLetters, err := meter.Int64Counter("mail.delivery.dead_letters")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build mail telemetry runtime: delivery.dead_letters: %w", err)
|
||||
}
|
||||
localeFallbacks, err := meter.Int64Counter("mail.template.locale_fallback")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build mail telemetry runtime: template.locale_fallback: %w", err)
|
||||
}
|
||||
providerSendDuration, err := meter.Float64Histogram("mail.provider.send.duration_ms", metric.WithUnit("ms"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build mail telemetry runtime: provider.send.duration_ms: %w", err)
|
||||
}
|
||||
attemptScheduleDepth, err := meter.Int64ObservableGauge("mail.attempt_schedule.depth")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build mail telemetry runtime: attempt_schedule.depth: %w", err)
|
||||
}
|
||||
attemptScheduleOldestAge, err := meter.Int64ObservableGauge("mail.attempt_schedule.oldest_age_ms", metric.WithUnit("ms"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build mail telemetry runtime: attempt_schedule.oldest_age_ms: %w", err)
|
||||
}
|
||||
registration, err := meter.RegisterCallback(func(ctx context.Context, observer metric.Observer) error {
|
||||
runtime.observeAttemptSchedule(ctx, observer, attemptScheduleDepth, attemptScheduleOldestAge)
|
||||
return nil
|
||||
}, attemptScheduleDepth, attemptScheduleOldestAge)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build mail telemetry runtime: attempt schedule callback: %w", err)
|
||||
}
|
||||
runtime.shutdownFns = append(runtime.shutdownFns, func(context.Context) error {
|
||||
return registration.Unregister()
|
||||
})
|
||||
|
||||
runtime.internalHTTPRequests = internalHTTPRequests
|
||||
runtime.internalHTTPDuration = internalHTTPDuration
|
||||
runtime.authDeliveryOutcomes = authDeliveryOutcomes
|
||||
runtime.genericDeliveryOutcomes = genericDeliveryOutcomes
|
||||
runtime.malformedCommands = malformedCommands
|
||||
runtime.acceptedAuthDeliveries = acceptedAuthDeliveries
|
||||
runtime.acceptedGenericDeliveries = acceptedGenericDeliveries
|
||||
runtime.suppressedDeliveries = suppressedDeliveries
|
||||
runtime.deliveryStatusTransitions = deliveryStatusTransitions
|
||||
runtime.attemptOutcomes = attemptOutcomes
|
||||
runtime.deadLetters = deadLetters
|
||||
runtime.localeFallbacks = localeFallbacks
|
||||
runtime.providerSendDuration = providerSendDuration
|
||||
|
||||
return runtime, nil
|
||||
}
|
||||
|
||||
func newTracerProvider(ctx context.Context, res *resource.Resource, cfg ProcessConfig) (*sdktrace.TracerProvider, error) {
|
||||
options := []sdktrace.TracerProviderOption{
|
||||
sdktrace.WithResource(res),
|
||||
}
|
||||
|
||||
if exporter, err := traceExporter(ctx, cfg); err != nil {
|
||||
return nil, err
|
||||
} else if exporter != nil {
|
||||
options = append(options, sdktrace.WithBatcher(exporter))
|
||||
}
|
||||
|
||||
if cfg.StdoutTracesEnabled {
|
||||
exporter, err := stdouttrace.New(stdouttrace.WithWriter(os.Stdout))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdout traces exporter: %w", err)
|
||||
}
|
||||
options = append(options, sdktrace.WithBatcher(exporter))
|
||||
}
|
||||
|
||||
return sdktrace.NewTracerProvider(options...), nil
|
||||
}
|
||||
|
||||
func newMeterProvider(ctx context.Context, res *resource.Resource, cfg ProcessConfig) (*sdkmetric.MeterProvider, error) {
|
||||
options := []sdkmetric.Option{
|
||||
sdkmetric.WithResource(res),
|
||||
}
|
||||
|
||||
if exporter, err := metricExporter(ctx, cfg); err != nil {
|
||||
return nil, err
|
||||
} else if exporter != nil {
|
||||
options = append(options, sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter)))
|
||||
}
|
||||
|
||||
if cfg.StdoutMetricsEnabled {
|
||||
exporter, err := stdoutmetric.New(stdoutmetric.WithWriter(os.Stdout))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdout metrics exporter: %w", err)
|
||||
}
|
||||
options = append(options, sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter)))
|
||||
}
|
||||
|
||||
return sdkmetric.NewMeterProvider(options...), nil
|
||||
}
|
||||
|
||||
func traceExporter(ctx context.Context, cfg ProcessConfig) (sdktrace.SpanExporter, error) {
|
||||
if cfg.TracesExporter != processExporterOTLP {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch normalizeProtocol(cfg.TracesProtocol) {
|
||||
case processProtocolGRPC:
|
||||
exporter, err := otlptracegrpc.New(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("otlp grpc traces exporter: %w", err)
|
||||
}
|
||||
return exporter, nil
|
||||
default:
|
||||
exporter, err := otlptracehttp.New(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("otlp http traces exporter: %w", err)
|
||||
}
|
||||
return exporter, nil
|
||||
}
|
||||
}
|
||||
|
||||
func metricExporter(ctx context.Context, cfg ProcessConfig) (sdkmetric.Exporter, error) {
|
||||
if cfg.MetricsExporter != processExporterOTLP {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch normalizeProtocol(cfg.MetricsProtocol) {
|
||||
case processProtocolGRPC:
|
||||
exporter, err := otlpmetricgrpc.New(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("otlp grpc metrics exporter: %w", err)
|
||||
}
|
||||
return exporter, nil
|
||||
default:
|
||||
exporter, err := otlpmetrichttp.New(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("otlp http metrics exporter: %w", err)
|
||||
}
|
||||
return exporter, nil
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProtocol(value string) string {
|
||||
switch strings.TrimSpace(value) {
|
||||
case processProtocolGRPC:
|
||||
return processProtocolGRPC
|
||||
default:
|
||||
return processProtocolHTTPProtobuf
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeContext(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (runtime *Runtime) observeAttemptSchedule(
|
||||
ctx context.Context,
|
||||
observer metric.Observer,
|
||||
depthGauge metric.Int64ObservableGauge,
|
||||
oldestAgeGauge metric.Int64ObservableGauge,
|
||||
) {
|
||||
depth := int64(0)
|
||||
oldestAge := int64(0)
|
||||
|
||||
reader := runtime.currentAttemptScheduleReader()
|
||||
if reader != nil {
|
||||
snapshot, err := reader.ReadAttemptScheduleSnapshot(ctx)
|
||||
if err != nil {
|
||||
otel.Handle(fmt.Errorf("observe mail attempt schedule: %w", err))
|
||||
} else {
|
||||
if snapshot.Depth > 0 {
|
||||
depth = snapshot.Depth
|
||||
}
|
||||
if snapshot.OldestScheduledFor != nil {
|
||||
oldestAge = time.Since(snapshot.OldestScheduledFor.UTC()).Milliseconds()
|
||||
if oldestAge < 0 {
|
||||
oldestAge = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
observer.ObserveInt64(depthGauge, depth)
|
||||
observer.ObserveInt64(oldestAgeGauge, oldestAge)
|
||||
}
|
||||
|
||||
func (runtime *Runtime) currentAttemptScheduleReader() AttemptScheduleSnapshotReader {
|
||||
runtime.attemptScheduleReaderMu.RLock()
|
||||
defer runtime.attemptScheduleReaderMu.RUnlock()
|
||||
return runtime.attemptScheduleReader
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
)
|
||||
|
||||
func TestRuntimeRecordsMetrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reader := sdkmetric.NewManualReader()
|
||||
meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
|
||||
tracerProvider := sdktrace.NewTracerProvider()
|
||||
|
||||
runtime, err := NewWithProviders(meterProvider, tracerProvider)
|
||||
require.NoError(t, err)
|
||||
|
||||
runtime.RecordInternalHTTPRequest(context.Background(), []attribute.KeyValue{
|
||||
attribute.String("route", "/api/v1/internal/login-code-deliveries"),
|
||||
attribute.String("method", "POST"),
|
||||
attribute.String("edge_outcome", "success"),
|
||||
}, 5*time.Millisecond)
|
||||
runtime.RecordAuthDeliveryOutcome(context.Background(), "sent")
|
||||
runtime.RecordGenericDeliveryOutcome(context.Background(), "accepted")
|
||||
runtime.RecordMalformedCommand(context.Background(), "invalid_payload")
|
||||
runtime.RecordAcceptedAuthDelivery(context.Background())
|
||||
runtime.RecordAcceptedGenericDelivery(context.Background())
|
||||
runtime.RecordDeliveryStatusTransition(context.Background(), "queued", "notification")
|
||||
runtime.RecordDeliveryStatusTransition(context.Background(), "suppressed", "authsession")
|
||||
runtime.RecordDeliveryStatusTransition(context.Background(), "dead_letter", "notification")
|
||||
runtime.RecordAttemptOutcome(context.Background(), "provider_accepted", "notification")
|
||||
runtime.RecordLocaleFallback(context.Background(), "auth.login_code", "fr-FR", "en")
|
||||
runtime.RecordProviderSendDuration(context.Background(), "smtp", "accepted", 15*time.Millisecond)
|
||||
scheduledAt := time.Now().Add(-time.Second).UTC()
|
||||
runtime.SetAttemptScheduleSnapshotReader(stubAttemptScheduleSnapshotReader{
|
||||
snapshot: AttemptScheduleSnapshot{
|
||||
Depth: 3,
|
||||
OldestScheduledFor: &scheduledAt,
|
||||
},
|
||||
})
|
||||
|
||||
assertMetricCount(t, reader, "mail.internal_http.requests", map[string]string{
|
||||
"route": "/api/v1/internal/login-code-deliveries",
|
||||
"method": "POST",
|
||||
"edge_outcome": "success",
|
||||
}, 1)
|
||||
assertMetricCount(t, reader, "mail.auth_delivery.outcomes", map[string]string{
|
||||
"outcome": "sent",
|
||||
}, 1)
|
||||
assertMetricCount(t, reader, "mail.generic_delivery.outcomes", map[string]string{
|
||||
"outcome": "accepted",
|
||||
}, 1)
|
||||
assertMetricCount(t, reader, "mail.stream_commands.malformed", map[string]string{
|
||||
"failure_code": "invalid_payload",
|
||||
}, 1)
|
||||
assertMetricCount(t, reader, "mail.delivery.accepted_auth", nil, 1)
|
||||
assertMetricCount(t, reader, "mail.delivery.accepted_generic", nil, 1)
|
||||
assertMetricCount(t, reader, "mail.delivery.suppressed", nil, 1)
|
||||
assertMetricCount(t, reader, "mail.delivery.status_transitions", map[string]string{
|
||||
"status": "queued",
|
||||
"source": "notification",
|
||||
}, 1)
|
||||
assertMetricCount(t, reader, "mail.delivery.status_transitions", map[string]string{
|
||||
"status": "suppressed",
|
||||
"source": "authsession",
|
||||
}, 1)
|
||||
assertMetricCount(t, reader, "mail.delivery.dead_letters", map[string]string{
|
||||
"source": "notification",
|
||||
}, 1)
|
||||
assertMetricCount(t, reader, "mail.attempt.outcomes", map[string]string{
|
||||
"status": "provider_accepted",
|
||||
"source": "notification",
|
||||
}, 1)
|
||||
assertMetricCount(t, reader, "mail.template.locale_fallback", map[string]string{
|
||||
"template_id": "auth.login_code",
|
||||
"requested_locale": "fr-FR",
|
||||
"resolved_locale": "en",
|
||||
}, 1)
|
||||
assertHistogramCount(t, reader, "mail.provider.send.duration_ms", map[string]string{
|
||||
"provider": "smtp",
|
||||
"outcome": "accepted",
|
||||
}, 1)
|
||||
assertGaugeValue(t, reader, "mail.attempt_schedule.depth", nil, 3)
|
||||
assertGaugePositive(t, reader, "mail.attempt_schedule.oldest_age_ms", nil)
|
||||
}
|
||||
|
||||
func assertMetricCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantValue int64) {
|
||||
t.Helper()
|
||||
|
||||
var resourceMetrics metricdata.ResourceMetrics
|
||||
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
|
||||
|
||||
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
|
||||
for _, metric := range scopeMetrics.Metrics {
|
||||
if metric.Name != metricName {
|
||||
continue
|
||||
}
|
||||
|
||||
sum, ok := metric.Data.(metricdata.Sum[int64])
|
||||
require.True(t, ok)
|
||||
|
||||
for _, point := range sum.DataPoints {
|
||||
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
|
||||
assert.Equal(t, wantValue, point.Value)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "test failed", "metric %q with attrs %v not found", metricName, wantAttrs)
|
||||
}
|
||||
|
||||
func assertHistogramCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantCount uint64) {
|
||||
t.Helper()
|
||||
|
||||
var resourceMetrics metricdata.ResourceMetrics
|
||||
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
|
||||
|
||||
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
|
||||
for _, metric := range scopeMetrics.Metrics {
|
||||
if metric.Name != metricName {
|
||||
continue
|
||||
}
|
||||
|
||||
histogram, ok := metric.Data.(metricdata.Histogram[float64])
|
||||
require.True(t, ok)
|
||||
|
||||
for _, point := range histogram.DataPoints {
|
||||
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
|
||||
assert.Equal(t, wantCount, point.Count)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "test failed", "histogram %q with attrs %v not found", metricName, wantAttrs)
|
||||
}
|
||||
|
||||
func assertGaugeValue(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantValue int64) {
|
||||
t.Helper()
|
||||
|
||||
var resourceMetrics metricdata.ResourceMetrics
|
||||
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
|
||||
|
||||
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
|
||||
for _, metric := range scopeMetrics.Metrics {
|
||||
if metric.Name != metricName {
|
||||
continue
|
||||
}
|
||||
|
||||
gauge, ok := metric.Data.(metricdata.Gauge[int64])
|
||||
require.True(t, ok)
|
||||
|
||||
for _, point := range gauge.DataPoints {
|
||||
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
|
||||
assert.Equal(t, wantValue, point.Value)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "test failed", "gauge %q with attrs %v not found", metricName, wantAttrs)
|
||||
}
|
||||
|
||||
func assertGaugePositive(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string) {
|
||||
t.Helper()
|
||||
|
||||
var resourceMetrics metricdata.ResourceMetrics
|
||||
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
|
||||
|
||||
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
|
||||
for _, metric := range scopeMetrics.Metrics {
|
||||
if metric.Name != metricName {
|
||||
continue
|
||||
}
|
||||
|
||||
gauge, ok := metric.Data.(metricdata.Gauge[int64])
|
||||
require.True(t, ok)
|
||||
|
||||
for _, point := range gauge.DataPoints {
|
||||
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
|
||||
assert.Greater(t, point.Value, int64(0))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "test failed", "gauge %q with attrs %v not found", metricName, wantAttrs)
|
||||
}
|
||||
|
||||
func hasMetricAttributes(values []attribute.KeyValue, want map[string]string) bool {
|
||||
if len(want) == 0 {
|
||||
return len(values) == 0
|
||||
}
|
||||
if len(values) != len(want) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
if want[string(value.Key)] != value.Value.AsString() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
type stubAttemptScheduleSnapshotReader struct {
|
||||
snapshot AttemptScheduleSnapshot
|
||||
err error
|
||||
}
|
||||
|
||||
func (reader stubAttemptScheduleSnapshotReader) ReadAttemptScheduleSnapshot(context.Context) (AttemptScheduleSnapshot, error) {
|
||||
return reader.snapshot, reader.err
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"galaxy/mail/internal/service/executeattempt"
|
||||
)
|
||||
|
||||
// AttemptExecutionService executes one claimed in-progress attempt.
|
||||
type AttemptExecutionService interface {
|
||||
// Execute runs one claimed attempt through provider execution and durable
|
||||
// state mutation.
|
||||
Execute(context.Context, executeattempt.WorkItem) error
|
||||
}
|
||||
|
||||
// AttemptWorkerPoolConfig stores the dependencies used by AttemptWorkerPool.
|
||||
type AttemptWorkerPoolConfig struct {
|
||||
// Concurrency stores how many workers run concurrently.
|
||||
Concurrency int
|
||||
|
||||
// WorkQueue stores the claimed attempt handoff channel produced by the
|
||||
// scheduler.
|
||||
WorkQueue <-chan executeattempt.WorkItem
|
||||
|
||||
// Service executes one claimed attempt.
|
||||
Service AttemptExecutionService
|
||||
}
|
||||
|
||||
// AttemptWorkerPool executes claimed attempts concurrently.
|
||||
type AttemptWorkerPool struct {
|
||||
concurrency int
|
||||
workQueue <-chan executeattempt.WorkItem
|
||||
service AttemptExecutionService
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewAttemptWorkerPool constructs one attempt worker pool.
|
||||
func NewAttemptWorkerPool(cfg AttemptWorkerPoolConfig, logger *slog.Logger) (*AttemptWorkerPool, error) {
|
||||
switch {
|
||||
case cfg.Concurrency <= 0:
|
||||
return nil, errors.New("new attempt worker pool: concurrency must be positive")
|
||||
case cfg.WorkQueue == nil:
|
||||
return nil, errors.New("new attempt worker pool: nil work queue")
|
||||
case cfg.Service == nil:
|
||||
return nil, errors.New("new attempt worker pool: nil attempt execution service")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &AttemptWorkerPool{
|
||||
concurrency: cfg.Concurrency,
|
||||
workQueue: cfg.WorkQueue,
|
||||
service: cfg.Service,
|
||||
logger: logger.With("component", "attempt_worker_pool", "concurrency", cfg.Concurrency),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run starts the attempt worker pool and blocks until ctx is canceled or one
|
||||
// worker returns an execution error.
|
||||
func (pool *AttemptWorkerPool) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run attempt worker pool: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if pool == nil {
|
||||
return errors.New("run attempt worker pool: nil pool")
|
||||
}
|
||||
|
||||
pool.logger.Info("attempt worker pool started")
|
||||
defer pool.logger.Info("attempt worker pool stopped")
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
errs := make(chan error, pool.concurrency)
|
||||
var waitGroup sync.WaitGroup
|
||||
|
||||
for index := 0; index < pool.concurrency; index++ {
|
||||
waitGroup.Add(1)
|
||||
go func(workerIndex int) {
|
||||
defer waitGroup.Done()
|
||||
if err := pool.runWorker(runCtx, workerIndex); err != nil {
|
||||
errs <- err
|
||||
}
|
||||
}(index)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
waitGroup.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
<-done
|
||||
return ctx.Err()
|
||||
case err := <-errs:
|
||||
cancel()
|
||||
<-done
|
||||
return err
|
||||
case <-done:
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return errors.New("run attempt worker pool: workers exited without shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func (pool *AttemptWorkerPool) runWorker(ctx context.Context, workerIndex int) error {
|
||||
pool.logger.Debug("attempt worker started", "worker_index", workerIndex)
|
||||
defer pool.logger.Debug("attempt worker stopped", "worker_index", workerIndex)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case item, ok := <-pool.workQueue:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := pool.service.Execute(ctx, item); err != nil {
|
||||
return fmt.Errorf("attempt worker %d: %w", workerIndex, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown stops the attempt worker pool within ctx. The pool does not own
|
||||
// additional resources beyond its run loop.
|
||||
func (pool *AttemptWorkerPool) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown attempt worker pool: nil context")
|
||||
}
|
||||
if pool == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/adapters/redisstate"
|
||||
"galaxy/mail/internal/adapters/stubprovider"
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/ports"
|
||||
"galaxy/mail/internal/service/executeattempt"
|
||||
"galaxy/mail/internal/service/renderdelivery"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAttemptWorkersSendImmediateFirstAttempt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fixture := newAttemptWorkerFixture(t, nil)
|
||||
createAcceptedRenderedDelivery(t, fixture.client, common.DeliveryID("delivery-immediate"), fixture.clock.Now())
|
||||
|
||||
cancel, wait := fixture.run(t)
|
||||
defer func() {
|
||||
cancel()
|
||||
wait()
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
deliveryRecord := loadDeliveryRecord(t, fixture.client, common.DeliveryID("delivery-immediate"))
|
||||
return deliveryRecord.Status == deliverydomain.StatusSent
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
require.Len(t, fixture.provider.Inputs(), 1)
|
||||
}
|
||||
|
||||
func TestAttemptWorkersRetryTransientFailuresUntilSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fixture := newAttemptWorkerFixture(t, []stubprovider.ScriptedOutcome{
|
||||
{
|
||||
Classification: ports.ClassificationTransientFailure,
|
||||
Script: "retry_1",
|
||||
},
|
||||
{
|
||||
Classification: ports.ClassificationTransientFailure,
|
||||
Script: "retry_2",
|
||||
},
|
||||
{
|
||||
Classification: ports.ClassificationAccepted,
|
||||
Script: "accepted",
|
||||
},
|
||||
})
|
||||
createAcceptedRenderedDelivery(t, fixture.client, common.DeliveryID("delivery-retry-success"), fixture.clock.Now())
|
||||
|
||||
cancel, wait := fixture.run(t)
|
||||
defer func() {
|
||||
cancel()
|
||||
wait()
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
deliveryRecord := loadDeliveryRecord(t, fixture.client, common.DeliveryID("delivery-retry-success"))
|
||||
return deliveryRecord.AttemptCount == 2 && deliveryRecord.Status == deliverydomain.StatusQueued
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
fixture.clock.Advance(time.Minute)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
deliveryRecord := loadDeliveryRecord(t, fixture.client, common.DeliveryID("delivery-retry-success"))
|
||||
return deliveryRecord.AttemptCount == 3 && deliveryRecord.Status == deliverydomain.StatusQueued
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
fixture.clock.Advance(5 * time.Minute)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
deliveryRecord := loadDeliveryRecord(t, fixture.client, common.DeliveryID("delivery-retry-success"))
|
||||
return deliveryRecord.Status == deliverydomain.StatusSent
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
require.Len(t, fixture.provider.Inputs(), 3)
|
||||
}
|
||||
|
||||
func TestAttemptWorkersDeadLetterAfterRetryExhaustion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fixture := newAttemptWorkerFixture(t, []stubprovider.ScriptedOutcome{
|
||||
{Classification: ports.ClassificationTransientFailure, Script: "retry_1"},
|
||||
{Classification: ports.ClassificationTransientFailure, Script: "retry_2"},
|
||||
{Classification: ports.ClassificationTransientFailure, Script: "retry_3"},
|
||||
{Classification: ports.ClassificationTransientFailure, Script: "retry_4"},
|
||||
})
|
||||
deliveryID := common.DeliveryID("delivery-dead-letter")
|
||||
createAcceptedRenderedDelivery(t, fixture.client, deliveryID, fixture.clock.Now())
|
||||
|
||||
cancel, wait := fixture.run(t)
|
||||
defer func() {
|
||||
cancel()
|
||||
wait()
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return loadDeliveryRecord(t, fixture.client, deliveryID).AttemptCount == 2
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
fixture.clock.Advance(time.Minute)
|
||||
require.Eventually(t, func() bool {
|
||||
return loadDeliveryRecord(t, fixture.client, deliveryID).AttemptCount == 3
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
fixture.clock.Advance(5 * time.Minute)
|
||||
require.Eventually(t, func() bool {
|
||||
return loadDeliveryRecord(t, fixture.client, deliveryID).AttemptCount == 4
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
fixture.clock.Advance(30 * time.Minute)
|
||||
require.Eventually(t, func() bool {
|
||||
return loadDeliveryRecord(t, fixture.client, deliveryID).Status == deliverydomain.StatusDeadLetter
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
deadLetter := loadDeadLetterRecord(t, fixture.client, deliveryID)
|
||||
require.Equal(t, "retry_exhausted", deadLetter.FailureClassification)
|
||||
require.Len(t, fixture.provider.Inputs(), 4)
|
||||
}
|
||||
|
||||
func TestAttemptWorkersRecoverExpiredClaimAfterCrash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fixture := newAttemptWorkerFixture(t, []stubprovider.ScriptedOutcome{
|
||||
{Classification: ports.ClassificationAccepted, Script: "accepted"},
|
||||
})
|
||||
deliveryID := common.DeliveryID("delivery-recovered")
|
||||
createAcceptedRenderedDelivery(t, fixture.client, deliveryID, fixture.clock.Now())
|
||||
|
||||
claimed, found, err := fixture.store.ClaimDueAttempt(context.Background(), deliveryID, fixture.clock.Now())
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, deliverydomain.StatusSending, claimed.Delivery.Status)
|
||||
|
||||
fixture.clock.Advance(20 * time.Millisecond)
|
||||
|
||||
cancel, wait := fixture.run(t)
|
||||
defer func() {
|
||||
cancel()
|
||||
wait()
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
deliveryRecord := loadDeliveryRecord(t, fixture.client, deliveryID)
|
||||
return deliveryRecord.Status == deliverydomain.StatusQueued && deliveryRecord.AttemptCount == 2
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
fixture.clock.Advance(time.Minute)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
deliveryRecord := loadDeliveryRecord(t, fixture.client, deliveryID)
|
||||
return deliveryRecord.Status == deliverydomain.StatusSent
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
require.Len(t, fixture.provider.Inputs(), 1)
|
||||
}
|
||||
|
||||
type attemptWorkerFixture struct {
|
||||
client *redis.Client
|
||||
store *redisstate.AttemptExecutionStore
|
||||
service *executeattempt.Service
|
||||
scheduler *Scheduler
|
||||
pool *AttemptWorkerPool
|
||||
provider *stubprovider.Provider
|
||||
clock *schedulerTestClock
|
||||
}
|
||||
|
||||
func newAttemptWorkerFixture(t *testing.T, scripted []stubprovider.ScriptedOutcome) attemptWorkerFixture {
|
||||
t.Helper()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
store, err := redisstate.NewAttemptExecutionStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
provider, err := stubprovider.New(scripted...)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, provider.Close()) })
|
||||
|
||||
clock := &schedulerTestClock{now: time.Unix(1_775_121_700, 0).UTC()}
|
||||
workQueue := make(chan executeattempt.WorkItem, 1)
|
||||
|
||||
service, err := executeattempt.New(executeattempt.Config{
|
||||
Renderer: noopRenderer{},
|
||||
Provider: provider,
|
||||
PayloadLoader: store,
|
||||
Store: store,
|
||||
Clock: clock,
|
||||
AttemptTimeout: 5 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
scheduler, err := NewScheduler(SchedulerConfig{
|
||||
Store: store,
|
||||
Service: service,
|
||||
WorkQueue: workQueue,
|
||||
Clock: clock,
|
||||
AttemptTimeout: 5 * time.Millisecond,
|
||||
PollInterval: 10 * time.Millisecond,
|
||||
RecoveryInterval: 10 * time.Millisecond,
|
||||
RecoveryGrace: 5 * time.Millisecond,
|
||||
}, testWorkerLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
pool, err := NewAttemptWorkerPool(AttemptWorkerPoolConfig{
|
||||
Concurrency: 1,
|
||||
WorkQueue: workQueue,
|
||||
Service: service,
|
||||
}, testWorkerLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
return attemptWorkerFixture{
|
||||
client: client,
|
||||
store: store,
|
||||
service: service,
|
||||
scheduler: scheduler,
|
||||
pool: pool,
|
||||
provider: provider,
|
||||
clock: clock,
|
||||
}
|
||||
}
|
||||
|
||||
func (fixture attemptWorkerFixture) run(t *testing.T) (context.CancelFunc, func()) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
schedulerDone := make(chan error, 1)
|
||||
poolDone := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
schedulerDone <- fixture.scheduler.Run(ctx)
|
||||
}()
|
||||
go func() {
|
||||
poolDone <- fixture.pool.Run(ctx)
|
||||
}()
|
||||
|
||||
wait := func() {
|
||||
require.ErrorIs(t, <-schedulerDone, context.Canceled)
|
||||
require.ErrorIs(t, <-poolDone, context.Canceled)
|
||||
}
|
||||
|
||||
return cancel, wait
|
||||
}
|
||||
|
||||
type schedulerTestClock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock *schedulerTestClock) Now() time.Time {
|
||||
clock.mu.Lock()
|
||||
defer clock.mu.Unlock()
|
||||
return clock.now
|
||||
}
|
||||
|
||||
func (clock *schedulerTestClock) Advance(delta time.Duration) {
|
||||
clock.mu.Lock()
|
||||
defer clock.mu.Unlock()
|
||||
clock.now = clock.now.Add(delta)
|
||||
}
|
||||
|
||||
type noopRenderer struct{}
|
||||
|
||||
func (noopRenderer) Execute(context.Context, renderdelivery.Input) (renderdelivery.Result, error) {
|
||||
return renderdelivery.Result{}, errors.New("unexpected render invocation")
|
||||
}
|
||||
|
||||
func createAcceptedRenderedDelivery(t *testing.T, client *redis.Client, deliveryID common.DeliveryID, createdAt time.Time) {
|
||||
t.Helper()
|
||||
|
||||
writer, err := redisstate.NewAtomicWriter(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
deliveryRecord := deliverydomain.Delivery{
|
||||
DeliveryID: deliveryID,
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeRendered,
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: []common.Email{common.Email("pilot@example.com")},
|
||||
},
|
||||
Content: deliverydomain.Content{
|
||||
Subject: "Turn ready",
|
||||
TextBody: "Turn 54 is ready.",
|
||||
},
|
||||
IdempotencyKey: common.IdempotencyKey("notification:" + deliveryID.String()),
|
||||
Status: deliverydomain.StatusQueued,
|
||||
AttemptCount: 1,
|
||||
CreatedAt: createdAt.UTC().Truncate(time.Millisecond),
|
||||
UpdatedAt: createdAt.UTC().Truncate(time.Millisecond),
|
||||
}
|
||||
require.NoError(t, deliveryRecord.Validate())
|
||||
|
||||
firstAttempt := attempt.Attempt{
|
||||
DeliveryID: deliveryID,
|
||||
AttemptNo: 1,
|
||||
ScheduledFor: createdAt.UTC().Truncate(time.Millisecond),
|
||||
Status: attempt.StatusScheduled,
|
||||
}
|
||||
require.NoError(t, firstAttempt.Validate())
|
||||
|
||||
require.NoError(t, writer.CreateAcceptance(context.Background(), redisstate.CreateAcceptanceInput{
|
||||
Delivery: deliveryRecord,
|
||||
FirstAttempt: &firstAttempt,
|
||||
}))
|
||||
}
|
||||
|
||||
func loadDeliveryRecord(t *testing.T, client *redis.Client, deliveryID common.DeliveryID) deliverydomain.Delivery {
|
||||
t.Helper()
|
||||
|
||||
payload, err := client.Get(context.Background(), redisstate.Keyspace{}.Delivery(deliveryID)).Bytes()
|
||||
require.NoError(t, err)
|
||||
record, err := redisstate.UnmarshalDelivery(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func loadDeadLetterRecord(t *testing.T, client *redis.Client, deliveryID common.DeliveryID) deliverydomain.DeadLetterEntry {
|
||||
t.Helper()
|
||||
|
||||
payload, err := client.Get(context.Background(), redisstate.Keyspace{}.DeadLetter(deliveryID)).Bytes()
|
||||
require.NoError(t, err)
|
||||
record, err := redisstate.UnmarshalDeadLetter(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func testWorkerLogger() *slog.Logger {
|
||||
return slog.New(slog.NewJSONHandler(io.Discard, nil))
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/adapters/redisstate"
|
||||
)
|
||||
|
||||
const cleanupInterval = time.Hour
|
||||
|
||||
// CleanupWorker stores the idle index cleanup worker used by the Stage 6
|
||||
// runtime skeleton.
|
||||
type CleanupWorker struct {
|
||||
cleaner *redisstate.IndexCleaner
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewCleanupWorker constructs the idle Stage 6 cleanup worker.
|
||||
func NewCleanupWorker(cleaner *redisstate.IndexCleaner, logger *slog.Logger) (*CleanupWorker, error) {
|
||||
if cleaner == nil {
|
||||
return nil, errors.New("new cleanup worker: nil index cleaner")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &CleanupWorker{
|
||||
cleaner: cleaner,
|
||||
logger: logger.With("component", "cleanup_worker"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run starts the idle cleanup worker and blocks until ctx is canceled.
|
||||
func (worker *CleanupWorker) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run cleanup worker: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if worker == nil || worker.cleaner == nil {
|
||||
return errors.New("run cleanup worker: nil cleanup worker")
|
||||
}
|
||||
|
||||
worker.logger.Info("cleanup worker started", "interval", cleanupInterval.String())
|
||||
ticker := time.NewTicker(cleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
worker.logger.Info("cleanup worker stopped")
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown stops the cleanup worker within ctx. The Stage 6 skeleton has no
|
||||
// additional resources to release.
|
||||
func (worker *CleanupWorker) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown cleanup worker: nil context")
|
||||
}
|
||||
if worker == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
// Package worker provides the long-lived background components used by the
|
||||
// runnable Mail Service process.
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/api/streamcommand"
|
||||
"galaxy/mail/internal/domain/malformedcommand"
|
||||
"galaxy/mail/internal/logging"
|
||||
"galaxy/mail/internal/service/acceptgenericdelivery"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// AcceptGenericDeliveryUseCase accepts one generic asynchronous delivery
|
||||
// command.
|
||||
type AcceptGenericDeliveryUseCase interface {
|
||||
// Execute durably accepts one normalized generic-delivery command.
|
||||
Execute(context.Context, streamcommand.Command) (acceptgenericdelivery.Result, error)
|
||||
}
|
||||
|
||||
// MalformedCommandRecorder stores one operator-visible malformed async command
|
||||
// record.
|
||||
type MalformedCommandRecorder interface {
|
||||
// Record persists entry idempotently by stream entry id.
|
||||
Record(context.Context, malformedcommand.Entry) error
|
||||
}
|
||||
|
||||
// StreamOffsetStore stores the last durably processed entry id of one plain
|
||||
// XREAD consumer.
|
||||
type StreamOffsetStore interface {
|
||||
// Load returns the last processed entry id for stream when one is stored.
|
||||
Load(context.Context, string) (string, bool, error)
|
||||
|
||||
// Save stores the last processed entry id for stream.
|
||||
Save(context.Context, string, string) error
|
||||
}
|
||||
|
||||
// CommandConsumerTelemetry records low-cardinality stream-consumer events.
|
||||
type CommandConsumerTelemetry interface {
|
||||
// RecordMalformedCommand records one malformed or rejected async stream
|
||||
// command.
|
||||
RecordMalformedCommand(context.Context, string)
|
||||
}
|
||||
|
||||
// Clock provides the current wall-clock time.
|
||||
type Clock interface {
|
||||
// Now returns the current time.
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
type systemClock struct{}
|
||||
|
||||
func (systemClock) Now() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// CommandConsumerConfig stores the dependencies used by CommandConsumer.
|
||||
type CommandConsumerConfig struct {
|
||||
// Client stores the Redis client used for XREAD.
|
||||
Client *redis.Client
|
||||
|
||||
// Stream stores the Redis Stream name to consume.
|
||||
Stream string
|
||||
|
||||
// BlockTimeout stores the blocking XREAD timeout.
|
||||
BlockTimeout time.Duration
|
||||
|
||||
// Acceptor durably accepts valid generic-delivery commands.
|
||||
Acceptor AcceptGenericDeliveryUseCase
|
||||
|
||||
// MalformedRecorder persists operator-visible malformed-command entries.
|
||||
MalformedRecorder MalformedCommandRecorder
|
||||
|
||||
// OffsetStore stores the last durably processed stream entry id.
|
||||
OffsetStore StreamOffsetStore
|
||||
|
||||
// Telemetry records malformed-command counters.
|
||||
Telemetry CommandConsumerTelemetry
|
||||
|
||||
// Clock provides wall-clock timestamps for malformed-command records.
|
||||
Clock Clock
|
||||
}
|
||||
|
||||
// CommandConsumer stores the Redis Streams consumer used for generic
|
||||
// asynchronous delivery intake.
|
||||
type CommandConsumer struct {
|
||||
client *redis.Client
|
||||
stream string
|
||||
blockTimeout time.Duration
|
||||
acceptor AcceptGenericDeliveryUseCase
|
||||
malformedRecorder MalformedCommandRecorder
|
||||
offsetStore StreamOffsetStore
|
||||
telemetry CommandConsumerTelemetry
|
||||
clock Clock
|
||||
logger *slog.Logger
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewCommandConsumer constructs the generic-delivery command consumer.
|
||||
func NewCommandConsumer(cfg CommandConsumerConfig, logger *slog.Logger) (*CommandConsumer, error) {
|
||||
switch {
|
||||
case cfg.Client == nil:
|
||||
return nil, errors.New("new command consumer: nil redis client")
|
||||
case strings.TrimSpace(cfg.Stream) == "":
|
||||
return nil, errors.New("new command consumer: stream must not be empty")
|
||||
case cfg.BlockTimeout <= 0:
|
||||
return nil, errors.New("new command consumer: block timeout must be positive")
|
||||
case cfg.Acceptor == nil:
|
||||
return nil, errors.New("new command consumer: nil acceptor")
|
||||
case cfg.MalformedRecorder == nil:
|
||||
return nil, errors.New("new command consumer: nil malformed recorder")
|
||||
case cfg.OffsetStore == nil:
|
||||
return nil, errors.New("new command consumer: nil offset store")
|
||||
}
|
||||
if cfg.Clock == nil {
|
||||
cfg.Clock = systemClock{}
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &CommandConsumer{
|
||||
client: cfg.Client,
|
||||
stream: cfg.Stream,
|
||||
blockTimeout: cfg.BlockTimeout,
|
||||
acceptor: cfg.Acceptor,
|
||||
malformedRecorder: cfg.MalformedRecorder,
|
||||
offsetStore: cfg.OffsetStore,
|
||||
telemetry: cfg.Telemetry,
|
||||
clock: cfg.Clock,
|
||||
logger: logger.With("component", "command_consumer", "stream", cfg.Stream),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run starts the command consumer and blocks until ctx is canceled or Redis
|
||||
// returns an unexpected error.
|
||||
func (consumer *CommandConsumer) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run command consumer: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if consumer == nil || consumer.client == nil {
|
||||
return errors.New("run command consumer: nil consumer")
|
||||
}
|
||||
|
||||
lastID, found, err := consumer.offsetStore.Load(ctx, consumer.stream)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run command consumer: load stream offset: %w", err)
|
||||
}
|
||||
if !found {
|
||||
lastID = "0-0"
|
||||
}
|
||||
|
||||
consumer.logger.Info("command consumer started", "block_timeout", consumer.blockTimeout.String(), "start_entry_id", lastID)
|
||||
|
||||
for {
|
||||
streams, err := consumer.client.XRead(ctx, &redis.XReadArgs{
|
||||
Streams: []string{consumer.stream, lastID},
|
||||
Count: 1,
|
||||
Block: consumer.blockTimeout,
|
||||
}).Result()
|
||||
switch {
|
||||
case err == nil:
|
||||
for _, stream := range streams {
|
||||
for _, message := range stream.Messages {
|
||||
if err := consumer.handleMessage(ctx, message); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := consumer.offsetStore.Save(ctx, consumer.stream, message.ID); err != nil {
|
||||
return fmt.Errorf("run command consumer: save stream offset: %w", err)
|
||||
}
|
||||
lastID = message.ID
|
||||
}
|
||||
}
|
||||
case errors.Is(err, redis.Nil):
|
||||
continue
|
||||
case ctx.Err() != nil && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, redis.ErrClosed)):
|
||||
consumer.logger.Info("command consumer stopped")
|
||||
return ctx.Err()
|
||||
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded), errors.Is(err, redis.ErrClosed):
|
||||
return fmt.Errorf("run command consumer: %w", err)
|
||||
default:
|
||||
return fmt.Errorf("run command consumer: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (consumer *CommandConsumer) handleMessage(ctx context.Context, message redis.XMessage) error {
|
||||
rawFields := cloneRawFields(message.Values)
|
||||
|
||||
command, err := streamcommand.DecodeCommand(rawFields)
|
||||
if err != nil {
|
||||
return consumer.recordMalformed(ctx, message.ID, rawFields, streamcommand.ClassifyDecodeError(err), err)
|
||||
}
|
||||
|
||||
result, err := consumer.acceptor.Execute(ctx, command)
|
||||
switch {
|
||||
case err == nil:
|
||||
logArgs := logging.CommandAttrs(command)
|
||||
logArgs = append(logArgs,
|
||||
"stream_entry_id", message.ID,
|
||||
"outcome", string(result.Outcome),
|
||||
)
|
||||
logArgs = append(logArgs, logging.TraceAttrsFromContext(ctx)...)
|
||||
consumer.logger.Info("generic command accepted", logArgs...)
|
||||
return nil
|
||||
case errors.Is(err, acceptgenericdelivery.ErrConflict):
|
||||
return consumer.recordMalformed(ctx, message.ID, rawFields, malformedcommand.FailureCodeIdempotencyConflict, err)
|
||||
case errors.Is(err, acceptgenericdelivery.ErrServiceUnavailable):
|
||||
return fmt.Errorf("handle command %q: %w", message.ID, err)
|
||||
default:
|
||||
return fmt.Errorf("handle command %q: %w", message.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (consumer *CommandConsumer) recordMalformed(
|
||||
ctx context.Context,
|
||||
streamEntryID string,
|
||||
rawFields map[string]any,
|
||||
failureCode malformedcommand.FailureCode,
|
||||
cause error,
|
||||
) error {
|
||||
entry := malformedcommand.Entry{
|
||||
StreamEntryID: streamEntryID,
|
||||
DeliveryID: optionalRawString(rawFields, "delivery_id"),
|
||||
Source: optionalRawString(rawFields, "source"),
|
||||
IdempotencyKey: optionalRawString(rawFields, "idempotency_key"),
|
||||
FailureCode: failureCode,
|
||||
FailureMessage: strings.TrimSpace(cause.Error()),
|
||||
RawFields: cloneRawFields(rawFields),
|
||||
RecordedAt: consumer.clock.Now().UTC().Truncate(time.Millisecond),
|
||||
}
|
||||
if err := consumer.malformedRecorder.Record(ctx, entry); err != nil {
|
||||
return fmt.Errorf("record malformed command %q: %w", streamEntryID, err)
|
||||
}
|
||||
if consumer.telemetry != nil {
|
||||
consumer.telemetry.RecordMalformedCommand(ctx, string(failureCode))
|
||||
}
|
||||
|
||||
consumer.logger.Warn("stream command rejected",
|
||||
append([]any{
|
||||
"stream_entry_id", streamEntryID,
|
||||
"delivery_id", entry.DeliveryID,
|
||||
"source", entry.Source,
|
||||
"idempotency_key", entry.IdempotencyKey,
|
||||
"trace_id", optionalRawString(rawFields, "trace_id"),
|
||||
"failure_code", string(entry.FailureCode),
|
||||
"failure_message", entry.FailureMessage,
|
||||
}, logging.TraceAttrsFromContext(ctx)...)...,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneRawFields(values map[string]any) map[string]any {
|
||||
if values == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
cloned := make(map[string]any, len(values))
|
||||
for key, value := range values {
|
||||
cloned[key] = cloneRawValue(value)
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneRawValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
return cloneRawFields(typed)
|
||||
case []any:
|
||||
cloned := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
cloned[index] = cloneRawValue(item)
|
||||
}
|
||||
return cloned
|
||||
default:
|
||||
return typed
|
||||
}
|
||||
}
|
||||
|
||||
func optionalRawString(values map[string]any, key string) string {
|
||||
raw, ok := values[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
value, ok := raw.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
// Shutdown stops the command consumer within ctx. The consumer uses the
|
||||
// shared process Redis client and therefore has no dedicated resources to
|
||||
// release here.
|
||||
func (consumer *CommandConsumer) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown command consumer: nil context")
|
||||
}
|
||||
if consumer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
consumer.closeOnce.Do(func() {
|
||||
if consumer.client != nil {
|
||||
err = consumer.client.Close()
|
||||
}
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/adapters/redisstate"
|
||||
"galaxy/mail/internal/service/acceptgenericdelivery"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCommandConsumerAcceptsRenderedCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fixture := newCommandConsumerFixture(t)
|
||||
messageID := addRenderedCommand(t, fixture.client, "mail-123", "notification:mail-123")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- fixture.consumer.Run(ctx)
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
delivery, found, err := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-123")
|
||||
if err != nil || !found {
|
||||
return false
|
||||
}
|
||||
entryID, found, err := fixture.offsetStore.Load(context.Background(), fixture.stream)
|
||||
return err == nil && found && entryID == messageID && delivery.DeliveryID == "mail-123"
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
cancel()
|
||||
require.ErrorIs(t, <-done, context.Canceled)
|
||||
}
|
||||
|
||||
func TestCommandConsumerAcceptsTemplateCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fixture := newCommandConsumerFixture(t)
|
||||
messageID := addTemplateCommand(t, fixture.client, "mail-124", "notification:mail-124")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- fixture.consumer.Run(ctx)
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
delivery, found, err := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-124")
|
||||
if err != nil || !found {
|
||||
return false
|
||||
}
|
||||
entryID, found, err := fixture.offsetStore.Load(context.Background(), fixture.stream)
|
||||
return err == nil && found && entryID == messageID && delivery.TemplateID == "game.turn_ready"
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
cancel()
|
||||
require.ErrorIs(t, <-done, context.Canceled)
|
||||
}
|
||||
|
||||
func TestCommandConsumerRecordsMalformedCommandAndContinues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fixture := newCommandConsumerFixture(t)
|
||||
malformedID := addMalformedRenderedCommand(t, fixture.client, "mail-bad", "notification:mail-bad")
|
||||
validID := addRenderedCommand(t, fixture.client, "mail-125", "notification:mail-125")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- fixture.consumer.Run(ctx)
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
_, deliveryFound, deliveryErr := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-125")
|
||||
entry, malformedFound, malformedErr := fixture.malformedStore.Get(context.Background(), malformedID)
|
||||
entryID, offsetFound, offsetErr := fixture.offsetStore.Load(context.Background(), fixture.stream)
|
||||
return deliveryErr == nil &&
|
||||
malformedErr == nil &&
|
||||
offsetErr == nil &&
|
||||
deliveryFound &&
|
||||
malformedFound &&
|
||||
entry.FailureCode == "invalid_payload" &&
|
||||
offsetFound &&
|
||||
entryID == validID
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
cancel()
|
||||
require.ErrorIs(t, <-done, context.Canceled)
|
||||
}
|
||||
|
||||
func TestCommandConsumerRestartsFromSavedOffset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fixture := newCommandConsumerFixture(t)
|
||||
firstID := addRenderedCommand(t, fixture.client, "mail-126", "notification:mail-126")
|
||||
|
||||
firstCtx, firstCancel := context.WithCancel(context.Background())
|
||||
firstDone := make(chan error, 1)
|
||||
go func() {
|
||||
firstDone <- fixture.consumer.Run(firstCtx)
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
entryID, found, err := fixture.offsetStore.Load(context.Background(), fixture.stream)
|
||||
return err == nil && found && entryID == firstID
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
firstCancel()
|
||||
require.ErrorIs(t, <-firstDone, context.Canceled)
|
||||
|
||||
secondID := addRenderedCommand(t, fixture.client, "mail-127", "notification:mail-127")
|
||||
|
||||
secondCtx, secondCancel := context.WithCancel(context.Background())
|
||||
secondDone := make(chan error, 1)
|
||||
go func() {
|
||||
secondDone <- fixture.consumer.Run(secondCtx)
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
_, firstFound, firstErr := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-126")
|
||||
_, secondFound, secondErr := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-127")
|
||||
entryID, offsetFound, offsetErr := fixture.offsetStore.Load(context.Background(), fixture.stream)
|
||||
return firstErr == nil &&
|
||||
secondErr == nil &&
|
||||
offsetErr == nil &&
|
||||
firstFound &&
|
||||
secondFound &&
|
||||
offsetFound &&
|
||||
entryID == secondID
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
secondCancel()
|
||||
require.ErrorIs(t, <-secondDone, context.Canceled)
|
||||
}
|
||||
|
||||
func TestCommandConsumerDoesNotDuplicateAcceptanceAfterOffsetSaveFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fixture := newCommandConsumerFixture(t)
|
||||
messageID := addRenderedCommand(t, fixture.client, "mail-128", "notification:mail-128")
|
||||
failingOffsetStore := &scriptedOffsetStore{
|
||||
saveErrs: []error{errors.New("offset unavailable")},
|
||||
}
|
||||
consumer := newCommandConsumerForTest(t, fixture.client, fixture.stream, fixture.acceptor, fixture.malformedStore, failingOffsetStore)
|
||||
|
||||
err := consumer.Run(context.Background())
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "save stream offset")
|
||||
|
||||
delivery, found, err := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-128")
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, "mail-128", delivery.DeliveryID.String())
|
||||
|
||||
indexCard, err := fixture.client.ZCard(context.Background(), redisstate.Keyspace{}.CreatedAtIndex()).Result()
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, indexCard)
|
||||
|
||||
replayConsumer := newCommandConsumerForTest(t, fixture.client, fixture.stream, fixture.acceptor, fixture.malformedStore, failingOffsetStore)
|
||||
replayCtx, replayCancel := context.WithCancel(context.Background())
|
||||
replayDone := make(chan error, 1)
|
||||
go func() {
|
||||
replayDone <- replayConsumer.Run(replayCtx)
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return failingOffsetStore.lastEntryID == messageID
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
replayCancel()
|
||||
require.ErrorIs(t, <-replayDone, context.Canceled)
|
||||
|
||||
indexCard, err = fixture.client.ZCard(context.Background(), redisstate.Keyspace{}.CreatedAtIndex()).Result()
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, indexCard)
|
||||
|
||||
scheduleCard, err := fixture.client.ZCard(context.Background(), redisstate.Keyspace{}.AttemptSchedule()).Result()
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, scheduleCard)
|
||||
}
|
||||
|
||||
func TestCommandConsumerRecordsIdempotencyConflictAsMalformed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fixture := newCommandConsumerFixture(t)
|
||||
addRenderedCommand(t, fixture.client, "mail-129", "notification:shared")
|
||||
conflictID := addRenderedCommandWithSubject(t, fixture.client, "mail-130", "notification:shared", "Different subject")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- fixture.consumer.Run(ctx)
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
_, firstFound, firstErr := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-129")
|
||||
_, secondFound, secondErr := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-130")
|
||||
entry, malformedFound, malformedErr := fixture.malformedStore.Get(context.Background(), conflictID)
|
||||
return firstErr == nil &&
|
||||
secondErr == nil &&
|
||||
malformedErr == nil &&
|
||||
firstFound &&
|
||||
!secondFound &&
|
||||
malformedFound &&
|
||||
entry.FailureCode == "idempotency_conflict"
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
cancel()
|
||||
require.ErrorIs(t, <-done, context.Canceled)
|
||||
}
|
||||
|
||||
type commandConsumerFixture struct {
|
||||
client *redis.Client
|
||||
stream string
|
||||
consumer *CommandConsumer
|
||||
acceptor *acceptgenericdelivery.Service
|
||||
acceptanceStore *redisstate.GenericAcceptanceStore
|
||||
malformedStore *redisstate.MalformedCommandStore
|
||||
offsetStore *redisstate.StreamOffsetStore
|
||||
}
|
||||
|
||||
func newCommandConsumerFixture(t *testing.T) commandConsumerFixture {
|
||||
t.Helper()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
acceptanceStore, err := redisstate.NewGenericAcceptanceStore(client)
|
||||
require.NoError(t, err)
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
acceptor, err := acceptgenericdelivery.New(acceptgenericdelivery.Config{
|
||||
Store: acceptanceStore,
|
||||
Clock: testClock{now: now},
|
||||
IdempotencyTTL: redisstate.IdempotencyTTL,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
malformedStore, err := redisstate.NewMalformedCommandStore(client)
|
||||
require.NoError(t, err)
|
||||
offsetStore, err := redisstate.NewStreamOffsetStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
stream := redisstate.Keyspace{}.DeliveryCommands()
|
||||
consumer := newCommandConsumerForTest(t, client, stream, acceptor, malformedStore, offsetStore)
|
||||
|
||||
return commandConsumerFixture{
|
||||
client: client,
|
||||
stream: stream,
|
||||
consumer: consumer,
|
||||
acceptor: acceptor,
|
||||
acceptanceStore: acceptanceStore,
|
||||
malformedStore: malformedStore,
|
||||
offsetStore: offsetStore,
|
||||
}
|
||||
}
|
||||
|
||||
func newCommandConsumerForTest(
|
||||
t *testing.T,
|
||||
client *redis.Client,
|
||||
stream string,
|
||||
acceptor AcceptGenericDeliveryUseCase,
|
||||
malformedRecorder MalformedCommandRecorder,
|
||||
offsetStore StreamOffsetStore,
|
||||
) *CommandConsumer {
|
||||
t.Helper()
|
||||
|
||||
consumer, err := NewCommandConsumer(CommandConsumerConfig{
|
||||
Client: client,
|
||||
Stream: stream,
|
||||
BlockTimeout: 20 * time.Millisecond,
|
||||
Acceptor: acceptor,
|
||||
MalformedRecorder: malformedRecorder,
|
||||
OffsetStore: offsetStore,
|
||||
Clock: testClock{now: time.Now().UTC().Truncate(time.Millisecond)},
|
||||
}, testLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
return consumer
|
||||
}
|
||||
|
||||
func addRenderedCommand(t *testing.T, client *redis.Client, deliveryID string, idempotencyKey string) string {
|
||||
t.Helper()
|
||||
|
||||
return addRenderedCommandWithSubject(t, client, deliveryID, idempotencyKey, "Turn ready")
|
||||
}
|
||||
|
||||
func addRenderedCommandWithSubject(t *testing.T, client *redis.Client, deliveryID string, idempotencyKey string, subject string) string {
|
||||
t.Helper()
|
||||
|
||||
messageID, err := client.XAdd(context.Background(), &redis.XAddArgs{
|
||||
Stream: redisstate.Keyspace{}.DeliveryCommands(),
|
||||
Values: map[string]any{
|
||||
"delivery_id": deliveryID,
|
||||
"source": "notification",
|
||||
"payload_mode": "rendered",
|
||||
"idempotency_key": idempotencyKey,
|
||||
"requested_at_ms": "1775121700000",
|
||||
"payload_json": `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":["noreply@example.com"],"subject":"` + subject + `","text_body":"Turn 54 is ready.","html_body":"<p>Turn 54 is ready.</p>","attachments":[]}`,
|
||||
},
|
||||
}).Result()
|
||||
require.NoError(t, err)
|
||||
|
||||
return messageID
|
||||
}
|
||||
|
||||
func addTemplateCommand(t *testing.T, client *redis.Client, deliveryID string, idempotencyKey string) string {
|
||||
t.Helper()
|
||||
|
||||
messageID, err := client.XAdd(context.Background(), &redis.XAddArgs{
|
||||
Stream: redisstate.Keyspace{}.DeliveryCommands(),
|
||||
Values: map[string]any{
|
||||
"delivery_id": deliveryID,
|
||||
"source": "notification",
|
||||
"payload_mode": "template",
|
||||
"idempotency_key": idempotencyKey,
|
||||
"requested_at_ms": "1775121700001",
|
||||
"payload_json": `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn_ready","locale":"fr-FR","variables":{"turn_number":54},"attachments":[]}`,
|
||||
},
|
||||
}).Result()
|
||||
require.NoError(t, err)
|
||||
|
||||
return messageID
|
||||
}
|
||||
|
||||
func addMalformedRenderedCommand(t *testing.T, client *redis.Client, deliveryID string, idempotencyKey string) string {
|
||||
t.Helper()
|
||||
|
||||
messageID, err := client.XAdd(context.Background(), &redis.XAddArgs{
|
||||
Stream: redisstate.Keyspace{}.DeliveryCommands(),
|
||||
Values: map[string]any{
|
||||
"delivery_id": deliveryID,
|
||||
"source": "notification",
|
||||
"payload_mode": "rendered",
|
||||
"idempotency_key": idempotencyKey,
|
||||
"requested_at_ms": "1775121700000",
|
||||
"payload_json": `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"text_body":"Turn 54 is ready.","attachments":[]}`,
|
||||
},
|
||||
}).Result()
|
||||
require.NoError(t, err)
|
||||
|
||||
return messageID
|
||||
}
|
||||
|
||||
type testClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock testClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
|
||||
type scriptedOffsetStore struct {
|
||||
lastEntryID string
|
||||
found bool
|
||||
saveErrs []error
|
||||
saveCalls int
|
||||
}
|
||||
|
||||
func (store *scriptedOffsetStore) Load(context.Context, string) (string, bool, error) {
|
||||
if !store.found {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
return store.lastEntryID, true, nil
|
||||
}
|
||||
|
||||
func (store *scriptedOffsetStore) Save(_ context.Context, _ string, entryID string) error {
|
||||
if store.saveCalls < len(store.saveErrs) && store.saveErrs[store.saveCalls] != nil {
|
||||
store.saveCalls++
|
||||
return store.saveErrs[store.saveCalls-1]
|
||||
}
|
||||
|
||||
store.saveCalls++
|
||||
store.lastEntryID = entryID
|
||||
store.found = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewJSONHandler(io.Discard, nil))
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/logging"
|
||||
"galaxy/mail/internal/service/executeattempt"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSchedulePollInterval = 250 * time.Millisecond
|
||||
defaultRecoveryInterval = 30 * time.Second
|
||||
defaultRecoveryGrace = 30 * time.Second
|
||||
)
|
||||
|
||||
// AttemptExecutionStore describes the durable state operations used by the
|
||||
// attempt scheduler.
|
||||
type AttemptExecutionStore interface {
|
||||
// NextDueDeliveryIDs returns up to limit due delivery identifiers.
|
||||
NextDueDeliveryIDs(context.Context, time.Time, int64) ([]common.DeliveryID, error)
|
||||
|
||||
// SendingDeliveryIDs returns every delivery currently indexed as sending.
|
||||
SendingDeliveryIDs(context.Context) ([]common.DeliveryID, error)
|
||||
|
||||
// LoadWorkItem loads the current delivery and active attempt for deliveryID.
|
||||
LoadWorkItem(context.Context, common.DeliveryID) (executeattempt.WorkItem, bool, error)
|
||||
|
||||
// ClaimDueAttempt atomically claims the due scheduled attempt for
|
||||
// deliveryID.
|
||||
ClaimDueAttempt(context.Context, common.DeliveryID, time.Time) (executeattempt.WorkItem, bool, error)
|
||||
|
||||
// RemoveScheduledDelivery removes deliveryID from the attempt schedule set.
|
||||
RemoveScheduledDelivery(context.Context, common.DeliveryID) error
|
||||
}
|
||||
|
||||
// AttemptPreparationService prepares queued template deliveries and recovers
|
||||
// stale claimed attempts.
|
||||
type AttemptPreparationService interface {
|
||||
// Prepare renders one queued template delivery when needed and reports
|
||||
// whether the scheduler may continue to claim the attempt.
|
||||
Prepare(context.Context, executeattempt.WorkItem) (bool, error)
|
||||
|
||||
// RecoverExpired marks one stale in-progress attempt as timed out.
|
||||
RecoverExpired(context.Context, executeattempt.WorkItem) error
|
||||
}
|
||||
|
||||
// SchedulerTelemetry records low-cardinality scheduler-side delivery
|
||||
// transitions.
|
||||
type SchedulerTelemetry interface {
|
||||
// RecordDeliveryStatusTransition records one durable delivery status
|
||||
// transition.
|
||||
RecordDeliveryStatusTransition(context.Context, string, string)
|
||||
}
|
||||
|
||||
// SchedulerConfig stores the dependencies used by Scheduler.
|
||||
type SchedulerConfig struct {
|
||||
// Store owns the durable scheduled and in-progress attempt state.
|
||||
Store AttemptExecutionStore
|
||||
|
||||
// Service prepares queued template deliveries and recovers stale claims.
|
||||
Service AttemptPreparationService
|
||||
|
||||
// WorkQueue stores the claimed attempt handoff channel consumed by the
|
||||
// attempt worker pool.
|
||||
WorkQueue chan<- executeattempt.WorkItem
|
||||
|
||||
// Clock provides the scheduler wall clock.
|
||||
Clock Clock
|
||||
|
||||
// AttemptTimeout stores the provider execution budget used to derive claim
|
||||
// recovery deadlines.
|
||||
AttemptTimeout time.Duration
|
||||
|
||||
// Telemetry records scheduler-side delivery transitions.
|
||||
Telemetry SchedulerTelemetry
|
||||
|
||||
// PollInterval overrides the default due-attempt polling interval when
|
||||
// positive.
|
||||
PollInterval time.Duration
|
||||
|
||||
// RecoveryInterval overrides the default stale-claim recovery interval when
|
||||
// positive.
|
||||
RecoveryInterval time.Duration
|
||||
|
||||
// RecoveryGrace overrides the default stale-claim grace window when
|
||||
// positive.
|
||||
RecoveryGrace time.Duration
|
||||
}
|
||||
|
||||
// Scheduler polls due attempts, optionally renders queued template
|
||||
// deliveries, atomically claims runnable work, and recovers stale in-progress
|
||||
// ownership.
|
||||
type Scheduler struct {
|
||||
store AttemptExecutionStore
|
||||
service AttemptPreparationService
|
||||
workQueue chan<- executeattempt.WorkItem
|
||||
clock Clock
|
||||
attemptTimeout time.Duration
|
||||
telemetry SchedulerTelemetry
|
||||
pollInterval time.Duration
|
||||
recoveryInterval time.Duration
|
||||
recoveryGrace time.Duration
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewScheduler constructs one attempt scheduler.
|
||||
func NewScheduler(cfg SchedulerConfig, logger *slog.Logger) (*Scheduler, error) {
|
||||
switch {
|
||||
case cfg.Store == nil:
|
||||
return nil, errors.New("new scheduler: nil attempt execution store")
|
||||
case cfg.Service == nil:
|
||||
return nil, errors.New("new scheduler: nil attempt preparation service")
|
||||
case cfg.WorkQueue == nil:
|
||||
return nil, errors.New("new scheduler: nil work queue")
|
||||
case cfg.Clock == nil:
|
||||
return nil, errors.New("new scheduler: nil clock")
|
||||
case cfg.AttemptTimeout <= 0:
|
||||
return nil, errors.New("new scheduler: non-positive attempt timeout")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
pollInterval := cfg.PollInterval
|
||||
if pollInterval <= 0 {
|
||||
pollInterval = defaultSchedulePollInterval
|
||||
}
|
||||
|
||||
recoveryInterval := cfg.RecoveryInterval
|
||||
if recoveryInterval <= 0 {
|
||||
recoveryInterval = defaultRecoveryInterval
|
||||
}
|
||||
|
||||
recoveryGrace := cfg.RecoveryGrace
|
||||
if recoveryGrace <= 0 {
|
||||
recoveryGrace = defaultRecoveryGrace
|
||||
}
|
||||
|
||||
return &Scheduler{
|
||||
store: cfg.Store,
|
||||
service: cfg.Service,
|
||||
workQueue: cfg.WorkQueue,
|
||||
clock: cfg.Clock,
|
||||
attemptTimeout: cfg.AttemptTimeout,
|
||||
telemetry: cfg.Telemetry,
|
||||
pollInterval: pollInterval,
|
||||
recoveryInterval: recoveryInterval,
|
||||
recoveryGrace: recoveryGrace,
|
||||
logger: logger.With(
|
||||
"component", "scheduler",
|
||||
"poll_interval", pollInterval.String(),
|
||||
"recovery_interval", recoveryInterval.String(),
|
||||
"recovery_grace", recoveryGrace.String(),
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run starts the scheduler loop and blocks until ctx is canceled or one
|
||||
// durable state operation fails.
|
||||
func (scheduler *Scheduler) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run scheduler: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if scheduler == nil {
|
||||
return errors.New("run scheduler: nil scheduler")
|
||||
}
|
||||
|
||||
scheduler.logger.Info("scheduler started")
|
||||
defer scheduler.logger.Info("scheduler stopped")
|
||||
|
||||
if err := scheduler.recoverExpired(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pollTicker := time.NewTicker(scheduler.pollInterval)
|
||||
defer pollTicker.Stop()
|
||||
|
||||
recoveryTicker := time.NewTicker(scheduler.recoveryInterval)
|
||||
defer recoveryTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-pollTicker.C:
|
||||
if err := scheduler.dispatchDueAttempts(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
case <-recoveryTicker.C:
|
||||
if err := scheduler.recoverExpired(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown stops the scheduler within ctx. The scheduler does not own
|
||||
// additional resources beyond its run loop.
|
||||
func (scheduler *Scheduler) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown scheduler: nil context")
|
||||
}
|
||||
if scheduler == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (scheduler *Scheduler) dispatchDueAttempts(ctx context.Context) error {
|
||||
for {
|
||||
now := scheduler.clock.Now().UTC().Truncate(time.Millisecond)
|
||||
deliveryIDs, err := scheduler.store.NextDueDeliveryIDs(ctx, now, 1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dispatch due attempts: %w", err)
|
||||
}
|
||||
if len(deliveryIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := scheduler.dispatchOne(ctx, deliveryIDs[0], now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (scheduler *Scheduler) dispatchOne(ctx context.Context, deliveryID common.DeliveryID, now time.Time) error {
|
||||
workItem, found, err := scheduler.store.LoadWorkItem(ctx, deliveryID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dispatch due delivery %q: load work item: %w", deliveryID, err)
|
||||
}
|
||||
if !found {
|
||||
if err := scheduler.store.RemoveScheduledDelivery(ctx, deliveryID); err != nil {
|
||||
return fmt.Errorf("dispatch due delivery %q: remove stale schedule: %w", deliveryID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !isSchedulable(workItem) {
|
||||
if err := scheduler.store.RemoveScheduledDelivery(ctx, deliveryID); err != nil {
|
||||
return fmt.Errorf("dispatch due delivery %q: remove unschedulable entry: %w", deliveryID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ready, err := scheduler.service.Prepare(ctx, workItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dispatch due delivery %q: prepare attempt: %w", deliveryID, err)
|
||||
}
|
||||
if !ready {
|
||||
return nil
|
||||
}
|
||||
|
||||
claimed, found, err := scheduler.store.ClaimDueAttempt(ctx, deliveryID, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dispatch due delivery %q: claim attempt: %w", deliveryID, err)
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
scheduler.recordStatusTransition(ctx, claimed.Delivery)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case scheduler.workQueue <- claimed:
|
||||
logArgs := logging.DeliveryAttemptAttrs(claimed.Delivery, claimed.Attempt)
|
||||
logArgs = append(logArgs, logging.TraceAttrsFromContext(ctx)...)
|
||||
scheduler.logger.Debug("attempt claimed", logArgs...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (scheduler *Scheduler) recoverExpired(ctx context.Context) error {
|
||||
now := scheduler.clock.Now().UTC().Truncate(time.Millisecond)
|
||||
deadline := now.Add(-(scheduler.attemptTimeout + scheduler.recoveryGrace))
|
||||
|
||||
deliveryIDs, err := scheduler.store.SendingDeliveryIDs(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("recover expired attempts: %w", err)
|
||||
}
|
||||
|
||||
for _, deliveryID := range deliveryIDs {
|
||||
workItem, found, err := scheduler.store.LoadWorkItem(ctx, deliveryID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("recover expired delivery %q: load work item: %w", deliveryID, err)
|
||||
}
|
||||
if !found || !isRecoverable(workItem) || workItem.Attempt.StartedAt == nil {
|
||||
continue
|
||||
}
|
||||
if workItem.Attempt.StartedAt.After(deadline) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := scheduler.service.RecoverExpired(ctx, workItem); err != nil {
|
||||
return fmt.Errorf("recover expired delivery %q: %w", deliveryID, err)
|
||||
}
|
||||
|
||||
logArgs := logging.DeliveryAttemptAttrs(workItem.Delivery, workItem.Attempt)
|
||||
logArgs = append(logArgs, "started_at", workItem.Attempt.StartedAt)
|
||||
logArgs = append(logArgs, logging.TraceAttrsFromContext(ctx)...)
|
||||
scheduler.logger.Warn("attempt claim expired", logArgs...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (scheduler *Scheduler) recordStatusTransition(ctx context.Context, record deliverydomain.Delivery) {
|
||||
if scheduler == nil || scheduler.telemetry == nil {
|
||||
return
|
||||
}
|
||||
|
||||
scheduler.telemetry.RecordDeliveryStatusTransition(ctx, string(record.Status), string(record.Source))
|
||||
}
|
||||
|
||||
func isSchedulable(item executeattempt.WorkItem) bool {
|
||||
if item.Delivery.AttemptCount != item.Attempt.AttemptNo {
|
||||
return false
|
||||
}
|
||||
switch item.Delivery.Status {
|
||||
case deliverydomain.StatusQueued, deliverydomain.StatusRendered:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
return item.Attempt.Status == attempt.StatusScheduled
|
||||
}
|
||||
|
||||
func isRecoverable(item executeattempt.WorkItem) bool {
|
||||
if item.Delivery.AttemptCount != item.Attempt.AttemptNo {
|
||||
return false
|
||||
}
|
||||
if item.Delivery.Status != deliverydomain.StatusSending {
|
||||
return false
|
||||
}
|
||||
|
||||
return item.Attempt.Status == attempt.StatusInProgress
|
||||
}
|
||||
Reference in New Issue
Block a user