179 lines
6.0 KiB
Go
179 lines
6.0 KiB
Go
// 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
|
|
}
|