Outbound email

Personalised, multi-step email sequences to the leads you supply. Delivery goes out over plain SMTP from pooled sender mailboxes (nodemailer under the hood); CogniLead owns the warmed sender pools, personalisation, the per-step cadence, and the outbound hygiene.

§0210 min read
On this page

The outbound email plane sends personalised, multi-step sequences to the leads you supply via POST /api/v1/leads. The transport itself is intentionally thin: each step is sent from a mailbox in the sender pool, through that mailbox's own SMTP transport (a contracted mailbox provider or mail servers CogniLead operates) or, for mailboxes without one, the process-wide SMTP relay (Amazon SES in production). Any RFC 5321 SMTP server is a drop-in transport. CogniLead owns everything around that thin transport: the warmed sender-domain pool, personalisation (via phi-cloud), the per-step cadence, the daily volume caps, and the gates that re-run before every send.

Cadence and dispatch

A campaign is a list of steps, each with a delay_days. dispatch() (lib/pipeline/stages/dispatch.ts) writes one sends row per step up front. Step 0 with delay_days=0 is dispatched inline against the SMTP transport immediately; every other step — including a later step whose own delay_days happens to be 0 — is persisted with scheduled_for set to now() + delay_days days and picked up later by the scheduler or by dispatchStep() (lib/pipeline/stages/dispatch-step.ts), the same function whether the caller is the in-process scheduler or the HTTP entry point at POST /api/v1/resend/step.

Before any step 1+ actually goes out, dispatchStep() re-runs every gate that could have changed since the row was queued, in order: (1) suppression — has this address unsubscribed, hard-bounced, or complained since the previous step; (2) reply — has any earlier step on this lead already got a reply (replied_at set), in which case every future step is skipped as replied_already so a human conversation is never talked over by an automated follow-up; (3) billing quota — checkSendQuota() re-checked fresh (a monthly period may have rolled over, or a burst earlier in the period may have capped it); (4) the per-domain daily send cap (see below). A send whose sent_at or skipped_at is already set short-circuits immediately — calling dispatchStep() twice on the same row is safe.

Per-domain daily send cap

Each sender_domains row carries a daily_cap. Before dispatching a step 1+, the code counts how many sends that domain has already made since the start of the current UTC day (repo().sends.countSentByDomainSince); at or above the cap, the step is NOT dropped — it is deferred to the next UTC day's start (scheduled_for pushed forward, skipped_reason="daily_cap_deferred") so the follow-up still goes out, just inside the next day's allowance. A daily_cap of 0 or below is treated as unbounded (used for demo/dev pools).

Sender-domain pool and jurisdiction routing

createSenderPool() (lib/pipeline/adapters/resend.ts) picks a sender domain by the recipient's jurisdiction: a bare "CH" jurisdiction routes to CH-region domains, the EU country-code set (DE, FR, IT, ES, NL, BE, LU, AT, IE, PT, DK, SE, FI, GB/UK) routes to EU-region domains, "US" routes to US-region domains, and anything else falls back to a WORLD-region pool. Within the matching region, the domain with the highest warmth_days is preferred. In production the pool is DB-backed (createDbSenderPool()): it reads sender_domains filtered to status IN (warming, active) AND paused_at IS NULL, and self-refreshes every SENDER_POOL_TTL_MS (default 30s) in the background so a reputation-breaker pause (§4) takes effect on a long-lived process without a restart.

Personalisation

Drafts are generated per lead per step via personalize() (lib/pipeline/stages/personalize.ts), grounded in the lead data plus the free-text insight you supply. The first-touch prompt requires sentence 1 to cite a concrete, checkable fact from that insight — if none exists, the model must set technical_hook_verified: false rather than invent one. Every follow-up (step index > 0) uses a different system prompt that is explicitly told the earlier drafts and instructed to advance the thread with something new rather than restate it; forbidden phrasing includes "just checking in", "bumping this", and "circling back". Follow-ups are capped at two sentences and 400 characters (vs. three sentences / 600 characters for the first touch), and the subject line is pinned to the first step's subject so mail clients thread the conversation correctly — the model's own subject_line output for a follow-up is discarded and replaced.

Output is strict JSON: { subject_line, email_body_markdown, technical_hook_verified, cited_source_url }. Both fields are truncated server-side if the model runs over budget, and personalize() throws if the response is missing the required string/boolean fields — a malformed draft never reaches dispatch.

Threading, unsubscribe, and headers

Every send carries a Message-ID of the form <uuid>@<sender_domain>, generated at step-0 dispatch and re-used verbatim on every later step of the same lead so replies thread correctly. The SMTP adapter signs an HMAC unsubscribe token per recipient (signUnsubscribeToken()) and attaches both an HTTPS unsubscribe link and a mailto: fallback via List-Unsubscribe, plus List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058) so a compliant mail client can unsubscribe with a single click and no page load.

Reply detection and bounce tracking

SMTP itself has no delivery push-notification. Relay sends report through Amazon SES → SNS → POST /api/webhooks/ses; sends through CogniLead-operated mail servers report through POST /api/webhooks/mta. Both correlate back to the send row by its Message-ID and write delivered_at / bounced_at. If that SNS wiring is not configured, delivery stays fire-and-forget — a send is stamped sent_at but never delivered_at or bounced_at, and operators should watch their SMTP provider's own dashboard for hard bounces.

Metering

Every successful dispatch calls recordSendUsage() immediately after the send row is stamped, writing a local, audit-grade usage_records row before anything is reported to Stripe — the local record is the source of truth; Stripe metering is a later, best-effort reporting step on top of it.

Outbound email — CogniLead docs