// 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 }