feat: mail service
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user