zylior
← Blog

Janelas de envio por fuso: chegar à caixa de entrada na hora certa

«Enviamos às 9h» — está bem, mas 9h de quem? Se a tua lista tem subscritores em Paris, Montreal e Singapura, um único envio «às 9h» chega às 9h para um fuso e às 3h da manhã para outro. Eis como calcular a janela de envio no fuso real de cada pessoa, sem te deixares enganar pela hora de verão.

O problema: «9h» não é uma hora, é um fuso horário

Quando agendas uma campanha «às 9h», a tua ferramenta interpreta isso no seu fuso (muitas vezes UTC, às vezes o do teu servidor Coolify, às vezes o teu pessoal). Para um subscritor em `America/Los_Angeles`, um envio fixado às 9h `Europe/Paris` cai à meia-noite para ele. Fixa-o às 9h UTC no inverno, e é 1h da manhã em Paris, 4h em Dubai.

O objetivo certo não é «enviar no instante T» mas «fazer o email chegar entre as 8h e as 10h hora local de cada subscritor». Isso significa um instante de envio diferente por fuso, calculado para trás a partir da hora local pretendida.

A diferença de aberturas entre «bom período» e «3h da manhã» não é marginal. Um email entregue de noite fica soterrado debaixo de outros 20 ao acordar, e quanto mais acumulas envios não abertos, mais a tua reputação de remetente se degrada (Gmail/Yahoo leem o engagement). O timing protege a entregabilidade, não só a taxa de abertura do dia.

Calcular a janela: a hora local primeiro, o instante UTC depois

A regra de ouro: armazenas e comparas sempre em UTC, mas raciocinas em hora local. O pipeline correto é: (1) partes da hora local pretendida, ex. 9h; (2) tomas o fuso IANA do subscritor, ex. `Europe/Paris`; (3) converte «9h local hoje» num instante UTC; (4) enfias esse instante na tua fila de envio.

A armadilha mortal é fixar a diferença. `Europe/Paris` NÃO é «UTC+1»: é UTC+1 no inverno e UTC+2 no verão (hora de verão / DST). Se fizeres hardcode de `+1`, metade do ano envias uma hora cedo demais. A única forma correta é passar pelo nome de fuso IANA e deixar a lib gerir a mudança de DST na data em causa.

// 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)
Não reinventes o cálculo do DST à mão. `Intl.DateTimeFormat` com `timeZone` (Node, Deno, navegadores) ou uma lib como `luxon`/`Temporal` conhece a base IANA e muda hora de verão/inverno na data certa. O código caseiro de «offset fixo» é O bug que passa na revisão e parte duas vezes por ano.

Espalhar o envio: não dispares o fuso inteiro de uma vez

Uma vez calculados os instantes, ficas com pacotes: todos os teus subscritores `Europe/Paris` às 07:00Z, todos os teus `America/New_York` às 13:00Z, etc. Se cada pacote sair numa rajada, corres dois riscos: throttling do lado do fornecedor (Resend, SES) e um pico de aberturas/cancelamentos que parece spam. Espalha cada janela.

A armadilha dos fusos em falta ou congelados

Nunca terás o fuso de 100% da tua lista. Dois maus reflexos: (1) aplicar o teu próprio fuso a toda a gente — vais enviar às 3h da manhã a meio planeta; (2) congelar o fuso no momento da inscrição e nunca o atualizar — alguém muda-se, viaja, ou detetaste-o mal.

Estratégia de recurso limpa, por ordem de fiabilidade: fuso declarado pelo subscritor > fuso deduzido da atividade recente (timestamp de abertura/clique mais frequente) > fuso deduzido do país (campo de inscrição, IP do registo). Em último recurso, um valor por omissão explícito — tipicamente o fuso do grosso da tua lista — e nunca antes das 8h nem depois das 21h nesse valor por omissão. Mais vale um email às 8h para um fuso aproximado do que um email garantido às 3h da manhã.

Armazena o nome IANA (`Europe/Paris`), nunca o offset (`+02:00`) nem uma abreviatura (`CET`, ambígua e não consciente do DST). O offset fica errado na próxima mudança de DST; o nome IANA permanece correto para sempre porque a lib recalcula o offset na data de envio.

Para começar amanhã: adiciona um campo `timezone` IANA aos teus subscritores, faz o teu scheduler passar de «instante UTC único» para «hora local pretendida + conversão por fuso», fixa a janela em 8h–10h locais com jitter, e regista o fuso usado + a sua origem (declarado/deduzido/omissão) em cada envio. Vais ver as aberturas subir nos fusos que massacravas sem saber — e terás o rasto para provar que não é sorte.

A newsletter

Ao subscreveres aceitas receber a newsletter da Zylior. Cancelamento em 1 clique em cada email.