feat: mail service

This commit is contained in:
Ilia Denisov
2026-04-17 18:39:16 +02:00
committed by GitHub
parent 23ffcb7535
commit 5b7593e6f6
183 changed files with 31215 additions and 248 deletions
@@ -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)