Receive and send email on any dev domain via Cloudflare. CLI + TypeScript SDK + Playwright fixture. No Gmail, no IMAP, no Mailgun — one vendor, one token.
cfmail config add example.dev
cfmail wait --to signup-xyz@example.dev --extract-link
cfmail sending enable --subdomain mail.example.dev
cfmail send -f noreply@mail.example.dev -t user@example.com -s "hi" --text "hi"- Magic-link tests — block until the signup email arrives, extract the link, follow it.
- Agents with email — give an AI agent
agent-42@yourdomain.devand let it receive mail autonomously. - Disposable addresses — catch-all accepts
anything@yourdomain.dev, so you can invent addresses on the fly. - Outbound too — CF Email Sending (beta) bound into the worker; DKIM managed by Cloudflare. Multipart form on
POST /send, attachments + inline supported.
npm i -g @rusintez/cfmail # or: pnpm add -g @rusintez/cfmailOr from source:
git clone https://github.com/rusintez/cfmail && cd cfmail
pnpm install && pnpm build && pnpm link --globalcfmail config add example.dev # browser opens with pre-filled token template; paste token
# → provisions D1 + R2 + Worker, wires Email Routing catch-all → Worker
cfmail address --prefix signup # prints e.g. signup-k3f9pn@example.dev
cfmail inbox # list received mail with routing latency
cfmail get <id> # pretty-print body + attachments
cfmail tail # stream as it arrivesFull command reference: see SKILL.md.
import { cfmail } from "cfmail";
const client = cfmail({ workspace: "example.dev" });
const mbox = await client.createMailbox({ prefix: "signup" });
await fetch("https://api.example.com/signup", {
method: "POST",
body: JSON.stringify({ email: mbox.address }),
});
const email = await mbox.wait({ subject: /verify/i, timeout: 60_000 });
console.log(email.links[0]);import { test as base, expect } from "@playwright/test";
import { withMailbox } from "cfmail/playwright";
const test = base.extend(withMailbox({ workspace: "example.dev" }));
test("signup", async ({ page, mailbox }) => {
await page.fill("[name=email]", mailbox.address);
await page.click("button[type=submit]");
const email = await mailbox.wait({ subject: /sign in/i });
await page.goto(email.links[0]);
await expect(page).toHaveURL(/dashboard/);
});inbound: sender → CF Email Routing MX → Email Worker
├── postal-mime parse
├── R2 ← raw.eml + attachments
└── D1 ← metadata row
↑
GET /messages (bearer) ← CLI / SDK
outbound: CLI / SDK ─── POST /send (bearer, multipart) ───▶ Email Worker
│
└── env.EMAIL.send(...)
│ DKIM by CF
▼
Recipient inbox
- D1 for metadata (queryable, indexed on recipient + date)
- R2 for raw RFC822 + attachment bytes (no 1 MiB D1 row cap)
- Worker bundle shipped inside the npm package —
cfmail config adduploads it via the CF REST API (no Wrangler dep at runtime)
- A domain on Cloudflare with no existing MX records
- A Cloudflare API token (CLI opens dashboard with pre-filled template on first run)
- Node ≥ 20
~/.config/cfmail/config.json — one entry per domain.
MIT