Send a newsletter from your own app

At some point every product wants to mail its users something that is not a receipt. A changelog, an announcement, a note about the maintenance window. The tempting implementation is a loop over your users table calling your transactional send endpoint, and it works for about two months.

4 min read

This post is about what that loop gets wrong and what to do instead.

Why the loop breaks

Unsubscribe. Gmail and Yahoo require bulk senders to include the RFC 8058 one-click headers, List-Unsubscribe and List-Unsubscribe-Post. A loop over your users table does not have them, and mail without them from a sender doing bulk volume gets treated accordingly. Adding them means minting and storing a per-recipient token, handling the POST, and remembering that mail clients prefetch links so a GET must never actually unsubscribe anybody.

Consent. Your users table records that somebody signed up for your product. It does not record that they agreed to hear from you about your product. Those are different permissions, and one of them is the one that keeps your complaint rate down.

Reputation. This is the expensive one. An announcement generates complaints at a rate a password reset never does, and if both go out on the same sending reputation, a campaign having a bad week starts pushing your verification links into spam. You will not notice, because your dashboards will keep saying the mail was delivered.

Mid-send changes. Somebody unsubscribes forty seconds into a fan-out to 40,000 people. A loop that expanded its recipient list up front mails them anyway.

The shape that works

Store the list separately from your users table. Send to the list, not to a query.

Shell
# One audience.
curl -sS https://emails.sh/v1/audiences \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Product updates"}'

# Import your existing subscribers, but plan it first.
curl -sS "https://emails.sh/v1/audiences/$AUDIENCE_ID/contacts?dry_run=true" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary @subscribers.csv

The dry run runs the same planner the real import does, so what it tells you is a prediction rather than an estimate. It reports how many contacts it would create, update, leave alone and hold back, every column with how it read it and three sample values, and a per-line sample with the action and the reason.

Addresses that have already bounced or complained land as cleaned rather than subscribed and are reported as held back. Unrecognised status values become pending, never subscribed, because a lenient parser here is a spam complaint later.

Then the send:

Shell
curl -sS https://emails.sh/v1/broadcasts \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "July changelog",
    "from": "Acme <news@acme.com>",
    "audience_id": "'"$AUDIENCE_ID"'",
    "subject": "What shipped in July",
    "html": "<p>Hello {{ first_name }}, here is what changed.</p>",
    "text": "Hello {{ first_name }}, here is what changed."
  }'

Test it against a real contact before you send it

Two endpoints, and using both takes a minute.

POST /v1/broadcasts/:id/test sends the real thing to your own address: the real configuration set, the real merge fields, the real headers. It writes no recipient rows and moves no counters, and it still honours suppression.

GET /v1/broadcasts/:id/preview?email= renders it against a real contact and returns the subject, the HTML, the text, the List-Unsubscribe headers, and a list of every merge field with whether that contact actually supplies it. That last list is the one that catches "Hello ," before forty thousand people see it.

Use a separate subdomain

Send the newsletter from news.acme.com and your transactional mail from mail.acme.com. Each publishes its own DKIM records and gets its own SPF lookup budget, and a complaint attaches to the subdomain that earned it.

This is the cheapest deliverability insurance there is and it costs you one extra DNS setup.

Read the result per recipient

After it sends, the broadcast carries counters for recipients, sent, delivered, bounced, complained, failed, skipped, opens, clicks and unsubscribes. Underneath them, GET /v1/broadcasts/:id/recipients pages through every individual address with its status, its message id, and the reason if it was skipped.

Nothing is silently omitted. A send to 40,000 people that reached 38,600 tells you what happened to the other 1,400 rather than leaving you to subtract.

Questions

Do I have to move my list out of my own database?
You have to have a list somewhere that records consent and carries unsubscribe tokens. Keeping the source of truth in your database and pushing changes to an audience through the API is a perfectly reasonable shape, and the import upserts by address so running it repeatedly does not duplicate anybody.
Does this cost per email?
No. The marketing side is priced by contacts stored, in tiers, and sends to your own audiences carry no per-email charge and do not draw on a transactional plan.
Can I send to part of a list?
Yes. A segment is a saved filter over an audience: a match mode and up to 20 rules over tags, attributes, subscription status, join date, and whether somebody opened or clicked recently. Pass segment_id alongside audience_id. Everyone outside the segment still gets a recipient row marked skipped, with the segment named as the reason.
What about a welcome sequence rather than one send?
That is an automation: a trigger, waits, branches and sends, running per contact over days or weeks. The same sequence is a YAML file you can keep in your repository.

Give your agent an address it can answer from.

Create an inbox