Outreach Send Preflight Gate
Verified: 2026-06-11
Summary
Every cold-outreach BATCH send must pass the mandatory preflight gate before a single
email goes out. src/lib/outreach/campaign-preflight.ts (runBatchPreflight) is wired
into BOTH batch-send routes — /api/founder/drafts/batch-send-approved and
/api/founder/send-batch — as a HARD BLOCK ahead of safeSendBatch; if pass is false
the route refuses the whole batch and surfaces blockers. A CLI twin,
scripts/campaign-preflight.mjs, runs the same gate from the terminal. Shipped PR #788
(2026-06-06); hardened by PRs #834, #835, #837, #839 (2026-06-09). Origin incident: a
batch reached the approve list with 30 duplicate demos + 2 bounced addresses.
Fuller standard — do not duplicate: churchwiseai-web/docs/COLD_EMAIL_SEND_STANDARD.md
is the complete send standard (throttle, env, signature, CASL footer). This doc is the
knowledge-base pointer + the invariants agents must never regress.
Absolute policy — founder presses send
- The founder personally previews/blesses every batch and presses send himself (CASL). Agents may stage, verify, and preflight — never send.
- NO cron may ever send a campaign batch (
feedback_never_cron_send_campaigns, P0 since the 2026-05-15 incident). Any new send path must be manual-trigger + founder-auth.
Single-source email rendering
renderChurchProspectEmailHtml()(src/lib/email.ts) feeds BOTH the preview UI and the actual send. NEVER re-inline a second template — preview-vs-send drift is how wrong emails ship. Local-business twin:renderBusinessProspectEmailHtml().- Golden email-render check: preflight renders each draft and asserts
GOLDEN_REQUIRED/GOLDEN_FORBIDDEN(+BUSINESS_GOLDEN_*) markers fromsrc/lib/outreach/business-golden.ts— e.g. forbidden stale church pricing ($49.95/$54.95) in business sends. Add new forbidden strings there when a copy bug is found, in the same PR as the fix.
What the gate checks (two layers)
- Per-row
checkGates(safe-send.ts) — row-level safety incl. the already-sent guard (PR #835: skip any row withsent_atunless an intentional resend), unsub, bounce, do-not-contact. - Batch
runBatchPreflight(campaign-preflight.ts) — batch-level problems per-row gates can't see: in-batch duplicates, NeverBounce coverage, cross-campaign already-sent, existing customers, generic/unpersonalized rows, golden-render markers. Read-only; performs no writes.
The campaign_members/leads model (local business) has a mirror gate:
campaign-members-preflight.ts, enforced inside /api/founder/campaigns/[id]/send.
Send-ready accounting (don't over-count "ready")
Full gate for counting genuinely send-ready church prospects:
checkGatespasses AND preflight passes, including:nb_result IN ('valid','catchall','unknown'),chatbot_validated, demo token present, plan set, email variant set, real prospect name (not generic);- AND exclude rows with
status='skipped', askip_reason, orsent_at. - Skipped rows have null
nb_resultby design — never count them as "needs NB verify" (re-verifying them is a no-op that wastes credits). True count on 2026-06-09 was 214. Seememory/project_outreach_send_ready_accounting.
PR #834 added the client-safe outreachProspectSendability() predicate
(src/lib/outreach-shared.ts) mirroring the server gate, wired into
OutreachEngineView / SendBatchModal / DraftCard, so the outreach-engine UI
"N ready" matches what the server will actually send (the premium_churches.plan
join stays server-only). PR #839 made it SermonWise-aware
(source='sermonwise_v1' rows are always demo_prep_status='not_run').
Gotchas
- Preflight is a strict-superset BATCH BLOCKER: one bad row rejects the whole batch.
Move un-sendable rows OUT of the drafted set (
status='skipped'+skip_reason) before send — the per-row gate never gets a chance to drop them. - Mid-send hard bounces flip
bounced_at/DNC, staling the modal's loaded cohort → preflight then refuses the batch; refresh and re-fire. - PR #837:
/api/founder/send-batchhasmaxDuration=600+ 520s wall budget withpartial/remainingProspectIdscontinuation — one press finishes a batch.