Ouvrir une boîte

API et automatisation

Python : recevoir des e-mails avec une API de boîte jetable

Soixante lignes de Python, une seule dépendance et aucune clé d’API : une adresse que vous inventez, une attente avec une échéance, le message sous forme de dict. Voici le module sur requests, le même sur httpx pour asyncio, la pagination dans une boîte chargée, l’enregistrement d’une pièce jointe, une fixture pytest, et les six erreurs qui apparaissent la première fois que ça tourne sans surveillance.

  • Intermédiaire
  • 23 min de lecture
Un tube gris enroulé lâchement, d'où une enveloppe bleue sort par l'extrémité ouverte sur un petit plateau gris

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 200 avec count: 0 — jamais un 404. limit plafonne une réponse (1 à 200, 50 par défaut), et before permet 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.

GET /api/v1/mailbox — la réponse
{
  "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
"""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

L’utiliser tient en quatre lignes. Affichez l’adresse, utilisez-la partout où une adresse est demandée, et attendez :

une première exécution
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

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

tous les messages, quel que soit le nombre de 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"])

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.

enregistrer toutes les pièces jointes d’un 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)

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
# 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

La 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ômeCauseCorrection
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 crashUne 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 tentativeUn 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 /mailboxLe 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 messageA 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 messageUn 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 ; 429 absorbé 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.

Essayez-le pendant que c'est encore frais

Une adresse s'obtient en un clic, sans compte et sans carte. Tout ce que contient ce guide fonctionne avec elle, immédiatement.

Bon retour

Vos boîtes et vos domaines, au même endroit.