テスト・CI

自動テストでメールのOTPコードを安定して取り出す

ワンタイムコードは、年号や価格、注文番号、電話番号も含まれているメッセージの中にある、6桁の数字です。正しい6桁を取り出すこと — 毎回、どのテストランナーでも — には、ちょっとした流儀が要ります。パターンを固定し、マークアップを取り除き、すでに見たメッセージは無視し、有効期限を尊重する。これがその流儀であり、シェル、Python、TypeScript向けにそのまま使えるコードを添えて解説します。

  • 中級
  • 読了14分
開いた青い封筒の前に、青い点が付いた小さな灰色のブロックが6つ並び、灰色の虫眼鏡がかざされている

コードは実際にはどこにあるのか

確認メールには、コードが存在しうる場所が最大3か所あり、どこを読むべきかによって、その先のすべてが決まります。APIから返されるメッセージのJSONは、その3つすべてを一度に返します。subjecttext(プレーンテキストの部分、なければnull)、そしてhtml(HTMLの部分、なければnull)です。

場所実際の見え方読み取り方
プレーンテキストの部分(text)Your code is 481920. It expires in 10 minutes.存在する場合は、まずこちらをパースしてください。マークアップがなく、デコードするものもなく、文言も安定しています。
HTMLの部分(html)同じ文がテーブルの中に入っており、数字が1桁ずつセルにスタイル付けされていることが多く、&はすべてエンティティとして書かれています。タグを空白に置き換え、エンティティをデコードし、空白をまとめ、それからパターンを適用してください。生のHTMLに正規表現をかけては絶対にいけません。
件名481920 is your verification code送信元がこれをしてくれるなら儲けものです。本文をパースする必要が一切ありません。件名でマッチさせ、だめなら本文にフォールバックしてください。
画像まさにこの種のスクリプトを無力化するために、コードが画像として描かれています。まれなケースであり、送信元が自動化されたくないというサインです。それが自分のテンプレートであれば変更してください。そうでなければ、まっとうな回避策はありません。

優先すべきはプレーンテキストの部分であり、たいていのテンプレートエンジンはHTMLから自動的にこれを生成するため、通常は存在します。それがnullの場合、本文はHTMLの部分だけになり、次の2つのセクションでは、それを安全に読み取る方法を扱います。

パターンを自分の文言に固定する

直感的には\d{6}を使いたくなります。これはコードにマッチしますが、フッターの年号、住所欄の郵便番号、電話番号の下6桁、コードの2行上にある注文番号にもマッチしてしまいます。先に出てきたものが勝ち、テストはそれを何の疑いもなくフォームに入力します。

パターン他にマッチするもの評価
\d{6}年号、郵便番号、区切りのない価格、注文番号、電話番号、追跡番号。絶対にだめです。これはパターンではなく、コイントスです。
\b\d{6}\b上の例のうち、両側に空白があってたまたま6桁ちょうどになっているもの全部です — それでもほとんどが該当します。ほとんど変わりません。単語境界は、何がコードかを知りません。
code is\D{0,12}(\d{6})自分のテンプレートがコードの前に置く言葉の直後に続く6桁だけにマッチします。コロンや空白、タグの名残の空白の分の余地も見ています。はい、これが正解です。コードだけにマッチし、それ以外には一切マッチしません。誰かがメールの文言を変えた日には失敗しますが、それはむしろ知りたい失敗です。

\D{0,12}が実務上のポイントです。タグを空白に置き換えたあと、言葉と数字の間には、コロンや連続した空白、あるいはかつて間にあった<strong>の名残が挟まることがあります。非数字文字を最大12個まで許容しておけば、それらすべてをカバーしつつ、パターンが別の数字まで飛んでしまうこともありません。

数字を分割するテンプレート

よくあるデザインでは、コードの各桁を個別のボックスに入れ、スマートフォンでも読みやすくしています。HTML上ではこれは6つのテーブルセル、あるいは6つの<span>になり、ソースのどこを見ても、その数字が6文字連続で現れることはありません。

HTMLの部分に実際に入っている内容
<p>Your code is</p>
<table><tr>
  <td class="digit">4</td><td class="digit">8</td><td class="digit">1</td>
  <td class="digit">9</td><td class="digit">2</td><td class="digit">0</td>
</tr></table>

