feat: notification service
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package publishmail
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/notification/internal/api/intentstream"
|
||||
"galaxy/notification/internal/service/acceptintent"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEncoderEncodesUserAndAdminEmailCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.UnixMilli(1775121700000).UTC()
|
||||
tests := []struct {
|
||||
name string
|
||||
notification acceptintent.NotificationRecord
|
||||
route acceptintent.NotificationRoute
|
||||
wantDeliveryID string
|
||||
wantIdempotency string
|
||||
wantPayloadJSON string
|
||||
}{
|
||||
{
|
||||
name: "user route",
|
||||
notification: acceptintent.NotificationRecord{
|
||||
NotificationID: "1775121700000-0",
|
||||
NotificationType: intentstream.NotificationTypeGameTurnReady,
|
||||
Producer: intentstream.ProducerGameMaster,
|
||||
AudienceKind: intentstream.AudienceKindUser,
|
||||
RecipientUserIDs: []string{"user-1"},
|
||||
PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`,
|
||||
IdempotencyKey: "game-123:turn-54",
|
||||
RequestFingerprint: "sha256:deadbeef",
|
||||
AcceptedAt: now,
|
||||
OccurredAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
route: acceptintent.NotificationRoute{
|
||||
NotificationID: "1775121700000-0",
|
||||
RouteID: "email:user:user-1",
|
||||
Channel: intentstream.ChannelEmail,
|
||||
RecipientRef: "user:user-1",
|
||||
Status: acceptintent.RouteStatusPending,
|
||||
MaxAttempts: 7,
|
||||
NextAttemptAt: now,
|
||||
ResolvedEmail: "pilot@example.com",
|
||||
ResolvedLocale: "en",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
wantDeliveryID: "1775121700000-0/email:user:user-1",
|
||||
wantIdempotency: "notification:1775121700000-0/email:user:user-1",
|
||||
wantPayloadJSON: `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn.ready","locale":"en","variables":{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54},"attachments":[]}`,
|
||||
},
|
||||
{
|
||||
name: "admin route",
|
||||
notification: acceptintent.NotificationRecord{
|
||||
NotificationID: "1775121700001-0",
|
||||
NotificationType: intentstream.NotificationTypeLobbyApplicationSubmitted,
|
||||
Producer: intentstream.ProducerGameLobby,
|
||||
AudienceKind: intentstream.AudienceKindAdminEmail,
|
||||
PayloadJSON: `{"applicant_name":"Nova Pilot","applicant_user_id":"user-42","game_id":"game-456","game_name":"Orion Front"}`,
|
||||
IdempotencyKey: "game-456:application-submitted:user-42",
|
||||
RequestFingerprint: "sha256:cafebabe",
|
||||
AcceptedAt: now,
|
||||
OccurredAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
route: acceptintent.NotificationRoute{
|
||||
NotificationID: "1775121700001-0",
|
||||
RouteID: "email:email:owner@example.com",
|
||||
Channel: intentstream.ChannelEmail,
|
||||
RecipientRef: "email:owner@example.com",
|
||||
Status: acceptintent.RouteStatusPending,
|
||||
MaxAttempts: 7,
|
||||
NextAttemptAt: now,
|
||||
ResolvedEmail: "owner@example.com",
|
||||
ResolvedLocale: "en",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
wantDeliveryID: "1775121700001-0/email:email:owner@example.com",
|
||||
wantIdempotency: "notification:1775121700001-0/email:email:owner@example.com",
|
||||
wantPayloadJSON: `{"to":["owner@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"lobby.application.submitted","locale":"en","variables":{"applicant_name":"Nova Pilot","applicant_user_id":"user-42","game_id":"game-456","game_name":"Orion Front"},"attachments":[]}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
command, err := Encoder{}.Encode(tt.notification, tt.route)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantDeliveryID, command.DeliveryID)
|
||||
require.Equal(t, tt.wantIdempotency, command.IdempotencyKey)
|
||||
require.Equal(t, now, command.RequestedAt)
|
||||
require.JSONEq(t, tt.wantPayloadJSON, command.PayloadJSON)
|
||||
|
||||
values := command.Values()
|
||||
require.Equal(t, tt.wantDeliveryID, values["delivery_id"])
|
||||
require.Equal(t, "notification", values["source"])
|
||||
require.Equal(t, "template", values["payload_mode"])
|
||||
require.Equal(t, tt.wantIdempotency, values["idempotency_key"])
|
||||
require.Equal(t, "1775121700000", values["requested_at_ms"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncoderPropagatesTracingMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.UnixMilli(1775121700000).UTC()
|
||||
command, err := Encoder{}.Encode(
|
||||
acceptintent.NotificationRecord{
|
||||
NotificationID: "1775121700000-0",
|
||||
NotificationType: intentstream.NotificationTypeGameTurnReady,
|
||||
Producer: intentstream.ProducerGameMaster,
|
||||
AudienceKind: intentstream.AudienceKindUser,
|
||||
RecipientUserIDs: []string{"user-1"},
|
||||
PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`,
|
||||
IdempotencyKey: "game-123:turn-54",
|
||||
RequestFingerprint: "sha256:deadbeef",
|
||||
RequestID: "request-1",
|
||||
TraceID: "trace-1",
|
||||
AcceptedAt: now,
|
||||
OccurredAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
acceptintent.NotificationRoute{
|
||||
NotificationID: "1775121700000-0",
|
||||
RouteID: "email:user:user-1",
|
||||
Channel: intentstream.ChannelEmail,
|
||||
RecipientRef: "user:user-1",
|
||||
Status: acceptintent.RouteStatusPending,
|
||||
MaxAttempts: 7,
|
||||
NextAttemptAt: now,
|
||||
ResolvedEmail: "pilot@example.com",
|
||||
ResolvedLocale: "en",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
values := command.Values()
|
||||
require.Equal(t, "request-1", values["request_id"])
|
||||
require.Equal(t, "trace-1", values["trace_id"])
|
||||
}
|
||||
|
||||
func TestEncoderPreservesNormalizedPayloadAsTemplateVariables(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.UnixMilli(1775121700000).UTC()
|
||||
command, err := Encoder{}.Encode(
|
||||
acceptintent.NotificationRecord{
|
||||
NotificationID: "1775121700000-0",
|
||||
NotificationType: intentstream.NotificationTypeGameFinished,
|
||||
Producer: intentstream.ProducerGameMaster,
|
||||
AudienceKind: intentstream.AudienceKindUser,
|
||||
RecipientUserIDs: []string{"user-1"},
|
||||
PayloadJSON: `{"final_turn_number":81,"game_id":"game-123","game_name":"Nebula Clash"}`,
|
||||
IdempotencyKey: "game-123:final",
|
||||
RequestFingerprint: "sha256:deadbeef",
|
||||
AcceptedAt: now,
|
||||
OccurredAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
acceptintent.NotificationRoute{
|
||||
NotificationID: "1775121700000-0",
|
||||
RouteID: "email:user:user-1",
|
||||
Channel: intentstream.ChannelEmail,
|
||||
RecipientRef: "user:user-1",
|
||||
Status: acceptintent.RouteStatusPending,
|
||||
MaxAttempts: 7,
|
||||
NextAttemptAt: now,
|
||||
ResolvedEmail: "pilot@example.com",
|
||||
ResolvedLocale: "en",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload struct {
|
||||
Variables map[string]any `json:"variables"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(command.PayloadJSON), &payload))
|
||||
require.Equal(t, map[string]any{
|
||||
"final_turn_number": float64(81),
|
||||
"game_id": "game-123",
|
||||
"game_name": "Nebula Clash",
|
||||
}, payload.Variables)
|
||||
}
|
||||
|
||||
func TestEncoderUsesEmptyAncillaryEnvelopeFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.UnixMilli(1775121700000).UTC()
|
||||
command, err := Encoder{}.Encode(
|
||||
acceptintent.NotificationRecord{
|
||||
NotificationID: "1775121700000-0",
|
||||
NotificationType: intentstream.NotificationTypeLobbyInviteExpired,
|
||||
Producer: intentstream.ProducerGameLobby,
|
||||
AudienceKind: intentstream.AudienceKindUser,
|
||||
RecipientUserIDs: []string{"user-1"},
|
||||
PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","invitee_name":"Nova Pilot","invitee_user_id":"user-2"}`,
|
||||
IdempotencyKey: "game-123:invite-expired",
|
||||
RequestFingerprint: "sha256:deadbeef",
|
||||
AcceptedAt: now,
|
||||
OccurredAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
acceptintent.NotificationRoute{
|
||||
NotificationID: "1775121700000-0",
|
||||
RouteID: "email:user:user-1",
|
||||
Channel: intentstream.ChannelEmail,
|
||||
RecipientRef: "user:user-1",
|
||||
Status: acceptintent.RouteStatusPending,
|
||||
MaxAttempts: 7,
|
||||
NextAttemptAt: now,
|
||||
ResolvedEmail: "pilot@example.com",
|
||||
ResolvedLocale: "en",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.JSONEq(
|
||||
t,
|
||||
`{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"lobby.invite.expired","locale":"en","variables":{"game_id":"game-123","game_name":"Nebula Clash","invitee_name":"Nova Pilot","invitee_user_id":"user-2"},"attachments":[]}`,
|
||||
command.PayloadJSON,
|
||||
)
|
||||
}
|
||||
|
||||
func TestEncoderRejectsInvalidRouteForMailPublication(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.UnixMilli(1775121700000).UTC()
|
||||
_, err := Encoder{}.Encode(
|
||||
acceptintent.NotificationRecord{
|
||||
NotificationID: "1775121700000-0",
|
||||
NotificationType: intentstream.NotificationTypeGameTurnReady,
|
||||
Producer: intentstream.ProducerGameMaster,
|
||||
AudienceKind: intentstream.AudienceKindUser,
|
||||
RecipientUserIDs: []string{"user-1"},
|
||||
PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`,
|
||||
IdempotencyKey: "game-123:turn-54",
|
||||
RequestFingerprint: "sha256:deadbeef",
|
||||
AcceptedAt: now,
|
||||
OccurredAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
acceptintent.NotificationRoute{
|
||||
NotificationID: "1775121700000-0",
|
||||
RouteID: "push:user:user-1",
|
||||
Channel: intentstream.ChannelPush,
|
||||
RecipientRef: "user:user-1",
|
||||
Status: acceptintent.RouteStatusPending,
|
||||
MaxAttempts: 3,
|
||||
NextAttemptAt: now,
|
||||
ResolvedEmail: "pilot@example.com",
|
||||
ResolvedLocale: "en",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, `route channel "push" is unsupported`)
|
||||
}
|
||||
Reference in New Issue
Block a user