Web

Email Worker の受信箱のキーを To ヘッダーではなくエンベロープの宛先にした

  • Cloudflare Workers
  • Email Routing
  • Durable Objects
  • SMTP
  • エージェント
  • Security

AI エージェントに、受け取ることしかできないメールアドレスを持たせました。Cloudflare Email Routing で届いたメールを Email Worker が受け取り、原文を R2 に、メタデータを宛先ごとの Durable Object に保存します。受信箱をどのアドレスで分けるかは、送信者が自由に書ける To ヘッダーではなく、エンベロープの宛先である message.to で決めます。

出発点は Cloudflare 自身のリファレンス実装 agentic-inbox です。受信箱のキーの取り方で、まずそこから外れました。

その先でも、ドキュメントに書いてあるプロパティが生成された型に無い、25 MiB のメールが 2 MB の行に入らない、Worker からは 550 を返せない、「受信専用」を決めているのはプラットフォームではなくこの構成だ、という確認が続きました。

出発点は Cloudflare のリファレンス実装

agentic-inbox は、Email Routing で受けたメールを宛先ごとの Durable Object に入れ、添付を R2 に置き、Web UI と AI エージェントと MCP サーバーまで付いたメールクライアントです。Cloudflare が公開しているので、Email Worker でメールを受けて保存する形の手本として、まずこれを読みました。

残したのは骨格です。MIME の解析に postal-mime を使うこと、idFromName で宛先ごとに Durable Object を分けること、大きいものを R2 に置くこと。

落としたのは、スレッド、フォルダー、AI エージェント、Web UI、そして send_email バインディングです。エージェントに持たせたいのは「届いたものを読める」だけで、送る経路は最初から持たせない方針でした。

GitHubGitHub - cloudflare/agentic-inbox: A self-hosted email client with an AI agent, running entirely on Cloudflare WorkersA self-hosted email client with an AI agent, running entirely on Cloudflare Workers - cloudflare/agentic-inbox

手元の実装は sandbox リポジトリに置いてあります。実ドメインやアカウントの識別子は含めていません。

GitHubsandbox/cloud-identity-email-routing at main · corrupt952/sandbox遊び場. Contribute to corrupt952/sandbox development by creating an account on GitHub.

受信箱のキーは To ヘッダーではなくエンベロープの宛先から取る

最初に agentic-inbox と違う判断をしたのが、届いたメールをどの受信箱に入れるかの決め方です。agentic-inbox は、MIME を解析した結果の To ヘッダーから受信箱を決めています。

