API

アプリからのメール送信は、SMTP 直結ではなく API 経由にすべき理由

アプリのコードから SMTP に接続すると、1 つのパスワードがすべてのサービスに散らばり、送信ペース、リトライ、バウンス処理まで自分で抱えることになります。API という境界が何を変えるのかを整理します。

公開日 2026年9月21日

アプリにメール送信を足すときの既定のやり方は、アプリの中から SMTP 接続を開くことです。nodemailer の transport、smtplib のクライアント、環境変数から SMTP_HOST と SMTP_PASSWORD を読むメーラーライブラリ。すぐ動きますし、そして静かに、あなたのアプリを「到達性に責任を持つ存在」に変えます。

SMTP そのものが問題なのではありません。GQ CRM も SMTP で送信します。重要なのはその接続がどこに住むかです。ペーシングとリトライのために作られたプラットフォームのコードか、他にやるべき仕事があるアプリケーションコードか。

SMTP がアプリの中に住むと何が壊れるか

認証情報が散らばる。 送信できるすべてのサービス——API サーバー、バックグラウンドワーカー、cron ジョブ、一度きりの移行スクリプト——がメールボックスのパスワードを必要とします。ローテーションはそれらすべての再デプロイを意味し、一番注意深くない箇所からの漏洩は、そのメールボックス上の全テナントの送信を露出させます。

送信ペースを調整するものが何もない。 レプリカを 3 つ動かせば、それぞれが自分の担当分を同時に気軽に送り始めます。アカウント単位のレート制限は、共有されたカウンターが 1 つあって初めて意味を持ちますが、アプリの中の SMTP クライアントは他のレプリカが何をしているか知りません。

リトライは自作になるので、重複送信が普通になる。 リレーが受理した後のタイムアウトと、受理する前のタイムアウトは、クライアントから見ると区別できません。雑なリトライは注文確認を 2 通送り、リトライが無ければ 1 通も届きません。

マルチテナントの分離が自分の仕事になる。 全顧客が 1 つのメールボックスを共有するなら、その transport に触れられるコードパスはどの顧客としてでも送信できます。正しい from を渡すことは分離ではありません。

バウンスと苦情のフィードバックの行き先がない。 ハードバウンス、ソフトバウンス、苦情、配信停止は非同期で届き、たいてい誰も読まないメールボックスに落ちます。そのフィードバックがシステムに入らなければ、死んだアドレスへ送り続け、最後はドメインの評判が払います。

リクエストハンドラが他人のサーバーを待つ。 SMTP は同期的で、やり取りの多いプロトコルです。Web リクエストの中でインラインに実行すると、レイテンシもタイムアウトも、自分で制御できないリレーに結び付きます。

API という境界が変えるもの

接続を開く代わりに POST /api/send を呼ぶと、これらの責任はプラットフォーム側へ移ります。

  • メールボックスごとのパスワードではなく、プロジェクトごとのキー。 キー自体がプロジェクトを識別するのでプロジェクト ID を渡す必要はなく、スコープ(現在は send:email)、有効期限、IPv4/IPv6 の CIDR 許可リストを持たせられます。SMTP パスワードは暗号化されたまま保管され、アプリには渡りません。
  • すべての呼び出し元を数えたペーシング。 レート制限はプロセス単位ではなく「プロジェクト + 分」で数えるので、レプリカもバックグラウンドジョブも 1 つの予算を共有します。上限はプランが決め、セルフホスト環境では上書きできます。
  • 送信前に効くクォータのガードレール。 クォータ超過のリクエストは 403 と current / limit / requested を含む data.quota で即座に失敗します。後で請求されるメールを黙って積むことはありません。
  • 署名付きイベントとしての配信フィードバック。 email.sent、email.failed、email.bounced、email.complained、email.unsubscribed が HMAC 署名付きでエンドポイントに push され、バックオフ付きでリトライされ、配信 ID で重複排除されます。
  • おかしいときに見る場所が 1 つ。 受理された送信はすべて messageId 付きのログを書くので、「この顧客に送ったか」には答えがあります。

見落とされがちなのが冪等性

接続を移すだけでは重複送信は直りません。直すのは、ビジネスイベントごとにクライアント側で生成するキーです。

curl -X POST https://gq-crm.com/api/send \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $GQ_CRM_API_KEY" \
  -H "Idempotency-Key: order-2026-0001-confirm" \
  -d '{"to":"[email protected]","subject":"Your order","html":"<p>Confirmed.</p>"}'

24 時間のあいだ、同じキーは再送せずに最初のレスポンスを再生します(Idempotency-Replayed: true が付きます)。同じキーを別のボディで使うと 409 と code = idempotency_key_reuse、最初のリクエストが処理中に並行して投げると 409 idempotency_in_progress が返ります。実務的にはこうです。キーはビジネスイベントから導出し(order-2026-0001-confirm のように)、タイムアウトは恐れずリトライし、すでに送ったかどうかはプラットフォームに判断させます。

分岐に使えるエラー

構造化された失敗は、スタックトレースを記録する代わりにコードが反応できることを意味します。

ステータス意味
401X-API-Key が無い、または無効
400プロジェクトに SMTP 未設定、to/subject 欠落、本文が空
403feature_not_available(API は Starter 以上)、quota_exceeded、expired、forbidden_scope、ip_blocked
404templateId がこのプロジェクトのものではない
409冪等キーが別のボディで再利用された、または最初のリクエストが処理中
429プロジェクト単位の毎分レート制限に到達

code フィールドは、同じステータスを共有する状況を分けます。quota_exceeded は feature_not_available ではなく、ip_blocked も同様です。

アプリから SMTP 直結が妥当な場合

シングルテナントの社内ツール、1 日に数通、守るべき評判がない——アプリ内の SMTP クライアントは妥当な選択です。次のいずれかが当てはまると計算が変わります。複数レプリカ、複数テナント、顧客に見える送信、あるいは 1 回の急激なバーストが問題になる量。そうした条件では、接続はペースを制御し、リトライし、記録できる 1 か所に置くべきです。

GQ CRM のアプローチ

GQ CRM は自分の SMTP の上に載るコントロールプレーンです。プロジェクトごとに SES、Mailgun、Google Workspace、任意のリレーを接続し、アプリはメールボックスのパスワードではなくプロジェクトキーで POST /api/send を呼びます。検証、レート制限、受信者ごとのトラッキング、バウンス分類、署名付き Webhook はプラットフォーム側に残り、SMTP の請求は自分のもののままです。

まずは Send API クイックスタート から。分離のモデルはプロジェクトと API キーで説明しています。リレー選びで迷っているなら、Amazon SES の接続が最も安い出発点です。