Dónde está realmente el código
Un correo de verificación tiene hasta tres sitios donde puede estar el código, y cuál de ellos debas leer decide todo lo que viene después. El JSON del mensaje que da la API te da los tres a la vez: subject, text (la parte en texto plano, o null) y html (la parte en HTML, o null).
| Dónde | Qué aspecto tiene | Cómo leerlo |
|---|---|---|
La parte en texto plano (text) | Your code is 481920. It expires in 10 minutes. | Analiza esta primero cuando exista. Sin marcado, nada que decodificar, y la redacción es estable. |
La parte en HTML (html) | La misma frase dentro de una tabla, a menudo con los dígitos estilizados uno por celda, y cada & escrito como una entidad. | Convierte las etiquetas en espacios, decodifica las entidades, colapsa los espacios en blanco, y entonces aplica el patrón. Nunca hagas una regex sobre HTML en bruto. |
| La línea de asunto | 481920 is your verification code | Un regalo cuando el remitente lo hace así: no hay cuerpo que analizar en absoluto. Compáralo en el asunto y usa el cuerpo como respaldo. |
| Una imagen | El código dibujado como una imagen, para derrotar precisamente este tipo de script. | Raro, y una señal de que el remitente no quiere automatización. Cambia la plantilla del remitente si es tuya; si no lo es, no hay ninguna solución honesta. |
La parte en texto plano es la que conviene preferir, y es la que la mayoría de los sistemas de plantillas generan automáticamente a partir del HTML — así que normalmente está ahí. Cuando es null, la parte en HTML es el único cuerpo, y las dos secciones siguientes tratan de cómo leerla de forma segura.
Ancla el patrón en tu propia redacción
El instinto es \d{6}. Coincide con el código, y también coincide con el año del pie de página, el código postal del bloque de dirección, las últimas seis cifras de un número de teléfono y el número de pedido que aparece dos líneas por encima del código. Gana el que aparezca primero, y el test lo escribe en el formulario con total confianza.
| Patrón | También coincide con | Veredicto |
|---|---|---|
\d{6} | Años, códigos postales, precios sin separador, números de pedido, números de teléfono, números de seguimiento. | Nunca. No es un patrón, es tirar una moneda al aire. |
\b\d{6}\b | Todo lo anterior que resulte tener exactamente seis dígitos con un espacio a cada lado — la mayoría, igualmente. | Apenas mejor. Los límites de palabra no saben qué es un código. |
code is\D{0,12}(\d{6}) | Solo los seis dígitos que siguen a las palabras que tu plantilla pone antes del código, con margen para dos puntos, un espacio o los restos de espacio en blanco que deja una etiqueta. | Sí. Coincide con el código y nada más, y falla el día en que alguien reescriba el correo — un fallo del que sí quieres enterarte. |
El \D{0,12} es el detalle práctico: después de reemplazar las etiquetas por espacios, las palabras y los dígitos pueden quedar separados por dos puntos, una serie de espacios, o los restos de un <strong> que antes estaba entre medias. Hasta una docena de caracteres que no son dígitos cubre todo eso sin dejar que el patrón salte a otro número.
Plantillas que separan los dígitos
Un diseño popular pone cada dígito del código en su propia casilla, para que se lea bien en el móvil. En el HTML eso son seis celdas de tabla, o seis <span>, y el número nunca aparece como seis caracteres consecutivos en ningún sitio del código fuente:
<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>Una regex sobre el HTML en bruto no encuentra nada. La solución no es una regex más ingeniosa; es convertir el HTML en texto primero, en un orden fijo:
- Reemplaza cada etiqueta por un espacio. Un espacio, no nada —
<td>4</td><td>8</td>debe convertirse en4 8, no en48pegado a lo que viniera después. - Decodifica las entidades.
&, ,'. Un espacio de no separación entre dos dígitos no es un espacio para una regex hasta que se decodifica. - Colapsa los espacios en blanco, y compara permitiendo que los dígitos tengan espacio entre ellos. Para el diseño en casillas,
code is\D{0,12}(\d)\s*(\d)\s*(\d)\s*(\d)\s*(\d)\s*(\d)y une los grupos; para una plantilla normal, el patrón simple de la sección anterior es suficiente.
Los ayudantes de más abajo hacen por ti los pasos uno y dos, y buscan en las dos partes a la vez, así que un test no tiene que saber qué diseño usa la plantilla este mes.
El mensaje más reciente no siempre es el correcto
Aquí, todo listado de buzón devuelve primero el más reciente, y messages[0] es lo que leen la mayoría de los primeros borradores. Tres situaciones hacen que ese sea el mensaje equivocado:
- Dos mensajes de una sola acción
- El registro envía un correo de bienvenida y un correo con el código, en el orden en que se vacíe la cola del remitente. La mitad de las veces la bienvenida es la más reciente. Filtra por asunto, o por remitente, antes de coger nada.
- Un reenvío
- El test pidió el código dos veces — una por error, otra a propósito — y el servidor solo acepta el más reciente. El mensaje antiguo sigue en el buzón, sigue coincidiendo con el patrón, y se sigue analizando en seis dígitos que ahora ya no son válidos.
- Una ejecución de test anterior
- Solo si se reutilizó la dirección, algo que nunca debería pasar. Una dirección nueva por ejecución hace que este caso sea imposible; si no puedes tener una, la instantánea de más abajo es el plan alternativo.
La forma robusta de hacerlo es la misma en cualquier ejecutor: mira qué hay en el buzón antes de disparar el correo, y acepta después solo un mensaje que todavía no estuviera ahí y que coincida con el asunto que esperas.
// 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));Códigos que caducan durante la ejecución
La mayoría de los códigos de un solo uso son válidos entre cinco y quince minutos. Suena generoso hasta que una batería de pruebas pone en cola veinte specs, cada uno de los cuales pidió su código al principio y lo escribió al final. Tres reglas mantienen el código vivo:
- Pide el código lo más tarde posible. Dispara el envío justo antes de la espera, no en un paso de preparación que se ejecuta mientras hay otros tests en cola.
- Mantén el plazo de espera bastante por debajo de la vida del código. Un plazo de sesenta segundos para un código de diez minutos deja nueve minutos para escribirlo. Un plazo de diez minutos no deja nada.
- Nunca guardes un código para otro test. Los códigos son de un solo uso además de efímeros; un fixture compartido que reparte uno es una carrera entre dos tests por un mismo número.
Extractores listos para usar
Tres versiones de las mismas cuatro líneas: las dos partes unidas, etiquetas convertidas en espacios, entidades decodificadas, espacios en blanco colapsados, y después el patrón anclado. Cambia el patrón para que coincida con la redacción de tu plantilla y no hace falta tocar nada más.
Desde una shell, con jq
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 -1Python
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
export type Message = { id: string; subject: string; text: string | null; html: string | null };
const TAGS = /<[^>]+>/g;
const ENTITIES: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ' ': ' ' };
/** 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];
}El JSON del mensaje que leen estos ejemplos viene de GET /api/v1/message/{id}, documentado en la referencia de la API; la espera que te consigue el id en primer lugar está en la guía de pruebas de principio a fin, y como ayudantes ya hechos para Playwright, Cypress, Python y Node.js.
Antes de darlo por terminado
- El patrón anclado en la redacción de tu plantilla, y guardado junto a la plantilla.
- Las dos partes buscadas, como texto: etiquetas convertidas en espacios, entidades decodificadas, espacios en blanco colapsados.
- Un filtro por asunto o remitente, para que un correo de bienvenida nunca gane sobre un correo con el código.
- Una instantánea antes de cualquier reenvío, y solo se aceptan mensajes nuevos después de ella.
- El plazo de espera bastante por debajo de la vida del código, y el envío disparado justo antes de la espera.
- Un mensaje de fallo que indique el id del mensaje y el asunto en el que buscó.
Eso cubre todas las formas en que se ha visto pasar a un extractor de seis dígitos con el número equivocado. Un agente que lee el mismo correo tiene los mismos problemas y una herramienta menos para resolverlos, que es la razón por la que el servidor MCP le entrega el mensaje completo en lugar de una suposición — un buzón que un agente de IA puede leer repasa eso.
Preguntas
¿Debería leer la parte en texto o la parte en HTML?
La parte en texto cuando exista: es estable y no tiene nada que decodificar. Aun así busca en las dos, como hacen los ayudantes, para que una plantilla que solo envía HTML siga funcionando y una plantilla que solo envía texto nunca tropiece con un HTML vacío.
Mi código tiene letras. ¿Cambia el patrón?
Solo la clase de caracteres: ([A-Z0-9]{6}), o el alfabeto que use el remitente, siempre anclada en la redacción que la precede. Añade la bandera i si no está garantizada la capitalización, y ten cuidado de que la clase no coincida también con una palabra que siga al ancla.
¿Qué pasa con los enlaces mágicos en lugar de códigos?
Misma disciplina, patrón distinto: compara la URL con un fragmento de ruta que conoces — /confirm/, /auth/magic/ — en lugar de con «el primer enlace», porque un correo transaccional normalmente lleva cinco enlaces y el que quieres rara vez es el primero. Decodifica & antes de visitarlo.
¿Cuánto tiempo está disponible un mensaje para leerlo?
5 días después de que llegue, se haya leído o no. Eso es mucho más de lo que dura válido cualquier código, así que un test nunca tiene que apresurar la lectura — solo la escritura.
¿Puedo obtener el código sin consultar el buzón?
Por REST, no: consultas una vez por segundo con un plazo, que es el ritmo documentado y nunca se limita. Por MCP hay una herramienta wait_for_message que mantiene la llamada abierta hasta que llega el mensaje, que es la forma que necesita un agente de IA.
¿Necesito una clave de API?
No. Los dominios públicos no piden clave, ni cuenta, ni cabecera. Solo el fondo de pago de dominios que se mantienen fuera de las listas de bloqueo de correo desechable usa un token Bearer, y el código de extracción es idéntico en los dos casos.


