Broadcast streams

A broadcast stream carries your marketing mail: newsletters, product announcements, the weekly digest. It answers to a stricter set of rules than a transactional stream, and CamelMailer enforces four of them for you.

What makes a stream a broadcast

A broadcast stream is a message stream whose stream_type is broadcast. Where a transactional stream ships a receipt or a password reset to someone who is expecting it, a broadcast stream ships promotional mail to a list, so it carries four extra guarantees.

Every one of them is gated on stream_type == "broadcast". A transactional or inbound stream behaves exactly as it always has, so nothing you already run changes.

AspectTransactional streamBroadcast stream
List-Unsubscribe headerNot addedRFC 8058 one-click header, built per recipient
Opt-in gateDelivers to any addressOnly addresses with a subscribed record, otherwise the send is rejected with 422
Compliance footerNoneUnsubscribe link plus your postal address, in HTML and text
IP poolServer defaultIts own ip_pool_id, with the server pool as fallback
Unsubscribe scopeNot applicableSuppresses this one stream, transactional mail keeps flowing

Subscribers are wired to the stream

Hold onto one mental model and the rest of the page follows from it. A subscriber is a per-stream consent record wired to the stream, and an unsubscribe is a per-stream opt-out wired to the stream. Neither is a global property of an email address.

A subscriber is a row in the subscriptions table keyed by (server_id, stream_id, address) with a status of subscribed or unsubscribed. Two things fall out of that key:

  • The same address can be subscribed to your newsletter stream and absent from your product-updates stream. They are independent rows.
  • The table sits behind the same row-level security as your messages. A subscriber list is only ever visible inside the owning server's tenant context.

Suppressions carry a stream scope in the same way. A row with stream_id unset is server-wide (hard bounces and manual suppressions land here and block every stream). A row scoped to a stream_idblocks that one stream, which is where a marketing unsubscribe lands. A recipient is held when a suppression matches server-wide or matches the message's own stream, so a marketing opt-out and a fully deliverable transactional relationship coexist for the same address. See Suppressions for how scope is stored and displayed.

1. One-click unsubscribe

Every message on a broadcast stream carries two headers, built per recipient at send time and baked into the stored raw message:

headers
List-Unsubscribe: <https://track.example.com/track/u/AbC123>, <mailto:unsubscribe@track.example.com>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

The https:// link is {web_protocol}://{track_domain}/track/u/{token}, the same tracking host the worker uses for opens and clicks. The List-Unsubscribe-Post header is the RFC 8058 signal that lets Gmail, Apple Mail, and Yahoo render a native Unsubscribe button and POST to the endpoint for the recipient, with no page visit. Each recipient gets their own token and their own raw message, so every opt-out link resolves to exactly one address on one stream.

The public endpoint

The opt-out link travels to strangers, so the endpoint is public and unauthenticated. It resolves the opaque token through a cross-tenant lookup, then does three things under the resolved tenant: writes a stream-scoped unsubscribe suppression, flips the matching subscriptions row to unsubscribed, and returns without leaking whether the token was valid.

terminal
# One-click (what a mailbox provider sends):
curl -s -X POST "https://track.example.com/track/u/AbC123"
# -> 200, empty body

# Browser click (what a recipient sees):
curl -s "https://track.example.com/track/u/AbC123"
# -> 200, the "You have been unsubscribed." page
The endpoint is idempotent and content-free about token validity. A provider that POSTs twice, or a recipient who clicks and then also gets an automated POST, produces one clean opt-out. Neither verb reveals whether an address is on your list.

After either call the address is suppressed on that stream and its subscription reads unsubscribed. Sending the same broadcast again holds the message. Sending that address on a transactional stream still delivers, which is the whole point.

2. Reputation isolation

Marketing mail and transactional mail earn different reputations, and a promotional campaign's complaint rate has no business weighing on the IP that delivers your password resets. A broadcast stream sends from its own IP pool to keep the two apart.

Each stream carries a nullable ip_pool_id. When the worker picks a source IP it resolves the stream's pool first, then the server's pool, then none. A broadcast stream with its own pool sends from dedicated addresses. A stream that leaves ip_pool_id unset inherits the server default, so streams you have not configured are untouched.

Set the pool from the broadcast stream's detail page in the dashboard, or when you create or update the stream through the API. Pools themselves are an admin-API resource. For how pools, warm-up, and per-pool reputation fit together, see Deliverability & IP pools.

CAN-SPAM requires marketing mail to carry a visible way to opt out and the sender's physical postal address. CamelMailer appends both to every broadcast message, in the HTML body and the plain-text body, before the raw message is built. The footer travels with the stored message exactly like the List-Unsubscribeheader, and its unsubscribe link reuses the recipient's one-click token, so the visible link and the header point at the same place.

