Tests & CI

OTP-Codes aus E-Mails in automatisierten Tests, stabil

Ein Einmalcode sind sechs Ziffern innerhalb einer Nachricht, die auch eine Jahreszahl, einen Preis, eine Bestellnummer und eine Telefonnummer enthält. Die richtigen sechs herauszuholen — jedes Mal, in jedem Runner — ist eine kleine Disziplin: das Muster verankern, das Markup entfernen, die Nachricht ignorieren, die man schon gesehen hat, den Ablauf respektieren. Genau das ist diese Disziplin, mit fertigem Code für die Shell, Python und TypeScript.

  • Fortgeschritten
  • 14 Min. Lesezeit
Ein offener blauer Umschlag mit sechs kleinen grauen Blöcken in einer Reihe davor, jeder mit einem blauen Punkt, unter einer grauen Lupe

Wo der Code tatsächlich steht

Eine Bestätigungsmail hat bis zu drei Stellen, an denen der Code stehen kann, und welche davon Sie lesen sollten, entscheidet alles Weitere. Das Nachrichten-JSON von der API liefert alle drei auf einmal: subject, text (den Klartext-Teil, oder null) und html (den HTML-Teil, oder null).

WoWie es aussiehtWie man es liest
Der Klartext-Teil (text)Your code is 481920. It expires in 10 minutes.Parsen Sie das zuerst, wenn es existiert. Kein Markup, nichts zu dekodieren, und der Wortlaut ist stabil.
Der HTML-Teil (html)Derselbe Satz innerhalb einer Tabelle, oft mit den Ziffern gestylt, eine pro Zelle, und jedes & als Entity geschrieben.Tags zu Leerzeichen machen, Entities dekodieren, Whitespace falten, dann das Muster anwenden. Nie Regex auf rohes HTML anwenden.
Die Betreffzeile481920 is your verification codeEin Geschenk, wenn der Absender das macht: gar kein Body zu parsen. Matchen Sie im Betreff und weichen Sie auf den Body aus.
Ein BildDer Code als Bild gezeichnet, um genau diese Art von Skript auszuhebeln.Selten, und ein Zeichen, dass der Absender keine Automatisierung möchte. Ändern Sie die Vorlage des Absenders, wenn sie Ihnen gehört; gibt es die nicht, gibt es dafür keinen ehrlichen Workaround.

Der Klartext-Teil ist der, den man bevorzugen sollte, und er ist derjenige, den die meisten Templating-Systeme automatisch aus dem HTML erzeugen — er ist also meistens vorhanden. Ist er null, ist der HTML-Teil der einzige Body, und die nächsten beiden Abschnitte handeln davon, wie man ihn sicher liest.

Das Muster im eigenen Wortlaut verankern

Der erste Instinkt ist \d{6}. Das passt auf den Code, und es passt genauso auf die Jahreszahl im Footer, die Postleitzahl im Adressblock, die letzten sechs Ziffern einer Telefonnummer und die Bestellnummer, die zwei Zeilen über dem Code steht. Was auch immer zuerst kommt, gewinnt, und der Test tippt es mit vollem Vertrauen ins Formular.

MusterPasst auch aufUrteil
\d{6}Jahreszahlen, Postleitzahlen, Preise ohne Trennzeichen, Bestellnummern, Telefonnummern, Sendungsnummern.Niemals. Das ist kein Muster, das ist ein Münzwurf.
\b\d{6}\bAlles oben Genannte, das zufällig genau sechs Ziffern mit einem Leerzeichen auf beiden Seiten hat — immer noch das meiste davon.Kaum besser. Wortgrenzen wissen nicht, was ein Code ist.
code is\D{0,12}(\d{6})Nur die sechs Ziffern, die auf die Worte folgen, die Ihre Vorlage vor den Code setzt, mit Raum für einen Doppelpunkt, ein Leerzeichen oder so viel übrig gebliebenes Whitespace, wie ein Tag hinterlässt.Ja. Es passt auf den Code und sonst nichts, und es schlägt an dem Tag fehl, an dem jemand die E-Mail umformuliert — und das ist ein Fehlschlag, von dem Sie erfahren wollen.

Das \D{0,12} ist das praktische Detail: Nachdem Tags durch Leerzeichen ersetzt wurden, können die Worte und die Ziffern durch einen Doppelpunkt, eine Folge von Leerzeichen oder die Überreste eines <strong>, das früher dazwischen saß, getrennt sein. Bis zu ein Dutzend Nicht-Ziffern deckt all das ab, ohne dem Muster zu erlauben, zu einer anderen Zahl zu springen.

