Die API, wie Python sie sieht
Auf der Serverseite gibt es nichts zu installieren und nichts, wogegen man sich authentifizieren müsste: Ein Postfach auf einer öffentlichen Domain ist für jeden lesbar, der seine Adresse kennt, über einfaches HTTPS, als JSON. Die gesamte Oberfläche sind drei Aufrufe:
GET /api/v1/mailbox?address=…- Alles, was an einer Adresse wartet, neueste zuerst, als Liste von Zusammenfassungen. Ein leeres Postfach ist
200mitcount: 0— nie ein 404.limitbegrenzt eine Antwort (1–200, Standard 50), undbeforeblättert darüber hinaus. GET /api/v1/message/{id}?mailbox=…- Eine Nachricht vollständig: Absender, Empfänger, Betreff, Datum, der Klartext-Teil, der HTML-Teil (oder
null), und eine Liste von Anhängen mit jeweils einer fertigen URL. DELETE /api/v1/message/{id}?mailbox=…- Entfernt sie jetzt statt in 5 Tagen. Idempotent: zweimal löschen beantwortet immer noch mit
200.
Eine Auflistung sieht so aus. Der alias ist eine zweite Adresse auf einer separaten Domain, die in dasselbe Postfach zustellt und sich nicht zum Lesen daraus verwenden lässt — die, die man einer Website gibt, wenn sie das Postfach lieber nicht öffnen können soll.
{
"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" }
]
}Das Modul
Eine Datei, eine Klasse, und requests als einzige Abhängigkeit. Es ist absichtlich unspektakulär: eine Session, eine Deadline-Schleife, und die eine Wiederholung, die je richtig ist — einen 429 abwarten.
"""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 targetEs zu verwenden sind vier Zeilen. Geben Sie die Adresse aus, verwenden Sie sie überall dort, wo eine Adresse verlangt wird, und warten Sie:
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 NoneDasselbe auf httpx, für asyncio
Wenn ein Skript mehrere Postfächer gleichzeitig beobachten muss — fünf Anmeldungen in einem Batch-Job, ein Agent, der mit mehreren Konten jongliert —, serialisiert die synchrone Schleife die Wartezeiten. httpx.AsyncClient macht dieselbe Klasse awaitable, und asyncio.gather wartet auf alle zusammen:
"""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())Fünf Postfächer, jedes einmal pro Sekunde gepollt, sind fünf Anfragen pro Sekunde, deutlich innerhalb der Obergrenze pro Client von 1200 pro Minute. Ab mehr als zwanzig gleichzeitigen Postfächern wären Sie an der Obergrenze, und der 429-Zweig würde anfangen zu warten — das ist das korrekte Verhalten, kein Fehlschlag.
Ein ausgelastetes Postfach: Paginierung mit before
Eine Auflistung liefert höchstens 200 Zusammenfassungen zurück. Ein Postfach, das mehr davon empfängt — etwa eine Catch-all-Adresse auf Ihrer eigenen Domain, die einen Tag lang Bounces sammelt —, wird seitenweise gelesen: Übergeben Sie den next-Wert einer Antwort als before-Parameter der folgenden Anfrage, und hören Sie auf, wenn next gleich null ist.
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"])Der Cursor ist die ID der ältesten Nachricht, die Sie bereits haben, sodass eine Seite stabil bleibt, selbst während oben neue Mail ankommt. Ein Postfach aus einem Skript heraus automatisieren geht ausführlicher auf den Cursor ein, zusammen mit Zeitplanung und Aufbewahrung.
Anhänge auf die Festplatte
Jede Nachricht listet ihre Anhänge mit einem Dateinamen, einem angegebenen Typ, einer Größe in Bytes und einer URL auf. Die URL trägt den Parameter ?mailbox= bereits, sie wird also so abgerufen, wie sie ist. Die Antwort ist immer application/octet-stream mit einem Content-Disposition: attachment-Header, egal was der Absender als Dateityp angegeben hat — der echte Typ steht im Feld mime im JSON, wo er Daten sind und keine Anweisung.
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)Als pytest-Fixture
Eine Fixture, die ein frisches Inbox zurückgibt, gibt jedem Test sein eigenes Postfach, und das ist die mit Abstand wichtigste Eigenschaft eines Mail-Tests: Kein Lauf kann je die Nachricht eines vorherigen Laufs lesen. Das Extraktionsmuster ist im Wortlaut der Vorlage verankert statt in „sechs Ziffern“, aus den Gründen, die OTP-Codes in automatisierten Tests darlegt.
# 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 == 200Die Fixture client ist, was auch immer Ihr Framework bereitstellt — Flasks Test-Client, Djangos, ein auf einen laufenden Server gerichteter httpx.Client. Der Postfach-Seite ist das gleichgültig. Die Suite auf einem CI-Runner laufen zu lassen, fügt eine Regel für ausgehenden Datenverkehr und ein Timeout hinzu, beides behandelt in der GitHub-Actions-Anleitung.
Fehler, die beim ersten unbeaufsichtigten Lauf auftauchen
Keiner davon bricht auf einem Laptop. Alle brechen an einem Dienstagabend in einem Cron-Job.
| Symptom | Ursache | Lösung |
|---|---|---|
| Besteht jedes Mal, selbst wenn der Absender kaputt ist | Jeden Lauf dieselbe Adresse; der erste Poll findet die Nachricht des letzten Laufs. | fresh_address() pro Lauf. Das ist die Regel, auf die es ankommt. |
429 im Log, dann ein Absturz | Eine Schleife ohne Sleep, oder zwei Skripte, die dieselbe Adresse pollen. | Ein Lesevorgang pro Sekunde und Adresse; Retry-After abwarten; eine Adresse pro Skript. |
| Läuft an einem langsamen Tag in ein Timeout, besteht bei Wiederholung | Eine Anzahl von Versuchen statt einer Deadline, oder eine Deadline, die kürzer ist als die Warteschlange des Absenders. | time.monotonic()-Deadline, sechzig Sekunden für eine transaktionale Mail. |
404 von /mailbox | Die Domain wird hier nicht gehostet — ein Tippfehler, oder die eigene Domain mit fehlendem MX. | Die Adresse prüfen; bei der eigenen Domain prüfen, ob der MX auf smtp.grabmail.io zeigt. |
| Liest die falsche Nachricht | Hat die neueste Nachricht genommen, obwohl der Ablauf zwei verschickt hat. | Mit subject_contains oder from_contains filtern. |
Funktioniert eine Woche, dann 404 bei einer Nachricht | Eine gespeicherte Nachrichten-ID, älter als 5 Tage. | Nichts übersteht 5 Tage. Neu abrufen statt zwischenspeichern. |
Bevor Sie es für fertig erklären
- Eine frische Adresse pro Lauf, pro Test oder pro Agent — nie eine Konstante.
- Eine monotone Deadline; ein Lesevorgang pro Sekunde;
429abgewartet, nie als Fehler ausgelöst. - Ein Filter auf Betreff oder Absender, wenn ein Ablauf mehr als eine Nachricht verschickt.
- Der Text-Teil zuerst geparst, mit einem Muster, das im eigenen Wortlaut verankert ist.
- Anhänge als nicht vertrauenswürdige Dateien behandelt, gespeichert unter der Nachrichten-ID.
- Keine Nachrichten-ID über Tage hinweg zwischengespeichert; nichts hier überlebt 5 Tage.
Das ist der ganze Client. Dasselbe Modul in TypeScript, für Node, Deno und Bun, steht in der Node.js-Anleitung; die Formen von Anfrage und Antwort, mit jedem Statuscode, stehen in der API-Referenz, und es gibt ein OpenAPI-3.1-Dokument für alle, die den Client lieber generieren als schreiben.
Fragen
Brauche ich einen API-Schlüssel?
Nein. Die öffentlichen Domains verlangen weder Schlüssel noch Konto noch Header. Nur der kostenpflichtige Pool von Domains, der von den Wegwerf-Mail-Sperrlisten ferngehalten wird, verwendet einen Authorization: Bearer-Header, und der Code oben ist ansonsten identisch dafür.
Kann ich den Client stattdessen aus dem OpenAPI-Dokument generieren?
Ja — /openapi.json ist OpenAPI 3.1, und jeder Generator erzeugt die drei Aufrufe. Die Deadline-Schleife aus dieser Anleitung werden Sie trotzdem um den Listing-Aufruf herum wollen, denn die schreibt Ihnen kein Generator.
Wie viele Postfächer kann ein Skript gleichzeitig pollen?
Zwanzig, komfortabel: Das Limit pro Adresse ist ein Lesevorgang pro Sekunde, und die Obergrenze pro Client liegt bei 1200 Anfragen pro Minute, das sind zwanzig Adressen, einmal pro Sekunde gepollt. Die async-Version oben respektiert beides, und jenseits der Obergrenze wartet sie den 429 ab, statt zu scheitern.
Kann ich meine eigene Domain von Python aus verwenden?
Ja, ohne Änderung am Code außer der Konstante DOMAIN. Ein MX-Eintrag, der auf smtp.grabmail.io zeigt, und jede Adresse auf der Domain wird zu einem Postfach, das dasselbe Modul liest — die Einrichtung steht hier. Das ist die richtige Antwort, wenn Ihre Anwendung die öffentlichen Wegwerf-Domains ablehnt.
Ist das Postfach privat, während mein Skript es verwendet?
Nein. Wer die Adresse kennt, kann es lesen, auf einer öffentlichen Domain genauso wie auf Ihrer eigenen. Eine zufällige Adresse, die für ein paar Sekunden einen Bestätigungscode enthält, ist unbedenklich; ein Skript, das echte Kunden-Mail dorthin lenkt, ist es nicht.
Gibt es etwas für einen KI-Agenten statt für ein Skript?
Es gibt einen MCP-Server am selben Origin, ohne Schlüssel, dessen Tool wait_for_message den Aufruf offen hält, bis die Mail landet — genau das, was ein Agent braucht, denn jeder Poll kostet ihn Tokens. Ein Postfach, das ein KI-Agent lesen kann behandelt das.


