Nakodo (nakodo.app) writes to creators on a brand's behalf, handles the replies, and introduces the ones who are interested. Because the sending is automatic, the question "when does this actually arrive" stopped being a user's problem and became a scheduling problem in the codebase.
The answer we settled on is one sentence at the top of the module:
// When outreach emails go out: in the creator's working hours, on weekdays,
// with a little randomness so a batch doesn't land on the minute. Mail that
// arrives at 3am sits under everything that comes in before breakfast.
Everything below follows from that, and the whole thing is pure functions over a Date, which matters for a scheduler more than almost anywhere else.
We do not know the creator's time zone, so we guess on purpose
Nobody fills in a form. What we have is the country on the creator's channel or account, and failing that the target markets the brand named in the brief. So there is a map:
const COUNTRY_TIMEZONES: Record<string, string> = {
US: "America/Chicago",
CA: "America/Toronto",
GB: "Europe/London",
IN: "Asia/Kolkata",
AU: "Australia/Sydney",
// ...
};
export const DEFAULT_TIMEZONE = "America/New_York";
// The creator's channel country first, then the campaign's markets in order.
export function timezoneFor(...countries: (string | null | undefined)[]): string {
for (const c of countries) {
const tz = c ? COUNTRY_TIMEZONES[c.toUpperCase()] : undefined;
if (tz) return tz;
}
return DEFAULT_TIMEZONE;
}
Around 64 countries, which is every country our discovery has turned up creators in. A country is not a time zone, obviously. The rule for the ones that span several is to take the zone most of the population lives in, or the middle one, and America/Chicago for the United States is a deliberate choice of the middle rather than the coast: a 9am send in Chicago is 7am in Los Angeles and 10am in New York, so the worst case is early rather than the middle of the night.
Being wrong by an hour or two is fine. Being wrong by eight is what the map exists to prevent.
Intl.DateTimeFormat is the entire date dependency
There is no date library in this module. Getting a wall-clock weekday and hour in an arbitrary zone is one platform call:
export function localTime(date: Date, timeZone: string): { weekday: number; hour: number; minute: number } {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
weekday: "short",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
return { weekday: WEEKDAYS[get("weekday")] ?? 1, hour: Number(get("hour")), minute: Number(get("minute")) };
}
Two details that are easy to get wrong. hourCycle: "h23" is what stops you parsing "12" as midnight or noon depending on the locale's idea of a 12 hour clock. And formatToParts rather than formatting and re-parsing a string, because the separators are a locale's business, not yours.
What you get for free by going through Intl: daylight saving transitions, half hour and three quarter hour offsets like Asia/Kolkata and Australia/Eucla, and historical rule changes, all maintained by the platform's tz database rather than by me.
export const WINDOW = { startHour: 9, endHour: 17 } as const;
export function inSendWindow(date: Date, timeZone: string): boolean {
const t = localTime(date, timeZone);
return t.weekday >= 1 && t.weekday <= 5 && t.hour >= WINDOW.startHour && t.hour < WINDOW.endHour;
}
Finding the next opening by stepping, not by arithmetic
The obvious implementation of "the next 9am in that zone" is arithmetic on offsets, and the obvious implementation is where DST bugs live. The version that does not have those bugs searches:
export function nextSendTime(from: Date, timeZone: string, random: () => number = Math.random): Date {
if (inSendWindow(from, timeZone)) return new Date(from.getTime() + Math.floor(random() * 20) * 60_000);
const step = 15 * 60_000;
// Start on a quarter hour so the opening is found exactly.
let t = Math.ceil(from.getTime() / step) * step;
const limit = from.getTime() + 8 * DAY_MS;
while (t < limit && !inSendWindow(new Date(t), timeZone)) t += step;
return new Date(t + Math.floor(random() * 90) * 60_000);
}
Four things packed into ten lines:
- Snap to a quarter hour before stepping. Every time zone in the database is a whole number of quarter hours from UTC, so a window boundary is always landed on exactly rather than overshot by the remainder of the starting minute.
- Step in fifteen minute increments. Worst case for a Friday evening is a few hundred iterations of a pure function. Cheap, and correct across a DST switch without knowing a DST switch happened.
-
The eight day limit. An unbounded
whiledriven by a predicate that could, with a bad zone string or a future window change, never be true is an infinite loop in a worker. Eight days is longer than any real gap and still terminates. - Jitter, of two sizes. Up to 20 minutes when we are already inside the window, and up to 90 when we had to wait for one to open, because otherwise everything queued overnight fires at 09:00:00 local and the whole batch looks like exactly what it is.
The injected random is the other half of that: tests pass a fixed function and assert exact instants, including across a DST boundary, with no clock mocking and no network.
The rhythm is a constant, and the guide says the same thing
// Days to wait before each follow-up, counted from the email before it:
// day 4, day 11 and day 21 after the first email.
export const FOLLOW_UP_GAPS_DAYS = [4, 7, 10] as const;
export const MAX_FOLLOW_UPS = FOLLOW_UP_GAPS_DAYS.length;
export const DEFAULT_FOLLOW_UPS = 2;
The gaps are relative to the previous email, not to the first, which is the only representation that stays correct when one of them is delayed by a weekend or by the next part. The comment translates to absolute days because that is how a human reasons about it, and the translation being in a comment rather than in the array is the point: code that counts in gaps, humans that count in days.
followUpDue returns Date | null, and the null is the end of the sequence rather than a flag somewhere else:
export function followUpDue(lastSentAt: Date, n: number, allowed: number, timeZone: string, random?: () => number): Date | null {
if (n >= Math.min(allowed, MAX_FOLLOW_UPS)) return null;
return nextSendTime(new Date(lastSentAt.getTime() + FOLLOW_UP_GAPS_DAYS[n] * DAY_MS), timeZone, random);
}
Note Math.min(allowed, MAX_FOLLOW_UPS). The brand chooses how many follow-ups it wants, and the code refuses to honour a number larger than the gaps it has, rather than reading past the end of the array.
Two more constants finish the lifecycle. A thread nobody has answered for ten days after our last email closes itself as no reply. And an out-of-office pauses rather than counts:
// How long an out-of-office pauses a thread: until the day after they're
// back, at most a month; a week when they don't say.
export function afterAutoReply(now: Date, returnDate: string | null, timeZone: string, random?: () => number): Date {
let until = now.getTime() + 7 * DAY_MS;
const back = returnDate && /^\d{4}-\d{2}-\d{2}$/.test(returnDate) ? Date.parse(`${returnDate}T12:00:00Z`) : NaN;
if (Number.isFinite(back) && back > now.getTime()) until = Math.min(back + DAY_MS, now.getTime() + 30 * DAY_MS);
return nextSendTime(new Date(until), timeZone, random);
}
The T12:00:00Z is so a date with no time in it cannot land on the wrong side of a day boundary for anybody. The one month cap is for the auto-reply that says "back in 2031". The day after, rather than the day itself, because the first morning back is the worst possible morning to arrive.
See it from the outside
The rhythm in that array is not a trick, it is the advice. Our guide on following up with a YouTuber says four days to a week for the first follow-up, a longer gap before the second, one or two in total and then stop, written for someone doing it by hand in Gmail. The scheduler implements that, including the stop. If a page of ours ever recommends a cadence the code does not follow, one of the two is a bug.
nakodo.app/how-it-works is the flow this sits inside, and the outreach template guides, such as the Instagram and TikTok ones, are the copy side of the same decision. The free tier needs no card if you want to watch a thread sit politely in a queue until Monday morning in Sydney.
Earlier posts in this series cover the thread addresses these emails are sent from and the Postgres job queue that runs the sends.
Top comments (0)