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

الأتمتة وAPI

API بريد مؤقت في Node.js: قراءة الصندوق عبر fetch

ملف واحد، بلا شيء أبعد من fetch التي يأتي بها Node 18 نفسه، وبلا مفتاح API: عنوان تبتكره، وانتظار بمهلة زمنية، ورسالة على شكل كائن (object). وإليك الوحدة (module) بـTypeScript، والنسخة بـJavaScript عادي، وملاحظات Deno وBun، والتصفح بالصفحات، والمرفقات المُبثَّة إلى القرص، ومثال بـVitest، والطرق الست التي يتعطل بها هذا أول مرة يعمل فيها دون مراقبة.

  • متوسط
  • 19 دقيقةً للقراءة
كتلة سداسية رمادية موصولة من أعلاها بثلاثة كابلات رمادية، ومظروف أزرق ينزلق خارجًا من فتحة في واجهتها

الـAPI، كما تراه JavaScript

لا شيء يجب تثبيته على جانب الخادم، ولا شيء يجب المصادقة (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.

والأنواع (types) في الوحدة أدناه هي أشكال الاستجابة بالضبط. ويحمل السرد أيضًا alias: عنوان ثانٍ على نطاق منفصل يسلِّم إلى الصندوق نفسه ولا يمكن استخدامه لقراءته — وهو ما تُعطيه لموقع حين تفضِّل ألا يستطيع فتح صندوق الوارد.

الوحدة (module)

ملف واحد، وصنف (class) واحد، وبلا تبعية. تعمل على المتغيرات العامة (globals) fetch وcrypto التي يأتي بها Node منذ الإصدار 18، فلا شيء يجب إضافته إلى package.json. وهي مُمِلة عمدًا: حلقة بمهلة زمنية، وإعادة المحاولة الوحيدة الصحيحة على الإطلاق، الانتظار عند 429.

src/grabmail.ts
// grabmail.ts — a disposable inbox from Node 18+, Deno or Bun. No dependency, no key.
const API = 'https://grabmail.io/api/v1';
const DOMAIN = 'grabmail.io';

export type Summary = {
  id: string; from: string; subject: string; date: string;
  seen: boolean; attachments: number; expires_at: string;
};
export type Attachment = { filename: string; mime: string; size: number; url: string };
export type Message = {
  id: string; from: string; to: string; subject: string; date: string;
  text: string | null; html: string | null; attachments: Attachment[];
};
type Listing = { address: string; alias: string | null; count: number; next: string | null; messages: Summary[] };
type WaitOpts = { timeoutMs?: number; subjectContains?: string; fromContains?: string };

const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));

/** A mailbox nothing else is using. Nothing has to be created first. */
export function freshAddress(prefix = 'node'): string {
  return `${prefix}-${crypto.randomUUID().slice(0, 8)}@${DOMAIN}`;
}

/** One GET, with the only retry that is ever right: waiting out a 429. */
async function get(url: string): Promise<Response> {
  for (;;) {
    const res = await fetch(url);
    if (res.status === 429) {
      await sleep(Number(res.headers.get('retry-after') ?? 1) * 1000);
      continue;
    }
    if (!res.ok) throw new Error(`${url} answered ${res.status}`);
    return res;
  }
}

export class Inbox {
  constructor(readonly address: string = freshAddress()) {}

  async list(limit = 50, before?: string): Promise<Listing> {
    const q = new URLSearchParams({ address: this.address, limit: String(limit) });
    if (before) q.set('before', before);
    return (await get(`${API}/mailbox?${q}`)).json();
  }

  /** Block until a matching message arrives, then return it in full. */
  async waitFor(opts: WaitOpts = {}): Promise<Message> {
    const deadline = Date.now() + (opts.timeoutMs ?? 60_000);
    while (Date.now() < deadline) {
      const { messages } = await this.list();
      const hit = messages.find(m =>
        (!opts.subjectContains || m.subject.toLowerCase().includes(opts.subjectContains.toLowerCase())) &&
        (!opts.fromContains    || m.from.toLowerCase().includes(opts.fromContains.toLowerCase())));
      if (hit) return this.read(hit.id);
      await sleep(1000);                              // one read a second, never throttled
    }
    throw new Error(`no message for ${this.address} within the deadline`);
  }