// workers/index.ts(agentic-inbox、抜粋)
async function receiveEmail(event: { raw: ReadableStream; rawSize: number }, env: Env, ctx: ExecutionContext) {
	const rawEmail = await streamToArrayBuffer(event.raw, event.rawSize);
	const parsedEmail = await new PostalMime().parse(rawEmail);
	// ...
	const allRecipients = parsedEmail.to.map((t) => t.address?.toLowerCase()).filter(Boolean) as string[];
	// ...
	let mailboxId: string | undefined;
	if (allowedAddresses.length > 0) {
		mailboxId = allRecipients.find((addr) => allowedAddresses.includes(addr));
		// ...
	} else { mailboxId = allRecipients[0]; }
	// ...
	const stub = env.MAILBOX.get(env.MAILBOX.idFromName(mailboxId));

該当箇所

この関数が受け取ると宣言しているのは rawrawSize だけで、エンベロープの宛先である message.to は型の上で見えません。受信箱を決める材料が、送信者が書いた本文の中にしかない形です。

To ヘッダーは、SMTP でいえば DATA の中身、つまり送信者が自由に書ける部分です。実際にどこへ配達するかを決めるのは RCPT TO コマンドで、こちらは本文の外側、エンベロープ(封筒に当たる部分)の側にあります。

1通のメールの SMTP セッションを、MAIL FROM と RCPT TO が並ぶエンベロープと、DATA の後に続く From・To・Subject と本文とに分けた図。Email Worker の message.to はエンベロープの RCPT TO から来て、この実装はそれを受信箱のキーにする。agentic-inbox は解析した To ヘッダーをキーにするので、RCPT TO が agent-a のままでも、To に agent-b と書くだけで agent-b の受信箱に入る

RFC 5321 の 7.2 節は、この2つはもともと無関係だと言い切っています。

There is no inherent relationship between either “reverse” (from MAIL, SAML, etc., commands) or “forward” (RCPT) addresses in the SMTP transaction (“envelope”) and the addresses in the header section.

Bcc を思い浮かべると分かりやすいです。Bcc で受け取ったメールの To には、受け取った本人のアドレスが載っていません。それでも届くのは、配達が RCPT TO で決まっているからです。

Email Worker に渡る message.to は、ランタイム API のドキュメントに “Recipient email address (envelope RCPT TO)” とあるとおり、エンベロープ側の宛先です。

agentic-inbox の README はルーティング規則を catch-all にして Worker へ流す手順を案内しているので、そのドメイン宛てならエンベロープの宛先が何であっても Worker に届きます。そこで To ヘッダーに既存の受信箱のアドレスを書けば、エンベロープの宛先とは無関係に、その受信箱にメールが入ります。

Cloudflare Access で守られたアプリを1人で使い、全部の受信箱を見る前提なら、実害は小さいです。エージェントごとに受信箱を分けて「自分の分だけ読める」を作りたい用途では、受信箱を隔てているのはここだけになります。

手元の実装では、キーは message.to からだけ取ります。

// email-worker/src/index.ts(抜粋)
const key = normalizeAddress(message.to);
if (key === null) {
  reject("unnormalizable_recipient");
  return;
}
if (!allowedRecipients(env).has(key)) {
  reject("recipient_not_configured");
  return;
}

To ヘッダーの値は header_to として行に残しますが、どの受信箱に入るかには関与しません。

この違いは手元で確かめられます。wrangler dev/cdn-cgi/handler/email というエンドポイントを開き、そこに raw MIME のメールを POST すると email() ハンドラーが動きます

クエリの fromto がエンベロープで、本文の To ヘッダーとは独立に指定できるので、「To には別の受信箱が書いてあるがエンベロープは自分宛て」というメールを作って、どちらの受信箱に入るかを見ました。

正規化は ASCII の判定を先にする

受信時と、まだ作っていない読み出し時とで同じキーに落とすために、正規化関数は1つだけ用意しました。順番に1か所だけ気を遣っています。

// email-worker/src/address.ts(抜粋)
const ADDRESS_CHARS = /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~@-]+$/;
// ...
if (!ADDRESS_CHARS.test(value)) return null;
// ...
return value.toLowerCase();

JavaScript の toLowerCase() は Unicode 全体を対象にします。たとえばケルビン記号 U+212A は、"K".toLowerCase() で ASCII の k になります(Node 24 で確認)。

先に小文字化してから ASCII かどうかを見ると、ケルビン記号入りのアドレスと普通の k のアドレスが同じキーに落ちます。ASCII の判定を先に置けば、ケルビン記号の方はそこで弾かれます。

Cloudflare 側も、ローカルパートに非 ASCII を許していませんinfo@piñata.es は可、piñata@piñata.es は不可)。ローカルパートを ASCII に限る分には、取りこぼしは出ません。

国際化ドメイン名の側は別です。この判定はドメインまで含めて見ているので、U-label のまま渡ってくると弾きます。message.to がどの形で渡るかはまだ確かめていません。

+ タグやドットも潰しません。潰すと、別のアドレスが同じキーに落ちます。

ドキュメントにある canBeForwarded は型に無い

Email Worker のランタイム API のページには、ForwardableEmailMessage のインターフェースがこう載っています。

interface ForwardableEmailMessage {
	readonly from: string; // Sender email address (envelope MAIL FROM)
	readonly to: string; // Recipient email address (envelope RCPT TO)
	readonly headers: Headers; // Email headers (Subject, Message-ID, etc.)
	readonly raw: ReadableStream; // Raw MIME email content stream
	readonly rawSize: number; // Size of raw email in bytes
	readonly canBeForwarded: boolean; // Whether the message can be forwarded
	// ...
}

canBeForwarded は、wrangler 4.124 の wrangler types が生成した worker-configuration.d.ts には存在しません。生成された型にあるのは from to raw headers rawSize setReject forward reply だけで、workerd 側の型定義の元(types/defines/email.d.ts)にもありません。

GitHub のコード検索で Cloudflare の組織内を探しても、この語が出てくるのはドキュメントのそのファイル1つです。

型はランタイムから生成されていて、説明文は人が書いています。食い違ったら型を信じます。

2025年7月3日から、Cloudflare は転送にあたって SPF か DKIM のどちらかを通っていることを要求するようになりました。「転送できるか」という名前のフラグは、この変更に合わせて説明に足されたもののように見えます。ただしこれは推測で、ドキュメントは “Whether the message can be forwarded” 以上のことを書いていません。

残るのは Authentication-Results ヘッダーだけ

