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, live demo-link probe (soft-404-aware by<title>), and the cold NeverBounce hard gate (below). - 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 golden render builds its URLs with the REAL per-property builders (buildUnsubscribeUrl/outreachHostFor, 2026-07-03) so a wrong-host link fails the gate;sermonwise_v1rows render the SermonWise template againstSERMONWISE_GOLDEN_*(they previously mis-rendered as church emails).
The campaign_members/leads model (local business) has a mirror gate:
campaign-members-preflight.ts, enforced inside /api/founder/campaigns/[id]/send.
Since 2026-07-03 it also live-probes every stored demo URL on its real branded host
(dead link → member auto-excluded, not a batch blocker; per-row re-probe at send in
send-members.ts is the backstop) and probes each property host's
/api/outreach/unsubscribe/ route with a synthetic token (404 → batch blocker, CASL).
Cold NeverBounce hard gate (2026-07-03 — founder rule)
Cold (first-touch) sends require nb_result = 'valid' verified within 30 days
(coldNbSkipReason in business-golden.ts), enforced in all three lanes:
church/SermonWise (checkGates), local-business (memberSendSkipReason +
members-preflight auto-exclusion), and mirrored in the GCC board predicates
(isCheapSendable, partitionLeadAvailability) so "N ready" equals what sends.
catchall/unknown/never-checked are BLOCKED — in the send data they bounced
10-44% and drove the ~10-11% bounce rate that spam-foldered the portfolio
(AUDIT_cold_email_outreach_strategy_2026-07-03.md). FOLLOW-UP touches keep the
legacy valid/catchall/unknown set — a bounce-free T1 already proved the address.
The purge cron (/api/cron/outreach-cleanup) also refuses to delete a demo whose
linked outreach touch is younger than 60 days (the 2026-06-27 dead-links class).
Send-ready accounting (don't over-count "ready")
Full gate for counting genuinely send-ready church prospects:
checkGatespasses AND preflight passes, including:nb_result = 'valid'verified within 30 days (cold sends — 2026-07-03 hard gate),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.