The API, as Python sees it
There is nothing to install on the server side and nothing to authenticate against: a mailbox on a public domain is readable by anyone who knows its address, over plain HTTPS, as JSON. The whole surface is three calls:
GET /api/v1/mailbox?address=…- Everything waiting at an address, newest first, as a list of summaries. An empty mailbox is
200withcount: 0— never a 404.limitcaps one response (1–200, default 50) andbeforepages past it. GET /api/v1/message/{id}?mailbox=…- One message in full: sender, recipient, subject, date, the plain-text part, the HTML part (or
null), and a list of attachments with a ready-made URL each. DELETE /api/v1/message/{id}?mailbox=…- Removes it now rather than in 5 days. Idempotent: deleting twice still answers
200.
A listing looks like this. The alias is a second address on a separate domain that delivers into the same mailbox and cannot be used to read it — the one to hand to a site when you would rather it could not open the inbox.
{
"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" }
]
}The module
One file, one class, and requests as the only dependency. It is deliberately boring: a session, a deadline loop, and the single retry that is ever correct — sleeping out a 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 targetUsing it is four lines. Print the address, use it wherever an address is asked for, and wait:
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 NoneThe same on httpx, for asyncio
When a script has to watch several inboxes at once — five sign-ups in a batch job, an agent juggling accounts — the synchronous loop serialises the waits. httpx.AsyncClient makes the same class awaitable, and asyncio.gather waits on all of them together:
"""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())Five inboxes polled once a second each is five requests a second, well inside the per-client ceiling of 1200 a minute. Past twenty inboxes at once you would be at the ceiling, and the 429 branch would start sleeping — which is the correct behaviour, not a failure.
A busy mailbox: paging with before
A listing returns at most 200 summaries. A mailbox that receives more than that — a catch-all address on your own domain collecting a day of bounces, say — is read page by page: pass the next value of one response as the before parameter of the following request, and stop when next is 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"])The cursor is the id of the oldest message you already have, so a page is stable even while new mail arrives at the top. Automating an inbox from a script goes through the cursor in more detail, along with scheduling and retention.
Attachments to disk
Every message lists its attachments with a filename, a declared type, a size in bytes and a URL. The URL already carries the ?mailbox= parameter, so it is fetched as it is. The response is always application/octet-stream with a Content-Disposition: attachment header, whatever the sender labelled the file — the real type is the mime field in the JSON, where it is data rather than an instruction.
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)As a pytest fixture
A fixture that returns a fresh Inbox gives every test its own mailbox, which is the single most important property of a mail test: no run can ever read a previous run’s message. The extraction pattern is anchored on the template’s wording rather than on “six digits”, for the reasons OTP codes in automated tests spells out.
# 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 == 200The client fixture is whatever your framework provides — Flask’s test client, Django’s, an httpx.Client pointed at a running server. The mailbox side does not care. Running the suite on a CI runner adds an egress rule and a timeout, both covered in the GitHub Actions guide.
Mistakes that show up the first time it runs unattended
None of these break on a laptop. All of them break on a Tuesday night in a cron job.
| Symptom | Cause | Fix |
|---|---|---|
| Passes every time, even when the sender is broken | The same address every run; the first poll finds last run’s message. | fresh_address() per run. This is the one that matters. |
429 in the log, then a crash | A loop with no sleep, or two scripts polling one address. | One read a second per address; sleep out Retry-After; one address per script. |
| Times out on a slow day, passes on a retry | A retry count instead of a deadline, or a deadline shorter than the sender’s queue. | time.monotonic() deadline, sixty seconds for a transactional mail. |
404 from /mailbox | The domain is not hosted here — a typo, or your own domain with a missing MX. | Check the address; for your own domain, check the MX points at smtp.grabmail.io. |
| Reads the wrong message | Took the newest message when the flow sent two. | Filter with subject_contains or from_contains. |
Works for a week, then 404 on a message | A stored message id older than 5 days. | Nothing survives 5 days. Re-fetch rather than cache. |
Before you call it done
- A fresh address per run, per test or per agent — never a constant.
- A monotonic deadline; one read a second;
429slept out, never raised. - A filter on subject or sender when a flow sends more than one message.
- The text part parsed first, with a pattern anchored on your own wording.
- Attachments treated as untrusted files, saved under the message id.
- No message id cached across days; nothing here outlives 5 days.
That is the whole client. The same module in TypeScript, for Node, Deno and Bun, is in the Node.js guide; the request and response shapes, with every status code, are in the API reference, and there is an OpenAPI 3.1 document for anyone who would rather generate the client than write it.
Questions
Do I need an API key?
No. The public domains take no key, no account and no header. Only the paid pool of domains kept off the disposable-mail blocklists uses an Authorization: Bearer header, and the code above is otherwise identical for it.
Can I generate the client from the OpenAPI document instead?
Yes — /openapi.json is OpenAPI 3.1 and any generator will produce the three calls. You will still want the deadline loop from this guide around the listing call, because no generator writes one for you.
How many mailboxes can one script poll at once?
Twenty, comfortably: the per-address limit is one read a second and the per-client ceiling is 1200 requests a minute, which is twenty addresses polled once a second. The async version above respects both, and past the ceiling it sleeps out the 429 rather than failing.
Can I use my own domain from Python?
Yes, with no change to the code beyond the DOMAIN constant. One MX record pointing at smtp.grabmail.io and every address on the domain becomes a mailbox the same module reads — the setup is here. It is the right answer when your application refuses the public disposable domains.
Is the mailbox private while my script uses it?
No. Anyone who knows the address can read it, on a public domain and on your own. A random address that holds one confirmation code for a few seconds is fine; a script that points real customer mail at one is not.
Is there something for an AI agent rather than a script?
There is an MCP server at the same origin, with no key, whose wait_for_message tool holds the call open until the mail lands — the shape an agent needs, since every poll costs it tokens. An inbox an AI agent can read covers it.


