> cf-mail-api-cloudflare-workers-d1-webhook

cf-mail-api: 在 Cloudflare Workers + D1 上搭一个 Webhook 优先的邮件后端
// ─────────────────────────────────────────────────────────────
OUTPUT 006 SEED 925091673 DATE 2026-06-30 CHARS 9,526 TAGS cloudflare-workers d1 email webhook open-source javascript

问题在哪儿

每次我做一个需要收邮件的东西——注册确认、密码重置、验证码——都会撞上同一堵墙。我不想跑 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 自动创建:

字段用途
mailboxesid, address, token, label, created_at, expires_at, active, max_messages一个邮箱地址一行。active 是 0/1 开关。expires_at 驱动 TTL 闸门。max_messages 驱动配额闸门。
messagesid, 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_messagesid, from_address, to_address, subject, text_body, html_body, provider, provider_message_id, status, created_at外发审计记录。只有用到可选的 Resend 外发功能时才会有数据。

mailbox_id 从邮件地址拆分得出:task_demo01@mail.your-domain.tldtask_demo01,取 @ 前面的部分。这意味着邮箱命名空间是扁平的——task_demo01@mail.your-domain.tldtask_demo01@other.your-domain.tld 是同一个邮箱。这是故意的:在 webhook 系统里,域名部分只是装饰,我不想搞域名作用域。邮箱活在 D1 里,不活在 DNS 里。

3. 消息摄入:ensureMailbox → purgeIfExpired → 入库 → 溢出检查

核心摄入管线是 handleInboundPayload()。五个步骤,全部同步执行,共享 D1 事务上下文:

  1. ensureMailbox:按地址查询。如果不存在,插一条新记录,TTL 十分钟,max_messages 设 5。这是 webhook 自动创建路径——不需要单独注册。第一封抵达的消息自己把邮箱生出来。
  2. purgeIfExpired:expires_atDate.now() 比较。过期了就 DELETE FROM messages WHERE mailbox_id = ? 然后 UPDATE mailboxes SET active = 0。给调用方返回 410 Gone。邮箱还留着,是一行墓碑记录——不是删除,只是停用。
  3. 活跃检查:active !== 1 就返回 410。拦截那些已自动清理或手动停用的邮箱。
  4. saveInboundMessage:写入 messages 表,字段一个不落,包括 raw_json——完整原始请求体序列化成字符串。返回数据库生成的 row ID。
  5. 溢出检查: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/messagesGET /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() 跑四重校验:

  1. 字段完整性:to 和 subject 必填。text 或 html body 至少有一个。
  2. 发件地址:未提供就自动生成 send_<8 位随机字符>@<MAIL_DOMAIN>。自动生成发件地址同时充当回信地址——对方回复会走 Email Routing 回到邮箱系统。
  3. 域名校验:from 域名必须等于 rootMailDomain() 或是其子域名。rootMailDomain()mail.your-domain.tld 返回 your-domain.tld,所以 send_abc123@mail.your-domain.tlduser@sub.your-domain.tld 都合法。someone@gmail.com 被拒绝。
  4. 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 规则后面。

Star on GitHub

← cd ../首页