指南2026年7月30日7 分钟阅读

用 Cloudflare Email Routing 把任意邮件变成 Webhook

一个 catch-all 地址、一个 Worker 邮件处理程序,加上约六十行代码:发到你域名的每封邮件,都变成发往现有 HTTP 管道的 JSON POST。

邮箱地址只是另一个接收 URL任何人发来一封邮件MX路由解析 + 验证POST你的处理程序或你的智能体日志和其他任何 Webhook 来源一样的重试、事件日志和重放按钮
MX 记录进,HTTP POST 出。解析和验证都在中间完成。

Cloudflare Email Routing 可以把发到你域名的每一封邮件交给一个 Worker。它是免费的,不用运行邮件服务器,也不用轮询 IMAP。把 catch-all 规则和邮件处理程序结合起来,你就得到一个真正有用的东西:你的域名上有无限多个收信地址,每一个都会把邮件变成一次 JSON POST,发往你已经在运行的 HTTP 管道。

我们正是为 AnyHook 上线了这个功能(现在每个端点同时也是一个邮箱地址),而花掉的那一个小时,大部分用在了三个设计决定上,而不是代码上。

配置

  1. 在你的域名上启用 Email Routing。Cloudflare 会添加 MX 记录,现有的具名规则(比如 hello@)继续生效,因为具名规则的优先级高于 catch-all
  2. 创建一条 catch-all 规则,动作选 Send to a Worker
  3. 在 fetch 处理程序旁边导出一个 email 处理程序:
export default {
  async fetch(request, env, ctx) { /* your existing worker */ },
  async email(message, env, ctx)  { /* new */ },
};

这个处理程序会收到一个 ForwardableEmailMessage:from、to、headers、rawSize、一个包含完整 MIME 邮件的 raw 流,以及三个动作:forward()、reply() 和 setReject()。

决定 1:地址本身就是路由

catch-all 会接收所有本地部分的邮件,所以地址本身就可以携带路由信息。我们的地址编码了一个租户和一个 App:

{user-slug}.{app-slug}@anyhook.net

让这件事安全的解析规则,是在最后一个点处拆分,而它之所以安全,只是因为 slug 在创建时会被清理成只含 [a-z0-9-]。slug 永远不会包含点号,所以拆分没有歧义,这是由构造保证的,而不是靠约定。如果你的标识符可能包含点号,就选一个它们不可能包含的分隔符,否则这个路由会把邮件送错地方。

export function parseInboxAddress(to: string) {
  const local = (to.split("@")[0] ?? "").toLowerCase();
  const dot = local.lastIndexOf(".");
  if (dot <= 0 || dot === local.length - 1) return null;

  const userSlug = local.slice(0, dot);
  const appSlug  = local.slice(dot + 1);
  if (!/^[a-z0-9-]+$/.test(userSlug) || !/^[a-z0-9-]+$/.test(appSlug)) return null;
  return { userSlug, appSlug };
}

先转成小写;发件方不会保留本地部分的大小写。并且让它保持为纯函数:它是分支多的部分,也就是值得测试的部分。我们为它写了八个测试(大小写、连字符、开头/结尾的点号、+tag、下划线、路径穿越、空的本地部分)。它周围的胶水代码一个测试都没有,因为胶水代码没有分支。

决定 2:在 SMTP 阶段拒收,绝不吞掉

catch-all 会吸引所有针对你域名的探测。很诱人的做法是把解析不了的邮件悄悄丢掉。别这么做,因为没收到错误的发件方会以为已经送达。对于一个打错了的地址,这意味着有个人在等一封永远不会来的回复。

const parsed = parseInboxAddress(message.to);
if (!parsed) {
  message.setReject("Address must be {user}.{app}@example.com");
  return;
}

setReject 会在 SMTP 阶段退信,所以发送方的服务器会生成一封规范的失败通知。超大的邮件也一样,而且要在缓冲数据流之前检查 rawSize,而不是之后:

if (message.rawSize > MAX_BYTES) {
  message.setReject(`Message exceeds ${MAX_BYTES} bytes`);
  return;
}

收到的邮件最大可达 25 MiB。先把邮件加载进 Worker 内存,才发现它太大,顺序就错了。

catch-all 地址会吸引探测流量收到的邮件解析地址先检查 rawSize通过一个 Webhook日志无法解析,或超过 25 MiBsetReject()在 SMTP 层退信发送方服务器会生成一封真正的退信通知悄悄丢弃,会让人以为邮件已经送达,而实际上并没有
两个出口,没有第三个。任何无法路由的邮件,都会在发件方看得到的地方被退回。

决定 3:不要建第二条管道

邮件处理程序可以自己解析 MIME、执行配额、写数据库、把投递放进队列,把你的 HTTP 路径已经做的事全部重复一遍。那样就成了两条永远在相互偏离的管道。

当工作流引擎同时也是入口时,自动化平台掉进的就是同一个坑,这也是为什么在那里一次失败的运行,只会把事件留在那次运行的执行记录里:n8n Webhook 不工作详细讲了这种失败模式。

更好的做法是,把邮件重新封装成它本该是的那个 HTTP 请求,然后调用你自己的 fetch 处理程序:

export async function handleEmail(message, env, ctx) {
  const parsed = parseInboxAddress(message.to);
  if (!parsed) { message.setReject("…"); return; }

  const raw = await new Response(message.raw).text();
  const req = new Request(
    `https://in.example.com/${parsed.userSlug}/${parsed.appSlug}`,
    {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "x-transport": "email",
      },
      body: JSON.stringify({
        type: "email.received",
        from: message.from,
        to: message.to,
        subject: message.headers.get("subject"),
        message_id: message.headers.get("message-id"),
        size: message.rawSize,
        raw,
      }),
    }
  );

  const res = await handler.fetch(req, env, ctx);
  if (!res.ok && res.status !== 202) {
    message.setReject(`Rejected (${res.status})`);
  }
}

下游的一切(路由的认证、限流、配额、请求体上限、排队、重试、日志)走的都是和你的 Webhook 相同的代码路径。一条管道,两种传输方式。HTTP 路径加了新功能,邮件也会自动拥有。x-transport: email 请求头是区分两者的唯一标记,供那些想显示信封而不是请求体的界面使用。

注意最后四行:如果你自己的管道拒收了这个事件(未知的 App、已达到每日上限),也要把它作为 SMTP 拒收传递回去。退信就是你向发件方报告错误的渠道。

依赖它之前需要知道的事

  • 把整个 raw 字符串传到下游,在那里用一个真正的 MIME 库解析。那些便利字段(subject、from)用于路由和显示;附件、HTML 部分和 DKIM 头都在 raw 里
  • 具名规则不受影响。catch-all 只会收到没有被任何具名规则匹配的邮件,所以现有的 hello@ 转发不会被改动
  • 和任何 Worker 一样,Workers Free 套餐的 CPU 限制也适用于邮件处理程序。重新封装的模式让处理程序保持轻薄,这会有帮助

如果你不想自己运行这一套,AnyHook 托管的正是这个模式:一次无需密钥的 API 调用,返回一个同时是 Webhook URL 和邮箱地址的端点。两者汇入同一份事件日志,你可以通过 HTTP 或 MCP 读取。

全部文章2026年7月30日 · 7 分钟

别再丢 Webhook 了。

只改一个 URL。获得重试、事件日志和一键重放。也可以先亲眼看看:注册后跑一次 30 秒的故障演练,看一个事件如何挺过你的服务器宕机。不需要服务器。