L’API, vue par JavaScript
Il n’y a rien à installer côté serveur et rien contre quoi s’authentifier : une boîte sur un domaine public est lisible par quiconque connaît son adresse, en simple HTTPS, sous forme de JSON. Toute la surface tient en trois appels :
GET /api/v1/mailbox?address=…- Tout ce qui attend à une adresse, le plus récent en premier, sous forme de liste de résumés. Une boîte vide, c’est
200aveccount: 0— jamais un 404.limitplafonne une réponse (1 à 200, 50 par défaut), etbeforepermet de paginer au-delà. GET /api/v1/message/{id}?mailbox=…- Un message en entier : expéditeur, destinataire, sujet, date, la partie texte brut, la partie HTML (ou
null), et une liste de pièces jointes avec chacune une URL prête à l’emploi. DELETE /api/v1/message/{id}?mailbox=…- Le supprime maintenant plutôt que dans 5 jours. Idempotent : supprimer deux fois répond quand même
200.
Les types du module ci-dessous sont exactement les formes des réponses. Le listage porte aussi un alias : une seconde adresse sur un domaine séparé, qui livre dans la même boîte et ne peut pas servir à la lire — celle à donner à un site quand vous préférez qu’il ne puisse pas ouvrir la boîte.
Le module
Un seul fichier, une seule classe, aucune dépendance. Il repose sur les globales fetch et crypto que Node fournit depuis la version 18, donc il n’y a rien à ajouter à package.json. C’est volontairement ennuyeux : une boucle à échéance et la seule nouvelle tentative qui soit jamais correcte, patienter sur un 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}`);
}
}L’utiliser tient en quatre lignes. Affichez l’adresse, utilisez-la partout où une adresse est demandée, et attendez :
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 nullJavaScript pur, Deno et Bun
Le TypeScript ci-dessus est la référence ; rien dedans n’est spécifique à Node, à l’exception du streaming des pièces jointes dans une section plus loin. Trois remarques pour les autres environnements où ça tourne :
- JavaScript pur
- Retirez les types, et c’est le même fichier. La version courte ci-dessous est tout ce dont un script a généralement besoin — une adresse et une attente.
- Deno
- Fonctionne tel quel :
fetchetcrypto.randomUUID()sont des globales, et le script a besoin de--allow-net=grabmail.ioet de rien d’autre. Enregistrez une pièce jointe avecDeno.writeFile(path, new Uint8Array(await res.arrayBuffer())). - Bun
- Fonctionne tel quel, TypeScript compris. Enregistrez une pièce jointe avec
Bun.write(path, res), qui prend directement leResponse.
// 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`);
}Une boîte chargée : paginer avec before
Un listage renvoie au maximum 200 résumés. Une boîte qui en reçoit davantage — une adresse catch-all sur votre propre domaine qui collecte une journée de bounces, par exemple — se lit page par page : passez la valeur next d’une réponse comme paramètre before de la requête suivante, et arrêtez-vous quand next vaut null. Un générateur asynchrone transforme cela en 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);
}Le curseur est l’id du message le plus ancien que vous avez déjà, donc une page reste stable même si du nouveau courrier arrive en tête. Automatiser une boîte depuis un script détaille davantage le curseur, ainsi que la planification et la rétention.
Pièces jointes diffusées en flux vers le disque
Chaque message liste ses pièces jointes avec un nom de fichier, un type déclaré, une taille en octets et une URL. L’URL porte déjà le paramètre ?mailbox=, donc elle se récupère telle quelle. La réponse est toujours application/octet-stream avec un en-tête Content-Disposition: attachment, quel que soit le type que l’expéditeur a indiqué pour le fichier — le vrai type est le champ mime du JSON. Diffusez-la en flux plutôt que de la mettre en tampon ; le plafond est 5 MB par message, et un script qui en enregistre une centaine ne devrait pas toutes les garder en mémoire.
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}`));
}Dans un test Vitest ou Jest
Un nouvel Inbox à l’intérieur du corps du test donne à chaque test sa propre boîte, ce qui est la propriété la plus importante d’un test de courrier : aucune exécution ne peut jamais lire le message d’une exécution précédente, et les workers parallèles ne peuvent jamais lire le courrier les uns des autres. Le motif d’extraction est ancré sur la formulation du modèle plutôt que sur « six chiffres », pour les raisons qu’expose les codes OTP dans les tests automatisés.
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
});Le troisième argument de it est le timeout du test, réglé au-dessus de l’échéance de courrier de soixante secondes ; le défaut de cinq secondes mettrait fin à chaque test avant que le courrier n’ait pu arriver. Pour une version pilotée par navigateur du même test, le guide Playwright enveloppe cette classe dans une fixture ; exécuter l’un ou l’autre sur un runner de CI ajoute une règle de trafic sortant et un timeout de job, tous deux dans le guide GitHub Actions.
Les erreurs qui apparaissent la première fois que ça tourne sans surveillance
Aucune de ces erreurs ne casse sur un ordinateur portable. Toutes cassent un mardi soir dans un job planifié.
| Symptôme | Cause | Correction |
|---|---|---|
| Réussit à chaque fois, même quand l’expéditeur est cassé | La même adresse à chaque exécution ; la première interrogation trouve le message de l’exécution précédente. | freshAddress() à chaque exécution. C’est celle-ci qui compte. |
Un 429 dans le journal, puis un crash | Une boucle sans pause, ou deux scripts qui interrogent une même adresse. | Une lecture par seconde par adresse ; patienter sur Retry-After ; une adresse par script. |
| Expire les jours lents, réussit à la nouvelle tentative | Un nombre de tentatives au lieu d’une échéance, ou une échéance plus courte que la file de l’expéditeur. | Une échéance sur Date.now(), soixante secondes pour un e-mail transactionnel. |
404 depuis /mailbox | Le domaine n’est pas hébergé ici — une faute de frappe, ou votre propre domaine avec un MX manquant. | Vérifiez l’adresse ; pour votre propre domaine, vérifiez que le MX pointe vers smtp.grabmail.io. |
| Lit le mauvais message | A pris le message le plus récent alors que le flux en a envoyé deux. | Filtrez avec subjectContains ou fromContains. |
Fonctionne pendant une semaine, puis 404 sur un message | Un id de message stocké plus vieux que 5 jours. | Rien ne survit 5 jours. Relisez plutôt que de mettre en cache. |
Avant de considérer que c’est terminé
- Une adresse fraîche par exécution, par test ou par agent — jamais une constante.
- Une échéance en temps réel ; une lecture par seconde ;
429absorbé par une pause, jamais levé comme une exception. - Un filtre sur le sujet ou l’expéditeur quand un flux envoie plus d’un message.
- La partie texte analysée en premier, avec un motif ancré sur votre propre formulation.
- Les pièces jointes diffusées en flux, traitées comme non fiables, enregistrées sous l’id du message.
- Aucun id de message mis en cache d’un jour sur l’autre ; rien ici ne survit 5 jours.
C’est tout le client. Le même module en Python, pour requests et httpx, se trouve dans le guide Python ; la forme des requêtes et des réponses, avec chaque code de statut, est dans la référence de l’API, et il existe un document OpenAPI 3.1 pour quiconque préfère générer le client plutôt que l’écrire.
Questions
Ai-je besoin d’une clé d’API ou d’un paquet npm ?
Ni l’un ni l’autre. Les domaines publics ne demandent ni clé, ni compte, ni en-tête, et le module n’utilise que le fetch livré avec Node 18 et versions ultérieures. Seul le pool payant de domaines tenus à l’écart des listes noires de mail jetable utilise un en-tête Authorization: Bearer, et le code reste par ailleurs identique pour lui.
Est-ce que ça fonctionne dans le navigateur ?
Les mêmes appels fonctionnent depuis une page, mais un navigateur n’est pas le bon endroit pour une boucle d’interrogation de soixante secondes, et la boîte est publique de toute façon — lisez-la depuis le serveur ou depuis le runner de test. Si vous testez une application web, le guide Playwright garde l’interrogation dans le processus de test, là où elle a sa place.
Combien de boîtes un processus peut-il interroger à la fois ?
Vingt, confortablement : la limite par adresse est d’une lecture par seconde, et le plafond par client est de 1200 requêtes par minute, soit vingt adresses interrogées une fois par seconde. Un Promise.all sur vingt appels waitFor reste dans cette limite ; au-delà, la branche 429 patiente plutôt que d’échouer.
Puis-je utiliser mon propre domaine depuis Node ?
Oui, sans autre changement que la constante DOMAIN. Un enregistrement MX pointant vers smtp.grabmail.io, et chaque adresse du domaine devient une boîte que le même module lit — la configuration se trouve ici. C’est la bonne réponse quand votre application refuse les domaines jetables publics.
La boîte est-elle privée pendant que mon script l’utilise ?
Non. Quiconque connaît l’adresse peut la lire, sur un domaine public comme sur le vôtre. Une adresse aléatoire qui contient un code de confirmation pendant quelques secondes ne pose pas de problème ; un script qui y fait pointer du vrai courrier client, si.
Existe-t-il quelque chose pour un agent IA plutôt qu’un script ?
Il existe un serveur MCP à la même origine, sans clé, dont l’outil wait_for_message garde l’appel ouvert jusqu’à ce que le courrier arrive — la forme dont un agent a besoin, puisque chaque interrogation lui coûte des tokens. Une boîte qu’un agent IA peut lire couvre ce sujet.


