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