The address comes from servers.broadcast_physical_address, a server-level setting. Set it under Server, then Settings, then Broadcast postal address in the dashboard, or through the server settings API.

When the address is unset, the footer still ships (with the unsubscribe link, without an address line) and the dashboard raises a compliance warning on the stream's detail page that links straight to Settings. Set the address before you run real campaigns: an unsubscribe link on its own does not satisfy CAN-SPAM.

4. Opt-in subscribers

A broadcast reaches only an address that has consented, and the consent record is the subscriptions row. At enqueue time a broadcast send checks every recipient with is_subscribed(server_id, stream_id, address), true only when a row exists with status = 'subscribed'. If any recipient is not subscribed, the whole request is rejected with 422 Unprocessable Entity, naming the first offender:

422 response
{
  "status": "error",
  "error": {
    "code": "ValidationError",
    "message": "someone@example.com has not opted in to the newsletter stream"
  }
}

Transactional and inbound streams have no such gate. This check runs for broadcast streams only.

Managing subscribers

Subscribers live under the messaging API, per stream, authenticated with a Server API key:

MethodPathPurpose
GET/api/v2/server/streams/{permalink}/subscribersList subscribers and their status
POST/api/v2/server/streams/{permalink}/subscribersAdd or update one {address, status?} (status defaults to subscribed)
POST/api/v2/server/streams/{permalink}/subscribers/importBulk-upsert {addresses: [...]} as subscribed
DELETE/api/v2/server/streams/{permalink}/subscribers/{address}Remove a subscriber row entirely
POST/api/v2/server/streams/{permalink}/subscribers/{address}/complaintFlip to unsubscribed and write a stream-scoped complaint suppression

Adding is an upsert: posting an address that already exists updates its status. Import skips blanks, de-duplicates within the request, and reports how many rows it touched plus the resulting count. The dashboard surfaces all of this on the stream's detail page: an add box, a paste-to-import textarea, the subscriber list with a status badge, and per-row Mark complaint and Remove actions.

terminal
# Opt a recipient in (status defaults to subscribed):
curl -s -X POST "$API/api/v2/server/streams/newsletter/subscribers" \
  -H "X-Server-API-Key: $SERVER_KEY" -H "Content-Type: application/json" \
  -d '{"address": "reader@example.com"}'

Consent closes on its own

You rarely flip consent by hand. Two events close it for you, each writing a stream-scoped suppression and setting the subscription to unsubscribed:

  • An unsubscribe through the /track/u/{token} endpoint writes an unsubscribe suppression.
  • An automatic feedback-loop complaint. When a mailbox provider forwards a spam complaint as an ARF report, the worker parses it, maps it back to the broadcast recipient through the original List-Unsubscribe token, and records a complaint suppression. A report it cannot parse or map is held rather than dropped.

Either way the address stops receiving this stream's mail and keeps receiving its transactional mail.

Sending a broadcast

Create the broadcast stream, opt a recipient in, then send with the stream field pointing at it. The stored raw carries the List-Unsubscribe header and the compliance footer.

terminal
# 1. Create the broadcast stream
curl -s -X POST "$API/api/v2/server/streams" \
  -H "X-Server-API-Key: $SERVER_KEY" -H "Content-Type: application/json" \
  -d '{"name": "Newsletter", "stream_type": "broadcast"}'

# 2. Send to a subscribed recipient
curl -s -X POST "$API/api/v2/server/messages" \
  -H "X-Server-API-Key: $SERVER_KEY" -H "Content-Type: application/json" \
  -d '{
    "from": "news@acme.example",
    "to": ["reader@example.com"],
    "stream": "newsletter",
    "subject": "This week at Acme",
    "html_body": "<p>Hello!</p>",
    "text_body": "Hello!"
  }'

Send the same broadcast to an address that has not opted in and the request is rejected before anything is queued. For a first-class, tracked send to your whole audience with a draft, schedule, and send-now lifecycle, reach for Campaigns. A campaign expands into one broadcast message per subscriber through this exact path. The full messaging API lives in Sending email.

Local-dev honesty: without a running worker, mail is stored and queued but never handed to an MX, so everything up to the stored raw is exercisable while the send itself is not. Feedback-loop complaints arrive as real inbound mail, so the automatic complaint path does not fire on a laptop, though the subscribers/{address}/complaint endpoint reproduces the same outcome. The unsubscribe endpoint does work locally: it needs only the web process and the store.