Cuando un signup dispara un correo en background, muchas APIs responden 202 Accepted y ahi termina la historia. El backend queda feliz, pero el usuario no sabe si el mensaje salio, si sigue en cola o si necesita reintentar. En equipos pequenos esto crea tickets bastante evitables, y en equipos medianos tambien, la verdad.
En varios proyectos Python me ha funcionado mejor tratar el email como un job visible y no como un efecto secundario oculto. No hace falta montar una plataforma enorme: con un estado consultable y un par de reglas de expiracion, el flujo se vuelve mucho mas claro para producto, soporte y QA.
El problema real no es enviar, es mostrar progreso
El envio suele fallar menos de lo que pensamos. Lo que de verdad rompe la experiencia es la falta de contexto entre pasos:
- El frontend pide enviar el correo.
- La API acepta la solicitud.
- Un worker lo procesa unos segundos despues.
- El usuario refresca porque no ve nada convincente.
Ese hueco entre aceptar y terminar es donde nacen los problemas. Si el sistema no expone estado, el frontend improvisa mensajes, soporte adivina y QA llena tickets con notas raras como tempail en capturas o seeds de prueba. No es grave-grave, pero si desgasta.
Tambien he visto que este tipo de contrato ayuda cuando el equipo ya trabaja con prompts de email que resisten retries. La misma idea aplica aqui: si una accion puede repetirse, conviene dejar claro que parte ya esta en curso y que parte aun no termina.
Un contrato pequeno para estado de correo
La version simple que recomiendo devolver desde la API tiene cuatro campos:
{
"job_id": "mail_8f3c",
"status": "queued",
"retry_after_seconds": 10,
"last_updated_at": "2026-08-16T11:22:23Z"
}
Con esto el cliente puede decidir si muestra spinner, texto de espera o una accion para reconsultar. Lo importante no es el nombre exacto de los campos; lo importante es que el backend tenga una sola fuente de verdad.
Yo suelo manejar estos estados:
queuedsendingsentfailedexpired
expired es muy util si no quieres guardar jobs eternamente. Tambien evita que frontend y soporte lean datos viejos y saquen conclusiones medio chuecas.
Implementacion simple en FastAPI
No necesitas Celery para demostrar el patron. Un ejemplo pequeno basta para fijar el contrato:
from datetime import datetime, timedelta, timezone
from fastapi import BackgroundTasks, FastAPI, HTTPException
from pydantic import BaseModel
from uuid import uuid4
app = FastAPI()
EMAIL_STATUS: dict[str, dict] = {}
class SignupEmailRequest(BaseModel):
email: str
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def send_email(job_id: str, email: str) -> None:
EMAIL_STATUS[job_id]["status"] = "sending"
EMAIL_STATUS[job_id]["last_updated_at"] = now_iso()
# envio real aqui
EMAIL_STATUS[job_id]["status"] = "sent"
EMAIL_STATUS[job_id]["last_updated_at"] = now_iso()
@app.post("/signup/email")
def enqueue_signup_email(payload: SignupEmailRequest, tasks: BackgroundTasks):
job_id = f"mail_{uuid4().hex[:8]}"
EMAIL_STATUS[job_id] = {
"status": "queued",
"email": payload.email.strip().lower(),
"expires_at": (datetime.now(timezone.utc) + timedelta(minutes=15)).isoformat(),
"last_updated_at": now_iso(),
}
tasks.add_task(send_email, job_id, payload.email)
return {
"job_id": job_id,
"status": "queued",
"retry_after_seconds": 10,
"last_updated_at": EMAIL_STATUS[job_id]["last_updated_at"],
}
@app.get("/signup/email/{job_id}")
def get_signup_email_status(job_id: str):
job = EMAIL_STATUS.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="job_not_found")
return {
"job_id": job_id,
"status": job["status"],
"last_updated_at": job["last_updated_at"],
}
No digo que esto sea el modelo final de produccion. Pero sirve para alinear al equipo rapido, y eso ya vale bastante. Luego puedes mover el estado a Redis o Postgres sin romper el contrato externo.
Algo que aprendi a las malas: evita devolver solo "email sent" cuando en realidad solo lo pusiste en cola. Esa pequeña diferencia mejora debugging, reduce reintentos nerviosos y hace que la API se sienta mas honesta.
Que revisar en frontend y soporte
Si el backend publica estado real, el frontend deja de inventar reglas locales. Yo revisaria estas cuatro cosas:
- Que el boton no dispare otro envio mientras el job sigue en
queuedosending. - Que exista polling corto o reconsulta manual, segun el producto.
- Que los mensajes de error no mezclen fallo de entrega con demora normal.
- Que soporte pueda buscar por
job_ido por email normalizado.
Esto tambien conecta bien con ideas de correos de mantenimiento utiles: el mensaje tecnico ayuda mas cuando trae contexto accionable y no solo una señal binaria de exito o fallo.
En QA, ademas, conviene separar casos reales de direcciones temporales o de obtener correo temporal para pruebas. No porque esos escenarios sean malos, sino porque mezclarlos con metricas de activacion produce ruido innecesario y alguna conclusion apurada, a veces.
Preguntas frecuentes
Conviene polling o webhook interno?
Para signup y verificacion, polling corto suele ser suficiente. Es mas facil de operar y bastante menos fragil al inicio.
Redis o base SQL?
Redis va muy bien si solo quieres estado efimero y tiempos rapidos. SQL gana cuando soporte o analitica necesitan mirar historico despues.
Cuanto tiempo deberia durar un job?
Entre 10 y 20 minutos suele alcanzar para correos transaccionales normales. Mas tiempo puede dejar basura; menos tiempo puede borrar evidencia util demasiado pronto.
Si tu API hoy manda correos async pero nadie puede explicar en que estado estan, este patron es una mejora muy rentable. Es pequeno, claro y se puede adoptar sin rehacer todo el backend. No resuelve cada caso, obvio, pero si elimina una friccion que aparece una y otra vez.
Top comments (0)