mirror of
https://github.com/hanzoai/notify.git
synced 2026-10-30 15:04:32 +00:00
- DefaultBrand const flipped from 'liquidity' to 'hanzo' (plivo_resolver.go, chain.go and all doc comments) - Replace <vendor> unsubscribe / mailto hostnames with hanzo.ai in listunsubscribe.go and all marketing tests (raw_test, category_test, marketing_test, etc.) - Rename seed<tenant> test fixture → seedDefault and switch its tenant literals from 'liquidity' to use DefaultBrand - Replace 'liquidity' tenant literals in chain_test / chain_marketing_test / preferences_test / router_test / kmsbridge client_test with 'acme' (generic test tenant) — tests still cover the brand→default fallback semantics - Drop github.com/<tenant>/* from GOPRIVATE in Dockerfile - Drop <tenant>/operator + '<tenant>/bd#105' references from k8s/kustomization.yaml + pkg/types/types.go comments - Replace 'LiquidReplicate sidecar' with generic 'replicator sidecar' in schema.go consent-log retention comment - Rewrite LLM.md fleet-default KMS path examples to use brand/hanzo/* Tests pass: tenant, marketing, preferences, kmsbridge, router.
244 lines
8.9 KiB
Go
244 lines
8.9 KiB
Go
package amazonses
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
"github.com/aws/aws-sdk-go-v2/service/ses"
|
|
"github.com/aws/aws-sdk-go-v2/service/ses/types"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestAmazonSES_SendRaw_EnvelopeShape locks the MIME envelope layout
|
|
// down to the byte. Gmail and Outlook both require List-Unsubscribe +
|
|
// List-Unsubscribe-Post to surface the native unsubscribe button (RFC
|
|
// 8058); the envelope order matters because some downstream verifiers
|
|
// expect Date / MIME-Version / Content-Type before any extension
|
|
// headers. The test asserts every line we put on the wire.
|
|
func TestAmazonSES_SendRaw_EnvelopeShape(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
|
|
s := &AmazonSES{
|
|
senderAddress: aws.String("[email protected]"),
|
|
receiverAddresses: []string{"[email protected]"},
|
|
}
|
|
|
|
headers := map[string]string{
|
|
"List-Unsubscribe": "<https://notify.dev.hanzo.ai/v1/notify/unsubscribe/TOKEN>, <mailto:[email protected]>",
|
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
|
}
|
|
|
|
raw, err := s.buildRawMessage("Hello there", "<p>body</p>", true, headers, now)
|
|
require.NoError(t, err)
|
|
|
|
got := string(raw)
|
|
|
|
// Header lines, in order — strings.Index would re-match "\r\n"
|
|
// against the first line ending, so we scan from a moving offset
|
|
// and require each subsequent header to appear AFTER the previous.
|
|
want := []string{
|
|
"From: [email protected]\r\n",
|
|
"To: [email protected]\r\n",
|
|
"Subject: Hello there\r\n",
|
|
"Date: Wed, 03 Jun 2026 12:00:00 +0000\r\n",
|
|
"MIME-Version: 1.0\r\n",
|
|
"Content-Type: text/html; charset=UTF-8\r\n",
|
|
"List-Unsubscribe: <https://notify.dev.hanzo.ai/v1/notify/unsubscribe/TOKEN>, <mailto:[email protected]>\r\n",
|
|
"List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n",
|
|
}
|
|
cursor := 0
|
|
for _, line := range want {
|
|
idx := strings.Index(got[cursor:], line)
|
|
if idx < 0 {
|
|
t.Fatalf("missing or out-of-order line %q in raw envelope:\n%s", line, got)
|
|
}
|
|
cursor += idx + len(line)
|
|
}
|
|
// After the last header we expect "\r\n" (blank line) then body.
|
|
if !strings.HasSuffix(got, "\r\n\r\n<p>body</p>") {
|
|
t.Fatalf("envelope did not end with blank-line + body, got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// TestAmazonSES_SendRaw_PlainText switches the Content-Type when isHTML
|
|
// is false. Marketing emails ship both HTML and plain-text variants; the
|
|
// minimal raw path here is one Content-Type per call (multipart/alternative
|
|
// is out of scope until §5.2 of the paper).
|
|
func TestAmazonSES_SendRaw_PlainText(t *testing.T) {
|
|
t.Parallel()
|
|
s := &AmazonSES{
|
|
senderAddress: aws.String("[email protected]"),
|
|
receiverAddresses: []string{"[email protected]"},
|
|
}
|
|
raw, err := s.buildRawMessage("subj", "plain body", false, nil, time.Now().UTC())
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(raw), "Content-Type: text/plain; charset=UTF-8\r\n")
|
|
require.NotContains(t, string(raw), "Content-Type: text/html")
|
|
}
|
|
|
|
// TestAmazonSES_SendRaw_MultiRecipient renders the comma-joined To
|
|
// header. SES is happy to parse this; receivers split on commas per
|
|
// RFC 5322 §3.4.
|
|
func TestAmazonSES_SendRaw_MultiRecipient(t *testing.T) {
|
|
t.Parallel()
|
|
s := &AmazonSES{
|
|
senderAddress: aws.String("[email protected]"),
|
|
receiverAddresses: []string{"[email protected]", "[email protected]", "[email protected]"},
|
|
}
|
|
raw, err := s.buildRawMessage("subj", "body", false, nil, time.Now().UTC())
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(raw), "To: [email protected], [email protected], [email protected]\r\n")
|
|
}
|
|
|
|
// TestAmazonSES_SendRaw_HeaderSmuggling blocks CR/LF in header values.
|
|
// An attacker who controls part of an unsubscribe URL could otherwise
|
|
// inject extra headers (Bcc, Reply-To, …) by embedding "\r\nBcc: …" in
|
|
// it. The builder rejects this at compose time rather than letting the
|
|
// SDK accept it.
|
|
func TestAmazonSES_SendRaw_HeaderSmuggling(t *testing.T) {
|
|
t.Parallel()
|
|
s := &AmazonSES{
|
|
senderAddress: aws.String("[email protected]"),
|
|
receiverAddresses: []string{"[email protected]"},
|
|
}
|
|
cases := map[string]map[string]string{
|
|
"CR in value": {"List-Unsubscribe": "<https://a.example>\r\nBcc: [email protected]"},
|
|
"LF in value": {"List-Unsubscribe": "<https://a.example>\nBcc: [email protected]"},
|
|
"CR in name": {"X-Evil\r\n": "anything"},
|
|
}
|
|
for name, headers := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
_, err := s.buildRawMessage("subj", "body", false, headers, time.Now().UTC())
|
|
if err == nil {
|
|
t.Fatalf("expected error for header smuggling input")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAmazonSES_SendRaw_RequiresSender returns an explicit error rather
|
|
// than letting SES reject the message at the API. Saves a round-trip
|
|
// and surfaces config bugs at the right layer.
|
|
func TestAmazonSES_SendRaw_RequiresSender(t *testing.T) {
|
|
t.Parallel()
|
|
s := &AmazonSES{
|
|
senderAddress: aws.String(""),
|
|
receiverAddresses: []string{"[email protected]"},
|
|
}
|
|
_, err := s.buildRawMessage("subj", "body", false, nil, time.Now().UTC())
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestAmazonSES_SendRaw_RequiresReceiver(t *testing.T) {
|
|
t.Parallel()
|
|
s := &AmazonSES{
|
|
senderAddress: aws.String("[email protected]"),
|
|
receiverAddresses: nil,
|
|
}
|
|
_, err := s.buildRawMessage("subj", "body", false, nil, time.Now().UTC())
|
|
require.Error(t, err)
|
|
}
|
|
|
|
// TestAmazonSES_SendRaw_HeadersAreSorted documents that extra header
|
|
// emission is alphabetical so concurrent tests + DKIM hashing produce
|
|
// stable output regardless of map iteration order.
|
|
func TestAmazonSES_SendRaw_HeadersAreSorted(t *testing.T) {
|
|
t.Parallel()
|
|
s := &AmazonSES{
|
|
senderAddress: aws.String("[email protected]"),
|
|
receiverAddresses: []string{"[email protected]"},
|
|
}
|
|
raw, err := s.buildRawMessage("subj", "body", false, map[string]string{
|
|
"X-Zeta": "z",
|
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
|
"List-Unsubscribe": "<https://a.example>",
|
|
"X-Alpha": "a",
|
|
}, time.Now().UTC())
|
|
require.NoError(t, err)
|
|
body := string(raw)
|
|
|
|
// All headers present.
|
|
require.Contains(t, body, "List-Unsubscribe: <https://a.example>")
|
|
require.Contains(t, body, "List-Unsubscribe-Post: List-Unsubscribe=One-Click")
|
|
require.Contains(t, body, "X-Alpha: a")
|
|
require.Contains(t, body, "X-Zeta: z")
|
|
|
|
// Alphabetical ordering within the extra-header block.
|
|
idxLU := strings.Index(body, "List-Unsubscribe:")
|
|
idxLUP := strings.Index(body, "List-Unsubscribe-Post:")
|
|
idxAlpha := strings.Index(body, "X-Alpha:")
|
|
idxZeta := strings.Index(body, "X-Zeta:")
|
|
require.True(t, idxLU < idxLUP, "List-Unsubscribe before List-Unsubscribe-Post")
|
|
require.True(t, idxLUP < idxAlpha, "List-Unsubscribe-Post before X-Alpha")
|
|
require.True(t, idxAlpha < idxZeta, "X-Alpha before X-Zeta")
|
|
}
|
|
|
|
// TestAmazonSES_SendRaw_CallsSendRawEmail asserts SendRaw invokes the
|
|
// SendRawEmail SDK call (not SendEmail) and propagates errors.
|
|
func TestAmazonSES_SendRaw_CallsSendRawEmail(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("happy path", func(t *testing.T) {
|
|
t.Parallel()
|
|
mockClient := new(mocksesClient)
|
|
mockClient.On("SendRawEmail", mock.Anything, mock.MatchedBy(func(in *ses.SendRawEmailInput) bool {
|
|
if in == nil || in.RawMessage == nil {
|
|
return false
|
|
}
|
|
return strings.Contains(string(in.RawMessage.Data), "List-Unsubscribe: ")
|
|
})).Return(&ses.SendRawEmailOutput{}, nil)
|
|
|
|
s := &AmazonSES{
|
|
client: mockClient,
|
|
senderAddress: aws.String("[email protected]"),
|
|
receiverAddresses: []string{"[email protected]"},
|
|
}
|
|
err := s.SendRaw(context.Background(), "subj", "body", false, map[string]string{
|
|
"List-Unsubscribe": "<https://x.example>",
|
|
})
|
|
require.NoError(t, err)
|
|
mockClient.AssertExpectations(t)
|
|
})
|
|
|
|
t.Run("SDK error", func(t *testing.T) {
|
|
t.Parallel()
|
|
mockClient := new(mocksesClient)
|
|
mockClient.On("SendRawEmail", mock.Anything, mock.AnythingOfType("*ses.SendRawEmailInput")).
|
|
Return((*ses.SendRawEmailOutput)(nil), errors.New("SES raw error"))
|
|
|
|
s := &AmazonSES{
|
|
client: mockClient,
|
|
senderAddress: aws.String("[email protected]"),
|
|
receiverAddresses: []string{"[email protected]"},
|
|
}
|
|
err := s.SendRaw(context.Background(), "subj", "body", false, nil)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "SES raw error")
|
|
})
|
|
}
|
|
|
|
// TestAmazonSES_SendEmail_StillWorks asserts the legacy SendEmail path
|
|
// is untouched — both surfaces coexist; only marketing-class sends
|
|
// switch to SendRaw at the call site.
|
|
func TestAmazonSES_SendEmail_StillWorks(t *testing.T) {
|
|
t.Parallel()
|
|
mockClient := new(mocksesClient)
|
|
mockClient.On("SendEmail", mock.Anything, mock.AnythingOfType("*ses.SendEmailInput")).
|
|
Return(&ses.SendEmailOutput{}, nil)
|
|
s := &AmazonSES{
|
|
client: mockClient,
|
|
senderAddress: aws.String("[email protected]"),
|
|
receiverAddresses: []string{"[email protected]"},
|
|
}
|
|
require.NoError(t, s.Send(context.Background(), "subj", "body"))
|
|
|
|
// And the raw envelope is RawMessage-shaped (smoke check).
|
|
_ = types.RawMessage{}
|
|
}
|