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:
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
  1. 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:

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:

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.

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, ...):

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.

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

Attachments

Up to 10 attachments, 10 MB in total:

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:

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:

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

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.