API & Automatisierung

Python: E-Mails empfangen mit einer Wegwerf-Postfach-API

Sechzig Zeilen Python, eine Abhängigkeit und kein API-Schlüssel: eine Adresse, die Sie erfinden, ein Warten mit einer Deadline, die Nachricht als dict. Hier ist das Modul auf requests, dasselbe auf httpx für asyncio, das Durchblättern eines ausgelasteten Postfachs, das Speichern eines Anhangs, eine pytest-Fixture, und die sechs Fehler, die beim ersten unbeaufsichtigten Lauf auftauchen.

  • Fortgeschritten
  • 23 Min. Lesezeit
Ein locker aufgerollter grauer Schlauch, aus dessen offenem Ende ein blauer Umschlag auf ein kleines graues Tablett gleitet

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 200 mit count: 0 — nie ein 404. limit begrenzt eine Antwort (1–200, Standard 50), und before blä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.

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

Es zu verwenden sind vier Zeilen. Geben Sie die Adresse aus, verwenden Sie sie überall dort, wo eine Adresse verlangt wird, und warten Sie:

ein erster Lauf
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

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

jede Nachricht, egal wie viele Seiten
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.

jeden Anhang einer Nachricht speichern
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
# 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

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

SymptomUrsacheLösung
Besteht jedes Mal, selbst wenn der Absender kaputt istJeden 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 AbsturzEine 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 WiederholungEine 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 /mailboxDie 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 NachrichtHat die neueste Nachricht genommen, obwohl der Ablauf zwei verschickt hat.Mit subject_contains oder from_contains filtern.
Funktioniert eine Woche, dann 404 bei einer NachrichtEine 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; 429 abgewartet, 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.

Probieren Sie es aus, solange es frisch ist

Eine Adresse braucht einen Klick, kein Konto und keine Karte. Alles in dieser Anleitung funktioniert damit sofort.

Willkommen zurück

Ihre Postfächer und Ihre Domains an einem Ort.