@marianmeres/bulk-email
v0.5.0
Published
[](https://www.npmjs.com/package/@marianmeres/bulk-email) [](https://jsr.io/@marianmeres/bulk-email) [ HTML alternative, same variables
recipients.csv EMAIL,NAME,TITLE,WHEN,TOPIC
[email protected],Alice,,on Tuesday,your onboarding
[email protected],Bob,Dr.,yesterday,the pricing options
log.jsonl written by the toolEvery CSV column is a template variable (${NAME}, $NAME). EMAIL is
required and is the recipient. A variable used without a fallback is strict:
it must be a column, and must be non-empty for every row that is sent. Use
${TITLE:-Mx.} to make one optional. Full syntax: see
interpolate.
The .env is optional per campaign — process env always wins, and --env-file
points at a shared file when you don't want credentials next to templates. See
.env.example and the runnable examples/campaign.
CLI usage
# What would happen? (nothing is sent, no SMTP needed)
bulk-email status ./2026-09-outreach
bulk-email preview ./2026-09-outreach --to [email protected]
bulk-email send ./2026-09-outreach --dry-run
# Check the SMTP credentials without sending
bulk-email verify ./2026-09-outreach
# Send to yourself first, look at it in your inbox …
bulk-email send ./2026-09-outreach --only [email protected]
# … then send the rest. Already-sent recipients are skipped automatically.
bulk-email send ./2026-09-outreachsend prints the plan and asks for confirmation (-y skips it; a non-terminal
without -y refuses). While running, a status line shows progress and a line
per recipient is printed above it:
Campaign: /…/2026-09-outreach
Recipients: 12 — 3 sent, 8 pending, 1 data-error
Send 8 emails as "Your Name <[email protected]>" via smtp.example.com:587? [y/N] y
✓ 1/8 [email protected] Following up on our call about your onboarding (<[email protected]>)
✗ 2/8 [email protected] attempt 1: 451 4.7.0 Try again later
⠹ 0:14 · 1✓ 1✗ next: [email protected] waiting 9 sRe-run later: the failed one is retried (up to MAX_ATTEMPTS, default 3), the
sent ones are not touched. status shows where everyone stands:
| Status | Meaning | Next run |
| ------------ | --------------------------------------- | -------- |
| pending | never attempted | sends |
| retry | failed before, under the attempt limit | sends |
| sent | accepted by the SMTP server | skips |
| gave-up | failed MAX_ATTEMPTS times | skips |
| data-error | a strict variable is blank for this row | skips |
| unknown | interrupted mid-send; outcome unknown | skips |
To force anything, edit log.jsonl: delete a recipient's lines and they are
pending again. The ledger is plain JSON lines, one per event.
Exit codes: 0 ok, 1 runtime failure (including any failed send), 2
usage/config error. See API.md for every flag.
Environment
Read from <dir>/.env (or --env-file), overridden by the process env.
| Variable | Required | Notes |
| ------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SMTP_HOST | to send | SMTP server. Plus SMTP_PORT (587), SMTP_SECURE, SMTP_USER, SMTP_PASS, SMTP_SERVERNAME, SMTP_TLS_REJECT_UNAUTHORIZED, timeouts — exactly as in send-email. |
| SMTP_FROM | to send | Sender, e.g. Name <[email protected]>. |
| SMTP_REPLY_TO | no | Reply-To header. |
| BCC | no | Added to every message — e.g. yourself, for a copy. |
| DELAY_MS | no | Pause between sends, ± 20 % jitter. Default 10000. |
| MAX_ATTEMPTS | no | Failed attempts before giving up on a recipient. Default 3. |
| PREVENT_THREADING | no | Every message gets a unique References and X-Entity-Ref-ID header, so Gmail does not group same-subject messages from you into one conversation (your BCC copies, a test send to several of your own addresses). Default true; set false to let sends thread. |
Library usage
The Deno/JSR package exposes the directory helpers and the core; the npm package exposes the runtime-agnostic core only (bring your own file I/O).
import {
createLedgerAppender,
loadCampaign,
loadCampaignEnv,
loadLedger,
planCampaign,
resolveCampaignSettings,
runPlan,
} from "@marianmeres/bulk-email";
import { createNodemailerTransport, resolveSmtpOptions } from "@marianmeres/send-email";
const dir = "./2026-09-outreach";
const env = await loadCampaignEnv(dir);
const settings = resolveCampaignSettings(env);
const campaign = await loadCampaign(dir);
const plan = planCampaign(campaign, await loadLedger(dir), settings.maxAttempts);
console.log(plan.counts); // { pending: 8, sent: 3, … }
const summary = await runPlan(plan, campaign.templates, {
transport: createNodemailerTransport(resolveSmtpOptions(env)),
settings,
appendLedger: createLedgerAppender(dir),
onEvent: (e) => console.log(e.type, "email" in e ? e.email : ""),
});API
See API.md for the complete library and CLI reference.
