A campaign in Nakodo runs unattended. It searches, writes to contacts, reads replies, and the brand is meant to be able to ignore it for a week. The product promise on how it works only holds if the app can tell you what it did without you opening it, so there is one email a day.
Four emails about campaigns exist, and the comment at the top of the file that lists them is the rule that shapes all of this:
// The emails Nakodo sends a brand about its campaigns. Each can be turned off
// under Settings → Emails (profiles.emails_off); they're all on until then.
// Whatever one says also waits in the app, so turning it off loses nothing.
Every notification is a copy of something already on a page. That makes sending nothing always safe, and it is why the digest's first job is deciding not to exist.
Six counters, and one of them does not count
export type DigestNews = {
emailed: number; // first emails that went out
replies: number; // replies from a person, not an auto-reply
answered: number; // replies Nakodo answered itself
intros: number; // introductions it made
needsYou: number; // conversations waiting on the brand now
inReview: number; // first emails waiting for approval now
};
export const hasNews = (n: DigestNews): boolean =>
n.emailed + n.replies + n.intros + n.needsYou + n.inReview > 0;
Five of the six are added up. answered is left out, and not by accident: an answer can only follow a reply, and the reply is already counted, so adding answered could never change the result. Leaving it in the sum would be a term that is always dominated, which is the kind of line that survives a refactor and then quietly becomes wrong.
Note also that the last two counters are not about the last day at all. needsYou and inReview are present state: conversations waiting on the brand now, however long they have been waiting. So a day where Nakodo did nothing but something is still sitting on your desk has news, and a day where it did nothing and nothing is waiting does not.
The words are a pure function
digestLines takes those six numbers and returns a subject and two paragraphs. No database, no user, no template engine:
const did: string[] = [];
if (n.emailed) did.push(`wrote to ${plural(n.emailed, "new contact")}`);
if (n.replies) did.push(`read ${plural(n.replies, "reply", "replies")}`);
if (n.answered) did.push(`answered ${n.answered === 1 ? "one of them" : `${n.answered} of them`}`);
if (n.intros) did.push(n.intros === 1 ? "introduced you to one of them" : `introduced you to ${n.intros} of them`);
plural is one line and does the thousands separator too, so a busy week reads "1,204 new contacts" rather than "1204". The === 1 branches are there because "answered 1 of them" is the sort of thing a program says and a person does not.
Which means the tests are assertions about English, and they are the most useful tests in the file:
assert.equal(
busy.paragraphs[0],
"In the last day, Nakodo wrote to 8 new contacts, read 2 replies, answered one of them and introduced you to one of them.",
);
assert.equal(busy.paragraphs[1], "Nothing needs you: it carries on by itself.");
assert.equal(busy.subject, "1 introduction for you");
The second paragraph is the one I would not have thought to write. When something is waiting, it says what. When nothing is, it says that nothing is, because the whole value of an unattended tool is the sentence that tells you no action is required. An email that lists achievements and then stops leaves you wondering whether you were supposed to do something.
The subject is a precedence ladder rather than a summary:
const subject = n.intros
? `${plural(n.intros, "introduction")} for you`
: n.needsYou
? `${plural(n.needsYou, "conversation")} waiting on you`
: n.replies
? ...
Introductions first, because that is the thing the product is for. Then what is waiting on you, then replies, then new contacts, and only then the emails waiting for approval. The comment above it says "never a count of nothing", and there is a test with a note on it recording why:
// Seen on real data: a day whose only news was emails waiting for approval
// used to be subjected "0 new contacts in the last day".
That is what a naive subject line does. It picks the first counter in the struct and prints it, and on the one day the user most needs to open the email it opens with a zero.
Two queries, and an interval that cannot be a parameter
The counting is two grouped queries, one over the day's emails and one over thread state, each using count(*) filter (where ...) so a user's whole row comes back at once:
intros: sql<number>`count(*) filter (where ${outreachThreads.handedOffAt} >= now() - ${sql.raw(`interval '${SINCE_HOURS} hours'`)})::int`,
The sql.raw there is ugly and the comment next to it explains why:
// The window as an interval, not a parameter: a raw Date in a sql``
// template is passed to the driver unmapped, which refuses it.
A Date interpolated into a Drizzle sql template is not a typed column reference, so nothing maps it to a timestamp and the driver rejects it. Options are to format the date yourself, or to let Postgres compute the window from a constant that is not user input. SINCE_HOURS is a number literal in the same file, so the interval is safe to inline, and now() is what the rest of the query is comparing against anyway.
Both results are merged into a Map<string, DigestNews> keyed by user, then the users with news are joined to their email address and their off switch:
off: sql<boolean>`coalesce('daily_digest' = any(${profiles.emailsOff}), false)`,
The insert is the lock, and the opt-out still gets a row
Sending is a separate function, because digestNews is reads only and that is a property worth keeping. It can be called from a script to see exactly what today's digests would say, for every account, without sending anything.
The sender wraps the decision in an advisory lock and an audit insert:
const due = await withLock("daily_digest", async (tx) => {
const people = await digestNews(tx);
const already = await tx.select(...).where(and(
eq(auditLog.action, "user.digest_emailed"),
inArray(auditLog.userId, people.map((p) => p.id)),
gte(auditLog.createdAt, new Date(Date.now() - APART_HOURS * HOUR_MS)),
));
const ready = people.filter((p) => !done.has(p.id));
if (ready.length > 0) await tx.insert(auditLog).values(ready.map(...));
return ready;
});
There is no digest_sent_at column. The audit log row that records the send is also the thing that prevents the next one, which means the "have they had today's" question is answered by the same write that answers "what did we tell them", and the two cannot disagree.
The two constants are 24 and 20:
const SINCE_HOURS = 24;
const APART_HOURS = 20;
The news window is a day. The dedupe window is shorter than a day on purpose, because the cron does not fire at the same second every day and a retry can fire minutes later. Twenty hours is wide enough that nothing sends twice and narrow enough that a digest is never skipped because yesterday's ran late.
The detail I like most is that an opted out user gets the audit row anyway:
metadata: r.off ? { ...r.news, off: true } : r.news,
They are filtered out of the actual sending by due.filter((p) => !p.off). But their row is written, with a flag and with the news they did not get, which does two things: the log tells you what the system decided rather than only what it did, and switching the digest back on at lunchtime does not produce a second copy of a morning you already missed.
Finally, the send loop catches per person:
await sendEmail({ ... }).catch((e) => console.error("Digest email failed", r.id, e));
One address that bounces at the provider must not stop the other digests. This is a background job with no user watching it, and the only wrong answer is for the whole run to die on the first failure.
That is about 200 lines all in, and two thirds of it is deciding whether to speak. The things it reports on, what gets counted and what a plan allows in a day, are described on pricing, and the longer argument for why a tool like this should run without supervision is in the piece on automated outreach.
Top comments (0)