# Outbox — complete documentation > Open source email API and dashboard. A self-hostable Resend. > Generated from https://wess.io/outbox --- # Outbox > Open source email API and dashboard. A self-hostable Resend, built on Bun, Atlas, and PostgreSQL.
Open source email API and dashboard. A self-hostable Resend.
Running locally and sending your first email in about five minutes.
Every endpoint, with request and response shapes.
What changes, what doesn't, and how to cut over.
Transports, DNS, deployment, and the operational realities of email.
Hi {{{first_name|there}}}, your order of {{{PRODUCT}}} shipped.
``` Declared variables can carry a typed fallback used when a value is missing. Double braces (`{{name}}`) HTML-escape; triple braces do not. ## Broadcasts A broadcast is one message sent to every contact in a segment. Fan-out happens in the worker: each eligible contact gets a real email row, personalised with their own values, so per-recipient tracking and metrics work exactly as they do for transactional mail. Contacts are skipped, with a recorded reason, when they are unsubscribed, suppressed, or opted out of the broadcast's topic. ## Automations An automation is a graph. **Steps** do things and **edges** connect them: | Step | Behaviour | |---|---| | `trigger` | Entry point, matched on a custom event name | | `condition` | Branches on `condition_met` / `condition_not_met` edges | | `delay` | Suspends the run and resumes later | | `wait_for_event` | Suspends until a named event arrives | | `send_email` | Sends, inline or from a template | | `add_to_segment` | Adds the contact to a segment | | `contact_update` | Updates contact fields | | `contact_delete` | Deletes the contact | You start a run by posting to `/events/send`. Delays and waits suspend the run rather than holding a worker, so a 30-day drip campaign costs nothing while it waits. See [Build a welcome automation](/tutorials/welcome-automation). ## Webhooks Webhooks deliver events to your endpoint, signed with the Svix scheme so the standard Svix libraries verify them unchanged. Failures retry with exponential backoff across roughly a day, and every attempt is recorded with its status code and response body. See [Handle webhooks](/tutorials/handle-webhooks) and [Webhooks API](/api/webhooks). ## The job queue Anything that must not happen inside a request — delivery, broadcast fan-out, webhook posts, automation steps, DNS verification — goes through a Postgres-backed queue. Workers claim jobs with `SELECT … FOR UPDATE SKIP LOCKED`, so you can run as many as you like without coordination. Failed jobs retry with exponential backoff up to `max_attempts`, and jobs whose worker died mid-flight are reclaimed after five minutes. That is why `bun run dev` starts a worker alongside the API: without one, mail is accepted and queued but never sent. --- # Self-hosting > Transports, configuration, deployment, and the operational realities of running your own mail. # Self-hosting Outbox delivers mail itself rather than fronting a provider. That is the point of it, and it is also the part that takes real work. This page is about that work. ## Choosing a transport `OUTBOX_TRANSPORT` decides how mail leaves the building. | Value | Behaviour | Use it when | |---|---|---| | `console` | Prints the message, delivers nothing | Local development, CI | | `relay` | Hands every message to one upstream SMTP server | You want real delivery without running an MTA | | `smtp` | Connects to each recipient domain's MX hosts directly | You want no third party in the path | ### console The default. Messages are rendered fully — MIME built, tracking applied, unsubscribe links injected — then printed instead of sent. Events still fire, so webhooks and metrics behave as they would in production. ### relay Hands off to an SMTP server you already trust: SES, Postmark, a corporate smarthost, or your own Postfix. ```sh OUTBOX_TRANSPORT=relay SMTP_RELAY_HOST=email-smtp.us-east-1.amazonaws.com SMTP_RELAY_PORT=587 SMTP_RELAY_USER=... SMTP_RELAY_PASS=... SMTP_RELAY_SECURE=starttls # none | starttls | tls ``` This is the pragmatic choice for most self-hosters. You keep your data and the API, and you let someone else own IP reputation. ### smtp Resolves MX records for each recipient domain and delivers directly, upgrading to TLS when the receiver offers STARTTLS. ```sh OUTBOX_TRANSPORT=smtp OUTBOX_HOSTNAME=mail.example.com ``` Before choosing this, be honest about the requirements: - **Outbound port 25.** Most cloud providers block it by default and some will not unblock it at all. Check before you build a plan around it. - **A static IP with matching forward and reverse DNS.** `mail.example.com` must resolve to your IP and that IP's PTR record must resolve back. Receivers reject mail from hosts that fail this. - **Reputation.** A new IP has none. Volume ramps over weeks, not hours. - **Someone to watch it.** Blocklists, feedback loops, and bounce handling are ongoing work, not setup steps. If that list is unwelcome, use `relay`. It is not a lesser option; it is the correct trade for most deployments. ## Configuration | Variable | Default | Notes | |---|---|---| | `DATABASE_URL` | `postgres://outbox:outbox@localhost:55432/outbox` | | | `JWT_SECRET` | — | **Set this.** Signs sessions and tracking tokens. | | `PORT` | `3000` | | | `PUBLIC_URL` | `http://localhost:3000` | Builds tracking and unsubscribe links. Must be the externally reachable URL. | | `OUTBOX_TRANSPORT` | `console` | `console`, `relay`, `smtp` | | `OUTBOX_HOSTNAME` | `localhost` | EHLO name, and the host DNS records point at | | `SMTP_RELAY_HOST` | — | Relay transport | | `SMTP_RELAY_PORT` | `587` | | | `SMTP_RELAY_USER` | — | | | `SMTP_RELAY_PASS` | — | | | `SMTP_RELAY_SECURE` | `starttls` | `none`, `starttls`, `tls` | | `INBOUND_ENABLED` | `false` | | | `INBOUND_PORT` | `2525` | Forward :25 to it rather than running as root | | `WORKER_CONCURRENCY` | `8` | Jobs in flight per worker | | `WORKER_POLL_MS` | `1000` | Queue poll interval | | `RATE_LIMIT_PER_SECOND` | `10` | Per team, matching Resend's default | | `MAX_ATTACHMENT_BYTES` | `41943040` | 40MB | | `TRUSTED_PROXIES` | — | Set behind a load balancer so client IPs are real | | `STORAGE_BUCKET` | — | Set to move attachments out of Postgres. See below | | `STORAGE_REGION` | `us-east-1` | | | `STORAGE_ENDPOINT` | — | Leave empty for AWS; set it for Spaces, R2, MinIO | | `STORAGE_ACCESS_KEY_ID` | — | | | `STORAGE_SECRET_ACCESS_KEY` | — | | | `STORAGE_PREFIX` | `outbox` | Key prefix, so one bucket can hold other things | | `AUTH_FROM` | — | Address password-reset mail comes from. See below | `TRUSTED_PROXIES` matters more than it looks. Outbox only honours `X-Forwarded-For` when the request genuinely arrived from a listed proxy; otherwise the header is attacker-controlled and would let anyone forge their IP past the rate limiter. ## Running the pieces `bun run dev` runs everything in one process, which is fine for a single box. To scale, run them separately: ```sh bun run api # stateless, run as many as you like behind a load balancer bun run worker # run as many as you like; they coordinate through Postgres bun run inbound # one per host that MX records point at ``` Workers claim jobs with `SELECT … FOR UPDATE SKIP LOCKED`, so adding workers needs no configuration and no leader election. ## Behind a reverse proxy Terminate TLS in front and forward to the API. Nginx: ```nginx server { listen 443 ssl http2; server_name outbox.example.com; ssl_certificate /etc/letsencrypt/live/outbox.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/outbox.example.com/privkey.pem; client_max_body_size 50m; # attachments location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` Then set: ```sh PUBLIC_URL=https://outbox.example.com TRUSTED_PROXIES=127.0.0.1 ``` ## Receiving mail Set `INBOUND_ENABLED=true` and enable receiving on the domain. > The inbound server is also what processes **bounces**, and that does not need > receiving enabled on the domain — see [Handle > bounces](/tutorials/handle-bounces). On the `smtp` transport, running without > it means asynchronous bounces are never seen and dead addresses are never > suppressed. Outbox listens on `INBOUND_PORT` (2525 by default) so it need not run as root; forward port 25 to it: ```sh iptables -t nat -A PREROUTING -p tcp --dport 25 -j REDIRECT --to-port 2525 ``` Publish an MX record for the domain pointing at `inbound.Sent from my own server.
" }' ``` ```json { "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c" } ``` That id is the handle for everything that follows. ## 2. Watch it move ```sh curl http://localhost:3000/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c \ -H "Authorization: Bearer $OUTBOX_API_KEY" -H "User-Agent: my-app/1.0" ``` `last_event` walks through the lifecycle: ``` queued accepted, waiting for a worker sent handed to the transport delivered accepted by the receiving server opened the tracking pixel loaded clicked a tracked link was followed ``` On the `console` transport the whole body is printed to the worker log, and the email is marked delivered immediately. That is the point of it — you can build the entire flow before you own a domain. If it stays `queued` forever, no worker is running. `bun run dev` starts one; `bun run api` alone does not. ## 3. Add the things that matter ### Plain text Always include a text part. Outbox derives one from your HTML when you omit it, but a hand-written version reads better and some filters weight its absence. ```json { "html": "Glad you're here, ${user.name}.
`, tags: [{ name: "category", value: "welcome" }], }) if (error) { // Log it and move on — a failed welcome email should not fail a signup. console.error("welcome email failed", error) return null } return data.id } ``` Two habits worth forming now: **Never block the user on an email.** Sending is fast but not instant, and a mail problem should not become a signup problem. **Log the id.** When someone asks whether the email went out, `last_event` answers it in one request — but only if you kept the id. ## Next - [Verify a domain](/tutorials/verify-a-domain) — required before real delivery. - [Transactional email with templates](/tutorials/transactional-with-templates) — get content out of your code. - [Handle webhooks](/tutorials/handle-webhooks) — react to bounces and opens. --- # Verify a domain > Publish SPF, DKIM, and DMARC records so your mail authenticates and arrives. # Verify a domain Outbox will not deliver from a domain you have not verified, and receiving servers would mostly reject it anyway. This is the step that makes mail arrive. ## 1. Pick a domain Use a subdomain, not your root domain: ``` mail.acme.com ✓ send.acme.com ✓ acme.com ✗ ``` Sending reputation attaches to the domain. Keeping bulk mail on a subdomain means a bad month for your newsletter does not follow your invoices, and it keeps your existing MX and SPF records untouched. ## 2. Add it In the dashboard: **Domains → Add domain**. Or: ```sh curl -X POST https://outbox.example.com/domains \ -H "Authorization: Bearer $OUTBOX_API_KEY" \ -H "User-Agent: my-app/1.0" \ -H "Content-Type: application/json" \ -d '{ "name": "mail.acme.com", "open_tracking": true, "click_tracking": true }' ``` Outbox generates a DKIM keypair and returns the records to publish. ## 3. Publish the records | Record | Type | Host | Value | |---|---|---|---| | SPF | TXT | `mail.acme.com` | `"v=spf1 include:mail.example.com ~all"` | | SPF | TXT | `send.mail.acme.com` | `"v=spf1 include:mail.example.com ~all"` | | SPF | MX | `send.mail.acme.com` | `feedback-smtp.mail.example.com` priority 10 | | DKIM | TXT | `outbox._domainkey.mail.acme.com` | `v=DKIM1; k=rsa; p=MIGfMA0…` | | DMARC | TXT | `_dmarc.mail.acme.com` | `"v=DMARC1; p=none; rua=mailto:dmarc@mail.acme.com"` | | Tracking | CNAME | `links.mail.acme.com` | `track.mail.example.com` | The exact values come from your instance — copy them from the dashboard rather than this page. ### Provider notes Most DNS UIs want the host **relative** to the zone. If your zone is `acme.com`, enter `outbox._domainkey.mail`, not the fully-qualified name. Entering the full name inside the zone produces `outbox._domainkey.mail.acme.com.acme.com`, which is the single most common reason verification fails. Cloudflare: set DKIM, SPF, and DMARC records to **DNS only** (grey cloud). Proxying breaks them. ## 4. Verify ```sh curl -X POST https://outbox.example.com/domains/$DOMAIN_ID/verify \ -H "Authorization: Bearer $OUTBOX_API_KEY" -H "User-Agent: my-app/1.0" ``` ```json { "object": "domain", "id": "…", "status": "verified", "records": [ … ] } ``` | Status | Meaning | |---|---| | `not_started` | Nothing resolves yet | | `pending` | Some records resolve | | `verified` | SPF and DKIM both resolve — sending enabled | | `failed` | Nothing resolved on the last check | DNS propagation takes minutes to hours. Re-run verify rather than assuming failure. Check what the world sees: ```sh dig +short TXT outbox._domainkey.mail.acme.com dig +short TXT mail.acme.com dig +short TXT _dmarc.mail.acme.com ``` ## 5. Send a real message ```sh curl -X POST https://outbox.example.com/emails \ -H "Authorization: Bearer $OUTBOX_API_KEY" \ -H "User-Agent: my-app/1.0" \ -H "Content-Type: application/json" \ -d '{ "from": "AcmeChecking SPF, DKIM, and DMARC.
" }' ``` In Gmail, open the message → **Show original**. You want: ``` SPF: PASS DKIM: PASS DMARC: PASS ``` Anything else, fix before sending volume. ## 6. Tighten DMARC Outbox starts you at `p=none`, which monitors without affecting delivery. Once you have run a few weeks of clean traffic, tighten it: ``` v=DMARC1; p=none; rua=mailto:dmarc@acme.com ← monitor v=DMARC1; p=quarantine; pct=10; rua=… ← 10% to spam v=DMARC1; p=quarantine; rua=… ← all to spam v=DMARC1; p=reject; rua=… ← reject outright ``` Move one step at a time and read the aggregate reports at `rua`. Jumping straight to `p=reject` is how organisations discover, loudly, that some forgotten system was also sending as their domain. ## Troubleshooting **Verification never succeeds.** Confirm the record resolves publicly with `dig`. If `dig` sees it and Outbox does not, your resolver may be caching an old negative response; wait for the TTL. **DKIM value looks truncated.** Some UIs split long TXT values across strings. Outbox uses a 1024-bit key so the record fits in one, but if your provider wraps it anyway, verification still passes — the check compares the `p=` tag, not the raw string. **Verified, but Gmail says SPF fails.** SPF authenticates the return path, not the visible `From`. Confirm the `send` subdomain records are published, not only the root. **Mail lands in spam despite passing.** Authentication is necessary, not sufficient. New domains have no reputation; volume has to ramp over weeks. Content, list quality, and engagement matter as much as DNS. --- # Migrate from Resend > What changes, what doesn't, and how to cut over without dropping mail. # Migrate from Resend The API is the same. In most codebases the migration is one line — the base URL — plus the operational work of standing up a server and moving your data. ## What stays the same - Every endpoint path, request body, and response shape. - The error envelope: `{ statusCode, name, message }`. - The list envelope: `{ object, has_more, data }` with `after`/`before` cursors. - Webhook event names and payloads. - Webhook signatures — Svix scheme, so the same verification code works. - Template variable syntax, including `{{{RESEND_UNSUBSCRIBE_URL}}}`. - The `/audiences` paths, alongside `/segments`. ## What changes | | Resend | Outbox | |---|---|---| | Base URL | `https://api.resend.com` | Your host | | API key prefix | `re_` | `ob_` | | DNS record values | Point at SES | Point at your host | | Billing | Metered | None | | Regions | Routed | Stored, single region | | SMTP submission | Available | Not supported — use the HTTP API | The DNS difference is the one with real work attached: you cannot reuse Resend's records because they authorise Resend's servers. New domain, new records, new verification. ## 1. Stand up Outbox Follow [self-hosting](/self-hosting). Decide your transport before you migrate: `relay` if you want someone else to own IP reputation, `smtp` if you are prepared to run an MTA. Set `PUBLIC_URL` to the externally reachable URL — tracking and unsubscribe links are built from it. ## 2. Move your data There is no import endpoint. Read from Resend, write to Outbox. ### Contacts ```ts import { Resend } from "resend" const source = new Resend(process.env.RESEND_API_KEY) const target = new Resend(process.env.OUTBOX_API_KEY, { baseUrl: process.env.OUTBOX_URL }) const segment = await target.audiences.create({ name: "Migrated" }) let after: string | undefined for (;;) { const page = await source.contacts.list({ audienceId: RESEND_AUDIENCE_ID, limit: 100, after }) for (const contact of page.data.data) { await target.contacts.create({ email: contact.email, firstName: contact.first_name, lastName: contact.last_name, unsubscribed: contact.unsubscribed, audienceId: segment.data.id, }) } if (!page.data.has_more) break after = page.data.data[page.data.data.length - 1].id } ``` Create [contact properties](/api/contact-properties) on Outbox first — unknown keys are rejected rather than dropped. ### Suppressions **Do this before your first send.** Everyone who bounced or complained on Resend must stay suppressed, or you will re-mail dead addresses from a domain with no reputation — the worst possible opening move. ```ts const suppressions = await source.suppressions.list({ limit: 100 }) await target.suppressions.batch.add(suppressions.data.data.map((s) => s.email)) ``` ### Templates Recreate them and publish. The variable syntax is identical, so the bodies copy across unchanged. ### Broadcasts and emails Historical sends do not migrate. Keep your Resend account readable for a while if you need the archive. ## 3. Point your code at Outbox ```diff - const resend = new Resend(process.env.RESEND_API_KEY) + const resend = new Resend(process.env.OUTBOX_API_KEY, { + baseUrl: process.env.OUTBOX_URL, + }) ``` Better, make it switchable so you can fall back without a deploy: ```ts export const mailer = new Resend(process.env.MAIL_API_KEY, { ...(process.env.MAIL_BASE_URL ? { baseUrl: process.env.MAIL_BASE_URL } : {}), }) ``` Unset `MAIL_BASE_URL` and you are on Resend; set it and you are on Outbox. ## 4. Verify domains New records, new verification — see [Verify a domain](/tutorials/verify-a-domain). Use a different subdomain from the one Resend uses so both can run at once during cutover. ## 5. Recreate webhooks Signing secrets differ, so your endpoint needs Outbox's. If you want to run both temporarily, accept either: ```ts const secrets = [process.env.RESEND_WEBHOOK_SECRET, process.env.OUTBOX_WEBHOOK_SECRET] let event = null for (const secret of secrets.filter(Boolean)) { try { event = await verifyWebhook({ payload: raw, headers, webhookSecret: secret! }) break } catch {} } if (!event) return new Response("bad signature", { status: 400 }) ``` ## 6. Cut over gradually Move traffic by category rather than all at once: ```ts const OUTBOX_CATEGORIES = new Set(["internal", "notifications"]) const client = OUTBOX_CATEGORIES.has(category) ? outbox : resend ``` Start with internal or low-stakes mail. Watch delivery, bounce, and complaint rates for a week. Then move password resets and receipts, then marketing last — it is the most sensitive to reputation. ## Verify the compatibility yourself The repo ships a suite that runs the official `resend` package against Outbox: ```sh OUTBOX_API_KEY=ob_... bun run test:compat ``` It checks sends, retrieval, listing, scheduling, cancellation, batching, audiences, contacts, API keys, and error shapes. If you depend on a call it does not cover, add it before you migrate. ## Rolling back Keep the Resend key live until you are confident. Rollback is unsetting the base URL — which is why making it an environment variable rather than a code change is worth the five minutes. --- # Transactional email with templates > Move email content out of your codebase and into versioned, publishable templates. # Transactional email with templates Hard-coded HTML in your application means every copy change is a deploy, and marketing cannot touch it without a pull request. Templates fix both. ## 1. Create one ```sh curl -X POST https://outbox.example.com/templates \ -H "Authorization: Bearer $OUTBOX_API_KEY" \ -H "User-Agent: my-app/1.0" \ -H "Content-Type: application/json" \ -d '{ "name": "Password reset", "alias": "password-reset", "from": "AcmeHi {{{first_name|there}}},
This link expires in {{{expiry_minutes}}} minutes. If you did not request it, ignore this email.
", "text": "Hi {{{first_name|there}}},\n\nReset your password: {{{reset_url}}}\n\nExpires in {{{expiry_minutes}}} minutes.", "variables": [ { "key": "first_name", "type": "string", "fallback_value": "there" }, { "key": "reset_url", "type": "string" }, { "key": "expiry_minutes", "type": "number", "fallback_value": 30 } ] }' ``` Set an **alias**. Sending to `password-reset` rather than a UUID means your code does not change when the template is recreated. ## 2. Publish it A new template is a draft and cannot be sent: ```sh curl -X POST https://outbox.example.com/templates/password-reset/publish \ -H "Authorization: Bearer $OUTBOX_API_KEY" -H "User-Agent: my-app/1.0" ``` ## 3. Send it ```ts await resend.emails.send({ to: [user.email], template: { id: "password-reset", variables: { first_name: user.firstName, reset_url: `https://acme.com/reset?token=${token}`, expiry_minutes: 30, }, }, }) ``` No `from`, no `subject`, no HTML — they come from the template. Override any of them by passing it explicitly. ## 4. Edit safely Any content change creates a **new draft version**. Production keeps using the published one until you publish again: ```sh curl -X PATCH https://outbox.example.com/templates/password-reset \ -H "Authorization: Bearer $OUTBOX_API_KEY" \ -H "User-Agent: my-app/1.0" \ -H "Content-Type: application/json" \ -d '{ "subject": "Reset your Acme password" }' ``` `has_unpublished_versions` becomes `true`. Nothing changes for senders until: ```sh curl -X POST https://outbox.example.com/templates/password-reset/publish \ -H "Authorization: Bearer $OUTBOX_API_KEY" -H "User-Agent: my-app/1.0" ``` This is the property that makes templates safe to hand to someone else: a mistake in the editor cannot reach customers until someone publishes it. ## Variables | Form | Behaviour | |---|---| | `{{{key}}}` | Raw insertion | | `{{{key\|fallback}}}` | Fallback when missing or empty | | `{{key}}` | HTML-escaped | | `{{{contact.first_name}}}` | Contact fields, when sending to a contact | | `{{{OUTBOX_UNSUBSCRIBE_URL}}}` | Signed per-recipient unsubscribe link | Resolution: supplied value → inline fallback → declared `fallback_value` → empty. **Escape anything a user typed.** `{{{name}}}` with a name of `