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
bun add @poststack.dev/sdk2. Create the client
// .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
// 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.
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.
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).
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.
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_..." }`.
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`.
// 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.