JavaScriptからみたAPI
サーバー側にインストールするものはなく、認証すべき相手もありません。公開ドメイン上のメールボックスは、そのアドレスを知っている人なら誰でも、ごく普通のHTTPS経由でJSONとして読み取れます。全体のインターフェースは、3つの呼び出しだけです。
GET /api/v1/mailbox?address=…- あるアドレスで待っているものすべてを、新しい順の要約リストとして返します。空のメールボックスは
count: 0を伴う200であり、404になることは決してありません。limitは1回のレスポンスの上限を決め(1〜200、デフォルト50)、beforeでそれ以降のページに進みます。 GET /api/v1/message/{id}?mailbox=…- 1通のメッセージの全体です。送信者、受信者、件名、日付、プレーンテキストの部分、HTMLの部分(なければ
null)、そしてそれぞれにURLが用意された添付ファイルの一覧が含まれます。 DELETE /api/v1/message/{id}?mailbox=…- 5日後を待たずに、今すぐ削除します。冪等であり、2回削除しても
200が返ります。
下のモジュールにある型は、レスポンスの形式をそのまま表しています。一覧にはaliasも含まれます。これは、別のドメイン上にある第2のアドレスで、同じメールボックスへ配信はしますが、そこから読み取ることはできません — 受信箱を開かれたくないサイトに渡すためのものです。
モジュール
1つのファイル、1つのクラス、依存パッケージなし。Nodeがバージョン18以降で標準搭載しているfetchとcryptoのグローバルの上で動くため、package.jsonに追加するものは何もありません。意図的に、面白みのない作りにしてあります。期限付きのループと、唯一正しいリトライ — 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}`);
}
}使い方はたった4行です。アドレスを表示し、アドレスが求められる場所ならどこでもそれを使い、そして待ちます。
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、DenoとBun
上のTypeScriptがリファレンスです。後のセクションにある添付ファイルのストリーミングを除けば、Node固有のものは何もありません。他の環境で動かす場合の注意点が3つあります。
- プレーンJavaScript
- 型を取り除けば、まったく同じファイルです。下にある短縮版が、たいていのスクリプトに必要なもののすべてです — アドレスと待機です。
- Deno
- そのまま動きます。
fetchとcrypto.randomUUID()はグローバルであり、スクリプトに必要なのは--allow-net=grabmail.ioだけです。添付ファイルはDeno.writeFile(path, new Uint8Array(await res.arrayBuffer()))で保存してください。 - Bun
- TypeScriptを含めて、そのまま動きます。添付ファイルは
Bun.write(path, res)で保存できます。これはResponseを直接受け取ります。
// 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`);
}混雑したメールボックス: beforeによるページング
一覧は最大200件の要約を返します。それを超えるメールボックス — たとえば、独自ドメイン上のキャッチオールアドレスが1日分のバウンスメールを集めている場合など — は、ページ単位で読みます。あるレスポンスのnextの値を、次のリクエストのbeforeパラメーターとして渡し、nextがnullになったら止めてください。非同期ジェネレーターを使えば、それは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);
}カーソルは、すでに手元にある中で最も古いメッセージのidです。そのため、新しいメールが上に届いている最中でも、ページは安定しています。スクリプトから受信箱を自動化するでは、カーソルについて、スケジューリングや保存期間とあわせてより詳しく扱っています。
ディスクへストリーミングする添付ファイル
すべてのメッセージには、ファイル名、申告された種類、バイト単位のサイズ、そしてURLとともに添付ファイルの一覧が含まれます。URLにはすでに?mailbox=パラメーターが付いているため、そのまま取得できます。レスポンスは、送信元がファイルに何とラベル付けしていようと、常にContent-Disposition: attachmentヘッダー付きのapplication/octet-streamです — 本当の種類は、JSON内のmimeフィールドです。バッファに溜め込まず、ストリーミングしてください。上限はメッセージあたり5 MBであり、100件保存するスクリプトが、それをすべてメモリ上に保持すべきではありません。
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}`));
}VitestまたはJestのテストで
テスト本体の中で新しいInboxを作ることで、すべてのテストに専用のメールボックスが与えられます。これは、メールのテストにおいて最も重要な性質です。どの実行も、それ以前の実行のメッセージを読むことは決してなく、並列に動くワーカー同士が互いのメールを読んでしまうこともありません。抽出パターンは、「6桁の数字」ではなく、テンプレートの文言に固定します。その理由は自動テストにおけるOTPコードで詳しく説明しています。
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
});itの第3引数はテストのタイムアウトで、60秒のメール期限より長く設定します。デフォルトの5秒では、メールが届く前にすべてのテストが終わってしまいます。同じテストのブラウザ駆動版については、Playwrightガイドで、このクラスをフィクスチャにラップしています。どちらをCIランナー上で実行する場合も、アウトバウンド通信のルールとジョブのタイムアウトが追加で必要になり、どちらもGitHub Actionsガイドにあります。
無人で初めて動かしたときに表面化する間違い
どれもノートPC上では問題になりません。すべて、ある火曜の夜、スケジュール実行されるジョブの中で壊れます。
| 症状 | 原因 | 対処法 |
|---|---|---|
| 送信元が壊れていても、毎回成功してしまう | 毎回同じアドレスを使っていて、最初のポーリングが前回実行時のメッセージを見つけてしまう。 | 実行ごとにfreshAddress()を使う。これが最も重要です。 |
ログに429が出て、その後クラッシュする | スリープのないループ、または2つのスクリプトが1つのアドレスをポーリングしている。 | アドレスごとに1秒に1回の読み取り。Retry-Afterの分だけスリープする。スクリプトごとに1アドレス。 |
| 遅い日にはタイムアウトし、リトライすると通る | 期限ではなく試行回数を使っている、または期限が送信元のキューより短い。 | Date.now()による期限を使い、トランザクションメールには60秒を設定する。 |
/mailboxから404 | そのドメインはここではホストされていない — タイプミス、または独自ドメインのMXレコードが未設定。 | アドレスを確認する。独自ドメインの場合は、MXがsmtp.grabmail.ioを指しているか確認する。 |
| 間違ったメッセージを読んでしまう | フローが2通送っているのに、最新のメッセージを採用してしまった。 | subjectContainsまたはfromContainsでフィルタする。 |
1週間は動くが、その後メッセージが404になる | 5日を超えて保存されたメッセージID。 | 5日を超えて残るものはない。キャッシュせず、取得し直す。 |
完了と呼ぶ前に
- 実行ごと、テストごと、エージェントごとに新しいアドレスを使う — 定数は使わない。
- 実時間の期限を使う。1秒に1回の読み取り。
429は例外を投げず、スリープで処理する。 - フローが複数のメッセージを送る場合は、件名か送信者でフィルタする。
- textの部分を優先してパースし、パターンは自分の文言に固定する。
- 添付ファイルはストリーミングし、信頼できないものとして扱い、メッセージIDの下に保存する。
- メッセージIDを日をまたいでキャッシュしない。ここでは5日を超えて残るものはない。
クライアントとしては、これがすべてです。requestsとhttpx向けにPythonで書いた同じモジュールはPythonガイドにあり、すべてのステータスコードを含むリクエストとレスポンスの形式はAPIリファレンスにあります。また、クライアントを書くよりも生成したいという方にはOpenAPI 3.1のドキュメントも用意されています。
質問
APIキーやnpmパッケージは必要ですか?
どちらも不要です。公開ドメインはキーもアカウントもヘッダーも不要で、このモジュールが使っているのはNode 18以降に標準搭載のfetchだけです。使い捨てメールのブロックリストに載らない有料ドメインプールだけがAuthorization: Bearerヘッダーを使いますが、それ以外のコードはまったく同じです。
ブラウザでも動きますか?
同じ呼び出しはページからでも動きますが、60秒のポーリングループを置く場所としてブラウザは適切ではなく、どのみちメールボックスは公開されています — サーバー側か、テストランナー側で読んでください。Webアプリケーションをテストしているのであれば、Playwrightガイドでは、ポーリングをテストプロセス側に留めています。本来あるべき場所です。
1つのプロセスは、一度に何個のメールボックスをポーリングできますか?
20個であれば余裕です。アドレスごとの上限は1秒に1回の読み取りで、クライアントごとの上限は1分間に1200リクエストであり、これは1秒に1回ポーリングするアドレス20個分にあたります。20個のwaitFor呼び出しに対するPromise.allはその範囲に収まり、それを超えると429の分岐が、失敗するのではなくスリープします。
Nodeから独自ドメインを使えますか?
使えます。変更が必要なのはDOMAIN定数だけです。smtp.grabmail.ioを指すMXレコード1件で、そのドメイン上のすべてのアドレスが、同じモジュールで読み取れるメールボックスになります — 設定方法はこちらです。アプリケーションが公開の使い捨てドメインを拒否する場合の、正しい答えです。
スクリプトが使っている間、メールボックスはプライベートですか?
いいえ。公開ドメインでも独自ドメインでも、アドレスを知っている人なら誰でも読めます。数秒間だけ1つの確認コードを保持するランダムなアドレスであれば問題ありませんが、実際の顧客宛てメールをそちらに向けるスクリプトには向いていません。
スクリプトではなくAIエージェント向けのものはありますか?
同じオリジンにMCPサーバーがあり、キーは不要で、そのwait_for_messageツールはメールが届くまで呼び出しを保持してくれます — ポーリング1回ごとにトークンを消費するエージェントにとって、必要な形です。AIエージェントが読める受信箱で扱っています。


