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,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
}