Skip to content
Writing
NewBunTypeScriptTutorialIdempotency

Send transactional email with Bun and TypeScript

Bun ships fetch, TypeScript and a test runner, so sending email needs no packages at all. What it does need is the handful of decisions that separate a send that works from one that fails quietly.

Tayyab MughalFounder & AI Chief7 min read

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.

TYPESCRIPT
// 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.

TYPESCRIPT
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.

TYPESCRIPT
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.

TYPESCRIPT
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!;
}
StatusRetry?What it means
429Yes, after Retry-AfterRate limited. Back off, do not hammer.
5xxYes, with backoffUpstream problem, usually brief.
Timeout / network errorYes — with the same idempotency keyThe send may already have succeeded.
400 / 422NoPayload is wrong. Fix the caller.
401 / 403NoKey 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.

TYPESCRIPT
// ✗ 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.

TYPESCRIPT
// 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 .env without dotenv.
  • Type the response as a discriminated union so success cannot be assumed.
  • Check res.ok before touching the body. A 4xx body has no id in 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:test and a mocked fetch.
Early Access

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.

Use Case:
One email when it opens
Social Hashtags & Share
#EmailAPI#DeveloperTools#BunJS#JavaScript#TypeScript#WebDev
Tayyab MughalFounder & AI Chief

Building SadaSend — transactional email with an MCP server that has a ceiling. Writes about deliverability, email infrastructure, and what happens when you hand an autonomous agent a sending credential.