独自ドメインのメール送信をPythonで自動化している場合、99%のチュートリアルは smtplib を使う。標準ライブラリだし、メンテナンス不要だし、動く。
しかし、本番で使い続けると、SMTPの「動く」には条件が付くことがわかる。VPC内のLambdaからポート587が通らない。コンテナの起動時にSMTPハンドシェイクで3秒待たされる。添付ファイルのMIMEエンコーディングで文字化けする。
私は去年から、サイドプロジェクトのメール送信をすべてHTTP APIに移行した。理由は、JSONの方がMIMEより扱いやすいというだけではない。運用の観点から、設計が単純になるのだ。
この記事では、SMTPをHTTP APIに置き換える際に実際に考えた設計判断と、最終的に落ち着いたPythonモジュールを紹介する。
SMTPの問題はライブラリではない
smtplib は悪くない。問題はプロトコルだ。![]()
接続のオーバーヘッド
SMTPはステートフルな対話型プロトコルだ。EHLO、STARTTLS、AUTH、MAIL FROM、RCPT TO、DATA、.、QUIT。最小でも7往復。サーバーレス環境では、これがコールドスタートの待ち時間に重なる。
HTTP APIは1往復だ。POSTして、202を受け取る。完了。
環境依存のブロック
AWS Lambda(VPC内)、Netlify Functions、Render、Railway、一部のDigitalOcean droplet——私が遭遇しただけでも5つのプラットフォームで、明示的または暗黙的にポート587がブロックされていた。インフラチームに「SMTP開けて」と交渉するより、HTTPSを使う方が圧倒的に早い。
エラーの可視性
SMTPのエラーレスポンスはテキストベースだ。4xxや5xxのコードはあるが、理由はサーバーごとに異なる。KaiMailの場合、mailbox_not_registered と domain_not_owned の区別が、SMTPでは同じ5xxの中に紛れている。
HTTP APIはJSONで構造化されたエラーコードを返す。error.code で分岐できる。
HTTP APIの設計:Pythonらしくする
KaiMailのHTTP APIは単純だ。Basic認証、JSONペイロード、202 Accepted。しかし「動くコード」を「運用できるコード」にするには、いくつかのレイヤーが必要になる。
レイヤー1:認証情報の分離
SMTPパスワードをハードコードするのは論外。環境変数に入れても、設定モジュールに分離するのが最低限だ。
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class KaiMailConfig:
username: str
password: str
base_url: str = "https://kaimail.net/api/v1"
@classmethod
def from_env(cls) -> "KaiMailConfig":
return cls(
username=os.environ["KAIMAIL_USER"],
password=os.environ["KAIMAIL_PASS"],
)
frozen=True にする理由は、設定オブジェクトがイミュータブルであることで、テスト時のモックが安全になるためだ。
レイヤー2:エラーの構造化
APIのエラーレスポンスは以下の構造になっている:
{
"request_id": "abc123...",
"error": {
"code": "mailbox_not_registered",
"message": "The sender mailbox is not registered."
}
}
これをPythonの例外にマップする:
class KaiMailError(Exception):
def __init__(self, code: str, message: str, request_id: str):
self.code = code
self.message = message
self.request_id = request_id
super().__init__(f"[{code}] {message} (request_id: {request_id})")
class KaiMailClient:
def __init__(self, config: KaiMailConfig):
self.config = config
self.session = requests.Session()
self.session.auth = (config.username, config.password)
self.session.headers.update({
"Accept": "application/json",
"Content-Type": "application/json",
})
def _post(self, path: str, payload: dict) -> dict:
resp = self.session.post(
f"{self.config.base_url}{path}",
json=payload,
timeout=30,
)
data = resp.json()
if resp.status_code != 202:
raise KaiMailError(
data["error"]["code"],
data["error"]["message"],
data["request_id"],
)
return data
requests.Session() を使うのは、HTTP Basic認証のヘッダー計算とTCPコネクションの再利用を自動化するためだ。毎回 requests.post() を呼ぶより効率が良い。
レイヤー3:送信メソッドの抽象化
APIには /email/send と /email/send-raw の2エンドポイントがある。呼び分けを隠蔽する:
from email.message import EmailMessage
import base64
class KaiMailClient:
# ... __init__ と _post ...
def send_simple(
self,
from_addr: str,
to_addrs: list[str],
subject: str,
text: str | None = None,
html: str | None = None,
) -> str:
if not text and not html:
raise ValueError("text または html のどちらかを指定する")
payload = {"from": from_addr, "to": to_addrs, "subject": subject}
if text:
payload["text"] = text
if html:
payload["html"] = html
data = self._post("/email/send", payload)
return data["data"]["tracking_id"]
def send_raw(
self,
message: EmailMessage,
envelope_to: list[str],
envelope_from: str | None = None,
) -> str:
raw_b64 = base64.b64encode(message.as_bytes()).decode("ascii")
payload = {"raw_message": raw_b64, "to": envelope_to}
if envelope_from:
payload["envelope_from"] = envelope_from
data = self._post("/email/send-raw", payload)
return data["data"]["tracking_id"]
send_simple は本文をPython側で構築せず、API側に委ねる。send_raw は添付ファイルやカスタムヘッダーが必要な場合に使う。
レイヤー4:ステータスポーリングの制御
tracking_id を取得した後、ステータスを確認する必要がある。ただし、無限ポーリングは避ける:
import time
from typing import Literal
class KaiMailClient:
# ... 省略 ...
def get_status(self, tracking_id: str) -> dict:
resp = self.session.get(
f"{self.config.base_url}/email/{tracking_id}",
timeout=30,
)
resp.raise_for_status()
return resp.json()
def poll_until(
self,
tracking_id: str,
desired: Literal["sent", "failed"] = "sent",
max_attempts: int = 10,
delay: float = 2.0,
) -> dict:
for attempt in range(max_attempts):
data = self.get_status(tracking_id)
status = data["data"]["status"]
if status == desired:
return data
if status == "failed":
raise KaiMailError(
"delivery_failed",
f"Message failed after retries: {data['data'].get('error')}",
data["request_id"],
)
time.sleep(delay)
raise TimeoutError(
f"Status did not reach {desired} after {max_attempts} attempts"
)
poll_until はテストで delay を0.01にすれば素早く検証できるように設計している。
レイヤー5:クォータの事前確認
バッチ送信の前に残りクォータを確認する。これはエラーを未然に防ぐ:
class KaiMailClient:
def check_quota(self) -> dict:
resp = self.session.get(
f"{self.config.base_url}/usage",
timeout=30,
)
resp.raise_for_status()
return resp.json()["data"]
def can_send(self, recipient_count: int) -> bool:
usage = self.check_quota()
remaining = usage.get("remaining", 0)
return remaining >= recipient_count
実際のユースケースと実装
ユースケース1:ユーザー登録の確認メール
シンプルなHTMLメールを送信する:
config = KaiMailConfig.from_env()
client = KaiMailClient(config)
tracking_id = client.send_simple(
from_addr="KaiMail <hello@example.com>",
to_addrs=["user@example.net"],
subject="メールアドレスの確認",
html="<p>以下のリンクをクリックして確認を完了してください。</p>",
text="以下のリンクをクリックして確認を完了してください。",
)
result = client.poll_until(tracking_id)
print(f"Final status: {result['data']['status']}")
ユースケース2:請求書のPDF添付
send_raw を使って添付ファイル付きメールを送信する:
from email.message import EmailMessage
msg = EmailMessage()
msg["From"] = "Billing <billing@example.com>"
msg["To"] = "customer@example.net"
msg["Subject"] = "請求書のお知らせ"
msg.set_content("請求書を添付いたします。")
with open("invoice.pdf", "rb") as f:
msg.add_attachment(
f.read(),
maintype="application",
subtype="pdf",
filename="invoice.pdf",
)
tracking_id = client.send_raw(
message=msg,
envelope_to=["customer@example.net"],
envelope_from="billing@example.com",
)
EmailMessage はPython 3.6から標準ライブラリに入っており、MIMEの手動構築が不要になった。base64エンコードはライブラリがやってくれる。
ユースケース3:バッチ送信の前にクォータを確認
recipients = ["user1@example.com", "user2@example.com", "user3@example.com"]
if not client.can_send(len(recipients)):
quota = client.check_quota()
raise RuntimeError(
f"Quota insufficient: {quota['remaining']} remaining, "
f"{len(recipients)} needed"
)
tracking_id = client.send_simple(
from_addr="Newsletter <news@example.com>",
to_addrs=recipients,
subject="今月の更新情報",
text="今月の更新情報をお届けします。",
)
テスト戦略
HTTP APIの利点の一つは、テストが書きやすいことだ。SMTPの場合、本番サーバーに接続しないと実際の挙動がわからない。HTTP APIは requests.Session を requests_mock や responses でモックできる。
import responses
import pytest
@responses.activate
def test_send_simple_success():
config = KaiMailConfig("test@example.com", "password123")
client = KaiMailClient(config)
responses.post(
"https://kaimail.net/api/v1/email/send",
json={
"request_id": "req-123",
"data": {
"tracking_id": "track-abc",
"status": "queued",
"recipients": ["to@example.net"],
},
},
status=202,
)
tracking_id = client.send_simple(
from_addr="from@example.com",
to_addrs=["to@example.net"],
subject="Test",
text="Hello",
)
assert tracking_id == "track-abc"
エラーケースも同様にモックできる:
@responses.activate
def test_invalid_credentials():
config = KaiMailConfig("test@example.com", "wrongpass")
client = KaiMailClient(config)
responses.post(
"https://kaimail.net/api/v1/email/send",
json={
"request_id": "req-456",
"error": {
"code": "invalid_credentials",
"message": "Invalid credentials",
},
},
status=401,
)
with pytest.raises(KaiMailError) as exc_info:
client.send_simple(
from_addr="from@example.com",
to_addrs=["to@example.net"],
subject="Test",
text="Hello",
)
assert exc_info.value.code == "invalid_credentials"
よくある落とし穴
実際の開発と運用で遭遇したものを挙げる。
1. SMTPパスワードとダッシュボードパスワードの混同
API認証にはSMTPパスワードが必要だ。ダッシュボードにログインするパスワードではない。これらは分離されているため、API認証情報をローテーションしてもWeb UIに影響しない。
2. メールボックスの未登録
ドメイン example.com を追加しても、メールボックス hello@example.com を作成していないと mailbox_not_registered になる。ドメイン追加とメールボックス作成は別の操作だ。
3. Base64のパディング忘れ
send-raw で base64.b64encode() を使えばパディングは自動的に付く。しかし、外部ツールでエンコードした場合、末尾の = が欠落することがある。標準のPython関数を使えば問題ない。
4. ステータスポーリングの過剰実装
ポーリングは必要だが、過剰に実装しない。メッセージが sent になっても、それはKaiMailのサーバーが受け入れたことを意味する。相手の受信箱に届いたわけではない。バウンスは別の仕組みで監視する。
まとめ
Pythonでのメール送信をHTTP APIに移行した結果、以下が改善された:
- 接続の単純化: SMTPハンドシェイクの7往復が、HTTPSの1往復になった
- 環境依存の排除: ポート587のブロックを気にする必要がなくなった
-
エラーの構造化: テキストパースではなく
error.codeで分岐できるようになった -
テスト容易性:
requestsのモックで完全にカバーできるようになった
移行の判断基準は単純だ。SMTPライブラリがすでに統合済みで、ポート587が通る環境ならSMTPでよい。新規実装、サーバーレス環境、あるいはJSONの方がMIMEより扱いやすいと感じているなら、HTTP APIを検討する価値がある。
KaiMailのHTTP APIは https://kaimail.net/api/v1 にある。認証情報はSMTPと同一で、追加の設定は不要だ。すでに有料プランでSMTP送信を使っているなら、APIエンドポイントはすでに有効になっている。
完全なリファレンスは HTTP Sending API ドキュメント を参照。
この記事はKaiMailのHTTP APIを使った実践的な開発記録である。KaiMailはKafkai Gikenが開発しているメール転送・送信サービスである。
