๐Ÿ“จ Outbox

Quickstart#

Outbox needs Bun 1.3 or newer and PostgreSQL 14 or newer. Docker is the easiest way to get Postgres, and the repo ships a compose file for it.

Install#

sh
git clone https://github.com/wess/outbox.git
cd outbox
bun install

Configure#

sh
cp .env.example .env

Open .env and set JWT_SECRET to a long random string. It signs dashboard sessions and the tokens embedded in tracking and unsubscribe links, so it must not stay at its default:

sh
openssl rand -hex 32

Everything else has a working default for local development. In particular OUTBOX_TRANSPORT=console, which prints messages to the worker log instead of delivering them โ€” you can exercise the whole product before you own a domain.

Start the database and migrate#

sh
bun run db:up      # Postgres on :55432, via Docker
bun run migrate    # creates the schema
bun run seed       # creates a team, a login, and an API key

seed prints credentials. Save the API key โ€” it is hashed on write and never shown again:

  team      Acme (0f8a...)
  owner     yes โ€” this is the instance owner
  user      [email protected]
  password  outbox-dev-password
  api key   ob_EXAMPLEKEYdonotusethisvalue123

The first account created on an instance owns it. That is whoever seed creates, or the first person to sign up if you skip seeding.

Run it#

sh
bun run dev

This starts the API and the background worker in one process:

[outbox] api        http://localhost:3000
[outbox] dashboard  http://localhost:3000/app
[outbox] worker worker-332b92ae started (concurrency 8, transport console)

Open http://localhost:3000/app and sign in.

Send an email#

sh
curl -X POST http://localhost:3000/emails \
  -H "Authorization: Bearer ob_yourapikey" \
  -H "User-Agent: my-app/1.0" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Hello World",
    "html": "<strong>It works!</strong>"
  }'
json
{ "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c" }

The User-Agent header is required โ€” Outbox rejects requests without one, exactly as Resend does. Every official SDK sets it for you.

Watch the worker log and you'll see the rendered message, then check the Emails page in the dashboard: the email moves from queued to sent to delivered.

Use an SDK#

Any Resend SDK works. In JavaScript:

ts
import { Resend } from "resend"

const resend = new Resend(process.env.OUTBOX_API_KEY, {
  baseUrl: "http://localhost:3000",
})

const { data, error } = await resend.emails.send({
  from: "Acme <[email protected]>",
  to: ["[email protected]"],
  subject: "Hello World",
  html: "<strong>It works!</strong>",
})

if (error) console.error(error)
else console.log(data.id)

Outbox also ships its own zero-dependency client โ€” see SDK.

What's next#

Commands#

CommandWhat it does
bun run devAPI + worker (+ inbound when enabled)
bun run apiAPI and dashboard only
bun run workerBackground worker only
bun run inboundInbound SMTP server only
bun run migrateApply pending migrations
bun run migrate:statusShow migration state
bun run migrate:downRoll back the most recent migration
bun run seedCreate a starter team, user, and API key
bun testUnit tests
bun run test:smokeAPI contract, against a running server
bun run test:e2eFull pipeline, including a webhook receiver
bun run test:compatThe official resend package, against Outbox