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