Edge Marketing Agency EDGE JOURNAL
Featured image of post Email a gran escala: cómo enviamos 892 correos 100% personalizados con 34% de apertura

Email a gran escala: cómo enviamos 892 correos 100% personalizados con 34% de apertura

Casos de estudio técnico de la infraestructura de email marketing de Edge Marketing: envío masivo de correos personalizados, enlaces con HMAC, CID embedding y un 34.2% de tasa de apertura — casi el doble del promedio del sector.

El email marketing sigue siendo el canal con mejor ROI del marketing digital: según el DMA, genera $36 por cada $1 invertido. Pero ese número solo lo alcanzan las marcas que no envían correos “random” — las que construyen infraestructura para que cada email llegue, se abra y haga clic.

En los últimos 8 días, nuestro sistema de envío procesó 892 correos para una campaña de un cliente, con estos resultados:

MétricaResultadoBenchmark sector
Emails enviados892—
Tasa de entrega96.52%~90%
Tasa de apertura34.22%~21%
Tasa de clics14.63%~2.6%
Rebote3.48% (0.78% hard)~5-10%
Quejas0—

Eso: 34% de apertura — casi el doble del promedio del sector — y 14.6% de clics — casi seis veces el benchmark. No es suerte: es infraestructura bien diseñada. Acá te explico cómo.


Arquitectura del sistema de envío

El sistema está construido en Python 3 con estos componentes:

Brevo SMTP (smtp-relay.brevo.com:587) ←→ Scripts Python (smtplib)
     ↑
     |  (logs JSONL por cohorte)
     ↓
Excel (postulaciones) → API REST → render_html() → MIMEMultipart → sendmail()
                                                  └── JSONL logs
                                                  └── Reporte HTML

Brevo como relay: no es lo mismo el login SMTP que la dirección de remitente. El login es [email protected] (usuario Brevo), pero el remitente visible es [email protected] (la dirección autenticada con SPF/DKIM). Permitir que el remitente cambie libremente rompería la autenticación y caería en spam.

Los datos vienen de una hoja de cálculo Excel con 429 postulaciones, paginados con un rate limit de 1000 registros por request (sí, se rompió una vez cuando pasó de 1000 y tuvimos que agregar pagination).


Personalización a nivel individual

Cada correo es un template HTML con 19 sustituciones dinámicas del jugador: nombre, foto, estadura, pie hábil, ciudad, código de jugador, hora de convocatoria, lugar… Todo renderizado en tiempo real desde los datos de la base.

