DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our mail provider sends us six kinds of delivery event, and only one of them is allowed to be quiet

Nakodo runs email conversations with people who did not ask to be emailed. A brand describes what it sells, Nakodo finds creators whose audience fits, writes to the ones who published a business address, follows up when there is no reply, and introduces the ones who say yes. Nobody at the brand touches the sending.

That means the only thing standing between us and being a nuisance is how carefully the app reads what the mail provider tells it afterwards. There are six event types:

const EVENT_TYPES = [
  "email.sent",
  "email.delivered",
  "email.bounced",
  "email.complained",
  "email.failed",
  "email.suppressed",
] as const;
Enter fullscreen mode Exit fullscreen mode

Three of them can end a conversation permanently and add somebody to a list that stops us ever writing to them again. One is pure bookkeeping. The interesting work is in telling those apart precisely, because both mistakes are bad in different directions: treat a recoverable failure as final and you drop a creator who would have replied, treat a final one as recoverable and you keep mailing an address that has already told you to stop.

Not every bounce is a bounce

case "email.bounced": {
  // Soft bounces are retried by the receiving side; only a hard one ends it.
  if (event.data.bounce.type !== "Permanent") return true;
  await setEmail({ status: "bounced", error: event.data.bounce.message });
  if (toCreator && thread.status !== "handed_off" && !isFinal(thread)) {
    await suppressCreator(thread, "bounced");
    await closeThread(thread.id, "bounced", "Their address doesn't accept mail.");
  }
  return true;
}
Enter fullscreen mode Exit fullscreen mode

The one line that matters is the early return. A full mailbox, a greylisting server, a temporary DNS failure and a rate-limited receiver all arrive as bounce events, and all of them mean "try later" rather than "this address does not exist". Suppressing on a transient bounce would quietly delete a real creator from the product, and the brand would never know it had happened, because the conversation would simply be closed with a plausible reason.

The note written onto the thread is deliberately in the brand's language rather than the provider's. The brand sees "Their address doesn't accept mail", not an SMTP code. The provider's own message goes into the email row for us to read.

Suppression is a different act from closing

Three different things happen when a conversation ends badly, and they have different scopes:

export async function suppressCreator(
  thread: Pick<Thread, "id" | "emailHash" | "channelId">,
  reason: "opted_out" | "bounced" | "complained",
  opts: { channel?: boolean } = {},
): Promise<void> {
  if (!outreachLive()) return;
  await suppress(
    [
      ...(thread.emailHash ? [{ kind: "email" as const, value: thread.emailHash }] : []),
      ...(opts.channel ? [{ kind: "channel" as const, value: thread.channelId }] : []),
    ],
    reason,
    thread.id,
  );
}
Enter fullscreen mode Exit fullscreen mode

Closing a thread ends one conversation between one brand and one creator. Suppression is global and outlives it: that address is never written to again by anyone on the platform. The address is stored as a hash, because the plain address is cleared 30 days after a conversation ends while the suppression has to keep working forever.

And opts.channel is the distinction I would most want to get right if I were building this again. A bounce suppresses the address. An opt-out or a spam complaint suppresses the address and the creator's channel, so a different brand that finds the same creator and a different published address for them still does not write. Someone who said "do not email me" meant it about themselves, not about one mailbox they happen to use.

case "email.complained":
  await setEmail({ status: "complained" });
  if (toCreator) {
    await suppressCreator(thread, "complained", { channel: true });
    if (thread.status !== "handed_off") await closeThread(thread.id, "complained", "They marked an email as spam.");
    await audit({ action: "outreach.opted_out", entityType: "outreach_thread", entityId: thread.id, metadata: { via: "complaint" } });
  }
  return true;
Enter fullscreen mode Exit fullscreen mode

A complaint is recorded in the audit log under the same action as a deliberate unsubscribe, with via: "complaint". They are the same event with different manners.

The provider's own list is a sixth answer

case "email.suppressed":
  // Resend's own list: the address bounced or complained for someone before.
  await setEmail({ status: "failed", error: `Not sent: ${event.data.suppressed.message}` });
  if (toCreator && thread.status !== "handed_off" && !isFinal(thread)) {
    await suppressCreator(thread, "bounced");
    await closeThread(thread.id, "suppressed", "Their address doesn't accept mail.");
  }
  return true;
Enter fullscreen mode Exit fullscreen mode

This one surprised me when I first saw it in production. The provider keeps its own suppression list, and an address can be on it because of something that happened to a different sender entirely. The email never left. From our side it looks like an instant refusal with no SMTP conversation behind it.

We mirror it into our own list rather than just recording the failure, because the alternative is retrying forever against a decision made somewhere we cannot see. "suppressed" is one of nine closed reasons, so the thread says exactly which of these paths it took:

