# Oakpost: email

Send transactional email (receipts, invoices, password resets, notifications) from your own domain, with PDFs attached in the same call. Receive email at an Oakstack address and get it in your app as a webhook. Every email is logged with what happened to it: sent, delivered, bounced, complained.

## Quick start

1. Get an API key at https://oakstack.dev/dashboard/api-keys. Use a **test key** (`ok_test_...`) while building: emails are checked and logged exactly like real ones, but nothing is delivered.
2. Send:

```ts
import { Oakstack } from "oakstack";
const oakstack = new Oakstack();

const email = await oakstack.post.emails.send({
  from: "Acme <hello@acme.com>",
  to: "riley@example.com",
  subject: "Your order #1042",
  html: "<p>Thanks for your order. Your invoice is attached.</p>",
  text: "Thanks for your order. Your invoice is attached.",
  attachments: [
    {
      filename: "invoice-1042.pdf",
      print: { template: "invoice", data: invoiceData }, // rendered by Oakprint
    },
  ],
  metadata: { orderId: "1042" },
});
// email.status === "queued"; delivery takes seconds.

const detail = await oakstack.post.emails.get(email.id); // status + every event
```

3. Before sending live (an `ok_live_` key), add and verify your domain (below). Live sending needs a paid plan, except to your own workspace members.

Or with an agent: the MCP tools `post_status`, `post_add_domain`, `post_send_email`, and `post_get_email`. With curl:

```sh
curl https://oakstack.dev/api/v1/post/emails \
  -H "Authorization: Bearer $OAKSTACK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"from":"Acme <hello@acme.com>","to":"riley@example.com","subject":"Hi","html":"<p>Hi</p>"}'
```

## Test mode

With an `ok_test_` key, every rule that doesn't depend on delivery is applied (addresses, size limits, attachments, the monthly limit) and the email is logged, but nothing leaves Oakstack. Delivery is simulated a moment later:

- any address: `delivered`
- `bounced@test.oakstack.dev`: `bounced` (permanent)
- `complained@test.oakstack.dev`: `complained`

Test emails can use any From address, don't need a verified domain, and never affect your suppression list or sending reputation. They do count toward the monthly limit.

## Sending domains

Live email must come from a domain you've verified. Verification proves you own it and sets up DKIM signing, which is what keeps your email out of spam folders.

```ts
const domain = await oakstack.post.domains.create({ domain: "acme.com" });
domain.records; // [{ type, name, value, purpose, required }]
```