では SPF や DKIM の結果を Worker はどこから知るのか。メッセージオブジェクトにその結果を持つプロパティは無く、残るのは Cloudflare が付ける Authentication-Results ヘッダーだけです。

Gmail から送った実際のメールには、こういう値が付いていました(ドメインと IP は伏せています)。

mx.cloudflare.net; dkim=pass header.d=….gappssmtp.com header.s=… ;
dmarc=none header.from=… policy.dmarc=none;
spf=none (no SPF records found for postmaster@mail-….google.com) smtp.helo=…;
spf=pass (domain of … designates 2607:f8b0:… as permitted sender) smtp.mailfrom=…;
arc=pass

読み方には罠が2つあります。

1つ目は spf= が2回出ることです。前の方は HELO に対する判定で none、後の方は MAIL FROM に対する判定で pass です。送信ドメインについて何かを言っているのは後者なので、spf= を部分一致で拾うと前の方を読んでしまいます。

2つ目は、このヘッダーを送信者も書けることです。RFC 8601 の 7.1 節がそのまま想定している攻撃で、受信側のドメインを authserv-id に使った偽のヘッダーを付けて「認証は通った」と主張できます。

しかも message.headersFetch 標準の Headers なので、同名のヘッダーが複数あると get() は値を , で連結して返します。Cloudflare の判定と送信者の主張が1本の文字列になって戻ってくる形です。

手元では Headers ではなく、postal-mime が返すヘッダーの配列を読んでいます。重複がそのまま残るので、先頭の authserv-id が自分側の MX が付けたもの(実測では mx.cloudflare.net)と一致する1件だけを採り、残りは件数だけ数えて保存します。

採った1件の中でも、spfsmtp.mailfrom を伴う方を優先します。

前提として、Cloudflare は SPF と DKIM の両方を落としたメールを拒否し、送信ドメインの DMARC ポリシーに従った拒否もします。Worker に届く時点で最低限の足切りは済んでいて、ヘッダーが教えてくれるのは「どちらが通ったか」と DMARC の結果です。

25 MiB のメールは 2 MB の行に入らない

Email Routing が受け付けるメールの上限は 25 MiB です(Limits)。一方で、Durable Object の SQLite には “Maximum string, BLOB or table row size” として 2 MB の上限があります(Durable Objects limits)。

agentic-inbox は添付を R2 に出しますが、本文(htmltext)は Durable Object の行に入れます。そして email() ハンドラーでは例外を捕まえて、ログを出したあとに投げ直しています。

// workers/app.ts(agentic-inbox、抜粋)
try {
	await receiveEmail(event, env, ctx);
} catch (e) {
	console.error("Failed to process incoming email:", (e as Error).message, (e as Error).stack);
	// Re-throw so Cloudflare's email routing can retry delivery or bounce the message.
	// Swallowing the error would silently drop the email.
	throw e;
}

本文が 2 MB を超えるメールを送れば、行の書き込みは失敗し、例外がそのまま上に抜けるはずです(実際に送って確かめてはいません)。そのあと Cloudflare が SMTP セッションに何を返すのか、ドキュメントには書かれていません。上のコメントも「再送か、バウンスか」と両方を書いています。

再送であれば、同じメールが何度も届いて毎回同じ場所で失敗します。どちらにしても、何度やっても失敗する入力への応答を、ドキュメントに無い挙動に任せている状態です。そしてアドレスを知っている人なら誰でも、その入力を送れます。

手元では、原文を丸ごと R2 に置き、Durable Object にはメタデータと R2 のキーだけを入れました。行の中に本文はありません。

// email-worker/src/index.ts(抜粋)
await env.RAW.put(rawKey, raw, {
  httpMetadata: { contentType: "message/rfc822" },
});

const stub = env.MAILBOX.get(env.MAILBOX.idFromName(key));
await stub.store({
  id,
  received_at: new Date().toISOString(),
  envelope_from: message.from,
  envelope_to: key,
  // ...
  raw_size: message.rawSize,
  raw_key: rawKey,
});

サイズの上限は MAX_MESSAGE_BYTES という変数で 10 MiB にしました。25 MiB まで受ける理由が無かったからで、これは Cloudflare の制限ではなく、この構成での判断です。

Worker の reject は 550 を返せない

上限を超えたメールに何を返すかを決める前に、届いたメールがどこで止まりうるかを並べました。止まる場所は3つあります。

