Cloudflare Email Routing 可以把发到你域名的每一封邮件交给一个 Worker。它是免费的,不用运行邮件服务器,也不用轮询 IMAP。把 catch-all 规则和邮件处理程序结合起来,你就得到一个真正有用的东西:你的域名上有无限多个收信地址,每一个都会把邮件变成一次 JSON POST,发往你已经在运行的 HTTP 管道。
我们正是为 AnyHook 上线了这个功能(现在每个端点同时也是一个邮箱地址),而花掉的那一个小时,大部分用在了三个设计决定上,而不是代码上。
配置
- 在你的域名上启用 Email Routing。Cloudflare 会添加 MX 记录,现有的具名规则(比如
hello@)继续生效,因为具名规则的优先级高于 catch-all - 创建一条 catch-all 规则,动作选 Send to a Worker
- 在
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 内存,才发现它太大,顺序就错了。
决定 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 读取。