DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Four ways to stop our email, and only one of them asks first

Nakodo emails creators and businesses on behalf of brands, which means every message it sends is a cold email to somebody who did not ask for it. "How do I make this stop" is therefore the most important flow in the product, and it is not one flow. There are four, and they behave differently on purpose.

The public version of this is one sentence in the privacy notice: "To stop, use the link at the bottom of any email, or reply 'stop'. No brand will email you through Nakodo again." This post is what sits behind it.

One: the link in the footer, which asks first

Every email carries a link to /o/<token>, and that page does not unsubscribe anybody. It asks.

// The link in the footer of every email Nakodo sends to a creator or business.
// Unlike the newsletter link it asks first: mail security scanners open links
// in incoming email, and one of those shouldn't speak for the recipient. Mail apps'
// own unsubscribe buttons use one-click (/api/email/opt-out) and need no page.
Enter fullscreen mode Exit fullscreen mode

That is the whole reason. A link in an inbound email is clicked by things that are not the recipient: scanners, previewers, link rewriters. If the page acted on load, a corporate mail gateway could opt out a creator who never saw the message, and the brand would never find out why that conversation went quiet.

So the page is a GET that changes nothing, and the button posts a Server Action:

export async function optOutAction(form: FormData): Promise<void> {
  const token = String(form.get("token") ?? "");
  if (token) await optOutByToken(token, "link");
  redirect(`/o/${encodeURIComponent(token)}?done=1`);
}
Enter fullscreen mode Exit fullscreen mode

The page it redirects back to reads the same token and shows the done state, so a reload is not a second opt-out and the back button is harmless.

Two details that are easy to miss. The query is a left join:

const [row] = await db
  .select({ brand: campaigns.name, kind: campaigns.kind, status: outreachThreads.status, closedReason: outreachThreads.closedReason })
  .from(outreachThreads)
  // A left join: the link still works after the brand deletes the campaign.
  .leftJoin(campaigns, eq(campaigns.id, outreachThreads.campaignId))
  .where(eq(outreachThreads.token, token));
Enter fullscreen mode Exit fullscreen mode

An email lives in somebody's archive for years. The campaign it came from might be deleted next week. An inner join would turn that footer link into "that link has expired" for a message that is still sitting in their inbox, and "expired" is the wrong answer to "stop emailing me". With the left join the thread is still there, the brand name is just missing, and the copy falls back to "one of them".

And the page tells them what they are stopping, in the language of the campaign kind:

const about =
  row?.kind === "businesses"
    ? "Nakodo introduces businesses to brands with something they might stock or use"
    : "Nakodo emails creators about partnerships on behalf of brands";
Enter fullscreen mode Exit fullscreen mode

Two: their mail app's own button, which may not ask

Gmail and Yahoo show an unsubscribe control next to the sender when the message carries List-Unsubscribe and List-Unsubscribe-Post (RFC 8058). That control does not open a page. It POSTs, with no body worth reading, and expects a success:

// One-click opt-out (RFC 8058) for outreach email. Every email to a creator
// carries `List-Unsubscribe: <this url>` and `List-Unsubscribe-Post`, and
// their mail app POSTs here when they use its own unsubscribe button. It
// always answers 200: an error would show them a failure for something that
// was in fact recorded.

export async function POST(request: NextRequest) {
  if (MAINTENANCE) return maintenanceResponse();
  const token = new URL(request.url).searchParams.get("token") ?? "";
  if (token && !(await optOutByToken(token, "link"))) console.warn("outreach opt-out: unknown token");
  return NextResponse.json({ unsubscribed: true }, { headers: { "Cache-Control": "no-store" } });
}
Enter fullscreen mode Exit fullscreen mode

An unknown token is a warning in our logs and a 200 to them. Nobody is served by a mail client saying "unsubscribe failed" about an address we are never going to write to again anyway, and the one thing a bad token cannot be is a reason to keep sending.

The one exception is maintenance. There is a hardcoded flag in the repository for the window when the database is down, and the comment on it spells out the consequence:

// - creators' opt-out and unsubscribe links say to come back later.
Enter fullscreen mode Exit fullscreen mode

A 503 with Retry-After: 600 is honest. A 200 for something that was not written down is not.

Three: an address that no brand name can produce

The mailto half of List-Unsubscribe is an address, and it has to be impossible to confuse with a brand's own thread address:

