A API, do jeito que o Python vê
Não há nada para instalar do lado do servidor e nada contra o que autenticar: uma caixa de entrada em um domínio público pode ser lida por qualquer um que saiba o endereço dela, via HTTPS simples, como JSON. Toda a superfície são três chamadas:
GET /api/v1/mailbox?address=…- Tudo o que está esperando em um endereço, mais recente primeiro, como uma lista de resumos. Uma caixa de entrada vazia é
200comcount: 0— nunca um 404.limitlimita uma resposta (1–200, padrão 50) ebeforepagina além dela. GET /api/v1/message/{id}?mailbox=…- Uma mensagem completa: remetente, destinatário, assunto, data, a parte em texto puro, a parte em HTML (ou
null), e uma lista de anexos, cada um com uma URL já pronta. DELETE /api/v1/message/{id}?mailbox=…- Remove a mensagem agora em vez de daqui a 5 dias. Idempotente: apagar duas vezes ainda responde
200.
Uma listagem se parece com isto. O alias é um segundo endereço em um domínio separado que entrega na mesma caixa de entrada e não pode ser usado para lê-la — o que você dá a um site quando prefere que ele não consiga abrir a caixa de entrada.
{
"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" }
]
}O módulo
Um arquivo, uma classe, e requests como a única dependência. É propositalmente sem graça: uma sessão, um loop com prazo, e a única forma de repetição que é sempre correta — esperar o tempo de um 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 targetUsá-lo são quatro linhas. Imprima o endereço, use-o onde quer que um endereço seja pedido, e espere:
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 NoneO mesmo em httpx, para asyncio
Quando um script precisa observar várias caixas de entrada ao mesmo tempo — cinco cadastros em um job em lote, um agente lidando com várias contas — o loop síncrono serializa as esperas. O httpx.AsyncClient torna a mesma classe awaitable, e asyncio.gather espera todas elas juntas:
"""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 caixas de entrada, cada uma consultada uma vez por segundo, são cinco requisições por segundo, bem dentro do teto por cliente de 1200 por minuto. Passando de vinte caixas de entrada ao mesmo tempo você chegaria ao teto, e o branch de 429 começaria a esperar — o que é o comportamento correto, não uma falha.
Uma caixa de entrada movimentada: paginação com before
Uma listagem retorna no máximo 200 resumos. Uma caixa de entrada que recebe mais do que isso — um endereço catch-all no seu próprio domínio coletando um dia de bounces, por exemplo — é lida página por página: passe o valor de next de uma resposta como o parâmetro before da requisição seguinte, e pare quando next for 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"])O cursor é o id da mensagem mais antiga que você já tem, então uma página fica estável mesmo enquanto novos e-mails chegam no topo. Automatizando uma caixa de entrada a partir de um script detalha o cursor com mais profundidade, junto com agendamento e retenção.
Anexos em disco
Toda mensagem lista seus anexos com um nome de arquivo, um tipo declarado, um tamanho em bytes e uma URL. A URL já carrega o parâmetro ?mailbox=, então ela é buscada exatamente como está. A resposta é sempre application/octet-stream com um cabeçalho Content-Disposition: attachment, seja lá o que quem enviou rotulou o arquivo como — o tipo real é o campo mime no JSON, onde ele é dado, não instrução.
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 uma fixture de pytest
Uma fixture que retorna uma Inbox nova dá a cada teste sua própria caixa de entrada, que é a propriedade mais importante de um teste de e-mail: nenhuma execução jamais pode ler a mensagem de uma execução anterior. O padrão de extração é ancorado no texto do template, e não em “seis dígitos”, pelos motivos que códigos OTP em testes automatizados detalha.
# 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 == 200A fixture client é o que quer que o seu framework forneça — o test client do Flask, o do Django, um httpx.Client apontado para um servidor rodando. O lado da caixa de entrada não se importa. Rodar a suíte em um runner de CI adiciona uma regra de saída e um timeout, ambos cobertos no guia do GitHub Actions.
Erros que aparecem na primeira vez que isso roda sem supervisão
Nenhum desses quebra em um notebook. Todos eles quebram em uma noite de terça-feira em um cron job.
| Sintoma | Causa | Correção |
|---|---|---|
| Passa sempre, mesmo quando quem envia está quebrado | O mesmo endereço a cada execução; a primeira consulta encontra a mensagem da execução anterior. | fresh_address() por execução. Esta é a que realmente importa. |
429 no log, depois um crash | Um loop sem espera, ou dois scripts fazendo polling em um endereço. | Uma leitura por segundo por endereço; espere o tempo de Retry-After; um endereço por script. |
| Estoura o tempo em um dia lento, passa ao repetir | Uma contagem de tentativas em vez de um prazo, ou um prazo mais curto que a fila de quem envia. | Prazo com time.monotonic(), sessenta segundos para um e-mail transacional. |
404 vindo de /mailbox | O domínio não é hospedado aqui — um erro de digitação, ou o seu próprio domínio sem um MX configurado. | Confira o endereço; para o seu próprio domínio, confira se o MX aponta para smtp.grabmail.io. |
| Lê a mensagem errada | Pegou a mensagem mais recente quando o fluxo enviou duas. | Filtre com subject_contains ou from_contains. |
Funciona por uma semana, depois 404 em uma mensagem | Um id de mensagem armazenado com mais de 5 dias. | Nada sobrevive 5 dias. Busque de novo em vez de guardar em cache. |
Antes de considerar concluído
- Um endereço novo por execução, por teste ou por agente — nunca uma constante.
- Um prazo monotônico; uma leitura por segundo;
429tratado com espera, nunca levantado como erro. - Um filtro por assunto ou remetente quando um fluxo envia mais de uma mensagem.
- A parte em texto analisada primeiro, com um padrão ancorado no seu próprio texto.
- Anexos tratados como arquivos não confiáveis, salvos sob o id da mensagem.
- Nenhum id de mensagem em cache entre dias; nada aqui sobrevive além de 5 dias.
Esse é o cliente inteiro. O mesmo módulo em TypeScript, para Node, Deno e Bun, está no guia de Node.js; os formatos de requisição e resposta, com cada código de status, estão na referência da API, e há um documento OpenAPI 3.1 para quem preferir gerar o cliente a escrevê-lo.
Perguntas
Preciso de uma chave de API?
Não. Os domínios públicos não exigem chave, conta nem cabeçalho. Só o conjunto pago de domínios mantidos fora das listas de bloqueio de e-mail descartável usa um cabeçalho Authorization: Bearer, e fora isso o código acima é idêntico para ele.
Posso gerar o cliente a partir do documento OpenAPI em vez disso?
Sim — /openapi.json é OpenAPI 3.1 e qualquer gerador vai produzir as três chamadas. Ainda assim você vai querer o loop com prazo deste guia em volta da chamada de listagem, porque nenhum gerador escreve um para você.
Quantas caixas de entrada um script pode consultar ao mesmo tempo?
Vinte, tranquilamente: o limite por endereço é uma leitura por segundo e o teto por cliente é de 1200 requisições por minuto, o que equivale a vinte endereços consultados uma vez por segundo. A versão assíncrona acima respeita os dois, e além do teto ela espera o tempo do 429 em vez de falhar.
Posso usar meu próprio domínio a partir do Python?
Sim, sem nenhuma mudança no código além da constante DOMAIN. Um registro MX apontando para smtp.grabmail.io e todo endereço no domínio vira uma caixa de entrada que o mesmo módulo lê — a configuração está aqui. É a resposta certa quando a sua aplicação recusa os domínios públicos descartáveis.
A caixa de entrada é privada enquanto meu script a usa?
Não. Qualquer um que saiba o endereço pode lê-la, em um domínio público e também no seu. Um endereço aleatório que guarda um único código de confirmação por alguns segundos não tem problema; um script que aponta e-mail real de clientes para um endereço desses tem.
Existe algo para um agente de IA em vez de um script?
Existe um servidor MCP na mesma origem, sem chave, cuja ferramenta wait_for_message mantém a chamada aberta até o e-mail chegar — o formato de que um agente precisa, já que todo polling custa tokens para ele. Uma caixa de entrada que um agente de IA consegue ler aborda isso.


