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.
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)
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.
- Janela, não instante: aponta para as 8h–10h locais, não as 9h em ponto. Isso dá-te 120 minutos para distribuir.
- Jitter por subscritor: adiciona um offset aleatório estável, ex. `hash(email) % 90` minutos, para suavizar sem reordenar a cada execução.
- Teto de débito: limita ao que o teu fornecedor tolera (Resend free ~2 req/s, SES conforme a tua quota). A fila consome a débito constante.
- Idempotência: chave `campanha+subscritor` para que um retry nunca envie duas vezes — um crash do worker a meio de um pacote não deve duplicar os envios.
- Evita 0h–6h locais: se um subscritor não tem fuso conhecido, NÃO adivinhes — ver a secção seguinte.
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ã.
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.