Skip to content

Send emails with Bun

Learn how to send transactional emails using PostStack and Bun.

Bun runs TypeScript directly, loads `.env` on its own and ships a web-standard `fetch`, so sending email from Bun takes one dependency — or none. The PostStack TypeScript SDK (`@poststack.dev/sdk`) is built on `fetch` with no Node-only imports, and PostStack’s own API runs on Bun. Below: a one-file script with top-level `await`, an HTTP endpoint with `Bun.serve`, attachments read with `Bun.file`, batch and scheduled sends, the same request with plain `fetch`, and the errors you are most likely to hit.

1. Install the SDK

bash
bun add @poststack.dev/sdk

2. Create the client

typescript
// .env — Bun loads it automatically, no dotenv package needed
// POSTSTACK_API_KEY=sk_live_...

// email.ts
import { PostStack } from '@poststack.dev/sdk';

export const poststack = new PostStack(Bun.env.POSTSTACK_API_KEY!);

3. Send an email from a Bun script

typescript
// send.ts — run with: bun run send.ts
import { PostStack, PostStackError } from '@poststack.dev/sdk';

const poststack = new PostStack(Bun.env.POSTSTACK_API_KEY!);

try {
  const { id } = await poststack.emails.send({
    from: 'Acme <hello@yourdomain.com>',   // must be on a verified domain
    to: ['user@example.com'],              // always an array
    subject: 'Hello from Bun!',
    html: '<h1>Welcome!</h1><p>Thanks for signing up.</p>',
    text: 'Welcome! Thanks for signing up.',
  });
  console.log('Queued', id);               // "em_..."
} catch (err) {
  if (err instanceof PostStackError) {
    console.error(err.statusCode, err.message, err.requestId);
  } else {
    throw err;
  }
}

4. Handle errors

Bun idioms for error handling, retries, and structured logging when sending through PostStack.

typescript
import { PostStack, PostStackError } from '@poststack.dev/sdk';

// The SDK already retries 408, 429 and 5xx responses (3 retries with jittered
// backoff) and sends an Idempotency-Key, so a retried request is never
// delivered twice. Only terminal errors reach your catch block.
const poststack = new PostStack(Bun.env.POSTSTACK_API_KEY!, {
  timeoutMs: 10_000, // per attempt
  maxRetries: 3,
});

export async function sendEmail(payload: Parameters<typeof poststack.emails.send>[0]) {
  try {
    return await poststack.emails.send(payload);
  } catch (err) {
    if (!(err instanceof PostStackError)) throw err; // network error after all retries
    switch (err.statusCode) {
      case 400: // invalid payload — err.message names the field
      case 422: // unverified from-domain, all recipients suppressed, template not published
        console.error('PostStack rejected the email', { message: err.message, requestId: err.requestId });
        return null;
      case 401:
      case 403: // bad/revoked key, or a key without sending permission
        throw err;
      default: // 429 or 5xx that survived the SDK's retries — let your queue retry
        throw err;
    }
  }
}

An HTTP endpoint with Bun.serve

Create the client once at module scope and call it from the `fetch` handler (or a `routes` entry on Bun 1.2+). Validate input before calling the API — `to` must be an array of addresses.

typescript
import { PostStack, PostStackError } from '@poststack.dev/sdk';

const poststack = new PostStack(Bun.env.POSTSTACK_API_KEY!);

const server = Bun.serve({
  port: Number(Bun.env.PORT ?? 3000),
  async fetch(req) {
    const url = new URL(req.url);
    if (req.method !== 'POST' || url.pathname !== '/send') {
      return new Response('Not found', { status: 404 });
    }

    const { email, name } = (await req.json()) as { email: string; name: string };
    try {
      const { id } = await poststack.emails.send({
        from: 'Acme <hello@yourdomain.com>',
        to: [email],
        subject: `Welcome, ${name}`,
        html: `<h1>Welcome, ${name}!</h1>`,
      });
      return Response.json({ id });
    } catch (err) {
      if (err instanceof PostStackError) {
        return Response.json({ error: err.message }, { status: err.statusCode });
      }
      throw err;
    }
  },
});

console.log(`Listening on ${server.url}`);

Send an attachment with Bun.file

Attachments are base64-encoded strings. `Bun.file()` reads lazily; convert its bytes with `Buffer`. Limits: 10 attachments, 10 MB per file and 25 MB in total (decoded size).

typescript
const pdf = Bun.file('./invoices/1042.pdf');

await poststack.emails.send({
  from: 'billing@yourdomain.com',
  to: ['customer@example.com'],
  subject: 'Invoice #1042',
  text: 'Your invoice is attached.',
  attachments: [
    {
      filename: '1042.pdf',
      content: Buffer.from(await pdf.arrayBuffer()).toString('base64'),
      content_type: pdf.type, // "application/pdf"
    },
  ],
});

Batch, scheduled and tagged sends

