Ir al contenido

Correo masivo (AWS SES)

Sistema propio para escribirle a los usuarios registrados: novedades del catálogo, promociones y avisos del servicio. Se compone y se envía desde /admin/correos.

El correo transaccional (compras, ventas, disputas) sale por Resend desde send.tcgcards.cl. El masivo sale por AWS SES desde news.tcgcards.cl, con su propio dominio, su propio remitente y su propia reputación.

La razón es simple: si una campaña genera quejas, lo que se quema es el dominio de las novedades y no el de los correos de compras. Un comprador que no recibe el aviso de que su carta fue despachada es un problema mucho más grave que una promoción que no llega.

Categoría¿Se puede dar de baja?Para qué
promotionalSets nuevos en el catálogo, promociones
service_updateFunciones nuevas del sitio, cambios sin urgencia
essentialNoSolo cuenta, seguridad y avisos legales

essential se mantiene deliberadamente mínima. Meter promociones ahí sería cómodo y suicida: quien no puede darse de baja marca el correo como spam. La baja es la válvula de escape que evita justo eso.

Los correos esenciales ignoran las preferencias del usuario pero respetan la supresión: a quien nos denunció por spam no se le escribe nunca más, ni siquiera para avisarle de su cuenta.

Dos filtros, en este orden:

  1. Preferencias del usuario según la categoría. Se usa $ne: false, no true: eso incluye a quien todavía no tiene el campo (opt-in suave), que de otro modo quedaría fuera hasta correr el backfill.
  2. Supresión, que gana siempre, incluso sobre alguien suscrito.

Además se excluye a quien nunca confirmó su correo, sin excepción ni para los esenciales: una dirección sin confirmar es la principal fuente de rebotes duros, y SES suspende pasado el 5% de rebotes.

Cada correo lleva el enlace de baja de la persona, firmado con HMAC sobre su id y la categoría. Alterar el id en la URL invalida la firma, así que nadie puede dar de baja a nadie más.

Van dos direcciones con el mismo token:

  • Enlace visible del pie → https://tcgcards.cl/baja?token=…, porque pinchar un enlace en un correo hace GET.
  • Cabecera List-Unsubscribe → el endpoint del API, porque ahí quien llama es el cliente de correo y hace POST (RFC 8058). Es lo que hace aparecer el botón «Cancelar suscripción» del propio Gmail.

La baja no pide sesión ni confirmación: se aplica al abrir la página, y esa página redirige a una URL sin el token. El token es una credencial firmada; si se quedara en la barra de direcciones viajaría a la analítica (PostHog captura la URL completa de cada visita), al historial y como Referer a cualquier recurso externo.

Cualquier fricción en la baja convierte una baja en una denuncia por spam, así que se acepta a cambio un riesgo conocido: un escáner de enlaces corporativo que visite la URL dará de baja a alguien que nunca pinchó. Es recuperable desde la cuenta; una queja de spam no.

Esa categoría no tiene baja, así que su botón de «Cancelar suscripción» no ejecuta nada: la cabecera apunta a la página de preferencias y se omite List-Unsubscribe-Post.

Firmarlo con la baja de promociones —como estaba al principio— hacía que el botón apagara algo que la persona no pidió apagar, mientras el correo esencial le seguía llegando: perdía lo que quería y encima concluía que el botón no servía, que es el camino corto a que marque el correo como spam.

SES publica los eventos en un tema de SNS que llama a POST /api/v1/marketing/sns. Se suprime la dirección cuando:

  • El rebote es permanente (la casilla no existe).
  • Hay una queja, sea cual sea el subtipo.

Los rebotes transitorios (buzón lleno, servidor caído) se ignoran a propósito: se resuelven solos, y suprimir ahí perdería un destinatario válido de forma irreversible. Tampoco suprime una queja de tipo not-spam, que llega cuando alguien saca nuestro correo de la carpeta de spam — o sea, dijo justo lo contrario.

El webhook exige dos cosas: firma válida de Amazon y que el TopicArn sea el nuestro. La firma sola no basta — cualquier cliente de AWS puede crear un tema y publicar contra nuestra URL con una firma perfecta. Sin MARKETING_SNS_TOPIC_ARN configurado, el webhook rechaza todo.

  1. Entra a /admin/correos (solo admin).
  2. Escribe el asunto y elige el tipo. Debajo aparece a cuánta gente llegaría, ya descontadas bajas y supresiones.
  3. Redacta en Markdown. La barra inserta títulos, negrita, enlaces y listas; el botón de imagen la sube y la inserta con el ancho elegido.
  4. Mira la vista previa de la derecha: es el mismo render que se enviará.
  5. Pulsa «Enviarme una prueba» y ábrela en tu propia bandeja. Revisa que las imágenes se vean y que el enlace de baja funcione.
  6. Recién entonces, «Enviar a todos». Pide confirmación mostrando el número.
  7. Aparece una barra de avance con los enviados sobre el total. Un envío de 600 personas tarda cerca de un minuto. No cierres la pestaña: si la cierras, el envío se pausa y queda en el historial con el botón «Reanudar».

Para listas grandes o para revisar la audiencia sin mandar nada:

Ventana de terminal
# En seco: solo cuenta, no envía
npx tsx scripts/send-campaign.ts --subject "Novedades" --file ./campana.md --dry-run
# De verdad (pide escribir ENVIAR para confirmar)
npx tsx scripts/send-campaign.ts --subject "Novedades" --file ./campana.md --author user-xxx
# Repetir a propósito algo que ya salió en las últimas 24 h
npx tsx scripts/send-campaign.ts --subject "Novedades" --file ./campana.md --author user-xxx --forzar

El script va por tramos igual que el panel e imprime el avance. Si se corta a la mitad, vuelve a correrlo: retoma desde los pendientes.

