Introduction
MailySend is a Resend-compatible email platform that runs on Cloudflare Workers. One REST API covers transactional sends, marketing broadcasts, automations, inbound mail and analytics — and if you already call Resend, the request bodies, status values and webhook names here are the ones you know. Everything is MIT licensed, so you can read the source, fork it, or deploy the platform into your own Cloudflare account.
Quickstart
Three minutes from zero to a delivered email.
$ npm install mailysend
import { MailySend } from 'mailysend';
const ms = new MailySend(process.env.MAILYSEND_API_KEY);
const { data, error } = await ms.emails.send({
from: 'MailySend <[email protected]>',
to: ['[email protected]'],
subject: 'Hello from the edge',
html: '<p>It works.</p>',
tags: [{ name: 'category', value: 'welcome' }],
});$ npx mailysend tail12:04:03 em_7Kq2xR accepted [email protected]12:04:04 em_7Kq2xR delivered gmail-smtp-in · 41ms
Domains & DNS
Add a domain, then publish three records. If the domain is on Cloudflare DNS we write them for you and verify in seconds; anywhere else, copy them into your provider and hit verify.
| TYPE | NAME | VALUE |
|---|---|---|
| TXT | send.yourdomain.com | v=spf1 include:spf.mailysend.com ~all |
| TXT | ms1._domainkey | p=MIGfMA0GCSq… (2048-bit DKIM) |
| TXT | _dmarc | v=DMARC1; p=none; rua=mailto:dmarc@… |
Every domain gets its own DKIM key pair, a click-tracking subdomain you control, and an optional custom return-path. DMARC reports are parsed for you — see DMARC analytics.
Authentication
Keys are scoped by permission, domain and environment. A leaked send-only key can’t read your logs or export contacts.
curl https://api.mailysend.com/v1/api-keys \ -H "Authorization: Bearer $MAILYSEND_API_KEY" \ -d '{ "name": "prod worker", "permission": "sending_access", "domain_id": "dom_9f2" }'
Emails API
Batch & scheduling
Batch sends fan out through Cloudflare Queues, so one slow recipient domain never blocks the rest. Scheduled mail is held in a Durable Object alarm — cancel or reschedule any time before it fires.
await ms.emails.batch([
{ from: f, to: '[email protected]', subject: 'Receipt', template_id: 'tpl_r1' },
{ from: f, to: '[email protected]', subject: 'Receipt', template_id: 'tpl_r1' },
], { schedule_at: '2026-09-10T09:00:00Z' });Attachments
Pass base64 content, or a URL we fetch at send time. Files land in R2 in your own bucket when self-hosting.
- cloudflare — 5 MiB per message, raised to 25 MiB only when the destination address is verified.
- ses — 40 MB per message, including encoding overhead.
- smtp — whatever the carrying transport allows; the relay adds no ceiling of its own.
attachments: [
{ filename: 'invoice.pdf', path: 'https://cdn.acme.dev/inv/4821.pdf' },
{ filename: 'terms.txt', content: base64, content_type: 'text/plain' },
]Idempotency & tags
Send Idempotency-Key and a retry inside 24 hours returns the original email instead of a duplicate. Tags are indexed, so every chart and log filter can slice by them.
-H "Idempotency-Key: order-4821-receipt" tags: [ { name: 'category', value: 'receipt' }, { name: 'plan', value: 'pro' }, ]
Templates (JSX, MJML, Handlebars)
Store templates server-side, version every change, and send by template_id — so marketing can fix a typo without a deploy. Or render React locally and send HTML. Both paths preview against 40 clients.
// templates/LoginCode.jsx import { Html, Text, Button } from '@mailysend/jsx'; export default ({ code }) => ( <Html> <Text>Your code is {code}</Text> <Button href="https://acme.dev/verify">Verify</Button> </Html> ); $ npx mailysend templates push # versioned, instant rollback
Audiences & contacts
Contacts live in D1 with arbitrary custom fields. Segments are saved SQL-ish filters that stay live — plan = 'pro' AND last_open < 30d.
Broadcasts
Create in the API or the visual editor — either one is editable in both places. A broadcast’s progress lives in a Durable Object, so you get live counts, pause/resume, throttle control and per-link click maps.
const b = await ms.broadcasts.create({
audience_id: 'aud_2Kx',
from: '[email protected]',
subject: 'September changelog',
html: '<p>Hi {{first_name}} …</p>',
ab_test: { subject_b: 'What shipped in September', split: 0.2 },
});
await ms.broadcasts.send(b.id, { throttle_per_minute: 5000 });AutomationsRUNS IN YOUR ACCOUNT
Drip sequences, welcome flows and win-backs run on Cloudflare Workflows: durable steps, waits measured in days, branching on your own events. No external orchestration, no cron soup — and the workflow executes in your account, against your data.
await ms.automations.create({
name: 'Onboarding',
trigger: { event: 'user.signed_up' },
steps: [
{ send: 'tpl_welcome' },
{ wait: '2 days' },
{ branch: { if: 'contact.projects == 0',
then: [{ send: 'tpl_nudge' }] } },
],
});Inbound email
Point a catch-all rule at MailySend and inbound mail arrives as parsed JSON: headers, text, HTML, attachments in R2, spam score, and the thread it belongs to. Sub-addressing ([email protected]) and HMAC-signed reply headers keep replies routed to the right object.
{
"type": "email.received",
"thread_id": "thr_5Nq",
"from": "[email protected]",
"spam_score": 0.02,
"attachments": [{ "r2_key": "inb/5Nq/photo.png" }]
}Webhooks
Signed with an HMAC timestamp, retried with exponential backoff for 24 hours, and replayable from the dashboard. Every attempt keeps its response code and body so you can debug your own endpoint.
- email.sent
- email.delivered
- email.delivery_delayed
- email.opened
- email.clicked
- email.bounced
- email.complained
- email.received
- contact.unsubscribed
- broadcast.finished
Suppressions
Hard bounces and complaints are suppressed automatically in KV, per workspace, within milliseconds — and a suppressed send returns a clear 422 suppressed_recipient instead of silently vanishing. Import your existing list on day one so you never re-mail a dead address.
Analytics API
Query the same Analytics Engine data the dashboard charts, grouped by tag, template, domain, recipient provider or country — and export raw events to R2 or your warehouse.
GET /v1/analytics/deliverability
?group_by=recipient_provider&tag=receipt&range=30d
{ "gmail.com": { delivered: 0.997, inbox_rate: 0.981, opens: 0.62 },
"outlook.com": { delivered: 0.991, inbox_rate: 0.943, opens: 0.48 } }Every inbox_rate carries a source field — seed, postmaster, snds or estimate — because an SMTP 250 means accepted, not inboxed. See what the analytics look like →
Providers: Cloudflare, Amazon SES, Resend
MailySend separates the API you code against from the wire that carries the mail. Cloudflare Email Service is the default. Point a domain at Amazon SES for the cheapest bulk rate, or keep sending through Resend while you migrate — same SDK, same logs, same webhooks.
| PROVIDER | RATE | BEST FOR | SETUP |
|---|---|---|---|
| Cloudflare | $0.35 / 1k | Default · lowest latency | Zero |
| Amazon SES | $0.10 / 1k | Millions/month, cost-first | IAM key |
| Resend | your plan | Zero-risk migration window | re_ key |
await ms.domains.update('dom_9f2', { provider: 'ses', // 'cloudflare' | 'ses' | 'resend' credentials_secret: 'SES_KEY', failover: ['cloudflare'], // auto-retry elsewhere on 5xx });
Migrating off Resend? Keep provider: 'resend' on day one, move traffic percentage by percentage, then flip to Cloudflare when the charts look boring. Migration guide →
SMTP relay
Rails, Django, Laravel, WordPress, Jira, anything legacy. SMTP sends appear in the same logs, analytics and webhooks as API sends.
SDKs & CLI
One first-party SDK — Node and TypeScript, MIT, with a Resend-compatible shim — and the OpenAPI document every other language generates from, served by your own deployment at /v1/openapi.json so a generated client can never drift from the API it was generated against.
- Node.js / TypeScriptSHIPPEDnpm i mailysend
- Pythonopenapi-generator-cli · python
- Goopenapi-generator-cli · go
- Rubyopenapi-generator-cli · ruby
- PHP (+ Laravel)openapi-generator-cli · php
- Java / Kotlinopenapi-generator-cli · java
- .NET / C#openapi-generator-cli · csharp
- Rustopenapi-generator-cli · rust
- Elixiropenapi-generator-cli · elixir
$ npx mailysend login$ npx mailysend send --to [email protected] --template tpl_welcome$ npx mailysend tail --tag receipt --status bounced$ npx mailysend domains verify acme.dev$ npx mailysend deploy --domain acme.dev # self-host
MCP & agents
Your workspace exposes an MCP endpoint at /mcp with nine tools: search threads, read a message, reply, send, look up delivery status, list domains, read analytics, manage contacts. The two that send mail never send on their first call — they return a confirmation bound to that exact message, which a person approves at /app/approvals. There is no MCP method that approves one, so an agent holding a valid key still cannot approve its own send.
{
"mcpServers": {
"mailysend": {
"url": "https://api.mailysend.com/mcp",
"headers": { "Authorization": "Bearer ms_live_…" }
}
}
}Errors & rate limits
Errors are typed, human-readable, and always name the fix. Rate limits are a fixed window per workspace, not per key: 600 sends a minute on /v1/emails and 1,000 requests a minute everywhere else. Inbound is never limited.
| CODE | TYPE | WHAT TO DO |
|---|---|---|
| 401 | invalid_api_key | Check the key’s environment |
| 403 | domain_not_verified | Publish the DKIM record, then verify |
| 422 | suppressed_recipient | Address hard-bounced before — see logs |
| 429 | rate_limited | Honour Retry-After; SDKs do it for you |
Ready to send?
Deploy the platform into your own Cloudflare account in about a minute end to end — most of it DNS propagation, which is out of anyone’s hands. MIT licensed, no vendor bill.