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.
Por qué un canal aparte
Sección titulada «Por qué un canal aparte»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.
Las tres categorías
Sección titulada «Las tres categorías»| Categoría | ¿Se puede dar de baja? | Para qué |
|---|---|---|
promotional | Sí | Sets nuevos en el catálogo, promociones |
service_update | Sí | Funciones nuevas del sitio, cambios sin urgencia |
essential | No | Solo 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.
Quién recibe una campaña
Sección titulada «Quién recibe una campaña»Dos filtros, en este orden:
- Preferencias del usuario según la categoría. Se usa
$ne: false, notrue: eso incluye a quien todavía no tiene el campo (opt-in suave), que de otro modo quedaría fuera hasta correr el backfill. - 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.
La baja
Sección titulada «La baja»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.
El caso especial de los correos esenciales
Sección titulada «El caso especial de los correos esenciales»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.
Rebotes y quejas
Sección titulada «Rebotes y quejas»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.
Runbook: enviar una campaña
Sección titulada «Runbook: enviar una campaña»- Entra a
/admin/correos(solo admin). - Escribe el asunto y elige el tipo. Debajo aparece a cuánta gente llegaría, ya descontadas bajas y supresiones.
- 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.
- Mira la vista previa de la derecha: es el mismo render que se enviará.
- Pulsa «Enviarme una prueba» y ábrela en tu propia bandeja. Revisa que las imágenes se vean y que el enlace de baja funcione.
- Recién entonces, «Enviar a todos». Pide confirmación mostrando el número.
- 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».
Vía alternativa: la terminal
Sección titulada «Vía alternativa: la terminal»Para listas grandes o para revisar la audiencia sin mandar nada:
# En seco: solo cuenta, no envíanpx 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 hnpx tsx scripts/send-campaign.ts --subject "Novedades" --file ./campana.md --author user-xxx --forzarEl 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.
Si algo sale mal a mitad de un envío
Sección titulada «Si algo sale mal a mitad de un envío»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.
Colecciones
Sección titulada «Colecciones»| Colección | Qué guarda |
|---|---|
marketingCampaigns | la campaña: asunto, cuerpo, contadores, huella del contenido y arriendo |
marketingDeliveries | una fila por persona y campaña, con su estado y la dirección a la que se escribió |
marketingSendGuards | la reserva de la huella de contenido, con vencimiento a 24 h |
marketingSuppressions | direcciones que rebotaron o nos marcaron como spam |
Endpoints
Sección titulada «Endpoints»| Endpoint | Qué hace |
|---|---|
POST /api/v1/admin/campaigns/send | prepara 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/drain | manda un tramo y devuelve el avance. Se llama en bucle hasta done |
GET /api/v1/admin/campaigns | historial, 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.
Probar en staging (sin esperar a AWS)
Sección titulada «Probar en staging (sin esperar a AWS)»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ón | Qué simula |
|---|---|
bounce@simulator.amazonses.com | Rebote permanente |
complaint@simulator.amazonses.com | Queja de spam |
success@simulator.amazonses.com | Entrega 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.
Puesta en marcha en AWS
Sección titulada «Puesta en marcha en AWS»Lo que hay que dejar configurado del lado de Amazon:
- Identidad de dominio
news.tcgcards.clverificada, con Easy DKIM (3 CNAME) y MAIL FROMmail.news.tcgcards.cl(MX + SPF). Los CNAME de DKIM van en Cloudflare con el proxy apagado (nube gris) o SES nunca los valida. - DMARC en
_dmarc.news.tcgcards.cl, conruaapuntando a una casilla que exista de verdad (hay una regla de Email Routing paradmarc@tcgcards.cl). - 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.
- 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. - Usuario IAM con permiso de envío, y sus claves en Secret Manager.
El orden importa
Sección titulada «El orden importa»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.
Qué NO toca este sistema
Sección titulada «Qué NO toca este sistema»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.