API・自動化

Python:使い捨て受信箱APIでメールを受信、キー不要

Python60行、依存パッケージ1つ、APIキー不要。自分で作るアドレス、期限付きの待機、dictとして返ってくるメッセージ。ここでは、requestsによるモジュール、asyncio向けのhttpx版、混雑したメールボックスのページング、添付ファイルの保存、pytestのフィクスチャ、そして無人稼働させた最初の日に表面化する6つの間違いを紹介します。

  • 中級
  • 読了23分
ゆるく巻かれた灰色のチューブの開いた先から、青い封筒が小さな灰色のトレーへ出てくる

PythonからみたAPI

サーバー側にインストールするものはなく、認証すべき相手もありません。公開ドメイン上のメールボックスは、そのアドレスを知っている人なら誰でも、ごく普通のHTTPS経由でJSONとして読み取れます。全体のインターフェースは、3つの呼び出しだけです。

GET /api/v1/mailbox?address=…
あるアドレスで待っているものすべてを、新しい順の要約リストとして返します。空のメールボックスはcount: 0を伴う200であり、404になることは決してありません。limitは1回のレスポンスの上限を決め(1〜200、デフォルト50)、beforeでそれ以降のページに進みます。
GET /api/v1/message/{id}?mailbox=…
1通のメッセージの全体です。送信者、受信者、件名、日付、プレーンテキストの部分、HTMLの部分(なければnull)、そしてそれぞれにURLが用意された添付ファイルの一覧が含まれます。
DELETE /api/v1/message/{id}?mailbox=…
5日後を待たずに、今すぐ削除します。冪等であり、2回削除しても200が返ります。

一覧はこのようになります。aliasは、別のドメイン上にある第2のアドレスで、同じメールボックスへ配信はしますが、そこから読み取ることはできません — 受信箱を開かれたくないサイトに渡すためのものです。

GET /api/v1/mailbox — レスポンス
{
  "address": "py-3f9a1c2e@grabmail.io",
  "alias": "k7m2p9x4q1wz@example.net",
  "count": 1,
  "next": null,
  "messages": [
    { "id": "01JR8W2K4Q", "from": "noreply@example.com", "subject": "Your verification code",
      "date": "2026-09-01T18:31:07Z", "seen": false, "attachments": 0, "expires_at": "2026-09-06T18:31:07Z" }
  ]
}

モジュール

1つのファイル、1つのクラス、唯一の依存パッケージはrequestsです。意図的に、面白みのない作りにしてあります。セッション、期限付きのループ、そして唯一正しいリトライ — 429の分だけスリープすることだけです。

grabmail.py
"""grabmail.py — a disposable inbox from Python. Three endpoints, no key, no account."""
from __future__ import annotations

import secrets
import time
from pathlib import Path

import requests

API = "https://grabmail.io/api/v1"
DOMAIN = "grabmail.io"


def fresh_address(prefix: str = "py") -> str:
    """A mailbox nothing else is using. Nothing has to be created first."""
    return f"{prefix}-{secrets.token_hex(4)}@{DOMAIN}"