Vorlagen, die die Ziffern aufteilen

Ein beliebtes Design setzt jede Ziffer des Codes in eine eigene Box, damit es sich auf dem Handy gut liest. Im HTML sind das sechs Tabellenzellen oder sechs <span>s, und die Zahl taucht im Quelltext nirgends als sechs aufeinanderfolgende Zeichen auf:

was der HTML-Teil tatsächlich enthält
<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>

Ein Regex auf dem rohen HTML findet nichts. Die Lösung ist kein cleverer Regex; sie besteht darin, das HTML zuerst in fester Reihenfolge in Text zu verwandeln:

  1. Ersetzen Sie jedes Tag durch ein Leerzeichen. Ein Leerzeichen, nicht nichts — <td>4</td><td>8</td> muss zu 4 8 werden, nicht zu 48, verklebt mit dem, was folgte.
  2. Dekodieren Sie die Entities. &amp;, &nbsp;, &#39;. Ein geschütztes Leerzeichen zwischen zwei Ziffern ist für einen Regex kein Leerzeichen, solange es nicht dekodiert ist.
  3. Falten Sie Whitespace, und matchen Sie dann so, dass zwischen den Ziffern Leerraum erlaubt ist. Für das Box-Design code is\D{0,12}(\d)\s*(\d)\s*(\d)\s*(\d)\s*(\d)\s*(\d) und die Gruppen zusammenfügen; für eine normale Vorlage reicht das einfache Muster aus dem vorigen Abschnitt.

Die Helfer weiter unten erledigen die Schritte eins und zwei für Sie und durchsuchen beide Teile auf einmal, sodass ein Test nicht wissen muss, welches Design die Vorlage gerade diesen Monat verwendet.

Die neueste Nachricht ist nicht immer die richtige

Jede Postfach-Auflistung liefert hier zuerst die neueste zurück, und messages[0] ist das, was die meisten ersten Entwürfe lesen. Drei Situationen machen das zur falschen Nachricht:

Zwei Nachrichten von einer Aktion
Die Anmeldung verschickt eine Willkommensmail und eine Code-Mail, in welcher Reihenfolge auch immer sich die Warteschlange des Absenders leert. In der Hälfte der Fälle ist die Willkommensmail die neuere. Filtern Sie nach Betreff oder Absender, bevor Sie irgendetwas übernehmen.
Ein erneuter Versand
Der Test hat den Code zweimal angefragt — einmal aus Versehen, einmal absichtlich —, und der Server akzeptiert nur den neuesten. Die ältere Nachricht steckt noch im Postfach, passt immer noch auf das Muster und lässt sich immer noch in sechs Ziffern parsen, die inzwischen ungültig sind.
Ein vorheriger Testlauf
Nur, wenn die Adresse wiederverwendet wurde, was nie vorkommen sollte. Eine frische Adresse pro Lauf macht diesen Fall unmöglich; wenn das nicht geht, ist die Momentaufnahme weiter unten der Rückfallplan.

Die robuste Form ist in jedem Runner dieselbe: Schauen Sie nach, was vor dem Auslösen der Mail im Postfach liegt, und akzeptieren Sie danach nur eine Nachricht, die vorher noch nicht da war und zum erwarteten Betreff passt.

auf eine Nachricht warten, die vor dem erneuten Versand noch nicht existierte
// 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));

Codes, die während des Laufs ablaufen

Die meisten Einmalcodes sind fünf bis fünfzehn Minuten gültig. Das klingt großzügig, bis eine Testsuite zwanzig Specs in eine Warteschlange stellt, von denen jede ihren Code am Anfang angefordert und am Ende eingetippt hat. Drei Regeln halten den Code am Leben:

  • Fordern Sie den Code so spät wie möglich an. Lösen Sie den Versand unmittelbar vor dem Warten aus, nicht in einem Setup-Schritt, der läuft, während andere Tests in der Warteschlange stehen.
  • Halten Sie die Warte-Deadline deutlich unter der Lebensdauer des Codes. Eine Sechzig-Sekunden-Deadline für einen zehn Minuten gültigen Code lässt neun Minuten zum Eintippen. Eine Zehn-Minuten-Deadline lässt nichts übrig.
  • Speichern Sie nie einen Code für einen anderen Test. Codes sind sowohl einmalig verwendbar als auch kurzlebig; eine gemeinsam genutzte Fixture, die einen ausgibt, ist ein Wettlauf zwischen zwei Tests um eine Zahl.

