Skip to content

email: skip recipients with bounces/unsubscribes in last 365d#14

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

email: skip recipients with bounces/unsubscribes in last 365d#14
mastermanas805 merged 1 commit into
masterfrom
feat/email-bounce-webhook-fresh

Conversation

@mastermanas805
Copy link
Copy Markdown
Member

Summary

  • Before every SendEvent, the audit-log forwarder now checks the new email_events table (created by api#56) and skips recipients who have already told us "stop". Suppressed rows still advance the cursor — the queue doesn't pin on poisoned addresses.
  • Two separate index range-scan queries: unsubscribes (no decay) + bounces/spam complaints (365d decay window). Soft bounces are deliberately not suppression-worthy (retry semantics).
  • Fail-OPEN on suppression-check errors. A Postgres blip in the lookup logs at WARN and treats the recipient as sendable — pinning the queue behind a transient DB error would be worse than sending a possible duplicate to a bouncing inbox.
  • No recipient address is logged when suppression fires (PII); only audit_id + kind so operators can correlate forensically.

Architecture

audit_log row → fetch → builder → [suppression check]  → send/skip → advance cursor
                                       ↓ (suppressed)
                                  log + skip + advance

suppressionChecker is an interface. Production wires sqlSuppressionChecker (queries email_events on the same *sql.DB the forwarder already holds — email_events lives in the platform DB alongside audit_log). Tests inject memSuppression so the spec stays hermetic.

Test plan

  • go test ./... -race -count=1 — all packages green
  • 4 new forwarder tests:
    • Suppressed recipient → 0 sends, cursor advances, skipped++
    • Non-suppressed recipient → 1 send, cursor advances normally
    • Unsubscribe is permanent (no decay)
    • Suppression-checker DB error → fail-open, send proceeds
  • All 9 pre-existing forwarder tests still pass
  • Cannot ship until api#56 lands — depends on email_events table

Pushback

  • Mirror constants between api/models and worker/jobs (SuppressionWindow=365d, the event-type strings). Keeping them in sync is on the developer; a regression test that pulls both via go generate would be tighter but adds module-coupling we deliberately don't have today (worker has no API-module import).

🤖 Generated with Claude Code

Wires the worker's audit-log forwarder to the email_events table that
PR #54 of the api repo creates. Before every SendEvent, the forwarder
asks "has this recipient told us to stop?" — bounces and spam complaints
suppress sends for 365d (the inbox may be fixed by then), unsubscribes
suppress permanently.

Implementation:
  - New suppressionChecker interface (production: sqlSuppressionChecker
    backed by *sql.DB; tests: in-memory map).
  - Two separate range-scan queries rather than one OR'd query, so the
    composite index (email, event_type, created_at DESC) is hit cleanly
    for both the "no decay" unsubscribe path and the "365d window"
    bounce/complaint path.
  - Fail-OPEN on DB errors. A Postgres blip in the suppression check
    must NOT pin the forwarder queue or block sends — sending a
    duplicate to a bouncing inbox during a DB outage is preferable to
    no sends at all.
  - Suppressed rows advance the cursor + bump the skipped counter.
    No recipient address is logged (PII); audit_id is enough for
    forensic lookup.

Tests: 4 new (suppressed → skip + cursor advances; not-suppressed →
still sends; unsubscribe = permanent; DB error → fail-open). All 13
existing forwarder tests still pass under -race.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mastermanas805 mastermanas805 merged commit 1653a4d into master May 13, 2026
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