class Inbox:
    def __init__(self, address: str | None = None, session: requests.Session | None = None):
        self.address = address or fresh_address()
        self.http = session or requests.Session()

    def _get(self, url: str, **params) -> requests.Response:
        """One GET, with the only retry that is ever right: waiting out a 429."""
        while True:
            r = self.http.get(url, params=params, timeout=15)
            if r.status_code == 429:
                time.sleep(float(r.headers.get("Retry-After", 1)))
                continue
            r.raise_for_status()
            return r

    def list(self, limit: int = 50, before: str | None = None) -> dict:
        params = {"address": self.address, "limit": limit}
        if before:
            params["before"] = before
        return self._get(f"{API}/mailbox", **params).json()

    def wait_for(self, timeout: float = 60, subject_contains: str | None = None,
                 from_contains: str | None = None) -> dict:
        """Block until a matching message arrives, then return it in full."""
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            for m in self.list()["messages"]:
                if subject_contains and subject_contains.lower() not in m["subject"].lower():
                    continue
                if from_contains and from_contains.lower() not in m["from"].lower():
                    continue
                return self.read(m["id"])
            time.sleep(1)                       # one read a second, never throttled
        raise TimeoutError(f"no message for {self.address} within {timeout}s")

    def read(self, message_id: str) -> dict:
        return self._get(f"{API}/message/{message_id}", mailbox=self.address).json()

    def delete(self, message_id: str) -> None:
        """Optional and idempotent: everything expires on its own."""
        self.http.delete(f"{API}/message/{message_id}", params={"mailbox": self.address}, timeout=15)

    def download(self, attachment: dict, into: Path) -> Path:
        """Save one entry of a message's `attachments` list. The URL carries its own ?mailbox=."""
        into.mkdir(parents=True, exist_ok=True)
        target = into / attachment["filename"]
        with self.http.get("https://grabmail.io" + attachment["url"], stream=True, timeout=60) as r:
            r.raise_for_status()
            with target.open("wb") as f:
                for chunk in r.iter_content(65536):
                    f.write(chunk)
        return target

使い方はたった4行です。アドレスを表示し、アドレスが求められる場所ならどこでもそれを使い、そして待ちます。

最初の実行
from grabmail import Inbox

inbox = Inbox()
print("sign up with:", inbox.address)

message = inbox.wait_for(subject_contains="code")
print(message["subject"])
print(message["text"])          # the plain-text part; message["html"] is the HTML part or None

同じものをhttpxで、asyncio向けに

スクリプトが複数の受信箱を同時に監視しなければならない場合 — バッチジョブでの5件のサインアップや、複数のアカウントを扱うエージェントなど — 同期的なループでは、待機が直列化されてしまいます。httpx.AsyncClientを使えば同じクラスをawait可能にでき、asyncio.gatherで、それらすべてをまとめて待てます。

grabmail_async.py
"""grabmail_async.py — the same inbox for asyncio, on httpx."""
from __future__ import annotations

import asyncio
import time

import httpx

from grabmail import API, fresh_address


class AsyncInbox:
    def __init__(self, address: str | None = None, client: httpx.AsyncClient | None = None):
        self.address = address or fresh_address()
        self.http = client or httpx.AsyncClient(timeout=15)

    async def _get(self, url: str, **params) -> httpx.Response:
        while True:
            r = await self.http.get(url, params=params)
            if r.status_code == 429:
                await asyncio.sleep(float(r.headers.get("Retry-After", 1)))
                continue
            r.raise_for_status()
            return r

    async def list(self, limit: int = 50, before: str | None = None) -> dict:
        params = {"address": self.address, "limit": limit, **({"before": before} if before else {})}
        return (await self._get(f"{API}/mailbox", **params)).json()

    async def wait_for(self, timeout: float = 60, subject_contains: str | None = None) -> dict:
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            for m in (await self.list())["messages"]:
                if not subject_contains or subject_contains.lower() in m["subject"].lower():
                    return await self.read(m["id"])
            await asyncio.sleep(1)
        raise TimeoutError(f"no message for {self.address} within {timeout}s")

    async def read(self, message_id: str) -> dict:
        return (await self._get(f"{API}/message/{message_id}", mailbox=self.address)).json()


async def main() -> None:
    inboxes = [AsyncInbox() for _ in range(5)]          # five sign-ups, waited on together
    for i in inboxes:
        print("sign up with:", i.address)
    messages = await asyncio.gather(*(i.wait_for(subject_contains="code") for i in inboxes))
    for m in messages:
        print(m["to"], "->", m["subject"])

asyncio.run(main())

5つの受信箱をそれぞれ1秒に1回ポーリングすると、1秒あたり5リクエストになり、クライアントごとの上限である1分間1200リクエストには余裕で収まります。一度に20の受信箱を超えると上限に達し、429の分岐がスリープし始めますが、それは失敗ではなく正しい挙動です。

混雑したメールボックス: beforeによるページング

