MailKite
Get your API key
All guides

Build a cold email workflow with JavaScript

Reviewed

Build and test the orchestration of a cold email workflow: recipient context, pacing, persistent checkpoints, and cancellation after replies, opt-outs, or bounces. Hosted MailKite is unsuitable as transport for unsolicited outreach. Its current terms prohibit unsolicited bulk or commercial email and mail to recipients who haven’t consented. This Node.js guide therefore runs a local state-machine simulation. No emails are sent, and there is no live-send flag.

# Download outreach.mjs and outreach.test.mjs into an empty working directory.
node outreach.mjs --state checkpoint.json
node outreach.mjs --state checkpoint.json
node --test outreach.test.mjs

Download the simulation and tests. Node.js 22 or newer is sufficient; the simulation uses only built-in modules. Repeating the command against the same checkpoint preserves the original attempts and stops. A new checkpoint path starts a separate local experiment. The addresses and context are fictional fixtures, not a lead list.

Choose the transport boundary first

MailKite, which we build, provides inbound webhooks and consent-based sending. Its terms, section 4 are the deciding transport constraint for this guide. An API batch endpoint does not override that policy. Calling consent-based newsletter recipients “cold leads” would change the label without implementing the requested outreach task.

Dedicated outreach software is another category. Instantly’s developer documentation describes accounts, campaigns, leads, connected Google/Microsoft mailboxes, and webhook events. That makes it a candidate for a separately evaluated adapter, not a transport implemented by this demo. Confirm its current acceptable-use terms, mailbox-provider policies, and your intended recipient basis before connecting anything. API documentation alone is not permission to send.

The difference matters technically too. A provider might own both scheduling and stop-on-reply. If your application also owns those jobs, two independent schedulers can send the same follow-up. Decide which system owns execution, then use the other for observation or explicit control. Here, one local worker owns the entire simulation.

Import context and give attempts stable identities

The fixture creates four recipients with a name and a concrete conversation topic. initialState(rows) trims and lowercases email keys and collapses duplicate addresses into one contact. Each contact gets two jobs: an initial message and one follow-up. Job identity is campaign plus recipient plus step, such as wave1:ada@example.com:1.

import { initialState, tick, applyEvent } from './outreach.mjs';

const state = initialState();
const first = tick(state, 0); // simulated initial attempt for Ada
tick(state, 59);             // null: global pacing window is still closed
tick(state, 60);             // simulated initial attempt for Bo
applyEvent(state, {
  id: 'reply-1', email: 'ada@example.com', kind: 'reply',
});

The text is assembled from the stored name and context, never from an invented claim about the recipient. A useful import pipeline would validate syntax, keep source provenance, and record why the context is relevant. Don’t infer identity from display names, and don’t let a CSV import clear an opt-out. This demo accepts local fixtures; it does not scrape, enrich, or validate real prospects.

Pace the worker, not just each recipient

tick(state, now) uses virtual seconds. It permits one attempt per 60 seconds across the whole worker and places each follow-up 86,400 seconds after that recipient’s initial attempt. These numbers are test parameters, not recommended sending rates or evidence of mailbox safety. The CLI advances the virtual clock instantly; it doesn’t sleep for a day.

Before selecting work, the function checks the global next slot, due time, pending status, and recipient stop state. Only then does it append an attempt with status simulated, transition the job, and schedule the follow-up. A stop recorded before the next tick prevents that follow-up even when its due time has passed.

This explicit clock makes a one-day scenario reproducible in milliseconds. For a real worker, use a trusted server clock, provider limits, mailbox health, daily budgets, and a queue lease. Those mechanisms would belong to the eventual adapter and scheduler; they are not present in this local program.

Checkpoint and cancel only the affected recipient

The JSON checkpoint contains contacts, jobs, events, attempts, and the next pacing slot. After each action the CLI writes a temporary file with restrictive permissions and renames it over the checkpoint. Readers won’t see half-written JSON. It is a single-writer design: atomic rename does not provide multi-worker locking, a database transaction, or guaranteed persistence after sudden power loss.

Reply, opt-out, and bounce events use stable IDs. applyEvent rejects unknown recipients and event kinds, ignores duplicate event IDs, records the first stop reason, and cancels that recipient’s pending jobs. It never edits another recipient’s jobs. Stops survive reopening the checkpoint and are not reset by another run of the scenario.

The provided scenario records initial attempts for Ada, Bo, Cy, and Dev, then applies Ada’s reply, Bo’s opt-out, and Cy’s bounce. It repeats Ada’s event to exercise dedupe. At the later virtual tick, only Dev’s follow-up remains eligible. The expected local result is five simulated attempts, three canceled follow-ups, and three distinct events. Those counts describe this fixture only.

Authenticate replies before they reach the state machine

An outreach provider’s webhook should map authenticated provider events into the internal reply, opt-out, and bounce vocabulary. Instantly documents reply_received, lead_unsubscribed, and email_bounced, with workspace/campaign/lead fields. Its schema page is not a complete webhook authentication contract; don’t expose an endpoint that trusts those JSON fields without verifying the delivery mechanism and tenant ownership.

MailKite can be evaluated as an inbound receiving component where permitted, independently of the outbound transport. For that receiving path, verify the raw body with the SDK before parsing. This is an integration sketch, not a deployed endpoint:

import { MailKite } from 'mailkite';
import { applyEvent } from './outreach.mjs';

function receiveReply(rawBody, signature, secret, state, resolveReply) {
  if (!MailKite.verifyWebhook(signature, rawBody, secret)) {
    throw new Error('Unauthenticated webhook');
  }
  const event = JSON.parse(rawBody);
  if (event.type !== 'email.received' || event.auth?.dmarc !== 'pass') {
    return 'review';
  }
  // Application-owned lookup checks inbox, tenant, thread, and expected sender.
  const recipient = resolveReply(event);
  if (!recipient) return 'review';
  return applyEvent(state, { id: event.id, email: recipient, kind: 'reply' });
}

resolveReply must match your stored conversation, controlled destination inbox, and expected sender; never authorize a stop using an arbitrary address supplied in JSON. The webhook signature authenticates MailKite’s delivery, not the email author’s identity. A null DMARC verdict is not a pass. Use the stable inbound message id for dedupe and acknowledge only after the event and canceled jobs are durably saved. Invalid signatures fail closed; ambiguous identity goes to review.

What remains before a transport adapter

The tests prove pacing boundaries, per-recipient cancellation for all three stop conditions, event dedupe, and restart-safe local attempt dedupe. They do not prove exactly-once sending. Once a network transport exists, a crash between provider acceptance and local checkpoint creates an ambiguous outcome. Record an intent before dispatch, retain provider IDs, and reconcile ambiguous attempts before retrying. A custom MIME header is not evidence of server-side idempotency.

A deployable adapter also needs credential storage, mailbox connection, authenticated feedback, error classification, remote cancellation, and reconciliation after outages. None is implemented here. Preserve terminal opt-outs and bounces across future imports; route replies to a human rather than automatically restarting sequences after classification. Restrict checkpoint access because real context would contain personal data.

Sources retrieved 2026-10-14: MailKite terms, webhook security, receiving contract, Instantly API introduction, and Instantly webhook event schema. Local review included the shared email-received-event and verify-webhook-request schemas and the Node SDK’s positional verifier.

Continue at the cold email hub, Python outreach simulation, JavaScript newsletter, or Python newsletter. Discuss the demo’s execution boundary in community discussions.