Abrir un buzón

API y automatización

Python: recibir email con una API de buzón desechable

Sesenta líneas de Python, una dependencia y sin clave de API: una dirección que inventas, una espera con un plazo, el mensaje como un dict. Aquí está el módulo sobre requests, el mismo sobre httpx para asyncio, cómo paginar un buzón con mucho tráfico, guardar un adjunto, un fixture de pytest, y los seis errores que aparecen la primera vez que se ejecuta sin supervisión.

  • Intermedio
  • 23 min de lectura
Un tubo gris enrollado sin apretar, con un sobre azul saliendo por su extremo abierto sobre una pequeña bandeja gris

La API, vista desde Python

No hay nada que instalar del lado del servidor ni nada contra lo que autenticarse: un buzón en un dominio público lo puede leer cualquiera que conozca su dirección, por HTTPS normal, como JSON. Toda la superficie son tres llamadas:

GET /api/v1/mailbox?address=…
Todo lo que espera en una dirección, lo más reciente primero, como una lista de resúmenes. Un buzón vacío es 200 con count: 0 — nunca un 404. limit limita una respuesta (1–200, 50 por defecto) y before pagina más allá de eso.
GET /api/v1/message/{id}?mailbox=…
Un mensaje completo: remitente, destinatario, asunto, fecha, la parte en texto plano, la parte en HTML (o null), y una lista de adjuntos con una URL ya preparada cada uno.
DELETE /api/v1/message/{id}?mailbox=…
Lo elimina ahora en lugar de dentro de 5 días. Idempotente: borrar dos veces sigue respondiendo 200.

Un listado tiene este aspecto. El alias es una segunda dirección en un dominio distinto que entrega en el mismo buzón y que no se puede usar para leerlo — la que le das a un sitio cuando prefieres que no pueda abrir el buzón.

GET /api/v1/mailbox — la respuesta
{
  "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" }
  ]
}

El módulo

Un archivo, una clase, y requests como única dependencia. Es deliberadamente aburrido: una sesión, un bucle con plazo, y el único reintento que alguna vez es correcto — dormir el tiempo de 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

Usarlo son cuatro líneas. Imprime la dirección, úsala donde se pida una dirección, y espera:

una primera ejecución
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

Lo mismo sobre httpx, para asyncio

Cuando un script tiene que vigilar varios buzones a la vez — cinco registros en un trabajo por lotes, un agente haciendo malabares con cuentas — el bucle síncrono serializa las esperas. httpx.AsyncClient hace que la misma clase admita await, y asyncio.gather espera a todas a la vez:

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())

Cinco buzones consultados una vez por segundo cada uno son cinco solicitudes por segundo, bien dentro del límite por cliente de 1200 por minuto. Pasados veinte buzones a la vez llegarías al límite, y la rama de 429 empezaría a dormir — que es el comportamiento correcto, no un fallo.

Un buzón con mucho tráfico: paginar con before

Un listado devuelve como máximo 200 resúmenes. Un buzón que recibe más que eso — una dirección catch-all en tu propio dominio que recoge un día de rebotes, por ejemplo — se lee página por página: pasa el valor next de una respuesta como el parámetro before de la siguiente solicitud, y para cuando next sea null.

todos los mensajes, sean cuantas páginas sean
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"])

El cursor es el id del mensaje más antiguo que ya tienes, así que una página es estable incluso mientras llega correo nuevo por arriba. Automatizar un buzón desde un script repasa el cursor con más detalle, junto con la programación y la retención.

Adjuntos a disco

Cada mensaje lista sus adjuntos con un nombre de archivo, un tipo declarado, un tamaño en bytes y una URL. La URL ya lleva el parámetro ?mailbox=, así que se descarga tal cual. La respuesta es siempre application/octet-stream con una cabecera Content-Disposition: attachment, sea lo que sea que el remitente etiquetó como el archivo — el tipo real es el campo mime del JSON, donde es un dato y no una instrucción.

guardar todos los adjuntos de un mensaje
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)

Como un fixture de pytest

Un fixture que devuelve un Inbox nuevo le da a cada test su propio buzón, que es la propiedad más importante de todas en un test de correo: ninguna ejecución puede leer jamás el mensaje de una ejecución anterior. El patrón de extracción está anclado en la redacción de la plantilla en lugar de en «seis dígitos», por las razones que explica códigos OTP en tests automatizados.

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

El fixture client es el que te dé tu framework — el test client de Flask, el de Django, un httpx.Client apuntando a un servidor en marcha. A la parte del buzón le da igual. Ejecutar la batería de pruebas en un ejecutor de CI añade una regla de salida y un tiempo de espera, los dos cubiertos en la guía de GitHub Actions.

