# A Broadcast Tool for 860 Members Now Needs a Typed SEND

> One misclick in a members composer could mail 860 people instantly. Two commits fixed that: a typed confirm step and a split send-test endpoint.

- Author: Dil Ahdan (https://dil.dev/)
- Published: 2026-10-22
- Updated: 2026-10-22
- Tags: safety, admin-ui, api-design, email
- Canonical: https://dil.dev/notes/broadcast-typed-send-confirm/

## Key takeaways

- Require typing SEND for any broadcast reaching 10 or more recipients, enforced client-side and server-side.
- The server re-counts recipients before writing any row, so a stale tab showing a lower number cannot bypass the gate.
- A dedicated send-test endpoint never resolves the real audience, writes no broadcast row, and only mails the signed-in admin.
- The real send endpoint refuses any request that includes test_email, so the two paths cannot be confused.
- Put the threshold in a shared constants file so the client and server always agree on the number.

## 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 `send` typed:** broadcast proceeds.
- **9 recipients with no confirm:** broadcast proceeds without requiring the typed step.
- **10 or more recipients without confirm:** 400 `confirm_required` with 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.

## Quick answers

**Why enforce the SEND check on the server if the client already blocks the button?**

A stale tab, a direct API call, or a race condition can all bypass client-side guards. The server re-counts resolved recipients before writing any row, so the gate holds regardless of what the browser sent.

**What happens if more people match the audience than the confirm drawer showed?**

The server returns a 400 with confirm_required and the real count in data.count. The client reopens the typed confirm step showing the updated number, so the admin sees the true scope before anything is sent.

**How does the test send work without accidentally mailing real members?**

The send-test endpoint never calls resolveRecipients and ignores any addresses in the request body. It reads only the session user's email, prefixes the subject with [TEST], and writes no broadcast row.

**Can a developer accidentally call the wrong endpoint?**

The real send endpoint returns a 400 use_send_test error if the request includes test_email. The two paths are mutually exclusive at the API level, not just by convention.

**Where does the 10-recipient threshold live?**

In a shared constants file so the client confirm sheet and the server validation always read the same value. Changing it in one place changes it everywhere.
