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
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;
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;
}
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,
);
}
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;
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;
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;
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);
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"];
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),
),
))
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;
// ...
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
}
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.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.