Cloudflare Email Routing を使うと、ドメイン宛てに送られたすべてのメールを Worker に渡せます。無料で、運用するメールサーバーも、IMAP のポーリングもありません。キャッチオールのルールとメールハンドラーを組み合わせると、本当に役立つものが手に入ります。ドメイン上で無制限に使える受信用アドレスで、そのどれもが、すでに運用している HTTP パイプラインへの JSON の POST にメールを変換します。
AnyHook でも、まさにこれを出荷しました(今では、どのエンドポイントもメールアドレスを兼ねています)。かかった 1 時間のほとんどは、コードではなく、3 つの設計判断に費やしました。
セットアップ
- ゾーンで Email Routing を有効にします。Cloudflare が MX レコードを追加します。名前付きのルールはキャッチオールより優先されるので、
hello@のような既存の名前付きルールはそのまま動き続けます - アクションを 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() の 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 のメモリに読み込んでから大きすぎると気づくのでは、順序が逆です。
判断 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 つのイベントログに入ります。