def render_html(jugador):
    # 19 campos dinámicos → HTML único por destinatario
    return f"""<!doctype html>
    <h1>¡Vas a la cancha, {nombre_corto}!</h1>
    <div class="carnet">
      <img src="{foto}" alt="Foto de {nombre_corto}">
      <span class="carnet-name">{nombre}</span>
      <span class="carnet-stat">{ciudad}</span>
      <span class="carnet-stat">{pie}</span>
      <span class="carnet-stat">{estatura}</span>
    </div>
    <a href="{url_convocatoria}">Ver mi convocatoria</a>
    ...

Los avatares de jugadores suben desde iPhone en formato HEIC — ningún cliente de correo renderiza HEIC inline. El script detecta .heic y cae al logo de marca como fallback, evitando la imagen rota.


Seguridad: enlaces con HMAC de un solo uso

El botón “Ver mi convocatoria” no es un link estático. Es un enlace firmado con HMAC que:

  1. Se genera en el momento del envío vía API (POST /api/convocatoria/enlace)
  2. Cada código tiene un solo enlace activo — pedir uno nuevo revoca el anterior
  3. El token lleva firma HMAC + expiración (30 días)
  4. El servidor valida: firma correcta + no revocado + no expirado

Esto significa que si un destinatario reenvía el correo a otra persona, el enlace sigue funcionando. Pero si el destinatario original pide otro enlace (ej. para corregir fecha), el anterior se invalida automáticamente. Nada de enlaces antiguos circulando con información desactualizada.

# El enlace real se genera en el momento del envío:
jugador["url_convocatoria"] = enlace_convocatoria(
    codigo, hora_convocatoria, hora_llegada, admin_token
)
# En el log (JSONL), NO en el draft generado horas antes:
"estado": "ok",
"url_convocatoria": "https://registro.../convocatoria#t=eyJqdGkiOiJlaVNiWVo0..."

Gotcha aprendido: los drafts .html en disco pueden tener un token caducado si se vuelve a regenerar el enlace para ese mismo código. Para reenvíos, siempre leer el url_convocatoria del log JSONL (estado ok), nunca del HTML de drafts.


Campo de batalla: compatibilidad con clientes de correo

Si algo rompe un email bonito, es la fragmentación de clientes. Soluciones implementadas:

CID embedding (para evitar bloqueo de imágenes)

Outlook/Hotmail bloquea imágenes remotas por reputación de remitente nuevo — no es un problema de SPF/DKIM (que pasan OK), es política de reputación. Solución: incrustar las imágenes reales como adjuntos cid: dentro del MIME, eliminando cualquier búsqueda remota. Los fondos CSS (background-image) se dejan remotos: cid: en backgrounds no tiene soporte confiable entre clientes, y bgcolor ya da un fallback de color plano aceptable.

Gmail Dark Mode

Gmail/iOS Mail repinta fondos oscuros en modo oscuro, perdiendo las imágenes de fondo navy. Solución: bgcolor explícito + background-image + selectors [data-ogsc] para forzar colores en modo oscuro. El truco clave: Gmail aplica [data-ogsc] al mismo elemento que repinta (no a un ancestro), así que los selectores van pegados sin espacio.

/* Gmail dark mode — el atributo data-ogsc solo aparece cuando
   Gmail activa su modo oscuro. Forzamos colores sobre el repintado. */
.carnet-name[data-ogsc]{color:#FFCC33 !important}

Emojis: BMP vs Suplementario

⏰ (U+23F0, plano básico) se ve en todos lados. 📱👕 (plano suplementario) necesitan Segoe UI Emoji completa — en dispositivos sin ella quedan como cuadro vacío. Solución: usar solo caracteres BMP para emojis críticos, o reemplazar por iconos SVG/data-URI.


Gestión de campañas: dry-run, logs y reanudación

Nada es más peligroso que un envío accidental. El sistema tiene tres salvaguardas:

  1. Dry-run por defecto: python enviar_g1.py sin --enviar no manda nada, solo procesa y loguea.
  2. Logs JSONL por cohorte: cada envío exitoso se escribe al instante en logs/envios-G1.jsonl. Si el proceso se corta a mitad del lote, codigos_completados() lee el JSONL (no el Excel) para saber qué falta.
  3. Reenvío puntual: --solo-codigo YSG-XXXX --reenviar para corregir un correo específico sin tocar el resto.
enviar_g1.py --cohorte G1 --drafts    # genera HTML de vista previa
enviar_g1.py --cohorte G1 --enviar    # envía de verdad
enviar_g1.py --cohorte G1 --solo-codigo YSG-0800 --reenviar  # reenvía uno

El Excel también se guarda por cada envío OK (no solo al final del lote), así si se corta queda actualizado.


Rate limiting respetuoso

time.sleep(0.7)  # no saturar el relay de Brevo

700ms entre envíos. No es el límite de Brevo (que admite más), es rate limiting ético: evita que el relay marque como spam por comportamiento bulk, y mantiene la entrega limpia. Con 892 correos, eso da ~10.5 minutos de envío distribuido — el tiempo justo para que cada correo tenga su propio espacio en la bandeja de entrada.


Resultado

892 correos enviados entre el 1 y el 8 de septiembre. Cero quejas, cero errores de autenticación. La tasa de apertura del 34.2% supera ampliamente el benchmark del 21% para el sector, y los 14.6% de clics superan el 2.6% promedio.

No es que mandamos mejores emails — es que construimos infraestructura que respeta al destinatario, autentica correctamente y entrega valor tangible en cada envío.


Próximos pasos

El sistema enviar_g1.py está siendo generalizado con --cohorte G1..G9 para soportar campañas multicohorte con bloques de horarios distintos. La próxima evolución: integración con el EdgeMail propio (API local en em-node-src) para que las campañas de email marketing de la agencia usen la misma infraestructura de logs, drafts y reportes, pero con la interfaz web de EdgeMail como frontend.

Edge Marketing Agency — Quito, Ecuador · edgemarketing.art

COMPARTIR ¿A quién le serviría esto?
in wa
Creado con Hugo
Tema Stack diseñado por Jimmy