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 のように)、タイムアウトは恐れずリトライし、すでに送ったかどうかはプラットフォームに判断させます。
分岐に使えるエラー
構造化された失敗は、スタックトレースを記録する代わりにコードが反応できることを意味します。
| ステータス | 意味 |
|---|---|
401 | X-API-Key が無い、または無効 |
400 | プロジェクトに SMTP 未設定、to/subject 欠落、本文が空 |
403 | feature_not_available(API は Starter 以上)、quota_exceeded、expired、forbidden_scope、ip_blocked |
404 | templateId がこのプロジェクトのものではない |
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 の接続が最も安い出発点です。