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のアドレスで、同じメールボックスへ配信はしますが、そこから読み取ることはできません — 受信箱を開かれたくないサイトに渡すためのものです。
{
"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 — 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 — 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パラメーターとして渡し、nextがnullになったら止めてください。
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
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
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 == 200clientフィクスチャは、使っているフレームワークが提供するものであれば何でも構いません — 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エージェントが読める受信箱で扱っています。


