API & otomatisasi

API email sementara di Node.js: baca kotak masuk via fetch

Satu file, tidak lebih dari fetch yang sudah ada bawaan di Node 18, dan tanpa kunci API: sebuah alamat yang Anda buat sendiri, penantian dengan batas waktu, pesannya sebagai sebuah object. Berikut modulnya dalam TypeScript, versi plain JavaScript-nya, catatan untuk Deno dan Bun, paging, lampiran yang di-stream ke disk, sebuah contoh Vitest, dan enam cara ini bisa rusak pertama kali berjalan tanpa pengawasan.

  • Menengah
  • 19 menit baca
Balok heksagonal abu-abu dengan tiga kabel abu-abu tertancap di atasnya dan amplop biru meluncur keluar dari celah di depannya

API-nya, sebagaimana dilihat JavaScript

Tidak ada yang perlu diinstal di sisi server dan tidak ada yang perlu diautentikasi: sebuah kotak surat pada domain publik bisa dibaca siapa pun yang tahu alamatnya, lewat HTTPS biasa, sebagai JSON. Keseluruhan permukaannya adalah tiga panggilan:

GET /api/v1/mailbox?address=…
Semua yang menunggu di sebuah alamat, terbaru lebih dulu, sebagai sebuah daftar ringkasan. Kotak surat kosong adalah 200 dengan count: 0 — bukan pernah 404. limit membatasi satu response (1–200, default 50) dan before melakukan paging melewatinya.
GET /api/v1/message/{id}?mailbox=…
Satu pesan secara lengkap: pengirim, penerima, subjek, tanggal, bagian teks polos, bagian HTML (atau null), dan sebuah daftar lampiran yang masing-masing sudah punya URL siap pakai.
DELETE /api/v1/message/{id}?mailbox=…
Menghapusnya sekarang, bukan menunggu 5 hari. Idempoten: menghapus dua kali tetap menjawab 200.

Tipe-tipe di dalam modul di bawah ini persis mengikuti bentuk response-nya. Listing-nya juga membawa sebuah alias: alamat kedua pada domain terpisah yang mengantarkan surat ke kotak surat yang sama dan tidak bisa dipakai untuk membacanya — inilah yang diberikan ke sebuah situs kalau Anda tidak ingin situs itu bisa membuka kotak masuknya.

Modulnya

Satu file, satu class, tanpa dependency. Ini berjalan di atas global fetch dan crypto yang sudah ada bawaan di Node sejak versi 18, jadi tidak ada apa pun yang perlu ditambahkan ke package.json. Ini sengaja dibuat membosankan: sebuah loop batas waktu dan satu-satunya retry yang pernah benar, sleep saat menerima 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}`);
  }
}

Memakainya cukup empat baris. Cetak alamatnya, pakai di mana pun sebuah alamat diminta, lalu tunggu:

percobaan pertama
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

Plain JavaScript, Deno, dan Bun

TypeScript di atas adalah acuannya; tidak ada satu pun di dalamnya yang spesifik untuk Node kecuali streaming lampiran di bagian selanjutnya. Tiga catatan untuk tempat-tempat lain ia bisa berjalan:

Plain JavaScript
Hilangkan tipe-tipenya dan itu jadi file yang sama persis. Versi singkat di bawah ini adalah keseluruhan yang biasanya dibutuhkan sebuah skrip — sebuah alamat dan sebuah penantian.
Deno
Berjalan apa adanya: fetch dan crypto.randomUUID() adalah global, dan skrip ini hanya butuh --allow-net=grabmail.io dan tidak ada yang lain. Simpan sebuah lampiran dengan Deno.writeFile(path, new Uint8Array(await res.arrayBuffer())).
Bun
Berjalan apa adanya, termasuk TypeScript-nya. Simpan sebuah lampiran dengan Bun.write(path, res), yang langsung menerima Response-nya.
grabmail.mjs — versi plain JavaScript yang singkat
// 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`);
}

Kotak surat yang sibuk: paging dengan before

