API & Automatisierung

Temp-Mail-API in Node.js: ein Postfach mit fetch lesen

Eine Datei, nichts über den fetch hinaus, der mit Node 18 mitgeliefert wird, und kein API-Schlüssel: eine Adresse, die Sie erfinden, ein Warten mit einer Deadline, die Nachricht als Objekt. Hier ist das Modul in TypeScript, die reine JavaScript-Version, die Hinweise zu Deno und Bun, das Paginieren, auf die Festplatte gestreamte Anhänge, ein Vitest-Beispiel, und die sechs Arten, wie das beim ersten unbeaufsichtigten Lauf kaputtgeht.

  • Fortgeschritten
  • 19 Min. Lesezeit
Ein grauer sechseckiger Block mit drei oben eingesteckten grauen Kabeln und einem blauen Umschlag, der aus einem Schlitz an der Vorderseite gleitet

Die API, wie JavaScript sie sieht

Auf der Serverseite gibt es nichts zu installieren und nichts, wogegen man sich authentifizieren müsste: Ein Postfach auf einer öffentlichen Domain ist für jeden lesbar, der seine Adresse kennt, über einfaches HTTPS, als JSON. Die gesamte Oberfläche sind drei Aufrufe:

GET /api/v1/mailbox?address=…
Alles, was an einer Adresse wartet, neueste zuerst, als Liste von Zusammenfassungen. Ein leeres Postfach ist 200 mit count: 0 — nie ein 404. limit begrenzt eine Antwort (1–200, Standard 50), und before blättert darüber hinaus.
GET /api/v1/message/{id}?mailbox=…
Eine Nachricht vollständig: Absender, Empfänger, Betreff, Datum, der Klartext-Teil, der HTML-Teil (oder null), und eine Liste von Anhängen mit jeweils einer fertigen URL.
DELETE /api/v1/message/{id}?mailbox=…
Entfernt sie jetzt statt in 5 Tagen. Idempotent: zweimal löschen beantwortet immer noch mit 200.

Die Typen im Modul unten sind genau die Formen der Antworten. Die Auflistung trägt außerdem einen alias: eine zweite Adresse auf einer separaten Domain, die in dasselbe Postfach zustellt und sich nicht zum Lesen daraus verwenden lässt — die, die man einer Website gibt, wenn sie das Postfach lieber nicht öffnen können soll.

Das Modul

Eine Datei, eine Klasse, keine Abhängigkeit. Es läuft auf den globalen Objekten fetch und crypto, die Node seit Version 18 mitliefert, es gibt also nichts, das zu package.json hinzugefügt werden müsste. Es ist absichtlich unspektakulär: eine Deadline-Schleife und die eine Wiederholung, die je richtig ist, einen 429 abwarten.

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

Es zu verwenden sind vier Zeilen. Geben Sie die Adresse aus, verwenden Sie sie überall dort, wo eine Adresse verlangt wird, und warten Sie:

ein erster Lauf
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

Reines JavaScript, Deno und Bun

Das TypeScript oben ist die Referenz; nichts daran ist Node-spezifisch außer dem Anhang-Streaming in einem späteren Abschnitt. Drei Hinweise für die anderen Orte, an denen es läuft:

Reines JavaScript
Entfernen Sie die Typen, und es ist dieselbe Datei. Die kurze Version unten ist alles, was ein Skript normalerweise braucht — eine Adresse und ein Warten.
Deno
Läuft, so wie es ist: fetch und crypto.randomUUID() sind global, und das Skript braucht --allow-net=grabmail.io und sonst nichts. Speichern Sie einen Anhang mit Deno.writeFile(path, new Uint8Array(await res.arrayBuffer())).
Bun
Läuft, so wie es ist, einschließlich des TypeScript. Speichern Sie einen Anhang mit Bun.write(path, res), das die Response direkt entgegennimmt.
grabmail.mjs — die kurze, reine JavaScript-Version
// 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`);
}

Ein ausgelastetes Postfach: Paginierung mit before

