ガイド2026年7月30日7 分で読めます

Cloudflare Email Routing でメールを Webhook に変える

キャッチオールと Worker のメールハンドラー、約 60 行で、ドメイン宛ての全メールが既存の HTTP パイプラインへの JSON POST になります。

メールアドレスも、ただの受信 URL誰でもメールを送るMXルーティングパース + 検証POSTハンドラーまたはエージェントログほかの Webhook の送信元と同じリトライ、同じイベントログ、同じ再送ボタン
MX レコードから入り、HTTP POST で出ていく。パースと検証は、その間で行われる。

Cloudflare Email Routing を使うと、ドメイン宛てに送られたすべてのメールを Worker に渡せます。無料で、運用するメールサーバーも、IMAP のポーリングもありません。キャッチオールのルールとメールハンドラーを組み合わせると、本当に役立つものが手に入ります。ドメイン上で無制限に使える受信用アドレスで、そのどれもが、すでに運用している HTTP パイプラインへの JSON の POST にメールを変換します。

AnyHook でも、まさにこれを出荷しました(今では、どのエンドポイントもメールアドレスを兼ねています)。かかった 1 時間のほとんどは、コードではなく、3 つの設計判断に費やしました。

セットアップ

  1. ゾーンで Email Routing を有効にします。Cloudflare が MX レコードを追加します。名前付きのルールはキャッチオールより優先されるので、hello@ のような既存の名前付きルールはそのまま動き続けます
  2. アクションを 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() の 3 つの操作です。

判断 1:アドレスをルーターにする

キャッチオールはすべてのローカル部宛てのメールを受け取るので、アドレス自体にルーティングの情報を持たせられます。AnyHook のアドレスは、テナントと App(Webhook の送信元ごとに作る受信設定)を表しています。

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

これを安全にするパースのルールは、最後のドットで分割することです。それが安全なのは、スラッグが作成時に [a-z0-9-] に制限されているからにすぎません。スラッグにドットが含まれることは決してないので、分割は慣習ではなく、構造によって一意に決まります。識別子にドットが含まれうるなら、含まれえない区切り文字を選んでください。そうしないと、このルーターは誤ったところへ振り分けます。

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 };
}

まず小文字にします。送信側は、ローカル部の大文字・小文字を保持しません。そして、これは純粋関数にしておきます。分岐が多い部分なので、ここがテストすべき部分です。AnyHook のものには 8 つのテストがあります(大文字・小文字、ハイフン、先頭・末尾のドット、+tag、アンダースコア、パストラバーサル、空のローカル部)。その周りのつなぎのコードには、テストがありません。分岐がないからです。

判断 2:SMTP で拒否し、決して飲み込まない

キャッチオールは、ドメインに向けられたあらゆる探りを引き寄せます。パースできないものは黙って捨てたくなりますが、そうしないでください。エラーを受け取らなかった送信者は、届いたものと考えるからです。アドレスを打ち間違えた場合なら、決して来ない返事を待ち続ける人間がいることになります。

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 のメモリに読み込んでから大きすぎると気づくのでは、順序が逆です。

キャッチオールには探りのメールが集まる受信メールアドレスをパース先に rawSize を確認OKWebhook 1 件ログパース不可、または 25 MiB 超setReject()SMTP でバウンス送信側サーバーが本物の不達通知を作る黙って捨てると、届いていないメールを届いたと人に思わせてしまう
出口は 2 つで、3 つ目はない。振り分けられないものは、送信者に見える形で差し戻される。

判断 3:2 本目のパイプラインを作らない

メールハンドラーで MIME をパースし、クォータを適用し、データベースに書き込み、配信をキューに入れることもできます。しかしそれは、HTTP の経路がすでにしていることを、すべて複製することになります。そうして生まれるのは、永遠に食い違っていく 2 本のパイプラインです。

これは、ワークフローエンジンが受信口も兼ねている自動化プラットフォームが陥るのと同じ罠です。だからそうしたプラットフォームでは、実行が失敗すると、イベントはその実行の記録の中にしか残りません。この失敗の形は、n8n の Webhook が動かないで詳しく扱っています。

代わりに、メッセージを、もし 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 が通るのと同じコードパスです。パイプラインは 1 本、経路は 2 つです。HTTP の経路に機能が加われば、メールにも自動的に加わります。両者を区別するのは x-transport: email ヘッダーだけで、これはペイロードの代わりに封筒を表示したい UI のためのものです。

最後の 4 行に注目してください。自分のパイプラインがイベントを拒否したら(存在しない App、1 日の上限に到達など)、それも SMTP での拒否として伝えます。差し戻しのメッセージが、送信者へのエラー報告の経路になります。

頼る前に知っておくこと

  • raw の文字列をまるごと下流に渡し、そこで本格的な MIME ライブラリを使ってパースします。便宜用のフィールド(subject、from)は、ルーティングと表示のためのものです。添付ファイル、HTML パート、DKIM ヘッダーは raw の中にあります
  • 名前付きのルールは残ります。キャッチオールが受け取るのは、どの名前付きルールにも該当しないメールだけなので、既存の hello@ の転送には影響しません
  • Workers Free プランの CPU 制限は、ほかの Worker と同じく、メールハンドラーにも適用されます。詰め直しのパターンならハンドラーを薄く保てるので、それが助けになります

自分で運用したくないなら、このパターンそのものを AnyHook がホストしています。キー不要の API 呼び出し 1 回で、Webhook の URL とメールアドレスを兼ねたエンドポイントが返ってきます。どちらも、HTTP や MCP で読める 1 つのイベントログに入ります。

すべての記事2026年7月30日 · 7 分

Webhook を、もう取りこぼさない。

URL を 1 つ変えるだけで、リトライ、イベントログ、1 クリックでの再送が使えます。先に動きを見ることもできます。新規登録して 30 秒の障害訓練を実行すると、サーバーがダウンしていてもイベントが失われない様子を確認できます。サーバーを用意する必要はありません。