zylior
← Blog

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.

L'écart d'ouverture entre « bon créneau » et « 3h du matin » n'est pas marginal. Un mail livré la nuit est enseveli sous 20 autres au réveil, et plus tu accumules d'envois non ouverts, plus ta réputation d'expéditeur se dégrade (Gmail/Yahoo lisent l'engagement). Le timing protège la délivrabilité, pas juste le taux d'ouverture du jour.

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)
Ne réinvente pas le calcul DST à la main. `Intl.DateTimeFormat` avec `timeZone` (Node, Deno, navigateurs) ou une lib comme `luxon`/`Temporal` connaît la base IANA et bascule heure d'été/hiver à la bonne date. Le code maison « offset fixe » est LE bug qui passe la revue et casse deux fois par an.

É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.

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.

Stocke le nom IANA (`Europe/Paris`), jamais l'offset (`+02:00`) ni une abréviation (`CET`, ambiguë et non DST-aware). L'offset devient faux à la prochaine bascule DST ; le nom IANA reste correct pour toujours car la lib recalcule l'offset à la date d'envoi.

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.