はじめに
Cloudflare Workersでウェブアプリケーションを運用していると、「メール機能」が必要になる場面があります。お問い合わせフォームの自動返信、社内スタッフ間の連絡、外部パートナーへの通知——しかし、Cloudflare Workersには標準のメール送信機能がありません。
本記事では、Cloudflare Email Routing(受信)とBrevo API(送信)を組み合わせて、Cloudflare Workers上で完全なメール送受信基盤を構築する方法を解説します。
全体アーキテクチャ
[外部メール送信者]
↓ SMTP
[Cloudflare Email Routing] ← MXレコードで受信
↓ Email Worker
[Cloudflare Worker (Hono)]
↓ D1に保存
[ダッシュボード] ← 管理画面で閲覧・返信
[ダッシュボード] → compose API → Brevo HTTP API → 外部宛先
コンポーネント一覧
| レイヤー | 技術 | 役割 |
|---|---|---|
| 受信 | Cloudflare Email Routing | MXレコードで受信 → Worker転送 |
| 処理 | Cloudflare Workers (Hono) | メール解析・D1保存 |
| 保存 | Cloudflare D1 | メールデータ永続化 |
| 送信 | Brevo Transactional Email API | HTTP経由で外部送信 |
| UI | 管理ダッシュボード | 受信一覧・詳細・返信・新規作成 |
なぜBrevoが必要なのか
Cloudflareには send_email バインディングがありますが、以下の制約があります:
- 送信先の事前検証が必要 — 任意の外部アドレスには送れない
- Port 25がブロック — Workers の
connect()APIでもSMTPポートへの接続は禁止 - Email Routingの有効化が必要 — ゾーンレベルでMXをCloudflareに向ける必要がある
これらの制約により、外部アドレスへの自由な送信には HTTP APIベースのメール配信サービス が不可欠です。
Brevoを選んだ理由
| 比較項目 | Brevo | Resend | SendGrid |
|---|---|---|---|
| 無料枠 | 300通/日 | 100通/日 | 100通/日 |
| API方式 | REST (HTTP) | REST (HTTP) | REST (HTTP) |
| セットアップ | 簡単 | 簡単 | やや複雑 |
| ドメイン認証 | DNS TXTのみ | DNS TXTのみ | 複数ステップ |
Brevoは無料枠が最大で、HTTP APIからWorker内の fetch() で直接呼び出せます。
送信側: Brevo のドメイン認証と送信者登録(重要)
Brevo経由でメールを送信するには、ドメイン認証と送信者登録の2段階が必要です。どちらか一方が欠けていると、Brevo APIは200(成功)を返しますが、内部でリジェクトされてメールは届きません。この「サイレントエラー」は非常にわかりにくいため、必ず両方を設定してください。
1. ドメイン認証(DKIM / SPF / DMARC)
Brevo APIでドメインを登録し、返却されるDNSレコードをCloudflareに追加します。
# ドメイン登録
curl -X POST "https://api.brevo.com/v3/senders/domains" \
-H "api-key: $BREVO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"yourdomain.co.jp"}'
返却されるDNSレコードをすべてCloudflareに追加します:
| レコード種別 | ホスト名 | 値 | 用途 |
|---|---|---|---|
| CNAME | brevo1._domainkey |
b1.yourdomain-co-jp.dkim.brevo.com |
DKIM署名(1) |
| CNAME | brevo2._domainkey |
b2.yourdomain-co-jp.dkim.brevo.com |
DKIM署名(2) |
| TXT | @ |
brevo-code:xxxx... |
ドメイン所有権確認 |
| TXT | _dmarc |
v=DMARC1; p=none; rua=mailto:rua@dmarc.brevo.com |
DMARC |
SPFレコードにもBrevoを追加します:
v=spf1 include:_spf.mx.cloudflare.net include:relay.sendinblue.com ~all
DNSレコード追加後、Brevo APIで認証を実行します:
curl -X PUT "https://api.brevo.com/v3/senders/domains/yourdomain.co.jp/authenticate" \
-H "api-key: $BREVO_API_KEY"
# → {"message": "Domain has been authenticated successfully."}
注意: 古い brevo-code TXTレコードが残っていると認証に失敗します。重複がないことを確認してください。
2. 送信者の個別登録(OTP確認が必要)
ドメイン認証だけでは不十分です。送信に使う各メールアドレスをBrevoに送信者として登録し、OTP(ワンタイムパスワード)による確認を完了する必要があります。
# 送信者登録
curl -X POST "https://api.brevo.com/v3/senders" \
-H "api-key: $BREVO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Sender Name","email":"staff@yourdomain.co.jp"}'
登録すると、そのメールアドレスにBrevoから確認メールが届きます。メール内の 6桁のOTPコード を取得し、バリデーションAPIに送信します:
# OTPでバリデーション(sender_idは登録時の返却値)
curl -X PUT "https://api.brevo.com/v3/senders/{sender_id}/validate" \
-H "api-key: $BREVO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"otp": 123456}'
バリデーション完了後、送信者のステータスが active: true になります。 この状態でなければメールは送信されません。
確認方法:
curl "https://api.brevo.com/v3/senders" -H "api-key: $BREVO_API_KEY"
# active: true であることを確認
サイレントエラーに注意
Brevo APIの /v3/smtp/email エンドポイントは、送信者が無効でも HTTPステータス201(成功)を返します。しかしメールは実際には送信されず、イベントログにのみ error が記録されます:
event: "error"
reason: "Sending has been rejected because the sender you used xxx@domain is not valid.
Validate your sender or authenticate your domain"
このため、新しい送信者アドレスを追加した際は、必ずイベントログで配信状況を確認してください:
curl "https://api.brevo.com/v3/smtp/statistics/events?limit=10&sort=desc&email=recipient@example.com" \
-H "api-key: $BREVO_API_KEY"
# event: "delivered" が表示されれば成功
受信側: Cloudflare Email Routing の設定
1. サブドメインのMXレコード設定
各部署サブドメインにCloudflare Email RoutingのMXレコードを追加します。
dev.kyotanishokai.co.jp MX 26 route2.mx.cloudflare.net
dev.kyotanishokai.co.jp MX 39 route1.mx.cloudflare.net
dev.kyotanishokai.co.jp MX 56 route3.mx.cloudflare.net
2. Email Routingルールの作成
各スタッフのメールアドレスに対して、Workerへの転送ルールを設定します。
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/email/routing/rules" \
-H "Authorization: Bearer {token}" \
-d '{
"name": "kato-dev",
"enabled": true,
"matchers": [{"type": "literal", "field": "to", "value": "kato@dev.kyotanishokai.co.jp"}],
"actions": [{"type": "worker", "value": ["your-worker-name"]}]
}'
3. Email Worker (受信ハンドラー)
import PostalMime from 'postal-mime';
// メールアドレス → スタッフ識別子のマッピング
const STAFF_EMAIL_MAP: Record<string, string> = {
'kato@dev.kyotanishokai.co.jp': 'kato-seiichi',
// ... 他のスタッフ
};
export async function handleEmail(
message: ForwardableEmailMessage,
env: Env
) {
const toAddress = message.to.toLowerCase();
const staffSlug = STAFF_EMAIL_MAP[toAddress];
if (!staffSlug) {
message.setReject(`Unknown recipient: ${toAddress}`);
return;
}
const parsed = await PostalMime.parse(message.raw);
await env.DB.prepare(`
INSERT INTO seo_staff_emails (
staff_slug, from_address, from_name, to_address,
subject, body_text, body_html, message_id, in_reply_to
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
`).bind(
staffSlug, message.from, parsed.from?.name || null,
toAddress, parsed.subject || '(件名なし)',
parsed.text || null, parsed.html || null,
parsed.messageId || null, null
).run();
}
4. Worker エントリーポイントでの登録
// src/index.ts
export default {
fetch: app.fetch,
email: handleEmail, // Email Workerとして登録
};
送信側: Brevo API の統合
1. Brevo アカウント設定
- Brevo でアカウント作成
- 電話番号認証を完了(SMTPアクティベーションに必須)
- Settings → SMTP & API → API キーを生成
- ドメイン認証を完了する(前述の「ドメイン認証と送信者登録」セクション参照)
- 送信に使う各メールアドレスを送信者として登録し、OTP確認を完了する
2. Worker シークレットに登録
echo "xkeysib-xxxxx..." | wrangler secret put BREVO_API_KEY
3. 送信関数の実装
async function sendViaBrevo(
env: Env,
from: { email: string; name?: string },
to: { email: string; name?: string },
subject: string,
textContent: string
): Promise<void> {
const res = await fetch('https://api.brevo.com/v3/smtp/email', {
method: 'POST',
headers: {
'api-key': env.BREVO_API_KEY,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
sender: { name: from.name || from.email, email: from.email },
to: [{ email: to.email, name: to.name || to.email }],
subject,
textContent,
}),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Brevo API error ${res.status}: ${err}`);
}
}
4. compose エンドポイント
app.post('/api/mail/compose', requireAuth, requireAdmin, async (c) => {
const body = await c.req.json();
await sendViaBrevo(
c.env,
{ email: body.from_address, name: body.from_name },
{ email: body.to_address },
body.subject,
body.body
);
return c.json({ ok: true });
});
新しい送信者アドレスを追加するチェックリスト
新しいスタッフのメールアドレスからBrevo経由で送信する際の手順をまとめます。
| # | 手順 | 確認方法 |
|---|---|---|
| 1 | Cloudflare Email Routingにルールを追加 | GET /zones/{zone}/email/routing/rules |
| 2 | email-handler.ts の STAFF_EMAIL_MAP にマッピング追加 |
Worker再デプロイ |
| 3 | Brevo Senders APIで送信者を登録 | POST /v3/senders |
| 4 | 確認メールのOTPを取得(D1に届く) | D1クエリで確認 |
| 5 | OTPでバリデーション完了 | PUT /v3/senders/{id}/validate |
| 6 | active: true を確認 |
GET /v3/senders |
| 7 | テスト送信後、イベントログで delivered を確認 |
GET /v3/smtp/statistics/events?email=... |
注意事項とハマりポイント
Email Routing 有効化時のMX競合
Email Routingを有効化するには、ゾーンのapexドメインにCloudflare MXレコードが必要です。Google Workspace等の既存MXレコードがあると有効化できません。
Error: Non-Cloudflare MX records exist
対処法: サブドメインのみにCloudflare MXを設定し、apexドメインは既存のメールサービスを維持。ただし、Email Routingの send_email バインディングが使えない場合があるため、Brevo等の外部APIで送信を担保します。
Brevo アカウントのアクティベーション
新規Brevoアカウントは 電話番号認証を完了しないとSMTP/APIが有効化されません。
Error 403: Unable to send email. Your SMTP account is not yet activated.
アカウント作成後、必ずオンボーディングの電話認証ステップを完了してください。
Brevo APIの「サイレント送信失敗」
Brevo APIの最大の落とし穴は、送信者が無効でもHTTP 201を返すことです。APIレスポンスだけでは送信成功か失敗か判断できません。
対策: 送信後に必ずイベントログAPIで配信状態を確認する処理を組み込んでください。
// 送信後の配信確認(推奨)
async function checkDelivery(env: Env, recipientEmail: string): Promise<string> {
const res = await fetch(
`https://api.brevo.com/v3/smtp/statistics/events?limit=1&sort=desc&email=${encodeURIComponent(recipientEmail)}`,
{ headers: { 'api-key': env.BREVO_API_KEY } }
);
const data = await res.json() as any;
return data.events?.[0]?.event || 'unknown';
}
brevo-code TXTレコードの重複
ドメイン認証を再登録すると、新しい brevo-code が発行されます。古いレコードが残っていると認証に失敗するため、CloudflareのDNS設定で重複がないことを確認してください。
Port 25 の制限
Cloudflare Workers は port 25(SMTP)への外部接続を禁止 しています。connect() APIでも同様です。
Error: Connections to port 25 are prohibited
このため、直接SMTPで外部メールサーバーにメール配信することはできません。HTTP APIベースのメール配信サービスが唯一の選択肢です。
まとめ
| 課題 | 解決策 |
|---|---|
| メール受信 | Cloudflare Email Routing → Worker → D1 |
| メール送信 | Brevo HTTP API (fetch() で呼び出し) |
| ドメイン認証 | DKIM (CNAME×2) + SPF + DMARC + brevo-code をDNSに追加 |
| 送信者認証 | Senders API登録 → OTP確認 → active: true |
| サイレント失敗対策 | イベントログAPI (/v3/smtp/statistics/events) で配信確認 |
| MX競合 | サブドメインのみCloudflare MX |
| Port 25制限 | HTTP API経由で回避 |
Cloudflare Workersの制約を理解した上で、Email Routing(受信)+ Brevo(送信)の組み合わせにより、サーバーレス環境でも実用的なメール送受信基盤を構築できます。特に、Brevoのドメイン認証と送信者登録は送信前に必ず完了してください。APIが成功を返しても実際にはメールが送信されない「サイレントエラー」が最大の落とし穴です。