Newsletter
Collect subscribers and send broadcasts powered by Postmark
Newsletter
The newsletter module is a parallel app under /newsletter for managing email
subscribers and sending broadcasts. It shares the same user accounts as the
CMS but has its own tenant entity (a "Newsletter") with its own subscriber
list, sender identity, and Postmark wiring.
Each newsletter is wired to its own Postmark Server so deliverability, suppression lists, and webhook events stay isolated per publication.
What you get
- Embeddable subscribe form — public POST endpoint with CORS that you can call from any external website.
- Double opt-in — new subscribers receive a confirmation email and only start receiving broadcasts after clicking the link.
- Broadcasts — write an HTML body, a plain-text body, or both, preview,
send a test, then send to all confirmed subscribers via Postmark’s
/email/batchendpoint. Composable through the dashboard or the public API. - Postmark-managed unsubscribe — every broadcast gets the standard
unsubscribe link rendered by Postmark, with
List-Unsubscribe/List-Unsubscribe-Postheaders (RFC 8058) so Gmail and Outlook show the one-click unsubscribe button. We mirror Postmark’s suppression state via webhooks. - CSV import — bulk-add subscribers you already have consent for.
- Bulk subscriber API — token-authenticated endpoint to import up to 5000 subscribers per call, with per-row status, source, and arbitrary metadata.
- API tokens — programmatically create and send broadcasts, and import subscribers, from external services.
Setting up Postmark
You do this once per newsletter, in the Postmark dashboard.
1. Create a Server
In Postmark: Servers → Create Server. Name it after your newsletter.
2. Verify your sending domain
In Postmark: Sender Signatures → Domains → Add Domain. Add the DNS
records Postmark provides (DKIM as a TXT record, Return-Path as a CNAME).
Once verified, any address on the domain can be used as a From address from
any of your Servers.
3. Add a Broadcasts message stream
Inside your Server: Message Streams → Add Stream → Broadcasts. Give it
any ID you like (e.g. broadcasts) and copy that ID — you’ll paste it
into bunmori.
A Postmark Server has multiple streams. The default outbound stream is
Transactional and is used by bunmori for confirmation emails. The
Broadcast stream you just created is what bunmori uses for actual broadcasts
— it’s also the stream that auto-injects the unsubscribe link and adds
the RFC 8058 headers.
4. Copy your Server API token
In your Server: API Tokens → Server API token. Copy it.
5. Wire it up in bunmori
Go to your newsletter in bunmori → Settings:
- Sender identity — From name, From email (must be on your verified domain), optional reply-to.
- Postmark — paste the Server API token and the Broadcast stream ID.
- Webhook URL — copy the displayed URL into Postmark (next step).
The webhook URL is built from the APP_URL environment variable, so it will
be a proper HTTPS URL in production as long as APP_URL is set to your
deployed origin (e.g. APP_URL=https://newsletters.example.com). The same
env var drives the auth verification and password-reset email links.
6. Configure the webhook in Postmark
In your Server: Webhooks → Add Webhook. Paste the URL from bunmori, and enable these events on the Broadcast stream:
Subscription change— fires when someone unsubscribes via Postmark’s link or is reactivated.Bounce— fires on hard bounces.Spam complaint— fires when a recipient marks your email as spam.
Bunmori uses these to keep Subscriber.status in sync (UNSUBSCRIBED /
BOUNCED / COMPLAINED) so your next broadcast skips suppressed addresses.
The webhook URL contains a per-newsletter secret in the ?s= query string.
If it leaks, click Rotate in the settings page and update Postmark with
the new URL.
Subscriber states
| Status | Meaning |
|---|---|
PENDING | Subscribed but hasn’t clicked the confirmation link yet. |
CONFIRMED | Receives broadcasts. |
UNSUBSCRIBED | Clicked the Postmark unsubscribe link or was manually removed. |
BOUNCED | Hard-bounced; suppressed by Postmark. |
COMPLAINED | Marked the email as spam; suppressed by Postmark. |
Only CONFIRMED subscribers receive broadcasts.
CSV import
Upload a CSV with an email column (and optional name column). Imported
rows are marked CONFIRMED immediately — no double opt-in. Only do this for
people who have already given you consent.
Limits: 50,000 rows / 5 MB per file. Duplicates and invalid emails are skipped.
Composing broadcasts (dashboard)
- Subject + body — provide an HTML body, a plain-text body, or both. At
least one is required (this matches Postmark’s API:
HtmlBodyandTextBodyare individually optional but you must supply at least one). When you provide both, Postmark sends amultipart/alternativeemail so subscribers’ clients pick the version they prefer. - Save as
DRAFT. You can keep editing. - Send test — sends a single copy to whatever email you specify. Doesn’t affect subscribers or stats.
- Send broadcast — opens a dialog where you choose batch size and
interval. Defaults are 25 recipients per batch, 5 minutes between
batches (~300/hour). Click Send and the recipient list is frozen into a
queue; the first batch goes out within ~60 seconds and subsequent batches
follow at the chosen interval. Status transitions
DRAFT → SENDING → SENT(orSTOPPEDif you cancel, orFAILEDafter 3 consecutive tick errors — see Reliability below).
While SENDING, the broadcast page shows live progress (sent count, next
batch ETA) and a Stop button that cancels remaining recipients without
affecting already-sent batches. After FAILED, a Retry button reprocesses
only the PENDING and FAILED queue items — already-SENT items are sticky,
so retries can never produce duplicate deliveries.
Why batch? A new sending domain that suddenly emits hundreds of messages
gets throttled by Gmail (SMTP 4.7.28) and other inbox providers. Pacing
emails over time keeps deliverability healthy while the domain warms up.
Reliability
The send pipeline is fully resumable: state lives in Postgres (the
BroadcastQueueItem table), and an in-process scheduler ticks once per
minute to process the next due batch. If the container restarts mid-send, it
picks up where it left off without double-sending — SENT queue items are
never reprocessed. Catch-up is intentionally disabled: after a long outage
the broadcast resumes one batch at a time, never bursting.
If a tick fails (e.g. Postmark token revoked), the broadcast keeps retrying
at the configured interval; after 3 consecutive tick failures it escalates
to FAILED so you can fix the underlying issue and click Retry.
If a send or test fails, the toast surfaces the underlying Postmark error
(e.g. Postmark 422 (code 400): Sender Signature not found). The most
common culprits are an unverified From domain, a Broadcast stream ID that
doesn’t match the one you created in Postmark, or a Postmark Server API
token that belongs to a different Server.
Unsubscribe link. Postmark auto-injects an unsubscribe footer if your
HTML doesn’t include one — you don’t need to do anything. To
control its placement, embed the literal placeholder
{{{ pm:unsubscribe }}} (with triple braces) somewhere in your HTML and
Postmark will swap it for a per-recipient unsubscribe URL on delivery.
Roles
| Role | Can |
|---|---|
ADMIN | Everything: settings, members, tokens, send broadcasts, manage subscribers. |
EDITOR | Manage subscribers, compose and send broadcasts. |
VIEWER | Read-only. |
Public API
The newsletter module exposes two kinds of public endpoints:
- Subscribe endpoint — open with CORS, no token required. For embedding the subscribe form on your website.
- Broadcast / management endpoints — token-authenticated. For driving the newsletter from your CI, automations, or other backends.
API tokens
Tokens are scoped per newsletter. Create them in Tokens under your newsletter (ADMIN only).
| Scope | What it allows |
|---|---|
read | List and read broadcasts. |
write | Create, update, delete, and send broadcasts. Bulk-import subscribers. |
Tokens are prefixed bn_ (24 random bytes encoded base64url). Send them in
the Authorization header:
Authorization: Bearer bn_xxxxxxxxxxxxxxxxxxTreat tokens like passwords. If one leaks, delete it from the dashboard — the change takes effect immediately.
POST /api/public/newsletter/{slug}/subscribe
Open / CORS-enabled. No token. Adds a subscriber and triggers the double-opt-in confirmation email.
POST /api/public/newsletter/your-slug/subscribe
Content-Type: application/json
{
"email": "[email protected]",
"name": "Person", // optional
"source": "homepage" // optional; otherwise we record the Origin header
}Responses:
200 { "status": "confirmation_sent" }— confirmation email sent.200 { "status": "already_subscribed" }— email is already confirmed (or was previously unsubscribed; we don’t auto-resurrect).400— invalid email or body.429— rate-limited (10/IP/5min, 1/email/min, 5/email/hour).
POST /api/public/newsletter/{slug}/subscribers/bulk
Token: write. Bulk-add subscribers from a trusted source (CRM export,
migration from another platform). Skips existing rows on conflict — never
overwrites or revives them. Per-row errors are returned in the response so a
single bad email doesn’t fail the batch.
This endpoint is not for the double-opt-in flow — no confirmation emails
are sent. Use /subscribe for that. Only call this with addresses that have
already opted in to receive your emails.
POST /api/public/newsletter/your-slug/subscribers/bulk
Authorization: Bearer bn_xxxxx
Content-Type: application/json
{
"defaults": {
"status": "CONFIRMED",
"source": "crm-export-2026-04"
},
"subscribers": [
{
"email": "[email protected]",
"name": "Alice",
"metadata": { "role": "developer", "company": "Acme" }
},
{
"email": "[email protected]",
"status": "PENDING"
}
]
}Per-row fields — only email is required:
| Field | Notes |
|---|---|
email | Required. Lower-cased and trimmed server-side. |
name | Optional, max 100 chars. |
status | Optional, "CONFIRMED" or "PENDING". Per-row override of defaults.status. |
source | Optional, max 200 chars. Per-row override of defaults.source. |
metadata | Optional. Same shape as /subscribe: max 25 keys, scalar values, strings ≤1000 chars. |
Top-level fields:
| Field | Notes |
|---|---|
subscribers | Required. Array, 1–5000 rows per request. Chunk client-side for larger lists. |
defaults.status | Default status for rows that don’t set one. Defaults to "CONFIRMED". |
defaults.source | Default source for rows that don’t set one. Defaults to "api-bulk-import". |
Response (always 200 unless the request shape is malformed):
{
"received": 1000,
"imported": 947,
"skipped": 51,
"invalid": 2,
"errors": [
{ "index": 12, "email": "x@x", "reason": "invalid_email" },
{ "index": 84, "email": "[email protected]", "reason": "duplicate_in_batch" },
{ "index": 91, "email": "[email protected]", "reason": "already_exists_confirmed" },
{ "index": 134, "email": "[email protected]", "reason": "suppressed_unsubscribed" }
]
}received always equals the row count you sent. imported + skipped + invalid equals received.
Error reasons:
| Reason | Meaning |
|---|---|
invalid_email | Empty or doesn’t look like an email. |
duplicate_in_batch | Same email appeared earlier in this request — first wins. |
already_exists_confirmed | Row exists with CONFIRMED status — left untouched. |
already_exists_pending | Row exists with PENDING status — left untouched. |
suppressed_unsubscribed | Row exists, previously unsubscribed — not revived. |
suppressed_bounced | Row exists, hard-bounced — not revived. |
suppressed_complained | Row exists, marked spam — not revived. |
Error responses:
400— request shape is invalid (e.g.subscribersempty, exceeds 5000, malformed JSON, metadata violates limits).401/403— token missing, invalid, or lackswritescope.
GET /api/public/newsletter/{slug}/broadcasts
Token: read. List all broadcasts for the newsletter (DRAFT through
SENT/FAILED).
GET /api/public/newsletter/your-slug/broadcasts
Authorization: Bearer bn_xxxxxResponse: { "broadcasts": [<Broadcast>, ...] }.
POST /api/public/newsletter/{slug}/broadcasts
Token: write. Create a broadcast in DRAFT status. Doesn’t send
anything.
POST /api/public/newsletter/your-slug/broadcasts
Authorization: Bearer bn_xxxxx
Content-Type: application/json
{
"subject": "Weekly digest #42",
"bodyHtml": "<!DOCTYPE html><html>...</html>",
"bodyText": "Plain-text version of the email."
}bodyHtml and bodyText are individually optional, but at least one must
be a non-empty string. Sending neither returns 400. Sending only one is
fine — Postmark accepts HTML-only or text-only emails.
Response: 201 { "broadcast": <Broadcast> }.
GET /api/public/newsletter/{slug}/broadcasts/{id}
Token: read. Fetch one broadcast by ID.
PATCH /api/public/newsletter/{slug}/broadcasts/{id}
Token: write. Update a DRAFT broadcast. Body accepts any subset of
subject, bodyHtml, bodyText. Returns 400 if the broadcast is not in
DRAFT, or if the merged result would leave both bodies empty.
You can clear one of the bodies by sending an empty string (e.g.
"bodyHtml": ""), as long as the other body is still non-empty.
DELETE /api/public/newsletter/{slug}/broadcasts/{id}
Token: write. Delete a broadcast. Returns 400 if the broadcast is
currently SENDING.
POST /api/public/newsletter/{slug}/broadcasts/{id}/send
Token: write. Start sending the broadcast to all CONFIRMED
subscribers. Asynchronous as of 1.8.0 — the request returns
immediately after the recipient list is frozen into a queue; an in-process
scheduler then sends batches at the configured cadence.
Optional JSON body:
{
"batchSize": 25,
"batchIntervalMinutes": 5
}batchSize is 1..100, batchIntervalMinutes is 1..1440. Defaults are
25 and 5. Both fields are optional; an empty body uses defaults.
Response:
{
"broadcastId": "cm0...",
"totalRecipients": 1234,
"firstBatchEta": "2026-04-26T15:30:00Z",
"batchSize": 25,
"batchIntervalMinutes": 5
}firstBatchEta is when the scheduler will send the first batch (typically
within ~60s of the request). Per-recipient delivery status is not in this
response — query GET .../broadcasts/{id} afterwards to see live progress
(status, totalRecipients, failedCount, errorSummary) or watch the
broadcast detail page in the dashboard.
If the newsletter doesn’t have Postmark configured, returns 400 with
the message "Postmark is not configured for this newsletter". Other
common 400 responses include "Sender From email is not configured for this newsletter", "Broadcast has no body — add HTML or plain text first", and "Broadcast is not in DRAFT status — it may already be sending" (the latter is also the response if a duplicate /send arrives
while a send is in flight).
POST /api/public/newsletter/{slug}/broadcasts/{id}/retry
Token: write. Retry a FAILED broadcast. Resets only PENDING and
FAILED recipients back to PENDING and re-enters the queue; already-
SENT recipients are not touched, so retries are safe to call repeatedly
without producing duplicate deliveries. Returns 400 if the broadcast is
not in FAILED status, or if there is nothing to retry.
Response:
{ "retryableCount": 12 }POST /api/public/newsletter/{slug}/broadcasts/{id}/stop
Token: write. Stop a SENDING broadcast. Remaining PENDING
recipients are marked CANCELLED (not deleted, so the audit trail
survives) and the broadcast transitions to STOPPED. Already-sent batches
are not affected. Returns 400 if the broadcast is not in SENDING
status.
Response:
{ "cancelledCount": 87, "sentCount": 163 }Broadcast object
{
"id": "cm0...",
"subject": "Weekly digest #42",
"bodyHtml": "<!DOCTYPE html>...",
"bodyText": "...",
"status": "DRAFT",
"scheduledAt": null,
"sentAt": null,
"totalRecipients": 0,
"failedCount": 0,
"errorSummary": null,
"batchSize": 25,
"batchIntervalMinutes": 5,
"batchesSent": 0,
"nextBatchAt": null,
"lastBatchAt": null,
"createdAt": "2026-04-25T15:30:00Z",
"updatedAt": "2026-04-25T15:30:00Z"
}status is one of DRAFT | SENDING | SENT | FAILED | STOPPED. The
batching fields are populated when the broadcast leaves DRAFT.
Embedding the subscribe form
The /subscribe endpoint is open and CORS-enabled, so you can call it from
any website:
<form id="subscribe">
<input type="email" name="email" required placeholder="Email" />
<button type="submit">Subscribe</button>
</form>
<script>
document.getElementById("subscribe").addEventListener("submit", async (e) => {
e.preventDefault()
const email = e.target.email.value
const r = await fetch(
"https://YOUR-BUNMORI-HOST/api/public/newsletter/YOUR-SLUG/subscribe",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
}
)
const data = await r.json()
alert(
data.status === "confirmation_sent"
? "Check your inbox!"
: "You’re already subscribed."
)
})
</script>Driving broadcasts from CI / automation
Compose your HTML/text however you like, then ship it through the API:
TOKEN="bn_xxxxx"
HOST="https://newsletters.example.com"
SLUG="your-slug"
# 1. Create the draft
BROADCAST_ID=$(curl -s -X POST \
"$HOST/api/public/newsletter/$SLUG/broadcasts" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @- <<'EOF' | jq -r '.broadcast.id'
{
"subject": "Weekly digest",
"bodyHtml": "<html>...</html>",
"bodyText": "..."
}
EOF
)
# 2. Start sending. Body is optional — defaults are 25/batch every 5 min.
curl -X POST \
"$HOST/api/public/newsletter/$SLUG/broadcasts/$BROADCAST_ID/send" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "batchSize": 25, "batchIntervalMinutes": 5 }'
# 3. (Optional) Poll for completion. Status will be SENT when done; you can
# also stop with /stop or retry a FAILED broadcast with /retry.
curl -s "$HOST/api/public/newsletter/$SLUG/broadcasts/$BROADCAST_ID" \
-H "Authorization: Bearer $TOKEN" | jq '.broadcast.status'