生のHTMLに対する正規表現では何も見つかりません。解決策はより賢い正規表現ではなく、決まった手順でHTMLを先にテキストへ変換することです。

  1. すべてのタグを空白に置き換える。「何もなし」ではなく空白にします — <td>4</td><td>8</td>は、後続の何かとくっついた48ではなく、4 8にならなければなりません。
  2. エンティティをデコードする。&amp;&nbsp;&#39;。2つの数字の間にあるノーブレークスペースは、デコードするまで正規表現にとって空白ではありません。
  3. 空白をまとめ、数字の間に空白が入ることを許容してマッチする。ボックス型のデザインでは、code is\D{0,12}(\d)\s*(\d)\s*(\d)\s*(\d)\s*(\d)\s*(\d)を使い、キャプチャグループを連結してください。通常のテンプレートであれば、前のセクションの単純なパターンで十分です。

下にあるヘルパーは、手順1と2をあなたの代わりに行い、両方の部分を一度に検索します。そのため、テストは今月のテンプレートがどちらのデザインを使っているかを知る必要がありません。

最新のメッセージが、常に正しいとは限らない

ここでは、メールボックスの一覧はすべて新しい順に返ってきます。最初に書くコードの多くはmessages[0]を読みます。次の3つの状況では、それは間違ったメッセージになります。

1つの操作から2通のメッセージ
サインアップは、ウェルカムメールとコードメールの2通を送ります。順番は、送信元のキューがどう消化されるか次第です。半分のケースでは、ウェルカムメールの方が新しくなります。何かを取得する前に、件名か送信者でフィルタしてください。
再送信
テストがコードを2回リクエストしました — 1回目は間違って、2回目は意図的に — そしてサーバーは最新のものしか受け付けません。古いメッセージはまだメールボックスに残っており、パターンにも一致し、6桁の数字としてパースできてしまいますが、それはすでに無効です。
以前のテスト実行
アドレスが使い回された場合にだけ起こりますが、それは絶対にすべきではありません。実行ごとに新しいアドレスを使えば、このケースはそもそも起こり得ません。それができない場合は、以下のスナップショットの方法が代替策になります。

堅牢なやり方は、どのテストランナーでも同じです。メールを発生させるにメールボックスの中身を確認しておき、それまで存在しておらず、かつ期待する件名に一致するメッセージだけを受け入れます。

再送信の前には存在しなかったメッセージを待つ
// Remember what is already there, THEN trigger the resend, THEN wait for something new.
const before = new Set((await listMailbox(address)).messages.map(m => m.id));

await page.getByRole('button', { name: 'Resend code' }).click();

const fresh = await waitFor(address, m => !before.has(m.id) && /code/i.test(m.subject));

実行中に期限切れになるコード

ほとんどのワンタイムコードは5〜15分間有効です。これは、テストスイートが20個のスペックをキューに入れ、それぞれが最初にコードをリクエストして最後に入力する、という状況になるまでは十分に思えます。コードを有効なまま保つには、次の3つのルールがあります。

  • コードのリクエストはできるだけ遅らせる。他のテストがキューに並んでいる間に動くセットアップ手順ではなく、待機の直前に送信をトリガーしてください。
  • 待機の期限は、コードの有効期間より十分短くする。10分間有効なコードに対して60秒の期限であれば、入力するための9分間が残ります。10分の期限では何も残りません。
  • コードを他のテストのために保存しない。コードは有効期間が短いだけでなく、使い切りでもあります。1つのコードを配る共有フィクスチャは、2つのテストが1つの数字を奪い合うレースになります。

そのまま使える抽出コード

同じ4行を3つの言語で書いたものです。両方の部分を結合し、タグを空白に置き換え、エンティティをデコードし、空白をまとめ、それから固定したパターンを適用します。パターンを自分のテンプレートの文言に合わせて変更すれば、他には何も触る必要がありません。

シェルから、jqを使って

shell
curl -sG "https://grabmail.io/api/v1/message/$ID" --data-urlencode "mailbox=$ADDR" \
| jq -r '[.text, .html] | map(select(. != null)) | join(" ") | gsub("<[^>]*>"; " ")' \
| grep -oiE 'code is[^0-9]{0,12}[0-9]{6}' | grep -oE '[0-9]{6}' | head -1

Python

extract.py
import html
import re

TAGS = re.compile(r"<[^>]+>")
CODE = re.compile(r"code is\D{0,12}(\d{6})", re.I)      # anchored on YOUR template's wording


def text_of(message: dict) -> str:
    """Both parts as plain text: tags out, entities decoded, whitespace folded."""
    raw = f"{message.get('text') or ''}\n{message.get('html') or ''}"
    return re.sub(r"\s+", " ", html.unescape(TAGS.sub(" ", raw)))


