DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A quiet day sends no email, and an audit row is the only thing stopping a second one

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.
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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`);
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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
      ? ...
Enter fullscreen mode Exit fullscreen mode

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".
Enter fullscreen mode Exit fullscreen mode

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`,
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)`,
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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,
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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)