API & tự động hóa

Python: nhận email bằng API hộp thư dùng một lần, không khóa

Sáu mươi dòng Python, một phụ thuộc duy nhất và không cần API key: một địa chỉ do bạn tự tạo ra, một lượt chờ có hạn chót, và thư được trả về dưới dạng một dict. Đây là module dùng requests, cùng một module đó dùng httpx cho asyncio, phân trang qua một hộp thư bận rộn, lưu một tệp đính kèm, một fixture pytest, và sáu lỗi sai sẽ lộ ra ngay lần đầu tiên nó chạy mà không có ai giám sát.

  • Trung cấp
  • 23 phút đọc
Một ống xám cuộn lỏng, một phong bì xanh trồi ra từ đầu mở của ống lên một khay xám nhỏ

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ề 200 kèm count: 0 — không bao giờ là 404. limit giới hạn số lượng cho một response (1–200, mặc định 50), còn before dù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ư.

GET /api/v1/mailbox — response trả về
{
  "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
"""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

Dù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ờ:

một lượt chạy đầu tiê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

Tươ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
"""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 nextnull.

mọi thư, bất kể bao nhiêu trang
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ị.

lưu mọi tệp đính kèm của một 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
# 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

Fixture 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ứngNguyên nhânCách khắc phục
Luôn luôn qua, ngay cả khi bên gửi đang hỏngCù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 crashMộ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ừ /mailboxTê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 đó.

Hãy thử ngay khi nó còn mới

Một địa chỉ chỉ mất một cú nhấp, không tài khoản và không thẻ. Mọi thứ trong hướng dẫn này đều hoạt động ngay trên đó.

Chào mừng trở lại

Hộp thư và tên miền của bạn, ở cùng một nơi.