def code_from(message: dict, pattern: re.Pattern = CODE) -> str:
    hit = pattern.search(text_of(message))
    if not hit:
        raise AssertionError(f"no code in message {message['id']!r} ({message['subject']!r})")
    return hit.group(1)

TypeScript

extract.ts
export type Message = { id: string; subject: string; text: string | null; html: string | null };

const TAGS = /<[^>]+>/g;
const ENTITIES: Record<string, string> = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&nbsp;': ' ' };

/** Both parts as plain text: tags out, the common entities decoded, whitespace folded. */
export const textOf = (m: Message): string =>
  `${m.text ?? ''}\n${m.html ?? ''}`
    .replace(TAGS, ' ')
    .replace(/&(amp|lt|gt|quot|#39|nbsp);/g, e => ENTITIES[e])
    .replace(/\s+/g, ' ');

/** Anchored on your own wording. A reworded template fails loudly. */
export function codeFrom(m: Message, pattern = /code is\D{0,12}(\d{6})/i): string {
  const hit = textOf(m).match(pattern);
  if (!hit) throw new Error(`no code in message ${m.id} ("${m.subject}")`);
  return hit[1];
}

これらが読み取るメッセージのJSONはGET /api/v1/message/{id}から取得したもので、APIリファレンスに記載されています。そもそもそのidを手に入れるための待機についてはエンドツーエンドテストガイドにあり、PlaywrightCypressPythonNode.js向けの、すぐ使えるヘルパーもあります。

完了と呼ぶ前に

  • パターンは自分のテンプレートの文言に固定し、テンプレートのそばに置く。
  • 両方の部分を、テキストとして検索する。タグは空白に、エンティティはデコード、空白はまとめる。
  • 件名か送信者でフィルタし、ウェルカムメールがコードメールより優先されてしまわないようにする。
  • 再送信の前にスナップショットを取り、その後は新しいメッセージだけを受け入れる。
  • 待機の期限はコードの有効期間より十分短くし、送信は待機の直前にトリガーする。
  • 失敗メッセージには、探していたメッセージIDと件名を明記する。

これで、6桁の抽出コードが間違った数字で通ってしまう、あらゆるパターンをカバーしています。同じメールを読むAIエージェントも同じ問題を抱えていますが、使える手段はひとつ少なくなります。だからこそMCPサーバーは、推測ではなくメッセージ全体をそのまま渡します — AIエージェントが読める受信箱で、その内容を詳しく扱っています。

質問

textの部分とhtmlの部分、どちらを読むべきですか?

存在するならtextの部分です。安定していて、デコードするものもありません。とはいえ、ヘルパーがそうしているように両方を検索してください。そうすればHTMLしか送ってこないテンプレートでも動きますし、textしか送ってこないテンプレートが空のHTMLでつまずくこともありません。

コードに文字も含まれています。パターンは変わりますか?

変わるのは文字クラスだけです。([A-Z0-9]{6})など、送信元が使っている文字種に合わせつつ、その前の文言への固定はそのままにします。大文字・小文字が保証されていない場合はiフラグを追加し、そのクラスが、固定文言の後に続く英単語にまでマッチしてしまわないよう注意してください。

コードの代わりにマジックリンクを使う場合はどうですか?

流儀は同じで、パターンが違うだけです。URLは、「最初のリンク」ではなく、/confirm//auth/magic/のように自分が知っているパスの断片でマッチさせてください。トランザクションメールにはたいてい5つのリンクが含まれていて、欲しいものが最初に来ることはめったにありません。アクセスする前に&amp;をデコードしてください。

メッセージはどのくらいの期間、読める状態にありますか?

届いてから5日間、読んだかどうかにかかわらずです。これはどんなコードの有効期間よりもはるかに長いため、テストが読み取りを急ぐ必要は一切なく、急ぐべきは入力だけです。

メールボックスをポーリングせずにコードを取得できますか?

RESTでは不可能です。1秒に1回、期限付きでポーリングする必要があり、これがドキュメントに定められたペースであり、これより遅くする必要はありません。MCPではwait_for_messageというツールがあり、メッセージが届くまで呼び出しを保持してくれます。これはAIエージェントが必要とする形そのものです。

APIキーは必要ですか?

いいえ。公開ドメインはキーもアカウントもヘッダーも不要です。使い捨てメールのブロックリストに載らない有料ドメインプールだけがベアラートークンを使いますが、抽出コード自体はどちらでも同じです。

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

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

おかえりなさい

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