Unsubscribe & Global DNC Gating
Status: DRAFTED Owner: John Moelker Last verified against prod: TBD (verification pass pending)
1. Purpose
Every cold or transactional email the ChurchWiseAI portfolio sends must be blockable by the recipient in one click. That one click must stop every future email from every property — not just the campaign the pastor clicked from. This document is the single source of truth for how that works and how to verify it before any send.
Two failure modes this prevents:
- A pastor clicks unsubscribe on a PewSearch outreach email, then gets a ChurchWiseAI lifecycle email three days later. (Legal: CASL violation, $10M max fine.)
- A pastor gets unsubscribed from outreach but is still on our newsletter because MailerLite didn't sync.
2. The two opt-out surfaces
We have two distinct opt-out mechanisms because cold recipients and paying customers don't overlap:
| Surface | Who uses it | Stored in | Granularity | Blocks |
|---|---|---|---|---|
Global DNC (churches.email_do_not_contact) | Cold outreach recipients, anyone who hits the /outreach/unsubscribe/{token} link | churches table | Per-church | ALL outbound email to any address on that church's record |
Customer email prefs (premium_churches.email_opt_out) | Paying customers via their admin dashboard → Settings → Notifications | premium_churches table | Per-premium-church | Lifecycle / marketing emails. Transactional (receipts, password reset) still send. |
Cold outreach unsubscribe also sets Global DNC — one click, one pastor, no email ever again from us until they re-opt-in. This is intentional and conservative.
Global DNC does NOT set premium_churches.email_opt_out automatically today. If a church is both a cold-outreach recipient AND a paying customer (rare but possible after conversion), the outreach unsub path also needs to set the premium opt-out. TODO: add this cross-write to the unsubscribe route — see §9.
3. Data model
-- Global DNC (per church, honored by every sender)
ALTER TABLE churches
ADD COLUMN email_do_not_contact BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN email_do_not_contact_reason TEXT,
ADD COLUMN email_do_not_contact_at TIMESTAMPTZ;
-- Per-contact status for this campaign (also audit trail for which email triggered the unsub)
-- outreach_contacts.status = 'unsubscribed'
-- outreach_contacts.unsubscribed_at = timestamp
-- Per-customer opt-out (paying customers, admin-dashboard-controlled)
-- premium_churches.email_opt_out BOOLEAN
Reason codes written to churches.email_do_not_contact_reason:
| Reason | When set |
|---|---|
outreach_unsubscribe | Clicked link in cold outreach email |
reply_unsubscribe | Pastor replied "unsubscribe" / "remove me" — reply triage cron sets this |
bounce_hard | Gmail hard-bounced the recipient (marked invalid) |
manual_founder | Founder manually marked via admin UI (rude reply, policy violation, etc.) |
4. The unsubscribe flow (step-by-step)
Step 1 — Email contains unsubscribe CTA
What happens: Every outreach email renders a footer with a one-click unsubscribe link containing a per-contact HMAC token.
Where:
- Token generation:
pewsearch/web/src/lib/outreach-tokens.ts:8(generateOutreachToken) - Rendered into email body by the drafting sub-agent (per structural template in
docs/superpowers/specs/2026-04-12-no-website-church-outreach-design.md) - Link format:
https://pewsearch.com/outreach/unsubscribe/{starter_kit_token}
Verifications:
- render: Inspect a drafted
outreach_contacts.draft_body, assert the rendered body contains exactly one anchor withhref="https://pewsearch.com/outreach/unsubscribe/"and that the token matchesoutreach_contacts.starter_kit_token. TODO: extende2e/email-link-audit.spec.tsto cover outreach drafts (currently covers lifecycle only). - db:
SELECT id, starter_kit_token FROM outreach_contacts WHERE campaign_id = '<campaign>' AND starter_kit_token IS NULL→ expect 0 rows.
Failure signal: Drafts rejected by the copy quality gate (check #3 in spec §160).
Step 2 — Pastor clicks unsubscribe link
What happens: Browser GETs /outreach/unsubscribe/{token}. Landing page at pewsearch/web/src/app/outreach/unsubscribe/[token]/page.tsx renders a one-click "Confirm unsubscribe" button (or, for true one-click compliance, fires the POST immediately on page load — TBD, see §9).
Where: pewsearch/web/src/app/outreach/unsubscribe/[token]/page.tsx
Verifications:
- code: Playwright test that navigates to
/outreach/unsubscribe/test-token-fixtureon the demo church and asserts the button is visible + clickable without any auth. - click: From a real delivered email (via Gmail MCP), follow the unsub link. Assert HTTP 200 and visible "Unsubscribe" button.
Failure signal: Token-invalid = 404 with kind message, not a server error.
Step 3 — Unsubscribe is recorded
What happens: POST /api/outreach/unsubscribe/{token} fires. Server-side:
- Look up contact by token (
lookupOutreachContactByToken). UPDATE outreach_contacts SET status='unsubscribed', unsubscribed_at=now() WHERE id=<contact>.UPDATE churches SET email_do_not_contact=true, email_do_not_contact_reason='outreach_unsubscribe', email_do_not_contact_at=now() WHERE id=<church>.- Return
{ok: true}.
Where: pewsearch/web/src/app/api/outreach/unsubscribe/[token]/route.ts:10-35
Verifications:
- code: Unit test in
pewsearch/web/src/test/that mocks Supabase, fires POST with a fixture token, asserts both UPDATE calls and the reason code. - db: After a live test unsub,
SELECT email_do_not_contact, email_do_not_contact_reason, email_do_not_contact_at FROM churches WHERE id='<church>'→true, 'outreach_unsubscribe', <recent_timestamp>. - db:
SELECT status, unsubscribed_at FROM outreach_contacts WHERE id='<contact>'→'unsubscribed', <recent_timestamp>.
Failure signal: 404 if token invalid; 500 (with console error) if Supabase write fails. TODO: Today the route swallows DB errors — if the first UPDATE fails the second may still run (race), or vice versa. Add a transaction wrapper.
Step 4 — Every future sender honors the DNC flag
What happens: Before any send (cold outreach, lifecycle, newsletter, care broadcast, cross-promo), the sender queries churches.email_do_not_contact and skips the contact if true.
Where — enforcement sites (every one MUST check):
| Sender | File | DNC check location | Status |
|---|---|---|---|
| Cold outreach send cron | churchwiseai-web/src/app/api/founder/outreach/drafts/[id]/gmail-draft/route.ts | Before draft-to-Gmail API call | VERIFIED via code (checks churches.email_do_not_contact) |
| Lifecycle emails cron | churchwiseai-web/src/app/api/cron/lifecycle-emails/route.ts | Contact filter before sendAndLog | TODO — VERIFY: the cron uses premium_churches.email_opt_out but does NOT currently check churches.email_do_not_contact. This is a cross-property gap. Add check. |
| Stripe welcome / trial / payment-failed | churchwiseai-web/src/app/api/stripe/webhook/route.ts | Before sendWelcomeEmail / sendTrialEndingEmail | TODO — VERIFY: transactional emails intentionally bypass opt-out but should still respect hard bounces + legal DNC. |
| Newsletter | churchwiseai-web/src/app/api/newsletter/route.ts | MailerLite subscribe call | TODO — VERIFY: MailerLite is a separate list; needs sync webhook to set email_do_not_contact on unsub from MailerLite. |
| Care broadcast | churchwiseai-web/src/app/api/care/broadcast/route.ts | Recipient list build | VERIFIED via code (care members have their own unsubscribed_at column, separate from cold outreach). |
| PewSearch claim/welcome | pewsearch/web/src/lib/email.ts | Before Resend call | TODO — VERIFY. |
| ITW premium | sermon-illustrations/src/lib/email.ts | Before Resend call | TODO — VERIFY. |
Verifications:
- code: A cross-sender unit test that, for each sender function, passes a fixture church with
email_do_not_contact=trueand asserts the function returns early / throws / logs skip — never calls Resend/Gmail. - db: After flagging a demo church as DNC, fire every sender with that church as recipient, then
SELECT COUNT(*) FROM email_log WHERE church_id='<demo>' AND sent_at > <test_start>→ 0. - delivered: Gmail MCP search of
test+flows@churchwiseai.comfor any message after flag → 0 messages.
Failure signal: A DNC'd pastor receives any email. This is a P0 compliance bug — founder alerted immediately, deploy rolled back, incident report to knowledge/handoffs/.
5. One-click compliance (CASL Art. 11.1, CAN-SPAM §316.5)
CASL requires unsubscribe to be honored within 10 business days; CAN-SPAM within 10 calendar days. Our standard is within 1 hour, enforced because Step 3 is synchronous.
Requirements for the unsubscribe link itself:
- No login required ✅ (token is the auth)
- Works on first click (Gmail prefetches
HEAD, must not process unsub on HEAD) — TODO: current route hasPOSTonly; Gmail's prefetch uses GET on the URL, so the landing page at/outreach/unsubscribe/{token}shows a confirm button. Verify Gmail's "List-Unsubscribe" header behavior for one-click. - Token is per-contact, not per-campaign (prevents guessing) ✅
- Token does not expire for unsubscribe purposes (even after the 30-day kit-download window) — TODO: verify
lookupOutreachContactByTokenallows expired tokens for unsub; today it returns null ifstarter_kit_token_expires_at < now(). Bug: a pastor who unsubscribes 40 days after receiving the email gets a 404. Fix: make the unsub lookup ignore expiry.
6. List-Unsubscribe headers (future enhancement)
For one-click Gmail/Outlook unsubscribe (the UI button above the email body), our outbound messages must include:
List-Unsubscribe: <https://pewsearch.com/outreach/unsubscribe/{token}>, <mailto:unsubscribe@pewsearch.com?subject=unsubscribe-{token}>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
TODO: Current Gmail-API send does not yet set these headers. Add in the Gmail draft construction path (/api/founder/outreach/drafts/[id]/gmail-draft/route.ts). Until then, users must click the in-body link.
7. Reply-based unsubscribe
A pastor who replies "unsubscribe" / "remove me" / "stop emailing" should be DNC'd automatically without needing to click.
Where: Reply triage cron (spec §199, TODO impl) classifies reply body via Claude Haiku 4.5; category unsubscribe-request triggers:
UPDATE outreach_contacts SET status='unsubscribed', unsubscribed_at=now()UPDATE churches SET email_do_not_contact=true, email_do_not_contact_reason='reply_unsubscribe', email_do_not_contact_at=now()- Auto-respond acknowledging removal (Resend).
Verifications:
- TODO — reply triage cron not yet implemented. Until then, founder manually processes unsubscribe replies and runs the flag update SQL by hand.
8. Reinstatement
If a pastor unsubscribes then later wants to be contacted again:
- Founder-only action via admin UI (not built): sets
email_do_not_contact=false, clearsemail_do_not_contact_reasonandemail_do_not_contact_at. - Pastor-initiated: currently no self-service re-opt-in. TODO: decide whether to build this or require a manual founder override.
9. Known gaps / TODOs (consolidated)
| # | Gap | Severity | Owner |
|---|---|---|---|
| 1 | Unsub route does not cross-write premium_churches.email_opt_out for pastors who are also paying customers | Medium | Founder |
| 2 | lookupOutreachContactByToken returns null for expired tokens — breaks unsubscribe after day 30 | HIGH | Ship this week |
| 3 | Lifecycle cron does not check churches.email_do_not_contact | HIGH | Ship this week |
| 4 | List-Unsubscribe headers not set on Gmail-API sends | Medium | Week 2 |
| 5 | Reply triage cron not implemented | Medium (manual process works for 20/day) | By Week 2 scale-up |
| 6 | Unsubscribe route has no transaction — partial write possible on Supabase error | Low | Next refactor |
| 7 | MailerLite unsub webhook → global DNC sync not wired | Medium | Before MailerLite list grows past 100 |
10. Verification manifest (summary)
flow: unsubscribe-and-dnc-gating
verifications:
- step: 3
verb: db
command: |
SELECT email_do_not_contact, email_do_not_contact_reason
FROM churches
WHERE id = '<test-church-id>';
expect:
email_do_not_contact: true
email_do_not_contact_reason: outreach_unsubscribe
- step: 4
verb: code
command: pnpm -C churchwiseai-web test -- dnc-gating
expect: All senders skip DNC'd contact
status: TODO — test not yet written
- step: 4
verb: delivered
command: |
gmail_search_messages(
q="to:test+flows@churchwiseai.com newer_than:1d",
)
expect: 0 messages after DNC flag set