The API, as JavaScript sees it
There is nothing to install on the server side and nothing to authenticate against: a mailbox on a public domain is readable by anyone who knows its address, over plain HTTPS, as JSON. The whole surface is three calls:
GET /api/v1/mailbox?address=…- Everything waiting at an address, newest first, as a list of summaries. An empty mailbox is
200withcount: 0— never a 404.limitcaps one response (1–200, default 50) andbeforepages past it. GET /api/v1/message/{id}?mailbox=…- One message in full: sender, recipient, subject, date, the plain-text part, the HTML part (or
null), and a list of attachments with a ready-made URL each. DELETE /api/v1/message/{id}?mailbox=…- Removes it now rather than in 5 days. Idempotent: deleting twice still answers
200.
The types in the module below are the response shapes exactly. The listing also carries an alias: a second address on a separate domain that delivers into the same mailbox and cannot be used to read it — the one to hand to a site when you would rather it could not open the inbox.
The module
One file, one class, no dependency. It runs on the fetch and crypto globals that Node has shipped since version 18, so there is nothing to add to package.json. It is deliberately boring: a deadline loop and the single retry that is ever correct, sleeping out a 429.
// 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}`);
}
}Using it is four lines. Print the address, use it wherever an address is asked for, and wait:
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 nullPlain JavaScript, Deno and Bun
The TypeScript above is the reference; nothing in it is Node-specific except the attachment streaming in a later section. Three notes for the other places it runs:
- Plain JavaScript
- Strip the types and it is the same file. The short version below is the whole of what a script usually needs — an address and a wait.
- Deno
- Runs as it is:
fetchandcrypto.randomUUID()are globals, and the script needs--allow-net=grabmail.ioand nothing else. Save an attachment withDeno.writeFile(path, new Uint8Array(await res.arrayBuffer())). - Bun
- Runs as it is, including the TypeScript. Save an attachment with
Bun.write(path, res), which takes theResponsedirectly.
// 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`);
}A busy mailbox: paging with before
A listing returns at most 200 summaries. A mailbox that receives more than that — a catch-all address on your own domain collecting a day of bounces, say — is read page by page: pass the next value of one response as the before parameter of the following request, and stop when next is null. An async generator makes that a 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);
}The cursor is the id of the oldest message you already have, so a page is stable even while new mail arrives at the top. Automating an inbox from a script goes through the cursor in more detail, along with scheduling and retention.
Attachments streamed to disk
Every message lists its attachments with a filename, a declared type, a size in bytes and a URL. The URL already carries the ?mailbox= parameter, so it is fetched as it is. The response is always application/octet-stream with a Content-Disposition: attachment header, whatever the sender labelled the file — the real type is the mime field in the JSON. Stream it rather than buffering it; the ceiling is 5 MB per message, and a script that saves a hundred of them should not hold them all in memory.
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}`));
}In a Vitest or Jest test
A new Inbox inside the test body gives every test its own mailbox, which is the single most important property of a mail test: no run can ever read a previous run’s message, and parallel workers can never read each other’s. The extraction pattern is anchored on the template’s wording rather than on “six digits”, for the reasons OTP codes in automated tests spells out.
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
});The third argument to it is the test timeout, set above the sixty-second mail deadline; the default of five seconds would end every test before the mail could land. For a browser-driven version of the same test, the Playwright guide wraps this class in a fixture; running either on a CI runner adds an egress rule and a job timeout, both in the GitHub Actions guide.
Mistakes that show up the first time it runs unattended
None of these break on a laptop. All of them break on a Tuesday night in a scheduled job.
| Symptom | Cause | Fix |
|---|---|---|
| Passes every time, even when the sender is broken | The same address every run; the first poll finds last run’s message. | freshAddress() per run. This is the one that matters. |
429 in the log, then a crash | A loop with no sleep, or two scripts polling one address. | One read a second per address; sleep out Retry-After; one address per script. |
| Times out on a slow day, passes on a retry | A retry count instead of a deadline, or a deadline shorter than the sender’s queue. | A Date.now() deadline, sixty seconds for a transactional mail. |
404 from /mailbox | The domain is not hosted here — a typo, or your own domain with a missing MX. | Check the address; for your own domain, check the MX points at smtp.grabmail.io. |
| Reads the wrong message | Took the newest message when the flow sent two. | Filter with subjectContains or fromContains. |
Works for a week, then 404 on a message | A stored message id older than 5 days. | Nothing survives 5 days. Re-fetch rather than cache. |
Before you call it done
- A fresh address per run, per test or per agent — never a constant.
- A wall-clock deadline; one read a second;
429slept out, never thrown. - A filter on subject or sender when a flow sends more than one message.
- The text part parsed first, with a pattern anchored on your own wording.
- Attachments streamed, treated as untrusted, saved under the message id.
- No message id cached across days; nothing here outlives 5 days.
That is the whole client. The same module in Python, for requests and httpx, is in the Python guide; the request and response shapes, with every status code, are in the API reference, and there is an OpenAPI 3.1 document for anyone who would rather generate the client than write it.
Questions
Do I need an API key or an npm package?
Neither. The public domains take no key, no account and no header, and the module uses only the fetch that ships with Node 18 and later. Only the paid pool of domains kept off the disposable-mail blocklists uses an Authorization: Bearer header, and the code is otherwise identical for it.
Does it work in the browser?
The same calls work from a page, but a browser is the wrong place for a sixty-second polling loop and the mailbox is public anyway — read it from the server or from the test runner. If you are testing a web application, the Playwright guide keeps the polling in the test process, where it belongs.
How many mailboxes can one process poll at once?
Twenty, comfortably: the per-address limit is one read a second and the per-client ceiling is 1200 requests a minute, which is twenty addresses polled once a second. Promise.all over twenty waitFor calls stays inside that; past it, the 429 branch sleeps rather than failing.
Can I use my own domain from Node?
Yes, with no change beyond the DOMAIN constant. One MX record pointing at smtp.grabmail.io and every address on the domain becomes a mailbox the same module reads — the setup is here. It is the right answer when your application refuses the public disposable domains.
Is the mailbox private while my script uses it?
No. Anyone who knows the address can read it, on a public domain and on your own. A random address that holds one confirmation code for a few seconds is fine; a script that points real customer mail at one is not.
Is there something for an AI agent rather than a script?
There is an MCP server at the same origin, with no key, whose wait_for_message tool holds the call open until the mail lands — the shape an agent needs, since every poll costs it tokens. An inbox an AI agent can read covers it.


