API & automation

Python: receive email with a disposable inbox API, no key

Sixty lines of Python, one dependency and no API key: an address you invent, a wait with a deadline, the message as a dict. Here is the module on requests, the same on httpx for asyncio, paging through a busy mailbox, saving an attachment, a pytest fixture, and the six mistakes that show up the first time it runs unattended.

  • Intermediate
  • 23 min read
A loosely coiled grey tube with a blue envelope emerging from its open end onto a small grey tray

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 200 with count: 0 — never a 404. limit caps one response (1–200, default 50) and before pages 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.

GET /api/v1/mailbox — the response
{
  "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
"""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

Using it is four lines. Print the address, use it wherever an address is asked for, and wait:

a first run
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

The 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
"""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.

every message, however many pages
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.

save every attachment of a message
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
# 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

The 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.

SymptomCauseFix
Passes every time, even when the sender is brokenThe 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 crashA 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 retryA 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 /mailboxThe 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 messageTook the newest message when the flow sent two.Filter with subject_contains or from_contains.
Works for a week, then 404 on a messageA 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; 429 slept 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.

Try it while it is fresh

An address takes one click, no account and no card. Everything in this guide works on it straight away.

Welcome back

Your inboxes and your domains, in one place.