Munchable sends service email: a terms change, a billing problem, something about how scans work. There is no marketing list and nothing promotional, which is written down on munchable.app/privacy under "Email we send you", so the audience is not a question the product has to answer. There is exactly one audience, account holders, and the composer has no audience picker at all.
What it does have is an interesting shape, because sending to a list from a serverless function is one of those problems where the obvious implementation is the one that hurts you.
The loop you cannot write
The obvious version is a route that reads every recipient and sends to them:
for (const batch of chunk(await allRecipients(), 100)) {
await sendEmailBatch(batch);
}
On a serverless platform this is a bet that the list is short. The route is capped:
export const maxDuration = 60;
Lose that bet and the function is killed partway through, and the damage is not the stopping. It is that nobody can say who was sent to. You have a half-delivered campaign and no way to resume it that does not either skip people or mail them twice.
So the loop went to the client. One request sends one batch and returns a cursor:
// 'bulk': ONE keyset-paginated batch (at most 100) of the account holders.
// The client re-invokes with the returned cursor until `done`.
Every request is short, progress is something an operator watches tick upward, and the cursor is the only state a resume needs. A hundred is not an arbitrary chunk size either, it is the most Resend's batch endpoint accepts in one call, so a full batch is 100 emails behind one HTTP request.
The client loop is bounded rather than open:
// Bounded rather than `while (true)`: 500 batches is 50,000 recipients,
// far beyond any real list, and a bug in the cursor must not turn into an
// unbounded send.
A while (true) whose exit condition is a value returned by a server is a send loop one off-by-one away from being infinite. A for loop with 500 iterations has a worst case you can write in a sentence.
Keyset, because the list changes while you are sending
// Recipients are paged with a KEYSET cursor rather than an offset. A send runs
// across many requests while accounts are being created and deleted underneath
// it; an offset would shift and silently skip people, a keyset will not.
export async function recipientPage(cursor: string | null, limit: number): Promise<RecipientPage> {
const rows = await db
.select({ userId: userConsent.userId, email: userConsent.email })
.from(userConsent)
.where(cursor ? gt(userConsent.userId, cursor) : undefined)
.orderBy(asc(userConsent.userId))
.limit(limit);
...
}
OFFSET 300 means "skip the first 300 rows of the current result", and the current result is different from the one the previous request saw if a single account was deleted in between. Everybody shifts up one, and one person silently never gets the email. With where id > cursor, a row disappearing behind the cursor cannot move anything in front of it.
The cursor is one column because the ordering is the primary key, which is the small design choice that keeps the resume state a single string rather than a tuple.
The cursor advances past what was read, not what was sent
This is the line in the route I would defend hardest:
// The cursor advances to the end of the page we READ, not the page we
// successfully sent. A failed batch is reported so the operator can decide;
// silently retrying inside the loop is how one bad address turns into
// duplicate sends for the 99 good ones next to it.
A batch send either succeeds or fails as a unit. If a batch fails and you retry the whole thing, the 99 recipients whose addresses were fine are now candidates for a second copy. Email has no undo, so a retry is a decision about whether duplicates are worse than omissions, and that decision belongs to a human who can read the error, not to a catch block three frames down.
So a failed batch returns a 502, reports how many it was carrying, and still hands back the next cursor. The loop stops, and the operator sees exactly where.
There is no send log, and that is the honest part
// There is deliberately no per-campaign send log. That means re-running a
// finished campaign WOULD send it again, so the composer gates bulk behind a
// typed confirmation and this route refuses to send without `confirm: true`.
Every piece of advice about bulk email says to keep a per-recipient send record. We do not, because that record is a table of who received which message and when, for a product whose entire pitch is that it stores as little about you as it can. A campaign log is a behavioural history of your inbox sitting in our database for a feature used a handful of times a year.
What replaces it is friction placed where the mistake would happen. The route refuses a bulk send without confirm: true, and the UI will not send that flag until the operator types a phrase that contains the count:
const confirmPhrase = `send to ${recipientCount}`;
You cannot type it without reading how many people it is going to. And when a run fails partway, the error says the thing that is true rather than the thing that is comforting:
text: `${text} ${sent} already went out, so re-running this will send to them twice.`
I am not claiming this is better than idempotency keys for a product that mails people weekly. For a handful of service notices a year, the trade is a real one: no behavioural table in exchange for an operator who has to pay attention.
The preview is not a preview
The composer shows the email next to the form, and that panel is not a reimplementation:
// `buildNotice` is a PURE function returning { subject, html, text }, so
// the same code renders the panel's live preview and the message the send route
// hands to Resend. That is the point of it being pure: the preview is not an
// approximation of the email, it is byte-identical to it.
A preview that drifts from what ships is worse than no preview, because it transfers confidence without transferring accuracy. One pure function of the content, called in the browser to render the panel and on the server to render the send, and an operator who approves what they see has approved what will be delivered.
There is a test mode on top of that, which sends one real copy to the operator's own address with [TEST] prefixed to the subject and the real footer attached, because a stripped-down test email is a different email.
The content is an ordered list of blocks (greeting, heading, paragraph, bullets, button, divider) with hand-written validation and no schema library:
export const NOTICE_LIMITS = {
subjectMax: 200,
preheaderMax: 160,
headingMax: 200,
paragraphMax: 5000,
bulletMax: 500,
bulletsMax: 40,
labelMax: 60,
urlMax: 2000,
blocksMax: 50,
} as const;
Nine numbers and a narrowing pass are cheaper than a dependency here, and blocksMax: 50 is the one that stops a composed email from becoming a denial of service against our own send quota.
The From address is a key, not a string
const FROM_KEYS = {
CONTACT: EmailAddresses.CONTACT,
SUPPORT: EmailAddresses.SUPPORT,
NOTIFICATIONS: EmailAddresses.NOTIFICATIONS,
NOREPLY: EmailAddresses.NOREPLY,
} as const;
The client picks a key and never supplies an address. An unknown key falls back to notifications rather than erroring, every option is on the SPF and DKIM verified domain, and there is no code path in which an arbitrary From reaches the mail provider. Bulk sends also carry replyTo: support@munchable.app, so a reply to an announcement lands in the ticket system instead of a mailbox nobody reads.
See the surfaces this touches
- munchable.app/privacy has the "Email we send you" section: sign in codes, receipts, document-change notices and support replies, with no marketing list and nothing to unsubscribe from. That paragraph is why the composer has no audience picker.
-
munchable.app/support is where the
replyToon every bulk send points. Replying to an announcement opens a ticket like any other message. - munchable.app/terms is the document whose version constant is the most common reason this tool gets used at all, since a material change has to be emailed to everyone still on the old version.
If you are about to write a send loop in a serverless function, the question that decides the design is not how long the list is. It is what you will know about who received what when the function is killed in the middle.
Top comments (0)