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