// The mailto: half of List-Unsubscribe. Its own shape, so no brand name can
// ever produce it.
export function optOutAddress(token: string, domain: string): string {
  return `unsubscribe+${token}@${domain}`;
}

const OPT_OUT_RE = new RegExp(`^unsubscribe\\+([${ALPHABET}]{${TOKEN_LENGTH}})$`);
Enter fullscreen mode Exit fullscreen mode

Brand names become slugs ("Café Noir & Co." -> "cafe-noir-co"), and a slug is [a-z0-9-], so it can never contain the + that this shape requires. The parser is exact: the literal word, a plus, and exactly 12 characters from a 31-character alphabet with no 0, o, 1, l or i in it. Mail arriving at that address is an opt-out and cannot be anything else.

Four: replying "stop"

The fourth path is the one people actually use, because it is the one a human thinks of: they answer the email. Replies are read and classified, and an opt-out intent runs exactly the same function as the button, with via: "email" instead of via: "link". The via is recorded in the audit row and changes nothing about the outcome.

What all four write

They converge on one function, and the record it writes is deliberately not an address:

export const suppressions = pgTable(
  "suppressions",
  {
    id: uuid().primaryKey().defaultRandom(),
    kind: text({ enum: ["email", "channel", "domain"] }).notNull(),
    value: text().notNull(), // email hash, YouTube channel ID or domain
    reason: text({ enum: ["opted_out", "bounced", "complained", "manual"] }).notNull(),
    threadId: uuid().references(() => outreachThreads.id, { onDelete: "set null" }),
    createdAt: createdAt(),
  },
  (t) => [uniqueIndex("suppressions_value_uq").on(t.kind, t.value), index("suppressions_thread_idx").on(t.threadId)],
).enableRLS();
Enter fullscreen mode Exit fullscreen mode
export function emailHash(email: string): string {
  return createHash("sha256").update(normalizeEmail(email)).digest("hex");
}
Enter fullscreen mode Exit fullscreen mode

A suppression list is the one list that has to outlive everything else, including the account that caused it, so the row is a sha256 of the lowercased, trimmed address and not the address. We cannot read it back, which is the point: "never contact this person again" needs a lookup, not a mailing list. onDelete: "set null" on the thread means deleting the conversation cannot take the suppression with it, and the unique index on (kind, value) makes a second opt-out a no-op rather than a duplicate.

The opt-out also suppresses the creator's channel id, not only their address, because the same person can be found again later under a different published email. Stopping has to mean the person, not the string.

The other unsubscribe page, which does the opposite

Nakodo also sends ordinary product email to its own customers, and that link goes to /unsubscribe, which unsubscribes on load:

// The link in the footer of every marketing email. It unsubscribes on arrival
// rather than asking first: someone who clicked "unsubscribe" has already
// decided, and a confirmation step is one more thing between them and the
// outcome they asked for. Changing your mind is one button away.
Enter fullscreen mode Exit fullscreen mode
export async function unsubscribeByToken(token: string): Promise<string | null> {
  const [row] = await db
    .update(emailPreferences)
    .set({ marketing: false, unsubscribedAt: new Date() })
    .where(eq(emailPreferences.unsubscribeToken, token))
    .returning({ email: emailPreferences.email });
  return row?.email ?? null;
}
Enter fullscreen mode Exit fullscreen mode

Two pages, two opposite answers to "should a click be enough", and both are right for their case. The difference is who is clicking. A customer who signed up for product news is a known person with an account, and friction on the way out of a newsletter is a dark pattern. A stranger who got a cold email is sharing their inbox with scanners, and acting on their behalf without them is worse than one extra tap. The undo button ("Put me back on") exists on the newsletter page and deliberately does not exist on the outreach one.

Both pages set robots: { index: false, follow: false } and dynamic = "force-dynamic". A token in a URL is not a secret worth indexing, and a cached opt-out page is a page that lies.

The rule I would take to another product

Write down who is going to click the link before you decide whether clicking it should be enough. If the answer includes software that is not the recipient, the destructive version needs a form; if the answer is only the person who asked for the email in the first place, a confirmation step is just a worse product.

If you want the user-facing shape of all this, how Nakodo works has a section on the emails it sends for brands, and the privacy notice is where the four paths above have to be described in one paragraph that a creator can read in ten seconds.

Top comments (0)