This is an English summary of the Chinese article. All keys, domains, and IDs below are placeholders
<...>— replace them with your own.
What problem this solves#
Own a domain but no email? Third-party mail hosts cost money or cap accounts. This guide shows how to run a full-featured email service on Cloudflare’s free tier using the open-source maillab/cloud-mail project — no VPS required. Result: receive mail at any xxx@your-domain, send from a web client, store attachments in R2, and share via self-service registration.
Architecture#
Email Routing (*@your-domain → Worker)
↓
cloud-mail Worker (Hono)
├─ D1 → accounts / mails / settings
├─ KV → sessions / cache
├─ R2 → attachments (custom-domain direct link)
├─ send_email→ CF Email Sending (sending channel 1)
├─ AI → CAPTCHA recognition (optional)
└─ Vue3 frontend → web mail client (built & uploaded with Worker)Cloudflare resources needed#
| Resource | Role |
|---|---|
| Worker | Runs cloud-mail (handlers: fetch / email / scheduled) |
| D1 | Accounts, mails, system settings |
| KV | Sessions, cache |
| R2 | Attachments (r2.<your-domain> custom domain) |
| Email Routing | Receiving: *@<your-domain> → Worker |
| send_email | Sending via CF Email Service (Beta) |
Steps#
- Clone & configure —
git clonecloud-mail; editmail-worker/wrangler.toml(D1/KV/R2/send_email bindings +[vars]);npx wrangler deploy --config wrangler.toml. The Vue3 frontend inmail-vue/builds automatically on deploy. - Init database & admin — visit
https://mail.<your-domain>/api/init/<jwt_secret>, log in asadmin@<your-domain>, confirm receive/send/register toggles. - Receiving (Email Routing) — CF’s catch-all (
type=all) does not support worker actions, so create a literal rule*@<your-domain>→ worker. MX/SPF/DKIM are auto-created. Verify by emailing anyxxx@<your-domain>and viewing it in the web client. - Sending (dual channel) — CF Email Sending via
[[send_email]]binding, with Resend fallback: if CF rejects (e.g. unverified recipientE_RECIPIENT_NOT_ALLOWED), auto-fallback to Resend using a per-domain token. Free quota: 3000 emails/mo, 100/day. Check theresendEmailIdfield to see which channel delivered (CF:<xxx@domain>; Resend: UUID). - Attachments in R2 — set
storageType=R2and bindr2.<your-domain>custom domain. ⚠️ Upstream bug: the/attachments/route always reads KV; patch it to dispatch by storageType (R2 reads fromenv.r2, KV keeps the old path) — see PR #325. - Registration — open registration (
register=0), disable CAPTCHA/code for closed groups; enable them if exposed publicly.
Known pitfalls#
orm_logmust not be the string"true"/"false"— drizzle crashes withthis.logger.logQuery is not a function. Pass empty string or bool-check.- Frontend sends JWT without a
Bearerprefix when calling APIs. - R2 requires manual one-click activation in the CF dashboard on first use.
- Email Routing wildcard: catch-all can’t target a worker — use a literal
*@domainrule; the worker action’s value is the script name. - Mail-list APIs crash on missing
accountId(Cannot read properties of undefined) — guard withallReceive=0default,?? 0on empty results, and Number conversion inlatest.
Summary#
Cloudflare’s free tier fully covers a personal/small-group email service: Email Routing for receiving, CF Email Sending + Resend fallback for sending, D1/KV/R2 for data, and a Vue3 web client. No VPS, no server upkeep. Before public deployment, sanitize everything — admin passwords, JWT secrets, API tokens, account IDs belong only in server-side environment variables.
Full Chinese version: /posts/cloud-mail-email-service/







