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.
RoutePOST /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.
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)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Phase (c) of
docs/DESIGN.mdsections 6 and 8 (8.3 Preferences waits for core #1162 in an RC). Stacked on #7 and #8.email_webhook_inbox(relation on the provider; unique event id and nonce per provider) andemail_event(child list on the message); both system-written.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.SigningCertURLhttps +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.email_event; message status DELIVERED/BOUNCED/COMPLAINED, never backwards; opens/clicks only when tracking is on; reasons truncated with addresses masked.POST /qqq/hooks/email/unsubscribe/{token}suppresses;GETshows 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.docs/PROVIDERS.mddocuments 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