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

694 lines
20 KiB
Go

// Package streamcommand defines the frozen Redis Streams command contract used
// by Mail Service for generic asynchronous delivery intake.
package streamcommand
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"sort"
"strconv"
"strings"
"time"
"galaxy/mail/internal/domain/common"
deliverydomain "galaxy/mail/internal/domain/delivery"
"galaxy/mail/internal/domain/malformedcommand"
)
const (
// DeliveryCommandsStream is the frozen Redis Stream name used for generic
// asynchronous delivery commands.
DeliveryCommandsStream = "mail:delivery_commands"
// MaxAttachments is the frozen attachment-count limit for one generic
// asynchronous command.
MaxAttachments = 5
// MaxEncodedAttachmentPayloadBytes is the frozen limit for the total
// encoded attachment payload, measured as the sum of attachment
// `content_base64` string lengths.
MaxEncodedAttachmentPayloadBytes = 2 * 1024 * 1024
)
const (
fieldDeliveryID = "delivery_id"
fieldSource = "source"
fieldPayloadMode = "payload_mode"
fieldIdempotency = "idempotency_key"
fieldRequestedAtMS = "requested_at_ms"
fieldPayloadJSON = "payload_json"
fieldRequestID = "request_id"
fieldTraceID = "trace_id"
)
var (
requiredFieldNames = map[string]struct{}{
fieldDeliveryID: {},
fieldSource: {},
fieldPayloadMode: {},
fieldIdempotency: {},
fieldRequestedAtMS: {},
fieldPayloadJSON: {},
}
optionalFieldNames = map[string]struct{}{
fieldRequestID: {},
fieldTraceID: {},
}
)
// ClassifyDecodeError maps one command-decoding or command-validation error to
// the stable malformed-command failure code surface.
func ClassifyDecodeError(err error) malformedcommand.FailureCode {
if err == nil {
return malformedcommand.FailureCodeInvalidCommand
}
message := err.Error()
switch {
case strings.Contains(message, "delivery envelope"),
strings.Contains(message, "must contain at least one recipient"):
return malformedcommand.FailureCodeInvalidEnvelope
case strings.Contains(message, "payload_json"),
strings.Contains(message, "stream command attachments"),
strings.Contains(message, "delivery content"),
strings.Contains(message, "template id"),
strings.Contains(message, "locale"),
strings.Contains(message, "variables"):
return malformedcommand.FailureCodeInvalidPayload
default:
return malformedcommand.FailureCodeInvalidCommand
}
}
// Command stores one normalized generic asynchronous command accepted from the
// Redis Streams contract.
type Command struct {
// DeliveryID stores the publisher-owned logical delivery identifier.
DeliveryID common.DeliveryID
// Source stores the frozen async source vocabulary value.
Source deliverydomain.Source
// PayloadMode stores whether the command contains final rendered content or
// template-selection data.
PayloadMode deliverydomain.PayloadMode
// IdempotencyKey stores the caller-owned stable deduplication key.
IdempotencyKey common.IdempotencyKey
// RequestedAt stores when the publisher originally requested the generic
// delivery.
RequestedAt time.Time
// RequestID stores the optional tracing request identifier.
RequestID string
// TraceID stores the optional tracing trace identifier.
TraceID string
// Envelope stores the SMTP addressing information frozen by the stream
// payload contract.
Envelope deliverydomain.Envelope
// Attachments stores the normalized attachment list including computed
// decoded sizes.
Attachments []Attachment
// Subject stores the required final subject for rendered-mode commands.
Subject string
// TextBody stores the required plaintext body for rendered-mode commands.
TextBody string
// HTMLBody stores the optional HTML body for rendered-mode commands.
HTMLBody string
// TemplateID stores the required template family for template-mode
// commands.
TemplateID common.TemplateID
// Locale stores the required canonical BCP 47 locale for template-mode
// commands.
Locale common.Locale
// Variables stores the arbitrary template variables object for
// template-mode commands.
Variables map[string]any
}
// Validate reports whether Command satisfies the frozen Stage 05 stream
// contract.
func (command Command) Validate() error {
if err := command.DeliveryID.Validate(); err != nil {
return fmt.Errorf("stream command delivery id: %w", err)
}
if command.Source != deliverydomain.SourceNotification {
return fmt.Errorf("stream command source %q is unsupported", command.Source)
}
if !command.PayloadMode.IsKnown() {
return fmt.Errorf("stream command payload mode %q is unsupported", command.PayloadMode)
}
if err := command.IdempotencyKey.Validate(); err != nil {
return fmt.Errorf("stream command idempotency key: %w", err)
}
if err := common.ValidateTimestamp("stream command requested at", command.RequestedAt); err != nil {
return err
}
if err := command.Envelope.Validate(); err != nil {
return err
}
if len(command.Attachments) > MaxAttachments {
return fmt.Errorf("stream command attachments must contain at most %d entries", MaxAttachments)
}
totalEncodedPayloadBytes := 0
for index, attachment := range command.Attachments {
if err := attachment.Validate(); err != nil {
return fmt.Errorf("stream command attachments[%d]: %w", index, err)
}
totalEncodedPayloadBytes += len(attachment.ContentBase64)
}
if totalEncodedPayloadBytes > MaxEncodedAttachmentPayloadBytes {
return fmt.Errorf(
"stream command encoded attachment payload must not exceed %d bytes",
MaxEncodedAttachmentPayloadBytes,
)
}
switch command.PayloadMode {
case deliverydomain.PayloadModeRendered:
if err := (deliverydomain.Content{
Subject: command.Subject,
TextBody: command.TextBody,
HTMLBody: command.HTMLBody,
}).ValidateMaterialized(); err != nil {
return err
}
if !command.TemplateID.IsZero() {
return errors.New("rendered stream command must not contain template id")
}
if !command.Locale.IsZero() {
return errors.New("rendered stream command must not contain locale")
}
if len(command.Variables) != 0 {
return errors.New("rendered stream command must not contain template variables")
}
case deliverydomain.PayloadModeTemplate:
if err := command.TemplateID.Validate(); err != nil {
return fmt.Errorf("stream command template id: %w", err)
}
if err := command.Locale.Validate(); err != nil {
return fmt.Errorf("stream command locale: %w", err)
}
if command.Variables == nil {
return errors.New("template stream command variables must not be nil")
}
if command.Subject != "" {
return errors.New("template stream command must not contain subject")
}
if command.TextBody != "" {
return errors.New("template stream command must not contain text body")
}
if command.HTMLBody != "" {
return errors.New("template stream command must not contain html body")
}
}
return nil
}
// Fingerprint returns the stable Stage 05 request fingerprint used by later
// idempotency handling. The fingerprint excludes tracing-only metadata
// (`request_id`, `trace_id`) but includes the normalized business fields of
// the command.
func (command Command) Fingerprint() (string, error) {
if err := command.Validate(); err != nil {
return "", err
}
normalized := fingerprintCommand{
DeliveryID: command.DeliveryID.String(),
Source: command.Source,
PayloadMode: command.PayloadMode,
IdempotencyKey: command.IdempotencyKey.String(),
RequestedAtMS: command.RequestedAt.UTC().UnixMilli(),
Envelope: fingerprintEnvelope{
To: cloneEmails(command.Envelope.To),
Cc: cloneEmails(command.Envelope.Cc),
Bcc: cloneEmails(command.Envelope.Bcc),
ReplyTo: cloneEmails(command.Envelope.ReplyTo),
},
Attachments: cloneAttachments(command.Attachments),
Subject: command.Subject,
TextBody: command.TextBody,
HTMLBody: command.HTMLBody,
TemplateID: command.TemplateID.String(),
Locale: command.Locale.String(),
Variables: command.Variables,
}
payload, err := json.Marshal(normalized)
if err != nil {
return "", fmt.Errorf("marshal stream command fingerprint: %w", err)
}
sum := sha256.Sum256(payload)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
// Attachment stores one inline base64 attachment accepted by the asynchronous
// generic stream contract.
type Attachment struct {
// Filename stores the user-facing attachment filename.
Filename string
// ContentType stores the MIME media type of the attachment.
ContentType string
// ContentBase64 stores the exact inline base64 payload published on the
// stream.
ContentBase64 string
// SizeBytes stores the computed decoded attachment size in bytes.
SizeBytes int64
}
// Validate reports whether Attachment contains a valid inline base64 payload
// and a complete metadata header.
func (attachment Attachment) Validate() error {
if _, err := base64.StdEncoding.DecodeString(attachment.ContentBase64); err != nil {
return fmt.Errorf("attachment content_base64 must be valid base64: %w", err)
}
metadata := common.AttachmentMetadata{
Filename: attachment.Filename,
ContentType: attachment.ContentType,
SizeBytes: attachment.SizeBytes,
}
if err := metadata.Validate(); err != nil {
return err
}
return nil
}
// DecodeCommand validates one raw Redis Streams entry and returns the
// normalized asynchronous generic command frozen by Stage 05.
func DecodeCommand(fields map[string]any) (Command, error) {
if fields == nil {
return Command{}, errors.New("stream command fields must not be nil")
}
if err := validateFieldSet(fields); err != nil {
return Command{}, err
}
deliveryIDValue, err := requiredString(fields, fieldDeliveryID)
if err != nil {
return Command{}, err
}
sourceValue, err := requiredString(fields, fieldSource)
if err != nil {
return Command{}, err
}
payloadModeValue, err := requiredString(fields, fieldPayloadMode)
if err != nil {
return Command{}, err
}
idempotencyValue, err := requiredString(fields, fieldIdempotency)
if err != nil {
return Command{}, err
}
requestedAtValue, err := requiredString(fields, fieldRequestedAtMS)
if err != nil {
return Command{}, err
}
payloadJSONValue, err := requiredString(fields, fieldPayloadJSON)
if err != nil {
return Command{}, err
}
requestedAtMS, err := strconv.ParseInt(requestedAtValue, 10, 64)
if err != nil {
return Command{}, fmt.Errorf("stream field %q must be a base-10 Unix milliseconds string", fieldRequestedAtMS)
}
command := Command{
DeliveryID: common.DeliveryID(deliveryIDValue),
Source: deliverydomain.Source(sourceValue),
PayloadMode: deliverydomain.PayloadMode(payloadModeValue),
IdempotencyKey: common.IdempotencyKey(idempotencyValue),
RequestedAt: time.UnixMilli(requestedAtMS).UTC(),
}
if requestIDValue, ok, err := optionalString(fields, fieldRequestID); err != nil {
return Command{}, err
} else if ok {
command.RequestID = requestIDValue
}
if traceIDValue, ok, err := optionalString(fields, fieldTraceID); err != nil {
return Command{}, err
} else if ok {
command.TraceID = traceIDValue
}
switch command.PayloadMode {
case deliverydomain.PayloadModeRendered:
if err := decodeRenderedPayload(payloadJSONValue, &command); err != nil {
return Command{}, err
}
case deliverydomain.PayloadModeTemplate:
if err := decodeTemplatePayload(payloadJSONValue, &command); err != nil {
return Command{}, err
}
default:
return Command{}, fmt.Errorf("stream field %q value %q is unsupported", fieldPayloadMode, payloadModeValue)
}
if err := command.Validate(); err != nil {
return Command{}, err
}
return command, nil
}
type renderedPayloadJSON struct {
To *[]string `json:"to"`
Cc *[]string `json:"cc"`
Bcc *[]string `json:"bcc"`
ReplyTo *[]string `json:"reply_to"`
Subject *string `json:"subject"`
TextBody *string `json:"text_body"`
HTMLBody *string `json:"html_body,omitempty"`
Attachments *[]attachmentJSON `json:"attachments"`
}
type templatePayloadJSON struct {
To *[]string `json:"to"`
Cc *[]string `json:"cc"`
Bcc *[]string `json:"bcc"`
ReplyTo *[]string `json:"reply_to"`
TemplateID *string `json:"template_id"`
Locale *string `json:"locale"`
Variables *json.RawMessage `json:"variables"`
Attachments *[]attachmentJSON `json:"attachments"`
}
type attachmentJSON struct {
Filename *string `json:"filename"`
ContentType *string `json:"content_type"`
ContentBase64 *string `json:"content_base64"`
}
type fingerprintCommand struct {
DeliveryID string `json:"delivery_id"`
Source deliverydomain.Source `json:"source"`
PayloadMode deliverydomain.PayloadMode `json:"payload_mode"`
IdempotencyKey string `json:"idempotency_key"`
RequestedAtMS int64 `json:"requested_at_ms"`
Envelope fingerprintEnvelope `json:"envelope"`
Attachments []Attachment `json:"attachments"`
Subject string `json:"subject,omitempty"`
TextBody string `json:"text_body,omitempty"`
HTMLBody string `json:"html_body,omitempty"`
TemplateID string `json:"template_id,omitempty"`
Locale string `json:"locale,omitempty"`
Variables map[string]any `json:"variables,omitempty"`
}
type fingerprintEnvelope struct {
To []string `json:"to"`
Cc []string `json:"cc"`
Bcc []string `json:"bcc"`
ReplyTo []string `json:"reply_to"`
}
func validateFieldSet(fields map[string]any) error {
missing := make([]string, 0, len(requiredFieldNames))
for name := range requiredFieldNames {
if _, ok := fields[name]; !ok {
missing = append(missing, name)
}
}
sort.Strings(missing)
if len(missing) > 0 {
return fmt.Errorf("stream command is missing required fields: %s", strings.Join(missing, ", "))
}
unexpected := make([]string, 0)
for name := range fields {
if _, ok := requiredFieldNames[name]; ok {
continue
}
if _, ok := optionalFieldNames[name]; ok {
continue
}
unexpected = append(unexpected, name)
}
sort.Strings(unexpected)
if len(unexpected) > 0 {
return fmt.Errorf("stream command contains unsupported fields: %s", strings.Join(unexpected, ", "))
}
return nil
}
func requiredString(fields map[string]any, name string) (string, error) {
value, ok := fields[name]
if !ok {
return "", fmt.Errorf("stream field %q is required", name)
}
result, ok := value.(string)
if !ok {
return "", fmt.Errorf("stream field %q must be a string", name)
}
return result, nil
}
func optionalString(fields map[string]any, name string) (string, bool, error) {
value, ok := fields[name]
if !ok {
return "", false, nil
}
result, ok := value.(string)
if !ok {
return "", false, fmt.Errorf("stream field %q must be a string", name)
}
return result, true, nil
}
func decodeRenderedPayload(payload string, command *Command) error {
var raw renderedPayloadJSON
if err := decodeStrictJSON("decode payload_json", payload, &raw); err != nil {
return err
}
envelope, attachments, err := decodeCommonPayloadFields(
raw.To,
raw.Cc,
raw.Bcc,
raw.ReplyTo,
raw.Attachments,
)
if err != nil {
return err
}
if raw.Subject == nil {
return errors.New("payload_json.subject is required")
}
if raw.TextBody == nil {
return errors.New("payload_json.text_body is required")
}
command.Envelope = envelope
command.Attachments = attachments
command.Subject = *raw.Subject
command.TextBody = *raw.TextBody
if raw.HTMLBody != nil {
command.HTMLBody = *raw.HTMLBody
}
return nil
}
func decodeTemplatePayload(payload string, command *Command) error {
var raw templatePayloadJSON
if err := decodeStrictJSON("decode payload_json", payload, &raw); err != nil {
return err
}
envelope, attachments, err := decodeCommonPayloadFields(
raw.To,
raw.Cc,
raw.Bcc,
raw.ReplyTo,
raw.Attachments,
)
if err != nil {
return err
}
if raw.TemplateID == nil {
return errors.New("payload_json.template_id is required")
}
if raw.Locale == nil {
return errors.New("payload_json.locale is required")
}
if raw.Variables == nil {
return errors.New("payload_json.variables is required")
}
variables, err := decodeVariables(*raw.Variables)
if err != nil {
return err
}
locale, err := common.ParseLocale(*raw.Locale)
if err != nil {
return fmt.Errorf("payload_json.locale: %w", err)
}
command.Envelope = envelope
command.Attachments = attachments
command.TemplateID = common.TemplateID(*raw.TemplateID)
command.Locale = locale
command.Variables = variables
return nil
}
func decodeCommonPayloadFields(
to *[]string,
cc *[]string,
bcc *[]string,
replyTo *[]string,
attachments *[]attachmentJSON,
) (deliverydomain.Envelope, []Attachment, error) {
if to == nil {
return deliverydomain.Envelope{}, nil, errors.New("payload_json.to is required")
}
if cc == nil {
return deliverydomain.Envelope{}, nil, errors.New("payload_json.cc is required")
}
if bcc == nil {
return deliverydomain.Envelope{}, nil, errors.New("payload_json.bcc is required")
}
if replyTo == nil {
return deliverydomain.Envelope{}, nil, errors.New("payload_json.reply_to is required")
}
if attachments == nil {
return deliverydomain.Envelope{}, nil, errors.New("payload_json.attachments is required")
}
envelope := deliverydomain.Envelope{
To: inflateEmails(*to),
Cc: inflateEmails(*cc),
Bcc: inflateEmails(*bcc),
ReplyTo: inflateEmails(*replyTo),
}
inflatedAttachments, err := inflateAttachments(*attachments)
if err != nil {
return deliverydomain.Envelope{}, nil, err
}
return envelope, inflatedAttachments, nil
}
func inflateAttachments(raw []attachmentJSON) ([]Attachment, error) {
attachments := make([]Attachment, 0, len(raw))
for index, entry := range raw {
if entry.Filename == nil {
return nil, fmt.Errorf("payload_json.attachments[%d].filename is required", index)
}
if entry.ContentType == nil {
return nil, fmt.Errorf("payload_json.attachments[%d].content_type is required", index)
}
if entry.ContentBase64 == nil {
return nil, fmt.Errorf("payload_json.attachments[%d].content_base64 is required", index)
}
decoded, err := base64.StdEncoding.DecodeString(*entry.ContentBase64)
if err != nil {
return nil, fmt.Errorf(
"payload_json.attachments[%d].content_base64 must be valid base64: %w",
index,
err,
)
}
attachments = append(attachments, Attachment{
Filename: *entry.Filename,
ContentType: *entry.ContentType,
ContentBase64: *entry.ContentBase64,
SizeBytes: int64(len(decoded)),
})
}
return attachments, nil
}
func inflateEmails(values []string) []common.Email {
emails := make([]common.Email, len(values))
for index, value := range values {
emails[index] = common.Email(value)
}
return emails
}
func decodeVariables(raw json.RawMessage) (map[string]any, error) {
var variables map[string]any
if err := decodeStrictJSON("decode payload_json.variables", string(raw), &variables); err != nil {
return nil, err
}
if variables == nil {
return nil, errors.New("payload_json.variables must be a JSON object")
}
return variables, nil
}
func decodeStrictJSON(label string, raw string, target any) error {
decoder := json.NewDecoder(bytes.NewBufferString(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return fmt.Errorf("%s: %w", label, err)
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return fmt.Errorf("%s: unexpected trailing JSON input", label)
}
return fmt.Errorf("%s: %w", label, err)
}
return nil
}
func cloneEmails(values []common.Email) []string {
result := make([]string, len(values))
for index, value := range values {
result[index] = value.String()
}
return result
}
func cloneAttachments(values []Attachment) []Attachment {
result := make([]Attachment, len(values))
copy(result, values)
return result
}