`emails.batch` sends up to 100 emails per request; each element succeeds or fails on its own, so check every result. `scheduled_at` takes an ISO 8601 time; `tags` (up to 10) make sends filterable in the dashboard and API.

typescript
const { data } = await poststack.emails.batch({
  emails: users.map((u) => ({
    from: 'Acme <hello@yourdomain.com>',
    to: [u.email],
    subject: 'Your weekly summary',
    html: renderSummary(u),
    tags: ['weekly-summary'],
  })),
});
const failed = data.filter((r): r is { error: string } => 'error' in r);

await poststack.emails.send({
  from: 'Acme <hello@yourdomain.com>',
  to: ['user@example.com'],
  subject: 'Your trial ends tomorrow',
  html: '<p>Upgrade to keep your projects.</p>',
  scheduled_at: new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
});

Without the SDK: plain fetch

The API is one JSON POST with a Bearer token, so Bun’s built-in `fetch` is enough for scripts that want zero dependencies. A successful send returns `202` with `{ "id": "em_..." }`.

typescript
const res = await fetch('https://api.poststack.dev/emails', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${Bun.env.POSTSTACK_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID(), // reuse the same key if you retry
  },
  body: JSON.stringify({
    from: 'Acme <hello@yourdomain.com>',
    to: ['user@example.com'],
    subject: 'Hello from Bun!',
    html: '<h1>Welcome!</h1>',
  }),
});

if (!res.ok) {
  const { error } = (await res.json()) as { error: string };
  throw new Error(`PostStack ${res.status}: ${error}`);
}
const { id } = (await res.json()) as { id: string };

Test without delivering (bun test)

A test-mode key (`sk_test_...`) goes through validation and shows up in your dashboard log, but nothing is handed to a mail server. The response carries `test_mode: true`.

typescript
// send.test.ts — POSTSTACK_API_KEY=sk_test_... bun test
import { expect, test } from 'bun:test';
import { PostStack } from '@poststack.dev/sdk';

const poststack = new PostStack(Bun.env.POSTSTACK_API_KEY!);

test('welcome email is accepted', async () => {
  const res = await poststack.emails.send({
    from: 'hello@yourdomain.com',
    to: ['user@example.com'],
    subject: 'Test',
    text: 'Hello',
  });
  expect(res.id).toStartWith('em_');
  expect(res.test_mode).toBe(true);
});

Framework integrations

Scripts and cron jobs

Top-level `await` works in any `.ts` file, so `bun run scripts/notify.ts` is a complete sender. Bun reads `.env`, `.env.local` and `.env.<NODE_ENV>` automatically.

Bun.serve

Instantiate the client at module scope, call `await poststack.emails.send(...)` in the handler and return `Response.json({ id })`. No framework or adapter needed.

Hono or Elysia on Bun

The SDK is a plain import in any Bun framework. See the Hono guide for routing, validation and error-handler patterns.

SMTP from Bun

If a library you depend on only speaks SMTP, point it at `smtp.poststack.dev` (587 STARTTLS or 465 TLS) with your API key as the password. For code you control, the HTTP API is simpler and returns the email id.

Common pitfalls

  • `to` must be an array

    `to: "user@example.com"` fails validation with a 400. Use `to: ["user@example.com"]` — the same applies to `cc` and `bcc`.

  • 422 "Domain … is not verified"

    The `from` address must be on a domain you have added and verified in PostStack. Add the DNS records from the dashboard and wait for the domain to show as verified.

  • `POSTSTACK_API_KEY` is undefined

    Bun loads `.env` from the current working directory. Running a script from another folder, or in a container without the file, leaves the variable unset — pass it with `--env-file` or your platform’s secrets.

  • Leaking the key to the browser

    Never call the API from code bundled with `Bun.build` for the browser. Keep sends on the server and expose your own endpoint.

FAQ

How do I send an email with Bun?

Install `@poststack.dev/sdk` with `bun add`, create `new PostStack(Bun.env.POSTSTACK_API_KEY!)` and `await poststack.emails.send({ from, to: [...], subject, html })`. Run it with `bun run file.ts` — no build step or dotenv needed.

Can I send email from Bun without any dependency?

Yes. POST JSON to `https://api.poststack.dev/emails` with `Authorization: Bearer <key>` using Bun’s built-in `fetch`. The SDK adds types, retries and idempotency keys on top of the same call.

Does the SDK need Node compatibility flags on Bun?

No. It only uses `fetch`, `crypto.randomUUID` and `AbortSignal.timeout`, which Bun provides natively. The same package works on Node 18+, Deno and edge runtimes.

How do I test email sending in Bun without sending real mail?

Use a test-mode API key (`sk_test_...`). Sends are validated and logged but never delivered, and the response includes `test_mode: true`, so `bun test` can assert on it.

Related guides

Ready to send emails with Bun?

Create a free account and get your API key in under a minute.