Los tests de email suelen empezar con una solución sencilla: enviar el mensaje, hacer sleep(5) y buscar el correo más reciente. Funciona en local, pero en CI se vuelve lento y bastante fragil. Un proveedor tarda más, dos jobs comparten inbox o el test encuentra un mensaje viejo.

Una alternativa práctica es tratar cada email de prueba como un evento correlacionado. La aplicación publica una señal con un identificador de ejecución y el lector busca exactamente ese evento, dentro de una ventana de tiempo. No elimina los retrasos de red, pero evita que el test adivine qué mensaje le pertenece.

El síntoma: un test que espera a ciegas

El polling no es malo por sí mismo. El problema aparece cuando es la única forma de saber que algo paso. Un flujo típico tiene estas decisiones escondidas:

  • cuánto tiempo esperar antes del primer intento;
  • qué ocurre si hay dos mensajes con el mismo asunto;
  • cómo distinguir un email de esta ejecución de uno anterior;
  • quién guarda la evidencia cuando el timeout expira.

Cuando esas reglas no están en un contrato, cada helper las resuelve de una forma distinta. En una búsqueda de documentación puede aparecer incluso tempail mail o tamp mail com, pero el nombre que se usa no arregla la falta de correlación.

El objetivo no es convertir un email en un sistema distribuido enorme. Es adjuntar contexto mínimo: run_id, destinatario, tipo de evento, momento de emisión y estado de consumo.

De polling a eventos correlacionados

Imagina que el backend genera un evento cuando crea el email de verificación. Ese evento no tiene que contener el cuerpo completo del mensaje. Puede incluir un identificador, un hash del contenido y la referencia al destinatario:

{ "event_id": "evt_8f2a", "run_id": "ci_2026_11_04_001", "kind": "verification_email.created", "recipient": "test-42@example.invalid", "created_at": "2026-11-04T00:15:20Z" } 

El test espera una señal con run_id=ci_2026_11_04_001. Después puede consultar el inbox para confirmar el enlace, pero ya no necesita decidir cuál de cinco mensajes recientes es el correcto.

Este patrón también mejora el diagnóstico. Si el evento existe pero el mensaje no llega, el problema está entre la cola y el proveedor. Si no existe el evento, hay que revisar la aplicación. La frontera queda bastante más clara.

Un contrato pequeño en FastAPI

FastAPI puede exponer un endpoint interno para registrar y consultar eventos de prueba. En producción conviene usar PostgreSQL o una cola real; este ejemplo se concentra en el contrato HTTP:

from datetime import datetime, timezone from uuid import UUID from fastapi import FastAPI, HTTPException from pydantic import BaseModel, Field app = FastAPI() events: dict[str, dict] = {} class EmailEvent(BaseModel): event_id: str = Field(min_length=4) run_id: str = Field(min_length=4) kind: str = "verification_email.created" recipient: str created_at: datetime @app.post("/test-events/email", status_code=202) def record_event(event: EmailEvent): if event.event_id in events: return {"accepted": True, "duplicate": True} events[event.event_id] = event.model_dump(mode="json") return {"accepted": True, "duplicate": False} @app.get("/test-events/email/{run_id}") def find_event(run_id: str): match = next((item for item in events.values() if item["run_id"] == run_id), None) if match is None: raise HTTPException(status_code=404, detail="Evento no encontrado") return match 

El endpoint devuelve duplicate=true para un reintento del mismo event_id. Esa respuesta es útil para un runner: repetir una llamada no crea una segunda señal que pueda confundir al lector.

Si el proyecto todavía usa polling, se puede introducir este contrato poco a poco. Primero registra el evento, luego conserva el polling como fallback y finalmente mide cuántos tests siguen necesitándolo. La transición es mas segura cuando cada paso deja métricas.

Idempotencia y reintentos

El evento debe ser idempotente, pero también lo debe ser el consumidor. Una secuencia razonable es:

  1. crear un run_id único para el caso;
  2. registrar el evento de emisión;
  3. esperar la señal con un timeout explícito;
  4. buscar el mensaje usando run_id, destinatario y fecha;
  5. marcar el evento como consumido una sola vez;
  6. guardar un resumen y limpiar los datos temporales.

No marques el evento como consumido antes de validar el enlace. Si el parser falla y el test reintenta, perderías una pista importante. También conviene registrar razones distintas para event_not_found, message_not_found y invalid_message; “email timeout” dice muy poco.

Para operar el flujo, las alertas de rollback con contexto real ofrecen una idea útil: una señal debe incluir contexto accionable, no solo un contador rojo.

Cómo llevarlo a CI

Antes de abrir el navegador o llamar al endpoint de registro, el job puede preparar:

  • un run_id que incluya el número de intento;
  • un artefacto JSON con el evento y sus timestamps;
  • un timeout total y un intervalo de polling definidos en configuración;
  • una política para conservar solo el resumen, no todo el contenido sensible;
  • un paso finally para liberar la bandeja o borrar el evento.

También ayuda aislar pruebas de email por entorno, porque el mismo contrato puede aplicarse a local, staging y CI sin mezclar bandejas. Si un job falla, guarda el run_id en el mensaje del error. Parece un detalle menor, pero ahorra bastante tiempo al revisar logs.

Preguntas rápidas

¿Esto elimina el polling?

No necesariamente. El evento indica qué esperar; el inbox todavía puede requerir polling si el proveedor no ofrece webhook. La diferencia es que el polling tiene una identidad y un límite.

¿Debo guardar el email completo?

Solo cuando haga falta para depurar. Un hash, el asunto, el remitente y los timestamps suelen ser suficientes para la mayoría de los fallos.

¿Qué pasa si dos tests usan el mismo destinatario?

El run_id debe seguir siendo distinto y el filtro debe comprobar también el tiempo de emisión. Si el proveedor permite direcciones aisladas, usarlas reduce todavía más la ambigüedad.

Cierre

Un evento correlacionado convierte “espera hasta que llegue algo” en “espera este mensaje de esta ejecución”. Con FastAPI, un endpoint pequeño, una clave idempotente y razones de fallo explícitas bastan para dar el primer paso.

El resultado no es solo un pipeline un poco mas rápido. Es una prueba que explica qué esperaba, qué recibió y en qué frontera se perdió la señal.