Cloudflare Email Routing 可以把寄到你網域的每一封信交給一個 Worker。它免費,不用自己跑郵件伺服器,也不用 IMAP 輪詢。把 catch-all 規則和 Email 處理程式組合起來,就能得到真正有用的東西:你網域上無限多個收信地址,每一個都會把郵件變成 JSON POST,送進你已經在跑的任何 HTTP 管線。
我們替 AnyHook 上線的正是這個(現在每個端點同時也是一個 Email 地址),花掉的那一小時,大部分都用在三個設計決定上,而不是寫程式。
設定
- 在你的 zone 上啟用 Email Routing。Cloudflare 會加上 MX 紀錄,而像
hello@這類既有的具名規則會繼續運作,因為具名規則的優先順序高於 catch-all。 - 建立一條 catch-all 規則,動作選 Send to a Worker(傳送到 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(一個接收網址,對應一個 Webhook 來源):
{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:不要另建第二條管線
Email 處理程式可以自己解析 MIME、套用配額、寫入資料庫、把傳遞排入佇列,把 HTTP 路徑已經做的事全部重做一遍。結果就是兩條永遠越走越遠的管線。
當 workflow 引擎同時也是入口時,自動化平台就會掉進同一個陷阱,這也是為什麼在那裡,一次失敗的執行只會把事件留在那次執行的紀錄裡: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})`);
}
}
之後的一切(路由驗證、速率限制、配額、payload 上限、排入佇列、重試、記錄)走的都是和你的 Webhook 相同的程式路徑。一條管線,兩種傳輸方式。HTTP 路徑多了一項功能,Email 也會自動跟著有。唯一區分兩者的,是 x-transport: email 標頭,供想要顯示信封而不是 payload 的介面使用。
注意最後四行:如果你自己的管線拒收了這個事件(App 不存在、已達每日上限),也要把它轉成 SMTP 拒收。退信訊息就是你回報錯誤給寄件者的管道。
依賴它之前要知道的事
- 把整個
raw字串送到下游,在那裡用正式的 MIME 函式庫解析。方便用的欄位(subject、from)是給路由和顯示用的;附件、HTML 部分和 DKIM 標頭都在raw裡。 - 具名規則不受影響。catch-all 只會看到沒有被任何具名規則接走的信,所以既有的
hello@轉寄不會被動到。 - Workers 免費方案的 CPU 限制,和任何 Worker 一樣適用於 Email 處理程式。重新包裝的模式讓處理程式保持精簡,這有幫助。
如果你不想自己跑,AnyHook 代管的正是這個模式:一次免金鑰的 API 呼叫,就會回傳一個同時是 Webhook 網址和 Email 地址的端點。兩者都匯入同一份事件紀錄,你可以透過 HTTP 或 MCP 讀取。