届いたメールの行き先を左から右に並べた図。宛先にルーティング規則が無ければ Cloudflare の MX が 550 5.1.1 を返し、Worker は起動しない。規則があれば Email Worker がサイズ上限、宛先の正規化と許可リスト、MIME 解析、R2 と Durable Object への書き込みの順に進む。1〜3 で落ちたら setReject で 555 5.7.1、4 で落ちたら例外のまま上に抜けて、その後の応答はドキュメントに無い。通れば原文を R2 に、メタデータを Durable Object に保存する

1つ目は Cloudflare の MX です。ルーティング規則の無いアドレス宛てに送ると、catch-all を無効にしてある状態では、MX がこう返しました。

550 5.1.1 Address does not exist.

Worker は起動していません。これはドキュメントに書かれていない挙動で、実測した時点の値です。

2つ目は Worker の setReject(reason) で、こちらが返すのはこうでした。

555 5.7.1 Message rejected.

reason に渡せるのは文字列だけで、コードは Cloudflare が決めます。拡張ステータスの 5.7.1RFC 3463 で “Delivery not authorized, message refused” なので合っています。555 は合っていません。RFC 5321 の 4.2.3 節での定義は次のとおりです。

555 MAIL FROM/RCPT TO parameters not recognized or not implemented

起きたことは「パラメーターを解釈できなかった」ではありません。同じ節の 550 は “mailbox unavailable (e.g., mailbox not found, no access, or command rejected for policy reasons)” で、ポリシーによる拒否まで含んでいます。こちらを返したいのですが、Worker からは選べません。

ここから、拒否の役割分担が決まりました。「そのアドレスは無い」と言いたいなら、Worker で拒否するより、ルーティング規則を作らないことの方が正しい応答になります。Worker 側の許可リストは、主役ではなく保険です。規則だけ残って受信箱が消えたアドレスや、誰かが後から catch-all を有効にしたときのために置いてあります。

黙って受け取って捨てるのではなく拒否する以上、代償もあります。MX にアドレスを片っ端から投げてみれば、どれが存在するかは分かります。アドレスが数個しかないドメインでは、それで失うものは小さいと判断しました。

Worker まで来たメールの失敗は、種類で応答を分けました。サイズ超過、宛先が設定に無い、MIME を解析できない。この3つは何度やり直しても同じ結果になるので、setReject で恒久的なエラーを返します。生成された型の説明にあるとおり、setReject は “a permanent SMTP error” を接続元に返すものです。

3つ目の止まる場所が例外です。R2 や Durable Object への書き込みが落ちたような一時的な失敗だけを、例外のまま上に抜けさせています。ここだけは、先に書いたドキュメントに無い挙動に任せている形です。

サブアドレスと許可リストは一緒に決める

これに絡む罠が1つあります。サブアドレスuser+detail@example.com の形)は設定で有効にする機能です。有効にすると user@example.com の規則にマッチするようになり、+detail の部分は message.to に残ります。

ここで message.to を完全一致で比較する許可リストを持っていると、規則を通ったメールを Worker が弾きます。+ を潰して比較すれば通りますが、それは先に書いたとおり別のアドレスを同じキーに落とすことなので、やりません。

サブアドレスを有効にするかどうかと、受信箱のキーを厳密に取るかどうかは、別々に決められる設定ではありませんでした。

「受信専用」を決めているのはプラットフォームではなくこの構成

Email Routing は受信専用だ、という言い方はよく見ます。ドキュメントの既知の制限にも “Email Routing does not support sending or replying from your Cloudflare domain” とあります。

ただし、それは Email Routing の話です。Worker は送れます。料金のページにはこうあります。

Sending to arbitrary recipients requires the Workers Paid plan. Sending to verified destination addresses in your account is free on all plans, including when only Email Routing is configured.

send_email バインディングを付けた Worker は、無料プランで Email Routing しか設定していなくても、同じアカウントの検証済み宛先へは送れます。バインディングの設定ページも、制限属性を付けなければ “The binding can send to any verified destination address in your account” と書いています。

任意の宛先に送れるようになるのは、送信ドメインをオンボードしてからです。Limits のページが “Before you onboard a sending domain, you can send emails only to verified destination addresses in your account. After you onboard a sending domain, you can send to any recipient immediately.” と書いているとおりで、それに加えて Workers Paid が要ります。

つまり「送る経路が無い」のは、プラットフォームがそうだからではなく、この構成でそう作ったからです。wrangler.jsoncsend_email バインディングを書かず、message.forward() も呼ばず、運用に使うトークンに email_sending:write を付けていない、という3つで成り立っています。Email Sending を有効にすれば、受信専用のはずの Worker からでも送れるようになるので、そこは構成を変えるときに注意が要ります。