API & tự động hóa

API email ảo trong Node.js: đọc hộp thư bằng fetch

Một tệp duy nhất, không cần gì ngoài fetch vốn đã có sẵn từ Node 18, và không cần API key: một địa chỉ do bạn tự nghĩ ra, một lượt chờ có hạn chót, và thư được trả về dưới dạng một object. Đây là module bằng TypeScript, phiên bản JavaScript thuần, các lưu ý cho Deno và Bun, phân trang, tệp đính kèm được stream xuống ổ đĩa, một ví dụ với Vitest, và sáu cách khiến việc này gãy ngay lần đầu tiên nó chạy mà không có ai giám sát.

  • Trung cấp
  • 19 phút đọc
Một khối lục giác xám có ba dây cáp xám cắm trên đỉnh và một phong bì xanh đang trượt ra từ khe ở mặt trước

API, dưới góc nhìn của JavaScript

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.

Các type trong module bên dưới chính xác là cấu trúc của các response này. Lượt liệt kê cũng mang theo một alias: 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ư.

Module

Một tệp, một class, không phụ thuộc gì cả. Nó chạy dựa trên các global fetchcrypto mà Node đã có sẵn từ phiên bản 18, nên không có gì cần thêm vào package.json. Nó được thiết kế nhàm chán một cách có chủ đích: 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.

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}`);
  }
}

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
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 thuần, Deno và Bun

Đoạn TypeScript ở trên là bản tham chiếu chuẩn; không có gì trong đó đặc thù riêng cho Node cả, ngoại trừ phần stream tệp đính kèm ở mục sau. Ba lưu ý cho những nơi khác mà nó có thể chạy:

JavaScript thuần
Bỏ hết các type đi thì đó vẫn là cùng một tệp. Phiên bản rút gọn bên dưới là toàn bộ những gì một script thường cần đến — một địa chỉ và một lượt chờ.
Deno
Chạy nguyên trạng: fetchcrypto.randomUUID() đều là global, và script chỉ cần --allow-net=grabmail.io, không cần gì khác. Lưu một tệp đính kèm bằng Deno.writeFile(path, new Uint8Array(await res.arrayBuffer())).
Bun
Chạy nguyên trạng, kể cả phần TypeScript. Lưu một tệp đính kèm bằng Bun.write(path, res), hàm này nhận thẳng đối tượng Response.
grabmail.mjs — phiên bản JavaScript thuần rút gọn
// 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`);
}

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ột async generator sẽ biến việc đó thành một vòng for await:

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

Tệp đính kèm được stream 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. Hãy stream tệp đó thay vì buffer toàn bộ; mức trần là 5 MB cho mỗi thư, và một script lưu cả trăm tệp như vậy không nên giữ hết tất cả trong bộ nhớ.

lưu mọi tệp đính kèm của một thư, theo kiểu stream
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}`));
}

Trong một bài kiểm thử Vitest hoặc Jest

Một Inbox mới được tạo ngay trong phần thân bài kiểm thử 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 đó, và các worker song song cũng không bao giờ đọc nhầm thư của nhau. 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õ.

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
});

Tham số thứ ba của it chính là timeout của bài kiểm thử, được đặt cao hơn hạn chót chờ thư sáu mươi giây; giá trị mặc định năm giây sẽ kết thúc mọi bài kiểm thử trước khi thư kịp đến. Với phiên bản chạy qua trình duyệt của cùng bài kiểm thử này, hướng dẫn Playwright bọc class này trong một fixture; chạy bất kỳ phiên bản nào trên một CI runner cũng cần thêm một quy tắc egress và một timeout cho job, cả hai đều nằm 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 job chạy theo lịch.

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.freshAddress() 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.Một hạn chót dựa trên Date.now(), 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 subjectContains hoặc fromContains.
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 theo đồng hồ thực; một lần đọc mỗi giây; 429 được chờ qua, không bao giờ bị throw 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 stream, được coi là 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 Python, cho requests và httpx, nằm trong hướng dẫn Python; 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 hay một gói npm nào không?

Không cần cái nào cả. 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, và module này chỉ dùng fetch vốn đã có sẵn từ Node 18 trở lên. 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 vẫn giống hệt khi áp dụng cho nó.

Nó có hoạt động trong trình duyệt không?

Cùng những lệnh gọi đó vẫn hoạt động được từ một trang, nhưng trình duyệt là nơi không phù hợp cho một vòng lặp thăm dò sáu mươi giây, và dù sao thì hộp thư cũng là công khai — hãy đọc nó từ phía máy chủ hoặc từ test runner. Nếu bạn đang kiểm thử một ứng dụng web, hướng dẫn Playwright giữ việc thăm dò trong tiến trình kiểm thử, đúng nơi nó nên thuộc về.

Một tiến trình 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. Một Promise.all bọc quanh hai mươi lệnh gọi waitFor vẫn nằm trong giới hạn đó; vượt quá nó, nhánh xử lý 429 sẽ chờ 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ừ Node không?

Được, mà không cần đổi gì 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.