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
- 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. - 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
- 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:
- 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.
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
- Accepted (
queued): checked and stored; the call returns. Recipients on your suppression list are dropped first (listed insuppressed, not counted). - Sent (
sent): handed to the mail servers, usually within seconds. Throttling or outages are retried for about 2 hours (lastErrorsays why); if it still can't go out it'sfailedand not counted. - Delivered, bounced, or complained: reported by the receiving servers.
emails.get(id).eventslists 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
- 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:
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
400 invalid_request: a field is wrong (the message names it): a bad address, nohtmlortext, 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.