指南2026年7月30日閱讀時間 7 分鐘

用 Cloudflare Email Routing 把任何郵件變成 Webhook

一個 catch-all 地址、一個 Worker 的 Email 處理程式,大約六十行:寄到你網域的每一封信,都會變成送進現有 HTTP 管線的 JSON POST。

信箱地址只是另一個接收網址任何人寄一封信MX路由解析 + 驗證POST你的處理程式或你的 agent紀錄和任何其他 Webhook 來源一樣的重試、一樣的事件紀錄、一樣的重播按鈕
MX 紀錄進,HTTP POST 出。解析和驗證都在中間完成。

Cloudflare Email Routing 可以把寄到你網域的每一封信交給一個 Worker。它免費,不用自己跑郵件伺服器,也不用 IMAP 輪詢。把 catch-all 規則和 Email 處理程式組合起來,就能得到真正有用的東西:你網域上無限多個收信地址,每一個都會把郵件變成 JSON POST,送進你已經在跑的任何 HTTP 管線。

我們替 AnyHook 上線的正是這個(現在每個端點同時也是一個 Email 地址),花掉的那一小時,大部分都用在三個設計決定上,而不是寫程式。

設定

  1. 在你的 zone 上啟用 Email Routing。Cloudflare 會加上 MX 紀錄,而像 hello@ 這類既有的具名規則會繼續運作,因為具名規則的優先順序高於 catch-all。
  2. 建立一條 catch-all 規則,動作選 Send to a Worker(傳送到 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(一個接收網址,對應一個 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 記憶體之後才發現它太大,順序就錯了。

catch-all 地址會招來各種探測收到的信解析地址先檢查 rawSize通過一個 Webhook紀錄無法解析,或超過 25 MiBsetReject()在 SMTP 層退信寄件伺服器會產生一封真正的退信通知悄悄丟掉,等於告訴寄件人信已送達,實際上卻沒有
兩個出口,沒有第三個。任何無法路由的信,都會在寄件者看得到的地方退回。

決定 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 讀取。

所有文章2026年7月30日 · 7 分鐘

別再漏掉 Webhook。

換一個網址,就有重試、事件紀錄和一鍵重播。或者先親眼看看:註冊後跑一次 30 秒的當機演練,看著一個事件在伺服器當機時保存下來。不需要伺服器。