はじめに
前回の記事では、Cloudflare Email Routing と Brevo を組み合わせて、6つのサブドメインで18個のメールアドレスを運用するメール送受信基盤を構築した。MXレコードの設定、Brevoのドメイン認証、send_email バインディングの制約と外部API経由の送信への切り替えなど、基盤の骨格となる部分を解説した。
今回のPart 2では、その基盤を21サブドメイン・108アドレスの規模へスケーリングした際に直面した課題と、その解決策を記録する。手作業では対応しきれない数のリソースを、Cloudflare APIとPythonスクリプトで一括処理した実践的な内容になっている。
スケーリングの課題を整理する
6サブドメインから21サブドメインへ
組織が当初の6部署から21部署・107名体制へ拡大したことで、メールアドレスの数は一気に増加した。各スタッフには {surname}@{dept}.kyotanishokai.co.jp 形式のメールアドレスが必要になり、合計108アドレス(スタッフ107名 + apexドメイン1アドレス)を管理することになった。
6サブドメインの段階では、Cloudflareダッシュボード上でMXレコードの追加やEmail Routingルールの設定を手動で行えた。しかし21サブドメインになると、MXレコードだけで63件(21サブドメイン × 3レコード)、ルーティングルールは108件が必要になる。ダッシュボードで1件ずつ設定するのは現実的ではなく、設定漏れや入力ミスのリスクも高い。
個別設定の限界とAPIによる自動化
Cloudflareダッシュボードは個別の設定変更には優れているが、数十件規模の一括操作には向いていない。幸いCloudflareはすべての操作をカバーするREST APIを提供しており、DNS・Email Routingの設定をプログラマブルに管理できる。今回はPythonの標準ライブラリ urllib を使い、外部パッケージへの依存を避けたスクリプトで自動化を行った。
MXレコードの一括設定(Cloudflare API)
既存MXレコード構成の確認
Email Routingを有効にしたサブドメインには、Cloudflareが指定する3つのMXレコードを設定する必要がある。
| 優先度 | ホスト |
|---|---|
| 26 | route2.mx.cloudflare.net |
| 39 | route1.mx.cloudflare.net |
| 56 | route3.mx.cloudflare.net |
既存の6サブドメインに設定済みのMXレコードが正しく入っているかを、まずCloudflare DNS Records APIで確認する。
import urllib.request
import json
ZONE_ID = "your_zone_id"
API_TOKEN = "your_api_token"
url = f"https://api.cloudflare.com/client/v4/zones/{ZONE_ID}/dns_records?type=MX&per_page=100"
req = urllib.request.Request(url, headers={
"Authorization": f"Bearer {API_TOKEN}",
"Content-Type": "application/json",
})
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read().decode())
for record in data["result"]:
print(f"{record['name']} → {record['content']} (priority: {record['priority']})")
既存の6サブドメイン分(18件)が正しく返ってくることを確認したうえで、残り15サブドメインの一括作成に進む。
Pythonスクリプトによる15サブドメイン一括作成
新規追加が必要な15サブドメインに対して、それぞれ3つのMXレコード(合計45件)を作成するスクリプトを書いた。
import urllib.request
import json
import time
ZONE_ID = "your_zone_id"
API_TOKEN = "your_api_token"
DOMAIN = "kyotanishokai.co.jp"
NEW_SUBDOMAINS = [
"ctn", "pub", "vid", "cld", "sec", "uxd", "pmq",
"biz", "ecm", "csr", "bpd", "dta", "ais", "mgt", "leg",
]
MX_SERVERS = [
{"content": "route1.mx.cloudflare.net", "priority": 39},
{"content": "route2.mx.cloudflare.net", "priority": 26},
{"content": "route3.mx.cloudflare.net", "priority": 56},
]
url = f"https://api.cloudflare.com/client/v4/zones/{ZONE_ID}/dns_records"
created = 0
for sub in NEW_SUBDOMAINS:
for mx in MX_SERVERS:
payload = json.dumps({
"type": "MX",
"name": f"{sub}.{DOMAIN}",
"content": mx["content"],
"priority": mx["priority"],
"ttl": 1, # auto
}).encode()
req = urllib.request.Request(url, data=payload, method="POST", headers={
"Authorization": f"Bearer {API_TOKEN}",
"Content-Type": "application/json",
})
with urllib.request.urlopen(req) as resp:
result = json.loads(resp.read().decode())
if result["success"]:
created += 1
print(f"[OK] {sub}.{DOMAIN} → {mx['content']}")
else:
print(f"[FAIL] {sub}.{DOMAIN}: {result['errors']}")
time.sleep(0.2) # rate limit対策
print(f"\n合計 {created}/45 レコード作成完了")
スクリプト実行後、nslookup で実際にMXレコードが引けることを確認する。
nslookup -type=MX cld.kyotanishokai.co.jp
DNS伝播には数分かかる場合があるが、CloudflareのDNSは通常1分以内に反映される。全15サブドメインの確認が終われば、MXレコードの設定は完了となる。
Email Routing Rulesの設計判断
個別リテラルルール vs catch-all
Email Routingには「catch-all」ルールがあり、一見するとサブドメイン全体を一括で受信できるように思える。しかしcatch-allルールはapexドメインにしか適用されず、サブドメイン宛のメールには反応しないという仕様が、実際にテストして初めて判明した。
catch-allルールの type: "all" マッチャーは *@kyotanishokai.co.jp にマッチするが、taniguchi@cld.kyotanishokai.co.jp のようなサブドメインアドレスにはマッチしない。つまり、サブドメインのメールアドレスごとに個別のリテラルルール(type: "literal")を作成する必要がある。
この制約はCloudflareの公式ドキュメントには明示されておらず、Cloudflare Communityの投稿や実地検証を通じて確認した。108アドレスなら108ルールが必要になるため、これもAPI経由での一括作成が前提となる。
108アドレス分のルーティングルール一括作成
各ルーティングルールは「リテラルマッチ → Workerアクション」の構造を持つ。受信したメールをWorkerの email エクスポートハンドラに渡し、Worker内でD1への保存処理を行う設計にしている。
import urllib.request
import json
import time
ZONE_ID = "your_zone_id"
API_TOKEN = "your_api_token"
# 全108アドレスのリスト(実際にはスタッフマッピングから生成)
EMAIL_ADDRESSES = [
"sato@seo.kyotanishokai.co.jp",
"takahashi@seo.kyotanishokai.co.jp",
"taniguchi@cld.kyotanishokai.co.jp",
# ... 残り105アドレス
]
url = f"https://api.cloudflare.com/client/v4/zones/{ZONE_ID}/email/routing/rules"
for addr in EMAIL_ADDRESSES:
payload = json.dumps({
"actions": [
{"type": "worker", "value": ["kyotani-shoukai-api"]}
],
"matchers": [
{"type": "literal", "field": "to", "value": addr}
],
"enabled": True,
"name": f"Route {addr} to worker",
}).encode()
req = urllib.request.Request(url, data=payload, method="POST", headers={
"Authorization": f"Bearer {API_TOKEN}",
"Content-Type": "application/json",
})
with urllib.request.urlopen(req) as resp:
result = json.loads(resp.read().decode())
status = "OK" if result["success"] else f"FAIL: {result['errors']}"
print(f"[{status}] {addr}")
time.sleep(0.3)
ルール作成の際、actions の worker タイプではWorkerの名前(wrangler.jsonの name フィールドに対応)を配列で指定する。この仕様はEmail Routing API リファレンスに記載されているが、値がstringではなくstring配列である点は見落としやすい。
メールアドレスからスタッフへのマッピング設計
命名規則と同姓回避
メールアドレスのローカルパートには、スタッフの姓をローマ字表記で使用するルールにした。
- 基本形:
{surname}@{dept}.kyotanishokai.co.jp(例:taniguchi@cld.kyotanishokai.co.jp) - 同じ部署に同姓のスタッフがいる場合:
{first-initial}-{surname}@{dept}.kyotanishokai.co.jp(例: SEO部に渡辺が2名いるためd-watanabe@seo.kyotanishokai.co.jpとk-watanabe@seo.kyotanishokai.co.jp)
スタッフの識別子(slug)は {surname}-{firstname} の小文字ローマ字で統一しており、D1テーブルの staff_slug カラムにはこの値が格納される。
STAFF_EMAIL_MAPの構造
Worker内でメールアドレスからスタッフを特定するために、Record<string, string> 型のシンプルなマッピングオブジェクトを定義した。
const STAFF_EMAIL_MAP: Record<string, string> = {
// ─── SEO部 (seo.kyotanishokai.co.jp) ─── 11名
'sato@seo.kyotanishokai.co.jp': 'sato-takumi',
'takahashi@seo.kyotanishokai.co.jp': 'takahashi-misaki',
'd-watanabe@seo.kyotanishokai.co.jp': 'd-watanabe-daiki',
'k-watanabe@seo.kyotanishokai.co.jp': 'k-watanabe-kaede',
// ... 全21部署・108アドレス分
'kyotani@kyotanishokai.co.jp': 'kyotani-admin',
};
KVやD1で外部管理する案も検討したが、メールアドレスの変更頻度は低く、Worker再デプロイで反映される方がシンプルだと判断した。108エントリ程度であればWorkerバンドルサイズへの影響もごくわずかで、V8 isolateの起動時間にも影響しない。
受信Workerの改善
ReadableStreamの明示的バッファリング
Email Routingが起動するWorkerの email ハンドラでは、message.raw として生のメールデータがReadableStreamで渡される。当初はこれを直接 PostalMime の parse() に渡していたが、一部のメールで body_text が null になる問題が発生した。
原因を調査したところ、ReadableStreamを直接渡した場合に、ストリームの消費タイミングによってパース結果が不完全になるケースがあることがわかった。修正として、Response コンストラクタでストリームを ArrayBuffer に変換してからパースする方法に切り替えた。
export async function handleEmail(
message: ForwardableEmailMessage,
env: Env,
ctx: ExecutionContext
) {
const toAddress = message.to.toLowerCase();
const staffSlug = STAFF_EMAIL_MAP[toAddress];
if (staffSlug) {
// ストリームを明示的にバッファリングしてから解析
const rawArrayBuffer = await new Response(message.raw).arrayBuffer();
const parsed = await PostalMime.parse(new Uint8Array(rawArrayBuffer));
// 以降、parsed.text / parsed.html / parsed.subject 等を使用
}
}
new Response(message.raw).arrayBuffer() というパターンは、Web Streams APIの設計に沿った変換方法で、Workers環境でも安定して動作する。この修正により、テスト対象のすべてのメールで body_text が正しく取得できるようになった。
HTMLからテキスト抽出フォールバック
マーケティングメールやニュースレターの多くは text/plain パートを含まず、HTMLのみで送信される。PostalMimeのパース結果で parsed.text が null の場合に備え、HTMLからプレーンテキストを抽出するフォールバック関数を追加した。
function htmlToPlainText(html: string): string {
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<\/div>/gi, '\n')
.replace(/<\/li>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/ /g, ' ')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/\n{3,}/g, '\n\n')
.trim();
}
この関数は完全なHTMLパーサーではないが、<style> / <script> タグの除去、ブロック要素の改行変換、HTMLエンティティのデコードを行い、実用上十分なプレーンテキストを生成する。呼び出し側では次のように使う。
const bodyText = parsed.text
|| (parsed.html ? htmlToPlainText(parsed.html) : null);
text/plainが存在すればそちらを優先し、HTMLのみの場合にフォールバックするという設計は、受信メールの多様性に対応するうえで重要なパターンになっている。
日本語メールとエンコーディングの落とし穴
PostalMimeとTextDecoderの関係
日本語メールではISO-2022-JP、Shift-JIS、EUC-JPなどのレガシーエンコーディングが使われることがある。PostalMimeは内部的に TextDecoder を使って文字コード変換を行うが、TextDecoderがサポートしていないエンコーディングの場合は windows-1252 にフォールバックし、結果として文字化け(mojibake)が発生する。
Cloudflare Workersでは、compatibility_date を 2024-09-23 以降に設定すると、WHATWG Encoding Standardで定義されているレガシーエンコーディングのサポートが有効になる。今回のWorkerは compatibility_date: "2025-01-01" を設定しているため、ISO-2022-JPやShift-JISを含む日本語メールも正しくデコードされる。
{
"compatibility_date": "2025-01-01",
"compatibility_flags": ["nodejs_compat"]
}
この設定がなかった場合、UTF-8以外のエンコーディングのメールが文字化けしていた可能性がある。Workers環境での日本語メール処理においては、compatibility_dateの設定が事実上必須の条件と言える。
CLIツールのUTF-8保証
もう一つ、メール基盤の構築中に遭遇したエンコーディング問題がある。Windows環境のGit Bashから curl でBrevo APIにテストメールを送信した際、日本語の件名や本文が文字化けした問題だ。
原因は、Git Bashの curl が日本語文字列をShift-JISでエンコードして送信していたことにある。Brevo APIはリクエストボディをUTF-8として解釈するため、Shift-JISのバイト列が届くと各マルチバイトが U+FFFD(REPLACEMENT CHARACTER)に置換される。
解決策は、APIコールにcurlではなくPythonを使うことで、Pythonの json.dumps() は文字列をUTF-8エンコードすることが保証されている。
import urllib.request
import json
url = "https://api.brevo.com/v3/smtp/email"
payload = json.dumps({
"sender": {"name": "テスト", "email": "test@seo.kyotanishokai.co.jp"},
"to": [{"email": "recipient@example.com"}],
"subject": "テストメール",
"textContent": "これはテスト本文です。",
}).encode("utf-8")
req = urllib.request.Request(url, data=payload, method="POST", headers={
"api-key": "your_brevo_api_key",
"Content-Type": "application/json; charset=utf-8",
})
with urllib.request.urlopen(req) as resp:
print(resp.read().decode())
なお、Workers内の fetch() と JSON.stringify() は常に有効なUTF-8を生成するため、Worker自体のコードでこの問題は発生しない。あくまで外部CLIからAPIを叩く際の注意点となる。Brevo Transactional Email APIのリファレンスにもcharsetの指定に関する記載があるが、クライアント側のエンコーディングまではカバーされていないため、自分で検証して確認する必要がある。
デプロイと動作検証
D1マイグレーション
6サブドメイン対応時のテーブル名は seo_staff_emails だったが、全部署対応に伴い staff_emails にリネームした。SQLiteの ALTER TABLE ... RENAME TO はインデックスを自動的に維持するため、テーブルリネーム時にインデックスの再作成は不要となる。
-- Migration 016: seo_staff_emails → staff_emails テーブルリネーム
ALTER TABLE seo_staff_emails RENAME TO staff_emails;
-- インデックスはALTER TABLE RENAMEで自動的に維持される(D1/SQLite仕様)
マイグレーションを適用した後、Workerをデプロイする。
npx wrangler d1 execute kyotani-shoukai-db --remote --file=api/db/migration-016-rename-staff-emails.sql
npm run deploy
テストメール送信と受信確認
デプロイ後の検証は、Brevo APIからテストメールを送信し、D1に正しく保存されることを確認する手順で行う。前述の通り、日本語を含むAPIコールにはPythonを使用する。
送信後、D1のデータを確認する。
npx wrangler d1 execute kyotani-shoukai-db --remote \
--command="SELECT staff_slug, from_address, subject, body_text FROM staff_emails ORDER BY created_at DESC LIMIT 3"
確認すべきポイントは、staff_slug が正しいスタッフにマッピングされていること、subject と body_text に文字化けがないこと、HTMLのみのメールでも body_text にフォールバック抽出されたテキストが入っていることの3点になる。
セットアップ段階でBrevoのバウンス(hardBounce)が発生していた場合は、Brevoのブラックリスト管理画面で該当アドレスを削除しておく必要がある。一度ブラックリストに入ると、そのアドレスへの送信はすべてブロックされるため、テスト中のバウンスは本番運用前に必ずクリーンアップしておきたい。
まとめ
6サブドメイン・18アドレスの基盤を、21サブドメイン・108アドレスの規模までスケーリングした。この過程で得られた知見を整理する。
まず、Email Routingのcatch-allルールはサブドメインには適用されないという仕様は、ドキュメントからは読み取りにくく、テストして初めて明らかになった。サブドメイン運用では個別のリテラルルールが必須となる。
次に、Workers環境でのメールパースでは、ReadableStreamをそのまま渡すのではなく、明示的にバッファリングしてからPostalMimeに渡す必要がある。加えて、HTMLのみのメールに備えたテキスト抽出フォールバックも実用上欠かせない。
最後に、日本語を扱う以上、エンコーディングの問題は避けて通れない。Workers側は compatibility_date の設定でレガシーエンコーディングをカバーできるが、外部ツールからのAPI呼び出しではUTF-8が保証される手段を選ぶ必要がある。
これらの知見を踏まえれば、さらにアドレス数が増えても同じアーキテクチャで対応できる。一括設定のスクリプトを用意しておくことで、新部署の追加はMXレコード3件 + ルーティングルールN件 + STAFF_EMAIL_MAPへのエントリ追加だけで完了する。
前回の記事(Part 1)とあわせて、Cloudflare Workers + Email Routing + Brevoによるメール基盤の全体像を参照してほしい。