Add the records at your DNS provider (wherever the domain's DNS is managed: Vercel, Cloudflare, GoDaddy, Namecheap, ...):

- **3 CNAME records** (`<token>._domainkey.acme.com`): **required**. DKIM.
- **MX and TXT on `bounce.acme.com`**: recommended. Bounces go to a subdomain so SPF lines up with your domain. Doesn't touch your normal inbox.
- **TXT on `_dmarc.acme.com`**: recommended (Gmail and Yahoo expect one). Skip it if the domain already has a DMARC record.

Some DNS providers add the domain to the name automatically: if the name field already ends in `.acme.com`, enter only the first part (e.g. `abc123._domainkey`).

The domain is checked automatically (every 5 minutes for the first hour, then every 30 minutes) and becomes `verified` by itself, usually within an hour. `oakstack.post.domains.verify(id)` checks right away. A domain that isn't verified within 72 hours becomes `failed`: fix the records, delete it, and add it again. Sending from any address at a verified domain, or a subdomain of it (`mail.acme.com`), works.

## Who you can send to

| Key                          | Recipients                     | From              |
| ---------------------------- | ------------------------------ | ----------------- |
| `ok_test_` (any plan)        | anyone (nothing is delivered)  | anything          |
| `ok_live_` on Free           | members of your workspace only | a verified domain |
| `ok_live_` on Builder/Studio | anyone                         | a verified domain |

Free plans can't email strangers because there's no card on file: it's how Oakstack keeps spammers off the shared sending infrastructure. Upgrade at https://oakstack.dev/dashboard/billing.

`oakstack.post.status()` tells you what applies right now: `liveSendingEnabled`, `status` (`active` or `paused`), `membersOnly`, and today's `dailyLimit`.

## What happens to an email

1. **Accepted** (`queued`): checked and stored; the call returns. Recipients on your suppression list are dropped first (listed in `suppressed`, not counted).
2. **Sent** (`sent`): handed to the mail servers, usually within seconds. Throttling or outages are retried for about 2 hours (`lastError` says why); if it still can't go out it's `failed` and not counted.
3. **Delivered**, **bounced**, or **complained**: reported by the receiving servers. `emails.get(id).events` lists every step per recipient, including the receiving server's reason for a bounce.

Statuses: `queued`, `sending`, `sent`, `delivered`, `delivery_delayed`, `bounced`, `complained`, `failed`, `suppressed` (every recipient was suppressed; nothing sent), `canceled`.

Schedule an email up to 30 days ahead with `scheduledAt: "2026-11-01T09:00:00Z"`, and cancel it before then with `oakstack.post.emails.cancel(id)` (recipients are given back).

## Suppression list (automatic)

When an address **hard-bounces** (it doesn't exist) or **marks your email as spam**, it's added to your workspace's suppression list and skipped on every later send. This protects your domain's reputation, and it's checked again right before each email goes out.

```ts
await oakstack.post.suppressions.add({ email: "someone@example.com" }); // e.g. they asked you to stop
await oakstack.post.suppressions.list();
await oakstack.post.suppressions.delete("someone@example.com"); // bounces and manual entries only
```

Addresses that complained can't be removed.

## Sending limits that protect your domain

- **Monthly limit** per plan (Free 100, Builder 10,000, Studio 50,000), counted per recipient (to + cc + bcc). Hitting it returns `usage_limit_reached`; nothing is sent.
- **New-sender daily limit**: 200 recipients a day for your first week of live sending, 1,000 a day in week 2, 5,000 a day in weeks 3 and 4, then just the monthly limit. Inbox providers trust new domains gradually; this keeps you out of spam folders. Also returns `usage_limit_reached`, with the numbers.
- **Automatic pause**: if more than 5% of your recipients bounce, or more than 0.1% mark your email as spam, over the last 30 days, live sending pauses (`sending_paused`). Queued emails fail without being counted. Test keys keep working. Email support@oakstack.dev to review and resume. Clean lists (only people who asked for your email) never get near these numbers.

## Attachments

Up to 10 attachments, 10 MB in total:

```ts
attachments: [
  { filename: "report.csv", content: base64String }, // contentType is guessed from the name
  { filename: "invoice.pdf", print: { template: "invoice", data } }, // Oakprint template
  { filename: "receipt.pdf", print: { html: "<h1>Receipt</h1>..." } }, // your HTML
];
```

`print` attachments are rendered before the call returns (it can take a few seconds) and count as PDFs too; the `pdfId` is on the attachment. If rendering fails, nothing is sent and the error names the attachment. Executable files (`.exe`, `.js`, `.bat`, ...) are refused, since every inbox provider blocks them.

## Receiving email (inbound)

Create an address, and mail sent to it arrives at one of your Oakhook webhook endpoints as JSON:

```ts
const endpoint = await oakstack.hook.endpoints.create({
  name: "Support inbox",
  forwardUrl: "https://yourapp.com/api/inbound-email",
});
const inbox = await oakstack.post.inbound.create({ name: "Support", hookEndpointId: endpoint.id });
inbox.address; // e.g. 4kq9x2...@in.oakstack.dev: give this out, or forward your own address to it
```

Your route receives a `POST` with `Content-Type: application/json`:

```json
{
  "object": "post.inbound_email",
  "address": "4kq9x2...@in.oakstack.dev",
  "from": { "address": "sam@example.com", "name": "Sam" },
  "to": [{ "address": "4kq9x2...@in.oakstack.dev", "name": "" }],
  "cc": [],
  "replyTo": [],
  "subject": "Help with my order",
  "text": "Hi, ...",
  "html": "<p>Hi, ...</p>",
  "date": "2026-10-04T17:30:00.000Z",
  "messageId": "<abc@example.com>",
  "inReplyTo": null,
  "references": [],
  "attachments": [
    {
      "filename": "photo.jpg",
      "contentType": "image/jpeg",
      "sizeBytes": 48211,
      "content": "<base64>"
    }
  ],
  "spamVerdict": "PASS",
  "virusVerdict": "PASS",
  "truncated": false
}
```

It's an ordinary Oakhook event: verify `Oakstack-Signature` with the endpoint's secret, and you get retries, replay, and the event log for free. Events are capped at 1 MB: larger attachments arrive with `content: null` and `truncated: true`. Mail that fails the virus check is dropped. Inbound mail counts toward webhook events, not emails. Mail to your own domain can be forwarded to the address with your mail provider's forwarding rules.

## Errors

- `400 invalid_request`: a field is wrong (the message names it): a bad address, no `html` or `text`, more than 50 recipients, a reserved header.
- `403 not_allowed`: a rule blocks this email: an unverified From domain, or a stranger on the Free plan. The message says which.
- `403 sending_paused`: live sending is paused for bounces or complaints (see above).
- `429 usage_limit_reached`: the monthly limit or the new-sender daily limit. Not retried.
- `503 email_not_enabled`: live email isn't switched on for Oakstack yet. Use a test key meanwhile.
- `413 payload_too_large`: attachments over 10 MB.

## API reference

| Method   | Path                               | What it does                                                                                                                                     |
| -------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `POST`   | `/api/v1/post/emails`              | Send. Body: `from`, `to`, `subject`, `html` and/or `text`; optional `cc`, `bcc`, `replyTo`, `headers`, `attachments`, `metadata`, `scheduledAt`. |
| `GET`    | `/api/v1/post/emails`              | Recent emails. `?status=bounced`, `?limit=` (max 100).                                                                                           |
| `GET`    | `/api/v1/post/emails/:id`          | One email with its body and events.                                                                                                              |
| `POST`   | `/api/v1/post/emails/:id/cancel`   | Cancel a scheduled email. Full-access keys only.                                                                                                 |
| `POST`   | `/api/v1/post/domains`             | Add a domain: `{ "domain": "acme.com" }`.                                                                                                        |
| `GET`    | `/api/v1/post/domains`             | Domains with status and DNS records.                                                                                                             |
| `GET`    | `/api/v1/post/domains/:id`         | One domain.                                                                                                                                      |
| `POST`   | `/api/v1/post/domains/:id/verify`  | Check DNS now.                                                                                                                                   |
| `DELETE` | `/api/v1/post/domains/:id`         | Remove a domain. Full-access keys only.                                                                                                          |
| `GET`    | `/api/v1/post/suppressions`        | The suppression list.                                                                                                                            |
| `POST`   | `/api/v1/post/suppressions`        | Add `{ "email": "..." }`.                                                                                                                        |
| `DELETE` | `/api/v1/post/suppressions/:email` | Remove a bounce or manual entry. Full-access keys only.                                                                                          |
| `POST`   | `/api/v1/post/inbound`             | New inbound address: `{ "name", "hookEndpointId" }`.                                                                                             |
| `GET`    | `/api/v1/post/inbound`             | Inbound addresses.                                                                                                                               |
| `DELETE` | `/api/v1/post/inbound/:id`         | Remove one. Full-access keys only.                                                                                                               |
| `GET`    | `/api/v1/post/status`              | Whether live email can go out right now.                                                                                                         |

Every `POST` accepts an `Idempotency-Key` header (the SDK sends one automatically), so a retried send never goes out twice. Up to 50 recipients per email; up to 20 custom headers (not `From`, `To`, `Subject`, `Content-*`, and other headers Oakstack sets). Plans: domains Free 1, Builder 5, Studio 20; inbound addresses Free 1, Builder 10, Studio 50.
