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.
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)
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.
- Ventana, no instante: apunta a las 8–10 locales, no a las 9 en punto. Eso te da 120 minutos para repartir.
- Jitter por suscriptor: añade un offset aleatorio estable, p. ej. `hash(email) % 90` minutos, para suavizar sin reordenar en cada ejecución.
- Tope de caudal: cápalo a lo que tu proveedor tolera (Resend free ~2 req/s, SES según tu cuota). La cola consume a caudal constante.
- Idempotencia: clave `campaña+suscriptor` para que un reintento nunca envíe dos veces — un crash del worker a mitad de un paquete no debe duplicar los envíos.
- Evita 0–6 locales: si un suscriptor no tiene zona horaria conocida, NO la adivines — ver la sección siguiente.
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.
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.