Send newsletters with JavaScript and Node.js
Reviewed
Build a newsletter worker that imports confirmed subscribers, selects a list, previews a broadcast, and exercises scheduling and cancellation. This guide is for developers adding product newsletters to their own application. MailKite, which we build, supplies the campaign send path and unsubscribe handling. The downloadable Node.js demo defaults to a local mock: it creates no accounts and sends no email.
npm install mailkite@0.20.0
# Download newsletter.mjs and newsletter.test.mjs into this directory first.
node newsletter.mjs
node newsletter.mjs --schedule-cancel
node --test newsletter.test.mjs
Download the worker and tests. Use Node.js 22 or newer. The mock overrides the SDK request seam while retaining the named list and broadcast methods. Its fixture has one eligible subscriber, a duplicate address, an opt-out, and an account-suppressed address. These are local placeholders, never live recipients.
Establish the account and management credential
Start with an existing verified MailKite account and an outbound-verified sender domain. Complete the DNS steps in Domains & DNS. Have a real postal address ready for the campaign footer and an inbox that receives replies. A draft can exist before sending prerequisites are satisfied; scheduling and sending run the server’s pre-send checks.
The shared spec labels lists, broadcasts, and suppressions Session token. The current server mounts contacts, lists, and broadcasts behind requireAuth and requireFullScope. Inspection of that middleware shows that it accepts a session/OAuth bearer token or an unrestricted account API key, while domain-scoped keys cannot perform these account-level operations. Use a separate management credential here rather than relying on that broader middleware behavior.
import { MailKite } from 'mailkite';
const management = new MailKite({
accessToken: process.env.MAILKITE_MANAGEMENT_TOKEN,
});
// A sending worker, if your app also has one, uses its own credential:
// const sending = new MailKite(process.env.MAILKITE_API_KEY);
For an account with password login, the runnable demo can acquire the session itself. Supply MAILKITE_ACCOUNT_EMAIL and MAILKITE_ACCOUNT_PASSWORD through your server’s secret environment; leave MAILKITE_MANAGEMENT_TOKEN unset. It calls the public login route through new MailKite('').request(...), checks user.emailVerified, and retains token in memory without printing it. It does not register an account.
For Google-only accounts or a multi-tenant integration, use the server-side OAuth flow: register a client with registerOauthClient, store a random state and S256 PKCE verifier in the initiating server session, navigate to the authorization URL, validate state on return, then call exchangeOauthToken. Store the access and refresh tokens server-side. Feed the current access token to MAILKITE_MANAGEMENT_TOKEN; use the SDK’s getToken callback when a long-running worker needs refresh. Neither credential belongs in browser code.
Import subscribers without inventing contact methods
Your application owns consent evidence: subscription purpose, confirmation time, source, and preference changes. An imported address is not evidence of permission. Run this workflow only after confirmation, and record a withdrawal even if an older import says the subscriber consented.
There is no upsertContact SDK method or contact-write request schema in the current shared spec. The actual server supports POST /api/contacts with email, name, and optional profile fields, returning a contact. It supports PATCH /api/contacts/:id with subscribed. These two operations use the SDK’s documented low-level escape hatch:
const contact = await management.request('POST', '/api/contacts', {
email: subscriber.email, name: subscriber.name,
});
if (contact.status === 'active') {
await management.request('PATCH',
`/api/contacts/${encodeURIComponent(contact.id)}`, { subscribed: true });
}
This is a server-verified extension, not a generated contact CRUD interface. Requests use camelCase where the handler requires it; contact responses expose fields such as first_name and integer subscribed. Never force status: 'active' during import: that handler also removes suppression for the address. The worker skips inactive contacts instead.
Before contact writes, listSuppressions() returns an object containing suppressions, not a bare array. The worker lowercases and deduplicates addresses, lets negative consent win, and excludes suppression entries. This prevents an import from silently undoing a previous unsubscribe or hard bounce.
After an authenticated subscriber preference change, record withdrawal in your app and use management.addSuppression({ email, reason: 'unsubscribe' }) for an account-wide stop. This also marks the contact unsubscribed and affects other outbound mail, not just this newsletter. For a single-list preference, remove membership with removeListContact(listId, contactId) instead. The tests apply an opt-out after draft creation and prove scheduling fails with no eligible audience; the mock mirrors the server’s empty-audience precheck.
Select the audience and preview the campaign
Create a list with createList({ name }), then call addListContacts(list.id, { contactIds }). Use returned contact IDs, not guessed prefixes. listListContacts(list.id) returns a bare array. The demo’s tiny list fits one page; a larger import must paginate with the SDK’s before and limit arguments.
The worker creates a broadcast with both HTML and plaintext, a reply address, and a list audience:
const draft = await management.createBroadcast({
from: process.env.MAILKITE_FROM,
replyTo: process.env.MAILKITE_REPLY_TO,
subject: 'Product notes for {{first_name}}',
text: 'Hello {{first_name}}, here are this week\'s product notes.',
html: '<p>Hello {{first_name}}, here are this week\'s product notes.</p>',
audience: { type: 'list', id: list.id },
footerAddress: process.env.MAILKITE_POSTAL_ADDRESS,
});
const preview = await management.getBroadcast(draft.id);
Inspect the returned draft content before approval. This is a data preview, not a rendered inbox preview; verify final HTML and merge behavior in the controlled live test. Keep subject and body changes together through updateBroadcast. MailKite inserts the unsubscribe footer and one-click headers on the broadcast send path. A manual batch loop would leave that campaign behavior to you.
Schedule, inspect, and cancel
The mock’s --schedule-cancel action calls sendBroadcast(id, { scheduledAt }) with an ISO timestamp one day ahead, verifies getBroadcast(id).status === 'scheduled', then cancels. Scheduling returns camelCase audienceCount; subsequent broadcast reads use scheduled_at, sent_count, and fail_count.
Cancellation exists in the server as POST /api/broadcasts/:id/cancel, but has no named shared-spec SDK method. The demo uses management.request('POST', path, {}). The handler accepts only a scheduled broadcast, resets it to draft, and clears its schedule. This route was checked in source, not exercised against production. If a deployment lacks it, keep the campaign as a draft until its contract is confirmed. Do not treat deletion as cancellation.
For a controlled live run, set the management credential plus MAILKITE_TEST_DOMAIN, MAILKITE_TEST_RECIPIENT, MAILKITE_FROM, MAILKITE_REPLY_TO, and MAILKITE_POSTAL_ADDRESS. The recipient must be a test-* inbox on your own receiving domain. --live creates only a draft; --live --send explicitly sends to that one inbox. --live --schedule-cancel creates a real schedule before canceling, so a crash can leave it pending: inspect the printed campaign ID or dashboard immediately. Never run that action unattended.
Outcomes, limits, and next steps
The local tests prove exclusion, dedupe, draft-only behavior, existing-unsubscribe preservation, and scheduled-to-draft cancellation. They also check the real SDK’s login serialization and management bearer header using a network-free fetch mock. They do not measure provider delivery or inbox placement.
Persist campaign IDs in your application before retries. A timed-out create or send is ambiguous; inspect state rather than creating a second campaign. These demos intentionally run one campaign at a time and leave lists and drafts for inspection. Remove test artifacts deliberately. For sent campaigns, inspect counters and the server recipient ledger; API acceptance is not confirmed delivery.
Choose listmonk when you want to operate a dedicated newsletter manager backed by PostgreSQL and separate delivery infrastructure. MailKite fits a developer-owned subscriber workflow; it doesn’t replace your consent database or editorial process. Its terms prohibit unsolicited bulk/commercial email.
Sources retrieved 2026-10-14: Node package, SDK source, authentication, server login, API reference, and listmonk documentation. Contract inspection included sdks/spec/api.json, contact/list/broadcast/suppression schemas, api/src/auth/middleware.ts, and contact/cancel handlers in api/src/index.ts.
Continue with the newsletter hub, Python newsletter, JavaScript outreach simulation, or Python outreach simulation. Share demo questions in community discussions.