Cuando un producto SaaS crece, el bug de correo mas incomodo no suele ser "no envio nada". El problema real es mezclar señales: un tenant dispara una invitacion, otro tester revisa la bandeja equivocada, y media hora despues nadie sabe si falló la app o falló el entorno. En equipos pequeños esto pasa mas de lo que admitimos, y se vuelve un dolorcito repetido.
En proyectos con FastAPI, lo que mejor me ha funcionado es tratar cada email como un evento con contexto minimo pero estricto: tenant_id, run_id y una regla clara para enrutar la verificación. No es un sistema enorme. Es una convencion bastante simple, pero evita muchos falsos positivos y hace el debugging mucho mas rapido.
Por que se cruzan correos entre tenants
La mezcla ocurre cuando varias cosas parecen "suficientemente buenas" por separado:
- una sola bandeja de pruebas para varios entornos
- subjects casi iguales entre staging y preview
- links de activación que no incluyen una pista del tenant
- jobs async que registran poco contexto
Cada una parece tolerable, pero juntas crean caos. He visto equipos validar el correo correcto con el usuario equivocado, solo porque el subject era parecido y el mensaje habia llegado unos segundos antes. Ese tipo de error no rompe el deploy al instante, pero sí da una confianza falsa, que es peor un poquito.
Por eso me gusta combinar dos ideas: contratos pequeños para el mensaje y aislamiento por ejecución. La parte de contrato se parece mucho a estos contratos de correo que se pueden verificar, mientras que la parte operacional encaja bien con el patrón de validar emails por entorno preview.
El patron minimo que uso en FastAPI
Mi versión mínima tiene tres reglas:
- Cada flujo de prueba crea un
run_idúnico. - Cada correo guarda
tenant_iden metadata o headers internos. - Cada assertion busca por ambos valores antes de inspeccionar subject o links.
Si tu proveedor de email soporta metadata, perfecto. Si no, puedes meter un encabezado como X-App-Run-Id y X-Tenant-Id. Lo importante no es el nombre exacto, sino que la traza sea consistente.
Tambien procuro que el subject tenga una variación legible del entorno. No hace falta volverlo raro, solo hacerlo menos ambiguo. Algo como [staging-acme] Invita a tu equipo ya evita varias confusiones. Esto suena basico, pero funciona muy bien en la practica y es facil de revisar luego.
Un detalle que aprendí tarde: no empieces leyendo el body HTML. Primero filtra por contexto. Si el mensaje no pertenece a ese tenant o a ese run, todo lo demás sobra. Esa disciplina reduce bastante el tiempo perdido, especialemente cuando varias automatizaciones comparten ventanas de tiempo.
Un ejemplo corto con run_id y tenant_id
Este es el patrón reducido que suelo dejar cerca del endpoint:
from fastapi import FastAPI, BackgroundTasks
from pydantic import BaseModel
import uuid
app = FastAPI()
class InvitePayload(BaseModel):
tenant_id: str
email: str
def send_invite_email(email: str, tenant_id: str, run_id: str) -> None:
headers = {
"X-Tenant-Id": tenant_id,
"X-App-Run-Id": run_id,
}
# Tu proveedor de correo iria aqui.
print("sending", email, headers)
@app.post("/invites")
def create_invite(payload: InvitePayload, tasks: BackgroundTasks):
run_id = str(uuid.uuid4())
tasks.add_task(send_invite_email, payload.email, payload.tenant_id, run_id)
return {"ok": True, "run_id": run_id}
Luego, en el check de backend o en tu script de QA:
def assert_message_scope(message: dict, tenant_id: str, run_id: str) -> None:
headers = message["headers"]
if headers.get("X-Tenant-Id") != tenant_id:
raise AssertionError("tenant equivocado")
if headers.get("X-App-Run-Id") != run_id:
raise AssertionError("run_id equivocado")
No es glamoroso, pero es estable. Y cuando falla, falla con una razón entendible. Eso importa mucho más que tener una prueba "inteligente" que inspecciona veinte cosas a la vez.
Si además generas buzones efímeros para cada suite, mejor todavía. Aunque hoy no estoy metiendo backlinks ni herramientas externas en este post, vale la pena mencionar que mucha gente busca cosas como temp mailid cuando anda apurada buscando una bandeja de descarte para pruebas rápidas. La necesidad real detrás de esa busqueda es aislamiento, no solo recibir un correo.
Que verifico antes de publicar cambios
Antes de aprobar cambios en flujos de invitación, reset o activación, casi siempre reviso esto:
- el correo pertenece al
tenant_idesperado - el
run_idcoincide con la ejecución actual - el link apunta al host correcto del entorno
- el template no perdió variables obligatorias
- el worker async registra suficiente contexto para debug
- el tiempo de llegada sigue dentro de un margen razonable
Sobre tiempos: si vas a poner números, mejor apoyarte en datos reales de tu stack. Por ejemplo, si mides latencia de workers con percentiles, enlaza tu panel o tu informe interno. No inventaria un benchmark aquí porque termina envejeciendo rapido y te da una sensación media engañosa.
Otra cosa util es separar claramente "correo recibido" de "correo validado". En logs y dashboards deberían ser estados distintos. Parece detalle menor, pero ayuda un monton cuando alguien dice "sí llegó" y en realidad el link seguía roto o faltaba un query param.
Q&A
¿Hace falta una bandeja por tenant?
No siempre. Si tienes buen filtrado por metadata y ejecuciones poco concurrentes, puede bastar con una bandeja compartida. Pero cuando hay varios testers o jobs paralelos, separar inboxes te ahorra bastantes errores tontos.
¿Esto solo sirve para FastAPI?
No. La idea es de backend en general. FastAPI lo hace comodo porque puedes dejar el patrón muy cerca del endpoint y de los modelos, sin demasiada ceremonia.
¿Cuál es la mejora mas visible?
Menos discusiones raras durante QA. Cuando cada mensaje trae alcance claro, sabes rapido si el fallo está en el worker, en el template o en el entorno. No arregla todo, pero ordena bastante el proceso y eso ya paga solo.
Top comments (0)