On this page
Key takeaways
- Every early exit happens before the provider is called, a bad address, a suppressed one, a message already sent. The model gets a typed outcome, not a hopeful string.
- Derive the idempotency key from the content and pass it to the provider too, so a retried tool call or a retried HTTP request cannot deliver twice.
- Retry only the failures the adapter classifies as transient. Errors the library doesn't recognise are never retried, so a bug can't turn into three emails.
- Spelling an address aloud is bad UX. Prefer a text the caller replies to; when the voice path is the fallback, parse, read back and confirm before sending.
The first time our voice agent told someone it had sent a message it hadn't, I assumed it was a one-off. It wasn't: the agent had logged the caller's details, skipped the send, and cheerfully confirmed a text that was never sent. That was SMS, and it taught me to stop trusting a model's account of its own actions. (The full story is in part 2.) Email came next, and I wanted the same class of mistake to be hard to make.
I should be honest about where this library comes from. The email feature in the phone agent was simple. It made one request to the email provider, with no retry, no protection against sending twice, and no list of people who should never be emailed. It ran as a fire-and-forget call: the tool told the model the email was on its way before the send had finished, and the only protection against a double send was a sentence in the prompt. This library is not code I extracted from it. It's what I wrote after reading my own handler and asking what would hurt.
- Valid address? A deliberately simple check. Bad: refused before anything else.invalid-address
- On the suppression list? Unsubscribed, bounced, complained. Checked before anything costs money.suppressed
- Already sent this exact message? A key derived from recipient, subject and body. A repeat returns the first result.the first result, no new send
- Send, with the same key passed to the provider Transient failures retry with backoff. Permanent ones stop.sent, or an error that says why
Refuse early, answer plainly
Every send goes through the same few steps, in the same order, and all of the early exits happen before the provider is called. A bad address is refused. A suppressed address, one that unsubscribed, bounced or complained, is refused before anything costs money. A message that was already sent returns the first result and sends nothing. Only then does a real request go out.
What comes back is a typed outcome, not a hopeful string. For the voice case there's a helper, "email me this," whose answer is one of four: sent, needs confirmation, invalid address, or suppressed. The model can only say "I sent it" when the outcome says so.
The same message, once
The idempotency key is derived from the content: the recipient, the subject and the body, so a repeated tool call collapses into the first. The address is lowercased first, so two spellings of one mailbox match. The key is also passed to the provider as a header, so a retried HTTP request can't deliver twice at the far end either.
This has a cost, and the README says so: if you legitimately send the same message to the same person twice, you must pass a scope, such as an order id. I hit the opposite bug in the phone agent, where deduplication was too eager and suppressed a real prompt because of a stale pending record. A content-based key has the same shape of risk. It's a trade, not a free win.
Retry only when it makes sense
- Your code to Mailer: send(message)
- Mailer to Provider: attempt 1 · key ek_…
- Provider to Mailer: 503. retryable: wait ~250 ms
- Mailer to Provider: attempt 2 · same key
- Provider to Mailer: 429 Retry-After. wait what the provider asked
- Mailer to Provider: attempt 3 · same key
- Provider to Mailer: 200 sent
- Mailer to Your code: one result
Failures are classified by the provider adapter. A network error, a 429 and a 5xx are retryable. Anything else, such as a 422 for a bad payload, is permanent and stops at once. Errors the library doesn't recognize are never retried, so a bug in your own code can't turn into three emails. Defaults are three attempts, 250 ms doubling to a 5 s cap, with up to 20% jitter, and a Retry-After header replaces the computed wait.
Spelling an address aloud is bad UX
On the phone agent, we chose to make callers reply to a text with their email instead of reading it out. The notes from that day say it directly: spelling an email during a call is a bad experience. The verbal path stayed as a fallback, with the model told to read it back.
The library covers that fallback properly. It turns "jane dot doe at example dot com" into an address, and "juan guion perez arroba ejemplo punto com" too, because the callers were in Miami. If the result isn't a valid address, the model must ask again rather than send. It spells the address back, "j a n e dot d o e at e x a m p l e dot c o m," and nothing is sent until the caller has confirmed.
Attachments, and the next failure
The agent could send several property flyers in one email, with nothing capping the count or the total size. I have no record of that ever failing; it is a failure I could see coming. A batcher splits attachments by count and total bytes, keeps their order, and reports any single file that is too big on its own instead of dropping it. It's a pure function, so you decide how to send the batches.
Smaller pieces
- Templates in English and Spanish, with typed variables. Values are HTML-escaped by default, and a missing variable throws instead of printing a blank.
- Unsubscribe headers for one-click unsubscribe, which most providers now expect.
- A fake provider for tests that behaves like one that honors idempotency keys, and can be told to fail the next few sends. Thirty-one offline tests, no network.
- Zero runtime dependencies, about 590 lines, and one provider adapter that uses plain fetch. Adding another is one small class.
What I have and haven't proven
- The Resend adapter has not been run against the live API. The request shape and the idempotency header come from its documentation and my reading of it, so test it with your own key first.
- The package isn't on npm yet, so install it from the repository.
- I found no logged case of a misheard address, a duplicate email or an oversized attachment in the phone agent. These guards are design, not rescue.
- Some rough edges, found by reading it again: the suppression bypass skips every reason, though its comment says unsubscribed only. The key ignores attachments and headers. Two simultaneous sends of one message can both get through, since there's no in-flight check. And the confirmed flag is trusted from the caller.
- It is not a marketing platform: no campaigns, no inbound parsing, no scheduling. Domain setup and deliverability are yours.
I built this with Claude Code in a short session, after writing the scope with it first. I decided what it should refuse, and I'd rather it refuse too much than say it sent something it didn't.
What's in the library
A mailer with validation, a suppression list, content-derived idempotency keys and retry with backoff; a spoken-address parser for English and Spanish with a read-back; an attachment batcher; templates; and a fake provider for tests. Offline tests need no keys.
import { createMailer } from "@27colab/email-kit";
import { FakeProvider } from "@27colab/email-kit/testing";
const provider = new FakeProvider().failNext(2);
const mailer = createMailer({ provider, from: "Acme <hello@example.test>", retry: { attempts: 3 } });
await mailer.send({ to: "jane@example.com", subject: "Your quote", text: "Hi!" });
console.log(provider.calls, provider.outbox.length); // 3 1
You bring a Resend account with a verified sending domain, or another provider behind the one small adapter class; stores for idempotency keys and the suppression list, if you need them to outlive the process; a scope whenever you legitimately send the same message to the same person twice; and deliverability, DNS, warm-up and reputation.
Get it
MIT-licensed, early (v0.1), written with Claude Code and directed and reviewed by me.
git clone https://github.com/27colab/email-kit.git
cd email-kit && npm ci && npm test
Who wrote this
Jesus Vasquez · Engineering · fractional CTO
Builds it: WordPress and Next.js, integrations and AI agents, with the tests, analytics and accessibility that keep it honest.