Skip to content

email: bounce + unsubscribe webhook ingestion + email_events table#56

Merged
mastermanas805 merged 1 commit into
masterfrom
feat/email-bounce-webhook-api-fresh
May 13, 2026
Merged

email: bounce + unsubscribe webhook ingestion + email_events table#56
mastermanas805 merged 1 commit into
masterfrom
feat/email-bounce-webhook-api-fresh

Conversation

@mastermanas805
Copy link
Copy Markdown
Member

Summary

  • Adds POST /api/v1/email/webhook/brevo and POST /api/v1/email/webhook/ses to ingest provider-side delivery feedback (bounces, unsubscribes, spam complaints), normalized into a new email_events table (migration 025).
  • New models.HasSuppressionFor function returns whether an address has a recent bounce / spam complaint (365d decay) or any unsubscribe (no decay) — the worker forwarder (PR refactor: extract all hardcoded URL/domain literals into internal/urls package #12 in worker repo) calls this before every send.
  • Dedupe: partial UNIQUE index on (provider, event_type, email, raw->>'message_id'); InsertEmailEvent uses ON CONFLICT DO NOTHING so provider redeliveries are silent no-ops.
  • Two new env vars surfaced in config.Config: BREVO_WEBHOOK_SECRET (HMAC-SHA256 over raw body) and SES_SNS_SUBSCRIPTION_ARN (TopicArn match — full SNS RSA verification reserved for follow-up; the ARN check stops drive-by traffic, the topic ARN is the credential).
  • Both endpoints registered before the /api/v1 auth group so RequireAuth doesn't demand a Bearer from Brevo's / AWS's servers.

Auth shape per provider

Provider Verification
Brevo hex(HMAC-SHA256(BREVO_WEBHOOK_SECRET, rawBody)) — accept X-Sib-Signature or legacy X-Mailin-Custom. Constant-time compare. Empty secret → 401 (closed by default).
SES/SNS Envelope TopicArn must equal SES_SNS_SUBSCRIPTION_ARN. Constant-time compare. SubscriptionConfirmation logged at INFO — never auto-confirmed.
SendGrid Stub only (config field reserved; handler not registered yet).

PII

Raw payloads land in email_events.raw JSONB for audit but are never echoed to user-facing logs. Recipient addresses are stamped on OTel spans only.

Test plan

  • make test-unit — all packages green against latest master (including new migrations 022/023)
  • 10 handler tests (bad sig → 401, good sig → 200 + insert, SES TopicArn mismatch → 401, SubscriptionConfirmation → 200 no insert, Delivery skipped, opens skipped, legacy header accepted, missing secret → fail-closed)
  • 9 model tests (DB-backed): insert/readback, recent bounce suppresses, stale bounce (>365d) doesn't, unsubscribe suppresses at any age, spam complaint suppresses, soft bounce never suppresses, message_id dedupe, validation, empty-email short-circuits
  • go vet ./... clean
  • Operator: set BREVO_WEBHOOK_SECRET + SES_SNS_SUBSCRIPTION_ARN in k8s secrets before deploy; configure Brevo dashboard webhook URL + matching shared secret

Pushback / caveats

  • SNS signature verification is incomplete — only the TopicArn is checked. Full RSA verify (download SigningCertURL, validate cert chain, verify signature) is a follow-up. An attacker who knows our topic ARN can forge inserts. For day-1 ingestion against a private SNS topic this is acceptable; not acceptable long-term.
  • Brevo payload shape — implemented to the public docs at https://developers.brevo.com/docs/transactional-webhooks (event/email/reason/message-id). Brevo also batches events in array form at a separate endpoint; we register the single-event endpoint only. A batched POST would 400 cleanly (invalid_payload).
  • Brevo's message-id field uses a hyphen; injectMessageID rewrites it to message_id before insert so the dedupe index fires.

🤖 Generated with Claude Code

Adds the ingestion surface for provider-side delivery feedback so the
worker can stop emailing addresses that bounce and respect users who
unsubscribe. Three pieces:

1. Migration 025 creates email_events with a (email, event_type,
   created_at DESC) composite index for the suppression query and a
   partial UNIQUE index on (provider, event_type, email, message_id)
   to dedupe provider redeliveries.

2. POST /api/v1/email/webhook/brevo verifies HMAC-SHA256(BREVO_WEBHOOK_SECRET, body)
   in constant time, accepting both X-Sib-Signature and the legacy
   X-Mailin-Custom header. Maps Brevo's event taxonomy
   (hard_bounce/soft_bounce/unsubscribed/spam/blocked) onto the
   normalized event_type set. Returns 401 on bad signature, 200 on
   unhandled events (Brevo also fires opens/clicks we drop silently),
   200 on insert success.

3. POST /api/v1/email/webhook/ses verifies SNS TopicArn against
   SES_SNS_SUBSCRIPTION_ARN. Full SNS signature verification (cert
   download + RSA verify) is reserved for a follow-up; ARN match
   stops drive-by traffic. Handles batched recipients (one row per
   bounced address) and short-circuits Delivery/DeliveryDelay
   notifications. SubscriptionConfirmation is logged for out-of-band
   handling — we never auto-confirm.

models.HasSuppressionFor splits the query into two index range scans:
unsubscribes (no decay) + bounces/spam_complaints (365d decay window).
Soft bounces are deliberately omitted from the suppression set.

Tests: 10 handler tests (signature paths, event-type mapping, both
providers) and 9 model tests (decay window, unsubscribe permanence,
message_id dedupe, validation). Full make test-unit green against
latest master.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant