En flujos de signup, el problema no suele ser enviar el primer correo. El problema real aparece cuando la persona pulsa refresh, abre otra pestaña o el frontend reintenta por timeout, y de pronto salen dos o tres emails de verificacion. En FastAPI eso pasa bastante si el endpoint solo piensa en "mandar correo" y no en "recordar que ya hay uno en camino". Parece un detalle pequeno, pero luego rompe soporte, analitica y confianza del usuario.
El bug casi siempre esta en el contrato
Cuando reviso este tipo de incidencias, casi siempre encuentro el mismo hueco:
- la API acepta la peticion
- crea o localiza al usuario
- manda el trabajo de email a background
- responde demasiado poco, o demasiado tarde
Si el cliente no recibe un estado reutilizable, vuelve a intentar. Si el worker tampoco tiene una llave estable, repite el envio. El fallo no esta solo en la cola ni en el frontend; esta en el contrato entre ambos. Y ese contrato deberia decir claramente si la verificacion ya esta queued, sending o sent.
Ese mismo principio me gusta en otros flujos asincronos, como estas aprobaciones por email con contexto minimo. Cuando cada intento carga un identificador y un estado legible, depurar deja de ser una loteria.
Un patron pequeno para congelar reintentos
La version simple funciona muy bien:
- normaliza el email
- genera una clave idempotente
- guarda una ventana corta de bloqueo
- devuelve el mismo estado si llega un intento igual
Un ejemplo corto en FastAPI:
from datetime import datetime, timedelta, timezone
from fastapi import BackgroundTasks, FastAPI
from pydantic import BaseModel
import hashlib
app = FastAPI()
verify_jobs: dict[str, dict] = {}
class VerifyRequest(BaseModel):
email: str
def normalized_email(value: str) -> str:
return value.strip().lower()
def verify_key(email: str) -> str:
return hashlib.sha256(normalized_email(email).encode()).hexdigest()
def send_verify_email(job_key: str, email: str) -> None:
verify_jobs[job_key]["status"] = "sending"
verify_jobs[job_key]["updated_at"] = datetime.now(timezone.utc).isoformat()
# Aqui iria la integracion real con el proveedor
verify_jobs[job_key]["status"] = "sent"
verify_jobs[job_key]["updated_at"] = datetime.now(timezone.utc).isoformat()
@app.post("/signup/verify-email")
def send_verify(payload: VerifyRequest, tasks: BackgroundTasks):
now = datetime.now(timezone.utc)
job_key = verify_key(payload.email)
current = verify_jobs.get(job_key)
if current:
locked_until = datetime.fromisoformat(current["locked_until"])
if locked_until > now:
return {"status": current["status"], "reused": True}
verify_jobs[job_key] = {
"status": "queued",
"locked_until": (now + timedelta(minutes=3)).isoformat(),
"updated_at": now.isoformat(),
}
tasks.add_task(send_verify_email, job_key, payload.email)
return {"status": "queued", "reused": False}
No es un sistema completo, claro. Pero si congela el reintento durante unos minutos, el backend ya deja de abrir correos duplicados por nervios del cliente o por latencia medio fea. Para muchas APIs, eso resuelve la mayor parte del caos inicial.
Que estado devolver al cliente
Aqui conviene ser humilde y consistente. Yo suelo devolver solo lo que el frontend necesita para no improvisar:
statusreused-
retry_after_secondscuando aplica - un
attempt_idojob_keysi luego habrá consulta de estado
Con eso, el cliente puede mostrar "Ya estamos procesando tu correo" en vez de lanzar otro POST a ciegas. Suena basico, pero evita una cantidad muy decente de ruido. Tambien hace mas faciles los tests de Automatizacion, porque el flujo deja de depender de esperas vagas o temporizadores magicos.
Si el producto tiene emails sensibles, tambien separaria sent de accepted_by_provider. No siempre quieres prometer que el correo salio cuando solo se puso en cola. Ese matiz parece menor, pero ahorra varios tickets un poco incomodos despues.
Donde encaja el correo temporal desechable
En QA o staging, muchas pruebas se hacen con un correo temporal desechable. No me parece malo por si mismo; de hecho ayuda a no contaminar bandejas reales. El punto importante es no mezclar esas direcciones con metricas de onboarding o soporte. Si en tus seeds, tickets o notas aparece texto raro como temp org mail o fake e mail com, mejor tratarlo como evidencia de prueba y no como comportamiento del usuario final.
Para equipos que necesitan una referencia controlada, un generador de cuentas de correo temporal como tempmailso puede servir dentro de pruebas tecnicas o revisiones internas. La clave es que ese uso no cambie el contrato principal del endpoint: la API igual debe responder con estado estable, con o sin bandeja efimera.
Tambien viene bien separar este trafico de mensajes de negocio, como los emails de churn con mejor contexto. Son problemas distintos. Uno busca verificar una cuenta sin duplicados; el otro intenta comunicar una decision de producto. Si mezclas ambos mundos, las metricas se vuelven confusas y el copy se resiente un poco.
Q&A rapido
Cuanto deberia durar el bloqueo?
Para signup normal, 2 a 5 minutos suele bastar. Menos tiempo deja escapar reintentos; mucho mas puede frustrar a quien de verdad necesita otro correo. No hay numero perfecto, pero tres minutos es un punto de partida bastante sano.
Redis o SQL?
Si solo quieres una ventana breve y mucha velocidad, Redis encaja bien. Si además quieres auditoria o correlacion con eventos de autenticacion, una tabla SQL es mas comoda. Las dos valen, depende mas del sistema que del framework.
Hay que bloquear todos los reenvios?
No siempre. A veces basta con reutilizar el estado actual y habilitar un boton de reenviar cuando la ventana termina. Bloquear por completo suele ser mas tosco de lo necesario, y aveces hasta complica soporte.
Top comments (0)