# MailySend
### **Resend, on _your_ Cloudflare.**
The complete email platform — transactional sending, marketing broadcasts, automations,
inbound mail with threading and deliverability analytics — running entirely on Cloudflare
Workers, **in your own account**. Drop-in compatible with the Resend API. MIT licensed.
Also runs on a plain Node server with no Cloudflare account at all.
[**Live demo → mailysend.com**](https://mailysend.com) ·
[Docs](https://mailysend.com/docs) ·
[Dashboard tour](https://mailysend.com/dashboard-tour) ·
[Honest comparisons](https://mailysend.com/compare) ·
[Cost](https://mailysend.com/pricing)
[](LICENSE)
[](https://workers.cloudflare.com/)
[](https://nodejs.org/)
[](https://deploy.workers.cloudflare.com/?url=https://github.com/GagnDeep/mailysend)
**Analytics that names its own denominators**
A 30-day series, delivery grouped by receiving domain and by the tags you set at send time, opens
and clicks split by who actually generated them, and inbox placement per provider with the source
of every figure attached.
**Every message, and what happened to it**
The full log with filters that live in the URL, and a drawer per message: state timeline, the SMTP
conversation, every webhook attempt and its response, and the raw MIME.
|
**One message, all the way down**
|
**Domain setup that finishes**
Every DNS record for the active transport, copy-buttoned, with SPF/DKIM/DMARC state and the
deliverability posture on one page.
|
**Broadcasts with a real denominator**
Progress from the coordinator's own counters, and every engagement rate stated over the
denominator it was actually computed from.
|
**Audiences and live segments**
Contacts, custom properties, CSV import, and segments written in a real query DSL that compiles to
parameterised SQL.
|
**⌘K to anywhere**
Jump to any message, domain, template or doc page. `G L` for logs, `G B` for broadcasts. No screen
in the product ends in "contact support".
|
It works.
" }' ``` The `id` comes back **before any provider is contacted**. That is deliberate: the id is ours, minted at accept time, so it survives a failover, a provider migration, and a provider that loses its own id. `provider_message_id` is recorded later and is queryable, but it is never the identity of a message. ### And then receive something Sending and receiving are two independent setups on the same domain, and the second one is configured in two different places — which is the whole reason the first test message usually bounces. 1. **Cloudflare dashboard → Email → Email Routing.** Enable it (Cloudflare publishes the MX records itself), then add a **catch-all** rule whose action is **Send to a Worker**, pointed at this instance's script. That is what delivers the domain's mail to MailySend. 2. **In MailySend, under the domain's Receiving tab**, create a mailbox — and turn on **Catch-all** on it if you want every address on the domain to land there rather than only the one you named. Step 1 alone is not enough. Mail for an address with no mailbox and no catch-all is refused at the door with a legible `550 5.1.1 No such mailbox`, which is the honest answer to a typo and tells a spammer nothing — but it is also exactly what "I bound the catch-all and nothing arrived" looks like. **Check receiving** on the domain resolves its MX and says which of the two halves is missing, and both outcomes are written to the event timeline rather than only to `wrangler tail`. ### Getting into the dashboard The API takes a key; the dashboard takes a session. A brand-new deployment has no verified sending domain, no identity provider and nobody to email, so the first session cannot come from any of those. It comes from **claiming the instance**. **1 — Claim it at `/setup`.** The first person to open it registers a passkey and becomes the owner. The claim is a single conditional insert, so two people opening `/setup` at the same moment produce exactly one owner — the other is told the instance is already claimed. Then it offers, both skippable, adding a sending domain and minting your first API key with a live test send. **2 — Save the recovery codes.** Ten of them, shown once, single-use. They are the way back in if the passkey is gone, and the reason removing your last passkey is allowed at all. **3 — Sign in afterwards with the passkey alone.** No email typed: the credential is discoverable, so the browser offers it and `/sign-in` trades the assertion for a session. Three other doors exist, and the sign-in page renders each one **only when it is actually open** — an offered door that answers 501 is worse than no door: | Door | When it appears | What it needs | |---|---|---| | **Recovery code** | always | one of the ten codes | | **Cloudflare Access** | `MS_ACCESS_TEAM` and `MS_ACCESS_AUD` are set | the assertion is verified against your team's published keys — signature, `iss`, `aud`, `exp` — never merely decoded | | **Emailed one-time code** | a sending domain is verified | it is sent through this deployment's own send path, from the domain you marked default | Until a domain is verified, `POST /v1/auth/otp` answers `202 {"status":"unavailable"}` and the page says so, instead of pointing you at an inbox that will never receive anything. **Locked out?** `npx mailysend claim --url https://your-instance` is the break-glass path, and it keeps working after the instance is claimed. It proves control of the deployment rather than of an inbox: it writes a one-time nonce into the instance's own database — through the Cloudflare D1 API when `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` are in your environment, otherwise by printing the exact `wrangler d1 execute` command for you to run — and then proves it knows that nonce. Only somebody who can write that database can produce it. You get a session and a fresh API key. **On the CLI**, `npx mailysend login` uses a device code: it prints an eight-character code and a URL, you approve it at **Settings → Access** in a browser that is already signed in, and the CLI receives a full-access key named after the client — revocable from the same screen as any other key. **Moving to your own domain** is done from **Settings → Access**. This matters more than it looks: a passkey is bound to a hostname, so changing the host invalidates every passkey registered on the old one. The instance stores the hostname each credential was registered for, shows you which are still usable, and tells you plainly that you need a recovery code and a re-registration on the new host. `mailysend claim` remains the guaranteed way in, which is what makes offering the move as a button safe at all. There is no password store. A self-hosted email platform that invents one is adding the single credential most likely to be reused and leaked.