package service import ( "context" "errors" "strings" "testing" awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" "github.com/aws/aws-sdk-go-v2/service/sesv2" sestypes "github.com/aws/aws-sdk-go-v2/service/sesv2/types" "github.com/aws/smithy-go" smithymiddleware "github.com/aws/smithy-go/middleware" "wucher/internal/config" "wucher/internal/queue" ) type mockSESAPI struct { input *sesv2.SendEmailInput output *sesv2.SendEmailOutput err error calls int } func (m *mockSESAPI) SendEmail(_ context.Context, params *sesv2.SendEmailInput, _ ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) { m.calls++ m.input = params if m.err != nil { return nil, m.err } if m.output != nil { return m.output, nil } return &sesv2.SendEmailOutput{}, nil } func TestSESSenderSendSimple(t *testing.T) { metadata := smithymiddleware.Metadata{} awsmiddleware.SetRequestIDMetadata(&metadata, "req-123") api := &mockSESAPI{ output: &sesv2.SendEmailOutput{ MessageId: strPtr("ses-msg-1"), ResultMetadata: metadata, }, } sender := NewSESSenderWithAPI(config.SESConfig{ Region: "ap-southeast-1", From: "Wucher ", ConfigurationSetName: "auth-events", }, api) result, err := sender.Send(context.Background(), queue.EmailJob{ MessageType: "password_reset", To: "user@example.com", CC: []string{"cc@example.com"}, BCC: []string{"bcc@example.com"}, ReplyTo: []string{"reply@example.com"}, Subject: "Reset Password", TextBody: "plain body", HTMLBody: "

html body

", Tags: map[string]string{ "message_type": "password_reset", "template": "auth_password_reset", }, }) if err != nil { t.Fatalf("send: %v", err) } if result.Provider != "ses" || result.ProviderMessageID != "ses-msg-1" || result.RequestID != "req-123" { t.Fatalf("unexpected result: %+v", result) } if api.calls != 1 || api.input == nil { t.Fatalf("expected SES SendEmail to be called once") } if got := *api.input.ConfigurationSetName; got != "auth-events" { t.Fatalf("unexpected configuration set: %q", got) } if got := *api.input.FromEmailAddress; got != "\"Wucher\" " { t.Fatalf("unexpected from address: %q", got) } if got := api.input.Destination.ToAddresses; len(got) != 1 || got[0] != "user@example.com" { t.Fatalf("unexpected to addresses: %#v", got) } if got := api.input.Destination.CcAddresses; len(got) != 1 || got[0] != "cc@example.com" { t.Fatalf("unexpected cc addresses: %#v", got) } if got := api.input.Destination.BccAddresses; len(got) != 1 || got[0] != "bcc@example.com" { t.Fatalf("unexpected bcc addresses: %#v", got) } if got := api.input.ReplyToAddresses; len(got) != 1 || got[0] != "reply@example.com" { t.Fatalf("unexpected reply-to addresses: %#v", got) } if api.input.Content == nil || api.input.Content.Simple == nil { t.Fatalf("expected simple SES content") } if got := *api.input.Content.Simple.Subject.Data; got != "Reset Password" { t.Fatalf("unexpected subject: %q", got) } if got := *api.input.Content.Simple.Body.Text.Data; got != "plain body" { t.Fatalf("unexpected text body: %q", got) } if got := *api.input.Content.Simple.Body.Html.Data; got != "

html body

" { t.Fatalf("unexpected html body: %q", got) } if len(api.input.EmailTags) != 2 { t.Fatalf("expected tags to be forwarded, got %#v", api.input.EmailTags) } } func TestSESSenderSendTemplate(t *testing.T) { api := &mockSESAPI{} sender := NewSESSenderWithAPI(config.SESConfig{ Region: "ap-southeast-1", From: "no-reply@example.com", }, api) _, err := sender.Send(context.Background(), queue.EmailJob{ MessageType: "invite_set_password", To: "user@example.com", TemplateName: "auth.invite_set_password", TemplateData: map[string]any{"name": "John"}, }) if err != nil { t.Fatalf("send: %v", err) } if api.input == nil || api.input.Content == nil || api.input.Content.Template == nil { t.Fatalf("expected templated SES content") } if got := *api.input.Content.Template.TemplateName; got != "auth.invite_set_password" { t.Fatalf("unexpected template name: %q", got) } if got := *api.input.Content.Template.TemplateData; got != "{\"name\":\"John\"}" { t.Fatalf("unexpected template data: %q", got) } } func TestSESSenderSendInlineAttachmentUsesRawMIME(t *testing.T) { api := &mockSESAPI{} sender := NewSESSenderWithAPI(config.SESConfig{ Region: "ap-southeast-1", From: "no-reply@example.com", }, api) _, err := sender.Send(context.Background(), queue.EmailJob{ MessageType: "security_pin_reset", To: "user@example.com", Subject: "Reset Security PIN", TextBody: "plain body", HTMLBody: `

html body

`, Attachments: []queue.EmailAttachment{{ Filename: "email-logo.png", ContentType: "image/png", ContentID: "branding-email-logo", Content: []byte("png"), Inline: true, }}, }) if err != nil { t.Fatalf("send: %v", err) } if api.input == nil || api.input.Content == nil || api.input.Content.Raw == nil { t.Fatalf("expected raw SES content") } raw := string(api.input.Content.Raw.Data) if !strings.Contains(raw, `Content-ID: `) { t.Fatalf("expected inline content id in raw MIME: %q", raw) } if !strings.Contains(raw, `Content-Disposition: inline; filename="email-logo.png"`) { t.Fatalf("expected inline content disposition in raw MIME: %q", raw) } if !strings.Contains(raw, `cid:branding-email-logo`) { t.Fatalf("expected html body cid reference in raw MIME: %q", raw) } } func TestSESSenderSendValidationError(t *testing.T) { sender := NewSESSenderWithAPI(config.SESConfig{ Region: "ap-southeast-1", From: "no-reply@example.com", }, &mockSESAPI{}) if _, err := sender.Send(context.Background(), queue.EmailJob{ To: "not-an-email", Subject: "Hello", TextBody: "Body", }); err == nil { t.Fatalf("expected invalid recipient error") } invalidCfgSender := NewSESSenderWithAPI(config.SESConfig{}, &mockSESAPI{}) if _, err := invalidCfgSender.Send(context.Background(), queue.EmailJob{ To: "user@example.com", Subject: "Hello", TextBody: "Body", }); err == nil { t.Fatalf("expected missing SES config error") } } func TestClassifyDeliveryError(t *testing.T) { t.Run("throttling is transient", func(t *testing.T) { err := classifyDeliveryError(sesSenderAPIError{code: "TooManyRequestsException", fault: smithy.FaultClient}) if !queue.IsTransient(err) { t.Fatalf("expected transient classification, got %v", err) } }) t.Run("message rejected is permanent", func(t *testing.T) { err := classifyDeliveryError(sesSenderAPIError{code: "MessageRejected", fault: smithy.FaultClient}) if !queue.IsPermanent(err) { t.Fatalf("expected permanent classification, got %v", err) } }) t.Run("validation error is permanent", func(t *testing.T) { err := classifyDeliveryError(errors.New("invalid email job: recipient is required")) if !queue.IsPermanent(err) { t.Fatalf("expected permanent classification, got %v", err) } }) } type sesSenderAPIError struct { code string fault smithy.ErrorFault } func (e sesSenderAPIError) Error() string { return e.code } func (e sesSenderAPIError) ErrorCode() string { return e.code } func (e sesSenderAPIError) ErrorMessage() string { return e.code } func (e sesSenderAPIError) ErrorFault() smithy.ErrorFault { return e.fault } func strPtr(value string) *string { return &value } var _ sesAPI = (*mockSESAPI)(nil) var _ = sestypes.MessageTag{}