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.
2 files changed,
+152
−0
internal/mail/events.go+85 −0internal/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) |
| + } |
| + } |
| +} |