Newsletter campaigns from your terminal. Built for AI agents.
A single Rust binary that gives an AI agent (or a human at a terminal) a real mailing list to run. Campaigns, segments, A/B tests, click tracking, double opt-in, hard-bounce auto-suppression, one-click unsubscribe — all driven by JSON-emitting commands the agent can pick up without an MCP server, schema file, or browser dashboard.
mailing-list-cli is the orchestration layer. It owns campaigns, segments, templates, suppression, double opt-in, A/B testing, and analytics. It does not talk to Resend directly — every send, every audience operation, every webhook event flows through its sister tool email-cli, which is the sole Resend API client. Two binaries, one job each.
Think Beehiiv or MailChimp, except it lives at ~/.local/bin/mailing-list-cli and an agent uses it the same way you'd use git.
Why | Status | Planned Commands | Architecture | Sister Project | Research
AI agents can already send single emails. Running a mailing list is a different sport.
Sending one newsletter to fifty thousand people involves things one-off email tools never touch: deduplicating against a global suppression list, honoring unsubscribes within minutes, watching the soft-bounce counter, throttling the burst so the ESP doesn't suspend you, A/B testing two subject lines on a five-percent slice and promoting the winner, segmenting by tag and engagement, signing the one-click unsubscribe header per RFC 8058, and writing every send result back to local state so the next campaign knows who not to email.
The existing options for an agent are bad:
- MailChimp / Beehiiv / Klaviyo — browser-first. Their APIs exist but were designed for Zapier and websites, not for an agent shelling out forty times per second.
- Resend's own dashboard — fine for humans, but the Broadcasts API alone doesn't cover the full list-management surface (no bulk import, no programmatic suppression list, no double opt-in workflow, no A/B testing, no segments-by-engagement).
- MCP servers wrapping the above — a 32× context overhead per call versus the same operation as a CLI command, and the agent has to learn a new tool schema for every platform.
mailing-list-cli is the missing layer. It owns the campaign / segmentation / template / suppression / opt-in / A/B / analytics surface. For the actual SMTP-side work — sending, audience CRUD, webhook ingestion, Resend API authentication — it shells out to email-cli. An agent runs mailing-list-cli agent-info once, learns every command, and gets to work.
v0.4.5 — design-gate enforcement on top of v0.4.4.
template create --from-filenow refuses browser/React/JSX handoffs and lint-error sources by default. The verdict comes fromtemplate inspect, which used to be advisory only. Override with--forcefor deliberate incremental editing.
broadcast sendre-runs the same design check at preflight and refuses error-level findings (browser_or_jsx_source,browser_script_dependency) before a single email-cli call. Override with--allow-design-errorsor set[guards].block_design_errors = falseinconfig.toml.The JSX heuristic now catches modern frameworks without an explicit React import (Next 13+, Vite,
export default function,<Capitalizedcomponent tags) so the gate fires on the handoffs people are actually shipping in 2026, not justimport React from 'react'.Everything else from v0.4.4 still applies:
--confirm-gated sends, resumable batch chunks of 100, RFC 8058 one-click unsubscribe headers, body unsubscribe links opt out of UTM rewriting, plain-text alternatives preserve anchor URLs asLabel (URL), integratedevent polltracking, bundled agent skill viaskill install, and the explicit email design rules inagent-infoand the embedded skill.
Synthesized from the research swarm. Directional, not final — every entry below is grounded in a feature real list operators rely on day-to-day.
| Command | What it does |
|---|---|
list create <name> |
Create a list (Resend audience) |
list ls |
Show all lists with subscriber counts |
contact add <email> --list <id> |
Add a contact |
contact import <file.csv> --list <id> |
Bulk import with rate-limit-aware chunking |
contact tag <email> <tag> |
Tag a contact |
contact ls --filter <expr> |
Filter contacts by tag, list, status, engagement |
contact erase <email> |
GDPR hard-delete (PII removed, suppression entry retained) |
| Command | What it does |
|---|---|
segment create <name> --filter-json <json> |
Save a dynamic segment from a JSON AST filter |
segment ls |
All segments with live member counts |
segment members <id> |
List currently-matching contacts |
Filter expressions are a JSON AST (v0.2 dropped the string DSL — agents emit JSON directly). Example: {"kind":"and","children":[{"kind":"atom","atom":{"type":"tag","pred":{"kind":"has","name":"vip"}}},{"kind":"atom","atom":{"type":"engagement","atom":{"kind":"opened_last","duration":{"value":30,"unit":"days"}}}}]}. See src/segment/ast.rs for the full shape. Segments re-evaluate at send time.
| Command | What it does |
|---|---|
template create <name> --subject "..." [--from-file <path>] [--force] |
Create a plain-HTML template (or scaffold). --from-file enforces the design + lint gate; --force overrides for deliberate non-final imports |
template ls |
List local templates |
template show <name> |
Print the raw HTML source |
template render <name> --with-data <file> |
Render to a JSON envelope; sendable HTML is in .data.html |
template preview <name> --with-data <file> [--out-dir <path>] [--open] |
Write preview to disk and optionally open in the browser |
template inspect <name> / template inspect --from-file <path> |
Classify stored templates or design handoff files as email-ready, lint-fixable, or browser/React prototypes that need conversion |
template lint <name> |
6-rule compliance check (CAN-SPAM + size + XSS allowlist + forbidden tags) |
Templates are plain HTML with {{ var }} merge tags and {{#if }} conditionals. Triple-brace {{{ name }}} is an allowlisted XSS-safe escape hatch, reserved for unsubscribe_link and physical_address_footer only. The send pipeline hard-fails on any unresolved placeholder before a single email goes out.
template render is for machine inspection and always prints the full CLI JSON envelope. Do not pass its whole stdout to email-cli --html; use template preview for rendered files, broadcast preview for test emails, or extract jq -r '.data.html' after checking lint_errors == 0.
Rendered plain-text alternatives preserve links as Label (URL). Generated unsubscribe anchors include data-utm="off" so the compliance link in the body is not rewritten with tracking parameters, while normal CTA links still receive campaign UTM tags.
template lint warns on fragile semantic layout tags such as <main> and on
unstyled text links, because email clients may collapse browser-style layout
and fall back to default blue/purple hyperlinks.
For designer handoffs and browser prototypes, run template inspect --from-file <path> before importing. It detects React/JSX/Babel/script dependencies,
external CSS, style blocks, flex/grid layout, missing table structure, and
missing compliance placeholders. A browser_prototype_needs_conversion verdict
means the file is design direction only; convert it into standalone static
email HTML before template create or any broadcast send.
v0.4.5 enforces the same check at the import boundary and at the send
boundary. template create --from-file refuses imports whose verdict is
browser_prototype_needs_conversion or whose lint reports any errors
(error codes template_create_design_blocked / template_create_lint_blocked,
override with --force). broadcast send re-runs the design scanner at
preflight and refuses error-level findings (error code
template_has_design_errors, override with --allow-design-errors). The two
override flags exist because capable agents may have a deliberate reason to
land a half-finished template or to ship something that the heuristic misclassifies;
they are not for routine use.
| Command | What it does |
|---|---|
broadcast create --template <name> --to <segment> |
Stage a broadcast |
broadcast preview <id> --to <email> |
Send a single test |
broadcast schedule <id> --at <time> |
Schedule for later |
broadcast send <id> --dry-run [--allow-design-errors] |
Project recipient counts and preflight checks without sending |
broadcast send <id> --confirm [--force-unlock] [--allow-design-errors] |
Send now, after explicit approval |
broadcast cancel <id> |
Cancel a scheduled broadcast |
broadcast ab <id> --vary subject --variants 2 --winner-by opens |
Configure A/B test |
broadcast ls |
Recent broadcasts and their statuses |
Large broadcasts are sent in chunks of 100 through email-cli batch send.
Each chunk is recorded in broadcast_send_attempt before the ESP call and
applied after acknowledgement, so resume skips already-sent recipients instead
of repeating them. To test a 1,000-recipient slice, target a list or segment
with those 1,000 recipients, run broadcast send <id> --dry-run, then send
that separate test broadcast with --confirm.
| Command | What it does |
|---|---|
report show <broadcast-id> |
Opens, clicks, bounces, unsubscribes, complaints, CTR |
report links <broadcast-id> |
Click count per link |
report engagement --segment <id> |
Engagement scores across a segment |
report deliverability |
Domain health: bounce rate, complaint rate, DMARC pass rate |
Click counting is integrated through event poll. Per-link CTA reporting is
recorded when the upstream email-cli email list row includes click.link or
link; if the upstream row only exposes last_event=clicked, the aggregate
clicked_count updates but report links cannot infer the clicked URL.
Tracking is a local mirror, not a direct Resend API call from this binary:
mailing-list-cli webhook poll(alias:event poll) asksemail-cli email listfor recent email rows.email-cliis the only tool that talks to Resend. It returns each email id pluslast_eventand, when available, click payloads such asclick.link.mailing-list-climatches the returned Resend email id tobroadcast_recipient.resend_email_id, writes an idempotent row to the localeventtable, stores CTA link rows inclickwhen the URL is present, and updates the broadcast counters.- Agents read the mirror with
report show <broadcast-id>,report links <broadcast-id>,report engagement, andreport deliverability.
| Command | What it does |
|---|---|
optin start <email> --list <id> |
Send a double opt-in confirmation |
optin verify <token> |
Confirm an opt-in |
unsubscribe <email> |
Honor an unsubscribe (writes to global suppression) |
suppression ls |
View the global suppression list |
suppression import <file> |
Import suppressions from another platform |
dnscheck <domain> |
Verify SPF / DKIM / DMARC alignment before first send |
| Command | What it does |
|---|---|
webhook poll / event poll |
Poll email-cli email list for new delivery/bounce/click events and mirror them locally |
v0.2 dropped the long-running HTTP listener (tiny_http + Svix HMAC verifier) — running an inbound HTTP server behind NAT is hostile to a local CLI. Polling via email-cli email list covers the same use case without the tunneling requirement.
| Command | What it does |
|---|---|
agent-info |
Self-describing JSON manifest of every command, flag, and exit code |
skill install |
Drop the embedded skill file into Claude / Codex / Gemini paths |
skill status |
Show whether installed skill copies match the binary |
update |
Self-update from GitHub Releases |
Release automation is documented in docs/release.md. This
is a Rust binary: cargo and Homebrew are the supported package channels; there
are no uv or bun artifacts.
Three layers, each replaceable.
┌──────────────────────────────────────────┐
│ Your Agent / You │
│ (Claude, Codex, Gemini) │
└────────────────┬─────────────────────────┘
│ CLI commands, JSON in/out
▼
┌──────────────────────────────────────────┐
│ mailing-list-cli │
│ campaigns · segments · A/B · opt-in │
│ suppression · analytics · templates │
└────────────┬─────────────────┬───────────┘
│ │
│ shells out │ reads/writes
│ for sending │ local state
▼ ▼
┌──────────────────┐ ┌────────────┐
│ email-cli │ │ SQLite │
│ • Resend API │ │ templates │
│ • send / batch │ │ campaigns │
│ • audiences │ │ suppression│
│ • contacts │ │ events │
│ • events / hooks │ │ optin tok. │
└─────────┬────────┘ └────────────┘
│
▼
┌──────────┐
│ Resend │
└──────────┘
mailing-list-cliis the orchestration layer. It composes campaigns, computes segments, renders templates, enforces suppression, runs A/B tests, and aggregates analytics. It has zero Resend code.email-cliis the transport layer. It is the only binary that talks to Resend's API.mailing-list-clishells out to it for every send, every audience operation, and every event read.- Local SQLite stores the things
email-clidoesn't track: templates, campaign metadata, the suppression list, double opt-in tokens, segment definitions, engagement aggregates, and a mirror of recent events polled fromemail-cli. - Plain HTML + hand-rolled
{{ var }}substitution for templates. v0.2 dropped MJML, Handlebars, css-inline, html2text, and the YAML frontmatter variable schema — all designed-for-humans safety nets that the agent-loop preview renders unnecessary. Merge tags are Mustache-style{{ first_name }}(HTML-escaped) with a hard-coded triple-brace allowlist for{{{ unsubscribe_link }}}and{{{ physical_address_footer }}}. The compile pipeline is ~500 lines of Rust acrosssrc/template/{subst,render}.rswith 14 runtime crate dependencies total.
Built following the agent-cli-framework patterns: structured JSON output (auto-detected via IsTerminal), semantic exit codes (0/1/2/3/4), self-describing agent-info, no interactive prompts, ever.
email-cli — the 1:1 messaging counterpart. Send, reply, draft, sync. Same conventions, same agent-friendly philosophy. Use both: email-cli for personal correspondence, mailing-list-cli for newsletters and campaigns.
Five research dossiers ground the design. Read them in /research:
- Modern creator newsletters — Beehiiv, Buttondown, Substack
- Marketing platforms — MailChimp, MailerLite, Kit
- Resend native capabilities — what's already there vs the gap to fill
- Deliverability and compliance — the non-negotiables for safe scale
- Email templates for agents — format choice, merge syntax, authoring guidelines
The spec isn't written yet. If you want to shape it, open a discussion or comment on the research files. Once the binary lands, contributions to commands, tests, and docs are welcome.
MIT — see LICENSE.