一覧は最大200件の要約を返します。それを超えるメールボックス — たとえば、独自ドメイン上のキャッチオールアドレスが1日分のバウンスメールを集めている場合など — は、ページ単位で読みます。あるレスポンスのnextの値を、次のリクエストのbeforeパラメーターとして渡し、nextnullになったら止めてください。

ページ数にかかわらず、すべてのメッセージ
from collections.abc import Iterator


def all_messages(inbox: Inbox) -> Iterator[dict]:
    """Every summary in the mailbox, newest first, however many pages it takes."""
    before = None
    while True:
        page = inbox.list(limit=200, before=before)
        yield from page["messages"]
        before = page["next"]
        if not before:
            return


for m in all_messages(inbox):
    print(m["date"], m["from"], m["subject"], "expires", m["expires_at"])

カーソルは、すでに手元にある中で最も古いメッセージのidです。そのため、新しいメールが上に届いている最中でも、ページは安定しています。スクリプトから受信箱を自動化するでは、カーソルについて、スケジューリングや保存期間とあわせてより詳しく扱っています。

添付ファイルをディスクに保存する

すべてのメッセージには、ファイル名、申告された種類、バイト単位のサイズ、そしてURLとともに添付ファイルの一覧が含まれます。URLにはすでに?mailbox=パラメーターが付いているため、そのまま取得できます。レスポンスは、送信元がファイルに何とラベル付けしていようと、常にContent-Disposition: attachmentヘッダー付きのapplication/octet-streamです — 本当の種類は、JSON内のmimeフィールドにあり、そこでは指示ではなくデータとして扱われています。

メッセージのすべての添付ファイルを保存する
from pathlib import Path

message = inbox.wait_for(subject_contains="invoice")
for a in message["attachments"]:
    print(a["filename"], a["mime"], a["size"], "bytes")
    path = inbox.download(a, into=Path("downloads") / message["id"])
    print("saved to", path)

pytestのフィクスチャとして

新しいInboxを返すフィクスチャは、すべてのテストに専用のメールボックスを与えます。これは、メールのテストにおいて最も重要な性質です。どの実行も、それ以前の実行のメッセージを読むことは決してありません。抽出パターンは、「6桁の数字」ではなく、テンプレートの文言に固定します。その理由は自動テストにおけるOTPコードで詳しく説明しています。

conftest.py
# conftest.py
import pytest

from grabmail import Inbox


@pytest.fixture
def inbox() -> Inbox:
    """A brand-new mailbox for this test, and this test only."""
    return Inbox()
test_signup.py
# test_signup.py
import re

CODE = re.compile(r"code is\D{0,12}(\d{6})", re.I)      # anchored on YOUR template's wording


def test_signup_confirmation(client, inbox):
    client.post("/signup", data={"email": inbox.address, "password": "hunter2hunter2"})

    message = inbox.wait_for(subject_contains="confirm")
    code = CODE.search(f"{message.get('text') or ''} {message.get('html') or ''}").group(1)

    response = client.post("/confirm", data={"email": inbox.address, "code": code})
    assert response.status_code == 200

clientフィクスチャは、使っているフレームワークが提供するものであれば何でも構いません — Flaskのテストクライアント、Djangoのもの、動作中のサーバーに向けたhttpx.Clientなど。メールボックス側は気にしません。スイートをCIランナー上で実行する場合は、アウトバウンド通信のルールとタイムアウトが追加で必要になります。どちらもGitHub Actionsガイドで扱っています。

無人で初めて動かしたときに表面化する間違い

どれもノートPC上では問題になりません。すべて、ある火曜の夜、cronジョブの中で壊れます。

