feat: mail service
This commit is contained in:
@@ -0,0 +1,625 @@
|
||||
// Package delivery defines the logical delivery and dead-letter entities owned
|
||||
// directly by Mail Service.
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
)
|
||||
|
||||
// Source identifies the trusted caller or workflow that created one delivery.
|
||||
type Source string
|
||||
|
||||
const (
|
||||
// SourceAuthSession reports deliveries accepted from Auth / Session Service.
|
||||
SourceAuthSession Source = "authsession"
|
||||
|
||||
// SourceNotification reports deliveries accepted from Notification Service.
|
||||
SourceNotification Source = "notification"
|
||||
|
||||
// SourceOperatorResend reports clone deliveries created by the operator
|
||||
// resend workflow.
|
||||
SourceOperatorResend Source = "operator_resend"
|
||||
)
|
||||
|
||||
// IsKnown reports whether Source belongs to the frozen v1 source vocabulary.
|
||||
func (source Source) IsKnown() bool {
|
||||
switch source {
|
||||
case SourceAuthSession, SourceNotification, SourceOperatorResend:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// PayloadMode identifies whether the delivery carries pre-rendered content or
|
||||
// template-selection metadata.
|
||||
type PayloadMode string
|
||||
|
||||
const (
|
||||
// PayloadModeRendered reports that the delivery already stores final
|
||||
// rendered content.
|
||||
PayloadModeRendered PayloadMode = "rendered"
|
||||
|
||||
// PayloadModeTemplate reports that final content is produced later from a
|
||||
// template and locale.
|
||||
PayloadModeTemplate PayloadMode = "template"
|
||||
)
|
||||
|
||||
// IsKnown reports whether PayloadMode is supported by the current domain
|
||||
// model.
|
||||
func (mode PayloadMode) IsKnown() bool {
|
||||
switch mode {
|
||||
case PayloadModeRendered, PayloadModeTemplate:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Status identifies the lifecycle state of one logical mail delivery.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
// StatusAccepted reports that intake validation succeeded and a durable
|
||||
// delivery record exists.
|
||||
StatusAccepted Status = "accepted"
|
||||
|
||||
// StatusQueued reports that the next attempt is durably scheduled.
|
||||
StatusQueued Status = "queued"
|
||||
|
||||
// StatusRendered reports that template-mode content has been materialized.
|
||||
StatusRendered Status = "rendered"
|
||||
|
||||
// StatusSending reports that one worker currently owns the active attempt.
|
||||
StatusSending Status = "sending"
|
||||
|
||||
// StatusSent reports that the provider accepted the SMTP envelope.
|
||||
StatusSent Status = "sent"
|
||||
|
||||
// StatusSuppressed reports that delivery was intentionally skipped as a
|
||||
// successful business outcome.
|
||||
StatusSuppressed Status = "suppressed"
|
||||
|
||||
// StatusFailed reports that delivery ended in a terminal failure without a
|
||||
// dead-letter entry.
|
||||
StatusFailed Status = "failed"
|
||||
|
||||
// StatusDeadLetter reports that delivery reached an operator-visible
|
||||
// dead-letter state.
|
||||
StatusDeadLetter Status = "dead_letter"
|
||||
)
|
||||
|
||||
// IsKnown reports whether Status belongs to the frozen v1 delivery lifecycle.
|
||||
func (status Status) IsKnown() bool {
|
||||
switch status {
|
||||
case StatusAccepted,
|
||||
StatusQueued,
|
||||
StatusRendered,
|
||||
StatusSending,
|
||||
StatusSent,
|
||||
StatusSuppressed,
|
||||
StatusFailed,
|
||||
StatusDeadLetter:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsTerminal reports whether Status can no longer accept lifecycle
|
||||
// transitions.
|
||||
func (status Status) IsTerminal() bool {
|
||||
switch status {
|
||||
case StatusSent, StatusSuppressed, StatusFailed, StatusDeadLetter:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CanTransitionTo reports whether the current Status may move to next under
|
||||
// the frozen Stage 2 delivery lifecycle rules.
|
||||
func (status Status) CanTransitionTo(next Status) bool {
|
||||
switch status {
|
||||
case StatusAccepted:
|
||||
switch next {
|
||||
case StatusQueued, StatusSuppressed:
|
||||
return true
|
||||
}
|
||||
case StatusQueued:
|
||||
switch next {
|
||||
case StatusRendered, StatusSending, StatusFailed:
|
||||
return true
|
||||
}
|
||||
case StatusRendered:
|
||||
switch next {
|
||||
case StatusSending, StatusFailed:
|
||||
return true
|
||||
}
|
||||
case StatusSending:
|
||||
switch next {
|
||||
case StatusSent, StatusSuppressed, StatusQueued, StatusFailed, StatusDeadLetter:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// AllowsResend reports whether deliveries in Status may be cloned through the
|
||||
// trusted resend workflow.
|
||||
func (status Status) AllowsResend() bool {
|
||||
switch status {
|
||||
case StatusSent, StatusSuppressed, StatusFailed, StatusDeadLetter:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Envelope stores the SMTP-addressing fields of one logical delivery.
|
||||
type Envelope struct {
|
||||
// To stores the primary recipients.
|
||||
To []common.Email
|
||||
|
||||
// Cc stores the carbon-copy recipients.
|
||||
Cc []common.Email
|
||||
|
||||
// Bcc stores the blind-carbon-copy recipients.
|
||||
Bcc []common.Email
|
||||
|
||||
// ReplyTo stores the reply-to addresses attached to the message headers.
|
||||
ReplyTo []common.Email
|
||||
}
|
||||
|
||||
// Validate reports whether Envelope contains only valid addresses and at
|
||||
// least one effective recipient.
|
||||
func (envelope Envelope) Validate() error {
|
||||
recipientCount := 0
|
||||
|
||||
validateGroup := func(name string, values []common.Email) error {
|
||||
for index, value := range values {
|
||||
if err := value.Validate(); err != nil {
|
||||
return fmt.Errorf("%s[%d]: %w", name, index, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := validateGroup("delivery envelope to", envelope.To); err != nil {
|
||||
return err
|
||||
}
|
||||
recipientCount += len(envelope.To)
|
||||
|
||||
if err := validateGroup("delivery envelope cc", envelope.Cc); err != nil {
|
||||
return err
|
||||
}
|
||||
recipientCount += len(envelope.Cc)
|
||||
|
||||
if err := validateGroup("delivery envelope bcc", envelope.Bcc); err != nil {
|
||||
return err
|
||||
}
|
||||
recipientCount += len(envelope.Bcc)
|
||||
|
||||
if err := validateGroup("delivery envelope reply to", envelope.ReplyTo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if recipientCount == 0 {
|
||||
return fmt.Errorf("delivery envelope must contain at least one recipient")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Content stores the materialized subject and body parts of one delivery.
|
||||
type Content struct {
|
||||
// Subject stores the final subject line.
|
||||
Subject string
|
||||
|
||||
// TextBody stores the final plaintext body.
|
||||
TextBody string
|
||||
|
||||
// HTMLBody stores the optional final HTML body.
|
||||
HTMLBody string
|
||||
}
|
||||
|
||||
// ValidateMaterialized reports whether Content contains the minimum subject
|
||||
// and plaintext body required for a concrete outbound message.
|
||||
func (content Content) ValidateMaterialized() error {
|
||||
if content.Subject == "" {
|
||||
return fmt.Errorf("delivery content subject must not be empty")
|
||||
}
|
||||
if content.TextBody == "" {
|
||||
return fmt.Errorf("delivery content text body must not be empty")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delivery stores one durable logical mail delivery record.
|
||||
type Delivery struct {
|
||||
// DeliveryID identifies the delivery.
|
||||
DeliveryID common.DeliveryID
|
||||
|
||||
// ResendParentDeliveryID identifies the original delivery when the current
|
||||
// record was created by the resend workflow.
|
||||
ResendParentDeliveryID common.DeliveryID
|
||||
|
||||
// Source stores the frozen source vocabulary value.
|
||||
Source Source
|
||||
|
||||
// PayloadMode stores whether the delivery uses pre-rendered content or
|
||||
// deferred template rendering.
|
||||
PayloadMode PayloadMode
|
||||
|
||||
// TemplateID stores the template family used by template-mode deliveries.
|
||||
TemplateID common.TemplateID
|
||||
|
||||
// Envelope stores the SMTP addressing information.
|
||||
Envelope Envelope
|
||||
|
||||
// Content stores the final rendered subject and bodies when materialized.
|
||||
Content Content
|
||||
|
||||
// Attachments stores long-lived attachment metadata only.
|
||||
Attachments []common.AttachmentMetadata
|
||||
|
||||
// Locale stores the canonical locale used for template selection when
|
||||
// applicable.
|
||||
Locale common.Locale
|
||||
|
||||
// LocaleFallbackUsed reports whether rendering fell back from the requested
|
||||
// locale to `en`.
|
||||
LocaleFallbackUsed bool
|
||||
|
||||
// TemplateVariables stores the JSON object used for later template
|
||||
// rendering when PayloadMode is `template`.
|
||||
TemplateVariables map[string]any
|
||||
|
||||
// IdempotencyKey stores the caller-owned deduplication key.
|
||||
IdempotencyKey common.IdempotencyKey
|
||||
|
||||
// Status stores the current delivery lifecycle state.
|
||||
Status Status
|
||||
|
||||
// AttemptCount stores how many attempts have been created for the delivery.
|
||||
AttemptCount int
|
||||
|
||||
// LastAttemptStatus stores the latest recorded attempt outcome when one is
|
||||
// available.
|
||||
LastAttemptStatus attempt.Status
|
||||
|
||||
// ProviderSummary stores redacted provider outcome details when available.
|
||||
ProviderSummary string
|
||||
|
||||
// CreatedAt stores when the delivery was created.
|
||||
CreatedAt time.Time
|
||||
|
||||
// UpdatedAt stores when the delivery was last mutated.
|
||||
UpdatedAt time.Time
|
||||
|
||||
// SentAt stores when the delivery entered the sent terminal state.
|
||||
SentAt *time.Time
|
||||
|
||||
// SuppressedAt stores when the delivery entered the suppressed terminal
|
||||
// state.
|
||||
SuppressedAt *time.Time
|
||||
|
||||
// FailedAt stores when the delivery entered the failed terminal state.
|
||||
FailedAt *time.Time
|
||||
|
||||
// DeadLetteredAt stores when the delivery entered the dead-letter terminal
|
||||
// state.
|
||||
DeadLetteredAt *time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether Delivery satisfies the frozen Stage 2 structural
|
||||
// and lifecycle invariants.
|
||||
func (record Delivery) Validate() error {
|
||||
if err := record.DeliveryID.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery id: %w", err)
|
||||
}
|
||||
if !record.Source.IsKnown() {
|
||||
return fmt.Errorf("delivery source %q is unsupported", record.Source)
|
||||
}
|
||||
if !record.PayloadMode.IsKnown() {
|
||||
return fmt.Errorf("delivery payload mode %q is unsupported", record.PayloadMode)
|
||||
}
|
||||
if err := record.Envelope.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
for index, attachment := range record.Attachments {
|
||||
if err := attachment.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery attachments[%d]: %w", index, err)
|
||||
}
|
||||
}
|
||||
if err := record.IdempotencyKey.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery idempotency key: %w", err)
|
||||
}
|
||||
if !record.Status.IsKnown() {
|
||||
return fmt.Errorf("delivery status %q is unsupported", record.Status)
|
||||
}
|
||||
if record.AttemptCount < 0 {
|
||||
return fmt.Errorf("delivery attempt count must not be negative")
|
||||
}
|
||||
if record.LastAttemptStatus != "" && !record.LastAttemptStatus.IsKnown() {
|
||||
return fmt.Errorf("delivery last attempt status %q is unsupported", record.LastAttemptStatus)
|
||||
}
|
||||
if err := validateOptionalToken("delivery provider summary", record.ProviderSummary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ValidateTimestamp("delivery created at", record.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ValidateTimestamp("delivery updated at", record.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.UpdatedAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("delivery updated at must not be before created at")
|
||||
}
|
||||
|
||||
switch record.Source {
|
||||
case SourceOperatorResend:
|
||||
if err := record.ResendParentDeliveryID.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery resend parent delivery id: %w", err)
|
||||
}
|
||||
if record.ResendParentDeliveryID == record.DeliveryID {
|
||||
return fmt.Errorf("delivery resend parent delivery id must differ from delivery id")
|
||||
}
|
||||
default:
|
||||
if !record.ResendParentDeliveryID.IsZero() {
|
||||
return fmt.Errorf("delivery resend parent delivery id must be empty unless source is %q", SourceOperatorResend)
|
||||
}
|
||||
}
|
||||
|
||||
switch record.PayloadMode {
|
||||
case PayloadModeRendered:
|
||||
if !record.TemplateID.IsZero() {
|
||||
return fmt.Errorf("rendered delivery must not contain template id")
|
||||
}
|
||||
if !record.Locale.IsZero() {
|
||||
return fmt.Errorf("rendered delivery must not contain locale")
|
||||
}
|
||||
if record.LocaleFallbackUsed {
|
||||
return fmt.Errorf("rendered delivery must not mark locale fallback")
|
||||
}
|
||||
if len(record.TemplateVariables) != 0 {
|
||||
return fmt.Errorf("rendered delivery must not contain template variables")
|
||||
}
|
||||
if err := record.Content.ValidateMaterialized(); err != nil {
|
||||
return err
|
||||
}
|
||||
case PayloadModeTemplate:
|
||||
if err := record.TemplateID.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery template id: %w", err)
|
||||
}
|
||||
if err := record.Locale.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery locale: %w", err)
|
||||
}
|
||||
if err := validateJSONObject("delivery template variables", record.TemplateVariables); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.Status == StatusRendered || record.Status == StatusSending || record.Status == StatusSent {
|
||||
if err := record.Content.ValidateMaterialized(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if record.Status == StatusRendered && record.PayloadMode != PayloadModeTemplate {
|
||||
return fmt.Errorf("delivery status %q requires payload mode %q", StatusRendered, PayloadModeTemplate)
|
||||
}
|
||||
|
||||
if err := validateTerminalTimestamps(record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeadLetterEntry stores the operator-visible dead-letter record for one
|
||||
// delivery that exhausted normal automated handling.
|
||||
type DeadLetterEntry struct {
|
||||
// DeliveryID identifies the dead-lettered delivery.
|
||||
DeliveryID common.DeliveryID
|
||||
|
||||
// FinalAttemptNo stores the last attempt number associated with the
|
||||
// dead-letter transition.
|
||||
FinalAttemptNo int
|
||||
|
||||
// FailureClassification stores the final machine-readable failure class.
|
||||
FailureClassification string
|
||||
|
||||
// ProviderSummary stores redacted provider outcome details when available.
|
||||
ProviderSummary string
|
||||
|
||||
// CreatedAt stores when the dead-letter entry was created.
|
||||
CreatedAt time.Time
|
||||
|
||||
// RecoveryHint stores an optional operator-facing recovery note.
|
||||
RecoveryHint string
|
||||
}
|
||||
|
||||
// Validate reports whether DeadLetterEntry contains a complete dead-letter
|
||||
// record.
|
||||
func (entry DeadLetterEntry) Validate() error {
|
||||
if err := entry.DeliveryID.Validate(); err != nil {
|
||||
return fmt.Errorf("dead-letter delivery id: %w", err)
|
||||
}
|
||||
if entry.FinalAttemptNo < 1 {
|
||||
return fmt.Errorf("dead-letter final attempt number must be at least 1")
|
||||
}
|
||||
if err := validateToken("dead-letter failure classification", entry.FailureClassification); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalToken("dead-letter provider summary", entry.ProviderSummary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalToken("dead-letter recovery hint", entry.RecoveryHint); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ValidateTimestamp("dead-letter created at", entry.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateFor reports whether entry is the required dead-letter record for
|
||||
// record.
|
||||
func (entry DeadLetterEntry) ValidateFor(record Delivery) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := entry.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.Status != StatusDeadLetter {
|
||||
return fmt.Errorf("dead-letter entry requires delivery status %q", StatusDeadLetter)
|
||||
}
|
||||
if entry.DeliveryID != record.DeliveryID {
|
||||
return fmt.Errorf("dead-letter delivery id must match delivery id")
|
||||
}
|
||||
if record.AttemptCount < entry.FinalAttemptNo {
|
||||
return fmt.Errorf("dead-letter final attempt number must not exceed delivery attempt count")
|
||||
}
|
||||
if record.DeadLetteredAt == nil {
|
||||
return fmt.Errorf("dead-letter delivery must contain dead-lettered at")
|
||||
}
|
||||
if entry.CreatedAt.Before(*record.DeadLetteredAt) {
|
||||
return fmt.Errorf("dead-letter created at must not be before delivery dead-lettered at")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateDeadLetterState reports whether record and entry satisfy the frozen
|
||||
// rule that only dead-lettered deliveries may own a dead-letter entry.
|
||||
func ValidateDeadLetterState(record Delivery, entry *DeadLetterEntry) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if record.Status == StatusDeadLetter {
|
||||
if entry == nil {
|
||||
return fmt.Errorf("dead-letter delivery requires dead-letter entry")
|
||||
}
|
||||
return entry.ValidateFor(record)
|
||||
}
|
||||
|
||||
if entry != nil {
|
||||
return fmt.Errorf("dead-letter entry is not allowed for delivery status %q", record.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTerminalTimestamps(record Delivery) error {
|
||||
if record.SentAt != nil {
|
||||
if err := common.ValidateTimestamp("delivery sent at", *record.SentAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.SentAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("delivery sent at must not be before created at")
|
||||
}
|
||||
}
|
||||
if record.SuppressedAt != nil {
|
||||
if err := common.ValidateTimestamp("delivery suppressed at", *record.SuppressedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.SuppressedAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("delivery suppressed at must not be before created at")
|
||||
}
|
||||
}
|
||||
if record.FailedAt != nil {
|
||||
if err := common.ValidateTimestamp("delivery failed at", *record.FailedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.FailedAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("delivery failed at must not be before created at")
|
||||
}
|
||||
}
|
||||
if record.DeadLetteredAt != nil {
|
||||
if err := common.ValidateTimestamp("delivery dead-lettered at", *record.DeadLetteredAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.DeadLetteredAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("delivery dead-lettered at must not be before created at")
|
||||
}
|
||||
}
|
||||
|
||||
switch record.Status {
|
||||
case StatusAccepted, StatusQueued, StatusRendered, StatusSending:
|
||||
if record.SentAt != nil || record.SuppressedAt != nil || record.FailedAt != nil || record.DeadLetteredAt != nil {
|
||||
return fmt.Errorf("non-terminal delivery must not contain terminal timestamp fields")
|
||||
}
|
||||
case StatusSent:
|
||||
if record.SentAt == nil {
|
||||
return fmt.Errorf("sent delivery must contain sent at")
|
||||
}
|
||||
if record.SuppressedAt != nil || record.FailedAt != nil || record.DeadLetteredAt != nil {
|
||||
return fmt.Errorf("sent delivery must not contain other terminal timestamp fields")
|
||||
}
|
||||
case StatusSuppressed:
|
||||
if record.SuppressedAt == nil {
|
||||
return fmt.Errorf("suppressed delivery must contain suppressed at")
|
||||
}
|
||||
if record.SentAt != nil || record.FailedAt != nil || record.DeadLetteredAt != nil {
|
||||
return fmt.Errorf("suppressed delivery must not contain other terminal timestamp fields")
|
||||
}
|
||||
case StatusFailed:
|
||||
if record.FailedAt == nil {
|
||||
return fmt.Errorf("failed delivery must contain failed at")
|
||||
}
|
||||
if record.SentAt != nil || record.SuppressedAt != nil || record.DeadLetteredAt != nil {
|
||||
return fmt.Errorf("failed delivery must not contain other terminal timestamp fields")
|
||||
}
|
||||
case StatusDeadLetter:
|
||||
if record.DeadLetteredAt == nil {
|
||||
return fmt.Errorf("dead-letter delivery must contain dead-lettered at")
|
||||
}
|
||||
if record.SentAt != nil || record.SuppressedAt != nil || record.FailedAt != nil {
|
||||
return fmt.Errorf("dead-letter delivery must not contain other terminal timestamp fields")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateToken(name string, value string) error {
|
||||
switch {
|
||||
case strings.TrimSpace(value) == "":
|
||||
return fmt.Errorf("%s must not be empty", name)
|
||||
case strings.TrimSpace(value) != value:
|
||||
return fmt.Errorf("%s must not contain surrounding whitespace", name)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateOptionalToken(name string, value string) error {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return validateToken(name, value)
|
||||
}
|
||||
|
||||
func validateJSONObject(name string, value map[string]any) error {
|
||||
if value == nil {
|
||||
return fmt.Errorf("%s must not be nil", name)
|
||||
}
|
||||
|
||||
if _, err := json.Marshal(value); err != nil {
|
||||
return fmt.Errorf("%s must be JSON-serializable: %w", name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStatusCanTransitionTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
from Status
|
||||
to Status
|
||||
want bool
|
||||
}{
|
||||
{name: "accepted to queued", from: StatusAccepted, to: StatusQueued, want: true},
|
||||
{name: "accepted to suppressed", from: StatusAccepted, to: StatusSuppressed, want: true},
|
||||
{name: "accepted to sent", from: StatusAccepted, to: StatusSent, want: false},
|
||||
{name: "queued to rendered", from: StatusQueued, to: StatusRendered, want: true},
|
||||
{name: "queued to sending", from: StatusQueued, to: StatusSending, want: true},
|
||||
{name: "queued to failed", from: StatusQueued, to: StatusFailed, want: true},
|
||||
{name: "rendered to sending", from: StatusRendered, to: StatusSending, want: true},
|
||||
{name: "rendered to failed", from: StatusRendered, to: StatusFailed, want: true},
|
||||
{name: "sending to sent", from: StatusSending, to: StatusSent, want: true},
|
||||
{name: "sending to dead letter", from: StatusSending, to: StatusDeadLetter, want: true},
|
||||
{name: "failed terminal", from: StatusFailed, to: StatusDeadLetter, want: false},
|
||||
{name: "dead letter terminal", from: StatusDeadLetter, to: StatusQueued, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, tt.want, tt.from.CanTransitionTo(tt.to))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusTerminalAndResend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.False(t, StatusAccepted.IsTerminal())
|
||||
require.False(t, StatusQueued.AllowsResend())
|
||||
require.True(t, StatusSent.IsTerminal())
|
||||
require.True(t, StatusSent.AllowsResend())
|
||||
require.True(t, StatusSuppressed.AllowsResend())
|
||||
require.True(t, StatusFailed.AllowsResend())
|
||||
require.True(t, StatusDeadLetter.AllowsResend())
|
||||
}
|
||||
|
||||
func TestDeliveryValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := validRenderedDelivery(t)
|
||||
templateQueued := validTemplateQueuedDelivery(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record Delivery
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid rendered delivery", record: base},
|
||||
{name: "valid template queued delivery", record: templateQueued},
|
||||
{
|
||||
name: "operator resend requires parent id",
|
||||
record: func() Delivery {
|
||||
record := base
|
||||
record.Source = SourceOperatorResend
|
||||
record.ResendParentDeliveryID = ""
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "non resend must not carry parent id",
|
||||
record: func() Delivery {
|
||||
record := base
|
||||
record.ResendParentDeliveryID = common.DeliveryID("delivery-parent")
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "rendered status requires template mode",
|
||||
record: func() Delivery {
|
||||
record := base
|
||||
record.Status = StatusRendered
|
||||
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
|
||||
record.SentAt = nil
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "rendered payload requires materialized content",
|
||||
record: func() Delivery {
|
||||
record := base
|
||||
record.Content = Content{}
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "template mode requires template id",
|
||||
record: func() Delivery {
|
||||
record := templateQueued
|
||||
record.TemplateID = ""
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "template mode requires locale",
|
||||
record: func() Delivery {
|
||||
record := templateQueued
|
||||
record.Locale = ""
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "template mode requires template variables",
|
||||
record: func() Delivery {
|
||||
record := templateQueued
|
||||
record.TemplateVariables = nil
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "template rendered requires content",
|
||||
record: func() Delivery {
|
||||
record := templateQueued
|
||||
record.Status = StatusRendered
|
||||
record.UpdatedAt = record.CreatedAt.Add(2 * time.Minute)
|
||||
record.Content = Content{}
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal must not carry terminal timestamps",
|
||||
record: func() Delivery {
|
||||
record := templateQueued
|
||||
record.FailedAt = ptrTime(record.CreatedAt.Add(time.Minute))
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "rendered delivery must not contain template variables",
|
||||
record: func() Delivery {
|
||||
record := base
|
||||
record.TemplateVariables = map[string]any{"code": "123456"}
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "template variables must be json serializable",
|
||||
record: func() Delivery {
|
||||
record := templateQueued
|
||||
record.TemplateVariables = map[string]any{"invalid": func() {}}
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "failed requires failed at",
|
||||
record: func() Delivery {
|
||||
record := templateQueued
|
||||
record.Status = StatusFailed
|
||||
record.UpdatedAt = record.CreatedAt.Add(2 * time.Minute)
|
||||
return record
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.record.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeadLetterState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := validDeadLetterDelivery(t)
|
||||
entry := validDeadLetterEntry(t, record)
|
||||
|
||||
require.NoError(t, ValidateDeadLetterState(record, &entry))
|
||||
|
||||
err := ValidateDeadLetterState(record, nil)
|
||||
require.Error(t, err)
|
||||
|
||||
failed := validTemplateQueuedDelivery(t)
|
||||
failed.Status = StatusFailed
|
||||
failed.UpdatedAt = failed.CreatedAt.Add(2 * time.Minute)
|
||||
failed.FailedAt = ptrTime(failed.CreatedAt.Add(2 * time.Minute))
|
||||
require.NoError(t, ValidateDeadLetterState(failed, nil))
|
||||
require.Error(t, ValidateDeadLetterState(failed, &entry))
|
||||
|
||||
mismatched := entry
|
||||
mismatched.DeliveryID = common.DeliveryID("delivery-other")
|
||||
require.Error(t, ValidateDeadLetterState(record, &mismatched))
|
||||
}
|
||||
|
||||
func validRenderedDelivery(t *testing.T) Delivery {
|
||||
t.Helper()
|
||||
|
||||
createdAt := time.Unix(1_775_121_700, 0).UTC()
|
||||
sentAt := createdAt.Add(5 * time.Minute)
|
||||
|
||||
record := Delivery{
|
||||
DeliveryID: common.DeliveryID("delivery-123"),
|
||||
Source: SourceNotification,
|
||||
PayloadMode: PayloadModeRendered,
|
||||
Envelope: validEnvelope(),
|
||||
Content: Content{Subject: "Turn ready", TextBody: "Turn 54 is ready."},
|
||||
Attachments: []common.AttachmentMetadata{{Filename: "report.txt", ContentType: "text/plain", SizeBytes: 64}},
|
||||
TemplateVariables: nil,
|
||||
IdempotencyKey: common.IdempotencyKey("notification:delivery-123"),
|
||||
Status: StatusSent,
|
||||
AttemptCount: 1,
|
||||
LastAttemptStatus: attempt.StatusProviderAccepted,
|
||||
ProviderSummary: "queued by provider",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: sentAt,
|
||||
SentAt: &sentAt,
|
||||
}
|
||||
|
||||
require.NoError(t, record.Validate())
|
||||
return record
|
||||
}
|
||||
|
||||
func validTemplateQueuedDelivery(t *testing.T) Delivery {
|
||||
t.Helper()
|
||||
|
||||
createdAt := time.Unix(1_775_121_700, 0).UTC()
|
||||
locale, err := common.ParseLocale("fr-fr")
|
||||
require.NoError(t, err)
|
||||
|
||||
record := Delivery{
|
||||
DeliveryID: common.DeliveryID("delivery-124"),
|
||||
Source: SourceNotification,
|
||||
PayloadMode: PayloadModeTemplate,
|
||||
TemplateID: common.TemplateID("game.turn_ready"),
|
||||
Envelope: validEnvelope(),
|
||||
Locale: locale,
|
||||
TemplateVariables: map[string]any{
|
||||
"turn_number": float64(54),
|
||||
},
|
||||
IdempotencyKey: common.IdempotencyKey("notification:delivery-124"),
|
||||
Status: StatusQueued,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt.Add(time.Minute),
|
||||
}
|
||||
|
||||
require.NoError(t, record.Validate())
|
||||
return record
|
||||
}
|
||||
|
||||
func validDeadLetterDelivery(t *testing.T) Delivery {
|
||||
t.Helper()
|
||||
|
||||
record := validTemplateQueuedDelivery(t)
|
||||
record.Status = StatusDeadLetter
|
||||
record.AttemptCount = 3
|
||||
record.LastAttemptStatus = attempt.StatusTimedOut
|
||||
record.UpdatedAt = record.CreatedAt.Add(10 * time.Minute)
|
||||
record.DeadLetteredAt = ptrTime(record.CreatedAt.Add(10 * time.Minute))
|
||||
|
||||
require.NoError(t, record.Validate())
|
||||
return record
|
||||
}
|
||||
|
||||
func validDeadLetterEntry(t *testing.T, record Delivery) DeadLetterEntry {
|
||||
t.Helper()
|
||||
|
||||
entry := DeadLetterEntry{
|
||||
DeliveryID: record.DeliveryID,
|
||||
FinalAttemptNo: 3,
|
||||
FailureClassification: "retry_exhausted",
|
||||
ProviderSummary: "smtp timeout",
|
||||
CreatedAt: record.DeadLetteredAt.Add(time.Second),
|
||||
RecoveryHint: "check SMTP connectivity",
|
||||
}
|
||||
|
||||
require.NoError(t, entry.ValidateFor(record))
|
||||
return entry
|
||||
}
|
||||
|
||||
func validEnvelope() Envelope {
|
||||
return Envelope{
|
||||
To: []common.Email{"pilot@example.com"},
|
||||
}
|
||||
}
|
||||
|
||||
func ptrTime(value time.Time) *time.Time {
|
||||
return &value
|
||||
}
|
||||
Reference in New Issue
Block a user