はじめに

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 バインディングがありますが、以下の制約があります:

  1. 送信先の事前検証が必要 — 任意の外部アドレスには送れない
  2. Port 25がブロック — Workers の connect() APIでもSMTPポートへの接続は禁止
  3. 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 アカウント設定

  1. Brevo でアカウント作成
  2. 電話番号認証を完了(SMTPアクティベーションに必須)
  3. Settings → SMTP & API → API キーを生成
  4. ドメイン認証を完了する(前述の「ドメイン認証と送信者登録」セクション参照)
  5. 送信に使う各メールアドレスを送信者として登録し、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が成功を返しても実際にはメールが送信されない「サイレントエラー」が最大の落とし穴です。