feat: mail service
This commit is contained in:
@@ -0,0 +1,693 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package streamcommand
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDecodeCommandSuccessRendered(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
command, err := DecodeCommand(validRenderedFields(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, Command{
|
||||
DeliveryID: common.DeliveryID("mail-123"),
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeRendered,
|
||||
IdempotencyKey: common.IdempotencyKey("notification:mail-123"),
|
||||
RequestedAt: mustUnixMilli(1_775_121_700_000),
|
||||
RequestID: "req-123",
|
||||
TraceID: "trace-123",
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: []common.Email{"pilot@example.com"},
|
||||
Cc: []common.Email{},
|
||||
Bcc: []common.Email{},
|
||||
ReplyTo: []common.Email{"noreply@example.com"},
|
||||
},
|
||||
Attachments: []Attachment{
|
||||
{
|
||||
Filename: "report.txt",
|
||||
ContentType: "text/plain",
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte("report")),
|
||||
SizeBytes: 6,
|
||||
},
|
||||
},
|
||||
Subject: "Turn ready",
|
||||
TextBody: "Turn 54 is ready.",
|
||||
HTMLBody: "<p>Turn 54 is ready.</p>",
|
||||
}, command)
|
||||
}
|
||||
|
||||
func TestDecodeCommandSuccessTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
command, err := DecodeCommand(validTemplateFields(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, common.TemplateID("game.turn_ready"), command.TemplateID)
|
||||
require.Equal(t, common.Locale("fr-FR"), command.Locale)
|
||||
require.Equal(t, map[string]any{
|
||||
"turn_number": float64(54),
|
||||
"player": map[string]any{
|
||||
"name": "Pilot",
|
||||
},
|
||||
}, command.Variables)
|
||||
require.Empty(t, command.Subject)
|
||||
require.Empty(t, command.TextBody)
|
||||
}
|
||||
|
||||
func TestDecodeCommandRejectsInvalidEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fields map[string]any
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing required field",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
delete(fields, fieldDeliveryID)
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "missing required fields: delivery_id",
|
||||
},
|
||||
{
|
||||
name: "unsupported field",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields["extra"] = "value"
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "unsupported fields: extra",
|
||||
},
|
||||
{
|
||||
name: "non string field",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldDeliveryID] = 42
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: `stream field "delivery_id" must be a string`,
|
||||
},
|
||||
{
|
||||
name: "invalid requested at",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldRequestedAtMS] = "not-a-timestamp"
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: `stream field "requested_at_ms" must be a base-10 Unix milliseconds string`,
|
||||
},
|
||||
{
|
||||
name: "unsupported source",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldSource] = "operator_resend"
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: `stream command source "operator_resend" is unsupported`,
|
||||
},
|
||||
{
|
||||
name: "unsupported payload mode",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadMode] = "unknown"
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: `stream field "payload_mode" value "unknown" is unsupported`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := DecodeCommand(tt.fields)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCommandRejectsInvalidPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fields map[string]any
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "payload must be object",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = `[]`
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "decode payload_json",
|
||||
},
|
||||
{
|
||||
name: "rendered payload unknown field",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": []map[string]any{},
|
||||
"template_id": "game.turn_ready",
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "unknown field",
|
||||
},
|
||||
{
|
||||
name: "trailing json input",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = validRenderedPayloadJSON(t) + `{}`
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "unexpected trailing JSON input",
|
||||
},
|
||||
{
|
||||
name: "empty recipients",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": []map[string]any{},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "must contain at least one recipient",
|
||||
},
|
||||
{
|
||||
name: "invalid locale",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validTemplateFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"template_id": "game.turn_ready",
|
||||
"locale": "english",
|
||||
"variables": map[string]any{},
|
||||
"attachments": []map[string]any{},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "payload_json.locale:",
|
||||
},
|
||||
{
|
||||
name: "variables must be object",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validTemplateFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"template_id": "game.turn_ready",
|
||||
"locale": "fr-FR",
|
||||
"variables": []string{"not", "object"},
|
||||
"attachments": []map[string]any{},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "decode payload_json.variables",
|
||||
},
|
||||
{
|
||||
name: "invalid attachment base64",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": []map[string]any{
|
||||
{
|
||||
"filename": "report.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": "!@#",
|
||||
},
|
||||
},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "content_base64 must be valid base64",
|
||||
},
|
||||
{
|
||||
name: "too many attachments",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
attachments := make([]map[string]any, 0, MaxAttachments+1)
|
||||
for index := 0; index < MaxAttachments+1; index++ {
|
||||
attachments = append(attachments, map[string]any{
|
||||
"filename": "report.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": base64.StdEncoding.EncodeToString([]byte("a")),
|
||||
})
|
||||
}
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": attachments,
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "must contain at most 5 entries",
|
||||
},
|
||||
{
|
||||
name: "encoded attachment payload limit exceeded",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": []map[string]any{{
|
||||
"filename": "report.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": oversizedBase64(),
|
||||
}},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "encoded attachment payload must not exceed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := DecodeCommand(tt.fields)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandFingerprintIgnoresTracingFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first, err := DecodeCommand(validRenderedFields(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
secondFields := validRenderedFields(t)
|
||||
secondFields[fieldRequestID] = "req-456"
|
||||
secondFields[fieldTraceID] = "trace-456"
|
||||
second, err := DecodeCommand(secondFields)
|
||||
require.NoError(t, err)
|
||||
|
||||
firstFingerprint, err := first.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
secondFingerprint, err := second.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, firstFingerprint, secondFingerprint)
|
||||
}
|
||||
|
||||
func TestCommandFingerprintChangesForBusinessFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first, err := DecodeCommand(validRenderedFields(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
secondFields := validRenderedFields(t)
|
||||
secondFields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{"noreply@example.com"},
|
||||
"subject": "Different subject",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"html_body": "<p>Turn 54 is ready.</p>",
|
||||
"attachments": []map[string]any{{"filename": "report.txt", "content_type": "text/plain", "content_base64": base64.StdEncoding.EncodeToString([]byte("report"))}},
|
||||
})
|
||||
second, err := DecodeCommand(secondFields)
|
||||
require.NoError(t, err)
|
||||
|
||||
firstFingerprint, err := first.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
secondFingerprint, err := second.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotEqual(t, firstFingerprint, secondFingerprint)
|
||||
}
|
||||
|
||||
func validRenderedFields(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
return map[string]any{
|
||||
fieldDeliveryID: "mail-123",
|
||||
fieldSource: "notification",
|
||||
fieldPayloadMode: "rendered",
|
||||
fieldIdempotency: "notification:mail-123",
|
||||
fieldRequestedAtMS: "1775121700000",
|
||||
fieldRequestID: "req-123",
|
||||
fieldTraceID: "trace-123",
|
||||
fieldPayloadJSON: validRenderedPayloadJSON(t),
|
||||
}
|
||||
}
|
||||
|
||||
func validTemplateFields(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
return map[string]any{
|
||||
fieldDeliveryID: "mail-124",
|
||||
fieldSource: "notification",
|
||||
fieldPayloadMode: "template",
|
||||
fieldIdempotency: "notification:mail-124",
|
||||
fieldRequestedAtMS: "1775121700001",
|
||||
fieldPayloadJSON: validTemplatePayloadJSON(t),
|
||||
}
|
||||
}
|
||||
|
||||
func validRenderedPayloadJSON(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{"noreply@example.com"},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"html_body": "<p>Turn 54 is ready.</p>",
|
||||
"attachments": []map[string]any{
|
||||
{
|
||||
"filename": "report.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": base64.StdEncoding.EncodeToString([]byte("report")),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func validTemplatePayloadJSON(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"template_id": "game.turn_ready",
|
||||
"locale": "fr-FR",
|
||||
"variables": map[string]any{
|
||||
"turn_number": 54,
|
||||
"player": map[string]any{
|
||||
"name": "Pilot",
|
||||
},
|
||||
},
|
||||
"attachments": []map[string]any{},
|
||||
})
|
||||
}
|
||||
|
||||
func mustJSONString(t *testing.T, value any) string {
|
||||
t.Helper()
|
||||
|
||||
payload, err := json.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
|
||||
return string(payload)
|
||||
}
|
||||
|
||||
func oversizedBase64() string {
|
||||
return string(bytesOf('A', MaxEncodedAttachmentPayloadBytes+4))
|
||||
}
|
||||
|
||||
func bytesOf(value byte, size int) []byte {
|
||||
result := make([]byte, size)
|
||||
for index := range result {
|
||||
result[index] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mustUnixMilli(value int64) time.Time {
|
||||
return time.UnixMilli(value).UTC()
|
||||
}
|
||||
Reference in New Issue
Block a user