  async read(id: string): Promise<Message> {
    return (await get(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`)).json();
  }

  /** Optional and idempotent: everything expires on its own. */
  async delete(id: string): Promise<void> {
    await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`, { method: 'DELETE' });
  }

  /** The bytes of one attachment. Its URL already carries ?mailbox=. */
  async download(a: Attachment): Promise<Response> {
    return get(`https://grabmail.io${a.url}`);
  }
}

استخدامها أربعة أسطر. اطبع العنوان، واستخدمه أينما طُلب عنوان، وانتظر:

تشغيلة أولى
import { Inbox } from './grabmail';

const inbox = new Inbox();
console.log('sign up with:', inbox.address);

const message = await inbox.waitFor({ subjectContains: 'code' });
console.log(message.subject);
console.log(message.text);      // the plain-text part; message.html is the HTML part or null

JavaScript عادي، وDeno، وBun

كود TypeScript أعلاه هو المرجع؛ ولا شيء فيه خاص بـNode باستثناء بث المرفقات في قسم لاحق. وإليك ثلاث ملاحظات للأماكن الأخرى التي يعمل فيها:

JavaScript عادي
أزل الأنواع (types) ويصبح الملف نفسه. والنسخة القصيرة أدناه هي كل ما يحتاجه سكربت عادةً — عنوان وانتظار.
Deno
يعمل كما هو: فـfetch وcrypto.randomUUID() متغيرات عامة، ويحتاج السكربت إلى --allow-net=grabmail.io ولا شيء غيره. احفظ مرفقًا بـDeno.writeFile(path, new Uint8Array(await res.arrayBuffer())).
Bun
يعمل كما هو، بما في ذلك TypeScript. احفظ مرفقًا بـBun.write(path, res)، التي تأخذ Response مباشرة.
grabmail.mjs — النسخة القصيرة بـJavaScript عادي
// grabmail.mjs — plain JavaScript, Node 18+: the same class without the types.
const API = 'https://grabmail.io/api/v1';
const sleep = ms => new Promise(r => setTimeout(r, ms));

export const freshAddress = (prefix = 'node') => `${prefix}-${crypto.randomUUID().slice(0, 8)}@grabmail.io`;

export async function waitFor(address, { timeoutMs = 60_000, subjectContains } = {}) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(address)}`);
    if (res.status === 429) { await sleep(Number(res.headers.get('retry-after') ?? 1) * 1000); continue; }
    if (!res.ok) throw new Error(`GET /mailbox answered ${res.status}`);
    const { messages } = await res.json();
    const hit = messages.find(m => !subjectContains || m.subject.toLowerCase().includes(subjectContains.toLowerCase()));
    if (hit) return (await fetch(`${API}/message/${hit.id}?mailbox=${encodeURIComponent(address)}`)).json();
    await sleep(1000);
  }
  throw new Error(`no message for ${address} within ${timeoutMs} ms`);
}

صندوق بريد مزدحم: التصفح بـbefore

يُعيد السرد 200 ملخص كحد أقصى. وصندوق بريد يستقبل أكثر من ذلك — عنوان catch-all على نطاقك الخاص يجمع يومًا من الرسائل المرتدة، مثلاً — يُقرأ صفحة صفحة: مرِّر قيمة next من استجابة إلى معامل before في الطلب التالي، وتوقف حين تكون next هي null. ومولِّد غير متزامن (async generator) يجعل ذلك for await:

كل رسالة، مهما بلغ عدد الصفحات
/** Every summary in the mailbox, newest first, however many pages it takes. */
export async function* allMessages(inbox: Inbox): AsyncGenerator<Summary> {
  let before: string | undefined;
  for (;;) {
    const page = await inbox.list(200, before);
    yield* page.messages;
    if (!page.next) return;
    before = page.next;
  }
}

for await (const m of allMessages(inbox)) {
  console.log(m.date, m.from, m.subject, 'expires', m.expires_at);
}

المؤشِّر (cursor) هو معرِّف أقدم رسالة لديك بالفعل، فتظل الصفحة ثابتة حتى مع وصول بريد جديد إلى الأعلى. ويستعرض دليل أتمتة صندوق الوارد من سكربت المؤشِّر بتفصيل أكبر، مع الجدولة ومدة الاحتفاظ.

المرفقات المُبثَّة إلى القرص

تسرد كل رسالة مرفقاتها باسم ملف، ونوع مُعلَن، وحجم بالبايت، وURL. ويحمل الـURL معامل ?mailbox= بالفعل، فيُجلَب كما هو. والاستجابة دائمًا application/octet-stream مع ترويسة Content-Disposition: attachment، أيًا كان ما وسم به المرسِل الملف — والنوع الحقيقي هو حقل mime في JSON. ابثَّه بدل تخزينه في ذاكرة مؤقتة (buffering)؛ والسقف 5 MB لكل رسالة، ولا ينبغي لسكربت يحفظ مئة منها أن يبقيها كلها في الذاكرة.

حفظ كل مرفقات رسالة، بالبث
import { createWriteStream } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';

const message = await inbox.waitFor({ subjectContains: 'invoice' });
await mkdir(`downloads/${message.id}`, { recursive: true });

for (const a of message.attachments) {
  console.log(a.filename, a.mime, a.size, 'bytes');
  const res = await inbox.download(a);
  await pipeline(Readable.fromWeb(res.body as any), createWriteStream(`downloads/${message.id}/${a.filename}`));
}

في اختبار Vitest أو Jest

Inbox جديد داخل متن الاختبار يمنح كل اختبار صندوق بريده الخاص، وهذه أهم خاصية على الإطلاق لاختبار بريدي: لا يمكن لأي تشغيلة أبدًا قراءة رسالة تشغيلة سابقة، ولا تستطيع العمليات (workers) المتوازية أبدًا قراءة بريد بعضها بعضًا. ونمط الاستخراج مثبَّت على صياغة القالب لا على «ست خانات»، للأسباب التي يشرحها دليل رموز OTP في الاختبارات الآلية.

tests/signup.test.ts
import { describe, it, expect } from 'vitest';
import { Inbox } from '../src/grabmail';
import { app } from '../src/app';               // whatever starts your server in-process

const CODE = /code is\D{0,12}(\d{6})/i;            // anchored on YOUR template's wording

describe('sign-up', () => {
  it('emails a code that confirms the account', async () => {
    const inbox = new Inbox();                     // a brand-new mailbox for this test only

    await app.request('/signup', { method: 'POST', body: JSON.stringify({ email: inbox.address, password: 'hunter2hunter2' }) });

    const message = await inbox.waitFor({ subjectContains: 'confirm' });
    const code = `${message.text ?? ''} ${message.html ?? ''}`.match(CODE)?.[1];
    expect(code).toBeDefined();

    const res = await app.request('/confirm', { method: 'POST', body: JSON.stringify({ email: inbox.address, code }) });
    expect(res.status).toBe(200);
  }, 120_000);                                     // above the 60 s mail deadline
});

الوسيط الثالث لـit هو مهلة الاختبار، مضبوطة فوق مهلة البريد البالغة ستين ثانية؛ إذ إن الافتراضي البالغ خمس ثوانٍ سينهي كل اختبار قبل أن يتمكن البريد من الوصول. ولنسخة من الاختبار نفسه مدفوعة بالمتصفح، يُغلِّف دليل Playwright هذا الصنف (class) في fixture؛ وتشغيل أي منهما على مُشغِّل CI يضيف قاعدة اتصال صادر ومهلة للمهمة، كلاهما في دليل GitHub Actions.

أخطاء تظهر أول مرة يعمل فيها دون مراقبة

لا شيء من هذه يتعطل على حاسوب محمول. وكلها تتعطل ليلة ثلاثاء داخل مهمة مجدولة.

العرضالسببالحل
ينجح في كل مرة، حتى حين يكون المرسِل معطَّلاًالعنوان نفسه في كل تشغيلة؛ فأول استطلاع يجد رسالة التشغيلة الأخيرة.استدعِ freshAddress() في كل تشغيلة. هذا هو الأمر المهم.
429 في السجل، ثم انهيارحلقة بلا انتظار، أو سكربتان يستطلعان عنوانًا واحدًا.قراءة واحدة في الثانية لكل عنوان؛ وانتظر حسب Retry-After؛ وعنوان واحد لكل سكربت.
تنتهي مهلته في يوم بطيء، وينجح عند إعادة المحاولةعدد محاولات بدل مهلة زمنية، أو مهلة أقصر من طابور المرسِل.مهلة بـDate.now()، ستون ثانية لبريد معاملاتي.
404 من /mailboxالنطاق غير مستضاف هنا — خطأ إملائي، أو نطاقك الخاص بلا سجل MX.تحقق من العنوان؛ ولنطاقك الخاص، تحقق من أن MX يشير إلى smtp.grabmail.io.
يقرأ الرسالة الخطأأخذ أحدث رسالة بينما أرسل المسار رسالتين.رشِّح بـsubjectContains أو fromContains.
يعمل لمدة أسبوع، ثم 404 على رسالةمعرِّف رسالة مُخزَّن أقدم من 5 يومًا.لا شيء يصمد 5 يومًا. أعِد الجلب بدل التخزين المؤقت (cache).

قبل أن تعتبره منتهيًا

  • عنوان جديد لكل تشغيلة، أو اختبار، أو وكيل — لا ثابت أبدًا.
  • مهلة زمنية فعلية؛ وقراءة واحدة في الثانية؛ و429 يُنتظَر، لا يُطلَق (throw) كخطأ أبدًا.
  • مرشِّح على الموضوع أو المرسِل حين يرسل مسار أكثر من رسالة.
  • الجزء النصي مُحلَّل أولاً، بنمط مثبَّت على صياغتك أنت.
  • المرفقات مُبثَّة، ومُعامَلة كغير موثوقة، ومحفوظة تحت معرِّف الرسالة.
  • لا معرِّف رسالة مُخزَّن مؤقتًا عبر الأيام؛ لا شيء هنا يعيش أطول من 5 يومًا.

هذا هو العميل كاملاً. والوحدة نفسها بـPython، لـrequests وhttpx، موجودة في دليل Python؛ وأشكال الطلب والاستجابة، بكل رمز حالة، موجودة في مرجع API، وهناك وثيقة OpenAPI 3.1 لمن يفضِّل توليد العميل بدل كتابته.

أسئلة

هل أحتاج إلى مفتاح API أو حزمة npm؟

لا هذا ولا ذاك. فالنطاقات العامة لا تحتاج مفتاحًا، ولا حسابًا، ولا ترويسة، والوحدة لا تستخدم إلا fetch التي يأتي بها Node 18 وما بعده. ولا تستخدم ترويسة Authorization: Bearer إلا مجموعة النطاقات المدفوعة التي تبقى بعيدة عن قوائم حظر البريد المؤقت، والكود مطابق لها في كل ما عدا ذلك.

هل يعمل في المتصفح؟

تعمل النداءات نفسها من صفحة، لكن المتصفح مكان خطأ لحلقة استطلاع مدتها ستون ثانية، وصندوق البريد عام على أي حال — اقرأه من الخادم أو من مُشغِّل الاختبار. وإن كنت تختبر تطبيق ويب، فدليل Playwright يُبقي الاستطلاع في عملية الاختبار، حيث ينتمي.

كم صندوق بريد تستطيع عملية (process) واحدة استطلاعه في آن واحد؟

عشرون، بأريحية: فالحد لكل عنوان قراءة واحدة في الثانية، وسقف كل عميل 1200 طلب في الدقيقة، أي عشرون عنوانًا تُستطلَع كل واحد مرة في الثانية. ويبقى Promise.all على عشرين نداء waitFor ضمن ذلك؛ وبعده، ينتظر فرع 429 بدل أن يفشل.

هل يمكنني استخدام نطاقي الخاص من Node؟

نعم، دون أي تغيير عدا ثابت DOMAIN. سجل MX واحد يشير إلى smtp.grabmail.io، ويصبح كل عنوان على النطاق صندوق بريد تقرؤه الوحدة نفسها — الإعداد هنا. وهذا هو الحل الصحيح حين يرفض تطبيقك النطاقات المؤقتة العامة.

هل صندوق البريد خاص أثناء استخدام سكربتي له؟

لا. يستطيع أي شخص يعرف العنوان قراءته، سواء على نطاق عام أو على نطاقك الخاص. وعنوان عشوائي يحمل رمز تحقق واحدًا لبضع ثوانٍ أمر لا بأس به؛ أما سكربت يوجِّه بريد عملاء حقيقيين إلى واحد فليس كذلك.

هل يوجد شيء لوكيل ذكاء اصطناعي بدلاً من سكربت؟

يوجد خادم MCP على المصدر نفسه، بلا مفتاح، تُبقي أداته wait_for_message النداء مفتوحًا حتى يصل البريد — وهذا هو الشكل الذي يحتاجه الوكيل، لأن كل استطلاع يكلِّفه tokens. ويغطي ذلك دليل صندوق وارد يستطيع وكيل ذكاء اصطناعي قراءته.

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

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

أهلًا بعودتك

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