افتح صندوق بريد

الأتمتة وAPI

Python: استقبال البريد عبر API صندوق مؤقت دون مفتاح

ستون سطرًا من Python، وتبعية واحدة، وبلا مفتاح API: عنوان تبتكره، وانتظار بمهلة زمنية، ورسالة على شكل dict. وإليك الوحدة (module) على requests، والنسخة نفسها على httpx لـasyncio، وتصفح صندوق بريد مزدحم صفحة صفحة، وحفظ مرفق، وfixture لـpytest، والأخطاء الستة التي تظهر أول مرة يعمل فيها السكربت دون مراقبة.

  • متوسط
  • 23 دقيقةً للقراءة
أنبوب رمادي ملفوف بتراخٍ يخرج من طرفه المفتوح مظروف أزرق إلى صينية رمادية صغيرة

الـ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 فهو عنوان ثانٍ على نطاق منفصل يسلِّم إلى الصندوق نفسه ولا يمكن استخدامه لقراءته — وهو ما تُعطيه لموقع حين تفضِّل ألا يستطيع فتح صندوق الوارد.

GET /api/v1/mailbox — الاستجابة
{
  "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
"""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
"""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
# 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 فهو أيًا كان ما يوفره إطار عملك — عميل اختبار 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. ويغطي ذلك دليل صندوق وارد يستطيع وكيل ذكاء اصطناعي قراءته.

تابع القراءة

جرّبه وهو جديد

عنوان واحد يستغرق نقرة واحدة، بلا حساب وبلا بطاقة. كل ما في هذا الدليل يعمل عليه فورًا.

أهلًا بعودتك

صناديقك ونطاقاتك في مكان واحد.