Nakodo signs a brand up, reads their website, writes them a campaign brief, and then waits for them to press launch. Some people never press it. So there are two reminder emails, a day and three days after sign-up, and that is the entire feature.
The dangerous part of a feature like that is not the email. It is the first cron run after you deploy it.
A query for "accounts with no launched campaign, created more than a day ago" is true of every dormant account the product has ever had. Deploy it on a Tuesday and on Tuesday morning a few hundred people who signed up in July, looked around and left get a cheerful nudge about the campaign they abandoned a quarter ago. It is technically correct, it reads as a mail-merge accident, and it is the kind of thing that gets a sending domain complained about.
So the constant that actually matters in this file is not the schedule:
const REMINDERS = [1, 3]; // days after signing up
// Accounts older than this at their first reminder are left alone: one that
// signed up weeks before the reminders existed doesn't get one out of the blue.
const FIRST_WITHIN_DAYS = 7;
Seven days is not tuned. It is "about a week", wide enough that a short outage or a paused cron does not silently drop a cohort, narrow enough that nobody receives their first ever reminder about something they did a month ago. Shipping the feature therefore reached accounts from the previous week and nobody else, and it needed no backfill flag, no migration and no "created after deploy" timestamp, which is the usual version of this guard and ages into a mystery constant in a config table.
The window is in SQL, and the rule is in TypeScript
const reminders = sql<number>`(select count(*)::int from ${auditLog} a
where a.user_id = ${authUsers.id} and a.action = 'user.setup_reminded')`;
const rows = await tx
.select({ id: authUsers.id, email: authUsers.email, fullName: profiles.fullName, createdAt: authUsers.createdAt,
off: sql<boolean>`coalesce('setup_reminder' = any(${profiles.emailsOff}), false)`,
sent: reminders })
.from(authUsers)
.leftJoin(profiles, eq(profiles.id, authUsers.id))
.where(
and(
isNotNull(authUsers.emailConfirmedAt),
isNull(profiles.onboardedAt),
gte(authUsers.createdAt, new Date(now - (FIRST_WITHIN_DAYS + REMINDERS[1]) * DAY_MS)),
lt(authUsers.createdAt, new Date(now - REMINDERS[0] * DAY_MS)),
),
);
The SQL narrows to a band of accounts: confirmed address, never launched anything, and created between one day and ten days ago. Ten, because the widest possible candidate is an account that was already FIRST_WITHIN_DAYS old when it got its first reminder and is now due the second one three days later. Get that bound wrong in the tight direction and the second reminder silently never sends for anyone who was close to the edge.
Then the exact decision is made in TypeScript, per row:
const ready: Reminder[] = rows.flatMap((r) => {
const age = now - (r.createdAt?.getTime() ?? now);
const n = r.sent + 1;
if (!r.email || n > REMINDERS.length || age < REMINDERS[n - 1] * DAY_MS) return [];
if (n === 1 && age > FIRST_WITHIN_DAYS * DAY_MS) return [];
return [{ id: r.id, email: r.email, fullName: r.fullName, off: r.off, n }];
});
Four refusals, in the order that makes them readable: no address at all, both reminders already sent, not old enough for this one yet, and the backfill guard, which applies only to a first reminder. An account that got reminder one inside the window stays eligible for reminder two even once it is older than seven days, because by then the conversation has already started.
Doing it this way rather than as one clever WHERE clause is deliberate. The query is a filter, the function is the policy. The policy is the thing I will be re-reading in six months when somebody asks why an account did or did not get an email, and n > REMINDERS.length reads like the sentence it is.
The audit row is the thing that stops a second send
There is no reminders_sent column. The count comes from the audit log, which already records 67 kinds of action across the app, and the row is written inside the same lock and transaction as the read, before anything is sent:
if (ready.length > 0) {
await tx.insert(auditLog).values(
ready.map((r) => ({
userId: r.id, actor: "system" as const, action: "user.setup_reminded" as const,
entityType: "user" as const, entityId: r.id,
metadata: r.off ? { n: r.n, off: true } : { n: r.n },
})),
);
}
return ready;
Three things fall out of that ordering.
A crash cannot double-send. The transaction commits the "we are about to send reminder 1" fact first, so the worst case is an email that never goes out, rather than two that do. For a nudge, the asymmetry is obvious: a missing reminder costs one launch, a duplicate costs trust.
Concurrency is a lock, not a unique index. The read and the write are inside withLock("setup_reminders", ...), select pg_advisory_xact_lock(hashtext($key)) for the whole job rather than a per-user lock, because this job is a single cron sweep and the only writer that matters is a second copy of itself.
Switching the email off does not store up a debt. Look at that metadata branch: a user who has turned setup reminders off in their settings still gets an audit row, marked off: true, and the loop then skips them:
for (const r of due.filter((p) => !p.off)) { ... }
Without the row, turning reminders back on a week later would make all their missed reminders suddenly due. That is the same bug as the deploy-day one, scoped to one person, and it has the same shape of fix: record that the moment passed, whether or not anything was delivered.
The text is a pure function, so the tests read like English
Nothing in the message-writing code touches the database:
export type NudgeEmail = { subject: string; paragraphs: string[]; button: { path: string; label: string }; reason: string };
export function setupReminderEmail(fullName: string | null, draft: SetupDraft | null, last: boolean): NudgeEmail
Two branches, because the two states a stalled account can be in deserve different first sentences. If the brief is written and waiting, the subject names it, and the button goes to the review screen:
Your brief for [brand] is ready to launch
Nakodo read [domain] and wrote your campaign's brief. Check it, change anything that's off, and launch.
If there is no campaign at all, the ask is the one field we need:
Give Nakodo your website and it does the rest
last appends "This is the last reminder." to the second one, which is the only honest thing to do when there is no third.
Every one of these emails also carries a reason line, rendered at the bottom next to the settings link: "You get this when your account has no campaign running yet. Twice at most." Writing the rule into the email is a small discipline with a large effect, because the sentence has to be true, and "twice at most" is only true if REMINDERS.length is the cap and the audit count is trustworthy. The copy audits the code.
The same idea again, at a monthly boundary
The second email in this file is for accounts on the free plan whose month is used up. Free contacts ten new creators a month; when the tenth is contacted, campaigns pause themselves with a reason:
const paused = await db
.selectDistinct({ userId: campaigns.userId })
.from(campaigns)
.where(and(eq(campaigns.status, "paused"), eq(campaigns.pauseReason, "outreach_limit"), isNotNull(campaigns.userId)));
A pause is not enough to send on, though, and the comment in the code is the whole lesson:
// Read through getSubscription, which moves a month that has ended on. A
// campaign paused last month stays paused into this one (they resume it),
// so the month has to be used up now, not just the pause still standing.
const sub = await getSubscription(userId);
const limit = plan.limits.creatorsPerMonth;
if (limit === null || (await outreachAllowance(userId)).monthLeft > 0) continue;
A paused row is a fact about the past. The allowance is a fact about now. Sending on the first would mail somebody on the 2nd of the month to tell them they have run out, while they have a fresh ten waiting.
Dedupe for this one is per billing period rather than per account, and again it is an audit row, written in the transaction:
.where(and(eq(auditLog.userId, userId), eq(auditLog.action, "user.limit_emailed"), gte(auditLog.createdAt, sub.currentPeriodStart)))
So the email can be sent once in each of the customer's own months, which drift with their sign-up date, and a plan change that moves the period start does not unlock a second one inside the same period.
The email itself follows the pattern the brand document sets for every limit in the product: what happened with its number and the date it comes back, what still works, then what the next plan gives with its number, directly above the button.
This month's 10 new creators on Free are all taken, so your campaigns have stopped finding and writing to new ones. You can resume them on 1 Nov, when the month's 10 come back.
Conversations carry on: Nakodo still follows up, answers questions and introduces you to anyone who says yes. 3 creators have replied so far.
The middle paragraph is the one that took thought. A limit email that only says "you are out" invites the reader to assume everything stopped, and in this product everything did not stop: the conversations already running keep running. Telling them that is both true and the best argument for the upgrade sentence underneath it.
One daily job, and two emails that must not say the same thing twice
All of this runs from one cron entry:
{ "path": "/api/cron/maintenance", "schedule": "15 9 * * *" }
Inside it, the reminders and the limit email go before the daily digest, on purpose, because the digest has a line about the plan too:
// For a Free account's digest while it can still write to new creators: how
// many good fits wait beyond the month's allowance, and what the next plan
// gives. Null on other plans, with nothing waiting, or once the limit email
// has said it this month.
export async function digestPlanLine(userId: string): Promise<string | null> {
Its first refusal is the limit email's own audit row, within the last 20 hours:
const [said] = await db.select({ id: auditLog.id }).from(auditLog)
.where(and(eq(auditLog.userId, userId), eq(auditLog.action, "user.limit_emailed"),
gte(auditLog.createdAt, new Date(Date.now() - 20 * HOUR_MS))));
if (said) return null;
Twenty hours rather than twenty four, because a cron that drifts by a few minutes must not open a gap a second email can fall through. The audit log is doing its third job here: a record, a dedupe key, and now a cross-feature mute.
What I would keep
Three habits, all of them cheap:
A constant that bounds how far into the past a new lifecycle email may reach, named after what it protects rather than what it filters. A row written before the send, in the same transaction as the decision, even for recipients who will not receive anything. And the message as a pure function returning subject, paragraphs and button, so that the tests assert whole sentences and the reason line in the footer can state the rule to the person receiving it.
The product side of this is on the pricing page and in how it works, if you want to see the numbers these emails quote.
Top comments (0)