Fenêtres d'envoi par fuseau : toucher l'inbox au bon moment
« On envoie à 9h » — d'accord, mais 9h de qui ? Si ta liste a des abonnés à Paris, Montréal et Singapour, un seul envoi « à 9h » touche 9h pour un fuseau et 3h du matin pour un autre. Voilà comment calculer la fenêtre d'envoi dans le fuseau réel de chaque personne, sans te faire avoir par l'heure d'été.
Le problème : « 9h » n'est pas une heure, c'est un fuseau
Quand tu programmes une campagne « à 9h », ton outil interprète ça dans son fuseau (souvent UTC, parfois celui de ton serveur Coolify, parfois ton fuseau perso). Pour un abonné à `America/Los_Angeles`, un envoi calé sur 9h `Europe/Paris` tombe à minuit chez lui. Cale-le sur 9h UTC en hiver, et c'est 1h du matin à Paris, 4h à Dubaï.
Le bon objectif n'est pas « envoyer à un instant T » mais « faire arriver le mail entre 8h et 10h heure locale de chaque abonné ». Ça veut dire un instant d'envoi différent par fuseau, calculé à rebours depuis l'heure locale cible.
Calculer la fenêtre : l'heure locale d'abord, l'instant UTC ensuite
La règle d'or : tu stockes et tu compares toujours en UTC, mais tu raisonnes en heure locale. Le pipeline correct est : (1) tu pars de l'heure cible locale, ex. 9h ; (2) tu prends le fuseau IANA de l'abonné, ex. `Europe/Paris` ; (3) tu convertis « 9h locale aujourd'hui » en instant UTC ; (4) tu enfiles cet instant dans ta file d'envoi.
Le piège mortel, c'est de figer le décalage. `Europe/Paris` n'est PAS « UTC+1 » : c'est UTC+1 en hiver et UTC+2 en été (heure d'été / DST). Si tu hardcodes `+1`, la moitié de l'année tu envoies une heure trop tôt. La seule façon correcte est de passer par le nom de fuseau IANA et de laisser la lib gérer la bascule DST à la date concernée.
// 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)
Étaler l'envoi : ne balance pas tout le fuseau d'un coup
Une fois les instants calculés, tu te retrouves avec des paquets : tous tes abonnés `Europe/Paris` à 07:00Z, tous tes `America/New_York` à 13:00Z, etc. Si chaque paquet part en une rafale, tu prends deux risques : throttling côté fournisseur (Resend, SES) et pic d'ouvertures/désinscriptions qui ressemble à du spam. Étale chaque fenêtre.
- Fenêtre, pas instant : vise 8h–10h locale, pas 9h pile. Ça te donne 120 minutes pour répartir.
- Jitter par abonné : ajoute un offset aléatoire stable, ex. `hash(email) % 90` minutes, pour lisser sans réordonner à chaque run.
- Plafond de débit : cape à ce que ton fournisseur tolère (Resend free ~2 req/s, SES selon ton quota). La file consomme à débit constant.
- Idempotence : clé `campagne+abonné` pour qu'un retry ne renvoie jamais deux fois — un crash worker au milieu d'un paquet ne doit pas doubler les envois.
- Évite 0h–6h locale : si un abonné n'a pas de fuseau connu, NE devine pas — voir section suivante.
Le piège des fuseaux manquants ou figés
Tu n'auras jamais le fuseau de 100% de ta liste. Deux mauvais réflexes : (1) appliquer ton fuseau à toi à tout le monde — tu enverras à 3h du matin à la moitié de la planète ; (2) figer le fuseau au moment de l'inscription et ne jamais le rafraîchir — quelqu'un déménage, voyage, ou tu as mal détecté.
Stratégie de repli propre, par ordre de fiabilité : fuseau déclaré par l'abonné > fuseau déduit de l'activité récente (timestamp d'ouverture/clic le plus fréquent) > fuseau déduit du pays (champ d'inscription, IP de signup). En tout dernier recours, un défaut explicite — typiquement le fuseau du gros de ta liste — et jamais avant 8h ni après 21h dans ce défaut. Mieux vaut un mail à 8h pour un fuseau approximatif qu'un mail garanti à 3h.
Pour commencer demain : ajoute un champ `timezone` IANA à tes abonnés, fais passer ton scheduler de « instant UTC unique » à « heure locale cible + conversion par fuseau », cale la fenêtre sur 8h–10h locale avec jitter, et logge le fuseau utilisé + sa source (déclaré/déduit/défaut) pour chaque envoi. Tu verras l'ouverture grimper sur les fuseaux que tu massacrais sans le savoir — et tu auras la trace pour prouver que ce n'est pas un hasard.
La newsletter
En t’inscrivant, tu acceptes de recevoir la newsletter Zylior. Désinscription en 1 clic dans chaque email.