Why sending email from Bun needs no dependencies
Most email tutorials still open with npm install nodemailer, which drags in an SMTP client, a stream stack and a set of Node polyfills written for a runtime that no longer needs them. Bun makes almost all of it redundant.
To send email from TypeScript in Bun you need no packages at all: Bun ships fetch with connection pooling, runs TypeScript without a build step, reads .env without dotenv, and includes a test runner. An HTTP email API needs exactly none of the packages the Node-era guides install — the whole integration is one function and a type.
The minimal send
This is the whole of it. Save the file as send.ts and run bun send.ts — no install step, no tsconfig, no build, and no JS-versus-TS distinction to think about.
Bun loads .env automatically, so SADASEND_API_KEY is available without any setup. Bun.env and process.env both work; Bun.env is typed slightly better.
// send.ts — run with: bun send.ts
const res = await fetch('https://api.sadasend.com/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${Bun.env.SADASEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'system@yourdomain.com',
to: 'user@example.com',
subject: 'Your authentication code',
text: 'Your code is 482-910. It is valid for ten minutes.',
}),
});
if (!res.ok) throw new Error(`Send failed: ${res.status} ${await res.text()}`);
const { id } = await res.json();
console.log('Queued', id);Types worth writing, and the one that catches bugs
Typing the request payload is obvious. Typing the response back is what stops the class of bug where a failed send reads as a successful one because nobody checked res.ok and data.id was quietly undefined.
Model the result as a discriminated union. The compiler then refuses to let you read id without first proving the send succeeded.
type SendEmail = {
from: string;
to: string | string[];
subject: string;
text?: string;
html?: string;
};
type SendResult =
| { ok: true; id: string }
| { ok: false; status: number; code: string; message: string };
export async function send(email: SendEmail): Promise<SendResult> {
const res = await fetch('https://api.sadasend.com/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${Bun.env.SADASEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(email),
signal: AbortSignal.timeout(10_000),
});
if (res.ok) {
const { id } = (await res.json()) as { id: string };
return { ok: true, id };
}
const { code, message } = (await res.json().catch(() => ({}))) as Partial<{ code: string; message: string }>;
return { ok: false, status: res.status, code: code ?? 'unknown', message: message ?? res.statusText };
}Idempotency: the header that makes a retry safe
The dangerous failure in transactional email is not the send that fails. It is the send that succeeds but looks like it failed — the response is lost to a timeout, your code retries, and the user receives two password resets.
An idempotency key removes the ambiguity. Derive it from the thing the email is about, never from a random value, and a retry of the same logical send returns the original result instead of sending again.
headers: {
Authorization: `Bearer ${Bun.env.SADASEND_API_KEY}`,
'Content-Type': 'application/json',
// Derived from your domain, so a retry is the same key.
// crypto.randomUUID() here would defeat the entire mechanism.
'Idempotency-Key': `password-reset:${user.id}:${resetToken}`,
}Which failures are worth retrying
Retrying everything is as wrong as retrying nothing. A 422 for a malformed address returns 422 forever; retrying it three times just delays the log line that would have told you about the bug.
async function sendWithRetry(email: SendEmail, attempts = 3): Promise<SendResult> {
let last: SendResult | undefined;
for (let i = 0; i < attempts; i++) {
const result = await send(email);
if (result.ok) return result;
last = result;
const retryable = result.status === 429 || result.status >= 500;
if (!retryable) return result;
await Bun.sleep(2 ** i * 250); // 250ms, 500ms, 1s
}
return last!;
}| Status | Retry? | What it means |
|---|---|---|
| 429 | Yes, after Retry-After | Rate limited. Back off, do not hammer. |
| 5xx | Yes, with backoff | Upstream problem, usually brief. |
| Timeout / network error | Yes — with the same idempotency key | The send may already have succeeded. |
| 400 / 422 | No | Payload is wrong. Fix the caller. |
| 401 / 403 | No | Key is missing, wrong, or lacks the scope. Alert. |
Do not fire and forget from a request handler
A pattern shows up in almost every Bun tutorial, including an earlier version of this one: return the HTTP response immediately and let the email send in the background with queueMicrotask or a bare un-awaited promise.
It is genuinely tempting, because it fixes the visible problem — the endpoint is fast again. What it actually does is convert a failure you can see into one you cannot. Nothing retries. Nothing records that the send was attempted. On a serverless platform the instance is frozen the moment the response is flushed, so the request often never leaves at all, and on a long-running server a deploy drops whatever was in flight.
You end up with users who did not get their password reset and no error anywhere to explain why. Do one of two things instead: await the send and accept the latency, or write the intent to your database in the same transaction as the thing that caused it, and let a worker drain it.
// ✗ Fast, and silently loses mail.
Bun.serve({
async fetch(req) {
const user = await createUser(await req.json());
queueMicrotask(() => send({ ...welcome(user) })); // may never run
return Response.json({ id: user.id }, { status: 201 });
},
});
// ✓ The send is durable because it is part of the same commit.
Bun.serve({
async fetch(req) {
const user = await db.transaction(async (tx) => {
const u = await tx.users.create(await req.json());
await tx.outbox.insert({ type: 'welcome', userId: u.id });
return u;
});
return Response.json({ id: user.id }, { status: 201 });
},
});Testing it with bun:test and no network
Bun has a test runner built in, so there is nothing to install here either. Mock fetch and assert on the branch that matters — that a 422 is not retried and a 503 is.
// send.test.ts — run with: bun test
import { test, expect, mock } from 'bun:test';
test('surfaces a validation failure without retrying', async () => {
globalThis.fetch = mock(async () =>
new Response(JSON.stringify({ code: 'invalid_recipient', message: 'bad address' }), { status: 422 }),
) as typeof fetch;
const result = await sendWithRetry({ from: 'a@b.com', to: 'nope', subject: 'x', text: 'y' });
expect(result.ok).toBe(false);
expect(fetch).toHaveBeenCalledTimes(1); // not 3
});The checklist
What separates a Bun send that works from one that fails quietly:
- Use native
fetch. There is no Bun reason to install an SMTP client. - Read the key from
Bun.env— Bun loads.envwithoutdotenv. - Type the response as a discriminated union so success cannot be assumed.
- Check
res.okbefore touching the body. A 4xx body has noidin it. - Set
AbortSignal.timeout()on every call. - Send a domain-derived
Idempotency-Key, never a random one. - Retry 429 and 5xx only; return 4xx to the caller immediately.
- Never fire-and-forget a send a user is waiting on. Persist the intent, then drain it.
- Test the retry classification with
bun:testand a mocked fetch.
Building AI agents that send email?
Join the SadaSend early access waitlist to get scoped API keys, recipient allowlists, and Model Context Protocol (MCP) servers upon launch.