Bunmori

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/batch endpoint. 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-Post headers (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

StatusMeaning
PENDINGSubscribed but hasn’t clicked the confirmation link yet.
CONFIRMEDReceives broadcasts.
UNSUBSCRIBEDClicked the Postmark unsubscribe link or was manually removed.
BOUNCEDHard-bounced; suppressed by Postmark.
COMPLAINEDMarked 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)

  1. Subject + body — provide an HTML body, a plain-text body, or both. At least one is required (this matches Postmark’s API: HtmlBody and TextBody are individually optional but you must supply at least one). When you provide both, Postmark sends a multipart/alternative email so subscribers’ clients pick the version they prefer.
  2. Save as DRAFT. You can keep editing.
  3. Send test — sends a single copy to whatever email you specify. Doesn’t affect subscribers or stats.
  4. 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 (or STOPPED if you cancel, or FAILED after 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

RoleCan
ADMINEverything: settings, members, tokens, send broadcasts, manage subscribers.
EDITORManage subscribers, compose and send broadcasts.
VIEWERRead-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).

ScopeWhat it allows
readList and read broadcasts.
writeCreate, 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_xxxxxxxxxxxxxxxxxx

Treat 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:

FieldNotes
emailRequired. Lower-cased and trimmed server-side.
nameOptional, max 100 chars.
statusOptional, "CONFIRMED" or "PENDING". Per-row override of defaults.status.
sourceOptional, max 200 chars. Per-row override of defaults.source.
metadataOptional. Same shape as /subscribe: max 25 keys, scalar values, strings ≤1000 chars.

Top-level fields:

FieldNotes
subscribersRequired. Array, 1–5000 rows per request. Chunk client-side for larger lists.
defaults.statusDefault status for rows that don’t set one. Defaults to "CONFIRMED".
defaults.sourceDefault 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:

ReasonMeaning
invalid_emailEmpty or doesn’t look like an email.
duplicate_in_batchSame email appeared earlier in this request — first wins.
already_exists_confirmedRow exists with CONFIRMED status — left untouched.
already_exists_pendingRow exists with PENDING status — left untouched.
suppressed_unsubscribedRow exists, previously unsubscribed — not revived.
suppressed_bouncedRow exists, hard-bounced — not revived.
suppressed_complainedRow exists, marked spam — not revived.

Error responses:

  • 400 — request shape is invalid (e.g. subscribers empty, exceeds 5000, malformed JSON, metadata violates limits).
  • 401 / 403 — token missing, invalid, or lacks write scope.

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_xxxxx

Response: { "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&rsquo;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'

On this page