DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A creator with no published email costs us nothing now, because the lookup moved to the front of the pipeline

Nakodo finds creators for a brand and emails the ones the brand picks. The work per creator runs as a chain of Postgres-queued jobs: read the profile and the recent uploads, apply the campaign's filters, read a sample of comments, score the result, write the reasons, draft the email.

Two of those steps cost real money. Reading comments spends YouTube API quota and one AI call to label them. Writing the reasons is another AI call. Both of them ran before anyone asked the only question that decides whether the result is usable at all: does this creator publish an email address?

We only ever write to an address a creator has published themselves, which is spelled out in how it works: the channel description, recent video descriptions, the bio, the profile link, and the pages those links lead to. No guessing, no pattern matching a name against a domain, no logging in as anybody. When there is nothing published, the creator is never contacted. That is the promise, and it means a creator without an email is a dead end no matter how good a fit they are.

So we were paying for quota and two AI calls on dead ends. The pipeline had been ordered by how cheap each step was, which is the wrong axis. The right one is whether the step's output can ever be used.

One gate, three answers

The fix is a single function the expensive steps call first. The interesting part is that it does not return a boolean per creator. It has three answers:

export async function withEmail(pairs: Pair[]): Promise<Pair[]> {
  if (pairs.length === 0) return [];
  const ids = [...new Set(pairs.map((p) => p.channelId))];
  const emailed = await db
    .selectDistinct({ id: channelContacts.channelId })
    .from(channelContacts)
    .where(and(inArray(channelContacts.channelId, ids), eq(channelContacts.kind, "email")));
  const hasEmail = new Set(emailed.map((r) => r.id));
  const checking = new Set(await linksToCheck(ids));
  await enqueue([...checking].map(contactsJob));
  // ... the rest get set aside
  return pairs.filter((p) => hasEmail.has(p.channelId));
}
Enter fullscreen mode Exit fullscreen mode
  • Has an email. Returned, and the caller carries on spending.
  • Might have one. Their website or link-in-bio page has not been read in the last 30 days. A contacts job is queued and the result waits. It is not returned, and it is not set aside either.
  • Neither. Set aside, with rejectionReason set to the sentence the customer reads: "No published email".

The call sites are two lines. runCommentsJob and runRationaleJob both start with:

if ((await withEmail([{ campaignId, channelId }])).length === 0) return;
Enter fullscreen mode Exit fullscreen mode

and runEnrichJobs ends with await queueComments(await withEmail(passed)) instead of queueing the comment jobs for everything that passed the filters.

The waiting state is where the bugs live

A three-state gate needs someone to collect the middle state later. That is the contacts job itself: when it has finished reading a creator's pages it looks for every result sitting at the enriched stage for that channel and runs them back through the same gate.

const waiting = await db
  .select({ campaignId: campaignChannels.campaignId, channelId: campaignChannels.channelId })
  .from(campaignChannels)
  .where(and(eq(campaignChannels.channelId, channelId), eq(campaignChannels.stage, "enriched")));
await queueComments(await withEmail(waiting));
Enter fullscreen mode Exit fullscreen mode

Which is fine while the job eventually finishes. Some creator sites are slow enough to blow the 40 second budget every single time, and a job that always throws never reaches that query, so every result waiting on it waits forever. So on the final attempt a timeout counts as a check that found nothing:

.catch((e) => {
  if (job.attempts < MAX_ATTEMPTS) throw e;
  return [];
});
Enter fullscreen mode Exit fullscreen mode

MAX_ATTEMPTS is 3. The row then gets contactsCheckedAt stamped like any other check, the gate moves it to "neither", and it is set aside with a reason rather than left in limbo. Giving up is a state, and it has to be written down.

Two places that must agree

The gate runs in TypeScript over a batch of ids. The creator library, which matches a new campaign against creators other campaigns already found, has to apply the same rule inside a much larger query, so the rule also exists as SQL:

export const mayHaveEmailSql = () => or(hasEmailSql(), linksUncheckedSql());
Enter fullscreen mode Exit fullscreen mode

One line in matchLibrary's where, and a brand new campaign's instant results no longer include creators nobody will ever be able to write to. Two implementations of one rule is a cost we keep paying deliberately in this codebase: the set logic has to run per batch with job queueing attached, and the filter has to run inside a query that never loads the rows. Both are small, both are next to each other in the same file, and the names say they are the same idea.

Do not reject someone you are already talking to

The one guard that is not about money. withEmail sets results aside, and a result can belong to a creator who is mid-conversation, because the thread's copy of the address is deleted 30 days after an introduction (FORGET_ADDRESS_DAYS). At that point the creator genuinely has no stored email, and the naive version of this change would mark a creator the brand is actively working with as rejected. So the update carries an anti-join:

notExists(
  db.select({ id: outreachThreads.id }).from(outreachThreads).where(
    and(
      eq(outreachThreads.campaignId, campaignChannels.campaignId),
      eq(outreachThreads.channelId, campaignChannels.channelId),
    ),
  ),
)
Enter fullscreen mode Exit fullscreen mode

A correlated not exists against the outer update's columns, so one statement still does the whole batch.

Businesses get the same treatment one layer over, with five reasons instead of one, because for a shop or a cafe the reason is useful on its own: no website or email listed, website unreachable, website blocks visits, contact form only, or no email on their website. Those are the sentences that end up on the row, and the business campaigns section describes the rules the addresses are filtered by.

The whole change is 115 lines added across five files. The headline is not the lines, it is the ordering: we had built a pipeline where the cheapest check came first, and the check that decides whether any of the rest matters came fourth. If you have a step that can rule a row out entirely, it goes first, even when it is the most awkward one to run.

If you want to see how the lookup itself works by hand, our guide on finding a YouTuber's email walks the same path a human would, and the Instagram version covers bios and link-in-bio pages.

Top comments (0)