zylior
← Blog

Ventanas de envío por zona horaria: llegar a la bandeja a tiempo

«Enviamos a las 9» — vale, pero ¿las 9 de quién? Si tu lista tiene suscriptores en París, Montreal y Singapur, un solo envío «a las 9» llega a las 9 para una zona y a las 3 de la madrugada para otra. Así calculas la ventana de envío en la zona horaria real de cada persona, sin que te traicione el horario de verano.

El problema: «las 9» no es una hora, es una zona horaria

Cuando programas una campaña «a las 9», tu herramienta lo interpreta en su zona horaria (a menudo UTC, a veces la de tu servidor Coolify, a veces la tuya personal). Para un suscriptor en `America/Los_Angeles`, un envío fijado a las 9 `Europe/Paris` cae a medianoche para él. Fíjalo a las 9 UTC en invierno, y son la 1 de la madrugada en París, las 4 en Dubái.

El objetivo correcto no es «enviar en el instante T» sino «hacer que el correo llegue entre las 8 y las 10 hora local de cada suscriptor». Eso significa un instante de envío distinto por zona horaria, calculado hacia atrás desde la hora local objetivo.

La diferencia de aperturas entre «buen tramo» y «las 3 de la madrugada» no es marginal. Un correo entregado de noche queda sepultado bajo otros 20 al despertar, y cuanto más acumulas envíos sin abrir, más se degrada tu reputación de remitente (Gmail/Yahoo leen el engagement). El timing protege la entregabilidad, no solo la tasa de apertura del día.

Calcular la ventana: la hora local primero, el instante UTC después

La regla de oro: almacenas y comparas siempre en UTC, pero razonas en hora local. El pipeline correcto es: (1) partes de la hora local objetivo, p. ej. las 9; (2) tomas la zona horaria IANA del suscriptor, p. ej. `Europe/Paris`; (3) conviertes «las 9 locales de hoy» en un instante UTC; (4) metes ese instante en tu cola de envío.

La trampa mortal es congelar el desfase. `Europe/Paris` NO es «UTC+1»: es UTC+1 en invierno y UTC+2 en verano (horario de verano / DST). Si hardcodeas `+1`, medio año envías una hora demasiado pronto. La única forma correcta es pasar por el nombre de zona horaria IANA y dejar que la librería gestione el cambio de DST en la fecha concreta.

// Node 18+, zéro dépendance : Intl gère IANA + DST nativement
// On veut : instant UTC où il sera `targetHour` heure locale chez l'abonné
function sendInstantUTC(tz, targetHour, baseDate = new Date()) {
  // Heure locale actuelle dans tz (gère DST automatiquement)
  const fmt = new Intl.DateTimeFormat('en-US', {
    timeZone: tz, hour12: false,
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', second: '2-digit',
  });
  const p = Object.fromEntries(
    fmt.formatToParts(baseDate).filter(x => x.type !== 'literal')
       .map(x => [x.type, x.value]));
  // décalage tz = (heure murale locale) - (heure UTC) à cet instant
  const asUTC = Date.UTC(+p.year, +p.month - 1, +p.day,
                         +p.hour, +p.minute, +p.second);
  const offsetMs = asUTC - baseDate.getTime();
  // instant UTC pour targetHour:00 heure locale, le jour local courant
  const localTargetUTC = Date.UTC(+p.year, +p.month - 1, +p.day, targetHour, 0, 0);
  return new Date(localTargetUTC - offsetMs);
}

sendInstantUTC('Europe/Paris', 9);        // 9h Paris -> ~07:00Z (été) / 08:00Z (hiver)
sendInstantUTC('America/Los_Angeles', 9); // 9h LA -> ~16:00Z (été) / 17:00Z (hiver)
No reinventes el cálculo del DST a mano. `Intl.DateTimeFormat` con `timeZone` (Node, Deno, navegadores) o una librería como `luxon`/`Temporal` conoce la base IANA y cambia horario de verano/invierno en la fecha correcta. El código casero de «offset fijo» es EL bug que pasa la revisión y se rompe dos veces al año.

Repartir el envío: no sueltes toda la zona horaria de golpe

Una vez calculados los instantes, te quedas con paquetes: todos tus suscriptores `Europe/Paris` a las 07:00Z, todos los `America/New_York` a las 13:00Z, etc. Si cada paquete sale en una ráfaga, corres dos riesgos: throttling del lado del proveedor (Resend, SES) y un pico de aperturas/bajas que parece spam. Reparte cada ventana.

La trampa de las zonas horarias ausentes o congeladas

Nunca tendrás la zona horaria del 100% de tu lista. Dos malos reflejos: (1) aplicar tu propia zona horaria a todo el mundo — enviarás a las 3 de la madrugada a media humanidad; (2) congelar la zona horaria en el momento de la inscripción y no refrescarla nunca — alguien se muda, viaja, o la detectaste mal.

Estrategia de repliegue limpia, por orden de fiabilidad: zona horaria declarada por el suscriptor > zona horaria deducida de la actividad reciente (timestamp de apertura/clic más frecuente) > zona horaria deducida del país (campo de inscripción, IP del registro). En último recurso, un valor por defecto explícito — típicamente la zona horaria del grueso de tu lista — y nunca antes de las 8 ni después de las 21 en ese valor por defecto. Mejor un correo a las 8 para una zona horaria aproximada que un correo garantizado a las 3 de la madrugada.

Almacena el nombre IANA (`Europe/Paris`), nunca el offset (`+02:00`) ni una abreviatura (`CET`, ambigua y no consciente del DST). El offset se vuelve falso en el próximo cambio de DST; el nombre IANA sigue siendo correcto para siempre porque la librería recalcula el offset en la fecha de envío.

Para empezar mañana: añade un campo `timezone` IANA a tus suscriptores, haz pasar tu scheduler de «instante UTC único» a «hora local objetivo + conversión por zona horaria», fija la ventana en 8–10 locales con jitter, y registra la zona horaria usada + su fuente (declarada/deducida/por defecto) en cada envío. Verás subir las aperturas en las zonas horarias que destrozabas sin saberlo — y tendrás el rastro para probar que no es casualidad.

La newsletter

Al suscribirte aceptas recibir la newsletter de Zylior. Baja en 1 clic en cada correo.