Resend・SES・SMTPを実装を意識せず切り替えられるようにする

Cloudflareをインフラに利用すると、メール送信は少し勝手が違う処理になります。開発中はResendで動作確認し、本番はAWS SESに切り替え、大口の導入先からは「自社のSMTPリレーを使ってほしい」と言われる、といった具合にプロバイダー自体が変わりやすいことに加え、SMTPという選択肢が実際にはどこまでサーバーレス環境で使えるのかが分かりにくいためです。それぞれのSDK・APIをチェックアウト処理やユーザー登録処理の中に直接書いてしまうと、プロバイダーを切り替えるたびにアプリケーションのあちこちを書き換えることになります。呼び出し元が「メールを送る」ということだけを知っていて、裏側の実装を意識しなくて済む形にしておくと、この切り替えが設定変更だけで済むようになります。

呼び出し元が知るべきは共通の型だけ

まず、プロバイダーに関係なく共通で使うMail型を決めます。

type Mail = {
  from: string;
  fromName?: string;
  to: string | string[];
  subject: string;
  html?: string;
  text?: string;
};

次に、どのプロバイダーを使うかという設定を、判別可能なユニオン型で表現します。

type EmailProviderConfig =
  | { provider: 'resend'; config: { apiKey: string } }
  | { provider: 'ses'; config: { region: string; accessKeyId: string; secretAccessKey: string } }
  | { provider: 'smtp'; config: { host: string; port: number; secure: boolean; username: string; password: string } };

送信処理そのものは、providerの値でどの実装を呼ぶかを振り分けるだけの薄いディスパッチャにします。

async function sendEmail(providerConfig: EmailProviderConfig, mail: Mail): Promise<void> {
  switch (providerConfig.provider) {
    case 'resend':
      return sendViaResend(providerConfig.config, mail);
    case 'ses':
      return sendViaSes(providerConfig.config, mail);
    case 'smtp':
      return sendViaSmtp(providerConfig.config, mail);
  }
}

チェックアウト処理やユーザー登録処理からはsendEmail(config, mail)しか呼ばれません。ResendのHTTP APIを叩いているのか、SESのSDKを使っているのか、生のSMTP接続をしているのかは、呼び出し元にとって完全に無関係な詳細になります。プロバイダーを切り替えたいときはproviderConfigの中身を変えるだけで済み、送信処理を呼んでいる箇所には一切手を入れません。

SMTPは「妥協の選択肢」ではない——2026年7月時点の実装事情

3つの選択肢のうち、SMTPだけは毛色が違って見えるかもしれません。ResendやSESはHTTP経由のAPIですが、SMTPは本来TCP上のテキストプロトコルであり、サーバーレス環境では扱いにくいというイメージを持たれがちです。実際、Cloudflare Workersは長らく「任意のTCP接続はできない」制約がありましたが、2026年7月時点ではcloudflare:socketsというTCPソケットAPIが提供されており、Workersから直接SMTP接続を張ることができます。

async function sendViaSmtp(
  config: { host: string; port: number; secure: boolean; username: string; password: string },
  mail: Mail
): Promise<void> {
  const { connect } = await import('cloudflare:sockets');
  const secureTransport = config.secure ? 'on' : 'starttls';
  const socket = connect(`${config.host}:${config.port}`, { secureTransport });

  // EHLO / (STARTTLSならTLSへ昇格) / AUTH LOGIN / MAIL FROM / RCPT TO / DATA を
  // SMTPプロトコルの応答コードを確認しながら順番に送っていく
}

ここで制約として残っているのは、ポート25への接続だけです。ポート25は迷惑メール対策として塞がれていますが、これは本来サーバー間の直接配送(MTA間通信)で使うポートで、アプリケーションが認証付きでメールを送るときはもともとポート587(STARTTLS)かポート465(暗黙のTLS)を使うのが一般的です。つまりResendやSESの代わりに、契約しているSMTPリレー(社内のメールサーバーや、SMTP経由でしか提供されないサービス)へ認証付きで送るという用途では、実務上SMTPも問題なく使える選択肢になっています。

ローカル開発と本番ランタイムの差に注意する

cloudflare:socketsはCloudflare Workersのランタイム固有のAPIのため、wrangler devまたは実際にデプロイされた環境でしか動きません。Node.jsやBunでそのままローカル開発サーバーを動かしている場合、このAPI自体が存在せず、importの時点で失敗します。

async function sendViaSmtp(config: SmtpConfig, mail: Mail): Promise<void> {
  let connect: (typeof import('cloudflare:sockets'))['connect'];
  try {
    ({ connect } = await import('cloudflare:sockets'));
  } catch {
    throw new Error(
      'SMTP送信はCloudflare Workersランタイムでのみ利用できます。ローカル開発ではResendまたはSESをお使いください。'
    );
  }
  // ...
}

これはSMTP実装のバグではなく、ランタイムの違いによる制約です。ローカル開発中はHTTP API系のResend・SESで動作確認し、SMTPの動作確認だけはwrangler devか本番デプロイ後に行う、という運用にしておくと、この差異に振り回されずに済みます。抽象化のおかげで、開発中はResend、本番は顧客の要望に応じてSMTPかSESに切り替える、という運用も設定を変えるだけで実現できます。

設定の出所も切り替え可能にしておく

プロバイダーの実装だけでなく、「どの設定を使うか」の出所も抽象化しておくと扱いやすくなります。たとえば管理画面でプロバイダー設定をDBに保存できるようにしつつ、未設定の場合は環境変数にフォールバックする、という2段構えにしておけば、テナントごとに管理画面から設定を変えられる製品でも、初期状態は環境変数だけで動かせます。

async function getEmailSetup(db: Db, env: EmailEnv): Promise<EmailSetup | null> {
  return (await getEmailSetupFromDb(db)) ?? getEmailSetupFromEnv(env);
}

これも「呼び出し元はgetEmailSetupを呼ぶだけで、設定がDB由来か環境変数由来かを意識しない」という、送信処理そのものの抽象化と同じ考え方の延長です。

まとめ

メール送信のプロバイダーは、開発フェーズ・本番環境・導入先の事情によって変わりやすい部分です。共通のMail型と、プロバイダーを判別可能なユニオン型、それを振り分けるだけの薄いディスパッチャを用意しておけば、呼び出し元を一切変えずにプロバイダーを切り替えられます。SMTPは2026年7月時点でCloudflare Workersからも問題なく送信でき、ポート25以外は制約がないため、HTTP API系のサービスと並ぶ対等な選択肢として設計に組み込んで構いません。ランタイム固有の制約(ローカル開発では動かない等)は実装の抽象化とは別の話として、呼び出し元には影響しない形で吸収しておくのが安全です。