问题在哪儿
每次我做一个需要收邮件的东西——注册确认、密码重置、验证码——都会撞上同一堵墙。我不想跑 Postfix。我不想配 SPF、DKIM、DMARC,尤其域名可能根本不是我的。我不想给 SendGrid 一个月付二十美元买五万封配额,而我只需要五十封。我只想要一个 webhook。POST 一个 JSON 到某个端点,它落进数据库,之后我 GET 回来。就这些。
Cloudflare Workers 能做这个。D1 给了我跑在边缘的 SQLite。免费套餐每天十万次 Worker 请求、五百万次 D1 读取——对于个人邮件后端来说根本用不完。于是写了 cf-mail-api,一个文件,748 行 JavaScript,零 npm 依赖,三张 D1 表,十二条路由用最原始的正则匹配。部署只需要一条 wrangler deploy。没有构建步骤。没有框架。打开文件就能看到全部逻辑。
1. 鉴权:三个通道,一个 token
auth() 函数从三个互不依赖的入口提取同一个 token:
function auth(req, env) {
const bearer = req.headers.get('authorization') || '';
const xApiKey = req.headers.get('x-api-key') || '';
const queryApiKey = url.searchParams.get('api_key') || '';
const envToken = String(env.API_TOKEN || '');
if (!envToken) return false; // 一刀切——全拒
return [bearer, xApiKey, queryApiKey].some((value) =>
value === envToken || value === `Bearer ${envToken}`
);
}
三条路:Authorization: Bearer <token>、x-api-key: <token>、?api_key=<token>。some() 短路匹配,第一个命中就返回。如果 wrangler.toml 里没配 API_TOKEN 或者 Cloudflare secret 里没设,函数直接返回 false——所有请求都拿 401。没有不鉴权的路径。就连 POST /api/inbound webhook 端点和别的端点一样要过 auth。
为什么搞三个通道?因为不同客户端习惯不同。curl 用户传 -H "Authorization: Bearer ..."。SDK 里设 x-api-key。浏览器里调试拼 ?api_key= 到地址栏。token 是 40 个字符,用 crypto.getRandomValues() 生成——不是 Math.random()。randomToken() 函数用 62 个字符的字母表做 bytes[i] % alphabet.length 映射,分布不完全均匀,但对一个 40 字符、62^40 ≈ 10^71 的密钥空间来说,差的那点分布偏差没有实际意义。
2. D1 表结构:三张表,一条管线
数据库三张表,都由 schema migration 自动创建:
| 表 | 字段 | 用途 |
|---|---|---|
mailboxes | id, address, token, label, created_at, expires_at, active, max_messages | 一个邮箱地址一行。active 是 0/1 开关。expires_at 驱动 TTL 闸门。max_messages 驱动配额闸门。 |
messages | id, mailbox_id, external_id, from_addr, to_addr, subject, text_body, html_body, raw_json, received_at | 入站邮件。mailbox_id 是 mailboxes 的外键。raw_json 存原始 webhook payload,方便调试和重放。 |
sent_messages | id, from_address, to_address, subject, text_body, html_body, provider, provider_message_id, status, created_at | 外发审计记录。只有用到可选的 Resend 外发功能时才会有数据。 |
mailbox_id 从邮件地址拆分得出:task_demo01@mail.your-domain.tld → task_demo01,取 @ 前面的部分。这意味着邮箱命名空间是扁平的——task_demo01@mail.your-domain.tld 和 task_demo01@other.your-domain.tld 是同一个邮箱。这是故意的:在 webhook 系统里,域名部分只是装饰,我不想搞域名作用域。邮箱活在 D1 里,不活在 DNS 里。
3. 消息摄入:ensureMailbox → purgeIfExpired → 入库 → 溢出检查
核心摄入管线是 handleInboundPayload()。五个步骤,全部同步执行,共享 D1 事务上下文:
- ensureMailbox:按地址查询。如果不存在,插一条新记录,TTL 十分钟,max_messages 设 5。这是 webhook 自动创建路径——不需要单独注册。第一封抵达的消息自己把邮箱生出来。
- purgeIfExpired:拿
expires_at跟Date.now()比较。过期了就DELETE FROM messages WHERE mailbox_id = ?然后UPDATE mailboxes SET active = 0。给调用方返回 410 Gone。邮箱还留着,是一行墓碑记录——不是删除,只是停用。 - 活跃检查:
active !== 1就返回 410。拦截那些已自动清理或手动停用的邮箱。 - saveInboundMessage:写入
messages表,字段一个不落,包括raw_json——完整原始请求体序列化成字符串。返回数据库生成的 row ID。 - 溢出检查:
SELECT COUNT(*) FROM messages WHERE mailbox_id = ?。count ≥ max_messages 就把消息清空、邮箱停用。响应里返回auto_cleared: true。
自动清理是这个设计的核心决策。我不想邮箱无限制堆积消息。每一条新消息都可能是最后一条。默认第 5 条到达时,整个邮箱自毁。这对验证码和密码重置来说正合适——你试几次,地址就废了。需要更多次数的话,通过 /api/generate-email 创建邮箱时把 max_messages 设大点。
4. URL 路由:十二个正则,没有框架
没有 router 库。fetch() handler 就是一串 if 语句,匹配 req.method + path 模式。十二条路由,其中三条用正则的 capture group 从 path.match() 提取参数:
const mailboxMsgsMatch = path.match(/^\/api\/mailboxes\/([^/]+)\/messages$/);
const mailboxMsgMatch = path.match(/^\/api\/mailboxes\/([^/]+)\/messages\/([^/]+)$/);
const emailMatch = path.match(/^\/api\/email\/([^/]+)$/);
capture group 直接从 URL 中提取邮箱标识和消息 ID——不用解析 query string 做路径参数。GET /api/mailboxes/task_demo01/messages 和 GET /api/mailboxes/task_demo01/messages/msg_abc123 都合法,正则靠匹配 /messages/ 后面跟着的是一个路径段还是两个来做区分。
路由顺序很重要。/api/emails/clear 必须在 /api/email/:id 之前检查,否则 clear 这个字面量会被当成 message ID 捕获,匹配到错误的 handler。同理,/api/mailboxes(集合)在 /api/mailboxes/:id/messages(子资源)之前检查。
唯一不鉴权的路由是 /health(返回 "OK")。POST /api/inbound 也要过 auth——看代码就知道了,auth 检查在 inbound 路由之后,但它是必经之路。webhook 端点和其他管理端点一样受保护。如果你想让外部服务直接写消息进来,需要单独放开这个路径或者在请求 body 里约定共享密钥。
5. 域名解析:rootMailDomain()
域名逻辑看起来简单,藏着两条路径:
function rootMailDomain(env) {
const configured = String(env.ROOT_MAIL_DOMAIN || '').trim().toLowerCase();
if (configured) return configured;
const mailDomain = String(env.MAIL_DOMAIN || '').trim().toLowerCase();
const parts = mailDomain.split('.').filter(Boolean);
if (parts.length >= 3) return parts.slice(1).join('.');
return mailDomain;
}
显式设了 ROOT_MAIL_DOMAIN 就直接用。否则从 MAIL_DOMAIN 推导:如果有三个及以上标签(比如 mail.your-domain.tld),剥掉第一个标签返回剩下的(your-domain.tld)。两个以下标签就直接返回。
这个函数直接服务于 buildMailboxAddress()——它在创建邮箱时用 rootMailDomain() 来校验用户请求的自定义域名或子域名是否在允许范围内。isValidDomainName() 做 RFC 合规的标签校验——每个标签必须匹配 ^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$,总长度不超过 253 字符。
邮箱名校验更严格:^[a-z0-9_-]{6,40}$。最少六个字符,最多四十个。不能有点。不能有大写。因为邮箱名最终会变成 email 地址的 local part,我不想对付 RFC 5321 里 quated local part、注释、特殊字符那堆东西。
6. 双导出:一个文件同时处理 HTTP 和 SMTP
Cloudflare Worker 一般只导出一个 handler。cf-mail-api 导出了两个:fetch() 处理 HTTP 请求,email() 处理 Email Routing 的 SMTP 中继。这是 Cloudflare 特有的能力——在域名上启用 Email Routing 并把目标设为 Worker 时,Cloudflare 会调用 Worker 的 email(message, env, ctx) 导出而不是 fetch()。
email() handler 只有四行:
async email(message, env, ctx) {
ctx.waitUntil(forwardAndStore(message, env));
}
ctx.waitUntil() 让 Worker 在 handler 返回后继续保持活跃直到 promise 完成。因为 forwardAndStore() 做了两件事:从 message 对象里抽出邮件头,喂进 handleInboundPayload()——跟 HTTP webhook 完全同一条管线——然后可选地调用 message.forward() 把副本中转到 FORWARD_TO_EMAIL。
同一条 handleInboundPayload() 函数处理 HTTP webhook JSON 和解析后的 SMTP 邮件头。唯一的区别是数据来源——req.json() vs. message.headers.get()。下游所有逻辑(ensureMailbox、purgeIfExpired、入库、溢出检查)完全一样。这意味着 TTL 和配额闸门对真实 SMTP 邮件和 curl POST 注入的 JSON 一视同仁——真实发件人的邮件跟 webhook 数据一样受 max_messages 限制。
7. 外发管线:四重校验 → Resend API → D1 审计
外发是可选的,依赖 RESEND_API_KEY。在碰 Resend 之前,handleSend() 跑四重校验:
- 字段完整性:to 和 subject 必填。text 或 html body 至少有一个。
- 发件地址:未提供就自动生成
send_<8 位随机字符>@<MAIL_DOMAIN>。自动生成发件地址同时充当回信地址——对方回复会走 Email Routing 回到邮箱系统。 - 域名校验:from 域名必须等于
rootMailDomain()或是其子域名。rootMailDomain()对mail.your-domain.tld返回your-domain.tld,所以send_abc123@mail.your-domain.tld和user@sub.your-domain.tld都合法。someone@gmail.com被拒绝。 - Resend key 检查:没配
RESEND_API_KEY直接返回 500——不降级,不排队。
Resend 调用是一次 fetch 到 https://api.resend.com/emails,body 是 { from, to: [to], subject, text?, html?, reply_to? }。成功时 Resend 返回 id(如 re_abc123),存进 sent_messages.provider_message_id。D1 的本地 ID 是 sent_<16 位随机字符>——独立生成,不依赖 Resend 的 ID 格式。
内置的 HTML 前端在 / 路径下,160 行内联 CSS 和原生 JS——暗色主题、表单校验、用 CSS @keyframes blink 做的三个点加载动画、成功/失败样式。没有框架。没有构建步骤。页面从 Worker 代码里的模板字符串直接吐出。
8. 响应外壳:每个请求都带 usage
每个 API 响应——无论成功还是失败——都包含一个 usage 块:
{
"success": true,
"data": { ... },
"usage": {
"daily_limit": 200000,
"used_today": 42,
"remaining_today": 199958,
"total_usage": 1337,
"active_mailboxes": 3
}
}
getUsage() 跑一条带三个子查询的 D1 语句:消息总数、今日消息数(date(received_at) = date('now'))、活跃邮箱数。每次 API 调用消耗一次 D1 读取——在每天五百万次读的免费额度面前毫无感觉。daily_limit 硬编码二十万,远超真实免费限制,纯粹当软上限看。如果你的个人邮件后端一天能跑到接近二十万条消息,那一定是哪里出大问题了。
9. 没有的东西
没有消息正文搜索——D1 目前没有全文索引,在 messages 表上跑 LIKE '%keyword%' 就是全表扫描。如果需要搜索,把消息从 D1 拉出来放到别的索引里去。
没有附件——webhook 接收的是 JSON,而 JSON 不适合传二进制。通过 Email Routing 收真实 SMTP 附件从理论上用 message.raw() 可以实现,但 Worker 128 MB 的内存上限让大附件变得很危险。这条门我没开。
没有垃圾邮件过滤——这是个 API,不是收件箱。垃圾过滤是调用方自己的事。
没有频率限制——Cloudflare 免费套餐自带基础 DDoS 防护,但 Worker 代码里没有做 per-mailbox 或 per-IP 的频率限制。如果你把 webhook 端点暴露在公网上,是可能被灌爆的。把它藏在 Cloudflare Access 或者 WAF 规则后面。