Email Broadcasts
A marketing email is a list, a body, and an approval. Everything after the approval is a routine: batches go out every 15 minutes, each person is re-checked against the CRM the moment before their send, and the campaign closes itself when nothing is left. Seventy-two hours after that, a second routine posts the settled numbers to the Marketing chat with one sentence from Claude on what they mean, exactly once.
Three human clicks, then the machine
Nobody presses send on a person. The human decisions are the content, the audience and the go; the routine owns every individual delivery and the report that follows.
How each step works
A broadcast is a row: name, subject, preheader, a Markdown body, the featured posts and call to action as blocks, a sender and reply-to, a batch size (150 by default), an optional schedule and an optional segment. “Build the list” resolves the audience from the CRM through one function that every sending path shares: only people who are subscribed, not marked do not contact, not archived and not in a blocked persona (job seekers never get marketing mail). A guest brand is scoped to its own contacts and can never draw from the house list. The list is paged explicitly so a large CRM cannot be silently truncated.
A test goes to the approver’s own inbox (or any address that matches a CRM contact), personalised from that record so the greeting and unsubscribe link are real. Approval is refused if the body is empty or the recipient list has not been built. If the segment carries a send window (a weekday and hour in the contact’s own time zone), approval stamps every recipient with their next such moment, counted from the approval, so “Tuesday 08:00” means the Tuesday after someone said go.
One more click moves the broadcast from approved to sending. Nothing goes out from the button itself; it only makes the campaign eligible for the routine. A scheduled time, if set, holds the campaign until it arrives without blocking any other campaign behind it. A broadcast can be cancelled at any point before it is marked sent.
Each tick picks the oldest campaign that is sending and due, then claims up to one batch of pending recipients in a single database statement (rows whose send window has not arrived are left alone). Claiming and selecting together means an overlapping tick finds nothing to take and cannot double-send; rows claimed by an invocation that died more than 30 minutes ago are returned to pending first.
Then, one recipient at a time: re-check the person against the live CRM (consent, do not contact, archived, persona) and against the bounce and complaint history; skip with a reason if any gate fails; otherwise make one Resend call with the shared blocks, the person’s first name, a single utm_campaign for the whole send and RFC 8058 one-click unsubscribe headers. Sent rows get the Resend id and a timestamp; failed rows keep the error and are not retried. Finally, any webhook events that arrived before the row was stamped are linked back to the campaign.
When a tick finds nothing to claim it counts the rows still pending or claimed. Only when that count is zero does it mark the campaign sent with a timestamp; a batch held by a slower invocation is never abandoned. The pacing is deliberate: the sending domain builds reputation gradually, and a bad list shows up as bounces on the first batch instead of after the whole run.
Every morning the summary routine looks for broadcasts marked sent at least 72 hours ago that have no summary yet (at most 20 per run). For each it reads sent, delivered, opened and clicked from the event ledger, clicks by topic from the utm_content tag on each link, and unsubscribes attributed by time since approval. Claude gets those numbers and returns one sentence on the single most useful thing they say; if it is unavailable the post goes out without the line.
The post lands in the Marketing Lark channel with a link to the results page, and only then is the broadcast latched as summarised, so a failed run is retried the next day and a successful one is never repeated.
Every exception has an owner
Sending mail is the one automation where a mistake reaches a stranger’s inbox, so most of the code is the off-path. Each row is a real condition, what the routine does, and where a human hears about it.
| Condition | What happens | Where you hear about it |
|---|---|---|
| Person unsubscribed, archived, marked do not contact, or lost consent after the list was built | Row marked skipped with the reason; no mail sent | The recipient table on the broadcast page |
| Address previously hard-bounced or reported spam | Row marked skipped; a transient bounce (full mailbox) does not count | The recipient table |
| CRM lookup fails mid-batch | Row returned to pending and retried next tick; a timeout is never treated as a suppression | Run log, as deferred |
| Resend rejects a send | Row marked failed with the error; never retried, so a bad address cannot burn reputation | The recipient table |
| Mail accepted but the row update fails | Counted as a write failure; the mail is not resent | Run log, as writeFailures |
| A tick dies mid-batch | Rows claimed more than 30 minutes ago go back to pending; the next tick takes them | Nowhere, by design |
| Two ticks overlap | Rows are claimed and selected in one statement; the second tick finds nothing to take | Nowhere, by design |
| Broadcast scheduled for later | Ignored by the picker until its time arrives, so it never blocks another campaign | The broadcast page shows the schedule |
| A send window is set (weekday and hour in the contact’s zone) | Each recipient carries its own earliest moment; the claim skips rows whose moment has not come | The broadcast page |
| Claude unavailable for the takeaway | Summary posts with the numbers and no takeaway line | The Lark post, shorter |
| Summary posted but the latch write fails | Logged; the broadcast is picked up again tomorrow, so a second post is possible | Run log; the duplicate in Lark |
| Marketing webhook unset or Lark down | Logged and swallowed; the latch still sets, so the recap is not re-attempted | Run log only |
The seven elements
Every workflow we document has the same anatomy: seven elements, each assigned to a human, a machine, or both. This is the Centaur Map from our workflow design method.
A human approves and starts the broadcast. From then on it is time: a tick every 15 minutes for the batches, a daily run at 10:20 (+07) for the summary.
The broadcast row (subject, body, featured posts, batch size, optional schedule), its recipient list, the live CRM record of each person, and the bounce and complaint history from Resend.
Humans decide the content, the segment, and when to go. The sender decides, per recipient and at the moment of send, whether that person may still be mailed. It never decides who to add.
One campaign at a time, oldest due first. One batch per tick. The summary goes to the Marketing Lark channel; the results page holds the detail.
One email per eligible recipient, a status on every recipient row (sent, skipped with a reason, failed with the error), and one Lark recap per broadcast.
Resend carries the mail with one-click unsubscribe headers. Delivery, open, click and bounce events flow back by webhook and are linked to the broadcast.
The machine waits 72 hours, then reports sent, delivered, open, click, unsubscribes and the top topic. A human reads the takeaway and decides what the next letter does differently.
The standing rules
- One function decides who may receive marketing mail; the sender and the audience count both call it
- Every recipient is re-checked at send time, never trusted from the list
- A database error is a retry, never a suppression
- A send failure is recorded, never retried
- One campaign at a time, one batch per tick, oldest due first
- A summary is posted once; the latch is set only after the post
How we know it works
- Every run writes a row to the routine log with sent, skipped, failed, deferred and write-failure counts
- The results page reconciles recipient statuses against the event ledger
- A broadcast still pending the morning after its last batch is the alarm
- A summary that never arrives means the latch or the webhook, both visible in the run log