DEV Community

Silviu Technology
Silviu Technology

Posted on

FastAPI: recibos para emails async

Cuando una API responde 202 Accepted, casi siempre sentimos alivio. El request no fallo, el worker ya tomara el trabajo, y todo parece limpio. El problema es que para email async esa respuesta dice muy poco. No te cuenta si el mensaje quedo en cola, si ya salio del provider o si se perdio entre reintentos. En FastAPI, ese hueco se nota bastante rapido.

Un patrón que me gusta en estos casos es devolver un recibo de entrega desde la API. No reemplaza al worker ni a la cola. Solo crea una referencia estable para seguir el estado del correo sin abrir logs a lo loco. Parece un detalle menor, pero hace que soporte, QA y backend hablen del mismo envio, no de suposiciones distintas.

Por qué un 202 no alcanza para correos async

El error comun es tratar "aceptado" como si significara "enviado". En realidad solo significa que el backend aceptó el trabajo. Entre ese punto y la entrega real pasan varias cosas:

  • el job entra a cola
  • el worker arma la plantilla
  • el proveedor responde con exito, retraso o fallo
  • el sistema actualiza el estado final

Si tu API no devuelve un identificador consultable, terminas resolviendo dudas con logs dispersos o con un dashboard del proveedor. Eso sirve aveces, pero llega tarde y deja a otros equipos medio ciegos. Tambien complica pruebas con una direccion de correo temporal, porque sabes que el request corrio pero no cual intento genero el mensaje correcto.

Este tipo de visibilidad se parece bastante a tener contexto verificable en correos de cambio. La idea de fondo es igual: cada accion importante necesita una referencia clara y compartida.

El recibo de entrega que devuelve la API

Yo suelo pensar el recibo como un recurso pequeño:

  • receipt_id
  • status
  • email_type
  • created_at
  • last_error

Con eso, el cliente puede mostrar "procesando", reintentar con criterio o consultar un endpoint de estado. No hace falta que el frontend conozca la cola ni la herramienta de envío. Solo necesita un contrato pequeño y estable.

Algo simple funciona bien:

  1. El POST /emails/reset-password crea el recibo en estado queued.
  2. La respuesta devuelve receipt_id.
  3. El worker actualiza el estado a sending, sent o failed.
  4. El cliente consulta GET /email-receipts/{receipt_id} por unos segundos.

Es una idea parecida a los contratos de salida para agentes cron: primero fijas el artefacto que todos van a leer, despues optimizas el resto. Cuando haces eso, hasta bugs chicos se vuelven bastante mas faciles de ubicar.

Ejemplo pequeño con FastAPI

from uuid import uuid4
from fastapi import APIRouter, BackgroundTasks
from pydantic import BaseModel

router = APIRouter()


class PasswordResetIn(BaseModel):
    user_id: str
    email: str


@router.post("/emails/reset-password", status_code=202)
async def queue_reset_email(payload: PasswordResetIn, bg: BackgroundTasks):
    receipt_id = f"mail_{uuid4().hex}"

    await save_receipt(
        receipt_id=receipt_id,
        user_id=payload.user_id,
        email_type="password_reset",
        status="queued",
    )

    bg.add_task(send_reset_email, payload.email, receipt_id)
    return {"receipt_id": receipt_id, "status": "queued"}


@router.get("/email-receipts/{receipt_id}")
async def get_email_receipt(receipt_id: str):
    return await load_receipt(receipt_id)
Enter fullscreen mode Exit fullscreen mode

Y el worker:

async def send_reset_email(email: str, receipt_id: str) -> None:
    await update_receipt(receipt_id, status="sending")

    try:
        await provider.send(
            to=email,
            template="password_reset",
            metadata={"receipt_id": receipt_id},
        )
        await update_receipt(receipt_id, status="sent")
    except Exception as exc:
        await update_receipt(receipt_id, status="failed", last_error=str(exc))
Enter fullscreen mode Exit fullscreen mode

No es un sistema perfecto, pero si crea una frontera util. El cliente deja de asumir demasiado, y el backend deja una pista concreta para depurar. En APIs pequeñas esto ayuda mucho, por que evita meter observabilidad enorme antes de tener una necesidad real.

Cómo validar el flujo sin leer logs a ciegas

Para pruebas, yo revisaria tres cosas:

  1. El POST siempre devuelve un receipt_id.
  2. El GET cambia de queued a sent o failed.
  3. El inbox de prueba contiene metadata o correlacion suficiente.

Si usas una inbox de staging o un tem email para flujos efimeros, este patrón te da una ruta mejor que "esperar y buscar el ultimo correo". Tambien reduce falsos positivos cuando dos pruebas corren casi al mismo tiempo. Y si el provider va lento, el estado intermedio deja claro que no fue un bug del cliente, solo una entrega atrasada.

Un detalle que vale oro: guarda last_error de forma legible. No con un traceback gigante, sino con un mensaje corto y util. Cuando soporte pregunta "que paso con este email?", poder responder desde el recibo ahorra mucho ida y vuelta, la verdad.

Segun la State of Application Strategy Report 2024 de F5, el 96% de las organizaciones ya operan aplicaciones modernas en varios entornos. Ese dato no habla solo de email, claro, pero si explica por qué conviene tener contratos simples entre clientes, workers y servicios auxiliares: el sistema ya no vive en un solo lugar y se rompe raro si cada parte interpreta estados distintos.

Q&A

¿Esto no duplica información del proveedor?

Un poco, pero con intención. El proveedor sabe si aceptó el envío. Tu API necesita saber qué prometió al cliente y cómo exponer ese estado de forma estable.

¿Conviene usar polling siempre?

Para muchos paneles internos, sí. Un polling corto de 5 a 15 segundos suele bastar. Si luego necesitas websockets o webhooks, ya partes de un contrato sano.

¿Qué cambia en la práctica?

Cambia la conversacion operativa. En vez de "creo que el correo salió", pasas a "el recibo mail_x quedo en failed por esta razon". Suena pequeño, pero ordena bastante el trabajo diario, y evita varios malentendidos tontos.

Top comments (0)