# 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.
# Outbox

Open source email API and dashboard. A self-hostable Resend.

Outbox implements the Resend API surface — the same paths, request bodies, response shapes, and error envelope — plus the dashboard that goes with it. Point any Resend SDK at your Outbox host and it works unchanged. ```ts import { Resend } from "resend" // The only change is the base URL. const resend = new Resend("ob_yourapikey", { baseUrl: "https://outbox.example.com" }) await resend.emails.send({ from: "Acme ", to: ["someone@example.com"], subject: "Hello World", html: "It works!", }) ```

Quickstart →

Running locally and sending your first email in about five minutes.

API reference →

Every endpoint, with request and response shapes.

Migrate from Resend →

What changes, what doesn't, and how to cut over.

Self-hosting →

Transports, DNS, deployment, and the operational realities of email.

## What's in it **Sending** — transactional email, batch send (100 per call), scheduling with natural language (`in 1 hour`) or ISO 8601, attachments, inline images, custom headers, tags, idempotency keys, and cancel/reschedule of queued sends. **Domains** — per-domain DKIM keypairs, generated SPF/DKIM/DMARC/MX/tracking records, live DNS verification, open and click tracking, custom return path, opportunistic or enforced TLS. **Audience** — contacts with typed custom properties, segments, topics with per-contact opt-in and opt-out, and a suppression list that bounces feed automatically. **Broadcasts** — fan out to a segment with per-contact personalisation, scheduling, and per-broadcast metrics including clicked links. **Templates** — versioned, with typed variables and fallbacks, a draft/publish workflow, duplication, and addressing by id or alias. **Automations** — event-triggered workflows as a step graph: conditions with branching, delays, waiting on an event, sending email, and mutating contacts. **Webhooks** — 19 event types, Svix-compatible signatures, delivery attempt history, and exponential-backoff retries. **Receiving** — an inbound SMTP server that parses MIME, stores attachments, and fires `email.received`. **Accounts** — the first account created on an instance owns it; later signups get their own team. Sessions are cookie-based and revocable. ## Why it exists Resend is a good product with an API worth copying. Outbox exists for the cases where sending someone else's servers your customers' email is the wrong answer: air-gapped deployments, data-residency requirements, high volume where per-email pricing stops making sense, or simply wanting the thing you depend on to be inspectable. The compatibility is the point. You should be able to develop against Resend and deploy against Outbox, or the reverse, without a rewrite. ## How it differs Outbox is not a proxy in front of SES — it delivers mail itself, so a few things necessarily differ: | | Resend | Outbox | |---|---|---| | API key prefix | `re_` | `ob_` | | DNS record values | Point at SES | Point at your host | | Billing | Metered | None — it's your server | | Regions | Routed | Stored and returned, single region | The record *set* is the same shape, the API is the same, and errors use the same envelope. See [Migrate from Resend](/tutorials/migrate-from-resend) for the full list. ## For agents The docs are available as plain text: [llms.txt](/llms.txt) for an index, and [llms-full.txt](/llms-full.txt) for everything in one file. Any page also serves its raw markdown by appending `.md` to the URL. --- # Quickstart > Get Outbox running locally and send your first email in about five minutes. # Quickstart Outbox needs [Bun](https://bun.sh) 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 admin@outbox.local 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 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 ", "to": ["someone@example.com"], "subject": "Hello World", "html": "It works!" }' ``` ```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 ", to: ["someone@example.com"], subject: "Hello World", html: "It works!", }) if (error) console.error(error) else console.log(data.id) ``` Outbox also ships its own zero-dependency client — see [SDK](/sdk). ## What's next - [Send your first email](/tutorials/send-your-first-email) — the same flow with more explanation. - [Verify a domain](/tutorials/verify-a-domain) — required before Outbox will deliver real mail. - [Self-hosting](/self-hosting) — transports, deployment, and what running an MTA actually involves. - [API reference](/api/introduction) — every endpoint. ## Commands | Command | What it does | |---|---| | `bun run dev` | API + worker (+ inbound when enabled) | | `bun run api` | API and dashboard only | | `bun run worker` | Background worker only | | `bun run inbound` | Inbound SMTP server only | | `bun run migrate` | Apply pending migrations | | `bun run migrate:status` | Show migration state | | `bun run migrate:down` | Roll back the most recent migration | | `bun run seed` | Create a starter team, user, and API key | | `bun test` | Unit tests | | `bun run test:smoke` | API contract, against a running server | | `bun run test:e2e` | Full pipeline, including a webhook receiver | | `bun run test:compat` | The official `resend` package, against Outbox | --- # Concepts > How teams, domains, contacts, topics, and the job queue fit together. # Concepts A short tour of the objects Outbox works with and how they relate. ## Teams and accounts Everything is scoped to a **team**. API keys, domains, contacts, emails, and templates all belong to exactly one team, and no query crosses that boundary. A **user** signs in to the dashboard and belongs to one or more teams through a **membership** carrying a role (`owner`, `admin`, `member`). The first account created on an instance is the **instance owner** (`is_owner`). That is a property of the installation, distinct from being the owner of a team. It is enforced by a partial unique index, so two simultaneous signups on a fresh instance cannot both claim it. ## API keys Keys are shown once, at creation, and stored only as a SHA-256 hash. They carry a permission: - `full_access` — every endpoint. - `sending_access` — only the send endpoints. Suitable for a key that ships somewhere less trusted. A key can additionally be restricted to a single domain, in which case it may only send from that domain. The dashboard authenticates with a session cookie against the *same* endpoints, so there is no second API to keep in sync. ## Domains You must add and verify a domain before Outbox will deliver mail from it. Adding one generates a DKIM keypair and the DNS records to publish: | Record | Type | Purpose | |---|---|---| | `@` and `send` | TXT | SPF authorising this host | | `send` | MX | Return path for bounces | | `outbox._domainkey` | TXT | DKIM public key | | `_dmarc` | TXT | DMARC policy, starting at `p=none` | | `links` | CNAME | Click and open tracking, when tracking is enabled | | `@` | MX | Inbound mail, when receiving is enabled | Verification resolves each record and compares it. A domain becomes `verified` once SPF and DKIM check out; tracking and receiving records are advisory. From that point outbound mail is DKIM-signed. See [Verify a domain](/tutorials/verify-a-domain). ## Emails An email is created synchronously — validated, persisted, and queued — and the API returns its id immediately. Delivery happens in the worker. Its state lives in `last_event`, which mirrors the webhook event names without the `email.` prefix: ``` queued → scheduled → sent → delivered → opened → clicked ↘ bounced / complained / failed / suppressed / canceled ``` Terminal states never regress. An open arriving after a bounce does not overwrite the bounce. Every state change writes a row to `email_events`, which is what the metrics endpoint aggregates over. ## Contacts, segments, and topics A **contact** is a person, unique by email within a team, with optional first and last names, an `unsubscribed` flag, and typed **contact properties** you define. A **segment** is a named group of contacts. Broadcasts target a segment. A **topic** is a subscription category — "Weekly newsletter", "Product updates" — letting recipients opt out of one kind of mail without opting out of everything. Each topic has a `default_subscription` of `opt_in` or `opt_out` that applies to anyone who has not expressed a preference. Sending with a `topic_id` gates each recipient: | Recipient | Outcome | |---|---| | Contact who opted in | Sent | | Contact who opted out | Skipped, `email.failed` | | Not a contact | Follows the topic's `default_subscription` | ## Suppressions The suppression list is the team's do-not-send list. Addresses land on it three ways: - `bounce` — a hard bounce, added automatically. - `complaint` — a spam report. - `manual` — added through the API or dashboard. Every send checks it per recipient. A suppressed address produces `email.suppressed` and is not delivered, which protects your sending reputation from repeatedly hitting a dead address. ## Templates A template holds content out of your codebase. Each edit creates a new **version**; publishing promotes a version to `current_version_id`. Sends always use the published version, so editing a template cannot break production mid-flight. Variables use triple mustaches with an optional inline fallback: ```html

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.`. Mail for addresses on that domain is parsed, stored with its attachments, and raises `email.received`. See [Receive inbound email](/tutorials/receive-inbound-email). ## Password reset The dashboard has a "Forgot your password?" flow. It sends the reset link **through this instance** — Outbox is an email service, so it delivers its own transactional mail rather than asking you to configure a second one. That has one consequence worth knowing before you need it: > **A brand new instance cannot send reset mail.** Until a domain is verified, > there is no address to send from, so the request is accepted, logged, and > nothing arrives. This is not a bug you can configure away — the first account > exists before any domain does. So: verify a domain early, and keep the owner password somewhere you can find it until you have. The log line to look for is ``` [outbox] password reset not sent — no verified sending domain and AUTH_FROM is unset ``` By default reset mail comes from `no-reply@`. Set `AUTH_FROM` to override that. Two things the flow deliberately does: - **It answers identically whether or not the address has an account.** An endpoint that needs no credentials and confirms which addresses are registered is an account-enumeration oracle; the detail goes to the log instead. - **A successful reset signs out every existing session.** If the reset happened because somebody else knew the password, leaving their session alive would defeat the point. Tokens are single-use, expire in an hour, and only their hash is stored — a database backup cannot be turned into working reset links. Reset mail goes through the ordinary send pipeline, which means **a suppressed address will not receive it**. Correct in general, maddening if it is yours: check `/suppressions` if a reset never arrives. ## Object storage By default everything lives in Postgres, attachments included. That is the right default — one thing to run, one thing to back up — but attachments are unlike everything else Outbox stores. A single 40MB attachment is larger than most teams' entire `emails` table, and base64 in a `text` column costs a third again on top. Send enough of them and the blobs dominate the database, every `pg_dump` carries them, and the disk fills long before the row count gets interesting. Point Outbox at a bucket and they go there instead, with Postgres keeping a key: ```sh STORAGE_BUCKET=my-bucket STORAGE_REGION=nyc3 STORAGE_ENDPOINT=https://nyc3.digitaloceanspaces.com # empty for AWS S3 STORAGE_ACCESS_KEY_ID=... STORAGE_SECRET_ACCESS_KEY=... ``` Any S3-compatible endpoint works — AWS, DigitalOcean Spaces, Cloudflare R2, MinIO, Backblaze B2. Give the key **read and write on that one bucket**, not full access. What moves: attachments on sent mail, attachments on received mail, and the raw MIME of received messages. Keys are namespaced `//// `, so one bucket serves every team and a team's objects can be found without consulting the database. Three things worth knowing: - **It is not a cutover.** Rows written before you configured a bucket keep their inline content and stay readable. Nothing backfills them and nothing needs to — the read path takes whichever column is populated. - **Turning it off strands anything already uploaded.** The objects survive, but an attachment with a key and no bucket configured is an error rather than an empty file, because silently sending a zero-byte attachment is worse than failing. - **The bucket becomes part of your backup.** `pg_dump` alone is no longer a complete backup once blobs live elsewhere. ## Backups Everything lives in Postgres — message bodies, DKIM private keys, and attachments too unless you configured object storage above. A standard `pg_dump` is a complete backup of the database. Two things deserve attention: - **DKIM private keys** are in `domains.dkim_private_key`. Losing them means republishing DNS for every domain. Treat the dump as a secret. - **Attachments make the database large** when they are stored inline. If you send many or large attachments, either configure a bucket or plan retention accordingly. ## Housekeeping Some tables grow without bound and nothing prunes them automatically, deliberately — retention is your policy, not ours: | Table | Grows with | Suggested | |---|---|---| | `api_logs` | Every API request | Keep 30 days | | `email_events` | Every state change | Keep as long as you want metrics | | `webhook_events` / `webhook_attempts` | Every event delivered | Keep 30 days | | `jobs` | Completed jobs | Delete `status = 'done'` older than a few days | | `rate_limits` | One row per bucket | Sweep windows older than your longest | ```sql DELETE FROM api_logs WHERE created_at < now() - interval '30 days'; DELETE FROM jobs WHERE status = 'done' AND updated_at < now() - interval '3 days'; ``` ## Health `GET /health` needs no authentication and checks database reachability: ```json { "status": "ok", "version": "0.1.0" } ``` It returns 503 with `"status": "degraded"` when Postgres is unreachable, which makes it suitable as a load balancer check. ## Upgrading ```sh git pull bun install bun run migrate ``` Migrations are forward-only in practice; `migrate:down` rolls back exactly one and is meant for development. Take a dump before upgrading production. --- # Send your first email > From a running instance to a delivered message, with the state machine explained. # Send your first email Assumes you have Outbox running from the [quickstart](/quickstart) and an API key. ## 1. Send it ```sh curl -X POST http://localhost:3000/emails \ -H "Authorization: Bearer $OUTBOX_API_KEY" \ -H "User-Agent: my-app/1.0" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["you@example.com"], "subject": "Hello from Outbox", "html": "