Fertige Extraktoren

Drei Versionen derselben vier Zeilen: beide Teile zusammengefügt, Tags zu Leerzeichen, Entities dekodiert, Whitespace gefaltet, dann das verankerte Muster. Ändern Sie das Muster passend zum Wortlaut Ihrer Vorlage, und sonst muss nichts angefasst werden.

Aus einer Shell, mit 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];
}

Das Nachrichten-JSON, das diese lesen, kommt von GET /api/v1/message/{id}, dokumentiert in der API-Referenz; das Warten, mit dem man die ID überhaupt erst bekommt, steht in der Anleitung zum Ende-zu-Ende-Testen, und als fertige Helfer für Playwright, Cypress, Python und Node.js.

Bevor Sie es für fertig erklären

  • Das Muster im Wortlaut Ihrer Vorlage verankert und direkt neben der Vorlage aufbewahrt.
  • Beide Teile als Text durchsucht: Tags zu Leerzeichen, Entities dekodiert, Whitespace gefaltet.
  • Ein Filter auf Betreff oder Absender, damit eine Willkommensmail nie gegen eine Code-Mail gewinnt.
  • Eine Momentaufnahme vor jedem erneuten Versand, und danach werden nur neue Nachrichten akzeptiert.
  • Die Warte-Deadline deutlich unter der Lebensdauer des Codes, und der Versand unmittelbar vor dem Warten ausgelöst.
  • Eine Fehlermeldung, die die Nachrichten-ID und den Betreff nennt, in denen gesucht wurde.

Das deckt jede beobachtete Art ab, wie ein Sechs-Ziffern-Extraktor mit der falschen Zahl durchgekommen ist. Ein Agent, der dieselbe Mail liest, hat dieselben Probleme und ein Werkzeug weniger dafür, weshalb der MCP-Server ihm die ganze Nachricht in die Hand gibt statt einer Vermutung — ein Postfach, das ein KI-Agent lesen kann geht das durch.

Fragen

Sollte ich den Text-Teil oder den HTML-Teil lesen?

Den Text-Teil, wenn er existiert: Er ist stabil und hat nichts zu dekodieren. Durchsuchen Sie trotzdem beide, so wie es die Helfer tun, damit eine Vorlage, die nur HTML ausliefert, trotzdem funktioniert und eine Vorlage, die nur Text ausliefert, nie über leeres HTML stolpert.

Mein Code enthält Buchstaben. Ändert sich das Muster?

Nur die Zeichenklasse: ([A-Z0-9]{6}), oder welches Alphabet der Absender auch verwendet, weiterhin im vorangehenden Wortlaut verankert. Fügen Sie das Flag i hinzu, wenn die Groß-/Kleinschreibung nicht garantiert ist, und achten Sie darauf, dass die Klasse nicht auch auf ein Wort passt, das dem Anker folgt.

Was ist mit Magic Links statt Codes?

Dieselbe Disziplin, ein anderes Muster: Gleichen Sie die URL mit einem Pfad-Fragment ab, das Sie kennen — /confirm/, /auth/magic/ — statt mit „dem ersten Link“, denn eine transaktionale E-Mail trägt meist fünf Links, und der, den Sie wollen, ist selten der erste. Dekodieren Sie &amp;, bevor Sie ihn aufrufen.

Wie lange ist eine Nachricht lesbar?

5 Tage nach ihrer Ankunft, egal ob sie gelesen wurde oder nicht. Das ist weit länger, als irgendein Code gültig bleibt, ein Test muss sich also beim Lesen nie beeilen — nur beim Eintippen.

Kann ich den Code bekommen, ohne das Postfach zu pollen?

Über REST nicht: Sie pollen einmal pro Sekunde mit einer Deadline, das ist der dokumentierte Rhythmus und wird nie gedrosselt. Über MCP gibt es ein Tool wait_for_message, das den Aufruf offen hält, bis die Nachricht landet, und genau das braucht ein KI-Agent.

Brauche ich einen API-Schlüssel?

Nein. Die öffentlichen Domains verlangen weder Schlüssel noch Konto noch Header. Nur der kostenpflichtige Pool von Domains, der von den Wegwerf-Mail-Sperrlisten ferngehalten wird, verwendet ein Bearer-Token, und der Extraktionscode ist so oder so identisch.

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.