L’API, vue par Python
Il n’y a rien à installer côté serveur et rien contre quoi s’authentifier : une boîte sur un domaine public est lisible par quiconque connaît son adresse, en simple HTTPS, sous forme de JSON. Toute la surface tient en trois appels :
GET /api/v1/mailbox?address=…- Tout ce qui attend à une adresse, le plus récent en premier, sous forme de liste de résumés. Une boîte vide, c’est
200aveccount: 0— jamais un 404.limitplafonne une réponse (1 à 200, 50 par défaut), etbeforepermet de paginer au-delà. GET /api/v1/message/{id}?mailbox=…- Un message en entier : expéditeur, destinataire, sujet, date, la partie texte brut, la partie HTML (ou
null), et une liste de pièces jointes avec chacune une URL prête à l’emploi. DELETE /api/v1/message/{id}?mailbox=…- Le supprime maintenant plutôt que dans 5 jours. Idempotent : supprimer deux fois répond quand même
200.
Un listage ressemble à ceci. L’alias est une seconde adresse sur un domaine séparé, qui livre dans la même boîte et ne peut pas servir à la lire — celle à donner à un site quand vous préférez qu’il ne puisse pas ouvrir la boîte.
{
"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" }
]
}Le module
Un seul fichier, une seule classe, et requests comme unique dépendance. C’est volontairement ennuyeux : une session, une boucle avec échéance, et la seule nouvelle tentative qui soit jamais correcte — patienter sur un 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 targetL’utiliser tient en quatre lignes. Affichez l’adresse, utilisez-la partout où une adresse est demandée, et attendez :
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 NoneLe même sur httpx, pour asyncio
Quand un script doit surveiller plusieurs boîtes à la fois — cinq inscriptions dans un job par lots, un agent qui jongle avec des comptes — la boucle synchrone sérialise les attentes. httpx.AsyncClient rend la même classe awaitable, et asyncio.gather attend sur toutes à la fois :
"""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())Cinq boîtes interrogées une fois par seconde chacune, ça fait cinq requêtes par seconde, largement en dessous du plafond par client de 1200 par minute. Au-delà de vingt boîtes à la fois, vous atteindriez ce plafond, et la branche 429 commencerait à patienter — ce qui est le comportement correct, pas un échec.
Une boîte chargée : paginer avec before
Un listage renvoie au maximum 200 résumés. Une boîte qui en reçoit davantage — une adresse catch-all sur votre propre domaine qui collecte une journée de bounces, par exemple — se lit page par page : passez la valeur next d’une réponse comme paramètre before de la requête suivante, et arrêtez-vous quand next vaut 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"])Le curseur est l’id du message le plus ancien que vous avez déjà, donc une page reste stable même si du nouveau courrier arrive en tête. Automatiser une boîte depuis un script détaille davantage le curseur, ainsi que la planification et la rétention.
Pièces jointes sur le disque
Chaque message liste ses pièces jointes avec un nom de fichier, un type déclaré, une taille en octets et une URL. L’URL porte déjà le paramètre ?mailbox=, donc elle se récupère telle quelle. La réponse est toujours application/octet-stream avec un en-tête Content-Disposition: attachment, quel que soit le type que l’expéditeur a indiqué pour le fichier — le vrai type est le champ mime du JSON, où c’est une donnée et non une 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)En tant que fixture pytest
Une fixture qui renvoie un Inbox fraîche donne à chaque test sa propre boîte, ce qui est la propriété la plus importante d’un test de courrier : aucune exécution ne peut jamais lire le message d’une exécution précédente. Le motif d’extraction est ancré sur la formulation du modèle plutôt que sur « six chiffres », pour les raisons qu’expose les codes OTP dans les tests automatisés.
# 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 == 200La fixture client est celle que fournit votre framework — le client de test de Flask, celui de Django, un httpx.Client pointé vers un serveur en cours d’exécution. Le côté boîte mail s’en moque. Exécuter la suite sur un runner de CI ajoute une règle de trafic sortant et un timeout, tous deux couverts dans le guide GitHub Actions.
Les erreurs qui apparaissent la première fois que ça tourne sans surveillance
Aucune de ces erreurs ne casse sur un ordinateur portable. Toutes cassent un mardi soir dans une tâche cron.
| Symptôme | Cause | Correction |
|---|---|---|
| Réussit à chaque fois, même quand l’expéditeur est cassé | La même adresse à chaque exécution ; la première interrogation trouve le message de l’exécution précédente. | fresh_address() à chaque exécution. C’est celle-ci qui compte. |
Un 429 dans le journal, puis un crash | Une boucle sans pause, ou deux scripts qui interrogent une même adresse. | Une lecture par seconde par adresse ; patienter sur Retry-After ; une adresse par script. |
| Expire les jours lents, réussit à la nouvelle tentative | Un nombre de tentatives au lieu d’une échéance, ou une échéance plus courte que la file de l’expéditeur. | Échéance sur time.monotonic(), soixante secondes pour un e-mail transactionnel. |
404 depuis /mailbox | Le domaine n’est pas hébergé ici — une faute de frappe, ou votre propre domaine avec un MX manquant. | Vérifiez l’adresse ; pour votre propre domaine, vérifiez que le MX pointe vers smtp.grabmail.io. |
| Lit le mauvais message | A pris le message le plus récent alors que le flux en a envoyé deux. | Filtrez avec subject_contains ou from_contains. |
Fonctionne pendant une semaine, puis 404 sur un message | Un id de message stocké plus vieux que 5 jours. | Rien ne survit 5 jours. Relisez plutôt que de mettre en cache. |
Avant de considérer que c’est terminé
- Une adresse fraîche par exécution, par test ou par agent — jamais une constante.
- Une échéance monotone ; une lecture par seconde ;
429absorbé par une pause, jamais levé comme une erreur. - Un filtre sur le sujet ou l’expéditeur quand un flux envoie plus d’un message.
- La partie texte analysée en premier, avec un motif ancré sur votre propre formulation.
- Les pièces jointes traitées comme des fichiers non fiables, enregistrées sous l’id du message.
- Aucun id de message mis en cache d’un jour sur l’autre ; rien ici ne survit 5 jours.
C’est tout le client. Le même module en TypeScript, pour Node, Deno et Bun, se trouve dans le guide Node.js ; la forme des requêtes et des réponses, avec chaque code de statut, est dans la référence de l’API, et il existe un document OpenAPI 3.1 pour quiconque préfère générer le client plutôt que l’écrire.
Questions
Ai-je besoin d’une clé d’API ?
Non. Les domaines publics ne demandent ni clé, ni compte, ni en-tête. Seul le pool payant de domaines tenus à l’écart des listes noires de mail jetable utilise un en-tête Authorization: Bearer, et le code ci-dessus reste par ailleurs identique pour lui.
Puis-je plutôt générer le client à partir du document OpenAPI ?
Oui — /openapi.json est en OpenAPI 3.1, et n’importe quel générateur produira les trois appels. Vous voudrez quand même la boucle à échéance de ce guide autour de l’appel de listage, parce qu’aucun générateur n’en écrit une pour vous.
Combien de boîtes un script peut-il interroger à la fois ?
Vingt, confortablement : la limite par adresse est d’une lecture par seconde, et le plafond par client est de 1200 requêtes par minute, soit vingt adresses interrogées une fois par seconde. La version asynchrone ci-dessus respecte les deux, et au-delà du plafond, elle patiente sur le 429 plutôt que d’échouer.
Puis-je utiliser mon propre domaine depuis Python ?
Oui, sans autre changement au code que la constante DOMAIN. Un enregistrement MX pointant vers smtp.grabmail.io, et chaque adresse du domaine devient une boîte que le même module lit — la configuration se trouve ici. C’est la bonne réponse quand votre application refuse les domaines jetables publics.
La boîte est-elle privée pendant que mon script l’utilise ?
Non. Quiconque connaît l’adresse peut la lire, sur un domaine public comme sur le vôtre. Une adresse aléatoire qui contient un code de confirmation pendant quelques secondes ne pose pas de problème ; un script qui y fait pointer du vrai courrier client, si.
Existe-t-il quelque chose pour un agent IA plutôt qu’un script ?
Il existe un serveur MCP à la même origine, sans clé, dont l’outil wait_for_message garde l’appel ouvert jusqu’à ce que le courrier arrive — la forme dont un agent a besoin, puisque chaque interrogation lui coûte des tokens. Une boîte qu’un agent IA peut lire couvre ce sujet.


