Our outreach address stopped being unguessable, so a reply now has to prove it belongs to the thread
Nakodo emails creators on a brand's behalf and each conversation has its own address on our sending domain. I wrote about why each thread gets its own address a couple of days ago: Reply-To is a suggestion that mail clie
Nakodo emails creators on a brand's behalf and each conversation has its own address on our sending domain. I wrote about why each thread gets its own address a couple of days ago: Reply-To is a suggestion that mail clients are free to ignore, so the From has to be the thing that identifies the conversation.
The address in that post was [email protected]. Twelve characters from a 31 character alphabet, about 59 bits, impossible to guess.
It is also obviously a robot. A creator who looks at the sender before replying sees a machine, and the first thing we ask them to do is treat the email as a real approach from a real brand. So the address a thread writes from is now this instead:
That reads like a mailbox at a company. It is also guessable, which is the whole subject of this post, because the moment the address stopped being a secret it stopped being proof of anything.
Two shapes, two jobs
Both addresses exist. Each thread has a random token and, once its first email goes out, a number.
The number is the public face. It is the From and the Reply-To of everything the creator receives.
The token kept the two jobs that actually need unguessability:
-
The opt-out links.
unsubscribe+<token>@nakodo.appis the mailto half ofList-Unsubscribe, and/o/<token>is the page. The shapeunsubscribe+is one no brand slug can ever produce, so a stop is never mistaken for an ordinary reply. -
The brand's own reply address. When a creator asks something only the brand can answer, we email the brand and set
Reply-Toto the token address. The brand answers that email, and its words go on to the creator from the thread, so the creator never sees the brand's address until we introduce them. Anyone who could guess that address could speak to a creator as the brand. It stays random.
You can see the first one working on a token that belongs to nothing at all: nakodo.app/o/abcdefghjkmn. It loads, and it asks you to confirm rather than acting on the GET, because mail security scanners open every link in an email before a human sees it. An unsubscribe that fires on page load is an unsubscribe that fires in a spam filter.
The check that replaced the secret
Our domain's MX points at our inbound provider, so every address at it reaches one webhook:
$ dig +short MX nakodo.app
10 inbound-smtp.us-east-1.amazonaws.com.
A catch-all plus a guessable local part means anyone can send mail to [email protected] and have it land on a real thread. If we treated that as the creator's reply, a stranger could end a conversation, answer on the creator's behalf, or make a brand believe a creator said yes.
So mail to a numbered address only counts as the thread's when it can show it came from the conversation:
async function fromTheThread(thread: Thread, fromAddress: string, headers: Record<string, string>): Promise<boolean> {
if (fromAddress && thread.emailHash && emailHash(fromAddress) === thread.emailHash) return true;
const cited = new Set([...messageIdsIn(headers["in-reply-to"]), ...messageIdsIn(headers["references"])]);
if (cited.size === 0) return false;
return (await threadMessageIds(thread.id)).some((id) => cited.has(id));
}
Two ways to pass. Either the sender is the creator we wrote to, compared as a SHA-256 hash rather than as the address, or the mail cites one of the Message-IDs we sent in that thread. The hash is the durable half: the address itself is cleared 30 days after a conversation ends, while the hash stays, so a reply that arrives long after we have stopped holding an address still matches the thread it belongs to. Those IDs are only known to someone holding the emails, which is the creator and anyone they forwarded to, which is exactly the set of people whose reply we want.
Anything else is not an error. It is just mail:
if (match.kind === "mailbox" && !(await fromTheThread(thread, fromAddress, headers))) return { handled: false };
handled: false means it falls through to the ordinary catch-all handling and gets forwarded like any other message to the domain. Nothing is dropped, nothing is attributed to a creator who did not write it.
The token path has no such check, because arriving at a 59 bit address is the proof.
Numbering without a sequence
Each brand's numbers start at 1 and go up, which means the number is per brand slug rather than global. There is no sequence to use, because the set of numbers that matter is defined by a string prefix:
export async function claimMailbox(threadId: string, brand: string): Promise<string | null> {
const prefix = mailboxPrefix(brand);
return withLock(`mailbox:${prefix}`, async (tx) => {
const [{ last }] = await tx
.select({ last: sql<number>`coalesce(max(substr(${outreachThreads.mailbox}, ${prefix.length + 1}::int)::int), 0)` })
.from(outreachThreads)
.where(sql`starts_with(${outreachThreads.mailbox}, ${prefix}) and substr(${outreachThreads.mailbox}, ${prefix.length + 1}::int) ~ '^[0-9]+$'`);
const [set] = await tx
.update(outreachThreads)
.set({ mailbox: `${prefix}${Number(last) + 1}` })
.where(and(eq(outreachThreads.id, threadId), isNull(outreachThreads.mailbox)))
.returning({ mailbox: outreachThreads.mailbox });
if (set) return set.mailbox;
return (await tx.query.outreachThreads.findFirst({ where: eq(outreachThreads.id, threadId), columns: { mailbox: true } }))?.mailbox ?? null;
});
}
Three things in there are load-bearing.
The lock is pg_advisory_xact_lock(hashtext('mailbox:acme-coffee-outreach-')), so it is held per brand name and released when the transaction ends. Two threads for the same brand serialise. Two threads for different brands do not wait for each other at all.
The regex in the where is not decoration. ::int on a non-numeric string raises, and a max() over a column that could contain acme-outreach-x would take the whole send down. The condition filters the rows before the cast sees them.
The update says where mailbox is null. That makes it idempotent: a thread that already has a number keeps it, and the fallback read returns it. Sending is retried by a job queue, and a retry that renumbered the conversation would change the From halfway through a thread. A unique index on the column is the backstop if all of that is ever wrong.
The migration that is not a migration
Old threads have a token address and no number. They were not rewritten, because the token path still works: a reply to an address a creator already has in their inbox arrives, matches on its token, and is handled exactly as before. The number is claimed by the next first email, not by a backfill.
async function fromAddress(thread: Thread, brand: string): Promise<string> {
thread.mailbox ??= await claimMailbox(thread.id, brand);
if (!thread.mailbox) throw new SendError("The conversation was deleted.", false);
return `${thread.mailbox}@${OUTREACH_DOMAIN}`;
}
Two address shapes forever is a real cost. It is cheaper than a migration that invalidates addresses sitting in other people's mailboxes, which is a thing you cannot do, because you do not control where your old From has been saved.
The tests worth having
The parser is pure, so the routing rules are testable without a mailbox:
assert.equal(mailboxFromAddress("[email protected]", "nakodo.app"), "cogniprep-outreach-12");
assert.equal(mailboxFromAddress("[email protected]", "nakodo.app"), "cogniprep-outreach-12");
assert.equal(mailboxFromAddress("[email protected]", "nakodo.app"), null);
assert.equal(mailboxFromAddress("[email protected]", "nakodo.app"), null);
assert.equal(mailboxFromAddress("[email protected]", "nakodo.app"), null);
assert.equal(tokenFromAddress("[email protected]", "nakodo.app"), null);
Case folding and plus addressing, because senders do both to an address they were given. A different domain is nobody's thread. -outreach-0 is refused because numbering starts at 1, so a zero is someone guessing. A bare outreach-3 with no brand part is refused for the same reason. And a numbered address must not also parse as a token, or the two paths would disagree about which check applies.
The ordering matters too: opt-out wins over everything, then the mailbox, then the token. A creator who replies and copies the unsubscribe address is unsubscribing.
If you want to see what the creator sees at the other end, how it works spells out what we send, what we never collect, and what happens when someone says no.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.