// Package attempt defines the logical delivery-attempt entity owned by Mail // Service. package attempt import ( "fmt" "strings" "time" "galaxy/mail/internal/domain/common" ) // Status identifies the lifecycle state of one concrete delivery attempt. type Status string const ( // StatusScheduled reports that the attempt is durably planned but has not // started execution yet. StatusScheduled Status = "scheduled" // StatusInProgress reports that one worker currently owns the attempt. StatusInProgress Status = "in_progress" // StatusProviderAccepted reports that the provider accepted the SMTP // envelope. StatusProviderAccepted Status = "provider_accepted" // StatusProviderRejected reports that the provider rejected the SMTP // envelope. StatusProviderRejected Status = "provider_rejected" // StatusTransportFailed reports that the attempt failed before a stable // provider accept or reject result was obtained. StatusTransportFailed Status = "transport_failed" // StatusTimedOut reports that the provider call exceeded the configured // execution deadline. StatusTimedOut Status = "timed_out" // StatusRenderFailed reports that template rendering failed before any // provider interaction was attempted. StatusRenderFailed Status = "render_failed" ) // IsKnown reports whether Status is supported by the current Mail Service // attempt state machine. func (status Status) IsKnown() bool { switch status { case StatusScheduled, StatusInProgress, StatusProviderAccepted, StatusProviderRejected, StatusTransportFailed, StatusTimedOut, StatusRenderFailed: return true default: return false } } // IsTerminal reports whether Status can no longer accept a lifecycle // transition. func (status Status) IsTerminal() bool { switch status { case StatusProviderAccepted, StatusProviderRejected, StatusTransportFailed, StatusTimedOut, StatusRenderFailed: return true default: return false } } // CanTransitionTo reports whether the current Status may move to next under // the frozen Stage 2 attempt lifecycle rules. func (status Status) CanTransitionTo(next Status) bool { switch status { case StatusScheduled: switch next { case StatusInProgress, StatusRenderFailed: return true } case StatusInProgress: switch next { case StatusProviderAccepted, StatusProviderRejected, StatusTransportFailed, StatusTimedOut: return true } } return false } // Attempt stores one durable execution record for a delivery attempt. type Attempt struct { // DeliveryID identifies the owning logical delivery. DeliveryID common.DeliveryID // AttemptNo stores the monotonically increasing attempt sequence number. AttemptNo int // ScheduledFor stores when the attempt becomes due. ScheduledFor time.Time // StartedAt stores when a worker claimed the attempt for execution. StartedAt *time.Time // FinishedAt stores when the attempt reached a terminal outcome. FinishedAt *time.Time // Status stores the current attempt lifecycle state. Status Status // ProviderClassification stores provider-specific or adapter-specific // result classification details when available. ProviderClassification string // ProviderSummary stores redacted provider outcome details when available. ProviderSummary string } // Validate reports whether Attempt satisfies the frozen Stage 2 structural and // lifecycle invariants. func (record Attempt) Validate() error { if err := record.DeliveryID.Validate(); err != nil { return fmt.Errorf("attempt delivery id: %w", err) } if record.AttemptNo < 1 { return fmt.Errorf("attempt number must be at least 1") } if err := common.ValidateTimestamp("attempt scheduled for", record.ScheduledFor); err != nil { return err } if !record.Status.IsKnown() { return fmt.Errorf("attempt status %q is unsupported", record.Status) } if err := validateOptionalToken("attempt provider classification", record.ProviderClassification); err != nil { return err } if err := validateOptionalToken("attempt provider summary", record.ProviderSummary); err != nil { return err } switch record.Status { case StatusScheduled: if record.StartedAt != nil { return fmt.Errorf("scheduled attempt must not contain started at") } if record.FinishedAt != nil { return fmt.Errorf("scheduled attempt must not contain finished at") } case StatusInProgress: if record.StartedAt == nil { return fmt.Errorf("in-progress attempt must contain started at") } if err := common.ValidateTimestamp("attempt started at", *record.StartedAt); err != nil { return err } if record.StartedAt.Before(record.ScheduledFor) { return fmt.Errorf("attempt started at must not be before scheduled for") } if record.FinishedAt != nil { return fmt.Errorf("in-progress attempt must not contain finished at") } default: if record.StartedAt == nil { return fmt.Errorf("terminal attempt must contain started at") } if err := common.ValidateTimestamp("attempt started at", *record.StartedAt); err != nil { return err } if record.StartedAt.Before(record.ScheduledFor) { return fmt.Errorf("attempt started at must not be before scheduled for") } if record.FinishedAt == nil { return fmt.Errorf("terminal attempt must contain finished at") } if err := common.ValidateTimestamp("attempt finished at", *record.FinishedAt); err != nil { return err } if record.FinishedAt.Before(*record.StartedAt) { return fmt.Errorf("attempt finished at must not be before started at") } } return nil } func validateOptionalToken(name string, value string) error { if value == "" { return nil } if strings.TrimSpace(value) != value { return fmt.Errorf("%s must not contain surrounding whitespace", name) } return nil }