export const OUTREACH_CLOSED_REASONS = [
  "no_reply", "declined", "opted_out", "bounced",
  "complained", "cancelled", "suppressed", "no_email", "failed",
] as const;
Enter fullscreen mode Exit fullscreen mode

Nine values in one enum is more than most people would write, and every one of them has a different sentence next to it in the UI and a different consequence in the sending rules. declined blocks the same brand from approaching that creator again for 180 days. bounced and suppressed are permanent for the address. no_reply is not a refusal at all and the creator can be approached again by another brand.

Three guards that appear in every branch

Read the snippets above again and the same three conditions keep showing up.

toCreator. Our test mode sends outreach to the account owner instead of the creator, and a handoff has the brand in copy. So a bounce from an address that is not the creator's says nothing about the creator:

const toCreator = !!thread.emailHash && event.data.to.some((a) => emailHash(parseFromAddress(a) || a) === thread.emailHash);
Enter fullscreen mode Exit fullscreen mode

Without that check, a developer with a full mailbox testing locally would suppress a real creator in the shared list.

thread.status !== "handed_off". Once we have introduced the creator to the brand, the conversation belongs to the two of them and we have stepped out. A bounce on a later message in that thread is their problem to see, not ours to act on. We still record it.

!isFinal(thread). Four of the nine reasons are final:

const FINAL_REASONS: OutreachClosedReason[] = ["opted_out", "bounced", "complained", "suppressed"];
Enter fullscreen mode Exit fullscreen mode

A thread already closed for one of those is not reopened or reclosed by a later event, and closeThread enforces that in SQL rather than trusting the caller:

.where(and(
  eq(outreachThreads.id, threadId),
  or(
    ne(outreachThreads.status, "closed"),
    isNull(outreachThreads.closedReason),
    notInArray(outreachThreads.closedReason, FINAL_REASONS),
  ),
))
Enter fullscreen mode Exit fullscreen mode

Webhooks are delivered more than once, out of order, and sometimes days late. A write that is only correct when the events arrive in the right order is a write that will be wrong.

Nothing is allowed to fall off the end

Two defences, because the webhook is the one part of this that is not under our control.

The first is that the handler returns a boolean rather than nothing:

export async function handleOutreachEvent(event: WebhookEventPayload): Promise<boolean> {
  if (!isDeliveryEvent(event)) return false;
  const tags = event.data.tags ?? {};
  if (tags.category !== "outreach") return false;
  // ...
Enter fullscreen mode Exit fullscreen mode

false means "this is not mine", and the caller falls through to the ordinary handling for account mail and notices. There is no default branch that silently swallows an event type we have not thought about, and the switch over event.type is exhaustive against the union, so adding a seventh event type to the array is a type error rather than a surprise in production.

The second is a sweeper, for the gap between storing an inbound reply and queueing the job that reads it:

export async function requeueStuckReplies(): Promise<number> {
  const rows = await db
    .select({ id: outreachEmails.id, campaignId: outreachThreads.campaignId })
    .from(outreachEmails)
    .innerJoin(outreachThreads, eq(outreachThreads.id, outreachEmails.threadId))
    .where(and(
      eq(outreachEmails.direction, "in"),
      isNull(outreachEmails.classification),
      lt(outreachEmails.createdAt, new Date(Date.now() - 10 * MINUTE_MS)),
      gte(outreachEmails.createdAt, new Date(Date.now() - 2 * DAY_MS)),
      ne(outreachThreads.status, "handed_off"),
      sql`not exists (select 1 from ${jobs} where ${jobs.dedupeKey} = ${jobKeySql("reply", outreachEmails.id)})`,
    ))
    .limit(200);
  // ... enqueue them
}
Enter fullscreen mode Exit fullscreen mode

The webhook writes the reply, then enqueues a job. A process that dies between those two statements leaves a reply nobody will ever read, which from the creator's point of view means they wrote to a brand and got silence. The sweeper finds inbound emails older than ten minutes with no classification and no job, and queues them.

The two bounds are both deliberate. Ten minutes, because a reply that arrived thirty seconds ago probably has a job in flight. Two days, because beyond that the problem is not a lost job and re-queueing a week-old backlog in one go is its own incident.

From the other side

The parts of this a creator experiences are all documented in the open, which is the only version of automated outreach I am willing to build: how Nakodo works covers what we send, how follow-ups stop, and what happens when someone asks not to be contacted. Every email carries both halves of List-Unsubscribe, and the page behind the link is live for any token at all, including an invented one: nakodo.app/o/abcdefghjkmn. It asks you to confirm instead of acting on the page load, because mail scanners open links before people do.

The privacy page is where the retention side is written down, including the bit above about keeping a hash after throwing the address away.

Top comments (0)