Sebuah listing mengembalikan paling banyak 200 ringkasan. Kotak surat yang menerima lebih dari itu — misalnya sebuah alamat catch-all pada domain Anda sendiri yang menampung bounce selama sehari penuh — dibaca halaman demi halaman: sertakan nilai next dari satu response sebagai parameter before pada request berikutnya, dan berhenti saat next bernilai null. Sebuah async generator mengubah itu menjadi sebuah for await:

semua pesan, berapa pun banyaknya halaman
/** 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-nya adalah id dari pesan tertua yang sudah Anda punya, sehingga satu halaman tetap stabil meski ada email baru yang tiba di bagian atas. Mengotomasi kotak masuk lewat skrip membahas cursor ini lebih detail, beserta penjadwalan dan retensi.

Lampiran yang di-stream ke disk

Setiap pesan mendaftar lampirannya dengan sebuah nama file, tipe yang dideklarasikan, ukuran dalam byte, dan sebuah URL. URL-nya sudah membawa parameter ?mailbox=, jadi tinggal diambil apa adanya. Response-nya selalu application/octet-stream dengan header Content-Disposition: attachment, apa pun label yang diberikan pengirim pada filenya — tipe yang sebenarnya ada di field mime dalam JSON-nya. Stream-kan, jangan di-buffer; batas atasnya adalah 5 MB per pesan, dan sebuah skrip yang menyimpan seratus lampiran sekaligus tidak boleh menahan semuanya di memori.

menyimpan setiap lampiran dari sebuah pesan, dengan streaming
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}`));
}

Di dalam pengujian Vitest atau Jest

Sebuah Inbox baru di dalam badan pengujian memberi setiap pengujian kotak suratnya sendiri, dan itulah satu sifat paling penting dari sebuah pengujian email: tidak ada run yang bisa membaca pesan dari run sebelumnya, dan worker paralel tidak akan pernah bisa membaca milik satu sama lain. Pola ekstraksinya dipatok pada kata-kata template, bukan pada “enam digit”, untuk alasan-alasan yang dijelaskan di kode OTP dalam pengujian otomatis.

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

Argumen ketiga pada it adalah timeout pengujiannya, diatur di atas batas waktu email yang enam puluh detik; default-nya yang lima detik akan menghentikan setiap pengujian sebelum emailnya sempat tiba. Untuk versi yang digerakkan browser dari pengujian yang sama, panduan Playwright membungkus class ini di dalam sebuah fixture; menjalankan salah satunya pada runner CI menambahkan sebuah aturan egress dan sebuah timeout job, keduanya ada di panduan GitHub Actions.

Kesalahan yang muncul pertama kali berjalan tanpa pengawasan

Tidak satu pun dari ini rusak di laptop. Semuanya rusak pada suatu malam Selasa di dalam sebuah job terjadwal.

GejalaPenyebabPerbaikan
Lolos setiap saat, bahkan saat pengirimnya rusakAlamat yang sama di setiap run; polling pertama menemukan pesan dari run sebelumnya.freshAddress() per run. Inilah yang benar-benar penting.
429 di dalam log, lalu crashLoop tanpa sleep, atau dua skrip yang melakukan polling ke satu alamat yang sama.Satu kali baca per detik per alamat; sleep sesuai Retry-After; satu alamat per skrip.
Timeout di hari yang lambat, lolos saat diulangHitungan retry alih-alih batas waktu, atau batas waktu yang lebih singkat daripada antrean pengirimnya.Batas waktu Date.now(), enam puluh detik untuk email transaksional.
404 dari /mailboxDomainnya tidak di-hosting di sini — salah ketik, atau domain Anda sendiri yang belum punya record MX.Periksa alamatnya; untuk domain Anda sendiri, periksa apakah MX-nya menunjuk ke smtp.grabmail.io.
Membaca pesan yang salahMengambil pesan terbaru padahal alurnya mengirim dua pesan.Lakukan filter dengan subjectContains atau fromContains.
Berfungsi selama seminggu, lalu 404 pada sebuah pesanSebuah id pesan yang tersimpan sudah lebih tua dari 5 hari.Tidak ada yang bertahan melewati 5 hari. Ambil ulang, jangan di-cache.

Sebelum Anda menganggapnya selesai

  • Alamat baru per run, per pengujian, atau per agen — jangan pernah konstanta.
  • Batas waktu jam dinding; satu kali baca per detik; 429 ditangani dengan sleep, jangan pernah dilempar sebagai error.
  • Filter pada subjek atau pengirim saat sebuah alur mengirim lebih dari satu pesan.
  • Bagian teksnya di-parse duluan, dengan pola yang dipatok pada kata-kata Anda sendiri.
  • Lampiran di-stream, diperlakukan sebagai tidak tepercaya, disimpan di bawah id pesannya.
  • Tidak ada id pesan yang di-cache melewati hari; tidak ada apa pun di sini yang bertahan lebih lama dari 5 hari.

Itulah keseluruhan client-nya. Modul yang sama dalam Python, untuk requests dan httpx, ada di panduan Python; bentuk request dan response-nya, lengkap dengan setiap kode status, ada di referensi API, dan ada sebuah dokumen OpenAPI 3.1 untuk siapa pun yang lebih suka men-generate client-nya daripada menulisnya sendiri.

Pertanyaan

Apakah saya butuh kunci API atau sebuah package npm?

Tidak keduanya. Domain publiknya tidak butuh kunci, tidak butuh akun, dan tidak butuh header apa pun, dan modulnya hanya memakai fetch yang sudah ada bawaan sejak Node 18 ke atas. Hanya kumpulan domain berbayar yang dijaga agar tidak masuk daftar blokir email sekali pakai yang memakai header Authorization: Bearer, dan selain itu kodenya identik saja.

Apakah ini berfungsi di browser?

Panggilan yang sama berfungsi dari sebuah halaman, tapi browser adalah tempat yang salah untuk sebuah loop polling enam puluh detik dan lagipula kotak suratnya bersifat publik — baca dari server atau dari test runner-nya. Jika Anda menguji sebuah aplikasi web, panduan Playwright menjaga polling-nya tetap berada di dalam proses pengujian, tempat yang seharusnya.

Berapa banyak kotak surat yang bisa di-polling satu proses sekaligus?

Dua puluh, dengan nyaman: batas per alamat adalah satu kali baca per detik dan batas atas per klien adalah 1200 request per menit, yaitu dua puluh alamat yang di-polling sekali per detik. Promise.all atas dua puluh panggilan waitFor masih berada di dalam batas itu; melewatinya, cabang 429 akan sleep, bukan gagal.

Bisakah saya memakai domain saya sendiri dari Node?

Bisa, tanpa perubahan apa pun selain konstanta DOMAIN. Satu record MX yang menunjuk ke smtp.grabmail.io dan setiap alamat pada domain itu menjadi kotak surat yang bisa dibaca modul yang sama — pengaturannya ada di sini. Ini jawaban yang tepat saat aplikasi Anda menolak domain sekali pakai publik.

Apakah kotak suratnya bersifat privat selama dipakai skrip saya?

Tidak. Siapa pun yang tahu alamatnya bisa membacanya, baik di domain publik maupun di domain Anda sendiri. Alamat acak yang menyimpan satu kode konfirmasi selama beberapa detik itu baik-baik saja; skrip yang mengarahkan email pelanggan sungguhan ke sana, itu tidak baik.

Apakah ada sesuatu untuk agen AI, bukan skrip?

Ada sebuah server MCP di origin yang sama, tanpa kunci, dengan tool wait_for_message yang menjaga panggilannya tetap terbuka sampai emailnya tiba — bentuk yang dibutuhkan seorang agen, karena setiap polling membebankan token baginya. Kotak masuk yang bisa dibaca agen AI membahas itu.

Coba selagi masih segar

Alamat hanya perlu satu klik, tanpa akun dan tanpa kartu. Semua yang ada di panduan ini langsung berfungsi dengannya.

Selamat datang kembali

Kotak surat dan domain Anda, di satu tempat.