Desde el 2026-08-18 el .env local apunta a staging. Las credenciales de producción viven en .env.prod.local y ningún script las carga solo. Corre siempre --dry-run primero de todas formas.

Se reanuda, y no le vuelve a escribir a nadie.

Al preparar la campaña se escribe una fila por persona en marketingDeliveries, en estado pending. Cada correo marca su fila justo después de salir. Reanudar es leer las que siguen pendientes, no una estimación sobre un contador.

En el historial, una campaña a medias muestra «Reanudar (N sin enviar)». Al retomar se vuelve a comprobar quién sigue queriendo el correo, así que quien se dio de baja mientras el envío estaba cortado queda como skipped y no recibe nada.

Tres garantías que sostienen esto:

  • Índice único sobre (campaignId, userId): nadie puede tener dos filas en la misma campaña, así que reanudar dos veces no duplica.
  • Arriendo por campaña: solo un envío a la vez sobre los mismos pendientes. Dos pestañas apretando «Reanudar» no se pisan. Vence solo a los dos minutos, así que un proceso muerto no bloquea nada.
  • Tramos acotados: cada llamada manda como mucho 150 correos o 20 segundos, lo que ocurra primero. Ninguna petición se acerca al tiempo límite, con 600 personas o con 50.000.

Lo peor que puede pasar si el proceso muere en el peor instante es que una persona reciba el correo dos veces: la que estaba en vuelo cuando se cayó.

Los registros llevan [campaign] planificada y [campaign] fin con el identificador de la campaña.

ColecciónQué guarda
marketingCampaignsla campaña: asunto, cuerpo, contadores, huella del contenido y arriendo
marketingDeliveriesuna fila por persona y campaña, con su estado y la dirección a la que se escribió
marketingSendGuardsla reserva de la huella de contenido, con vencimiento a 24 h
marketingSuppressionsdirecciones que rebotaron o nos marcaron como spam
EndpointQué hace
POST /api/v1/admin/campaigns/sendprepara la campaña y deja las filas pendientes. No manda ni un correo. Devuelve 409 si ese contenido ya salió en 24 h; force: true lo repite igual
POST /api/v1/admin/campaigns/:id/drainmanda un tramo y devuelve el avance. Se llama en bucle hasta done
GET /api/v1/admin/campaignshistorial, con pendingCount para saber cuáles se pueden reanudar

MARKETING_SES_RATE_PER_SECOND controla los correos por segundo. Por omisión 10, que es el ritmo con el que ya salieron las campañas de agosto y septiembre sin un solo fallo.

El defecto que se corrigió no era el número, era que no se podía cambiar: el valor no llegaba al emisor. El límite documentado de la cuenta es 14/s, pero ese dato viene de un comentario y no de una medición contra SES. Subirlo acorta poco (596 personas: 60 s a 10/s, 43 s a 14/s) y no vale arriesgar una campaña por 17 segundos hasta medirlo.

La variable es opcional a propósito, porque una obligatoria nueva habría que cablearla también en los tres Cloud Run Jobs.

El entorno de pruebas de SES no impide enviar: impide enviar a direcciones sin verificar. Verificando tres o cuatro casillas propias se puede probar el recorrido completo — redactar, recibir, ver cómo queda en Gmail, pinchar la baja — antes de que Amazon apruebe nada.

Y para rebotes y quejas hay direcciones de simulación que funcionan en el entorno de pruebas sin verificar y no cuentan contra la reputación:

DirecciónQué simula
bounce@simulator.amazonses.comRebote permanente
complaint@simulator.amazonses.comQueja de spam
success@simulator.amazonses.comEntrega correcta

Con ellas se prueba de punta a punta lo que más importa: que el aviso llegue por SNS, entre a la lista de supresión y que el siguiente envío ya excluya esa dirección.

Cada entorno necesita su propio conjunto de configuración y su propio tema de SNS: un tema apunta a un solo endpoint, así que compartirlo dejaría a producción sin recibir sus rebotes.

Lo que hay que dejar configurado del lado de Amazon:

  1. Identidad de dominio news.tcgcards.cl verificada, con Easy DKIM (3 CNAME) y MAIL FROM mail.news.tcgcards.cl (MX + SPF). Los CNAME de DKIM van en Cloudflare con el proxy apagado (nube gris) o SES nunca los valida.
  2. DMARC en _dmarc.news.tcgcards.cl, con rua apuntando a una casilla que exista de verdad (hay una regla de Email Routing para dmarc@tcgcards.cl).
  3. Acceso a producción solicitado y aprobado. Sin él, SES está en entorno de pruebas: 200 correos al día y solo a direcciones verificadas una por una.
  4. Conjunto de configuración con destino de eventos hacia un tema de SNS, y ese tema suscrito a POST /api/v1/marketing/sns. Sin esto los rebotes no llegan nunca y la lista de supresión no se llena.
  5. Usuario IAM con permiso de envío, y sus claves en Secret Manager.

El tema de SNS se suscribe después de desplegar el API, nunca antes. Amazon confirma una suscripción llamando al endpoint, y el webhook rechaza cualquier aviso mientras MARKETING_SNS_TOPIC_ARN esté vacío: si se suscribe primero, la confirmación se rechaza y la suscripción queda colgada.

El tema debe ser Estándar. Los temas FIFO solo admiten suscripciones SQS, no HTTPS.

En la suscripción, la entrega de mensajes sin procesar va deshabilitada. El webhook necesita el sobre completo de SNS para verificar la firma y comprobar de qué tema viene; con la entrega sin procesar recibiría solo el contenido y rechazaría todo.

Ni Resend, ni el flujo de pagos, ni los correos transaccionales. Es un canal nuevo y aislado; si mañana hubiera que apagarlo entero, nada de lo anterior se entera.