MailKite
Get your API key
All guides

Send bulk email with JavaScript and Node.js

Reviewed

Bulk email in JavaScript is a server worker that turns an audience into separate messages, then records what happened to each recipient. This guide builds that worker with MailKite (which we build): personalized SDK batches, a local checkpoint, selective retries, and a mock you can run without credentials. Accepted messages stay out of subsequent requests.

The sending operation in the complete Node download is:

response = await mk.sendBatch({
  from,
  subject: 'Your update, {{name}}',
  text: 'Hi {{name}}, your requested product update is ready.',
  recipients: chunk.map(r => ({ to: r.to, templateData: { name: r.name } })),
});

This is an excerpt from the worker, where chunk, from, and mk are already defined. In live mode mk is the official Node SDK; in mock mode it implements the same method locally. Each entry produces one private message to one address. A shared to array on a single message has different visibility and outcome semantics. BCC doesn’t give you a per-recipient result ledger either.

Run the whole worker first

Download node.mjs into a private local directory. Node 18 or newer is enough for the mock. No npm install or account is needed:

node node.mjs
node node.mjs
node node.mjs --retry-failed

The first invocation processes 53 synthetic recipients in chunks of 50 and 3. The mock deliberately accepts 51, temporarily refuses person1, and suppresses person2. The second invocation makes no new requests. The third submits only person1, which succeeds on its second attempt. Inspect node-mock.json: accepted rows retain one attempt, and the suppressed row remains failed.

These are labeled MOCK outcomes. They demonstrate worker behavior, not measured delivery or throughput. The example.com fixture is reachable only through the mock path. Live mode replaces it with one controlled MailKite test address.

Prepare the server-side SDK

The npm release checked on October 14, 2026 is 0.20.0. Install alongside the download:

npm install mailkite@0.20.0

The live branch in the file loads it only after you opt in:

const { MailKite } = await import('mailkite');
mk = new MailKite(process.env.MAILKITE_API_KEY);

Set MAILKITE_API_KEY and MAILKITE_FROM in the server environment or your secret manager. Sending requires a verified account and a sender on a verified domain; follow authentication and domain setup. Don’t place this key in a React bundle, Astro client script, or browser playground.

node node.mjs --live is an explicit real-send operation. It submits only test-bulk-guide@mailkite.dev, using node-live.json. That address is controlled by the MailKite project; it is not your mailbox and isn’t an inbox-preview service. To verify rendered mail yourself, adapt a reviewed copy for an address you control after understanding the checkpoint behavior.

Validate the audience before creating work

The fixture contains bare addresses and names. The worker rejects malformed addresses and line breaks in names, then deduplicates addresses case-insensitively. This is deliberately narrow validation, not a complete RFC mailbox parser. In an application, use your existing subscriber or account records, including their notification preference and suppression state, rather than importing an unreviewed list.

Personalization belongs in each entry’s templateData; the shared subject and text use {{name}}. For HTML, MailKite escapes merge values. Escaping doesn’t authorize a recipient, validate a URL, or make arbitrary user-written HTML safe. Preview templates with representative missing and long values before selecting a real audience.

The ledger also stores the job identity: mode, sender, recipient data, and a version. Reopening it with a different identity fails rather than borrowing another campaign’s successful rows. When changing the message, bump the job version and decide whether a new notification is actually intended. Don’t erase accepted history just to make the script run again.

Checkpoint before crossing the network

The crucial ordering in the download is:

for (const row of chunk) { row.state = 'inflight'; row.attempts++; }
ledger.requests.push(chunk.map(r => r.to));
save(); // Write intent BEFORE the SDK call. A restart holds these rows as unknown.

An inflight checkpoint means a call was intended, not that MailKite received it. A restart converts these rows to unknown. That covers both a process dying just before the request and a response arriving just before the process dies. The local filesystem cannot tell those cases apart.

Writing a temporary JSON file and renaming it reduces torn updates. It doesn’t atomically commit with the remote send, survive every power failure, or coordinate multiple workers. Run one process per ledger and keep its directory private. This is a recovery teaching example, not an exactly-once delivery system.

Validate outcomes, then retry selectively

The batch contract allows 1–50 entries and returns ordered results. A successful batch HTTP response can still contain failures. Before applying any result, the worker checks cardinality, recipient order, status, IDs for accepted/scheduled rows, and agreement between counts and rows.

Ledger stateMeaningNext action
pendingNo request intent recordedEligible for a new request
sentAPI reported the per-message send outcomeKeep ID; don’t resend
scheduledParked for later with an IDInspect/cancel that scheduled job
failedExplicit recipient-level refusalReview code and eligibility
unknownNo trustworthy complete outcomeReconcile before any resend

The demo’s only retryable code is invented: mock_transient. It is not a MailKite error name. Live failures remain held until you implement a reviewed policy. Suppressions aren’t temporary failures; retrying them defeats the user’s choice. Request exceptions, even whole-request HTTP errors, are conservatively unknown in this small worker.

Try node node.mjs --scenario timeout --ledger timeout.json. The first 50 become unknown and the invocation stops. A subsequent normal run can send the three untouched pending fixtures; it cannot retry the unknown 50. short, reordered, and badstatus exercise invalid responses. crash exits with status 75 immediately after saving request intent.

Add queue operations when the audience grows

Chunk size and concurrency are independent. Fifty is the endpoint limit, not a promised send rate. This example has one active request. A production Node worker should read bounded pages from storage, lease jobs transactionally, impose request deadlines, and apply capped backoff with jitter only to known retryable refusals. The inspected SDK doesn’t add automatic retries or a configurable deadline to this method.

Pausing means stop selecting new work. Interrupting a call can leave an unknown batch. For scheduled messages, store the ssnd_… ID and use the scheduling reference to inspect cancellation rather than enqueueing a replacement. For bounces and complaints, verify signed feedback before updating suppressions; see webhook security. API acceptance, provider delivery, and inbox placement are separate observations.

Choose the layer your application needs

Node optionUse it whenWhat your worker still owns
MailKite SDKPrivate personalized fan-out plus inbound repliesJob state, audience preferences, retry policy
Provider SDK, such as SESYour app already has a delivery/feedback pipelineProvider-specific batching and result mapping
Nodemailer over SMTPYour transport exposes SMTPSeparate envelope jobs and SMTP acceptance tracking

SMTP is a reasonable transport, but switching libraries doesn’t remove ambiguous acceptance after a disconnect. Review Nodemailer SMTP and the bulk API comparison before choosing your adapter.

Next, use the bulk email hub for architecture, programmatic JavaScript for event-driven reminders, or the JavaScript newsletter and cold-email workflow guides for their different audience lifecycles. Sources: Send API, batch request schema, response schema, and Node SDK source. The download README includes fault commands and shared tests; discuss adaptations in community discussions.