package mail import ( "encoding/json" "strings" "testing" "time" ) // SES's event JSON nests the From address under mail.source and the type // under eventType. Attribution is by SENDING DOMAIN, because that is the // only field that ties an event back to a tenant — SES does not know about // CARLOS accounts. func TestParseEventAttributesByDomain(t *testing.T) { body := []byte(`{ "eventType": "Bounce", "mail": { "timestamp": "2026-08-19T12:00:00.000Z", "messageId": "0100abc", "source": "noreply@harbourmail.bag.oncarlos.com" } }`) ev, err := ParseEvent(body) if err != nil { t.Fatalf("ParseEvent: %v", err) } if ev.Type != EventBounce { t.Fatalf("Type = %q, want %q", ev.Type, EventBounce) } if ev.Domain != "harbourmail.bag.oncarlos.com" { t.Fatalf("Domain = %q", ev.Domain) } if ev.MessageID != "0100abc" { t.Fatalf("MessageID = %q", ev.MessageID) } if ev.At.IsZero() { t.Fatal("timestamp did not parse") } } // An event type this build does not know must not fail the whole delivery — // SES adds event types, and a 500 makes SNS retry forever. func TestParseEventAcceptsAnUnknownType(t *testing.T) { ev, err := ParseEvent([]byte(`{"eventType":"Subscription","mail":{"source":"a@b.com"}}`)) if err != nil { t.Fatalf("an unknown event type must parse, got %v", err) } if ev.Type != "Subscription" { t.Fatalf("Type = %q", ev.Type) } } func TestParseEventRefusesGarbage(t *testing.T) { if _, err := ParseEvent([]byte(`not json`)); err == nil { t.Fatal("garbage parsed") } if _, err := ParseEvent([]byte(`{"eventType":"Send"}`)); err == nil { t.Fatal("an event with no source parsed — it can be attributed to nobody") } } func TestDomainOfIsCaseFoldedAndAngleTolerant(t *testing.T) { for _, c := range []struct{ in, want string }{ {"noreply@Example.COM", "example.com"}, {"Bob ", "example.com"}, {"nodomain", ""}, } { if got := DomainOf(c.in); got != c.want { t.Fatalf("DomainOf(%q) = %q, want %q", c.in, got, c.want) } } } // Stray trailing > from unbalanced brackets should be stripped. func TestDomainOfStripsStrayTrailingBracket(t *testing.T) { for _, c := range []struct{ in, want string }{ {"a@b.com>", "b.com"}, {"<>", "b.com"}, } { if got := DomainOf(c.in); got != c.want { t.Fatalf("DomainOf(%q) = %q, want %q", c.in, got, c.want) } } } // Trailing FQDN dots should be normalised away. func TestDomainOfStripsTrailingDot(t *testing.T) { if got := DomainOf("a@b.com."); got != "b.com" { t.Fatalf("DomainOf(\"a@b.com.\") = %q, want \"b.com\"", got) } } // Multi-address sources are ambiguous and must be refused. Attribution is by // domain, and multiple addresses would make it impossible to know which tenant // the event belongs to. func TestParseEventRefusesAmbiguousMultiAddressSources(t *testing.T) { testCases := []string{ // Comma inside raw source (not in display name) indicates multiple addresses `{"eventType":"Send","mail":{"source":"a@b.com,evil@attacker.com"}}`, // Multiple bracket pairs separated by comma outside brackets `{"eventType":"Send","mail":{"source":"Legit , Evil "}}`, // Multiple bracket pairs with no space between them `{"eventType":"Send","mail":{"source":""}}`, } for _, tc := range testCases { if _, err := ParseEvent([]byte(tc)); err == nil { t.Fatalf("ParseEvent accepted ambiguous multi-address source: %s", tc) } } } // Display-name-with-comma is standard and must be accepted. A comma inside // a display name (like "Smith, Bob") does not indicate multiple addresses. func TestParseEventAcceptsDisplayNameWithComma(t *testing.T) { testCases := []struct { source string want string }{ {`Smith, Bob `, `example.com`}, {`Bob `, `example.com`}, {`noreply+tag@example.com`, `example.com`}, {`noreply@mail-eu-west-1.harbourmail.bag.oncarlos.com`, `mail-eu-west-1.harbourmail.bag.oncarlos.com`}, } for _, tc := range testCases { ev, err := ParseEvent([]byte(`{"eventType":"Send","mail":{"source":"` + tc.source + `"}}`)) if err != nil { t.Fatalf("ParseEvent rejected valid address %q: %v", tc.source, err) } if ev.Domain != tc.want { t.Fatalf("Domain for %q = %q, want %q", tc.source, ev.Domain, tc.want) } } } // Control characters in the source indicate malformation or injection. func TestParseEventRefusesControlCharacters(t *testing.T) { testCases := []struct { name string body string }{ {"newline", `{"eventType":"Send","mail":{"source":"a@b.com\nX-Injected: true"}}`}, {"null", `{"eventType":"Send","mail":{"source":"a@b.com\x00injected"}}`}, } for _, tc := range testCases { if _, err := ParseEvent([]byte(tc.body)); err == nil { t.Fatalf("ParseEvent accepted control character in source (%s)", tc.name) } } } // A malformed timestamp must be silently ignored, not fail the parse. Timestamp // is unrelated to attribution, so failing loudly would block valid events. func TestParseEventIgnoresMalformedTimestamp(t *testing.T) { ev, err := ParseEvent([]byte(`{ "eventType":"Send", "mail":{ "timestamp":"not-a-time", "messageId":"msg123", "source":"a@b.com" } }`)) if err != nil { t.Fatalf("ParseEvent failed on malformed timestamp: %v", err) } if !ev.At.IsZero() { t.Fatalf("At = %v, want zero (timestamp ignored)", ev.At) } } // What SES publishes for a bounce, in the shape its event destinations // send: the message's own envelope under mail, the automatic tags naming // the sending identity, and a bounce block that says, per recipient, what // the receiving server answered. const bounceEvent = `{ "eventType": "Bounce", "bounce": { "bounceType": "Permanent", "bounceSubType": "NoEmail", "bouncedRecipients": [ {"emailAddress": "ann@example.com", "action": "failed", "status": "5.1.1", "diagnosticCode": "smtp; 550 5.1.1 : Recipient address rejected"} ], "timestamp": "2026-09-15T08:00:02.000Z", "feedbackId": "0102019-feedback" }, "mail": { "timestamp": "2026-09-15T08:00:00.000Z", "source": "bookings@lgc.li", "messageId": "0102019abc-7c8d-000000", "destination": ["ann@example.com"], "tags": { "ses:caller-identity": ["carlos-mail-beb-jb-book"], "ses:from-domain": ["lgc.li"] } } }` func TestParseEventReadsWhoBouncedAndWhy(t *testing.T) { ev, err := ParseEvent([]byte(bounceEvent)) if err != nil { t.Fatal(err) } if ev.MessageID != "0102019abc-7c8d-000000" || ev.Caller != "carlos-mail-beb-jb-book" { t.Errorf("message %q, caller %q", ev.MessageID, ev.Caller) } if len(ev.Outcomes) != 1 { t.Fatalf("outcomes = %+v, want one", ev.Outcomes) } o := ev.Outcomes[0] if o.Recipient != "ann@example.com" || o.Status != "5.1.1" || !strings.Contains(o.Diagnostic, "Recipient address rejected") { t.Errorf("outcome = %+v", o) } if got := ev.Detail.Summary(); got != "Permanent/NoEmail" { t.Errorf("summary = %q", got) } } func TestParseEventReadsEachTypesOwnRecipients(t *testing.T) { for _, c := range []struct { name, body, recipient, diagnostic, summary string }{ {"delivery", `{"eventType":"Delivery","mail":{"source":"a@lgc.li","messageId":"m1","destination":["bob@example.com"]}, "delivery":{"recipients":["bob@example.com"],"smtpResponse":"250 2.0.0 OK 1726387202"}}`, "bob@example.com", "250 2.0.0 OK 1726387202", ""}, {"complaint", `{"eventType":"Complaint","mail":{"source":"a@lgc.li","messageId":"m2"}, "complaint":{"complaintFeedbackType":"abuse","complainedRecipients":[{"emailAddress":"cat@example.com"}]}}`, "cat@example.com", "", "abuse"}, {"delay", `{"eventType":"DeliveryDelay","mail":{"source":"a@lgc.li","messageId":"m3"}, "deliveryDelay":{"delayType":"MailboxFull","delayedRecipients":[{"emailAddress":"dan@example.com","status":"4.2.2","diagnosticCode":"452 mailbox full"}]}}`, "dan@example.com", "452 mailbox full", "MailboxFull"}, } { t.Run(c.name, func(t *testing.T) { ev, err := ParseEvent([]byte(c.body)) if err != nil { t.Fatal(err) } rs := ev.Recipients() if len(rs) != 1 || rs[0].Recipient != c.recipient || rs[0].Diagnostic != c.diagnostic { t.Errorf("recipients = %+v", rs) } if got := ev.Detail.Summary(); got != c.summary { t.Errorf("summary = %q, want %q", got, c.summary) } }) } } // A Send or a Reject is about the whole message, so it concerns everyone // the message was addressed to. An event that names nobody is still one // event, not none. func TestAnEventAboutTheWholeMessageConcernsEveryAddressee(t *testing.T) { ev, err := ParseEvent([]byte(`{"eventType":"Reject","mail":{"source":"a@lgc.li","messageId":"m4", "destination":["one@example.com","two@example.com"]},"reject":{"reason":"Bad content"}}`)) if err != nil { t.Fatal(err) } if rs := ev.Recipients(); len(rs) != 2 || rs[1].Recipient != "two@example.com" { t.Errorf("recipients = %+v", rs) } if ev.Detail.Summary() != "Bad content" { t.Errorf("summary = %q", ev.Detail.Summary()) } bare, _ := ParseEvent([]byte(`{"eventType":"Send","mail":{"source":"a@lgc.li"}}`)) if rs := bare.Recipients(); len(rs) != 1 || rs[0].Recipient != "" { t.Errorf("an event naming nobody should still be one entry: %+v", rs) } } // A type's recipients are read from its own block only. A malformed // message carrying a delivery block under a bounce must not report the // delivered address as bounced. func TestParseEventReadsOnlyTheBlockItsTypeNames(t *testing.T) { ev, err := ParseEvent([]byte(`{"eventType":"Bounce","mail":{"source":"a@lgc.li"}, "delivery":{"recipients":["bob@example.com"]}}`)) if err != nil { t.Fatal(err) } if len(ev.Outcomes) != 0 { t.Errorf("a bounce reported the delivery block's recipients: %+v", ev.Outcomes) } } // The text in an event was written by whichever server answered. It is // kept, so it is kept clean: no control characters, and bounded. func TestEventTextIsCleanedBeforeItIsKept(t *testing.T) { long := strings.Repeat("x", 5000) ev, err := ParseEvent([]byte(`{"eventType":"Bounce","mail":{"source":"a@lgc.li"},"bounce":{"bounceType":"Permanent", "bouncedRecipients":[{"emailAddress":"ann@example.com\r\nX-Injected: yes","diagnosticCode":"` + long + `"}]}}`)) if err != nil { t.Fatal(err) } o := ev.Outcomes[0] if strings.ContainsAny(o.Recipient, "\r\n") { t.Errorf("control characters survived: %q", o.Recipient) } if len(o.Diagnostic) > maxDiagnosticLen { t.Errorf("diagnostic kept at %d bytes", len(o.Diagnostic)) } } func TestAppOfCallerReadsBackTheDeliveredIdentity(t *testing.T) { for _, c := range []struct { caller, acct, app string ok bool }{ {UserName("beb", "jb-book"), "beb", "jb-book", true}, {StandaloneUserName("beb", "jb-book", "laptop"), "beb", "jb-book", true}, {"arn:aws:iam::123456789012:user/carlos/mail/carlos-mail-beb-jb-book", "beb", "jb-book", true}, {"arn:aws:sts::123456789012:assumed-role/x/carlos-mail-beb-jb-book", "", "", false}, // a role session names itself {"arn:aws:iam::123456789012:role/carlos-mail-beb-jb-book", "", "", false}, // not a user {"carlos/mail/carlos-mail-beb-jb-book", "", "", false}, // a path with no ARN {"carlos-mail-beb-jb-book+s+", "", "", false}, // a standalone with no name {"carlos-mail-beb", "", "", false}, // no app {"carlos-mail-BEB-jb-book", "", "", false}, // not an account sqid {"someone-else-entirely", "", "", false}, // not ours {"", "", "", false}, } { acct, app, ok := AppOfCaller(c.caller) if acct != c.acct || app != c.app || ok != c.ok { t.Errorf("AppOfCaller(%q) = %q, %q, %v; want %q, %q, %v", c.caller, acct, app, ok, c.acct, c.app, c.ok) } } } // A terminal can be made to show something the bytes do not say: an 8-bit // CSI, or a right-to-left override that reverses the rest of the line. The // far end writes the diagnostic, so none of that is kept. func TestEventTextLosesInvisibleAndEightBitControls(t *testing.T) { sneaky := []rune{0x9b, 0x202e, 0x202c, 0x200b} diag := "550 " + string(sneaky[0]) + "31m red " + string(sneaky[1]) + "evil" + string(sneaky[2]) + string(sneaky[3]) + " ok" quoted, _ := json.Marshal(diag) ev, err := ParseEvent([]byte(`{"eventType":"Bounce","mail":{"source":"a@lgc.li"},"bounce":{"bouncedRecipients":[ {"emailAddress":"ann@example.com","diagnosticCode":` + string(quoted) + `}]}}`)) if err != nil { t.Fatal(err) } for _, r := range ev.Outcomes[0].Diagnostic { for _, bad := range sneaky { if r == bad { t.Errorf("%U survived in %q", r, ev.Outcomes[0].Diagnostic) } } } } // A bounce can come a day after the send. The event is dated by the bounce, // and the send is kept beside it. func TestAnEventIsDatedByWhatItReports(t *testing.T) { ev, err := ParseEvent([]byte(`{"eventType":"Bounce","mail":{"source":"a@lgc.li","timestamp":"2026-09-14T08:00:00Z"}, "bounce":{"timestamp":"2026-09-15T09:30:00Z","bouncedRecipients":[{"emailAddress":"ann@example.com"}]}}`)) if err != nil { t.Fatal(err) } if got := ev.At.UTC().Format(time.RFC3339); got != "2026-09-15T09:30:00Z" { t.Errorf("At = %s, want the bounce's own time", got) } if got := ev.SentAt.UTC().Format(time.RFC3339); got != "2026-09-14T08:00:00Z" { t.Errorf("SentAt = %s, want the send", got) } send, _ := ParseEvent([]byte(`{"eventType":"Send","mail":{"source":"a@lgc.li","timestamp":"2026-09-14T08:00:00Z"}}`)) if !send.At.Equal(send.SentAt) || send.At.IsZero() { t.Errorf("a Send is dated by the send: %v / %v", send.At, send.SentAt) } } // What only the log reads must never cost the count. A notification whose // tags, destination or type block arrive in a shape this code does not // expect still parses for everything the counts and the cap depend on, and // says what it could not read. func TestAMisshapedLogFieldDoesNotCostTheEvent(t *testing.T) { for name, body := range map[string]string{ "tags as a string": `{"eventType":"Send","mail":{"source":"a@lgc.li","timestamp":"2026-09-15T08:00:00.000Z","tags":{"ses:caller-identity":"x"}}}`, "destination as a string": `{"eventType":"Send","mail":{"source":"a@lgc.li","timestamp":"2026-09-15T08:00:00.000Z","destination":"ann@example.com"}}`, "recipients as objects": `{"eventType":"Delivery","mail":{"source":"a@lgc.li","timestamp":"2026-09-15T08:00:00.000Z"},"delivery":{"recipients":[{"a":1}]}}`, "reason as an object": `{"eventType":"Reject","mail":{"source":"a@lgc.li","timestamp":"2026-09-15T08:00:00.000Z"},"reject":{"reason":{"x":1}}}`, "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}}`, } { t.Run(name, func(t *testing.T) { ev, err := ParseEvent([]byte(body)) if err != nil { t.Fatalf("refused — a count would be dropped for a field only the log reads: %v", err) } if ev.Type == "" || ev.Domain != "lgc.li" || ev.SentAt.IsZero() { t.Errorf("the fields the counts need did not survive: %+v", ev) } if ev.Incomplete == "" { t.Error("what could not be read should be said, for the log") } }) } // A malformed type block does not also cost the envelope beside it. ev, _ := ParseEvent([]byte(`{"eventType":"Bounce","mail":{"source":"a@lgc.li","tags":{"ses:caller-identity":["carlos-mail-beb-jb-book"]}},"bounce":{"bounceType":7}}`)) if ev.Caller != "carlos-mail-beb-jb-book" { t.Errorf("a malformed bounce block cost the sender: %q", ev.Caller) } good, _ := ParseEvent([]byte(bounceEvent)) if good.Incomplete != "" { t.Errorf("a well-formed event claims to be incomplete: %q", good.Incomplete) } } // A bounce is about the recipients it names. One that names nobody is not a // bounce for everybody the message was addressed to. func TestAPerRecipientEventThatNamesNobodyIsNotWidened(t *testing.T) { ev, err := ParseEvent([]byte(`{"eventType":"Bounce","mail":{"source":"a@lgc.li","messageId":"m", "destination":["one@example.com","two@example.com","three@example.com"]},"bounce":{"bounceType":"Permanent","bouncedRecipients":[]}}`)) if err != nil { t.Fatal(err) } if rs := ev.Recipients(); len(rs) != 1 || rs[0].Recipient != "" { t.Errorf("a bounce naming nobody was widened to %+v", rs) } }