The problem with one Send button
A members app had a composer that let an admin write a message and press Send. That single action resolved the audience and dispatched emails in one go. With roughly 860 active members, a misclick or a wrong audience filter meant 860 inboxes got mail before anyone could stop it.
No confirm step. No preview of the real recipient count. No way to test the copy without firing the real broadcast.
Two commits landed on the same day to close that gap.
What the confirm sheet actually shows
The first change opens a confirm sheet before any email leaves. The sheet states:
- Count: how many people will receive this
- Channel: the delivery method
- Audience: the filter that produced that count
- Subject: the exact subject line
For nine recipients or fewer, the "Send to N" button is active immediately. For ten or more, the button stays disabled until the admin types SEND into a text input. The check is case-insensitive, so send, Send and SEND all work.
The threshold lives in shared/constants/broadcast-confirm.ts. Both the client and the server import from that file, so they can never drift apart.
Why the server re-counts before writing anything
Client-side guards are easy to bypass. A tab left open for twenty minutes may show a stale audience count. Someone could call the API directly. A filter change made in another tab would not update the drawer.
The server's send.post.ts handler re-runs recipient resolution after receiving the request. If the resolved count is ten or more and the request does not include a confirmed SEND, it returns a 400 with error code confirm_required and puts the real count in data.count. No broadcast row is written. Nothing is queued.
If the server count is higher than what the confirm drawer showed, the client reopens the typed step with the updated number. The admin has to type SEND again against the number that is actually true.
This matters because the dangerous case is not a malicious actor. It is an admin who filtered by "active members in group A", saw 12, typed SEND, and did not notice the filter had silently expanded to 94 while they were writing the subject line.
The send-test endpoint is a completely separate path
Before these commits, testing a broadcast meant either sending to a real audience or manually editing the request. Neither is acceptable at scale.
The new POST /api/admin/messaging/send-test endpoint:
- Never calls resolveRecipients. The audience payload is accepted but ignored entirely.
- Reads only the session user's email. Whatever address is in the request body is discarded.
- Prefixes the subject with [TEST]. The admin can see immediately that this is a preview.
- Writes no broadcast row. The send is invisible to analytics and audit logs.
Eleven tests cover the endpoint, including cases where a body address is supplied and must be ignored.
The real send endpoint refuses test_email
This is the detail that makes the split durable. If a request to the real /send endpoint includes test_email in its payload, the server returns a 400 with error code use_send_test.
That means a developer cannot accidentally wire the wrong endpoint. A stale frontend that still sends test_email to the real path will get a clear error rather than silently mailing the full audience. The two paths are mutually exclusive at the protocol level, not just by team convention.
What the test suite covers
The tests for the confirm flow include:
- 860 recipients with
sendtyped: broadcast proceeds. - 9 recipients with no confirm: broadcast proceeds without requiring the typed step.
- 10 or more recipients without confirm: 400
confirm_requiredwith count in the response body. - Server count higher than client count: client reopens the typed step.
The send-test suite covers the eleven cases including the ignored-body-address scenario and the [TEST] prefix on the subject.
Having the 860-recipient case as an explicit test is useful. It documents the scale the system is designed for and will catch any regression that accidentally lowers the threshold or removes the server-side check.
Shared constants are load-bearing
Putting the threshold in shared/constants/broadcast-confirm.ts sounds like a small thing. It is not.
If the client checks at ten and the server checks at fifteen, an admin can type SEND for eleven recipients, the client is satisfied, and the server lets it through without a confirm because it does not consider eleven to be above its threshold. The mismatch is invisible until something goes wrong.
A single source of truth means the confirm sheet and the API handler always agree. It also means a future decision to lower the threshold to five, or raise it to twenty, is one line change that propagates everywhere.
What I'd do differently
The confirm sheet shows the audience filter as a label. I would also show a short sample of actual recipient names or email addresses, maybe three to five, so the admin can sanity-check that the filter resolved to the people they expected. A count of 12 is easy to accept; seeing that two of the twelve are names you do not recognise prompts a second look.
I would also add a short time-to-live on the confirm token or session state. Right now the flow assumes the admin types SEND and immediately submits. If they type SEND, walk away for an hour, and come back, the audience may have changed. The server re-count catches this, but surfacing a warning that the count changed since the drawer opened would be clearer than silently reopening the typed step with a new number.
Finally, the send-test endpoint currently sends only to the session user. A "send test to a list of addresses" variant would be useful for checking rendering across different email clients without touching the real audience. That is a separate feature, not a safety fix, but it would reduce the temptation to use the real send endpoint with a small hand-picked audience as a workaround.




