Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

Two emails about a new campaign, and whichever one goes first cancels the other for good

A campaign in Nakodo is meant to be launched and then left alone, which how it works describes from the outside. The awkward part of that promise is the first hour. You press launch, a progress line starts moving, and th

A campaign in Nakodo is meant to be launched and then left alone, which how it works describes from the outside. The awkward part of that promise is the first hour. You press launch, a progress line starts moving, and then you close the tab, and whatever happens next has to reach you by email.

Two things can happen next. Either the first good fits arrive, or a day passes and none do. So there are two emails, written by two functions in the same file, and most of the design is in making sure you never get both.

The good one waits for a quorum, or an hour

The naive version sends as soon as there is one result. That is a worse email than it sounds: a campaign's first fit often arrives minutes after launch, while you are still looking at the screen that produced it, and an email telling you about one creator when thirty are coming is an email that trains you to ignore the next one.

const ENOUGH_TO_EMAIL = 5; // good fits; fewer is still sent an hour after launch
const WAIT_FOR_MORE_MS = 60 * 60 * 1000;

const ready = rows.filter(
  (r) => r.email && (r.fit >= ENOUGH_TO_EMAIL
    || (r.launchedAt && Date.now() - r.launchedAt.getTime() > WAIT_FOR_MORE_MS)),
);

A quorum with a timeout. Five good fits is worth an email immediately. Fewer is worth an email once the first hour is over, because by then the number is news rather than a progress indicator. Both senders run at the tail of every pipeline run, which is itself on a cron, so both branches are arithmetic on a timestamp the campaign already has:

// Best effort: a failed email mustn't fail the run.
await notifyFirstResults().catch((e) => console.error("Results email failed", e));
await notifyNoFits().catch((e) => console.error("No-fits email failed", e));

The query that feeds it also refuses to look back very far:

gte(campaigns.launchedAt, new Date(Date.now() - 14 * DAY_MS)),

Anything launched more than a fortnight ago is not a new campaign, and nothing that happens to it now should produce a "your first results are in" email. That line is also the safety rail for the day somebody runs a backfill: the blast radius of this email is two weeks wide, by construction.

The exclusion lives in the log, not in a column

There is no resultsEmailedAt column on the campaign. Both senders ask the audit log whether this campaign has already been written about, and the no-fits one asks about both actions at once:

sql`not exists (
  select 1 from ${auditLog} a
  where a.action in ('campaign.results_emailed', 'campaign.no_fits_emailed')
    and a.entity_id = ${campaigns.id}::text
)`,

That one in (...) is the whole mutual exclusion. A campaign that got the good email is permanently ineligible for the bad one, which matters because a campaign can genuinely go from five fits to a dry spell. And a campaign that got the bad one is ineligible for both, so the apology arrives once and is never followed by a cheerful correction a day later.

The two functions also take the same advisory lock:

const due = await withLock("results_email", async (tx) => { /* ... */ });

Not one lock each. One lock for the pair, named after the subject rather than the sender, because the thing that must not interleave is not "two runs of this function" but "two statements about the same campaign". Inside it, the rows are selected and the audit rows are inserted in the same transaction, so a second worker arriving mid-flight sees the row that makes it skip. The audit row doubling as the dedupe key is a pattern these emails all share, including the ones that are turned off in settings, which stops a switch being turned back on from releasing a flood of late mail.

The good email ends with whatever the next real step is

An email that says "you have results" and links to them is fine. An email that says what to do next has to know what the campaign is waiting for, and there are three different answers:

c.mode !== "auto"
  ? `Open the campaign to see why each one fits, and choose who Nakodo writes to.`
  : c.hasOffer
    ? `Nakodo is writing to the best of them, a few each day.${
        autoSendsSaved(c.approval) ? "" : " Their first emails wait for your approval on the Outreach tab."}`
    : `Save your proposal on the campaign's Outreach tab, and Nakodo starts writing to the best of them.`,

Three branches and a nested fourth, from two facts the query already had to fetch: whether the campaign picks its own recipients, and whether the offer it would make has been saved yet. The worst of the three, by a distance, is the last one: a campaign that found good fits and cannot act on them because something is missing. That sentence is the only reason this email is worth sending at all to that user, and it is one hasOffer away from being lost in a cheerful generic paragraph.

Both facts come straight out of SQL on a JSONB column, which is why they cost nothing:

hasOffer: hasOfferSql(campaigns.outreach),
approval: sql<string | null>`${campaigns.outreach}->>'approval'`,

The bad email names a number and a next move

The no-fits email is the one I rewrote most, because the first version was a shrug. It now says how far the search actually got, whether it is still widening by itself, and the single change that helps most for that kind of campaign:

const looked = c.checked > 0 ? `has checked ${plural(c.checked, w.one, w.many)}` : "has been looking";
// ...
`Nakodo ${looked} for ${c.name} since you launched it, but none fit well enough to write to yet. It keeps looking`
  + (c.widen ? ", and widens the search by itself when it runs dry." : "."),
c.kind === "businesses"
  ? "What helps most is more kinds of business or more places to look, under Businesses and places on the brief, or a looser Ideal business."
  : "What helps most is broader search keywords on the brief: the words creators use about their own posts, rather than your product's. A looser Ideal creator helps too.",

checked is a correlated subquery counting the rows that have been scored at all, which turns "nothing fits" into "it looked at 180 and none fit", and those are different emails to receive. The widen clause exists because the sentence would otherwise be a lie for half the accounts: a campaign with widening on is already doing the obvious thing, and telling that user to go and do it by hand is worse than saying nothing.

Both emails link to a specific tab rather than the app: the good one to the campaign filtered to its fits, the bad one straight to the brief, which is the page the email is asking you to change.

The finding

The two sends are not written the same way, and only one of them is right:

}).catch((e) => console.error("No-fits email failed", c.id, e));

That is the no-fits loop. The first-results loop awaits sendEmail with no catch of its own, so a single failure throws out of the loop and lands in the call site's catch above, which keeps the run alive and abandons the rest of the batch. The audit rows for that batch are already committed by then, so one provider hiccup on one address does two bad things: it marks every remaining campaign in the batch as emailed without emailing it, and it does it to the one email in the product that only ever gets one chance. The fix is the sibling's catch, and the better fix is writing the audit row per campaign as each send succeeds, at the cost of the single-statement dedupe that makes the pair safe to run twice at once. I would take the catch now and think about the second part later.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.