0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pythonのメール送信でSMTPをやめてHTTP APIに移行した話

0
Posted at

独自ドメインのメール送信をPythonで自動化している場合、99%のチュートリアルは smtplib を使う。標準ライブラリだし、メンテナンス不要だし、動く。

しかし、本番で使い続けると、SMTPの「動く」には条件が付くことがわかる。VPC内のLambdaからポート587が通らない。コンテナの起動時にSMTPハンドシェイクで3秒待たされる。添付ファイルのMIMEエンコーディングで文字化けする。

私は去年から、サイドプロジェクトのメール送信をすべてHTTP APIに移行した。理由は、JSONの方がMIMEより扱いやすいというだけではない。運用の観点から、設計が単純になるのだ。

この記事では、SMTPをHTTP APIに置き換える際に実際に考えた設計判断と、最終的に落ち着いたPythonモジュールを紹介する。

pexels-dkomov-34803995.jpg

SMTPの問題はライブラリではない

smtplib は悪くない。問題はプロトコルだ。:grin:

接続のオーバーヘッド

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_registereddomain_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.Sessionrequests_mockresponses でモックできる。

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-rawbase64.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が開発しているメール転送・送信サービスである。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?