carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package mail
2
3import (
4 "encoding/json"
5 "strings"
6 "testing"
7 "time"
8)
9
10// SES's event JSON nests the From address under mail.source and the type
11// under eventType. Attribution is by SENDING DOMAIN, because that is the
12// only field that ties an event back to a tenant — SES does not know about
13// CARLOS accounts.
14func TestParseEventAttributesByDomain(t *testing.T) {
15 body := []byte(`{
16 "eventType": "Bounce",
17 "mail": {
18 "timestamp": "2026-08-19T12:00:00.000Z",
19 "messageId": "0100abc",
20 "source": "noreply@harbourmail.bag.oncarlos.com"
21 }
22 }`)
23 ev, err := ParseEvent(body)
24 if err != nil {
25 t.Fatalf("ParseEvent: %v", err)
26 }
27 if ev.Type != EventBounce {
28 t.Fatalf("Type = %q, want %q", ev.Type, EventBounce)
29 }
30 if ev.Domain != "harbourmail.bag.oncarlos.com" {
31 t.Fatalf("Domain = %q", ev.Domain)
32 }
33 if ev.MessageID != "0100abc" {
34 t.Fatalf("MessageID = %q", ev.MessageID)
35 }
36 if ev.At.IsZero() {
37 t.Fatal("timestamp did not parse")
38 }
39}
40
41// An event type this build does not know must not fail the whole delivery —
42// SES adds event types, and a 500 makes SNS retry forever.
43func TestParseEventAcceptsAnUnknownType(t *testing.T) {
44 ev, err := ParseEvent([]byte(`{"eventType":"Subscription","mail":{"source":"a@b.com"}}`))
45 if err != nil {
46 t.Fatalf("an unknown event type must parse, got %v", err)
47 }
48 if ev.Type != "Subscription" {
49 t.Fatalf("Type = %q", ev.Type)
50 }
51}
52
53func TestParseEventRefusesGarbage(t *testing.T) {
54 if _, err := ParseEvent([]byte(`not json`)); err == nil {
55 t.Fatal("garbage parsed")
56 }
57 if _, err := ParseEvent([]byte(`{"eventType":"Send"}`)); err == nil {
58 t.Fatal("an event with no source parsed — it can be attributed to nobody")
59 }
60}
61
62func TestDomainOfIsCaseFoldedAndAngleTolerant(t *testing.T) {
63 for _, c := range []struct{ in, want string }{
64 {"noreply@Example.COM", "example.com"},
65 {"Bob <bob@example.com>", "example.com"},
66 {"nodomain", ""},
67 } {
68 if got := DomainOf(c.in); got != c.want {
69 t.Fatalf("DomainOf(%q) = %q, want %q", c.in, got, c.want)
70 }
71 }
72}
73
74// Stray trailing > from unbalanced brackets should be stripped.
75func TestDomainOfStripsStrayTrailingBracket(t *testing.T) {
76 for _, c := range []struct{ in, want string }{
77 {"a@b.com>", "b.com"},
78 {"<<a@b.com>>", "b.com"},
79 } {
80 if got := DomainOf(c.in); got != c.want {
81 t.Fatalf("DomainOf(%q) = %q, want %q", c.in, got, c.want)
82 }
83 }
84}
85
86// Trailing FQDN dots should be normalised away.
87func TestDomainOfStripsTrailingDot(t *testing.T) {
88 if got := DomainOf("a@b.com."); got != "b.com" {
89 t.Fatalf("DomainOf(\"a@b.com.\") = %q, want \"b.com\"", got)
90 }
91}
92
93// Multi-address sources are ambiguous and must be refused. Attribution is by
94// domain, and multiple addresses would make it impossible to know which tenant
95// the event belongs to.
96func TestParseEventRefusesAmbiguousMultiAddressSources(t *testing.T) {
97 testCases := []string{
98 // Comma inside raw source (not in display name) indicates multiple addresses
99 `{"eventType":"Send","mail":{"source":"a@b.com,evil@attacker.com"}}`,
100 // Multiple bracket pairs separated by comma outside brackets
101 `{"eventType":"Send","mail":{"source":"Legit <legit@good.com>, Evil <evil@bad.com>"}}`,
102 // Multiple bracket pairs with no space between them
103 `{"eventType":"Send","mail":{"source":"<a@good.com><b@evil.com>"}}`,
104 }
105 for _, tc := range testCases {
106 if _, err := ParseEvent([]byte(tc)); err == nil {
107 t.Fatalf("ParseEvent accepted ambiguous multi-address source: %s", tc)
108 }
109 }
110}
111
112// Display-name-with-comma is standard and must be accepted. A comma inside
113// a display name (like "Smith, Bob") does not indicate multiple addresses.
114func TestParseEventAcceptsDisplayNameWithComma(t *testing.T) {
115 testCases := []struct {
116 source string
117 want string
118 }{
119 {`Smith, Bob <bob@example.com>`, `example.com`},
120 {`Bob <bob@example.com>`, `example.com`},
121 {`noreply+tag@example.com`, `example.com`},
122 {`noreply@mail-eu-west-1.harbourmail.bag.oncarlos.com`, `mail-eu-west-1.harbourmail.bag.oncarlos.com`},
123 }
124 for _, tc := range testCases {
125 ev, err := ParseEvent([]byte(`{"eventType":"Send","mail":{"source":"` + tc.source + `"}}`))
126 if err != nil {
127 t.Fatalf("ParseEvent rejected valid address %q: %v", tc.source, err)
128 }
129 if ev.Domain != tc.want {
130 t.Fatalf("Domain for %q = %q, want %q", tc.source, ev.Domain, tc.want)
131 }
132 }
133}
134
135// Control characters in the source indicate malformation or injection.
136func TestParseEventRefusesControlCharacters(t *testing.T) {
137 testCases := []struct {
138 name string
139 body string
140 }{
141 {"newline", `{"eventType":"Send","mail":{"source":"a@b.com\nX-Injected: true"}}`},
142 {"null", `{"eventType":"Send","mail":{"source":"a@b.com\x00injected"}}`},
143 }
144 for _, tc := range testCases {
145 if _, err := ParseEvent([]byte(tc.body)); err == nil {
146 t.Fatalf("ParseEvent accepted control character in source (%s)", tc.name)
147 }
148 }
149}
150
151// A malformed timestamp must be silently ignored, not fail the parse. Timestamp
152// is unrelated to attribution, so failing loudly would block valid events.
153func TestParseEventIgnoresMalformedTimestamp(t *testing.T) {
154 ev, err := ParseEvent([]byte(`{
155 "eventType":"Send",
156 "mail":{
157 "timestamp":"not-a-time",
158 "messageId":"msg123",
159 "source":"a@b.com"
160 }
161 }`))
162 if err != nil {
163 t.Fatalf("ParseEvent failed on malformed timestamp: %v", err)
164 }
165 if !ev.At.IsZero() {
166 t.Fatalf("At = %v, want zero (timestamp ignored)", ev.At)
167 }
168}
169
170// What SES publishes for a bounce, in the shape its event destinations
171// send: the message's own envelope under mail, the automatic tags naming
172// the sending identity, and a bounce block that says, per recipient, what
173// the receiving server answered.
174const bounceEvent = `{
175 "eventType": "Bounce",
176 "bounce": {
177 "bounceType": "Permanent",
178 "bounceSubType": "NoEmail",
179 "bouncedRecipients": [
180 {"emailAddress": "ann@example.com", "action": "failed", "status": "5.1.1",
181 "diagnosticCode": "smtp; 550 5.1.1 <ann@example.com>: Recipient address rejected"}
182 ],
183 "timestamp": "2026-09-15T08:00:02.000Z",
184 "feedbackId": "0102019-feedback"
185 },
186 "mail": {
187 "timestamp": "2026-09-15T08:00:00.000Z",
188 "source": "bookings@lgc.li",
189 "messageId": "0102019abc-7c8d-000000",
190 "destination": ["ann@example.com"],
191 "tags": {
192 "ses:caller-identity": ["carlos-mail-beb-jb-book"],
193 "ses:from-domain": ["lgc.li"]
194 }
195 }
196}`
197
198func TestParseEventReadsWhoBouncedAndWhy(t *testing.T) {
199 ev, err := ParseEvent([]byte(bounceEvent))
200 if err != nil {
201 t.Fatal(err)
202 }
203 if ev.MessageID != "0102019abc-7c8d-000000" || ev.Caller != "carlos-mail-beb-jb-book" {
204 t.Errorf("message %q, caller %q", ev.MessageID, ev.Caller)
205 }
206 if len(ev.Outcomes) != 1 {
207 t.Fatalf("outcomes = %+v, want one", ev.Outcomes)
208 }
209 o := ev.Outcomes[0]
210 if o.Recipient != "ann@example.com" || o.Status != "5.1.1" || !strings.Contains(o.Diagnostic, "Recipient address rejected") {
211 t.Errorf("outcome = %+v", o)
212 }
213 if got := ev.Detail.Summary(); got != "Permanent/NoEmail" {
214 t.Errorf("summary = %q", got)
215 }
216}
217
218func TestParseEventReadsEachTypesOwnRecipients(t *testing.T) {
219 for _, c := range []struct {
220 name, body, recipient, diagnostic, summary string
221 }{
222 {"delivery", `{"eventType":"Delivery","mail":{"source":"a@lgc.li","messageId":"m1","destination":["bob@example.com"]},
223 "delivery":{"recipients":["bob@example.com"],"smtpResponse":"250 2.0.0 OK 1726387202"}}`,
224 "bob@example.com", "250 2.0.0 OK 1726387202", ""},
225 {"complaint", `{"eventType":"Complaint","mail":{"source":"a@lgc.li","messageId":"m2"},
226 "complaint":{"complaintFeedbackType":"abuse","complainedRecipients":[{"emailAddress":"cat@example.com"}]}}`,
227 "cat@example.com", "", "abuse"},
228 {"delay", `{"eventType":"DeliveryDelay","mail":{"source":"a@lgc.li","messageId":"m3"},
229 "deliveryDelay":{"delayType":"MailboxFull","delayedRecipients":[{"emailAddress":"dan@example.com","status":"4.2.2","diagnosticCode":"452 mailbox full"}]}}`,
230 "dan@example.com", "452 mailbox full", "MailboxFull"},
231 } {
232 t.Run(c.name, func(t *testing.T) {
233 ev, err := ParseEvent([]byte(c.body))
234 if err != nil {
235 t.Fatal(err)
236 }
237 rs := ev.Recipients()
238 if len(rs) != 1 || rs[0].Recipient != c.recipient || rs[0].Diagnostic != c.diagnostic {
239 t.Errorf("recipients = %+v", rs)
240 }
241 if got := ev.Detail.Summary(); got != c.summary {
242 t.Errorf("summary = %q, want %q", got, c.summary)
243 }
244 })
245 }
246}
247
248// A Send or a Reject is about the whole message, so it concerns everyone
249// the message was addressed to. An event that names nobody is still one
250// event, not none.
251func TestAnEventAboutTheWholeMessageConcernsEveryAddressee(t *testing.T) {
252 ev, err := ParseEvent([]byte(`{"eventType":"Reject","mail":{"source":"a@lgc.li","messageId":"m4",
253 "destination":["one@example.com","two@example.com"]},"reject":{"reason":"Bad content"}}`))
254 if err != nil {
255 t.Fatal(err)
256 }
257 if rs := ev.Recipients(); len(rs) != 2 || rs[1].Recipient != "two@example.com" {
258 t.Errorf("recipients = %+v", rs)
259 }
260 if ev.Detail.Summary() != "Bad content" {
261 t.Errorf("summary = %q", ev.Detail.Summary())
262 }
263 bare, _ := ParseEvent([]byte(`{"eventType":"Send","mail":{"source":"a@lgc.li"}}`))
264 if rs := bare.Recipients(); len(rs) != 1 || rs[0].Recipient != "" {
265 t.Errorf("an event naming nobody should still be one entry: %+v", rs)
266 }
267}
268
269// A type's recipients are read from its own block only. A malformed
270// message carrying a delivery block under a bounce must not report the
271// delivered address as bounced.
272func TestParseEventReadsOnlyTheBlockItsTypeNames(t *testing.T) {
273 ev, err := ParseEvent([]byte(`{"eventType":"Bounce","mail":{"source":"a@lgc.li"},
274 "delivery":{"recipients":["bob@example.com"]}}`))
275 if err != nil {
276 t.Fatal(err)
277 }
278 if len(ev.Outcomes) != 0 {
279 t.Errorf("a bounce reported the delivery block's recipients: %+v", ev.Outcomes)
280 }
281}
282
283// The text in an event was written by whichever server answered. It is
284// kept, so it is kept clean: no control characters, and bounded.
285func TestEventTextIsCleanedBeforeItIsKept(t *testing.T) {
286 long := strings.Repeat("x", 5000)
287 ev, err := ParseEvent([]byte(`{"eventType":"Bounce","mail":{"source":"a@lgc.li"},"bounce":{"bounceType":"Permanent",
288 "bouncedRecipients":[{"emailAddress":"ann@example.com\r\nX-Injected: yes","diagnosticCode":"` + long + `"}]}}`))
289 if err != nil {
290 t.Fatal(err)
291 }
292 o := ev.Outcomes[0]
293 if strings.ContainsAny(o.Recipient, "\r\n") {
294 t.Errorf("control characters survived: %q", o.Recipient)
295 }
296 if len(o.Diagnostic) > maxDiagnosticLen {
297 t.Errorf("diagnostic kept at %d bytes", len(o.Diagnostic))
298 }
299}
300
301func TestAppOfCallerReadsBackTheDeliveredIdentity(t *testing.T) {
302 for _, c := range []struct {
303 caller, acct, app string
304 ok bool
305 }{
306 {UserName("beb", "jb-book"), "beb", "jb-book", true},
307 {StandaloneUserName("beb", "jb-book", "laptop"), "beb", "jb-book", true},
308 {"arn:aws:iam::123456789012:user/carlos/mail/carlos-mail-beb-jb-book", "beb", "jb-book", true},
309 {"arn:aws:sts::123456789012:assumed-role/x/carlos-mail-beb-jb-book", "", "", false}, // a role session names itself
310 {"arn:aws:iam::123456789012:role/carlos-mail-beb-jb-book", "", "", false}, // not a user
311 {"carlos/mail/carlos-mail-beb-jb-book", "", "", false}, // a path with no ARN
312 {"carlos-mail-beb-jb-book+s+", "", "", false}, // a standalone with no name
313 {"carlos-mail-beb", "", "", false}, // no app
314 {"carlos-mail-BEB-jb-book", "", "", false}, // not an account sqid
315 {"someone-else-entirely", "", "", false}, // not ours
316 {"", "", "", false},
317 } {
318 acct, app, ok := AppOfCaller(c.caller)
319 if acct != c.acct || app != c.app || ok != c.ok {
320 t.Errorf("AppOfCaller(%q) = %q, %q, %v; want %q, %q, %v", c.caller, acct, app, ok, c.acct, c.app, c.ok)
321 }
322 }
323}
324
325// A terminal can be made to show something the bytes do not say: an 8-bit
326// CSI, or a right-to-left override that reverses the rest of the line. The
327// far end writes the diagnostic, so none of that is kept.
328func TestEventTextLosesInvisibleAndEightBitControls(t *testing.T) {
329 sneaky := []rune{0x9b, 0x202e, 0x202c, 0x200b}
330 diag := "550 " + string(sneaky[0]) + "31m red " + string(sneaky[1]) + "evil" + string(sneaky[2]) + string(sneaky[3]) + " ok"
331 quoted, _ := json.Marshal(diag)
332 ev, err := ParseEvent([]byte(`{"eventType":"Bounce","mail":{"source":"a@lgc.li"},"bounce":{"bouncedRecipients":[
333 {"emailAddress":"ann@example.com","diagnosticCode":` + string(quoted) + `}]}}`))
334 if err != nil {
335 t.Fatal(err)
336 }
337 for _, r := range ev.Outcomes[0].Diagnostic {
338 for _, bad := range sneaky {
339 if r == bad {
340 t.Errorf("%U survived in %q", r, ev.Outcomes[0].Diagnostic)
341 }
342 }
343 }
344}
345
346// A bounce can come a day after the send. The event is dated by the bounce,
347// and the send is kept beside it.
348func TestAnEventIsDatedByWhatItReports(t *testing.T) {
349 ev, err := ParseEvent([]byte(`{"eventType":"Bounce","mail":{"source":"a@lgc.li","timestamp":"2026-09-14T08:00:00Z"},
350 "bounce":{"timestamp":"2026-09-15T09:30:00Z","bouncedRecipients":[{"emailAddress":"ann@example.com"}]}}`))
351 if err != nil {
352 t.Fatal(err)
353 }
354 if got := ev.At.UTC().Format(time.RFC3339); got != "2026-09-15T09:30:00Z" {
355 t.Errorf("At = %s, want the bounce's own time", got)
356 }
357 if got := ev.SentAt.UTC().Format(time.RFC3339); got != "2026-09-14T08:00:00Z" {
358 t.Errorf("SentAt = %s, want the send", got)
359 }
360 send, _ := ParseEvent([]byte(`{"eventType":"Send","mail":{"source":"a@lgc.li","timestamp":"2026-09-14T08:00:00Z"}}`))
361 if !send.At.Equal(send.SentAt) || send.At.IsZero() {
362 t.Errorf("a Send is dated by the send: %v / %v", send.At, send.SentAt)
363 }
364}
365
366// What only the log reads must never cost the count. A notification whose
367// tags, destination or type block arrive in a shape this code does not
368// expect still parses for everything the counts and the cap depend on, and
369// says what it could not read.
370func TestAMisshapedLogFieldDoesNotCostTheEvent(t *testing.T) {
371 for name, body := range map[string]string{
372 "tags as a string": `{"eventType":"Send","mail":{"source":"a@lgc.li","timestamp":"2026-09-15T08:00:00.000Z","tags":{"ses:caller-identity":"x"}}}`,
373 "destination as a string": `{"eventType":"Send","mail":{"source":"a@lgc.li","timestamp":"2026-09-15T08:00:00.000Z","destination":"ann@example.com"}}`,
374 "recipients as objects": `{"eventType":"Delivery","mail":{"source":"a@lgc.li","timestamp":"2026-09-15T08:00:00.000Z"},"delivery":{"recipients":[{"a":1}]}}`,
375 "reason as an object": `{"eventType":"Reject","mail":{"source":"a@lgc.li","timestamp":"2026-09-15T08:00:00.000Z"},"reject":{"reason":{"x":1}}}`,
376 "bounce type as a number": `{"eventType":"Bounce","mail":{"source":"a@lgc.li","timestamp":"2026-09-15T08:00:00.000Z","tags":{"ses:caller-identity":["carlos-mail-beb-jb-book"]}},"bounce":{"bounceType":7}}`,
377 } {
378 t.Run(name, func(t *testing.T) {
379 ev, err := ParseEvent([]byte(body))
380 if err != nil {
381 t.Fatalf("refused — a count would be dropped for a field only the log reads: %v", err)
382 }
383 if ev.Type == "" || ev.Domain != "lgc.li" || ev.SentAt.IsZero() {
384 t.Errorf("the fields the counts need did not survive: %+v", ev)
385 }
386 if ev.Incomplete == "" {
387 t.Error("what could not be read should be said, for the log")
388 }
389 })
390 }
391 // A malformed type block does not also cost the envelope beside it.
392 ev, _ := ParseEvent([]byte(`{"eventType":"Bounce","mail":{"source":"a@lgc.li","tags":{"ses:caller-identity":["carlos-mail-beb-jb-book"]}},"bounce":{"bounceType":7}}`))
393 if ev.Caller != "carlos-mail-beb-jb-book" {
394 t.Errorf("a malformed bounce block cost the sender: %q", ev.Caller)
395 }
396 good, _ := ParseEvent([]byte(bounceEvent))
397 if good.Incomplete != "" {
398 t.Errorf("a well-formed event claims to be incomplete: %q", good.Incomplete)
399 }
400}
401
402// A bounce is about the recipients it names. One that names nobody is not a
403// bounce for everybody the message was addressed to.
404func TestAPerRecipientEventThatNamesNobodyIsNotWidened(t *testing.T) {
405 ev, err := ParseEvent([]byte(`{"eventType":"Bounce","mail":{"source":"a@lgc.li","messageId":"m",
406 "destination":["one@example.com","two@example.com","three@example.com"]},"bounce":{"bounceType":"Permanent","bouncedRecipients":[]}}`))
407 if err != nil {
408 t.Fatal(err)
409 }
410 if rs := ev.Recipients(); len(rs) != 1 || rs[0].Recipient != "" {
411 t.Errorf("a bounce naming nobody was widened to %+v", rs)
412 }
413}
414