Three ways a single unsubscribe link fails silently, none of them legal:
- A sender adds
List-Unsubscribe, tests it in Outlook, ships. Gmail's native Unsubscribe button never appears — because the header sits outside the DKIMh=tag, and Gmail refuses to trust a one-click header it can't verify was signed by you. - A team wires the unsubscribe URL to a
GEThandler that flips the flag on load. Two weeks later the list is quietly bleeding subscribers: security gateways like Proofpoint, Mimecast, and Barracuda follow every link in the message within seconds of delivery, and each fetch is an unsubscribe nobody clicked. - Someone implements opt-out as
DELETE FROM subscribers WHERE id = ?. Next quarter a fresh CSV import re-adds the same address, you mail them again — a CAN-SPAM violation — and when a regulator asks for proof the person withdrew consent, the row is gone.
Stop building three flows for three regimes. Build one flow to the strictest common denominator: a DKIM-signed RFC 8058 header, a POST-only mutation guarded by an HMAC token, and a suppression list instead of a delete. The legal text is the easy part. The deliverability mechanics are where teams get burned.
The three rulebooks, deduplicated
CAN-SPAM, GDPR, and the Gmail/Yahoo bulk-sender rules (in force since February 2024) overlap far more than they conflict. Put them on the same axes, take the max of each, and you get an engineering spec instead of three legal checklists.
Read the highlighted column and the conclusion writes itself: the binding constraints are Gmail's, not the statutes'. CAN-SPAM gives you 10 business days; Gmail gives you 2. GDPR is the only regime that demands a withdrawal record, so log for GDPR and the other two come free. CAN-SPAM alone insists the link keeps working 30 days after send, so tokens must not expire early. Build to every strictest cell and you are compliant everywhere at once. The penalties aren't theoretical either — CAN-SPAM violations run up to roughly $53,088 per email under the FTC's 2025 inflation-adjusted schedule.
The two headers that turn on Gmail's button
Gmail's native Unsubscribe UI is triggered by a header pair defined in RFC 8058 ("Signaling One-Click Functionality for List Email Headers"), building on the original List-Unsubscribe from RFC 2369:
List-Unsubscribe: <https://evilmail.pro/api/unsubscribe?t=aGVsbG8...>, <mailto:[email protected]?subject=unsubscribe>
List-Unsubscribe-Post: List-Unsubscribe=One-ClickBoth matter. The https: URI is what Gmail and Yahoo POST to. The mailto: fallback is what Apple Mail and older clients use. List both — an https-only header degrades badly on clients that only speak mailto, and a mailto-only header won't light up Gmail's one-click button at all.
The non-negotiable, and the single most common reason the button never appears: both headers must be covered by DKIM, and the message must pass DMARC alignment. Gmail won't surface a one-click control it can't cryptographically tie to the signing domain — otherwise anyone could inject a List-Unsubscribe-Post on a spoofed message. So your DKIM signature's h= tag has to name them:
DKIM-Signature: v=1; a=rsa-sha256; d=evilmail.pro; s=mail;
h=from:to:subject:date:list-unsubscribe:list-unsubscribe-post;
...Miss list-unsubscribe-post in that h= list and the header is unsigned as far as Gmail is concerned — the UI silently vanishes with no error anywhere in your logs. Keep the https URI lean, too: some clients truncate long, query-heavy URLs, so put your token in a single short parameter and nothing else.
Why the link must be inert on GET
Security gateways and mobile link scanners issue GET requests to every URL in a message — Proofpoint, Mimecast, and Barracuda among them — within seconds of delivery and with no human involved. If your unsubscribe endpoint mutates state on GET, those prefetches unsubscribe people who never opened the mail. It looks like a mysterious engagement collapse; it's your own handler.
The rule is a clean split by HTTP method:
- `GET` renders a confirmation page. No state change. A human sees a "Confirm unsubscribe" button.
- `POST` performs the mutation. RFC 8058's one-click body is the machine path; the on-page Confirm button POSTs the same endpoint for the human path.
The token that ties this together is an opaque HMAC-SHA256 over subscriber_id | list_id | issued_at, base64url-encoded. That buys three properties at once: the endpoint verifies the token with no database lookup on the hot path; the IDs are never sequential or guessable, so a scanner can't walk ?id=1,2,3…; and because the MAC is keyed, nobody forges a valid token for someone else's address.
The handler
Here's the shape in a Next.js App Router route — the same stack evilmail.pro runs on. It handles the machine POST and the human-confirm POST identically, verifies in constant time, and is idempotent:
// app/api/unsubscribe/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(token: string): { sub: string; list: string } | null {
const raw = Buffer.from(token, "base64url").toString();
const [sub, list, issued, sig] = raw.split("|");
if (!sub || !list || !issued || !sig) return null;
const expected = createHmac("sha256", process.env.UNSUB_SECRET!)
.update(`${sub}|${list}|${issued}`).digest();
const given = Buffer.from(sig, "base64url");
if (expected.length !== given.length) return null;
if (!timingSafeEqual(expected, given)) return null;
return { sub, list }; // no TTL check — CAN-SPAM wants links live 30+ days
}
export async function POST(req: Request) {
const token = new URL(req.url).searchParams.get("t") ?? "";
const body = new URLSearchParams(await req.text());
const oneClick = body.get("List-Unsubscribe") === "One-Click";
const v = verify(token);
if (!v) return new Response("bad token", { status: 400 });
// idempotent: second POST is a 200 no-op
await db.suppression.upsert({
where: { email_list: { email: v.sub, listId: v.list } },
create: { email: v.sub, listId: v.list, reason: "unsubscribe" },
update: {},
});
await db.unsubAudit.create({ data: {
subscriber: v.sub, listId: v.list,
method: oneClick ? "one_click" : "preference_center",
ip: req.headers.get("x-forwarded-for") ?? "",
userAgent: req.headers.get("user-agent") ?? "",
}});
return new Response(null, { status: 200 }); // fast, no redirect, no auth
}
// GET renders the confirmation page only — never mutates.
export async function GET() {
return new Response(confirmPageHtml, { headers: { "content-type": "text/html" } });
}Three non-obvious rules are baked in: no TTL rejection (an expired token that bounces the user breaks CAN-SPAM's 30-day rule), no redirect chain (every hop is another chance for a client to drop the request), and no auth wall or captcha on the one-click path (a login requirement fails both Gmail's rules and GDPR Art 7(3)). Rate-limit by IP if you must, but never let the limiter reject a valid unsubscribe — throttling opt-outs is exactly the behavior these regimes exist to punish.
Suppress, don't delete
Unsubscribe is not erasure. Unsubscribe means: this is a still-valid address, do not send to it. So you keep it — on a suppression list the campaign builder consults at send time. Delete the row and the next CSV import silently re-adds the person, you mail them again, and you've earned both a CAN-SPAM violation and a spam complaint that nudges you toward the cliff.
GDPR erasure (Art 17) is a separate, explicit request. Handle it by tombstoning the address to a salted SHA-256 hash: you can still match and suppress a future import without retaining the plaintext PII. That's the reconciliation people miss — "delete my data" and "stop emailing me" have opposite storage requirements, and a hash satisfies both.
Store enough to prove it. The suppression row plus an audit row carrying consent_origin, withdrawal_ts, method (one_click / preference_center / mailto), source_campaign, ip, and user_agent. That's your GDPR Art 7(3) evidence trail, and it doubles as deliverability forensics when complaint rates spike and you need to see which campaign lit the fuse.
Timing, logging, proving you did it
Process suppressions synchronously or within minutes — never on a nightly batch. The 2-day Gmail window has no slack once you count messages already queued behind the opt-out. And watch the number that actually gates your sending: keep your Postmaster Tools spam complaint rate under 0.10%. The 0.30% line is the hard cliff where Gmail starts throttling and eventually junking you, and the fastest way to blow past it is re-mailing people who already opted out.
Ship checklist
- DKIM
h=includes bothlist-unsubscribeandlist-unsubscribe-post; message passes DMARC alignment. List-Unsubscribecarries both anhttps:and amailto:URI;List-Unsubscribe-Post: List-Unsubscribe=One-Clickis present.GET= confirmation page (inert);POST= the only path that mutates.- Token is HMAC-SHA256, base64url, verified with
timingSafeEqual, no sequential IDs. - Endpoint returns a fast
200/202— no redirect, no login, no captcha.
Verify before you send
Simulate Gmail's one-click exactly and confirm the endpoint mutates without redirecting or challenging:
curl -i -X POST 'https://evilmail.pro/api/unsubscribe?t=TOKEN' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'List-Unsubscribe=One-Click'
# Expect: HTTP/2 200 (or 202), no Location header, no WWW-Authenticate.Confirm the GET path is genuinely inert — run the same URL as a GET and check the address is still subscribed afterward. Then prove the headers are actually signed. Send yourself a real message, open Show original in Gmail, and verify DKIM: PASS and DMARC: PASS. From a raw .eml on disk, read the signed-header list directly:
grep -i '^DKIM-Signature' -A6 raw.eml | grep -io 'h=[^;]*'
# must contain: list-unsubscribe:list-unsubscribe-postIf that grep doesn't show both header names, Gmail's button will never appear no matter how correct your handler is. Fix the h= tag first — everything downstream depends on it.