症状原因対処法
送信元が壊れていても、毎回成功してしまう毎回同じアドレスを使っていて、最初のポーリングが前回実行時のメッセージを見つけてしまう。実行ごとにfresh_address()を使う。これが最も重要です。
ログに429が出て、その後クラッシュするスリープのないループ、または2つのスクリプトが1つのアドレスをポーリングしている。アドレスごとに1秒に1回の読み取り。Retry-Afterの分だけスリープする。スクリプトごとに1アドレス。
遅い日にはタイムアウトし、リトライすると通る期限ではなく試行回数を使っている、または期限が送信元のキューより短い。time.monotonic()による期限を使い、トランザクションメールには60秒を設定する。
/mailboxから404そのドメインはここではホストされていない — タイプミス、または独自ドメインのMXレコードが未設定。アドレスを確認する。独自ドメインの場合は、MXがsmtp.grabmail.ioを指しているか確認する。
間違ったメッセージを読んでしまうフローが2通送っているのに、最新のメッセージを採用してしまった。subject_containsまたはfrom_containsでフィルタする。
1週間は動くが、その後メッセージが404になる5日を超えて保存されたメッセージID。5日を超えて残るものはない。キャッシュせず、取得し直す。

完了と呼ぶ前に

  • 実行ごと、テストごと、エージェントごとに新しいアドレスを使う — 定数は使わない。
  • モノトニックな期限を使う。1秒に1回の読み取り。429は例外にせず、スリープで処理する。
  • フローが複数のメッセージを送る場合は、件名か送信者でフィルタする。
  • textの部分を優先してパースし、パターンは自分の文言に固定する。
  • 添付ファイルは信頼できないものとして扱い、メッセージIDの下に保存する。
  • メッセージIDを日をまたいでキャッシュしない。ここでは5日を超えて残るものはない。

クライアントとしては、これがすべてです。Node、Deno、Bun向けにTypeScriptで書いた同じモジュールはNode.jsガイドにあり、すべてのステータスコードを含むリクエストとレスポンスの形式はAPIリファレンスにあります。また、クライアントを書くよりも生成したいという方にはOpenAPI 3.1のドキュメントも用意されています。

質問

APIキーは必要ですか?

いいえ。公開ドメインはキーもアカウントもヘッダーも不要です。使い捨てメールのブロックリストに載らない有料ドメインプールだけがAuthorization: Bearerヘッダーを使いますが、それ以外のコードはまったく同じです。

代わりにOpenAPIドキュメントからクライアントを生成できますか?

できます — /openapi.jsonはOpenAPI 3.1形式であり、どのジェネレーターでも3つの呼び出しを生成できます。それでも、一覧取得の呼び出しの周りには、このガイドにある期限付きループが必要です。ジェネレーターはそれを書いてくれないからです。

1つのスクリプトは、一度に何個のメールボックスをポーリングできますか?

20個であれば余裕です。アドレスごとの上限は1秒に1回の読み取りで、クライアントごとの上限は1分間に1200リクエストであり、これは1秒に1回ポーリングするアドレス20個分にあたります。上のasync版はどちらも守っており、上限を超えても失敗するのではなく429の分だけスリープします。

Pythonから独自ドメインを使えますか?

使えます。コードの変更はDOMAIN定数だけです。smtp.grabmail.ioを指すMXレコード1件で、そのドメイン上のすべてのアドレスが、同じモジュールで読み取れるメールボックスになります — 設定方法はこちらです。アプリケーションが公開の使い捨てドメインを拒否する場合の、正しい答えです。

スクリプトが使っている間、メールボックスはプライベートですか?

いいえ。公開ドメインでも独自ドメインでも、アドレスを知っている人なら誰でも読めます。数秒間だけ1つの確認コードを保持するランダムなアドレスであれば問題ありませんが、実際の顧客宛てメールをそちらに向けるスクリプトには向いていません。

スクリプトではなくAIエージェント向けのものはありますか?

同じオリジンにMCPサーバーがあり、キーは不要で、そのwait_for_messageツールはメールが届くまで呼び出しを保持してくれます — ポーリング1回ごとにトークンを消費するエージェントにとって、必要な形です。AIエージェントが読める受信箱で扱っています。

新しいうちに試してみてください

アドレスの取得はワンクリックで、アカウントもカードも不要です。このガイドの内容はすべて、そのアドレスですぐに試せます。

おかえりなさい

受信箱とドメインを、ひとつの場所に。