One-click unsubscribe for a static-site newsletter, with a free Cloudflare Worker
If you send a newsletter yourself, each email should carry a List-Unsubscribe header so Gmail, Yahoo and Apple Mail can show an Unsubscribe button next to the sender name. This page has a complete, tested Worker that serves the link, and says what it does not do.
What the header has to do
The standard for one-click unsubscribe is RFC 8058. A message carries two headers: List-Unsubscribe with an HTTPS link in angle brackets, and List-Unsubscribe-Post: List-Unsubscribe=One-Click. When the reader presses the mail client's button, the client sends a POST to that link, with the body List-Unsubscribe=One-Click, and the server must unsubscribe the address without asking anything more. The RFC also requires the message to be DKIM-signed with those headers covered, which a sending service such as Resend does for a verified domain; check that your domain is verified there.
Google's sender guidelines make one-click unsubscribe a requirement for bulk senders (more than 5,000 messages a day to Gmail addresses) and ask for requests to be honoured within two days. Small senders are not bound by that number, but the button also keeps people from pressing Report spam, which hurts you more.
Why a GET must not unsubscribe
Mail scanners and link previews open every link in a message. If opening the link unsubscribed the address, a scanner would silently remove subscribers. So the Worker below shows a button on GET and acts only on POST. It is the same rule as in the double opt-in Worker.
The Worker
The link carries the address and an HMAC-SHA-256 signature made with your secret, so nobody can unsubscribe somebody else by editing the address, and no database is needed to check it. Save as worker.js. The optional UNSUBS KV binding records who opted out; without it the request is accepted but nothing is stored, so bind it for real use.
// One-click unsubscribe for a newsletter you send with Resend (RFC 8058).
// Secrets: TOKEN_SECRET (24+ chars). Optional KV namespace UNSUBS to record opt-outs.
const enc = new TextEncoder();
const b64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
async function sign(secret, text) {
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
return b64u(await crypto.subtle.sign("HMAC", key, enc.encode(text)));
}
export async function makeLink(base, secret, email) {
const e = email.trim().toLowerCase();
return `${base}/u?e=${encodeURIComponent(e)}&s=${await sign(secret, "unsub:" + e)}`;
}
// Headers to pass to Resend's "headers" field.
export async function unsubHeaders(base, secret, email, mailto) {
const link = await makeLink(base, secret, email);
return { "List-Unsubscribe": `<${link}>` + (mailto ? `, <mailto:${mailto}?subject=unsubscribe>` : ""), "List-Unsubscribe-Post": "List-Unsubscribe=One-Click" };
}
const page = (title, body) => new Response(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title><main style="font:18px/1.5 system-ui;max-width:32rem;margin:3rem auto;padding:0 1rem">${body}</main>`, { headers: { "content-type": "text/html;charset=utf-8", "cache-control": "no-store" } });
const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname !== "/u") return new Response("Not found", { status: 404 });
const e = (url.searchParams.get("e") || "").trim().toLowerCase();
const s = url.searchParams.get("s") || "";
const ok = e && s && (await sign(env.TOKEN_SECRET, "unsub:" + e)) === s;
if (!ok) return page("Link not valid", "<p>This unsubscribe link is not valid. Reply to any of our emails and we will remove you by hand.</p>");
// GET only shows a button: mail scanners open links, and must not unsubscribe anyone.
if (request.method === "GET") return page("Unsubscribe", `<form method="post" action="${esc(url.pathname + url.search)}"><p>Stop emails to <strong>${esc(e)}</strong>?</p><button type="submit">Unsubscribe me</button></form>`);
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
// Mail clients send a POST here with body "List-Unsubscribe=One-Click"; the button sends a POST too.
if (env.UNSUBS) await env.UNSUBS.put(e, new Date().toISOString());
return page("Unsubscribed", "<p>You are unsubscribed. You will not get further newsletters.</p>");
},
};
Attach the headers when you send
Resend's send call accepts a headers object, so one helper is enough. Make the signed link per recipient and send one email per recipient.
import { unsubHeaders } from "./worker.js";
const headers = await unsubHeaders("https://news.YOUR-NAME.workers.dev", TOKEN_SECRET, to, "[email protected]");
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { authorization: "Bearer " + RESEND_API_KEY, "content-type": "application/json" },
body: JSON.stringify({ from: "News <[email protected]>", to, subject, html, headers }),
});
Deploy
- Create a KV namespace:
npx wrangler kv namespace create UNSUBS, and add the binding towrangler.toml([[kv_namespaces]]withbinding = "UNSUBS"). - Set the secret:
npx wrangler secret put TOKEN_SECRET(24 or more random characters). npx wrangler deploy.- Before each send, skip every address that has a key in
UNSUBS(npx wrangler kv key list --binding UNSUBS).
How it was tested
I ran the Worker under Node 18+ with a fake KV store and checked eight things: the link normalises the address; GET shows a button and stores nothing; a POST with List-Unsubscribe=One-Click stores the address; an edited address or signature stores nothing; markup in the address is escaped; other paths give 404; other methods give 405; the helper returns both headers in the right form. I did not deploy it to Cloudflare or send a real message through Gmail, so check the Unsubscribe button appears in your own test inbox, which can take a while to show and depends on the mail provider's own judgement of your sender reputation.
What it does not do
- It does not stop anyone signing up a stranger. For that you need confirmation and limits, covered in the subscription-bombing guide.
- Links never expire, and the same link works again after you resubscribe someone. That is the usual trade-off for unsubscribe links.
- KV is eventually consistent, so an opt-out can take up to a minute to be visible everywhere. Check the unsubscribe list right before each send.
- It does not write the unsubscribe into your email service's own suppression list. If you send through a tool that has one, use its built-in unsubscribe instead.
If you want the whole thing assembled
Signup Shield Kit ($15, once) already includes signed unsubscribe and confirmation links, a subscriber export, per-address and per-IP limits, three sample sites and an attack simulator. This page's Worker is free to use for any purpose.