Email infrastructure
EmailMate Docs
Rails for agents and humans who build email products. REST for apps, MCP for agents, dashboard for operators. Same domains, keys, logs, and reputation. SES under the hood.
Base URL
https://www.emailmate.dev/v1
Auth
Bearer em_…
MCP
https://mcp.emailmate.dev/mcp
OpenAPI · Scalar
/api-reference
https://www.emailmate.dev/v1/openapi.json
Platform
Three doors on one send stack. Use cases (newsletters, ship notes, outreach) are things you build — not separate products.
| Door | Who | Surface |
|---|---|---|
| REST | Apps and CI | /v1/emails · /inboxes · /domains · /api-keys · /webhooks |
| MCP | Agents | mcp.emailmate.dev — same keys and From rules |
| Dashboard | Humans | /dashboard — logs, inbox, DNS, suppressions |
mail. subdomain = marketing, newsletter, outreach. Never mix.Quickstart
Verify a domain → mint a domain-scoped key → send. Target under 15 minutes.
1. Install the SDK
bun add emailmate
# npm install emailmate2. Send (Resend-shaped)
import EmailMate from "emailmate";
const em = new EmailMate(process.env.EMAILMATE_API_KEY!);
const { id } = await em.emails.send({
from: "Acme <hello@acme.com>",
to: "user@example.com",
subject: "Welcome",
html: "<p>You're in.</p>",
text: "You're in.",
});3. curl
curl https://www.emailmate.dev/v1/emails \
-H "Authorization: Bearer em_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <hello@acme.com>",
"to": "user@example.com",
"subject": "Welcome",
"html": "<p>Hi</p>",
"text": "Hi"
}'4. Coming from Resend?
// Before
import { Resend } from "resend";
// After — drop-in class export
import { Resend } from "emailmate";
const resend = new Resend(process.env.EMAILMATE_API_KEY!);
await resend.emails.send({ from, to, subject, html });Authentication
Every REST call needs a Bearer key. Keys start with em_ (legacy re_ / ml_ still resolve). Prefer domain-scoped keys — From ownership is fail-closed.
Authorization: Bearer em_xxxxxxxxxxxx
Content-Type: application/json| Field | Meaning |
|---|---|
| permission | full_access (manage) or sending_access (send only) |
| domainId | When set, From must match that verified domain |
| tokenHash | Only form stored server-side; plaintext shown once |
Sending rules
Production lessons, enforced so Gmail stays green.
| Rule | Why |
|---|---|
| Always set from | Explicit From every send — don't rely on app defaults for marketing |
| Split domains | root = transactional · mail.* = marketing / newsletter / outreach |
| bucket: "marketing" | List-Unsubscribe + bucket suppressions. Never on OTP / magic links |
| html + text | Always include both for deliverability |
| reply_to matches channel | Marketing Reply-To on mail.*; transactional on root |
| Verified domain only | Domain-scoped key → that domain; unscoped → owned verified domain |
// Transactional (OTP, receipts)
await em.emails.send({
from: "Acme <hello@acme.com>",
to, subject, html, text,
// omit bucket
});
// Marketing one-shot via API
await em.emails.send({
from: "Acme <jimmy@mail.acme.com>",
to, subject, html, text,
bucket: "marketing",
reply_to: "jimmy@mail.acme.com",
});MCP for agents
Agent-native surface mirrors REST. Default URL is rails only (send, domains, logs, keys). Inbox tools need https://mcp.emailmate.dev/mcp?sets=inbound. OAuth consent at https://www.emailmate.dev/oauth/mcp, or Bearer key on Streamable HTTP.
claude mcp add --transport http emailmate https://mcp.emailmate.dev/mcp// .cursor/mcp.json
{
"mcpServers": {
"emailmate": {
"type": "http",
"url": "https://mcp.emailmate.dev/mcp"
}
}
}| Domain | Tools |
|---|---|
| Sending | em_send, em_send_batch, em_send_template, em_get_status |
| Domains / keys | em_domain_*, em_domain_enable_inbound, em_key_* |
| Templates / logs | em_template_*, em_log_* |
| Broadcasts | em_broadcast_*, em_audience_*, em_contact_*, em_segment_* |
| Newsletters | em_newsletter_*, em_issue_* |
| Outreach | em_outreach_* |
| Webhooks / suppress / reputation | em_webhook_*, em_suppress_*, em_reputation_* |
| Inbox (opt-in `?sets=inbound`) | em_inbox_list_inboxes, em_inbox_create, em_inbox_send, em_inbox_reply, em_inbox_forward, em_inbox_*draft*, em_inbox_*list* |
Core
Transactional send
Send, batch, domains, keys, templates, webhooks, suppressions. This is the product.
Send email
Programmatic transactional mail — OTP, receipts, product notifications. Response shape is Resend-compatible: { "id": "…" }.
/v1/emailsSend one email
Body
| Parameter | Type | Description |
|---|---|---|
fromrequired | string | Sender. "Name <user@domain.com>" or bare email |
torequired | string | string[] | Recipient(s). Max 50 per request |
subjectrequired | string | Subject line (or from template) |
html | string | HTML body |
text | string | Plain text body |
reply_to | string | string[] | Reply-To header |
cc | string | string[] | CC recipients |
bcc | string | string[] | BCC recipients |
tags | { name, value }[] | Metadata for logs / filters |
headers | Record<string, string> | Custom headers |
attachments | Attachment[] | filename + content (+ content_type) |
bucket | string | Set "marketing" for promo path + List-Unsubscribe |
template | string | Template slug — fills subject/html from registry |
data | object | Variables when using template |
{
"from": "Acme <hello@acme.com>",
"to": ["user@example.com"],
"subject": "Your code is 482910",
"html": "<p>482910</p>",
"text": "482910",
"tags": [{ "name": "category", "value": "otp" }]
}/v1/emailsList delivery logs
/v1/emails/:idRetrieve one email / log
Batch send
Send up to 100 messages in one request (Resend batch shape).
/v1/emails/batchArray of email objects · max 100
[
{ "from": "Acme <hello@acme.com>", "to": "a@x.com", "subject": "Hi", "html": "<p>A</p>" },
{ "from": "Acme <hello@acme.com>", "to": "b@x.com", "subject": "Hi", "html": "<p>B</p>" }
]Templates
CRUD templates in the API, or use registry templates from emailmate/templates (React Email). Send with template + data on POST /emails.
/v1/templatesList templates
/v1/templatesCreate template
/v1/templates/:idGet template
/v1/templates/:idUpdate template
/v1/templates/:idDelete template
// POST /v1/emails with template
{
"from": "Acme <hello@acme.com>",
"to": "user@example.com",
"template": "welcome",
"data": { "name": "Ada" }
}Domains
Add domain → publish SPF, DKIM, MAIL FROM → verify. Status until verified. Add DMARC (p=none OK to start) before scale.
/v1/domainsCreate · returns DNS records
| Parameter | Type | Description |
|---|---|---|
namerequired | string | e.g. acme.com or mail.acme.com |
/v1/domainsList domains
/v1/domains/:idStatus + DNS
/v1/domains/:id/verifyRe-check SES
/v1/domains/:idRemove domain
Bring your own SES
EmailMate is a SES plugin. Cloud uses our AWS account. BYOK uses yours. Same REST, MCP, and desk. Resend does not let you plug in keys.
IAM
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"ses:SendEmail",
"ses:SendRawEmail",
"ses:GetAccount",
"ses:CreateEmailIdentity",
"ses:DeleteEmailIdentity",
"ses:GetEmailIdentity",
"ses:PutEmailIdentityMailFromAttributes"
],
"Resource": "*"
}]
}Self-host later
The same plugin reads AWS_SES_* env vars. Connecting keys in the desk is the cloud form of that plugin.
API keys
Create in dashboard or API. Domain-scoped keys can only send From that domain.
/v1/api-keysList keys
/v1/api-keysCreate key (token once)
/v1/api-keys/:idRevoke key
Webhooks
Customer endpoints for delivery events — Resend-style CRUD. Secret whsec_… returned once on create. SES system ingestion is separate: POST /v1/webhooks/ses (no Bearer).
/v1/webhooksCreate endpoint
{
"name": "Prod",
"url": "https://app.example.com/hooks/email",
"events": [
"email.delivered",
"email.bounced",
"email.complained"
]
}/v1/webhooksList endpoints
/v1/webhooks/:idGet endpoint
/v1/webhooks/:idUpdate endpoint
/v1/webhooks/:idDelete endpoint
Events
| Event | When |
|---|---|
| email.sent | Accepted into queue / SES |
| email.delivered | Mailbox accepted |
| email.delivery_delayed | Temporary deferral |
| email.opened | Open pixel (when tracked) |
| email.clicked | Link click (when tracked) |
| email.bounced | Hard / soft bounce |
| email.complained | Spam complaint |
| email.received | Inbound mail threaded in Inbox |
| contact.created | Audience contact added |
| contact.unsubscribed | Unsub / preference |
Inbox
Named mailboxes on your verified domain. Enable receive on the domain page (Cloudflare Email Routing catch-all). Create agent@your-domain — or omit the username for a random local-part. Unknown addresses land in catch-all (receive-only). Reply and send go out via SES. MCP inbox tools are opt-in: https://mcp.emailmate.dev/mcp?sets=inbound.
/v1/domains/:id/inboundEnable Cloudflare Email Routing catch-all
/v1/inboxesList inboxes
/v1/inboxesCreate named inbox (username optional)
/v1/inboxes/:inboxId/threadsList threads
/v1/inboxes/:inboxId/messagesSend from this mailbox
/v1/inboxes/:inboxId/messages/:id/replyReply (reply_all optional)
/v1/inboxes/:inboxId/messages/:id/forwardForward
/v1/inboxes/:inboxId/draftsList drafts
/v1/inboxes/:inboxId/drafts/:id/sendSend a draft
/v1/inboxes/:inboxId/listsAllow / block senders
/v1/inbox/threadsLegacy: list threads across inboxes
Suppressions
Hard bounces and complaints auto-suppress. Marketing respects bucket prefs. Never re-mail suppressed addresses.
/v1/suppressionList suppressions
/v1/suppressionAdd suppression
/v1/suppression/:idRemove (careful)
/v1/suppressions/syncPartner / host preference sync
GDPR
Export and erase paths for compliance workflows.
/v1/gdpr/accessData export for a subject
/v1/gdpr/erasureErase logs for a subject
Lists
Campaigns
Send one HTML to a list. Always from a marketing subdomain. List-Unsubscribe is automatic on broadcasts. Not a Mailchimp replacement — list-send on the same rails as OTP.
| Mailchimp concept | EmailMate |
|---|---|
| Audience / list | POST /v1/audiences |
| Subscribers | POST /v1/audiences/:id/contacts |
| Segments | POST /v1/segments (filter AST) |
| Campaign | POST /v1/broadcasts + /send |
| Tags | Contact tags + email tags |
| Unsubscribe | One-click + /v1/unsubscribe/:token |
Audiences
Named lists for campaigns and newsletters.
/v1/audiencesCreate audience
| Parameter | Type | Description |
|---|---|---|
namerequired | string | List name |
/v1/audiencesList audiences
/v1/audiences/:idGet audience
/v1/audiences/:idDelete audience
Contacts
Nested under audiences (Resend / Mailchimp-style). Supports first/last name and unsubscribed flag. Import merges on duplicate email where enabled.
/v1/audiences/:audience_id/contactsAdd contact
| Parameter | Type | Description |
|---|---|---|
emailrequired | string | Contact email |
first_name | string | First name |
last_name | string | Last name |
unsubscribed | boolean | Default false |
{
"email": "ada@example.com",
"first_name": "Ada",
"last_name": "Lovelace"
}/v1/audiences/:audience_id/contactsList contacts
/v1/audiences/:audience_id/contacts/:idGet contact
/v1/audiences/:audience_id/contacts/:idUpdate contact
/v1/audiences/:audience_id/contacts/:idRemove contact
Segments
Mailchimp-style filters over an audience. AST: { op: "and"|"or", clauses: [{ field, cmp, value }] }. Broadcasts may target a segment_id when supported on the desk.
/v1/segments?audience_id=List segments for an audience
/v1/segmentsCreate segment
{
"name": "VIPs",
"audience_id": "aud_…",
"filter": {
"op": "and",
"clauses": [{ "field": "tag", "cmp": "eq", "value": "vip" }]
}
}| Field | Use |
|---|---|
| tag | Contact tag match |
| source | Import / form source |
| status | Subscription status |
| unsubscribed | Boolean |
| email / firstName | Identity |
| hasEmail | Has address |
| cf:<key> | Custom field |
Campaigns (broadcasts)
Create a draft → send now or schedule. Sends use the marketing bucket + List-Unsubscribe. From domain must be verified.
/v1/broadcastsCreate draft campaign
| Parameter | Type | Description |
|---|---|---|
namerequired | string | Internal campaign name |
subjectrequired | string | Email subject |
audience_idrequired | string | Target list |
fromrequired | string | "Name <jimmy@mail.acme.com>" |
html | string | HTML body |
preview_text | string | Inbox preview |
reply_to | string | Reply-To |
template_id | string | Optional template |
{
"name": "Launch",
"subject": "We're live",
"audience_id": "aud_…",
"from": "Jimmy <jimmy@mail.acme.com>",
"html": "<p>Ship day.</p>",
"preview_text": "Ship day",
"reply_to": "jimmy@mail.acme.com"
}/v1/broadcastsList campaigns (?status=)
/v1/broadcasts/:idGet campaign
/v1/broadcasts/:idUpdate draft
/v1/broadcasts/:id/sendSend or schedule
// optional body
{ "scheduled_at": "2026-08-01T15:00:00.000Z" }/v1/broadcasts/:id/cancelCancel scheduled send
const b = await em.broadcasts.create({
name: "Launch",
audience_id: "aud_…",
from: "Jimmy <jimmy@mail.acme.com>",
subject: "We're live",
html: "<p>Ship day.</p>",
});
await em.broadcasts.send(b.id);Publications
/v1/newslettersCreate publication
| Parameter | Type | Description |
|---|---|---|
namerequired | string | Publication name |
from_namerequired | string | From display name |
from_emailrequired | string | Verified From address (prefer mail.*) |
slug | string | Public URL slug |
description | string | About blurb |
reply_to | string | Reply-To |
audience_id | string | Bind to existing list (optional) |
double_opt_in | boolean | Confirm email before subscribe |
public_subscribe | boolean | Enable /s/{slug} page |
{
"name": "Weekly Ship",
"from_name": "Acme",
"from_email": "jimmy@mail.acme.com",
"slug": "weekly-ship",
"public_subscribe": true,
"double_opt_in": false
}/v1/newslettersList publications
/v1/newsletters/:idGet publication
/v1/newsletters/:idUpdate publication
Also supports archive_public for public issue archives.
Issues
Draft issues → send to the publication audience. Status: draft · scheduled · sending · sent · cancelled.
/v1/newsletters/:id/issuesCreate issue
| Parameter | Type | Description |
|---|---|---|
titlerequired | string | Internal / archive title |
subject | string | Email subject |
preview_text | string | Inbox preview |
html | string | HTML body |
text | string | Plain text |
/v1/newsletters/:id/issuesList issues
/v1/issues/:idGet issue
/v1/issues/:idUpdate draft issue
/v1/issues/:id/sendSend issue to list
const nl = await em.newsletters.create({
name: "Weekly Ship",
from_name: "Acme",
from_email: "jimmy@mail.acme.com",
public_subscribe: true,
});
const issue = await em.newsletters.createIssue(nl.id, {
title: "Issue 12",
subject: "What shipped this week",
html: "<p>…</p>",
});
await em.newsletters.sendIssue(issue.id);Subscribe pages
When public_subscribe is true, the publication is live at:
Public subscribe
https://www.emailmate.dev/s/{slug}
Archive (when enabled)
https://www.emailmate.dev/s/{slug}/archive
No embed iframe required for MVP — share the URL. Double opt-in sends a confirm message when enabled.
Use case
Ship notes
Product updates are a campaign, not a product. Same POST /v1/broadcasts path, marketing domain, one-click unsub.
Use case
Outreach
Cold send with daily caps on the marketing domain. You build the product; we provide the rails.
Cold campaigns
Daily caps protect reputation. Marketing domain only. Start queues min(list, daily_cap) via the marketing path. Cap clamped 1–500 (default 50).
/v1/outreachCreate campaign
{
"name": "Builder intros",
"subject": "Quick question",
"audience_id": "aud_…",
"from_name": "Jimmy",
"from_email": "jimmy@mail.acme.com",
"daily_cap": 50,
"html": "<p>…</p>",
"notes": "opt-in list only"
}/v1/outreachList campaigns
/v1/outreach/:idGet campaign
/v1/outreach/:idUpdate campaign
/v1/outreach/:id/startStart / resume
/v1/outreach/:id/pausePause
SDK
Official TypeScript client. Resources: emails · domains · inboxes · apiKeys · templates · webhooks · audiences · contacts · segments · broadcasts · newsletters · outreach · suppressions · gdpr.
import EmailMate from "emailmate";
const em = new EmailMate(process.env.EMAILMATE_API_KEY!);
// Transactions
await em.emails.send({ from, to, subject, html, text });
// Inbox (named mailbox on a verified domain)
const box = await em.inboxes.create({ domain: "acme.com", username: "agent" });
await em.inboxes.messages.send(box.id, { to, subject, text });
// Segments
await em.segments.create({
name: "VIPs", audience_id, filter: { op: "and", clauses: [{ field: "tag", cmp: "eq", value: "vip" }] },
});
// Marketing
const b = await em.broadcasts.create({
audience_id, from: "Name <mail@domain.com>", subject, html,
});
await em.broadcasts.send(b.id);
// Newsletter
const nl = await em.newsletters.create({
name: "Weekly", from_name: "Acme", from_email: "jimmy@mail.acme.com",
});
const issue = await em.newsletters.createIssue(nl.id, { title: "Ship notes", html });
await em.newsletters.sendIssue(issue.id);
// Outreach
const o = await em.outreach.create({
name: "Cold A", subject, audience_id,
from_name: "Jimmy", from_email: "jimmy@mail.acme.com", daily_cap: 40,
});
await em.outreach.start(o.id);Rate limits
Per API key, by plan. Exceeded → 429 with Retry-After. Agent keys on Free are stricter.
| Plan | Default | Agent key |
|---|---|---|
| Free | 2/s · 100/h · 500/d | 1/s · 50/h · 200/d |
| Pro | 10/s · 1k/h · 20k/d | 5/s · 500/h · 5k/d |
| Scale | 20/s · 5k/h · 50k/d | 10/s · 2k/h · 20k/d |
Errors
JSON error bodies. Honor status codes for retries.
| Status | Meaning |
|---|---|
| 400 | Bad request |
| 401 | Missing or invalid API key |
| 403 | Not allowed (domain, key scope, or account paused) |
| 404 | Resource not found |
| 422 | Validation error |
| 429 | Rate limited — use Retry-After |
| 503 | Temporarily unavailable — retry shortly |
| 500 | Server error — exponential backoff |
{
"statusCode": 422,
"message": "Domain not verified",
"name": "validation_error"
}OpenAPI
Machine-readable contract for codegen, agents, and Scalar UI.
JSON spec
https://www.emailmate.dev/v1/openapi.json
Interactive
| System | Path |
|---|---|
| Health (no auth) | GET /v1/health |
| API root / tool map | GET /v1/ |
| One-click unsub | POST /v1/unsubscribe/:token |