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
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
- Import existing mail and lists to seed your first audience.
- Send transactional email for one-to-one messages.
- Browse the campaign tools an agent uses.