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