API, dưới góc nhìn của Python
Không có gì cần cài đặt ở phía máy chủ, và không có gì cần xác thực: một hộp thư trên tên miền công khai có thể được đọc bởi bất kỳ ai biết địa chỉ của nó, qua HTTPS thuần túy, dưới dạng JSON. Toàn bộ bề mặt API chỉ gồm ba lệnh gọi:
GET /api/v1/mailbox?address=…- Mọi thứ đang chờ tại một địa chỉ, mới nhất trước, dưới dạng một danh sách các bản tóm tắt. Một hộp thư trống trả về
200kèmcount: 0— không bao giờ là 404.limitgiới hạn số lượng cho một response (1–200, mặc định 50), cònbeforedùng để phân trang qua phần còn lại. GET /api/v1/message/{id}?mailbox=…- Một thư đầy đủ: người gửi, người nhận, tiêu đề, ngày tháng, phần văn bản thuần, phần HTML (hoặc
null), và một danh sách tệp đính kèm, mỗi tệp kèm sẵn một URL. DELETE /api/v1/message/{id}?mailbox=…- Xóa thư ngay lập tức thay vì phải đợi 5 ngày. Có tính idempotent: xóa hai lần vẫn trả về
200.
Một lượt liệt kê trông như thế này. alias là một địa chỉ thứ hai, trên một tên miền riêng biệt, chuyển thư vào cùng một hộp thư đó nhưng không thể dùng để đọc nó — chính là địa chỉ nên đưa cho một trang web khi bạn muốn nó không thể mở được hộp thư.
{
"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" }
]
}Module
Một tệp, một class, và requests là phụ thuộc duy nhất. Nó được thiết kế nhàm chán một cách có chủ đích: một session, một vòng lặp có hạn chót, và kiểu thử lại duy nhất từng đúng đắn — chờ đúng khoảng thời gian khi gặp 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 targetDùng nó chỉ mất bốn dòng. In địa chỉ ra, dùng nó ở bất cứ đâu cần một địa chỉ, rồi chờ:
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 NoneTương tự với httpx, dành cho asyncio
Khi một script phải theo dõi nhiều hộp thư cùng lúc — năm lượt đăng ký trong một batch job, một agent đang xoay xở với nhiều tài khoản — vòng lặp đồng bộ sẽ khiến các lượt chờ nối đuôi nhau tuần tự. httpx.AsyncClient biến cùng một class đó thành awaitable, và asyncio.gather chờ tất cả chúng cùng một lúc:
"""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())Năm hộp thư, mỗi hộp được thăm dò mỗi giây một lần, tức là năm yêu cầu mỗi giây, vẫn còn cách xa mức trần 1200 yêu cầu mỗi phút cho mỗi client. Vượt quá hai mươi hộp thư cùng lúc, bạn sẽ chạm đến mức trần đó, và nhánh xử lý 429 sẽ bắt đầu chờ — đó là hành vi đúng đắn, không phải một lỗi.
Một hộp thư bận rộn: phân trang bằng before
Một lượt liệt kê trả về tối đa 200 bản tóm tắt. Một hộp thư nhận nhiều hơn con số đó — chẳng hạn một địa chỉ catch-all trên tên miền riêng của bạn hứng cả một ngày thư dội ngược — sẽ được đọc từng trang một: truyền giá trị next của một response vào làm tham số before của yêu cầu tiếp theo, và dừng lại khi next là 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"])Cursor chính là id của thư cũ nhất mà bạn đã có, nên một trang luôn ổn định ngay cả khi có thư mới đến ở phía trên. Bài tự động hóa một hộp thư bằng script đi sâu hơn vào cursor này, cùng với việc lập lịch và thời gian lưu.
Lưu tệp đính kèm xuống ổ đĩa
Mỗi thư đều liệt kê các tệp đính kèm của nó, kèm tên tệp, loại tệp được khai báo, kích thước tính bằng byte, và một URL. URL đó đã mang sẵn tham số ?mailbox=, nên chỉ cần tải về nguyên trạng. Response luôn là application/octet-stream kèm header Content-Disposition: attachment, bất kể bên gửi đã gắn nhãn gì cho tệp — loại tệp thật sự nằm ở trường mime trong JSON, nơi nó chỉ là dữ liệu chứ không phải một chỉ thị.
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)Dưới dạng một fixture pytest
Một fixture trả về một Inbox mới cấp cho mỗi bài kiểm thử hộp thư riêng của nó, đây chính là đặc tính quan trọng nhất của một bài kiểm thử liên quan đến thư: không lượt chạy nào có thể đọc được thư của một lượt chạy trước đó. Mẫu biểu thức trích xuất được neo vào câu chữ của template, chứ không phải vào “sáu chữ số”, vì những lý do mà bài mã OTP trong các bài kiểm thử tự động đã trình bày rõ.
# 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 == 200Fixture client là bất cứ thứ gì framework của bạn cung cấp sẵn — test client của Flask, của Django, hay một httpx.Client trỏ đến một máy chủ đang chạy. Phía hộp thư không hề quan tâm đến điều đó. Chạy bộ kiểm thử này trên một CI runner sẽ cần thêm một quy tắc egress và một timeout, cả hai đều được trình bày trong hướng dẫn GitHub Actions.
Những lỗi sai chỉ lộ ra ngay lần đầu chạy mà không có ai giám sát
Không lỗi nào trong số này gãy trên một chiếc laptop cả. Tất cả chúng đều gãy vào một đêm thứ Ba trong một cron job.
| Triệu chứng | Nguyên nhân | Cách khắc phục |
|---|---|---|
| Luôn luôn qua, ngay cả khi bên gửi đang hỏng | Cùng một địa chỉ ở mọi lượt chạy; lượt thăm dò đầu tiên tìm thấy thư của lượt chạy trước. | fresh_address() cho mỗi lượt chạy. Đây chính là điều quan trọng nhất. |
429 xuất hiện trong log, rồi crash | Một vòng lặp không có sleep, hoặc hai script cùng thăm dò một địa chỉ. | Một lần đọc mỗi giây cho mỗi địa chỉ; chờ đúng Retry-After; một địa chỉ cho mỗi script. |
| Timeout vào một ngày chậm, chạy qua khi thử lại | Đếm số lần thử lại thay vì dùng hạn chót, hoặc một hạn chót ngắn hơn thời gian xếp hàng của bên gửi. | Hạn chót dựa trên time.monotonic(), sáu mươi giây cho một email giao dịch. |
404 từ /mailbox | Tên miền này không được lưu trữ ở đây — một lỗi gõ sai, hoặc tên miền riêng của bạn thiếu bản ghi MX. | Kiểm tra lại địa chỉ; với tên miền riêng của bạn, kiểm tra xem MX có trỏ đến smtp.grabmail.io hay không. |
| Đọc nhầm thư | Lấy thư mới nhất trong khi luồng đã gửi tới hai thư. | Lọc bằng subject_contains hoặc from_contains. |
Chạy tốt suốt một tuần, rồi 404 trên một thư | Một id thư đã lưu, cũ hơn 5 ngày. | Không gì tồn tại quá 5 ngày. Hãy lấy lại thay vì cache. |
Trước khi bạn coi như đã xong
- Một địa chỉ mới cho mỗi lượt chạy, mỗi bài kiểm thử, hoặc mỗi agent — không bao giờ là một hằng số.
- Một hạn chót monotonic; một lần đọc mỗi giây;
429được chờ qua, không bao giờ được raise thành lỗi. - Một bộ lọc theo tiêu đề hoặc người gửi khi một luồng gửi nhiều hơn một thư.
- Phần text được phân tích trước, với một mẫu biểu thức được neo vào đúng câu chữ của riêng bạn.
- Tệp đính kèm được coi là tệp không đáng tin cậy, được lưu dưới tên id của thư.
- Không id thư nào được cache qua nhiều ngày; không gì ở đây tồn tại lâu hơn 5 ngày.
Đó là toàn bộ client. Cùng một module đó bằng TypeScript, cho Node, Deno và Bun, nằm trong hướng dẫn Node.js; cấu trúc request và response, cùng mọi mã trạng thái, nằm trong tài liệu tham chiếu API, và có sẵn một tài liệu OpenAPI 3.1 cho ai muốn sinh ra client thay vì tự viết.
Câu hỏi
Tôi có cần API key không?
Không cần. Các tên miền công khai không cần key, không cần tài khoản, không cần header nào. Chỉ có nhóm tên miền trả phí được giữ ngoài các danh sách chặn email dùng một lần mới dùng đến header Authorization: Bearer, còn lại đoạn code ở trên vẫn giống hệt khi áp dụng cho nó.
Tôi có thể sinh ra client từ tài liệu OpenAPI thay vì tự viết không?
Được — /openapi.json là OpenAPI 3.1 và bất kỳ generator nào cũng sẽ tạo ra được ba lệnh gọi đó. Nhưng bạn vẫn sẽ cần vòng lặp có hạn chót từ hướng dẫn này bọc quanh lệnh gọi liệt kê, vì không generator nào viết sẵn nó cho bạn cả.
Một script có thể thăm dò cùng lúc bao nhiêu hộp thư?
Hai mươi, một cách thoải mái: giới hạn cho mỗi địa chỉ là một lần đọc mỗi giây, còn mức trần cho mỗi client là 1200 yêu cầu mỗi phút, tương đương hai mươi địa chỉ được thăm dò mỗi giây một lần. Phiên bản async ở trên tuân thủ cả hai giới hạn đó, và khi vượt quá mức trần, nó sẽ chờ qua 429 thay vì báo thất bại.
Tôi có thể dùng tên miền riêng của mình từ Python không?
Được, mà không cần đổi gì trong code ngoài hằng số DOMAIN. Một bản ghi MX trỏ đến smtp.grabmail.io và mọi địa chỉ trên tên miền đó đều trở thành một hộp thư mà chính module này đọc được — phần thiết lập ở đây. Đây chính là câu trả lời đúng khi ứng dụng của bạn từ chối các tên miền dùng một lần công khai.
Hộp thư có riêng tư trong lúc script của tôi đang dùng nó không?
Không riêng tư. Bất kỳ ai biết địa chỉ đều đọc được nó, dù trên tên miền công khai hay trên tên miền riêng của bạn. Một địa chỉ ngẫu nhiên chỉ giữ một mã xác nhận trong vài giây thì không sao; nhưng một script trỏ thư khách hàng thật vào đó thì không ổn chút nào.
Có giải pháp nào dành cho một AI agent thay vì một script không?
Có một máy chủ MCP tại cùng origin, không cần key, với công cụ wait_for_message giữ lệnh gọi mở cho đến khi thư đến — đúng là hình dạng mà một agent cần, vì mỗi lượt thăm dò đều tốn token của nó. Bài một hộp thư mà AI agent có thể đọc được trình bày đầy đủ về điều đó.


