الدفع والسحب، وتكلفة كل منهما
لا توجد إلا طريقتان يعرف بهما كودك أن رسالة قد وصلت: فإما أن يخبرك الطرف الآخر، أو تسأل أنت. وكل ما عداهما — مكتبة عميل تحمل waitFor، أو SDK يزعم أنه «يُبَثّ» صندوق بريد، أو دالة اختبار مساعدة تحجب التنفيذ — ما هو إلا واحد من هذين الاثنين بآلياته المخفية، ويستحق أن تعرف أيّهما تحمل قبل أن تضطر إلى تتبّع خلله.
أربعة ترتيبات تغطي تقريبًا كل ما هو معروض.
- webhook
- تقوم الخدمة بطلب HTTP إلى عنوان تملكه أنت، في كل مرة يصل فيها بريد. وهو أرخص انتظار ممكن — فأنت لا تفعل شيئًا على الإطلاق حتى يصبح هناك ما تفعله — والثمن هو عنوان على الإنترنت العام، ومستمع يعمل في اللحظة نفسها التي يصل فيها البريد، وسر مشترك يثبت أن الطلب جاء منها فعلاً، وجوابك الخاص عن ماذا يحدث حين لا يكون مستمعك موجودًا.
- استطلاع طويل
- تُجري الطلب ويُبقيه الخادم مفتوحًا إلى أن يصل بريد أو تنتهي مهلة زمنية. ولا يحتاج منك شيئًا سوى اتصال صادر، ويكلّف الخادم عاملاً واحدًا لكل منتظر — ولهذا تضع كل خدمة تقدّمه حدًا لطول الانتظار وعدد المنتظرين في الوقت نفسه.
- استطلاع بسيط
- تسأل، مرارًا، ويُجاب كل طلب فورًا بما هو موجود فعلاً. وهو الترتيب الوحيد الذي يعمل من حاسوب محمول خلف موجّه (router)، ومن مُشغِّل CI بلا مسار وارد، ومن وكيل يعمل داخل بيئة رملية (sandbox) يملكها شخص آخر — وهو موضوع هذا الدليل بأكمله.
- بروتوكول صندوق بريد
- لدى IMAP أمر
IDLE، وهو استطلاع طويل يرتدي قبعة مختلفة: يبقى الاتصال مفتوحًا ويُعلن الخادم عن البريد الجديد عليه. وهو دفع حقيقي بكل معنى الكلمة، ويحتاج إلى صندوق بريد ببيانات اعتماد، وعميل يستطيع إبقاء مقبس مفتوحًا وإعادة الاتصال عندما ينقطع، وخادم يحترم الأمر — وهذا قدر كبير من الآليات لمهمة لا تحتاج إلا رسالة واحدة.
وحين توضعان جنبًا إلى جنب، يتبيّن أن الاختيار بينهما أقل ارتباطًا بالأناقة منه بما يفرضه كل منهما على الجهاز الذي يعمل عليه كودك.
| ما يحتاجه منك | webhook | الاستطلاع |
|---|---|---|
| عنوان يمكن الوصول إلى كودك عبره | نعم: رابط عام بشهادة أمان، قابل للتوجيه من الإنترنت. | لا. طلب صادر واحد هو المطلوب كله. |
| سر يجب الاحتفاظ به وتدويره | نعم: مفتاح توقيع، وإلا استطاع غريب أن يرسل إليك رسالة مزيّفة. | لا. لا شيء يحتاج إلى تحقق، لأن شيئًا لا يصل أبدًا دون أن تطلبه. |
| شيء يعمل في اللحظة التي يصل فيها البريد | نعم — وحين يتعطل، فحصولك على الرسالة من الأساس يصبح رهن سياسة إعادة المحاولة عند المرسِل، لا قرارك أنت. | لا. لا يُفوَّت شيء بينما لا تنظر: يحتفظ صندوق البريد به لمدة 5 يومًا في كل الأحوال. |
| طلبات تُجرى حين لا يوجد بريد | لا شيء إطلاقًا. وهذا هو كل سحرها. | طلب واحد في كل فاصل زمني — وهذه هي التكلفة الحقيقية، وموضوع بقية هذا الدليل. |
الحلقة التي يكتبها كل شخص أولاً
هي أربعة أسطر، وتعمل في اليوم الذي تُكتب فيه، وتظهر كل مشكلة فيها لاحقًا وفي مكان آخر: في خط أنابيب عند الثالثة فجرًا، في وكيل ظل «يفكّر» إحدى عشرة دقيقة، في صندوق بريد يجيب زميلًا بـ429 لأن حلقتك تستهلك الحصة.
import time
import requests
while True:
r = requests.get("https://grabmail.io/api/v1/mailbox",
params={"address": "signup-42@grabmail.io"})
if r.json()["messages"]:
break
time.sleep(1)خمسة أشياء خاطئة فيها، والأول فقط واضح.
- لا تتوقف أبدًا
- لا موعد نهائي هنا، فحين لا تكون الرسالة قادمة فعلاً — رفض النموذج العنوان، أو انسدّ طابور المرسِل، أو أخطأ أحدهم في كتابة النطاق — لا تفشل هذه الحلقة. بل تتعلّق. ومهمة تتعلّق أسوأ من مهمة تفشل، لأن السجل ينتهي دون أن يقول أبدًا لماذا.
- تعدّ المحاولات وتسمّيها ثوانٍ
- وحتى مع حد على عدد الجولات، ثلاثون محاولة بـ«ثانية واحدة» ليست أبدًا ثلاثين ثانية: فكل جولة تكلّف طلبًا أيضًا، وطلب يستغرق 400 مللي ثانية يحوّل ثلاثين ثانيتك إلى اثنتين وأربعين. وأضف محاولة إعادة واحدة فيتوقف الحساب عن كونه حسابًا.
- كل مُشغِّل يسأل في اللحظة نفسها
- شغّل عشرين مهمة من خط الأنابيب نفسه وستستطلع كلها بخطى متزامنة، لأنها بدأت جميعًا في غضون أجزاء من الثانية بعضها من بعض وتنام جميعًا الثانية الكاملة نفسها. فالقمة عشرون مرة المعدل، والقمة هي ما يُرفض.
- تأخذ أحدث رسالة، لا رسالتك
- أول عنصر في القائمة هو أيًّا كان ما في قمة ذلك الصندوق، وقد يكون على عنوان عام بريد شخص آخر، وعلى عنوان أعيد استخدامه بريد الأسبوع الماضي. وحلقة تخرج عند أول رسالة تراها ستخرج بارتياح قبل أن تصل الرسالة التي كانت تنتظرها فعلاً.
- تعامل كل إجابة كنجاح
- قراءة قائمة الرسائل من إجابة
429أو404تُطلق خطأً على بُعد ثلاث طبقات من أي شيء يفسّره، وقراءتها من500قد لا تُطلق شيئًا على الإطلاق. رمز الحالة هو أول ما ينبغي النظر إليه، لا آخره.
التوقف بالساعة، لا بالعدّ
خذ الموعد النهائي مرة واحدة، قبل أول طلب، من ساعة رتيبة (monotonic) — ساعة لا يمكن أن تتراجع إلى الخلف حين يصحّح الجهاز توقيته — وقارن بها في أعلى كل جولة. وكل ما عدا ذلك في الحلقة يصبح حرًّا في التغيّر دون أن يتغيّر طول الانتظار: تستطيع أن توسّع الفاصل الزمني، أو تعيد محاولة رفض، أو تضيف فلترًا ثانيًا، وتبقى التسعون ثانية تسعين ثانية.
وسؤال ما هي المدة الكافية سؤال عن المرسِل لا عنك. فالبريد الذي تولّده آلة ردًا على نموذج يصل عادة في ثوانٍ أحادية الرقم؛ أما طابور متراكم، أو مستقبِل يستخدم التصنيف الرمادي (greylisting)، أو دفعة تُرسل كل ساعة، فمقياسها مختلف تمامًا، ولا فاصل زمني تختاره يجعله يصل أسرع.
| ما تنتظره | موعد نهائي صادق | ما تفعله حين يمر |
|---|---|---|
| بريد تسجيل أو تحقق، داخل اختبار | 60 إلى 120 ثانية | أفشل الاختبار واطبع العنوان. في تسع من كل عشر مرات يكون الصندوق فارغًا لأن النموذج رفض العنوان، والعنوان هو أول شيء يحتاج أي قارئ للسجل أن يراه. |
| إعادة تعيين كلمة مرور طلبها شخص للتو | 30 إلى 60 ثانية | أخبره أنها لم تصل واعرض إرسالها مجددًا. لا تستمر في الدوران خلف شاشة صامتة: فهو سيطلب رمزًا ثانيًا على أي حال، وسيصبح هناك رمزان الآن. |
| وكيل يُتمّم تسجيلاً بمفرده | انتظاران أو ثلاثة من جهة الخادم، أي 50 إلى 75 ثانية | قل ذلك في الإجابة. فـ«لا بريد تأكيد بعد دقيقة» نتيجة يستطيع الوكيل التصرف بموجبها؛ أما استدعاء أداة لا يعود أبدًا فليس كذلك. |
| نشرة بريدية، أو إيصال، أو أي شيء يُرسل على دفعات | دقائق — أو لا تنتظر إطلاقًا | استطلع وفق جدول زمني بدلاً من ذلك واترك العملية تنتهي. فما يظل جالسًا على مقبس عشر دقائق سيقتله وسيط (proxy)، أو مُشغِّل، أو حد لحاوية. |
والموعد النهائي هو أيضًا المكان الصادق لرسالة الخطأ. فـ«لم تصل أي رسالة مطابقة لـ‹تأكيد بريدك الإلكتروني› إلى signup-42@grabmail.io في غضون 90 ثانية» تسمّي العنوان والفلتر والحصة، وهذه ثلاثة من الأشياء الأربعة المطلوبة لمعرفة ما حدث. والرابع — ما وصل فعلاً — يستحق أن يُطبع أيضًا: فسرد الموضوعات التي رأتها الحلقة ورفضتها يحوّل «إنه غير مستقر» إلى «تغيّر سطر الموضوع» في قراءة واحدة.
كم مرة تسأل، ومتى توسّع
الحد الأدنى هو أيًّا كان ما تسمح به الخدمة، وهنا هو طلب واحد في الثانية لكل عنوان. وهذا ليس تثبيطًا — فالاستطلاع مرة كل ثانية هو النمط المقصود أصلاً، ولا حصة يومية ولا حصة شهرية ولا رصيد اندفاع (burst) يجب إدارته — لكنه حد أدنى، وحلقة تسأل مرتين في الثانية نفسها تحصل على 429 على الثانية منهما بدل إجابة أسرع.
- فاصل زمني ثابت
- ثانية واحدة، في كل مرة، حتى الموعد النهائي. مناسب تمامًا لانتظار سينتهي في غضون عشر ثوانٍ، والخيار الافتراضي الصحيح لاختبار واحد على مُشغِّل واحد. وعيبه الوحيد أنه يستمر في السؤال بالمعدل نفسه بعد أن يصبح واضحًا تمامًا أن البريد لن يأتي.
- فاصل زمني يتوسّع
- ثانية واحدة بينما لا تزال الرسالة في طريقها على الأرجح، ثم يتضاعف — اثنتان، أربع، ثماني — بسقف. يكلّف قليلاً من التأخير على رسالة تصل متأخرة، ويوفّر معظم الطلبات في انتظار كان سيفشل على أي حال. وضع له سقفًا: فاصل زمني يتضاعف دون سقف يقضي النصف الأخير من موعد نهائي مدته دقيقتان نائمًا.
- الـjitter: يُضاف ولا يُطرح أبدًا
- بَعِّد بين الجولات بجزء عشوائي حتى يتوقف عشرون مُشغِّلاً عن السؤال في اللحظة نفسها. والوصفة المعتادة — قيمة عشوائية بين صفر والفاصل الزمني — خاطئة هنا، لأن نصف مداها يقع تحت الحد الأدنى البالغ ثانية واحدة. أضف العشوائية فوق الفاصل بدلاً من ذلك: فالفاصل الزمني حد أدنى، وjitter لا يفعل شيئًا سوى تأخير الجولة أكثر.
- وقفة ليس لك أن تختارها
- حين تكون الإجابة
429، يصبح الفاصل الزمني أيًّا كان ما تقولهRetry-After، والجولة التي رُفضت لم تكن محاولة. اعتبرها محاولة وستقضي حلقة مقيَّدة المعدل موعدها النهائي كله في جمع الرفوض دون أن تقرأ صندوق البريد ولو مرة.
خمس عشرة ثانية بمعدل ثانية واحدة، ثم التضاعف إلى سقف ثمانٍ، مع jitter فوق ذلك، يُدخل تقريبًا كل انتظار في هذا الدليل في 6 أسطر:
def delay(attempt: int) -> float:
# One second while the message is probably still in flight, then
# wider. Never below a second: the list endpoint allows one call
# per second, per address, so jitter is added and never taken off.
step = 1.0 if attempt < 15 else min(8.0, 2.0 ** (attempt - 14))
return step + random.uniform(0.0, step / 2)وقد أُزيح الأس حتى يبدأ التوسّع بعد المرحلة الثابتة لا من الجولة الأولى. فمن دون هذا الإزاحة يكون الفاصل الزمني قد بلغ ثماني ثوانٍ فعلاً بحلول وصول بريد تسجيل بطيء، فيستغرق انتظار كان ينبغي أن يأخذ اثنتي عشرة ثانية عشرين ثانية.
لا شيء من هذا ينطبق على الطلب الأول. اسأل فورًا، قبل أي نوم: فرسالة كانت موجودة أصلاً في صندوق البريد حين بدأت الحلقة — وهي الحالة المعتادة لكل ما يُستحث قبل بدء الانتظار — لا ينبغي أن تكلّف ثانية من التأخير لملاحظتها.
قراءة الرفض
كل إجابة من نقطة نهاية القائمة هي JSON، وما لا يكون منها صندوق بريد يتشارك شكلاً واحدًا: رمز error، وهو ثابت وهو ما يجب التفريع عليه، ورسالة message، وهي نص عادي قد تُعاد صياغته في أي وقت. ويحمل الرفض بسبب السرعة الزائدة ترويسة أيضًا:
HTTP/1.1 429 Too Many Requests
Retry-After: 1
Content-Type: application/json; charset=utf-8
{"error":"rate_limited","message":"one request per second, per address"}Retry-After بالثواني الكاملة، وهو الرقم الحقيقي — مأخوذ من المتبقي فعلاً من حصة هذا العنوان، لا من ثابت في الوثائق. والنوم بالضبط تلك المدة هو أكثر ما يمكن فعله لطفًا وأسرعه معًا: فنوم أقصر يُرفض مجددًا، ونوم أطول وقت مُهدر. وفيما يلي كل ما يمكن أن تقابله حلقة استطلاع، وما تطلبه كل إجابة منها فعلاً.
| ما يعود | ماذا يعني | ما ينبغي أن تفعله الحلقة |
|---|---|---|
200 مع count: 0 | صندوق البريد موجود وفارغ. وهذه هي الإجابة الطبيعية لمعظم مدة الانتظار. | استمر في الانتظار. فهذا ليس خطأً، ولن يصبح خطأً أبدًا. |
429 — rate_limited | سريع جدًا: طلب قائمة ثانٍ في غضون ثانية واحدة لهذا العنوان، أو أكثر من 1,200 طلب في الدقيقة من هذا المصدر. | نَم عدد الثواني التي يحددها Retry-After، ثم اسأل مجددًا. ولا تحسب الرفض محاولة. |
404 — unknown_domain | الجزء الذي بعد @ غير مستضاف هنا. خطأ مطبعي في الغالب دائمًا، أو نطاق لم يُوجَّه سجل MX الخاص به إلى هنا قط. | توقف. ما من مقدار من الانتظار يُصلح نطاقًا. اطبع العنوان الذي أُعطيته. |
400 — invalid_address | معطى address غائب، أو أطول من 320 حرفًا، أو ليس على شكل name@domain. | توقف. هذا خلل في المستدعي، وسيكون الخلل نفسه في كل جولة. |
400 — bad_cursor | قيمة before ليست على شكل معرّف رسالة إطلاقًا. أما المعرّف السليم الشكل ولكنه انتهت صلاحيته فليس هذا الخطأ: فهو يجيب بـ200 مع صفحة فارغة. | أوقف التصفح بالصفحات وابدأ مجددًا من الصفحة الأولى. |
404 — not_found، من رسالة واحدة | ذلك المعرّف ليس في ذلك الصندوق — أو كان فيه، ثم انتهت صلاحيته أو حُذف منذ ذلك الحين. | عامِله بصفته زائلاً لا متأخرًا. فمعرّف رأيته في قائمة منذ ثوانٍ لن يعود. |
500 — storage_failed | فشل شيء من جهتنا خلال قراءة صندوق البريد. | اسأل مجددًا، ولكن اترك الموعد النهائي يحكم ولا تسأل أسرع من المعتاد. |
اثنان من تلك السبعة يعنيان التوقف، وهما الاثنان الجديران بأن يُرفع الصوت بشأنهما. فحلقة تعامل unknown_domain كأنه «ليس الآن» تقضي تسعين ثانية كاملة في إثبات شيء أخبرتها به الخدمة في أول أربعين مللي ثانية.
أي رسالة هي رسالتك
صندوق البريد ليس طابورًا، وأحدث ما فيه ليس بالضرورة ما تنتظره. فعلى نطاق عام يستطيع أي شخص يخمّن العنوان أن يرسل إليه؛ وفي مجموعة اختبارات يُعاد استخدام العنوان نفسه غالبًا بين التشغيلات؛ وتسجيل واحد يرسل غالبًا رسالتين — ترحيبًا وتأكيدًا — لا تحمل الرمز منهما إلا واحدة. والحل هو علامة مرجعية، ويجب أخذها قبل الشيء الذي يتسبب في البريد.
- قبل أن تُرسل النموذج، اسرد صندوق البريد بـ
limit=1واحتفظ بمعرّف أحدث رسالة، أو بلا شيء إطلاقًا إن كان فارغًا. ذلك المعرّف هو العلامة المرجعية. - افعل الأمر نفسه — أرسل النموذج، أو استدعِ نقطة النهاية، أو اضغط الزر.
- استطلع القائمة. تعود الرسائل بترتيب الأحدث أولاً، فامشِ من القمة نزولاً وتوقف في اللحظة التي تصل فيها إلى العلامة المرجعية: فكل ما بعدها نزولاً أقدم من إجرائك ويمكن تجاهله دون قراءته.
- رشِّح ما هو أعلاها بحسب المرسِل أو الموضوع أو كليهما. وتكفي عادة سلسلة فرعية، وينبغي أن تكون الجزء الذي لن يُترجم — فاختبار يطابق «Confirm your email» يفشل يوم يُبدَّل حساب الاختبار إلى لغة أخرى.
- ثم، وفقط بعد ذلك، افتحها. يحمل السرد معاينة
previewقصيرة لا المتن، والرمز الذي تبحث عنه غالبًا ما يقع بعد نهايتها. طلب واحد إضافي يحصل على الرسالة كاملة، ويُحتسب على حصة منفصلة أكبر بكثير من حصة القائمة.
في shell، تبدو هاتان القراءتان على هذا الشكل — العلامة المرجعية أولاً، ثم الاستطلاع:
curl -sG https://grabmail.io/api/v1/mailbox \
--data-urlencode "address=signup-42@grabmail.io" --data-urlencode "limit=1"
curl -sG https://grabmail.io/api/v1/mailbox \
--data-urlencode "address=signup-42@grabmail.io" --data-urlencode "limit=25"يسمّي كلا الطلبين العنوان كاملاً، لأن العنوان هنا هو صندوق البريد: لا جلسة، ولا مؤشر محفوظ نيابة عنك، ولا شيء في استدعاء واحد يتذكره الاستدعاء التالي. وهذا أيضًا سبب أمان مراقبة عنوان من مكانين في الوقت نفسه — فالقراءة لا تستهلك شيئًا، فتكون حلقتان على الصندوق نفسه تريان كل رسالة، ولا تستطيع واحدة أن تسحب رسالة من تحت الأخرى.
التصرف مرة واحدة بالضبط
استطلاع تُعاد محاولته قد يرى الرسالة نفسها مرتين، وهذا ليس حدثًا نادرًا: يجيب الخادم، وينقطع الاتصال قبل أن يصلك المتن، فيعيد عميل HTTP الخاص بك المحاولة، وتحمل الإجابة الثانية الرسالة التي حملتها الأولى فعلاً. وإن كان ما تفعله بالرسالة هو النقر على رابط، أو تأكيد دفعة، أو النشر في قناة، ففعل ذلك مرتين خلل له عواقب خارج عمليتك.
- احتفظ بالمعرّفات التي تعاملت معها فعلاً
- مجموعة من المعرّفات في الذاكرة تكفي لانتظار يولد ويموت داخل دالة واحدة. أما ما يجب أن ينجو من إعادة تشغيل — صندوق بريد تُفرِّغه مهمة مجدولة، أو وكيل يعمل خلال تراكم متأخر — فيجب تدوينه في مكان ينجو معه.
- الحذف idempotent
- حذف رسالة يجيب بـ
200في المرة الثانية كما في الأولى، فحذف تُعاد محاولته لا يبدو أبدًا كفشل ولا يحتاج أبدًا حالة خاصة. احذف بعد أن تتصرف لا قبله: فانهيار بين الاثنين يكلّفك عندئذٍ إعادة قراءة، وهذا قابل للتعافي، بدل الرسالة، وهي ليست كذلك. - المعرّف هنا ليس Message-ID الخاص بالمرسِل
- المعرّف في API معرّفنا نحن: نطاقه صندوق بريد واحد، ويتوقف عن الوجود حين تنتهي صلاحية الرسالة. أما ترويسة
Message-IDفهي ترويسة المرسِل، تسافر مع الرسالة، وهي ما تريده إن كنت تطابق البريد نفسه عبر نظامين — دليل الترويسات يقول أين تجدها.
لا شيء من هذا مطلوب لاختبار ينتظر رمزًا واحدًا ثم يرمي صندوق البريد جانبًا. لكن كله مطلوب من اللحظة التي تعمل فيها حلقة دون إشراف، لأن الفشل الذي يمنعه لا يبدو كفشل: بل يبدو كالعمل يُنجَز، مرتين، وبشكل صحيح.
حين يكون الانتظار على الخادم
هناك مكان واحد هنا لا تكتب فيه الحلقة بنفسك، وهو موجود من أجل المستدعين الذين لا يستطيعون تحمّل كتابتها. فوكيل الذكاء الاصطناعي يدفع ثمن كل دورة يقضيها في التحقق، فأداة تجيب بـ«لا شيء بعد» تسع مرات هي تسع دورات من العدم. وأداة wait_for_message في خادم MCP تُبقي الطلب مفتوحًا بدلاً من ذلك، وتستطلع من جهتنا، وتجيب مرة واحدة — إما بالرسالة، أو بجملة صريحة تقول إنها انتظرت ولم يأتِ شيء.
$ curl -sX POST https://grabmail.io/mcp -H "Content-Type: application/json" -d '{
"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"wait_for_message","arguments":{
"address":"signup-42@grabmail.io","subject_contains":"code",
"timeout_seconds":25}}}'أربعة أشياء عنها تستحق أن تُعرف قبل أن تبني عليها.
- تنتظر 25 ثانية على الأكثر
- يستطيع
timeout_secondsأن يطلب أقل، ولا يستطيع أبدًا أن يطلب أكثر. والسقف ليس اعتباطيًا: فكل منتظر عامل لا يفعل شيئًا سوى النوم، وطلب يُترك مفتوحًا لدقائق هو طلب يموت عند مهلة وسيط (proxy) أحدهم قبل أن يعود بوقت طويل. - تُرشِّح عند الدخول
from_containsوsubject_containsوsince_idهي القرارات الثلاثة نفسها في القسم أعلاه، تُتخذ على الخادم.since_idهو العلامة المرجعية، وهو أكثر أهمية هنا من أي مكان آخر: فمن دونه يعود الاستدعاء فورًا بأيًّا كان الجالس فعلاً في صندوق البريد.- انتهاء المهلة إجابة، لا خطأ
- حين لا يصل شيء، تعيد
timed_outمضبوطًا، مع المدة التي انتظرتها فعلاً، وتقول بوضوح إن الاستدعاء مجددًا هو طريقة الاستمرار في الانتظار. واستدعاءان أو ثلاثة انتظار طبيعي لبريد تسجيل: تلك هي الحلقة، وهي ثلاث دورات بدل تسعين. - توجد 8 مكانًا للانتظار، ولا طابور
- حين تكون كلها مشغولة يعود الاستدعاء فورًا ويقول ذلك، بدل الانضمام إلى صف خلف سبعة وكلاء آخرين. وهذا هو الفشل الصحيح: فوكيل قيل له «عدد كبير جدًا من الانتظارات الجارية» يستطيع سرد صندوق البريد والاستمرار، أما وكيل جالس في طابور فلا يملك إلا أن يجلس.
ويظل حد الطلب الواحد للعنوان ساريًا بداخلها — فحلقتنا مقيَّدة بالمعدل تمامًا مثل حلقتك، فانتظار من جهة الخادم ليس طريقة للالتفاف حول الحد الأدنى، بل فقط طريقة للتوقف عن دفع ثمنه بالدورات. ولمجموعة اختبارات لا يستحق شيء من هذا العناء: فالاختبار عملية يُسمح لها بالنوم أصلاً، وحلقة بلغة الاختبار نفسها أسهل بكثير في تتبع خللها من حلقة عن بُعد. دليل MCP يغطي بقية الأدوات.
الحلقة كاملة، مرة واحدة
كل ما سبق، في ملف واحد: موعد نهائي من ساعة رتيبة، وفاصل زمني يتوسّع بـjitter من جهة واحدة، واحترام Retry-After دون حسابه محاولة، وعلامة مرجعية لتحديد ما هو جديد، وفلتر على الموضوع، وطلب إضافي واحد لجلب الرسالة التي لا يعاينها السرد إلا معاينة.
import random
import time
import requests
API = "https://grabmail.io/api/v1"
ADDRESS = "signup-42@grabmail.io"
def delay(attempt: int) -> float:
# One second while the message is probably still in flight, then
# wider. Never below a second: the list endpoint allows one call
# per second, per address, so jitter is added and never taken off.
step = 1.0 if attempt < 15 else min(8.0, 2.0 ** (attempt - 14))
return step + random.uniform(0.0, step / 2)
def watermark(s):
# Read this BEFORE the form is submitted. Every id above it
# afterwards is mail that arrived because of what you did.
r = s.get(f"{API}/mailbox", params={"address": ADDRESS, "limit": 1})
r.raise_for_status()
seen = r.json()["messages"]
return seen[0]["id"] if seen else None
def wait_for(s, subject, since, timeout=120.0):
deadline = time.monotonic() + timeout
attempt = 0
rejected = set()
while time.monotonic() < deadline:
r = s.get(f"{API}/mailbox", params={"address": ADDRESS, "limit": 25})
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "1")))
continue # refused, so it was not an attempt
if r.status_code == 200:
for m in r.json()["messages"]: # newest first
if m["id"] == since:
break # older than the watermark
if subject.lower() in m["subject"].lower():
full = s.get(f"{API}/message/{m['id']}",
params={"mailbox": ADDRESS})
full.raise_for_status()
return full.json()
rejected.add(m["subject"])
elif r.status_code < 500:
raise RuntimeError(r.json().get("error", r.status_code))
# a 5xx falls through: transient, and the deadline still governs
time.sleep(delay(attempt))
attempt += 1
raise TimeoutError(
f"nothing matching {subject!r} at {ADDRESS} in {timeout:.0f}s; "
f"saw {sorted(rejected) or 'nothing at all'}")وهو عمدًا نحو 50 سطرًا من المكتبة القياسية وعميل HTTP واحد. لا شيء يحتاج إلى تثبيت، ولا شيء يحتاج إلى إعداد، ولا سر في أي مكان منه — وهذا هو المقصود: فالشكل نفسه ينتقل دون تغيير إلى Node، أو إلى سكربت shell، أو إلى أيًّا كان ما يستخدمه إطار اختبارك أصلاً لإجراء الطلبات.
- اقرأ العلامة المرجعية قبل الإجراء الذي يتسبب في البريد، لا بعده أبدًا.
- اسأل مرة واحدة فورًا، ثم نَم بعد ذلك فقط. لا تنم أولاً أبدًا.
- خذ الموعد النهائي من ساعة رتيبة، واختبره في أعلى كل جولة.
- حافظ على الفاصل الزمني عند ثانية واحدة لكل عنوان أو أكثر، وأضف jitter تصاعديًا فقط.
- نَم بالضبط ما تقوله
Retry-After، ولا تحسب رفضًا محاولة. - فرِّع على رمز
error: فـunknown_domainوinvalid_addressيعنيان التوقف لا الانتظار. - طابِق على المرسِل أو الموضوع، وتوقف عن السير في القائمة عند الوصول إلى العلامة المرجعية.
- افتح الرسالة قبل أن تحلّلها — يحمل السرد معاينة، لا المتن.
- أفشل بذكر العنوان والفلتر والحصة والموضوعات التي رفضتها الحلقة.
تسع قواعد، وثمانٍ منها موجودة بسبب فشل اضطر أحدهم إلى إعادة بنائه من سجل. والقاعدة التي لا تتعلق بالفشل هي الثانية: فالسؤال مرة قبل أول نوم هو ما يجعل انتظار رسالة وصلت فعلاً يستغرق أربع مللي ثوانٍ بدل ثانية كاملة — وهو عبر مجموعة من مئتي اختبار ثلاث دقائق من الوقت الفعلي لا يحتاج أحد بعد ذلك إلى تفسيرها.
أسئلة
هل تملك GrabMail webhook؟
لا، وهذا ليس نقصًا في انتظار أن يُملأ. تستقبل الخدمة البريد وتعرضه عبر HTTP بلا مفتاح: فلا يوجد حساب خلف عنوان عام يمكن إلحاق callback به، ولا طابور يحتفظ بتسليمة رفضتها نقطة نهايتك. وإن كان مسار عملك حقًا لا يستطيع الاستطلاع، فـصفحة المقارنة تسمّي الخدمات التي تقدّم واحدًا.
كم مرة يُسمح لي باستطلاع عنوان؟
مرة كل ثانية، لكل عنوان — وهذا هو النمط المقصود لا حافته. لا حصة يومية، ولا حصة شهرية، ولا رصيد اندفاع يجب إدارته. عشرون صندوق بريد تُستطلع مرة كل ثانية من مُشغِّل واحد استخدام عادي؛ والسقف الآخر الوحيد هو 1,200 طلب في الدقيقة من مصدر واحد، وهو تحديدًا تلك العشرون وليس واحدًا وعشرين.
لماذا أعادت حلقتي رسالة من تشغيلة اختبار سابقة؟
لأنها أخذت أول عنصر في القائمة دون أن تسأل متى وصل. يحتفظ صندوق البريد بكل ما أُرسل إليه لمدة 5 يومًا، وعنوان أُعيد استخدامه مليء بالتشغيلة الأخيرة. اقرأ أحدث معرّف قبل أن تُحدث البريد وتجاهل كل ما هو من ذلك المعرّف نزولاً — أو احذف محتويات صندوق البريد في بداية الاختبار، وهو طلب واحد لكل رسالة ويزيل الغموض كليًا.
هل صندوق البريد الفارغ هو 404؟
لا. صندوق البريد الفارغ هو 200 مع count: 0 وقائمة فارغة، عمدًا، حتى لا تحتاج حلقة الاستطلاع أبدًا إلى معالجة خاصة لحالة «لا شيء بعد». 404 من نقطة نهاية القائمة يعني أن النطاق غير مستضاف هنا؛ و404 من رسالة واحدة يعني أن ذلك المعرّف ليس في ذلك الصندوق، أو انتهت صلاحيته.
كم ينبغي أن أنتظر بريد تحقق؟
من ستين إلى مئة وعشرين ثانية في اختبار مؤتمت، ومن ثلاثين إلى ستين لشخص ينتظر أمام شاشة. يصل معظم البريد الذي تولّده آلة في ثوانٍ أحادية الرقم؛ والذيل الطويل يخص طابور المرسِل لا عملية التسليم. وإن كان الأمر يقترب بانتظام من موعدك النهائي، فموعد نهائي أطول ليس الجواب — هناك خطأ آخر.
هل تستطيع عمليتان استطلاع العنوان نفسه في الوقت نفسه؟
نعم. القراءة لا تستهلك شيئًا، فترى كل منهما كل رسالة ولا تخفي واحدة عن الأخرى بريدًا. لكنهما تتشاركان حصة الطلب الواحد في الثانية لذلك العنوان، فحلقتان تسألان كل ثانية سترفضان نحو نصف الوقت لكل منهما: أعطِهما ثانيتين لكل واحدة، أو اجعل واحدة تقوم بالاستطلاع وتُسلّم النتائج إلى الأخرى.
هل ينبغي أن أستطلع، أم أستخدم أداة الانتظار عبر MCP؟
استطلع، إن كنت تكتب اختبارًا أو سكربتًا: فعملية يُسمح لها بالنوم ينبغي أن تنام، وحلقة بلغتك أنت أسهل في تتبع خللها من حلقة عن بُعد. استخدم wait_for_message حين يدفع المستدعي ثمن كل دورة لا كل ثانية، وهذا يعني عمليًا وكيل ذكاء اصطناعي. تنتظر حتى 25 ثانية في كل استدعاء، وتُرشِّح على المرسِل والموضوع، وتُعيد انتهاء مهلة صريحًا يمكنك استدعاءها مجددًا ببساطة.