Eine Auflistung liefert höchstens 200 Zusammenfassungen zurück. Ein Postfach, das mehr davon empfängt — etwa eine Catch-all-Adresse auf Ihrer eigenen Domain, die einen Tag lang Bounces sammelt —, wird seitenweise gelesen: Übergeben Sie den next-Wert einer Antwort als before-Parameter der folgenden Anfrage, und hören Sie auf, wenn next gleich null ist. Ein async-Generator macht daraus ein for await:

jede Nachricht, egal wie viele Seiten
/** 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);
}

Der Cursor ist die ID der ältesten Nachricht, die Sie bereits haben, sodass eine Seite stabil bleibt, selbst während oben neue Mail ankommt. Ein Postfach aus einem Skript heraus automatisieren geht ausführlicher auf den Cursor ein, zusammen mit Zeitplanung und Aufbewahrung.

Anhänge, auf die Festplatte gestreamt

Jede Nachricht listet ihre Anhänge mit einem Dateinamen, einem angegebenen Typ, einer Größe in Bytes und einer URL auf. Die URL trägt den Parameter ?mailbox= bereits, sie wird also so abgerufen, wie sie ist. Die Antwort ist immer application/octet-stream mit einem Content-Disposition: attachment-Header, egal was der Absender als Dateityp angegeben hat — der echte Typ steht im Feld mime im JSON. Streamen Sie ihn, statt ihn zu puffern; die Obergrenze liegt bei 5 MB pro Nachricht, und ein Skript, das hundert davon speichert, sollte nicht alle im Speicher halten.

jeden Anhang einer Nachricht speichern, gestreamt
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 einem Vitest- oder Jest-Test

Ein neues Inbox innerhalb des Testkörpers gibt jedem Test sein eigenes Postfach, und das ist die mit Abstand wichtigste Eigenschaft eines Mail-Tests: Kein Lauf kann je die Nachricht eines vorherigen Laufs lesen, und parallele Worker können nie gegenseitig ihre lesen. Das Extraktionsmuster ist im Wortlaut der Vorlage verankert statt in „sechs Ziffern“, aus den Gründen, die OTP-Codes in automatisierten Tests darlegt.

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

Das dritte Argument von it ist das Test-Timeout, gesetzt über der sechzig Sekunden langen Mail-Deadline; der Standardwert von fünf Sekunden würde jeden Test beenden, bevor die Mail landen könnte. Für eine browsergesteuerte Version desselben Tests verpackt die Playwright-Anleitung diese Klasse in einer Fixture; beide auf einem CI-Runner laufen zu lassen, fügt eine Regel für ausgehenden Datenverkehr und ein Job-Timeout hinzu, beides in der GitHub-Actions-Anleitung.

Fehler, die beim ersten unbeaufsichtigten Lauf auftauchen

Keiner davon bricht auf einem Laptop. Alle brechen an einem Dienstagabend in einem geplanten Job.

SymptomUrsacheLösung
Besteht jedes Mal, selbst wenn der Absender kaputt istJeden Lauf dieselbe Adresse; der erste Poll findet die Nachricht des letzten Laufs.freshAddress() pro Lauf. Das ist die Regel, auf die es ankommt.
429 im Log, dann ein AbsturzEine Schleife ohne Sleep, oder zwei Skripte, die dieselbe Adresse pollen.Ein Lesevorgang pro Sekunde und Adresse; Retry-After abwarten; eine Adresse pro Skript.
Läuft an einem langsamen Tag in ein Timeout, besteht bei WiederholungEine Anzahl von Versuchen statt einer Deadline, oder eine Deadline, die kürzer ist als die Warteschlange des Absenders.Eine Date.now()-Deadline, sechzig Sekunden für eine transaktionale Mail.
404 von /mailboxDie Domain wird hier nicht gehostet — ein Tippfehler, oder die eigene Domain mit fehlendem MX.Die Adresse prüfen; bei der eigenen Domain prüfen, ob der MX auf smtp.grabmail.io zeigt.
Liest die falsche NachrichtHat die neueste Nachricht genommen, obwohl der Ablauf zwei verschickt hat.Mit subjectContains oder fromContains filtern.
Funktioniert eine Woche, dann 404 bei einer NachrichtEine gespeicherte Nachrichten-ID, älter als 5 Tage.Nichts übersteht 5 Tage. Neu abrufen statt zwischenspeichern.

Bevor Sie es für fertig erklären

  • Eine frische Adresse pro Lauf, pro Test oder pro Agent — nie eine Konstante.
  • Eine Deadline in Echtzeit; ein Lesevorgang pro Sekunde; 429 abgewartet, nie geworfen.
  • Ein Filter auf Betreff oder Absender, wenn ein Ablauf mehr als eine Nachricht verschickt.
  • Der Text-Teil zuerst geparst, mit einem Muster, das im eigenen Wortlaut verankert ist.
  • Anhänge gestreamt, als nicht vertrauenswürdig behandelt, gespeichert unter der Nachrichten-ID.
  • Keine Nachrichten-ID über Tage hinweg zwischengespeichert; nichts hier überlebt 5 Tage.

Das ist der ganze Client. Dasselbe Modul in Python, für requests und httpx, steht in der Python-Anleitung; die Formen von Anfrage und Antwort, mit jedem Statuscode, stehen in der API-Referenz, und es gibt ein OpenAPI-3.1-Dokument für alle, die den Client lieber generieren als schreiben.

Fragen

Brauche ich einen API-Schlüssel oder ein npm-Paket?

Weder noch. Die öffentlichen Domains verlangen weder Schlüssel noch Konto noch Header, und das Modul verwendet nur den fetch, der ab Node 18 mitgeliefert wird. Nur der kostenpflichtige Pool von Domains, der von den Wegwerf-Mail-Sperrlisten ferngehalten wird, verwendet einen Authorization: Bearer-Header, und der Code ist ansonsten identisch dafür.

Funktioniert das im Browser?

Dieselben Aufrufe funktionieren von einer Seite aus, aber ein Browser ist der falsche Ort für eine sechzig Sekunden lange Polling-Schleife, und das Postfach ist ohnehin öffentlich — lesen Sie es vom Server oder vom Test-Runner aus. Wenn Sie eine Webanwendung testen, hält die Playwright-Anleitung das Pollen im Testprozess, wo es hingehört.

Wie viele Postfächer kann ein Prozess gleichzeitig pollen?

Zwanzig, komfortabel: Das Limit pro Adresse ist ein Lesevorgang pro Sekunde, und die Obergrenze pro Client liegt bei 1200 Anfragen pro Minute, das sind zwanzig Adressen, einmal pro Sekunde gepollt. Promise.all über zwanzig waitFor-Aufrufe bleibt innerhalb dessen; darüber hinaus wartet der 429-Zweig, statt zu scheitern.

Kann ich meine eigene Domain von Node aus verwenden?

Ja, ohne Änderung außer der Konstante DOMAIN. Ein MX-Eintrag, der auf smtp.grabmail.io zeigt, und jede Adresse auf der Domain wird zu einem Postfach, das dasselbe Modul liest — die Einrichtung steht hier. Das ist die richtige Antwort, wenn Ihre Anwendung die öffentlichen Wegwerf-Domains ablehnt.

Ist das Postfach privat, während mein Skript es verwendet?

Nein. Wer die Adresse kennt, kann es lesen, auf einer öffentlichen Domain genauso wie auf Ihrer eigenen. Eine zufällige Adresse, die für ein paar Sekunden einen Bestätigungscode enthält, ist unbedenklich; ein Skript, das echte Kunden-Mail dorthin lenkt, ist es nicht.

Gibt es etwas für einen KI-Agenten statt für ein Skript?

Es gibt einen MCP-Server am selben Origin, ohne Schlüssel, dessen Tool wait_for_message den Aufruf offen hält, bis die Mail landet — genau das, was ein Agent braucht, denn jeder Poll kostet ihn Tokens. Ein Postfach, das ein KI-Agent lesen kann behandelt das.

Probieren Sie es aus, solange es frisch ist

Eine Adresse braucht einen Klick, kein Konto und keine Karte. Alles in dieser Anleitung funktioniert damit sofort.

Willkommen zurück

Ihre Postfächer und Ihre Domains an einem Ort.