API・自動化

Node.jsの捨てメアドAPI:fetchで受信箱を読む

1つのファイルだけで、Node 18に標準搭載のfetch以外は何も使わず、APIキーも不要です。自分で作るアドレス、期限付きの待機、オブジェクトとして返ってくるメッセージ。ここでは、TypeScript版のモジュール、プレーンJavaScript版、DenoとBunに関する注意点、ページング、ディスクへストリーミングする添付ファイル、Vitestの例、そして無人稼働させた最初の日に壊れる6つのパターンを紹介します。

  • 中級
  • 読了19分
上部に灰色のケーブルが3本刺さった灰色の六角形ブロックの前面スロットから、青い封筒が滑り出している

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以降で標準搭載しているfetchcryptoのグローバルの上で動くため、package.jsonに追加するものは何もありません。意図的に、面白みのない作りにしてあります。期限付きのループと、唯一正しいリトライ — 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}`);
  }
}

使い方はたった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
そのまま動きます。fetchcrypto.randomUUID()はグローバルであり、スクリプトに必要なのは--allow-net=grabmail.ioだけです。添付ファイルはDeno.writeFile(path, new Uint8Array(await res.arrayBuffer()))で保存してください。
Bun
TypeScriptを含めて、そのまま動きます。添付ファイルはBun.write(path, res)で保存できます。これはResponseを直接受け取ります。
grabmail.mjs — 短縮版のプレーンJavaScript
// 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パラメーターとして渡し、nextnullになったら止めてください。非同期ジェネレーターを使えば、それは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コードで詳しく説明しています。

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

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エージェントが読める受信箱で扱っています。

新しいうちに試してみてください

アドレスの取得はワンクリックで、アカウントもカードも不要です。このガイドの内容はすべて、そのアドレスですぐに試せます。

おかえりなさい

受信箱とドメインを、ひとつの場所に。