When I rebuilt an old email system, I found this. Every marketing email had an "Unsubscribe" link in the footer, pointing to a real endpoint.
And clicking it didn't unsubscribe anyone.
The footer link was a normal link, so clicking it sent a GET. The endpoint only accepted POST. The link existed on paper, in the code and in every email, and it did nothing for the one person who needed it.
Unsubscribing isn't a nice-to-have. Under the GDPR, Article 7(3) says withdrawing consent must be "as easy to withdraw as to give consent", and Article 21 says that when someone objects to direct marketing, their data can't be used for it anymore. I'm a developer, not a lawyer, so check with yours about what applies to you. But on the technical side, the lesson was clear, and it wasn't really about GET.
What the AI writes if you let it
Ask an AI assistant for "an unsubscribe endpoint" and you get something reasonable:
export async function POST(req: Request) { const { email } = await req.json(); await db.query(`update subscribers set active = false where email = $1`, [email]); return new Response('ok'); } Then you ask for a webhook from your email provider, and it writes another update, slightly different. Then a recovery job, and a third one.
Now the rule "this person said no" lives in three places. One closes all sequences, one only the current one; one writes to the consent log, two don't. Each looks fine in its own pull request.
Andrew Hunt and David Thomas put this in one sentence in The Pragmatic Programmer: every piece of knowledge must have a single, unambiguous, authoritative representation within a system. They mean knowledge, not just code. "What happens when someone unsubscribes" is knowledge, and it's legal knowledge, which makes copies of it more expensive.
One function, every door
In my rebuild, the rule lives in one function that runs inside a transaction someone else opened. Everything that unsubscribes a person ends up there:
type UnsubscribeOutcome = | { kind: 'unsubscribed'; closedSequences: number } | { kind: 'rejected'; reason: 'malformed' | 'bad-signature' | 'expired' | 'unknown-subscription' }; export async function unsubscribeWithin( tx: Transaction, deps: { secret: string; now: () => Date }, rules: { tokenValidDays: number }, // from configuration, not from code request: { token: string; requestedAt: Date }, ): Promise<UnsubscribeOutcome> { const token = readUnsubscribeToken(deps.secret, request.token, request.requestedAt, rules.tokenValidDays); if (token.kind === 'rejected') return token; const subscription = await findSubscription(tx, token.subscriptionId); if (!subscription) return { kind: 'rejected', reason: 'unknown-subscription' }; const closedSequences = await closeAllSequencesFor(tx, subscription.email, 'unsubscribed', deps.now()); await recordConsent(tx, { email: subscription.email, purpose: 'marketing', granted: false, source: 'unsubscribe-link', evidence: { sequence: subscription.sequence, subscription: subscription.id }, }); return { kind: 'unsubscribed', closedSequences }; } A few decisions are hidden in there, so let me spell them out:
- It closes every sequence for that address, not just the one the email came from. Someone who says "stop" doesn't mean "stop this newsletter, keep the other two".
- The "no" goes into a consent log, with where it came from. If someone asks "why did you stop writing to me?" or "why did you write to me after I said no?", there's an answer with evidence.
- The token is signed and has an expiry, and the expiry is checked at the time the person clicked, not the time the server got around to processing it. That matters for the next section.
- The token carries a subscription id, not the email address. Links end up in access logs. An email address in a URL is personal data in a log file.
- The outcome is explicit, not
trueorfalse: every caller knows why a request was refused.
Then the doors. The route serves both methods with the same handler:
export const GET = handleUnsubscribe; // the footer link people click export const POST = handleUnsubscribe; // the one-click button in Gmail, Yahoo and others The POST is not optional anymore. RFC 8058 (John Levine and Tobias Herkula) defines one-click unsubscribe: a List-Unsubscribe header with an HTTPS link, plus List-Unsubscribe-Post: List-Unsubscribe=One-Click. The mailbox shows an "Unsubscribe" button and sends a POST for the user. Since 2024, Gmail and Yahoo expect it from bulk senders, and Gmail also wants a visible link in the body.
There's a trade-off with the GET, and the RFC itself names it: anti-spam software often fetches links in emails automatically, without the user doing anything. A GET that unsubscribes can unsubscribe someone who never clicked. The alternative is a GET that shows a page with a confirm button. I chose the direct GET: a "no" too many costs me a reader, a lost "no" costs someone their right. Make that choice on purpose, whichever way you go.
The other two doors:
- The email provider's webhook. A hard bounce closes all sequences for the address. An unsubscribe made in the provider's panel closes them and writes the "no" too. This one has no token, so it can't call
unsubscribeWithin; it calls the same two pieces underneath,closeAllSequencesForandrecordConsent. If the webhook is lost, a periodic job asks the provider for blocked addresses and does the same. - The recovery job. This one needs a story.
The "ok" that can't be taken back
When the one-click button sends its POST and gets a 200, the mailbox is done. It won't retry. The person has seen "you're unsubscribed". So if my database write fails after I said "ok", that "no" is gone.
So the route does two things, in two steps. First it stores the request (token and time of the click) in a table. Then it processes it, and the same transaction that runs unsubscribeWithin marks the request as processed:
async function processStoredRequest(db: Database, id: string) { return db.transaction(async (tx) => { const claimed = await claimUnprocessedRequest(tx, id); // update ... where processed_at is null returning ... if (!claimed) return { kind: 'already-processed' } as const; return unsubscribeWithin(tx, deps, rules, claimed); }); } If anything inside fails, the transaction rolls back and the request stays unprocessed. A background job picks up requests older than a few minutes and calls the same processStoredRequest. Either the sequences are closed and the "no" is recorded and the request is marked, or none of them. And because the expiry is checked against the original click time, a token clicked on its last valid day still works when the job processes it later.
The bug the tests couldn't see
Here's the second lesson, and it's subtler than the GET.
A request must be processed once. That rule ended up in two places: the job's query that lists unprocessed requests (where processed_at is null), and the claim that marks one as processed (update ... where processed_at is null). Belt and braces. It looks like extra safety.
While building this, I removed the condition from one of the two places to see what would break. The tests stayed green: the other place covered for it. They only went red with both removed. So next month my AI assistant could "simplify" one guard away and nothing would say no, and the day someone touches the other one, a real person gets two entries in the consent log.
That question, "if I break this line, does any test notice?", has a name. It's mutation testing, and it goes back to R. A. DeMillo, R. J. Lipton and F. G. Sayward, "Hints on Test Data Selection" (1978): make small deliberate changes to the program, and check that your tests can tell the changed program from the real one. A change that no test notices is a test you're missing.
You don't need a tool to start. When the AI writes a guard, I ask it, line by line: which test goes red if I delete this? If the answer is "none", there are two honest fixes:
- Decide which place is the rule. For me, the claim is the rule: it's atomic, inside the transaction, and it also protects against the route and the job racing each other. The list filter is just an optimisation.
- Test the rule directly. Process the same request twice, or let the job process it while the route is still in the middle, and assert one "no" in the consent log. That test fails if the claim loses its condition, no matter what the list query does.
And write the trap down where the next session will read it. Mine is one line in the project's instructions file: the "once" rule lives in two places, removing it from one keeps tests green, change the rules only in unsubscribeWithin.
And then you lock it in
Every door gets its own test, because every door is a separate way to get it wrong:
it('the footer link unsubscribes with GET and with POST', async () => { const link = unsubscribeLinkFrom(await sentEmail()); expect((await request(link, { method: 'GET' })).status).toBe(200); expect(await openSequences('mario@example.com')).toEqual([]); expect(await lastConsent('mario@example.com')).toMatchObject({ granted: false, source: 'unsubscribe-link' }); expect((await request(link, { method: 'POST' })).status).toBe(200); await runSequenceJob(later); expect(emailsSent()).toHaveLength(1); // nothing after the "no" }); Then the same shape for the rest: the job after a failed consent write (one "no", not zero, not two), the database down at the click, the route and the job racing, a bad token, a hard bounce, a lost webhook. If a door has no test, sooner or later it's the door that doesn't work. That's how the footer link went unnoticed.
The rule, in one line
A legal rule lives in one function, every way in has its own test, and every guard has a test that fails without it.
Further reading
- Regulation (EU) 2016/679 (GDPR), Article 7(3) "Conditions for consent" and Article 21 "Right to object", text at gdpr-info.eu.
- J. Levine, T. Herkula, RFC 8058, "Signaling One-Click Functionality for List Email Headers" (IETF, 2017).
- Google, "Email sender guidelines" (Gmail Help), and Yahoo, "Sender Best Practices" (Yahoo Sender Hub): the one-click unsubscribe requirements for bulk senders, in force since 2024.
- Andrew Hunt, David Thomas, The Pragmatic Programmer, 20th Anniversary Edition (Addison-Wesley, 2019): "DRY: The Evils of Duplication".
- R. A. DeMillo, R. J. Lipton, F. G. Sayward, "Hints on Test Data Selection: Help for the Practicing Programmer", Computer (IEEE), April 1978: where mutation testing starts.
None of this is legal advice: it's the engineering side of a legal duty.
I'm Matteo Mazzocchi, an Italian developer. I build CRMs and back-office systems for small businesses, with an AI pair and a lot of tests that say no. More about my work: mazzocchimatteo.com.