feat: phase (c) - delivery tracking, suppression and one-click unsubscribe #9

Merged
KofTwentyTwo merged 12 commits from feature/phase-c-tracking into develop 2026-10-14 12:34:16 +00:00
KofTwentyTwo commented 2026-10-14 03:28:47 +00:00 (Migrated from github.com)

Phase (c) of docs/DESIGN.md sections 6 and 8 (8.3 Preferences waits for core #1162 in an RC). Stacked on #7 and #8.

  • Tables: email_webhook_inbox (relation on the provider; unique event id and nonce per provider) and email_event (child list on the message); both system-written.
  • Route POST /qqq/hooks/email/{kind}/{providerName} (outside /qqq/v1): 1 MB cap (413), provider/kind match (404), missing secret (503), failed verification (401), durable dedup insert (200 received/duplicate). No bodies or addresses logged.
  • Verification: SES via SNS (SignatureVersion 1 and 2, SigningCertURL https + sns.<region>.amazonaws.com + .pem, topic ARN match, subscription confirmation through the same host rule, in-memory cert cache); Postmark basic auth (constant time); SendGrid ECDSA with 5-minute window; Mailgun HMAC with 5-minute window and token replay refusal.
  • Inbox processor (claim-safe, retries, FAILED after 6, Reprocess action): SES, Postmark, SendGrid and Mailgun events to email_event; message status DELIVERED/BOUNCED/COMPLAINED, never backwards; opens/clicks only when tracking is on; reasons truncated with addresses masked.
  • Suppression: hard bounce and complaint → ALL; provider unsubscribe → the message's category; optional complaint-rate alert.
  • One-click unsubscribe (RFC 8058): POST /qqq/hooks/email/unsubscribe/{token} suppresses; GET shows a self-contained light/dark confirmation page and never changes state; transactional/security tokens refused. A message carrying an unsubscribe link goes to exactly one recipient, so nobody can unsubscribe someone else.
  • Home metrics now come from real events. docs/PROVIDERS.md documents webhook setup per provider.

Tests: 235, all green (verifiers valid/tampered/stale/missing secret, SNS cert rules, route status codes on a real Javalin, provider payload fixtures, status transitions, suppression rules, unsubscribe page and POSTs, one-recipient rule, no addresses in logs). JaCoCo 94.3% instructions / 100% classes; Checkstyle clean.

🤖 Generated with Claude Code

Phase (c) of `docs/DESIGN.md` sections 6 and 8 (8.3 Preferences waits for core #1162 in an RC). Stacked on #7 and #8. - **Tables:** `email_webhook_inbox` (relation on the provider; unique event id and nonce per provider) and `email_event` (child list on the message); both system-written. - **Route** `POST /qqq/hooks/email/{kind}/{providerName}` (outside `/qqq/v1`): 1 MB cap (413), provider/kind match (404), missing secret (503), failed verification (401), durable dedup insert (200 received/duplicate). No bodies or addresses logged. - **Verification:** SES via SNS (SignatureVersion 1 and 2, `SigningCertURL` https + `sns.<region>.amazonaws.com` + `.pem`, topic ARN match, subscription confirmation through the same host rule, in-memory cert cache); Postmark basic auth (constant time); SendGrid ECDSA with 5-minute window; Mailgun HMAC with 5-minute window and token replay refusal. - **Inbox processor** (claim-safe, retries, FAILED after 6, Reprocess action): SES, Postmark, SendGrid and Mailgun events to `email_event`; message status DELIVERED/BOUNCED/COMPLAINED, never backwards; opens/clicks only when tracking is on; reasons truncated with addresses masked. - **Suppression:** hard bounce and complaint → ALL; provider unsubscribe → the message's category; optional complaint-rate alert. - **One-click unsubscribe (RFC 8058):** `POST /qqq/hooks/email/unsubscribe/{token}` suppresses; `GET` shows a self-contained light/dark confirmation page and never changes state; transactional/security tokens refused. A message carrying an unsubscribe link goes to exactly one recipient, so nobody can unsubscribe someone else. - Home metrics now come from real events. `docs/PROVIDERS.md` documents webhook setup per provider. Tests: 235, all green (verifiers valid/tampered/stale/missing secret, SNS cert rules, route status codes on a real Javalin, provider payload fixtures, status transitions, suppression rules, unsubscribe page and POSTs, one-recipient rule, no addresses in logs). JaCoCo 94.3% instructions / 100% classes; Checkstyle clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.