Errores que aparecen la primera vez que se ejecuta sin supervisión

Ninguno de estos falla en un portátil. Todos fallan un martes por la noche en una tarea cron.

SíntomaCausaSolución
Pasa siempre, incluso cuando el remitente está rotoLa misma dirección en cada ejecución; la primera consulta encuentra el mensaje de la ejecución anterior.fresh_address() por ejecución. Esta es la que importa.
429 en el log, y después un cuelgueUn bucle sin espera, o dos scripts consultando una misma dirección.Una lectura por segundo por dirección; duerme el tiempo de Retry-After; una dirección por script.
Agota el tiempo de espera en un día lento, pasa al reintentarUn número de intentos en lugar de un plazo, o un plazo más corto que la cola del remitente.Un plazo con time.monotonic(), sesenta segundos para un correo transaccional.
404 desde /mailboxEl dominio no está alojado aquí — una errata, o tu propio dominio sin registro MX.Comprueba la dirección; para tu propio dominio, comprueba que el MX apunta a smtp.grabmail.io.
Lee el mensaje equivocadoCogió el mensaje más reciente cuando el flujo envió dos.Filtra con subject_contains o from_contains.
Funciona una semana, y después 404 en un mensajeUn id de mensaje guardado con más de 5 días.Nada sobrevive 5 días. Vuelve a pedirlo en lugar de guardarlo en caché.

Antes de darlo por terminado

  • Una dirección nueva por ejecución, por test o por agente — nunca una constante.
  • Un plazo monótono; una lectura por segundo; los 429 dormidos, nunca lanzados como excepción.
  • Un filtro por asunto o remitente cuando un flujo envía más de un mensaje.
  • La parte de texto analizada primero, con un patrón anclado en tu propia redacción.
  • Los adjuntos tratados como archivos no confiables, guardados bajo el id del mensaje.
  • Ningún id de mensaje guardado en caché de un día para otro; nada aquí sobrevive 5 días.

Eso es todo el cliente. El mismo módulo en TypeScript, para Node, Deno y Bun, está en la guía de Node.js; la forma de las solicitudes y las respuestas, con todos los códigos de estado, está en la referencia de la API, y hay un documento OpenAPI 3.1 para quien prefiera generar el cliente antes que escribirlo.

Preguntas

¿Necesito una clave de API?

No. Los dominios públicos no piden clave, ni cuenta, ni cabecera. Solo el fondo de pago de dominios que se mantienen fuera de las listas de bloqueo de correo desechable usa una cabecera Authorization: Bearer, y el código de arriba es idéntico salvo por eso.

¿Puedo generar el cliente a partir del documento OpenAPI en su lugar?

Sí — /openapi.json es OpenAPI 3.1 y cualquier generador va a producir las tres llamadas. Aun así vas a querer el bucle con plazo de esta guía alrededor de la llamada de listado, porque ningún generador te escribe uno.

¿Cuántos buzones puede consultar un script a la vez?

Veinte, sin problema: el límite por dirección es una lectura por segundo y el límite por cliente es de 1200 solicitudes por minuto, que son veinte direcciones consultadas una vez por segundo. La versión asíncrona de arriba respeta los dos, y por encima del límite duerme el tiempo del 429 en lugar de fallar.

¿Puedo usar mi propio dominio desde Python?

Sí, sin ningún cambio en el código más allá de la constante DOMAIN. Un registro MX que apunte a smtp.grabmail.io y cada dirección del dominio se convierte en un buzón que lee el mismo módulo — la configuración está aquí. Es la respuesta correcta cuando tu aplicación rechaza los dominios públicos desechables.

¿Es privado el buzón mientras lo usa mi script?

No. Cualquiera que conozca la dirección puede leerlo, tanto en un dominio público como en el tuyo propio. Una dirección aleatoria que contiene un código de confirmación durante unos segundos no supone ningún problema; un script que apunta correo real de clientes a una sí lo es.

¿Hay algo pensado para un agente de IA en lugar de un script?

Hay un servidor MCP en el mismo origen, sin clave, cuya herramienta wait_for_message mantiene la llamada abierta hasta que llega el correo — la forma que necesita un agente, ya que cada consulta le cuesta tokens. Un buzón que un agente de IA puede leer lo cubre.

Pruébalo mientras está reciente

Una dirección lleva un clic, sin cuenta y sin tarjeta. Todo lo de esta guía funciona con ella de inmediato.

Bienvenido de nuevo

Tus buzones y tus dominios, en un solo lugar.