Skip to content

Send a Newsletter

Build an audience, author a campaign, review and approve it, then fan out an idempotent newsletter with one-click unsubscribe, all on your own Cloudflare account.

A campaign is one-to-many email: authored, reviewed, then fanned out to an audience. This is the highest-blast-radius part of the stack, so it is built around two locks (content review + a send capability) and an idempotent ledger that cannot double-send.

Build an audience

An audience is a mailing list. You populate it two ways.

Import an existing list (Mailchimp, a CSV, or contacts from your old tool). See Import existing mail and lists.

Capture leads through a hosted form. The public path is anonymous, browser safe, and double-opt-in: a captured lead is pending until it confirms.

import { captureLead } from "tinbox/sdk";

await captureLead({
  baseUrl: "https://acme.dev/api/send",
  formSlug: "newsletter",
  email: "ada@example.com",
  mergeFields: { FNAME: "Ada" },
});

From a trusted backend you can subscribe a contact directly with an api:send key; a globally-suppressed address is surfaced, never subscribed.

await tinbox.audiences.addContact({
  audienceId: "aud_…",
  email: "ada@example.com",
  status: "subscribed",
  mergeFields: { FNAME: "Ada" },
});

List your audiences and their member counts with tinbox audiences list; inspect membership with tinbox audiences members <audienceId>.

Author and approve a campaign

A campaign moves through a state machine: draft → in_review → approved, and only then can it dispatch. Approval is the content lock: the state machine rejects a send from draft or in_review. An agent may draft and update a campaign freely (it is just data and a PR), but enacting the irreversible fan-out is held behind that human gate by default.

List campaigns and their per-status recipient counts:

tinbox campaigns list

Inspect one campaign, its ledger progress, and its analytics:

tinbox campaigns status camp_…

This combines the row, the recipient-ledger counts (total / sent / pending / failed / suppressed), and the resolved recipient count. See tinbox campaigns status.

Send a test first

Before fan-out, send a single safe preview to one verified address. It never touches the recipient ledger.

tinbox campaigns test camp_… --to you@acme.dev

See tinbox campaigns test.

Fan out the campaign

Dispatch requires both locks: the campaign must be approved (content) and the calling token must carry the newsletter:send capability on a write scope (authority). Without the capability the API returns FORBIDDEN and the CLI exits non-zero with a clear authz error. The CLI gets no special powers.

tinbox campaigns send camp_…

The fan-out is ledgered and idempotent: it keys one row per (campaign, recipient), enqueues to Cloudflare Queues, and checks the ledger before each send. A retry, a double-merge, or a re-run only enqueues un-sent rows, so it cannot double-send the list. The response reports enqueued plus resolved / inserted / alreadySent / suppressed counts. See tinbox campaigns send and the send_campaign tool.

Scheduling

A campaign draft carries a send_at. Authored in outbox/newsletters/<slug>.md, its frontmatter sets subject, the recipient segment (a query) or an explicit list, and send_at. Merge to main fires a webhook to a worker (not the CI runner) that resolves recipients, writes the ledger, and enqueues at the scheduled time.

Unsubscribe and open tracking

Every campaign honors List-Unsubscribe one-click immediately: an unsubscribe writes straight to the suppression list, so the address is never contacted again. Open tracking is opt-in per campaign. A 1x1 pixel logs an opened event; links are rewritten through a redirect that logs clicked. Treat opens as a soft signal: Apple Mail Privacy Protection pre-fetches pixels and inflates the number. You can disable tracking per campaign.

Next steps