carlos / platform Public

mail: parse SES events, attributing by sending domain

The domain is the only field tying an event back to a tenant — SES knows
nothing about CARLOS accounts. An unknown event type parses rather than
erroring, because a 500 from the ingest endpoint makes SNS retry forever; a
missing source is refused, because an unattributable event would be dropped
from the counts that enforce a cap.
Paul Campbell pushed by paul@keymail.dev 9ff7d29ffbc075926eef8d32a6ec6c09b2d1578e parent 6f028f3
2 files changed, +152 −0
  • internal/mail/events.go +85 −0
  • internal/mail/events_test.go +67 −0
diff --git a/internal/mail/events.go b/internal/mail/events.go
new file mode 100644
index 000000000..1eb646d29
--- /dev/null
+++ b/internal/mail/events.go
@@ -0,0 +1,85 @@
+// SES event parsing (spec §9). Events are the abuse signal AND the only
+// source of a per-tenant send count — SES has no per-identity rate limit to
+// read, so a cap can only be enforced by counting what already went out.
+package mail
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+)
+
+const (
+ EventSend = "Send"
+ EventBounce = "Bounce"
+ EventComplaint = "Complaint"
+ EventReject = "Reject"
+ EventDelivery = "Delivery"
+)
+
+// Event is the part of an SES notification this package acts on.
+type Event struct {
+ Type string
+ Domain string
+ From string
+ MessageID string
+ At time.Time
+}
+
+// DomainOf extracts the lowercase domain from an address, tolerating a
+// display-name wrapper. Empty when there is no domain to find.
+func DomainOf(address string) string {
+ s := strings.TrimSpace(address)
+ if i := strings.LastIndex(s, "<"); i >= 0 {
+ if j := strings.LastIndex(s, ">"); j > i {
+ s = s[i+1 : j]
+ }
+ }
+ at := strings.LastIndex(s, "@")
+ if at < 0 || at == len(s)-1 {
+ return ""
+ }
+ return strings.ToLower(strings.TrimSpace(s[at+1:]))
+}
+
+// ParseEvent reads one SES notification.
+//
+// An UNKNOWN event type parses successfully and is carried through as-is.
+// SES adds event types, and refusing one would make the ingest endpoint
+// answer 500, which makes SNS retry it forever — a self-inflicted flood
+// over a field nothing acts on.
+//
+// A missing source IS refused: an event that can be attributed to no tenant
+// is worse than no event, because it would be silently dropped from the
+// counts that enforce a cap.
+func ParseEvent(body []byte) (Event, error) {
+ var raw struct {
+ EventType string `json:"eventType"`
+ Mail struct {
+ Timestamp string `json:"timestamp"`
+ MessageID string `json:"messageId"`
+ Source string `json:"source"`
+ } `json:"mail"`
+ }
+ if err := json.Unmarshal(body, &raw); err != nil {
+ return Event{}, fmt.Errorf("mail: parse ses event: %w", err)
+ }
+ if raw.Mail.Source == "" {
+ return Event{}, fmt.Errorf("mail: ses event has no mail.source — it can be attributed to no tenant")
+ }
+ dom := DomainOf(raw.Mail.Source)
+ if dom == "" {
+ return Event{}, fmt.Errorf("mail: ses event source %q has no domain", raw.Mail.Source)
+ }
+ ev := Event{
+ Type: raw.EventType, Domain: dom,
+ From: raw.Mail.Source, MessageID: raw.Mail.MessageID,
+ }
+ if raw.Mail.Timestamp != "" {
+ if at, err := time.Parse(time.RFC3339, raw.Mail.Timestamp); err == nil {
+ ev.At = at
+ }
+ }
+ return ev, nil
+}
diff --git a/internal/mail/events_test.go b/internal/mail/events_test.go
new file mode 100644
index 000000000..083f33967
--- /dev/null
+++ b/internal/mail/events_test.go
@@ -0,0 +1,67 @@
+package mail
+
+import "testing"
+
+// 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@seapointish.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 != "seapointish.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 <bob@example.com>", "example.com"},
+ {"nodomain", ""},
+ } {
+ if got := DomainOf(c.in); got != c.want {
+ t.Fatalf("DomainOf(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}