It works

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": "

It works

", "text": "It works" } ``` ### Reply-to ```json { "from": "Acme ", "reply_to": "support@acme.com" } ``` Sending from `noreply@` and offering nowhere to reply is a choice customers notice. ### Tags ```json { "tags": [ { "name": "category", "value": "welcome" }, { "name": "user_id", "value": "1234" } ] } ``` Tags come back on the email and in webhook payloads, so you can attribute events to your own concepts. Names and values allow letters, numbers, `_`, and `-`. ### Multiple recipients ```json { "to": ["a@example.com", "b@example.com"], "cc": ["c@example.com"] } ``` Everyone in `to` sees everyone else. For a mailing list you want either [batch send](/api/emails#send-a-batch) or a [broadcast](/tutorials/broadcast-to-a-segment) — not fifty addresses in one `to`. Maximum 50 across to, cc, and bcc. ### Attachments ```json { "attachments": [ { "filename": "receipt.pdf", "content": "JVBERi0xLjQK..." } ] } ``` Base64 in `content`, or a URL in `path` for Outbox to fetch. Up to 40MB. ### Scheduling ```json { "scheduled_at": "in 1 hour" } ``` Or ISO 8601. Scheduled emails can be [rescheduled or cancelled](/api/emails#reschedule) until they send. ## 4. Make retries safe Any network call can time out after the server accepted it. Without protection, your retry sends a second copy. ```sh curl -X POST http://localhost:3000/emails \ -H "Authorization: Bearer $OUTBOX_API_KEY" \ -H "User-Agent: my-app/1.0" \ -H "Idempotency-Key: order-1234-confirmation" \ -H "Content-Type: application/json" \ -d '{ … }' ``` Repeating that request with the same key replays the original response. Derive the key from the thing you are emailing about — an order id, an invoice number — not a random UUID, which defeats the purpose. ## 5. From an application ```ts import { Resend } from "resend" const resend = new Resend(process.env.OUTBOX_API_KEY, { baseUrl: process.env.OUTBOX_URL, }) export const sendWelcome = async (user: { email: string; name: string }) => { const { data, error } = await resend.emails.send({ from: "Acme ", to: [user.email], subject: `Welcome, ${user.name}`, html: `

Welcome

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": "Acme ", "to": ["you@gmail.com"], "subject": "Authentication check", "html": "

Checking 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": "Acme ", "subject": "Reset your password", "html": "

Hi {{{first_name|there}}},

Reset your password

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 `