// Package publishmail encodes accepted email routes into Mail Service generic // asynchronous template commands. package publishmail import ( "encoding/json" "fmt" netmail "net/mail" "strconv" "strings" "time" "galaxy/notification/internal/api/intentstream" "galaxy/notification/internal/service/acceptintent" ) const ( commandSourceNotification = "notification" commandPayloadModeTemplate = "template" ) // Command stores one Mail Service-compatible template delivery command // produced from a durable notification email route. type Command struct { // DeliveryID stores the stable route-level delivery identifier. DeliveryID string // IdempotencyKey stores the stable Mail Service deduplication key. IdempotencyKey string // RequestedAt stores when Notification Service durably accepted the // notification intent. RequestedAt time.Time // PayloadJSON stores the fully encoded template-mode command payload. PayloadJSON string // RequestID stores the optional correlation identifier. RequestID string // TraceID stores the optional tracing correlation identifier. TraceID string } // Values returns the Redis Stream fields appended to the Mail Service command // stream for Command. func (command Command) Values() map[string]any { values := map[string]any{ "delivery_id": command.DeliveryID, "source": commandSourceNotification, "payload_mode": commandPayloadModeTemplate, "idempotency_key": command.IdempotencyKey, "requested_at_ms": strconv.FormatInt(command.RequestedAt.UTC().UnixMilli(), 10), "payload_json": command.PayloadJSON, } if command.RequestID != "" { values["request_id"] = command.RequestID } if command.TraceID != "" { values["trace_id"] = command.TraceID } return values } // Encoder converts one accepted notification record plus its email route into // one Mail Service-compatible generic template command. type Encoder struct{} // Encode converts notification plus route into one template delivery command. func (Encoder) Encode(notification acceptintent.NotificationRecord, route acceptintent.NotificationRoute) (Command, error) { if err := notification.Validate(); err != nil { return Command{}, fmt.Errorf("encode mail command: %w", err) } if err := route.Validate(); err != nil { return Command{}, fmt.Errorf("encode mail command: %w", err) } if notification.NotificationID != route.NotificationID { return Command{}, fmt.Errorf("encode mail command: notification id %q does not match route notification id %q", notification.NotificationID, route.NotificationID) } if route.Channel != intentstream.ChannelEmail { return Command{}, fmt.Errorf("encode mail command: route channel %q is unsupported", route.Channel) } if !notification.NotificationType.SupportsChannel(notification.AudienceKind, intentstream.ChannelEmail) { return Command{}, fmt.Errorf("encode mail command: payload_encoding_failed: notification type %q does not support email", notification.NotificationType) } recipientEmail, err := normalizedRecipientEmail(route.ResolvedEmail) if err != nil { return Command{}, fmt.Errorf("encode mail command: payload_encoding_failed: %w", err) } locale, err := normalizedLocale(route.ResolvedLocale) if err != nil { return Command{}, fmt.Errorf("encode mail command: payload_encoding_failed: %w", err) } variables, err := payloadVariables(notification.PayloadJSON) if err != nil { return Command{}, fmt.Errorf("encode mail command: payload_encoding_failed: %w", err) } payloadJSON, err := json.Marshal(templatePayloadJSON{ To: []string{recipientEmail}, Cc: []string{}, Bcc: []string{}, ReplyTo: []string{}, TemplateID: string(notification.NotificationType), Locale: locale, Variables: variables, Attachments: []templateAttachmentJSON{}, }) if err != nil { return Command{}, fmt.Errorf("encode mail command: payload_encoding_failed: marshal payload_json: %w", err) } return Command{ DeliveryID: notification.NotificationID + "/" + route.RouteID, IdempotencyKey: "notification:" + notification.NotificationID + "/" + route.RouteID, RequestedAt: notification.AcceptedAt, PayloadJSON: string(payloadJSON), RequestID: notification.RequestID, TraceID: notification.TraceID, }, nil } 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 []templateAttachmentJSON `json:"attachments"` } type templateAttachmentJSON struct { Filename string `json:"filename"` ContentType string `json:"content_type"` ContentBase64 string `json:"content_base64"` } func normalizedRecipientEmail(value string) (string, error) { if strings.TrimSpace(value) == "" { return "", fmt.Errorf("resolved email must not be empty") } parsed, err := netmail.ParseAddress(value) if err != nil { return "", fmt.Errorf("resolved email %q must be valid: %w", value, err) } if parsed.Name != "" || parsed.Address != value { return "", fmt.Errorf("resolved email %q must not include a display name", value) } return value, nil } func normalizedLocale(value string) (string, error) { switch { case strings.TrimSpace(value) == "": return "", fmt.Errorf("resolved locale must not be empty") case strings.TrimSpace(value) != value: return "", fmt.Errorf("resolved locale %q must not contain surrounding whitespace", value) default: return value, nil } } func payloadVariables(payloadJSON string) (json.RawMessage, error) { var payloadObject map[string]json.RawMessage if err := json.Unmarshal([]byte(payloadJSON), &payloadObject); err != nil { return nil, fmt.Errorf("decode payload_json: %w", err) } if payloadObject == nil { return nil, fmt.Errorf("payload_json must be a JSON object") } return json.RawMessage(payloadJSON), nil }