Files
galaxy-game/mail/internal/api/internalhttp/operator_contract.go
T
2026-04-17 18:39:16 +02:00

626 lines
20 KiB
Go

package internalhttp
import (
"encoding/base64"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"galaxy/mail/internal/domain/attempt"
"galaxy/mail/internal/domain/common"
deliverydomain "galaxy/mail/internal/domain/delivery"
"galaxy/mail/internal/service/listdeliveries"
)
const (
// ErrorCodeDeliveryNotFound identifies a missing trusted delivery lookup
// target.
ErrorCodeDeliveryNotFound = "delivery_not_found"
// ErrorCodeResendNotAllowed identifies resend requests against non-terminal
// deliveries.
ErrorCodeResendNotAllowed = "resend_not_allowed"
deliveryIDPathValue = "delivery_id"
)
// DeliveryListQuery stores the raw trusted query-string values accepted by the
// operator delivery-list route before normalization.
type DeliveryListQuery struct {
// Recipient stores the optional recipient filter covering `to`, `cc`, and
// `bcc`.
Recipient string
// Status stores the optional delivery-status filter.
Status string
// Source stores the optional delivery-source filter.
Source string
// TemplateID stores the optional template-family filter.
TemplateID string
// IdempotencyKey stores the optional idempotency-key filter.
IdempotencyKey string
// FromCreatedAtMS stores the optional inclusive lower creation-time bound.
FromCreatedAtMS string
// ToCreatedAtMS stores the optional inclusive upper creation-time bound.
ToCreatedAtMS string
// Limit stores the optional page size.
Limit string
// Cursor stores the optional opaque continuation cursor.
Cursor string
}
// DeliverySummaryResponse stores one brief operator-facing delivery record.
type DeliverySummaryResponse struct {
DeliveryID string `json:"delivery_id"`
ResendParentDeliveryID string `json:"resend_parent_delivery_id,omitempty"`
Source string `json:"source"`
PayloadMode string `json:"payload_mode"`
TemplateID string `json:"template_id,omitempty"`
To []string `json:"to"`
Cc []string `json:"cc"`
Bcc []string `json:"bcc"`
ReplyTo []string `json:"reply_to"`
Locale string `json:"locale,omitempty"`
LocaleFallbackUsed bool `json:"locale_fallback_used"`
IdempotencyKey string `json:"idempotency_key"`
Status string `json:"status"`
AttemptCount int `json:"attempt_count"`
LastAttemptStatus string `json:"last_attempt_status,omitempty"`
ProviderSummary string `json:"provider_summary,omitempty"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
SentAtMS *int64 `json:"sent_at_ms,omitempty"`
SuppressedAtMS *int64 `json:"suppressed_at_ms,omitempty"`
FailedAtMS *int64 `json:"failed_at_ms,omitempty"`
DeadLetteredAtMS *int64 `json:"dead_lettered_at_ms,omitempty"`
}
// DeliveryListResponse stores one deterministic page of brief delivery
// summaries.
type DeliveryListResponse struct {
Items []DeliverySummaryResponse `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
}
// AttachmentResponse stores one durable attachment audit record.
type AttachmentResponse struct {
Filename string `json:"filename"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
}
// DeadLetterResponse stores one operator-visible dead-letter entry.
type DeadLetterResponse struct {
DeliveryID string `json:"delivery_id"`
FinalAttemptNo int `json:"final_attempt_no"`
FailureClassification string `json:"failure_classification"`
ProviderSummary string `json:"provider_summary,omitempty"`
CreatedAtMS int64 `json:"created_at_ms"`
RecoveryHint string `json:"recovery_hint,omitempty"`
}
// DeliveryDetailResponse stores one full operator-facing delivery view.
type DeliveryDetailResponse struct {
DeliveryID string `json:"delivery_id"`
ResendParentDeliveryID string `json:"resend_parent_delivery_id,omitempty"`
Source string `json:"source"`
PayloadMode string `json:"payload_mode"`
TemplateID string `json:"template_id,omitempty"`
TemplateVariables map[string]any `json:"template_variables,omitempty"`
To []string `json:"to"`
Cc []string `json:"cc"`
Bcc []string `json:"bcc"`
ReplyTo []string `json:"reply_to"`
Subject string `json:"subject,omitempty"`
TextBody string `json:"text_body,omitempty"`
HTMLBody string `json:"html_body,omitempty"`
Attachments []AttachmentResponse `json:"attachments"`
Locale string `json:"locale,omitempty"`
LocaleFallbackUsed bool `json:"locale_fallback_used"`
IdempotencyKey string `json:"idempotency_key"`
Status string `json:"status"`
AttemptCount int `json:"attempt_count"`
LastAttemptStatus string `json:"last_attempt_status,omitempty"`
ProviderSummary string `json:"provider_summary,omitempty"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
SentAtMS *int64 `json:"sent_at_ms,omitempty"`
SuppressedAtMS *int64 `json:"suppressed_at_ms,omitempty"`
FailedAtMS *int64 `json:"failed_at_ms,omitempty"`
DeadLetteredAtMS *int64 `json:"dead_lettered_at_ms,omitempty"`
DeadLetter *DeadLetterResponse `json:"dead_letter,omitempty"`
}
// AttemptResponse stores one operator-facing delivery-attempt record.
type AttemptResponse struct {
DeliveryID string `json:"delivery_id"`
AttemptNo int `json:"attempt_no"`
ScheduledForMS int64 `json:"scheduled_for_ms"`
StartedAtMS *int64 `json:"started_at_ms,omitempty"`
FinishedAtMS *int64 `json:"finished_at_ms,omitempty"`
Status string `json:"status"`
ProviderClassification string `json:"provider_classification,omitempty"`
ProviderSummary string `json:"provider_summary,omitempty"`
}
// DeliveryAttemptsResponse stores the attempt history of one accepted
// delivery.
type DeliveryAttemptsResponse struct {
Items []AttemptResponse `json:"items"`
}
// DeliveryResendResponse stores the identifier of the clone delivery created
// by one resend request.
type DeliveryResendResponse struct {
DeliveryID string `json:"delivery_id"`
}
// DecodeDeliveryListInput validates one trusted operator delivery-list
// request and returns the normalized list input.
func DecodeDeliveryListInput(request *http.Request) (listdeliveries.Input, error) {
if request == nil {
return listdeliveries.Input{}, errors.New("delivery list request must not be nil")
}
query, err := decodeDeliveryListQuery(request)
if err != nil {
return listdeliveries.Input{}, err
}
input, err := query.Normalize()
if err != nil {
return listdeliveries.Input{}, err
}
return input, nil
}
// DecodeDeliveryIDFromPath validates one trusted path delivery identifier.
func DecodeDeliveryIDFromPath(request *http.Request) (common.DeliveryID, error) {
if request == nil {
return "", errors.New("delivery lookup request must not be nil")
}
return parseDeliveryID(request.PathValue(deliveryIDPathValue))
}
// EncodeDeliveryListCursor encodes cursor into the frozen opaque base64url
// format `created_at_ms:delivery_id`.
func EncodeDeliveryListCursor(cursor listdeliveries.Cursor) (string, error) {
if err := cursor.Validate(); err != nil {
return "", fmt.Errorf("encode delivery list cursor: %w", err)
}
payload := fmt.Sprintf("%d:%s", cursor.CreatedAt.UTC().UnixMilli(), cursor.DeliveryID.String())
return base64.RawURLEncoding.EncodeToString([]byte(payload)), nil
}
// DecodeDeliveryListCursor decodes raw from the frozen opaque cursor format.
func DecodeDeliveryListCursor(raw string) (listdeliveries.Cursor, error) {
if strings.TrimSpace(raw) == "" {
return listdeliveries.Cursor{}, errors.New("cursor must not be empty")
}
if strings.TrimSpace(raw) != raw {
return listdeliveries.Cursor{}, errors.New("cursor must not contain surrounding whitespace")
}
payload, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return listdeliveries.Cursor{}, fmt.Errorf("decode cursor: %w", err)
}
createdAtRaw, deliveryIDRaw, ok := strings.Cut(string(payload), ":")
if !ok {
return listdeliveries.Cursor{}, errors.New("decode cursor: invalid cursor payload")
}
createdAtMS, err := strconv.ParseInt(createdAtRaw, 10, 64)
if err != nil {
return listdeliveries.Cursor{}, fmt.Errorf("decode cursor created_at_ms: %w", err)
}
cursor := listdeliveries.Cursor{
CreatedAt: time.UnixMilli(createdAtMS).UTC(),
DeliveryID: common.DeliveryID(deliveryIDRaw),
}
if err := cursor.Validate(); err != nil {
return listdeliveries.Cursor{}, fmt.Errorf("decode cursor: %w", err)
}
return cursor, nil
}
// Normalize converts the raw trusted query-string shape into the operator list
// input consumed by the service layer.
func (query DeliveryListQuery) Normalize() (listdeliveries.Input, error) {
var input listdeliveries.Input
recipient, err := parseOptionalEmail(query.Recipient, "recipient")
if err != nil {
return listdeliveries.Input{}, err
}
status, err := parseOptionalStatus(query.Status)
if err != nil {
return listdeliveries.Input{}, err
}
source, err := parseOptionalSource(query.Source)
if err != nil {
return listdeliveries.Input{}, err
}
templateID, err := parseOptionalTemplateID(query.TemplateID)
if err != nil {
return listdeliveries.Input{}, err
}
idempotencyKey, err := parseOptionalIdempotencyKey(query.IdempotencyKey)
if err != nil {
return listdeliveries.Input{}, err
}
fromCreatedAt, err := parseOptionalUnixMilli(query.FromCreatedAtMS, "from_created_at_ms")
if err != nil {
return listdeliveries.Input{}, err
}
toCreatedAt, err := parseOptionalUnixMilli(query.ToCreatedAtMS, "to_created_at_ms")
if err != nil {
return listdeliveries.Input{}, err
}
limit, err := parseOptionalLimit(query.Limit)
if err != nil {
return listdeliveries.Input{}, err
}
cursor, err := parseOptionalCursor(query.Cursor)
if err != nil {
return listdeliveries.Input{}, err
}
input = listdeliveries.Input{
Limit: limit,
Cursor: cursor,
Filters: listdeliveries.Filters{
Recipient: recipient,
Status: status,
Source: source,
TemplateID: templateID,
IdempotencyKey: idempotencyKey,
FromCreatedAt: fromCreatedAt,
ToCreatedAt: toCreatedAt,
},
}
if err := input.Validate(); err != nil {
return listdeliveries.Input{}, err
}
return input, nil
}
func decodeDeliveryListQuery(request *http.Request) (DeliveryListQuery, error) {
values := request.URL.Query()
recipient, err := singleQueryValue(values, "recipient")
if err != nil {
return DeliveryListQuery{}, err
}
status, err := singleQueryValue(values, "status")
if err != nil {
return DeliveryListQuery{}, err
}
source, err := singleQueryValue(values, "source")
if err != nil {
return DeliveryListQuery{}, err
}
templateID, err := singleQueryValue(values, "template_id")
if err != nil {
return DeliveryListQuery{}, err
}
idempotencyKey, err := singleQueryValue(values, "idempotency_key")
if err != nil {
return DeliveryListQuery{}, err
}
fromCreatedAtMS, err := singleQueryValue(values, "from_created_at_ms")
if err != nil {
return DeliveryListQuery{}, err
}
toCreatedAtMS, err := singleQueryValue(values, "to_created_at_ms")
if err != nil {
return DeliveryListQuery{}, err
}
limit, err := singleQueryValue(values, "limit")
if err != nil {
return DeliveryListQuery{}, err
}
cursor, err := singleQueryValue(values, "cursor")
if err != nil {
return DeliveryListQuery{}, err
}
return DeliveryListQuery{
Recipient: recipient,
Status: status,
Source: source,
TemplateID: templateID,
IdempotencyKey: idempotencyKey,
FromCreatedAtMS: fromCreatedAtMS,
ToCreatedAtMS: toCreatedAtMS,
Limit: limit,
Cursor: cursor,
}, nil
}
func singleQueryValue(values map[string][]string, key string) (string, error) {
rawValues := values[key]
switch len(rawValues) {
case 0:
return "", nil
case 1:
return rawValues[0], nil
default:
return "", fmt.Errorf("query parameter %q must appear at most once", key)
}
}
func parseDeliveryID(raw string) (common.DeliveryID, error) {
deliveryID := common.DeliveryID(raw)
if err := deliveryID.Validate(); err != nil {
return "", fmt.Errorf("delivery id: %w", err)
}
return deliveryID, nil
}
func parseOptionalEmail(raw string, name string) (common.Email, error) {
if raw == "" {
return "", nil
}
email := common.Email(strings.TrimSpace(raw))
if err := email.Validate(); err != nil {
return "", fmt.Errorf("%s: %w", name, err)
}
return email, nil
}
func parseOptionalStatus(raw string) (deliverydomain.Status, error) {
if raw == "" {
return "", nil
}
status := deliverydomain.Status(strings.TrimSpace(raw))
if !status.IsKnown() {
return "", fmt.Errorf("status %q is unsupported", raw)
}
return status, nil
}
func parseOptionalSource(raw string) (deliverydomain.Source, error) {
if raw == "" {
return "", nil
}
source := deliverydomain.Source(strings.TrimSpace(raw))
if !source.IsKnown() {
return "", fmt.Errorf("source %q is unsupported", raw)
}
return source, nil
}
func parseOptionalTemplateID(raw string) (common.TemplateID, error) {
if raw == "" {
return "", nil
}
templateID := common.TemplateID(strings.TrimSpace(raw))
if err := templateID.Validate(); err != nil {
return "", fmt.Errorf("template id: %w", err)
}
return templateID, nil
}
func parseOptionalIdempotencyKey(raw string) (common.IdempotencyKey, error) {
if raw == "" {
return "", nil
}
key := common.IdempotencyKey(strings.TrimSpace(raw))
if err := key.Validate(); err != nil {
return "", fmt.Errorf("idempotency key: %w", err)
}
return key, nil
}
func parseOptionalUnixMilli(raw string, name string) (*time.Time, error) {
if raw == "" {
return nil, nil
}
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
timestamp := time.UnixMilli(value).UTC()
if err := common.ValidateTimestamp(name, timestamp); err != nil {
return nil, err
}
return &timestamp, nil
}
func parseOptionalLimit(raw string) (int, error) {
if raw == "" {
return 0, nil
}
value, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return 0, fmt.Errorf("limit: %w", err)
}
if value < 1 {
return 0, errors.New("limit must be at least 1")
}
return value, nil
}
func parseOptionalCursor(raw string) (*listdeliveries.Cursor, error) {
if raw == "" {
return nil, nil
}
cursor, err := DecodeDeliveryListCursor(raw)
if err != nil {
return nil, err
}
return &cursor, nil
}
func summaryResponseFromDelivery(record deliverydomain.Delivery) DeliverySummaryResponse {
return DeliverySummaryResponse{
DeliveryID: record.DeliveryID.String(),
ResendParentDeliveryID: record.ResendParentDeliveryID.String(),
Source: string(record.Source),
PayloadMode: string(record.PayloadMode),
TemplateID: record.TemplateID.String(),
To: emailStrings(record.Envelope.To),
Cc: emailStrings(record.Envelope.Cc),
Bcc: emailStrings(record.Envelope.Bcc),
ReplyTo: emailStrings(record.Envelope.ReplyTo),
Locale: record.Locale.String(),
LocaleFallbackUsed: record.LocaleFallbackUsed,
IdempotencyKey: record.IdempotencyKey.String(),
Status: string(record.Status),
AttemptCount: record.AttemptCount,
LastAttemptStatus: string(record.LastAttemptStatus),
ProviderSummary: record.ProviderSummary,
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
UpdatedAtMS: record.UpdatedAt.UTC().UnixMilli(),
SentAtMS: unixMilliPtr(record.SentAt),
SuppressedAtMS: unixMilliPtr(record.SuppressedAt),
FailedAtMS: unixMilliPtr(record.FailedAt),
DeadLetteredAtMS: unixMilliPtr(record.DeadLetteredAt),
}
}
func detailResponseFromDelivery(record deliverydomain.Delivery, deadLetter *deliverydomain.DeadLetterEntry) DeliveryDetailResponse {
response := DeliveryDetailResponse{
DeliveryID: record.DeliveryID.String(),
ResendParentDeliveryID: record.ResendParentDeliveryID.String(),
Source: string(record.Source),
PayloadMode: string(record.PayloadMode),
TemplateID: record.TemplateID.String(),
TemplateVariables: cloneJSONObject(record.TemplateVariables),
To: emailStrings(record.Envelope.To),
Cc: emailStrings(record.Envelope.Cc),
Bcc: emailStrings(record.Envelope.Bcc),
ReplyTo: emailStrings(record.Envelope.ReplyTo),
Subject: record.Content.Subject,
TextBody: record.Content.TextBody,
HTMLBody: record.Content.HTMLBody,
Attachments: attachmentResponses(record.Attachments),
Locale: record.Locale.String(),
LocaleFallbackUsed: record.LocaleFallbackUsed,
IdempotencyKey: record.IdempotencyKey.String(),
Status: string(record.Status),
AttemptCount: record.AttemptCount,
LastAttemptStatus: string(record.LastAttemptStatus),
ProviderSummary: record.ProviderSummary,
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
UpdatedAtMS: record.UpdatedAt.UTC().UnixMilli(),
SentAtMS: unixMilliPtr(record.SentAt),
SuppressedAtMS: unixMilliPtr(record.SuppressedAt),
FailedAtMS: unixMilliPtr(record.FailedAt),
DeadLetteredAtMS: unixMilliPtr(record.DeadLetteredAt),
}
if deadLetter != nil {
response.DeadLetter = &DeadLetterResponse{
DeliveryID: deadLetter.DeliveryID.String(),
FinalAttemptNo: deadLetter.FinalAttemptNo,
FailureClassification: deadLetter.FailureClassification,
ProviderSummary: deadLetter.ProviderSummary,
CreatedAtMS: deadLetter.CreatedAt.UTC().UnixMilli(),
RecoveryHint: deadLetter.RecoveryHint,
}
}
return response
}
func attemptResponseFromRecord(record attempt.Attempt) AttemptResponse {
return AttemptResponse{
DeliveryID: record.DeliveryID.String(),
AttemptNo: record.AttemptNo,
ScheduledForMS: record.ScheduledFor.UTC().UnixMilli(),
StartedAtMS: unixMilliPtr(record.StartedAt),
FinishedAtMS: unixMilliPtr(record.FinishedAt),
Status: string(record.Status),
ProviderClassification: record.ProviderClassification,
ProviderSummary: record.ProviderSummary,
}
}
func attachmentResponses(attachments []common.AttachmentMetadata) []AttachmentResponse {
if len(attachments) == 0 {
return []AttachmentResponse{}
}
result := make([]AttachmentResponse, len(attachments))
for index, attachment := range attachments {
result[index] = AttachmentResponse{
Filename: attachment.Filename,
ContentType: attachment.ContentType,
SizeBytes: attachment.SizeBytes,
}
}
return result
}
func emailStrings(values []common.Email) []string {
if len(values) == 0 {
return []string{}
}
result := make([]string, len(values))
for index, value := range values {
result[index] = value.String()
}
return result
}
func unixMilliPtr(value *time.Time) *int64 {
if value == nil {
return nil
}
encoded := value.UTC().UnixMilli()
return &encoded
}
func cloneJSONObject(value map[string]any) map[string]any {
if value == nil {
return nil
}
cloned := make(map[string]any, len(value))
for key, entry := range value {
cloned[key] = entry
}
return cloned
}