Skip to main content

Cold Outreach — No-Website Church → Pro Website Pitch

Status: DRAFTED Owner: John Moelker Last verified against prod: TBD — verification pass is the first thing to do after this doc lands. Related skill: church-outreach (plugin skill at ~/.claude/skills/church-outreach/) Upstream spec: docs/superpowers/specs/2026-04-12-no-website-church-outreach-design.md Upstream plan: docs/superpowers/plans/2026-04-12-no-website-church-outreach.md

1. Purpose

Send warm, researched cold emails from john@pewsearch.com to 739 PewSearch-visible churches that have no website on file. Primary gift: free AI Starter Kit download (normally $4.95). Secondary offer: PewSearch Pro Website bundle ($19.95/mo). Do this without harming sender reputation and without violating CASL / CAN-SPAM.

Success criteria (measured weekly):

  • Delivery rate ≥ 95% (of attempted sends, how many didn't bounce)
  • Open rate ≥ 25% (by link-click proxy — no tracking pixel)
  • Starter-kit download rate ≥ 10% of deliveries
  • Pro Website click-through ≥ 5% of kit downloads
  • Unsubscribe rate ≤ 1% (kill-switch at 3% rolling 24h)
  • Reply rate (any category) ≥ 3%

2. Trigger

Not a single trigger — a multi-step workflow:

StepTrigger typeWho/what
Campaign loadManual (founder action)Founder runs /church-outreach research 20 locally — invokes skill; populates outreach_contacts rows
Draft approvalManual (founder UI)Founder reviews draft cards at churchwiseai.com/founder/<token>/outreach
Gmail draft creationAPI call from UIPOST /api/founder/outreach/drafts/[id]/gmail-draft
Actual sendManual (founder clicks Send in Gmail)Gmail web UI — not automated in Week 1
Sent-status syncCron (Week 2+)/api/cron/outreach-gmail-poll polls Gmail for drafts that became "sent"
Reply ingestionCron or webhookReply-triage cron (TODO — not yet impl, manual in Week 1)
Landing page hitRecipient clickGET /starter-kit/{token} on pewsearch.com
UnsubscribeRecipient clickPOST /api/outreach/unsubscribe/{token} (see unsubscribe-and-dnc-gating.md)

3. Preconditions

Before any send:

  • outreach_campaigns row exists with name='no-website-churches-2026-04' and status='active'.
  • Target list populated: ~739 rows in outreach_contacts with campaign_id=<this>, each linked to a churches.id where directory_visible=true AND (website IS NULL OR website='') AND email IS NOT NULL.
  • Each contact row has a non-null starter_kit_token (HMAC, 30-day expiry).
  • Sender domain pewsearch.com passes SPF + DKIM + DMARC. Verify with dig or mail-tester.com.
  • Google Workspace "Send as" alias john@pewsearch.com is active on john@churchwiseai.com.
  • Gmail API OAuth scopes granted to the founder session: gmail.compose, gmail.modify, gmail.readonly. UI falls back to 428 response with authorize_url if missing.
  • churches.email_do_not_contact column exists and is false for targeted churches (see unsubscribe-and-dnc-gating.md for how DNC is enforced).
  • Daily cap not yet exceeded (Week 1: 20/day).
  • Kill-switch not triggered (/api/founder/outreach/kill-switch returns triggered: false).

4. Steps

Step 1 — Load campaign + research contacts

What happens: Founder runs the church-outreach skill which (a) queries PewSearch for eligible churches, (b) inserts outreach_contacts rows with status='queued', (c) dispatches parallel Claude sub-agents to research each church (WebSearch + WebFetch across Google Business, denominational directory, news), (d) writes structured research JSON back to outreach_contacts.research_json and moves status to researched.

Where: Skill at ~/.claude/skills/church-outreach/ (plugin) → invokes Supabase MCP for inserts → dispatches parallel Agent tool calls for research.

Verifications:

  • db: SELECT COUNT(*) FROM outreach_contacts WHERE campaign_id='<id>' AND status='queued' after load → expected batch size.
  • db: SELECT COUNT(*), jsonb_typeof(research_json) FROM outreach_contacts WHERE campaign_id='<id>' AND status='researched' GROUP BY 2 → expect all object, count = batch size.
  • db: SELECT COUNT(*) FROM outreach_contacts WHERE starter_kit_token IS NULL AND campaign_id='<id>' → 0 (every row must have a token).
  • code: churchwiseai-web/src/test/unit/outreach-queries.test.ts asserts query shapes.

Failure signal: Research sub-agent timeout → contact stuck at researching. Cron should reap stuck rows after 30 min (TODO).

Step 2 — Draft email copy

What happens: Drafting sub-agent reads research_json + church data, produces draft_subject + draft_body. Copy passes quality gate (6 checks, see spec §164). Status → drafted.

Where: Skill drafter → writes to outreach_contacts.draft_subject, draft_body, subject_pattern (A/B/C), personalization_json.

Verifications:

  • code: Copy-quality-gate unit test — must reject any draft containing banned phrases (/hope this (email|finds)/i, /circle back/i, etc.) or missing unsubscribe link.
  • render: For every drafted row, extract hrefs from draft_body. Assert: (1) ≥1 link to pewsearch.com/starter-kit/<token>, (2) exactly 1 link to pewsearch.com/outreach/unsubscribe/<token>, (3) token matches outreach_contacts.starter_kit_token, (4) footer contains literal address 125 Concession Street, Ingersoll, ON N5C 1G2. TODO: extend e2e/email-link-audit.spec.ts to cover outreach drafts — currently only covers lifecycle emails.
  • db: SELECT COUNT(*) FROM outreach_contacts WHERE campaign_id='<id>' AND status='drafted' AND (draft_subject IS NULL OR draft_body IS NULL) → 0.
  • manual: Spot-check 3 random drafts each morning of Week 1 — does this read like a real pastor wrote it?

Failure signal: Draft rejected → goes back to drafter with feedback. Never reaches founder.

Step 3 — Founder reviews + approves

What happens: Founder opens churchwiseai.com/founder/<token>/outreach. UI lists drafted cards (church name + city + subject + body preview). Founder clicks a card → right pane shows full editable draft + collapsible research + church-record panels. Founder takes one of four actions:

ActionEndpointDB effect
ApprovePOST /api/founder/outreach/drafts/[id]/approvestatus = 'approved'
Edit & savePATCH /api/founder/outreach/drafts/[id] with subject/body + noteUpdates draft, appends edit_history jsonb entry
Skip & re-draftPOST /api/founder/outreach/drafts/[id]/skip with reasonstatus = 'do_not_contact', skip_reason set
DNC foreverSame skip endpoint with reason manual_founderAlso sets churches.email_do_not_contact=true

Where: UI at churchwiseai-web/src/app/founder/[token]/outreach/OutreachWorkspace.tsx.

Verifications:

  • code: Playwright test hitting the outreach workspace with a founder token — approve, edit, skip flows each assert the correct status transition in DB.
  • db: After approve: SELECT status, approved_at FROM outreach_contacts WHERE id='<id>''approved', <timestamp>.
  • db: Edit-history integrity: SELECT jsonb_array_length(edit_history) FROM outreach_contacts WHERE id='<id>' equals number of PATCHes made.
  • dashboard: Journey runner persona "skeptical founder" clicks through all 4 actions — UI must be usable without reading docs.

Failure signal: Skip reason missing → validation error (400). Token invalid → 401.

Step 4 — Create Gmail draft

What happens: Founder clicks "Create Gmail draft" on an approved card. UI POSTs /api/founder/outreach/drafts/[id]/gmail-draft. Server:

  1. Checks churches.email_do_not_contact — if true, returns 409 { error: 'dnc' } and marks contact do_not_contact.
  2. Checks kill-switch (rolling 24h unsub rate < 3%). If triggered, returns 423 { error: 'kill_switch' }.
  3. Validates Gmail OAuth scopes. If missing, returns 428 { authorize_url: '...' }.
  4. Constructs MIME message with From: John Moelker <john@pewsearch.com>, subject, HTML body, List-Unsubscribe header (TODO — header not yet set), label outreach-2026-04.
  5. Calls gmail.users.drafts.create → gets draft_id.
  6. Writes outreach_contacts.gmail_draft_id, status='scheduled', scheduled_at=now().

Where: churchwiseai-web/src/app/api/founder/outreach/drafts/[id]/gmail-draft/route.ts

Verifications:

  • code: Contract test outreach-gmail-route-contract.test.ts — DNC check, kill-switch check, 428 scope-missing, 200 happy path all asserted.
  • db: After success: SELECT status, gmail_draft_id, scheduled_at FROM outreach_contacts WHERE id='<id>''scheduled', <draft_id_string>, <timestamp>.
  • delivered (in draft sense): Gmail MCP gmail_list_drafts with query label:outreach-2026-04 → draft exists with matching subject.
  • click: Inspect the draft HTML via gmail_read_message — assert hrefs resolve correctly (see unsubscribe-and-dnc-gating.md Step 1 for the link audit pattern).

Failure signal: 428 → UI prompts re-auth. 423 → UI shows kill-switch banner. 409 → contact row marked DNC, founder sees "already unsubscribed" message.

Step 5 — Founder sends the draft from Gmail

What happens: Founder opens Gmail, reviews draft once more, clicks Send. Gmail dispatches SMTP via Google's outbound infrastructure. Message lands in Sent folder and (hopefully) recipient's Primary inbox.

Where: Gmail web UI — outside our code. This is the one manual step in Week 1.

Verifications:

  • delivered: Gmail MCP gmail_search_messages with from:me to:<recipient> subject:"<draft subject>" label:sent newer_than:1h → 1 message. The exact query is stored in the contact's verify_sent_query field (TODO — add this column).
  • delivered: Separate test send to test+flows@churchwiseai.com at start of each campaign day — must land in Primary (not Promotions/Spam). manual: screenshot saved to knowledge/tests/artifacts/outreach-inbox-placement/YYYY-MM-DD.png.
  • TODO: no automated "did it send" check in Week 1. Founder must manually click Send. Risk: founder gets distracted, draft sits unsent for days, pastor is confused when the follow-up references an email that never arrived. Mitigation: daily audit cron counts drafts older than 24h in scheduled status and alerts founder.

Failure signal: Message bounces → see Step 8. Message lands in Promotions/Spam → reputation issue, pause campaign, investigate.

Step 6 — Cron detects "sent" state

What happens: /api/cron/outreach-gmail-poll runs every hour. For each contact with status='scheduled', polls Gmail API to check if the draft became a sent message:

  • If yes → status='sent', sent_at=<message_internalDate>, clear gmail_draft_id.
  • If no → leave alone.
  • If draft deleted without send → status='approved' (unsend) with alert.

Where: churchwiseai-web/src/app/api/cron/outreach-gmail-poll/route.ts

Status: STUB (Week 1) — full implementation due Wed Apr 15.

Verifications:

  • cron: curl -H "Authorization: Bearer $CRON_SECRET" $BASE_URL/api/cron/outreach-gmail-poll → 200 with summary JSON ({polled: N, marked_sent: M, errors: E}).
  • db: SELECT COUNT(*) FROM outreach_contacts WHERE status='scheduled' AND scheduled_at < now() - interval '48 hours' → 0 after cron has had time to process (any rows > 48h scheduled likely mean founder never sent).
  • TODO: Add Sentry/console alert when cron detects draft deletion without send.

Failure signal: Cron 500 → Vercel cron monitor fires, founder paged. Cron silently no-ops (returns 200 with polled: 0 when drafts exist) → no alert today, gap.

Step 7 — Pastor receives email & clicks CTA

What happens: Pastor opens email (or doesn't). If they click:

  • Unsubscribe link → Step 8
  • Starter-kit link → Step 9
  • Pro Website link on landing page → Step 10

Verifications:

  • click: For one canonical email per campaign, via Gmail MCP fetch test+flows@churchwiseai.com copy, extract every href, Playwright request.fetch each one — assert valid destination (not 404, not redirect to /, not ?auth=invalid). The general pattern lives in churchwiseai-web/e2e/email-link-audit.spec.ts; TODO: add outreach-link-audit.spec.ts that pulls sample from outreach_contacts.draft_body.

Step 8 — Unsubscribe path

What happens: See unsubscribe-and-dnc-gating.md Steps 1-4. Click → landing page → POST → DB write → DNC flag set → no future emails from any property.

Verifications: (deferred to linked doc)

Failure signal: P0 if a DNC'd pastor receives any further email.

Step 9 — Starter Kit download

What happens: GET /starter-kit/{token} on pewsearch.com (pewsearch/web/src/app/starter-kit/[token]/page.tsx):

  1. Validate token against outreach_contacts.starter_kit_token + starter_kit_token_expires_at.
  2. Record kit_downloaded_at=now() on first visit (idempotent).
  3. Status transitions: sent → link_clicked → kit_downloaded.
  4. Render page: above-fold = church name + big "Download your AI Starter Kit" button. Below-fold = 1-paragraph Pro Website pitch with "Tell me more" CTA.

Where: pewsearch/web/src/app/starter-kit/[token]/page.tsx + API at pewsearch/web/src/app/api/outreach/kit-download/[token]/route.ts.

Verifications:

  • code: Playwright: navigate to /starter-kit/<valid-token> → assert church name in hero, download button visible, clicking it returns a PDF with Content-Type application/pdf.
  • code: Navigate to /starter-kit/<expired-token> → assert 404 with kind copy + link to paid kit.
  • code: Navigate to /starter-kit/<invalid-token> → same 404.
  • db: After download: SELECT status, kit_downloaded_at FROM outreach_contacts WHERE starter_kit_token='<token>''kit_downloaded', <timestamp>.
  • db: Idempotency — second download must not overwrite kit_downloaded_at.

Failure signal: Page returns 500 → PDF storage bucket misconfigured, or SUPABASE_SERVICE_ROLE_KEY missing.

Step 10 — Pro Website CTA click

What happens: Pastor clicks "Tell me more about Pro Website" on landing page. Client fires POST /api/outreach/pro-website-click/{token} (non-blocking analytics beacon), then browser navigates to pewsearch.com/pro-website (or /claim/<slug> if church slug known).

Where:

  • Click handler: pewsearch/web/src/app/starter-kit/[token]/page.tsx (client component)
  • Beacon API: pewsearch/web/src/app/api/outreach/pro-website-click/[token]/route.ts
  • Destination: pewsearch/web/src/app/pro-website/page.tsx (marketing) OR pewsearch/web/src/app/claim/[slug]/page.tsx (if pre-personalized)

Verifications:

  • code: Playwright: click the Pro Website CTA on the demo kit page → assert (1) POST fires (intercept network), (2) browser lands on /pro-website or /claim/<slug>, (3) page renders pricing and a "Claim" button.
  • db: After click: SELECT status, pro_website_clicked_at FROM outreach_contacts WHERE starter_kit_token='<token>''pro_website_clicked', <timestamp>.
  • click: The landing destination (/pro-website or /claim/<slug>) itself has working CTAs — price cards, "Claim your church" buttons — that don't 404 or redirect home. This is where the Hope Community bug class lives. Run link audit on the destination page.

Failure signal: Beacon POST 404 → analytics gap but doesn't block pastor. Destination 500 → HIGH, pastor intent lost.

Step 11 — Pastor claims Pro Website (conversion path)

What happens: Pastor clicks "Claim" on /claim/<slug> or /pro-website. Follows the standard PewSearch claim flow (GenericClaimPage → Pre-checkout → PreviewPage → Stripe Checkout → Webhook → ActivationSuccessPage). On activation, flow sets outreach_contacts.converted_at=now(), status='converted'.

Where: pewsearch/web/src/app/claim/[slug]/page.tsx/api/stripe/pre-checkout → Stripe → /api/stripe/webhook.

Verifications:

  • code: E2E test pewsearch/web/e2e/crud-admin-*.spec.ts family already covers parts — verify the outreach-attribution hook runs in the webhook.
  • db: After Stripe webhook fires: SELECT converted_at, status FROM outreach_contacts WHERE church_id='<converted-church>'<timestamp>, 'converted'.
  • db: SELECT plan, status FROM premium_churches WHERE church_id='<converted-church>''pro_website', 'active'.
  • TODO: Stripe webhook currently does NOT backfill outreach_contacts.converted_at — it only knows about premium_churches. Need a reverse-lookup hook. Gap: campaign attribution reporting will under-count conversions until this is added.

Failure signal: Webhook fires but outreach_contacts never updated → attribution lost, founder can't prove the campaign worked.

Step 12 — Reply / bounce / no-response

Three terminal branches:

  • Reply → Step 13
  • Bounce → Step 14
  • No response after 14 days → contact stays at sent, no follow-up in this campaign (Week 3 follow-up email is out-of-scope for this doc).

Step 13 — Reply triage

What happens: Reply-triage cron (TODO — not yet built) reads inbox via Gmail API push, classifies with Haiku 4.5, writes replied_at + reply_category, takes action:

  • send-kit-link-again → Resend auto-responder with kit URL.
  • question-about-product → Flag for founder.
  • not-interested → Mark status='do_not_contact'.
  • unsubscribe-request → Mark status='unsubscribed' + set churches.email_do_not_contact=true.

Status: TODO. Week 1 process: founder reads replies in Gmail manually.

Verifications: All currently manual. Weekly audit: count replies in Gmail with label:outreach-2026-04 label:replies vs outreach_contacts.replied_at IS NOT NULL — must match.

Step 14 — Bounce handling

What happens: Gmail soft-bounces retry automatically. Hard bounces return delivery-status notifications to john@pewsearch.com. Resend's feedback loop at send.pewsearch.com also captures hard bounces. Both paths should set outreach_contacts.bounced_at + mark churches.email_do_not_contact=true with reason bounce_hard.

Status: TODO. Resend feedback loop webhook not yet wired into a bounce-handling endpoint for the outreach campaign.

Verifications:

  • TODO: After bounce, SELECT bounced_at, status FROM outreach_contacts WHERE email='<bounced-addr>'<timestamp>, 'bounced'.
  • TODO: Verify churches.email_do_not_contact=true with email_do_not_contact_reason='bounce_hard'.
  • manual (Week 1): Founder reads bounce notifications in Gmail, runs update SQL by hand.

Step 15 — Kill-switch monitoring

What happens: Rolling 24h unsub rate is computed by /api/founder/outreach/kill-switch. If > 3%, subsequent Gmail-draft-create calls return 423. UI banner tells founder "Campaign paused — unsub rate 4.2%, review before resuming."

Where: churchwiseai-web/src/app/api/founder/outreach/kill-switch/route.ts + churchwiseai-web/src/app/founder/[token]/outreach/KillSwitchBanner.tsx.

Verifications:

  • code: Unit test churchwiseai-web/src/test/unit/outreach-funnel-stats.test.ts — simulate 100 sent + 4 unsub → triggered=true.
  • db: SELECT COUNT(*) FILTER (WHERE unsubscribed_at > now() - interval '24 hours') * 100.0 / COUNT(*) FILTER (WHERE sent_at > now() - interval '24 hours') AS unsub_rate FROM outreach_contacts → matches the API response.
  • dashboard: Load founder workspace — if triggered, banner visible with accurate percentage.

Failure signal: Kill-switch stuck on → founder can't send even after resolving root cause. Admin override endpoint needed (TODO).

5. What the recipient sees

Sample (from spec §244, with real values substituted):

From: John Moelker <john@pewsearch.com>
Subject: Greater Nazaree's listing on PewSearch

Hi Pastor Williams,

I noticed Greater Nazaree has been serving Franklin for over 40 years —
your Easter sunrise service on the riverfront came up in the Franklin
Observer's community events calendar.

We also noticed your listing on PewSearch doesn't link to a website.
That's not unusual — lots of faithful churches are focused on people,
not pixels. But we built something that might help the people who
Google you before they ever visit.

It's called the AI Starter Kit — a PDF playbook + 12 prompts for
pastoral use. Normally $4.95 on our site. Here's a free copy for
Greater Nazaree: https://pewsearch.com/starter-kit/abc123-xyz456

(If you'd like a simple website that shows up when people search
"church near me" in Franklin, we have a $19.95/mo option — the kit
page has the details.)

Thanks for what you do.

— John
Founder, ChurchWiseAI + PewSearch

---
ChurchWiseAI LTD · 125 Concession Street, Ingersoll, ON N5C 1G2
You're receiving this because Greater Nazaree is listed at
pewsearch.com/churches/greater-nazaree-franklin-tn.
Don't want these emails? Unsubscribe in one click:
https://pewsearch.com/outreach/unsubscribe/abc123-xyz456

6. Compliance & unsubscribe

  • Regime: Both CAN-SPAM (US recipients) and CASL (Canadian sender). Stricter wins per message.
  • Implied consent basis (CASL): Pastor's email is "conspicuously published" on Google Business profile for the pastoral role; topic is directly relevant. Valid 24 months.
  • Required elements (every email must have all 6):
    1. ✅ Sender name + from address accurate (John Moelker <john@pewsearch.com>)
    2. ✅ Relationship disclosure ("listed at pewsearch.com/churches/{slug}")
    3. ✅ One-click unsubscribe link with per-contact token
    4. ✅ Physical mailing address in footer (125 Concession Street, Ingersoll, ON N5C 1G2)
    5. ✅ Non-misleading subject line
    6. TODO: List-Unsubscribe and List-Unsubscribe-Post headers for one-click Gmail UI button
  • DNC gate: churches.email_do_not_contact checked in Step 4 (Gmail draft creation). Enforcement sites catalog in unsubscribe-and-dnc-gating.md §4.

7. Failure modes

FailureSignalAlert / handling
Draft sits scheduled > 48h (founder forgot to send)Daily audit cronTODO — not yet built
Gmail cron returns 200 but marks nothing sent when drafts existSilent no-opgap — add metric
Pastor clicks unsub after 30 days (token expired)404 on unsub pageHIGH — FIX THIS WEEK per unsubscribe-and-dnc-gating.md §9 gap #2
Starter-kit PDF link on landing page 404sFounder doesn't noticeAdd pre-campaign smoke test of PDF download
Pro Website CTA destination /pro-website or /claim/<slug> has broken links (same class as Hope Community lifecycle bug)Silent — pastor bailsRun e2e/email-link-audit.spec.ts pattern against these pages monthly
Kill-switch triggered → founder unawareBanner must be visibleDashboard loads render banner before any draft list
MX / SPF / DKIM misconfigured → all emails spam-folderedWeek 1 inbox-placement test catchesTest with test+flows@churchwiseai.com each morning
Gmail rate-limit hit429 from Gmail APICap at 20/day Week 1; bump only after clean Week 1
Stripe webhook on conversion fails to backfill outreach_contacts.converted_atAttribution reporting under-countsTODO — known gap

8. Verification manifest (summary)

flow: cold-outreach-no-website-pro-website
priority: P0
verifications:
- step: 1
verb: db
command: |
SELECT COUNT(*) FROM outreach_contacts
WHERE campaign_id = '<id>' AND starter_kit_token IS NULL;
expect: 0

- step: 2
verb: render
command: |
For each drafted row, extract hrefs from draft_body.
Assert one /starter-kit/ link, one /outreach/unsubscribe/ link,
physical address footer present.
status: TODO — extend e2e/email-link-audit.spec.ts

- step: 3
verb: code
command: |
pnpm -C churchwiseai-web playwright test
e2e/outreach-workspace-crud.spec.ts
status: TODO — write spec

- step: 4
verb: code
command: |
pnpm -C churchwiseai-web test -- outreach-gmail-route-contract
expect: All 4 contract tests pass

- step: 4
verb: delivered
command: |
gmail_list_drafts(query="label:outreach-2026-04 newer_than:1h")
expect: 1 draft per recent API call

- step: 5
verb: delivered
command: |
gmail_search_messages(q="from:me label:sent newer_than:24h
to:test+flows@churchwiseai.com subject:<subject>")
expect: 1 message

- step: 8
verb: click
ref: unsubscribe-and-dnc-gating.md#step-2

- step: 9
verb: db
command: |
SELECT status FROM outreach_contacts
WHERE starter_kit_token = '<token>';
expect-after-download: kit_downloaded

- step: 10
verb: click
command: |
After Pro Website CTA click on /starter-kit/<token>:
fetch /pro-website or /claim/<slug>, assert status 200,
assert no broken hrefs in response body (same pattern as
e2e/email-link-audit.spec.ts).
status: TODO

- step: 11
verb: db
command: |
After Stripe checkout success for outreach church:
SELECT converted_at FROM outreach_contacts
WHERE church_id = '<id>';
status: TODO — webhook backfill not yet wired

- step: 15
verb: code
command: |
pnpm -C churchwiseai-web test -- outreach-funnel-stats
expect: kill-switch triggered at 3% threshold

9. Open questions / known gaps (consolidated)

See each step's "Failure signal" and the TODO markers in verifications. Rolled up:

  1. [P1] Unsub after 30 days returns 404 — token-expiry check should not block unsub (see unsubscribe-and-dnc-gating.md §9 #2).
  2. [P1] Lifecycle cron doesn't check churches.email_do_not_contact — cross-property DNC gap.
  3. [P2] List-Unsubscribe headers not set on Gmail-API sends.
  4. [P2] Reply triage cron not yet impl — manual process in Week 1.
  5. [P2] Bounce handling not wired to update churches.email_do_not_contact.
  6. [P2] /api/cron/outreach-gmail-poll is a stub — full impl due Wed Apr 15.
  7. [P2] Daily audit of stuck scheduled drafts not built.
  8. [P2] Stripe webhook conversion path does not backfill outreach_contacts.converted_at.
  9. [P3] Pro Website CTA destination (/pro-website, /claim/<slug>) not yet link-audited.
  10. [P3] No admin override for kill-switch.

10. Go-live gate

This flow is NOT launch-ready until:

  • e2e/outreach-link-audit.spec.ts exists and passes (Step 2 verification).
  • Unsub-after-expiry bug (gap #1) fixed.
  • Lifecycle cron DNC check (gap #2) added.
  • Founder has executed end-to-end verification against test+flows@churchwiseai.com and signed off with date + screenshot in knowledge/tests/artifacts/outreach/.

Flip Status to VERIFIED only when every checkbox above is green.