الـAPI، كما تراه Python
لا شيء يجب تثبيته على جانب الخادم، ولا شيء يجب المصادقة (authenticate) مقابله: صندوق البريد على نطاق عام يستطيع قراءته أي شخص يعرف عنوانه، عبر HTTPS عادي، كـJSON. والواجهة كاملةً ثلاثة نداءات:
GET /api/v1/mailbox?address=…- كل ما ينتظر عند عنوان، بترتيب الأحدث أولاً، كقائمة ملخصات. وصندوق البريد الفارغ هو
200معcount: 0— ليس 404 أبدًا. ويحددlimitسقف استجابة واحدة (من 1 إلى 200، والافتراضي 50)، ويتصفحbeforeما بعدها. GET /api/v1/message/{id}?mailbox=…- رسالة واحدة كاملة: المرسِل، والمستلم، والموضوع، والتاريخ، والجزء النصي البسيط، وجزء HTML (أو
null)، وقائمة مرفقات لكل منها URL جاهز. DELETE /api/v1/message/{id}?mailbox=…- يزيلها الآن بدل بعد 5 يومًا. وهي idempotent: فالحذف مرتين لا يزال يجيب بـ
200.
وهذا شكل سرد صندوق البريد. أما alias فهو عنوان ثانٍ على نطاق منفصل يسلِّم إلى الصندوق نفسه ولا يمكن استخدامه لقراءته — وهو ما تُعطيه لموقع حين تفضِّل ألا يستطيع فتح صندوق الوارد.
{
"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)
ملف واحد، وصنف (class) واحد، وrequests باعتبارها التبعية الوحيدة. وهو مُمِل عمدًا: جلسة (session)، وحلقة بمهلة زمنية، وإعادة المحاولة الوحيدة الصحيحة على الإطلاق — الانتظار عند 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 targetاستخدامها أربعة أسطر. اطبع العنوان، واستخدمه أينما طُلب عنوان، وانتظر:
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الشيء نفسه على httpx، لـasyncio
حين يجب على سكربت مراقبة عدة صناديق بريد في آن واحد — خمس عمليات تسجيل في مهمة دفعية، أو وكيل يوازن بين حسابات — تُسلسِل الحلقة المتزامنة الانتظارات. ويجعل httpx.AsyncClient الصنف نفسه قابلاً للانتظار (awaitable)، وتنتظر asyncio.gather عليها جميعًا معًا:
"""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())خمسة صناديق بريد تُستطلَع كل واحدة مرة في الثانية هي خمسة طلبات في الثانية، ضمن سقف كل عميل البالغ 1200 في الدقيقة بأريحية. وبعد عشرين صندوق بريد في آن واحد ستكون عند السقف، وسيبدأ فرع 429 بالانتظار — وهذا سلوك صحيح، لا فشل.
صندوق بريد مزدحم: التصفح بـbefore
يُعيد السرد 200 ملخص كحد أقصى. وصندوق بريد يستقبل أكثر من ذلك — عنوان catch-all على نطاقك الخاص يجمع يومًا من الرسائل المرتدة، مثلاً — يُقرأ صفحة صفحة: مرِّر قيمة next من استجابة إلى معامل before في الطلب التالي، وتوقف حين تكون next هي 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) هو معرِّف أقدم رسالة لديك بالفعل، فتظل الصفحة ثابتة حتى مع وصول بريد جديد إلى الأعلى. ويستعرض دليل أتمتة صندوق الوارد من سكربت المؤشِّر بتفصيل أكبر، مع الجدولة ومدة الاحتفاظ.
المرفقات إلى القرص
تسرد كل رسالة مرفقاتها باسم ملف، ونوع مُعلَن، وحجم بالبايت، وURL. ويحمل الـURL معامل ?mailbox= بالفعل، فيُجلَب كما هو. والاستجابة دائمًا application/octet-stream مع ترويسة Content-Disposition: attachment، أيًا كان ما وسم به المرسِل الملف — والنوع الحقيقي هو حقل mime في JSON، حيث يكون بيانات لا تعليمات.
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)كـfixture في pytest
fixture يُعيد Inbox جديدًا يمنح كل اختبار صندوق بريده الخاص، وهذه أهم خاصية على الإطلاق لاختبار بريدي: لا يمكن لأي تشغيلة أبدًا قراءة رسالة تشغيلة سابقة. ونمط الاستخراج مثبَّت على صياغة القالب لا على «ست خانات»، للأسباب التي يشرحها دليل رموز OTP في الاختبارات الآلية.
# 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 == 200أما fixture الخاص بـclient فهو أيًا كان ما يوفره إطار عملك — عميل اختبار Flask، أو عميل Django، أو httpx.Client موجَّه إلى خادم يعمل. وجانب صندوق البريد لا يهتم بذلك. وتشغيل مجموعة الاختبارات على مُشغِّل CI يضيف قاعدة اتصال صادر ومهلة زمنية، كلاهما مشروح في دليل GitHub Actions.
أخطاء تظهر أول مرة يعمل فيها دون مراقبة
لا شيء من هذه يتعطل على حاسوب محمول. وكلها تتعطل ليلة ثلاثاء داخل مهمة cron.
| العرض | السبب | الحل |
|---|---|---|
| ينجح في كل مرة، حتى حين يكون المرسِل معطَّلاً | العنوان نفسه في كل تشغيلة؛ فأول استطلاع يجد رسالة التشغيلة الأخيرة. | استدعِ fresh_address() في كل تشغيلة. هذا هو الأمر المهم. |
429 في السجل، ثم انهيار | حلقة بلا انتظار، أو سكربتان يستطلعان عنوانًا واحدًا. | قراءة واحدة في الثانية لكل عنوان؛ وانتظر حسب Retry-After؛ وعنوان واحد لكل سكربت. |
| تنتهي مهلته في يوم بطيء، وينجح عند إعادة المحاولة | عدد محاولات بدل مهلة زمنية، أو مهلة أقصر من طابور المرسِل. | مهلة بـtime.monotonic()، ستون ثانية لبريد معاملاتي. |
404 من /mailbox | النطاق غير مستضاف هنا — خطأ إملائي، أو نطاقك الخاص بلا سجل MX. | تحقق من العنوان؛ ولنطاقك الخاص، تحقق من أن MX يشير إلى smtp.grabmail.io. |
| يقرأ الرسالة الخطأ | أخذ أحدث رسالة بينما أرسل المسار رسالتين. | رشِّح بـsubject_contains أو from_contains. |
يعمل لمدة أسبوع، ثم 404 على رسالة | معرِّف رسالة مُخزَّن أقدم من 5 يومًا. | لا شيء يصمد 5 يومًا. أعِد الجلب بدل التخزين المؤقت (cache). |
قبل أن تعتبره منتهيًا
- عنوان جديد لكل تشغيلة، أو اختبار، أو وكيل — لا ثابت أبدًا.
- مهلة زمنية رتيبة (monotonic)؛ وقراءة واحدة في الثانية؛ و
429يُنتظَر، لا يُطلَق كخطأ أبدًا. - مرشِّح على الموضوع أو المرسِل حين يرسل مسار أكثر من رسالة.
- الجزء النصي مُحلَّل أولاً، بنمط مثبَّت على صياغتك أنت.
- المرفقات مُعامَلة كملفات غير موثوقة، ومحفوظة تحت معرِّف الرسالة.
- لا معرِّف رسالة مُخزَّن مؤقتًا عبر الأيام؛ لا شيء هنا يعيش أطول من 5 يومًا.
هذا هو العميل كاملاً. والوحدة نفسها بـTypeScript، لـNode وDeno وBun، موجودة في دليل Node.js؛ وأشكال الطلب والاستجابة، بكل رمز حالة، موجودة في مرجع API، وهناك وثيقة OpenAPI 3.1 لمن يفضِّل توليد العميل بدل كتابته.
أسئلة
هل أحتاج إلى مفتاح API؟
لا. فالنطاقات العامة لا تحتاج مفتاحًا، ولا حسابًا، ولا ترويسة. ولا تستخدم ترويسة Authorization: Bearer إلا مجموعة النطاقات المدفوعة التي تبقى بعيدة عن قوائم حظر البريد المؤقت، والكود أعلاه مطابق لها في كل ما عدا ذلك.
هل يمكنني توليد العميل من وثيقة OpenAPI بدلاً من ذلك؟
نعم — /openapi.json هي OpenAPI 3.1 وأي مولِّد سينتج النداءات الثلاثة. لكنك ستظل تريد حلقة المهلة الزمنية من هذا الدليل حول نداء السرد، لأن لا مولِّد يكتب واحدة نيابة عنك.
كم صندوق بريد يستطيع سكربت واحد استطلاعه في آن واحد؟
عشرون، بأريحية: فالحد لكل عنوان قراءة واحدة في الثانية، وسقف كل عميل 1200 طلب في الدقيقة، أي عشرون عنوانًا تُستطلَع كل واحد مرة في الثانية. وتحترم نسخة asyncio أعلاه كليهما، وبعد السقف تنتظر عند 429 بدل أن تفشل.
هل يمكنني استخدام نطاقي الخاص من Python؟
نعم، دون أي تغيير في الكود عدا ثابت DOMAIN. سجل MX واحد يشير إلى smtp.grabmail.io، ويصبح كل عنوان على النطاق صندوق بريد تقرؤه الوحدة نفسها — الإعداد هنا. وهذا هو الحل الصحيح حين يرفض تطبيقك النطاقات المؤقتة العامة.
هل صندوق البريد خاص أثناء استخدام سكربتي له؟
لا. يستطيع أي شخص يعرف العنوان قراءته، سواء على نطاق عام أو على نطاقك الخاص. وعنوان عشوائي يحمل رمز تحقق واحدًا لبضع ثوانٍ أمر لا بأس به؛ أما سكربت يوجِّه بريد عملاء حقيقيين إلى واحد فليس كذلك.
هل يوجد شيء لوكيل ذكاء اصطناعي بدلاً من سكربت؟
يوجد خادم MCP على المصدر نفسه، بلا مفتاح، تُبقي أداته wait_for_message النداء مفتوحًا حتى يصل البريد — وهذا هو الشكل الذي يحتاجه الوكيل، لأن كل استطلاع يكلِّفه tokens. ويغطي ذلك دليل صندوق وارد يستطيع وكيل ذكاء اصطناعي قراءته.


