Skip to content

Send email with Bun.js

Send transactional email from Bun: a one-file script, a Bun.serve endpoint, attachments with Bun.file, and SMTP with nodemailer.

To send email with Bun.js you need one dependency — or none. Bun runs TypeScript directly, loads .env on its own and ships a web-standard fetch. 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, HTML and attachments read with Bun.file, SMTP with nodemailer for code that already speaks SMTP, batch and scheduled sends, the same request with plain fetch, and the errors you are most likely to hit.

Quickstart#

Before you start

You need an API key (sk_live_…, or sk_test_… to try it without delivering anything) and a sending domain you have verified in PostStack. See Authentication and Domains.

  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

    typescript
    import { PostStack, PostStackError } from '@poststack.dev/sdk';
    
    // The SDK already retries 408, 5xx and short-Retry-After 429s (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"
    },
  ],
});

Send HTML email with an inline image#

Pass the HTML body as html and a plain-text fallback as text; mail clients show whichever they support. To embed an image instead of attaching it, give the attachment a content_id and reference it as cid:<content_id> in the HTML.

typescript
const html = await Bun.file('./emails/welcome.html').text(); // your template
const logo = Bun.file('./emails/logo.png');

await poststack.emails.send({
  from: 'Acme <hello@yourdomain.com>',
  to: ['user@example.com'],
  subject: 'Welcome to Acme',
  html: html.replace('{{name}}', 'Ada'),
  text: 'Welcome to Acme, Ada!',
  attachments: [
    {
      filename: 'logo.png',
      content: Buffer.from(await logo.arrayBuffer()).toString('base64'),
      content_type: 'image/png',
      content_id: 'logo', // in the HTML: <img src="cid:logo" alt="Acme">
    },
  ],
});

SMTP with nodemailer on Bun#

nodemailer runs on Bun unchanged. Use it when a library or existing code already builds SMTP messages; for new code the SDK is simpler and returns the email id. Port 465 uses implicit TLS (secure: true); port 587 accepts STARTTLS (secure: false, requireTLS: true) or implicit TLS. The password is your API key; the username can be any non-empty value.

typescript
// bun add nodemailer
import nodemailer from 'nodemailer';

const transport = nodemailer.createTransport({
  host: 'smtp.poststack.dev',
  port: 465,
  secure: true, // implicit TLS
  auth: { user: 'poststack', pass: Bun.env.POSTSTACK_API_KEY! },
});

const info = await transport.sendMail({
  from: 'Acme <hello@yourdomain.com>',
  to: 'user@example.com',
  subject: 'Hello from Bun over SMTP',
  html: '<h1>Welcome!</h1>',
  text: 'Welcome!',
  attachments: [{ filename: 'report.csv', path: './report.csv' }],
  headers: { 'X-PostStack-Tags': 'welcome,onboarding' }, // tags, as on the API
});
console.log(info.response); // "250 ..." once PostStack has accepted the message

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 (465 with implicit TLS, or 587 with STARTTLS or implicit TLS) with your API key as the password — nodemailer works on Bun as shown above. 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.

Does nodemailer work with Bun?

Yes. bun add nodemailer and create an SMTP transport for smtp.poststack.dev — port 465 with secure: true, or 587 with requireTLS: true — using your API key as the password. Attachments, HTML bodies and custom headers work as they do on Node.

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.

Reference#

The docs this guide builds on:

  • SDKs — every official client, its retries and idempotency keys
  • SMTP relay — hosts, ports, TLS modes and the X-PostStack-* headers
  • Emails API — every send field, attachments, scheduling and batch sends
  • Templates — stored templates with variables
  • Webhooks — event types, payloads and signature verification
  • Suppressions — what happens to an address after a hard bounce or complaint

Ready to send from Bun?

Create a free account and an API key — no card needed.

Get started free

Next steps

Was this page helpful?