{"title":"One-Click Unsubscribe The Way Email Platforms Want It","html_url":"https://deallocatedobjects.com/posts/one-click-unsubscribe-the-way-email-platforms-want-it","source_url":"","markdown_content":"This blog has a small newsletter. When a post goes live, a little Bun service on my server emails it to everyone who signed up (it sends through Resend). Every one of those emails has to make leaving easy, and since 2024 Gmail and Yahoo have spelled out exactly what \"easy\" means for bulk senders.\n\nMy list is nowhere near the 5,000 messages a day where Google's rules start to apply. I followed them anyway. They describe a good unsubscribe, the headers they ask for are what lets a mail client show its own Unsubscribe button, and the code is short. Here's how mine works, and the details I had to read the RFCs to get right.\n\n### What The Rules Ask For\n\nGoogle's [sender guidelines](https://support.google.com/a/answer/81126) say marketing and subscribed messages \"must support one-click unsubscribe, and include a clearly visible unsubscribe link in the message body.\" Yahoo's [best practices](https://senders.yahooinc.com/best-practices/) ask for the same header and add a deadline: \"Honor unsubscribes within 2 days.\"\n\n\"One-click\" has a precise meaning. It's [RFC 8058](https://www.rfc-editor.org/rfc/rfc8058), which builds on the older `List-Unsubscribe` header from [RFC 2369](https://www.rfc-editor.org/rfc/rfc2369). RFC 2369 lets a message say where to go to unsubscribe. RFC 8058 adds a second header that tells the mail provider it can do the unsubscribing itself, with a single POST, when the reader taps the button in the provider's own interface.\n\n### The Two Headers\n\nBoth come from one function in `newsletter/emails.ts`:\n\n```ts title=\"newsletter/emails.ts\"\nexport const unsubscribeUrl = (token: string) => `${SITE}/api/newsletter/unsubscribe?t=${encodeURIComponent(token)}`;\nexport const unsubscribeHeaders = (token: string) => ({\n  \"List-Unsubscribe\": `<${unsubscribeUrl(token)}>, <mailto:${REPLY_TO}?subject=Unsubscribe>`,\n  \"List-Unsubscribe-Post\": \"List-Unsubscribe=One-Click\",\n});\n```\n\n`List-Unsubscribe` holds a list of URIs in angle brackets. RFC 8058 says it \"MUST contain one HTTPS URI\", and it's allowed to carry others too, so I include a `mailto:` as a fallback. Yahoo calls the POST method highly recommended and says the mailto method is acceptable, and some older clients only know how to send an email. That address is the same one replies go to, so a \"please unsubscribe me\" email reaches me either way.\n\n`List-Unsubscribe-Post` is always the exact string `List-Unsubscribe=One-Click`. It's the flag that says \"you may POST to the HTTPS URI above without sending the reader to a web page first.\"\n\nBoth the welcome email and every new-post email get these headers, with each subscriber's own token baked into the URL. The confirmation email for a brand new signup doesn't. That address hasn't subscribed to anything yet, and the email says to ignore it if you didn't sign up.\n\n### What The Mail Provider Sends\n\nWhen a reader taps Gmail's Unsubscribe button, Gmail's servers make the request. Google's page shows exactly what arrives:\n\n```http\nPOST /unsubscribe/example HTTP/1.1\nHost: solarmora.com\nContent-Type: application/x-www-form-urlencoded\nContent-Length: 26\n\nList-Unsubscribe=One-Click\n```\n\nA few things in RFC 8058 shape the server side. The POST \"MUST NOT include cookies, HTTP authorization, or any other context information,\" so the URL itself has to identify the subscriber. That's what the token is for. The sender \"MUST NOT return an HTTPS redirect,\" so the answer to this request has to be a plain response. And the RFC never defines a body or a specific status for that response, so any 2xx is a success.\n\n### Why A GET Can't Unsubscribe Anyone\n\nThe tempting version of an unsubscribe link deletes the subscriber the moment anyone opens it. The problem is who opens links. RFC 8058's introduction explains it: \"anti-spam software often fetches all resources in mail header fields automatically, without any action by the user.\" Corporate mail filters, security scanners that check every URL in a message, and clients that prefetch links all send GET requests nobody asked for. If GET unsubscribes, a reader at a company with a link scanner gets silently unsubscribed by the first email you send them.\n\nSo GET only shows a page with a button. The page names the address it's about to remove, and the button submits a form that POSTs back to the same URL. A scanner can fetch that page a hundred times and nothing happens. The link in the email footer goes to this same page, so it's just as harmless.\n\nThis is also why the one-click POST and the manual link can share a URL. RFC 8058 describes the POST target as \"the same as the one in the GET action for a manual unsubscription.\" GET asks, POST does it.\n\n### The POST Handler\n\nThe POST half handles two different callers: Gmail or Yahoo doing a one-click, and a person pressing the button on that confirm page.\n\n```ts title=\"newsletter/server.ts\"\nif (req.method === \"POST\") {\n  if (sub) { db.run(\"delete from sends where subscriber_id = ?\", [sub.id]); db.run(\"delete from subscribers where id = ?\", [sub.id]); }\n  // RFC 8058 says senders SHOULD use multipart/form-data (urlencoded MAY), so parse the form either way.\n  const form = await req.clone().formData().catch(() => null);\n  const raw = await req.text().catch(() => \"\");\n  if (form?.get(\"List-Unsubscribe\") === \"One-Click\" || raw.includes(\"List-Unsubscribe=One-Click\")) return new Response(\"Unsubscribed\");\n  return redirect(\"/subscribe/unsubscribed\");\n}\n```\n\nThe delete happens first, for any POST with a valid token, whatever the body says. Then the response depends on who's asking. The mail provider gets a bare `200 Unsubscribed`, because the RFC forbids a redirect and nobody is looking at the result anyway. A person in a browser gets a `303` to a friendly \"You're unsubscribed\" page on the blog.\n\nIt's also safe to repeat. If the token doesn't match anyone (because the address is already gone, or the same tap got retried), the delete just doesn't find a row and the response is the same. A provider retrying a request never sees an error, and neither does someone who taps the link in an older email after they've left.\n\nGmail sends `application/x-www-form-urlencoded`, where the body is literally `List-Unsubscribe=One-Click`. RFC 8058 says the body \"SHOULD be sent as multipart/form-data,\" and in a multipart body the name and value sit on separate lines with a boundary around them, so a plain substring check misses it. That's why the handler parses the form first (on a clone, since a request body can only be read once) and keeps the substring check for senders that leave off the `Content-Type`. The button on my confirm page has no name, so its POST carries an empty form and falls through to the redirect.\n\n### Tokens\n\nEach subscriber gets one random token when they sign up:\n\n```ts title=\"newsletter/server.ts\"\nconst token = () => Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString(\"base64url\");\n```\n\nThat's 192 bits, stored in a unique column. Tokens don't expire, because an unsubscribe link in a two-year-old email should still work. A token can only confirm a pending signup or remove that one address, so there's no need for a signature on top of it.\n\n### DKIM Has To Cover The Headers\n\nThis is the part that's easy to miss. RFC 8058 requires a valid DKIM signature \"that covers at least the List-Unsubscribe and List-Unsubscribe-Post headers,\" and on the receiving side it says that without one, \"the mail receiver SHOULD NOT offer a one-click unsubscribe.\" The reasoning is good: if the headers weren't signed, anyone relaying the message could swap in their own URL and turn the button into something else.\n\nIn my setup the headers go into the same API call as the subject and body, so they're part of the message before it's signed and sent. You don't have to take that on faith, though. Open a received copy in Gmail, choose \"Show original\", and find the `DKIM-Signature` header. Its `h=` tag lists every header the signature covers. Both `List-Unsubscribe` and `List-Unsubscribe-Post` need to be in that list. If they aren't, the button can quietly disappear even though your server handles the POST perfectly.\n\n### Deleting The Address\n\nA lot of email tools flag an unsubscribed address and keep it. Mine deletes the subscriber row and the rows recording which posts went to them, because my [privacy page](/privacy) promises that. The usual argument for keeping a flagged row is a suppression list, and double opt-in covers that here: if someone types a former subscriber's address into the form, that person gets one confirmation email they can ignore, and the unconfirmed row is deleted after seven days.\n\nSends pick recipients from `subscribers where status = 'active'` when the job runs, so once the row is gone, the next post can't go to that address. That also covers Yahoo's two-day rule.","html_content":"<p>This blog has a small newsletter. When a post goes live, a little Bun service on my server emails it to everyone who signed up (it sends through Resend). Every one of those emails has to make leaving easy, and since 2024 Gmail and Yahoo have spelled out exactly what “easy” means for bulk senders.</p>\n<p>My list is nowhere near the 5,000 messages a day where Google’s rules start to apply. I followed them anyway. They describe a good unsubscribe, the headers they ask for are what lets a mail client show its own Unsubscribe button, and the code is short. Here’s how mine works, and the details I had to read the RFCs to get right.</p>\n<h2 id=\"what-the-rules-ask-for\" data-level=\"3\">What The Rules Ask For</h2>\n<p>Google’s <a href=\"https://support.google.com/a/answer/81126\">sender guidelines</a> say marketing and subscribed messages “must support one-click unsubscribe, and include a clearly visible unsubscribe link in the message body.” Yahoo’s <a href=\"https://senders.yahooinc.com/best-practices/\">best practices</a> ask for the same header and add a deadline: “Honor unsubscribes within 2 days.”</p>\n<p>“One-click” has a precise meaning. It’s <a href=\"https://www.rfc-editor.org/rfc/rfc8058\">RFC 8058</a>, which builds on the older <code>List-Unsubscribe</code> header from <a href=\"https://www.rfc-editor.org/rfc/rfc2369\">RFC 2369</a>. RFC 2369 lets a message say where to go to unsubscribe. RFC 8058 adds a second header that tells the mail provider it can do the unsubscribing itself, with a single POST, when the reader taps the button in the provider’s own interface.</p>\n<h2 id=\"the-two-headers\" data-level=\"3\">The Two Headers</h2>\n<p>Both come from one function in <code>newsletter/emails.ts</code>:</p>\n<figure class=\"code\" data-lang=\"typescript\"><figcaption><span class=\"file\">newsletter/emails.ts</span><span class=\"lang\">TypeScript</span></figcaption><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">export</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> const</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> unsubscribeUrl</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> =</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> (</span><span style=\"--shiki-light:#E36209;--shiki-dark:#FFAB70\">token</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">:</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> string</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">) </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=></span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> `${</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">SITE</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">}/api/newsletter/unsubscribe?t=${</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">encodeURIComponent</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">(</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">token</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">)</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">}`</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">export</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> const</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> unsubscribeHeaders</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> =</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> (</span><span style=\"--shiki-light:#E36209;--shiki-dark:#FFAB70\">token</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">:</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> string</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">) </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=></span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> ({</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">  \"List-Unsubscribe\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">`&#x3C;${</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">unsubscribeUrl</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">(</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">token</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">)</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">}>, &#x3C;mailto:${</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">REPLY_TO</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">}?subject=Unsubscribe>`</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">  \"List-Unsubscribe-Post\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"List-Unsubscribe=One-Click\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">});</span></span></code></pre></figure><p><code>List-Unsubscribe</code> holds a list of URIs in angle brackets. RFC 8058 says it “MUST contain one HTTPS URI”, and it’s allowed to carry others too, so I include a <code>mailto:</code> as a fallback. Yahoo calls the POST method highly recommended and says the mailto method is acceptable, and some older clients only know how to send an email. That address is the same one replies go to, so a “please unsubscribe me” email reaches me either way.</p>\n<p><code>List-Unsubscribe-Post</code> is always the exact string <code>List-Unsubscribe=One-Click</code>. It’s the flag that says “you may POST to the HTTPS URI above without sending the reader to a web page first.”</p>\n<p>Both the welcome email and every new-post email get these headers, with each subscriber’s own token baked into the URL. The confirmation email for a brand new signup doesn’t. That address hasn’t subscribed to anything yet, and the email says to ignore it if you didn’t sign up.</p>\n<h2 id=\"what-the-mail-provider-sends\" data-level=\"3\">What The Mail Provider Sends</h2>\n<p>When a reader taps Gmail’s Unsubscribe button, Gmail’s servers make the request. Google’s page shows exactly what arrives:</p>\n<figure class=\"code\" data-lang=\"text\"><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span>POST /unsubscribe/example HTTP/1.1</span></span>\n<span class=\"line\"><span>Host: solarmora.com</span></span>\n<span class=\"line\"><span>Content-Type: application/x-www-form-urlencoded</span></span>\n<span class=\"line\"><span>Content-Length: 26</span></span>\n<span class=\"line\"><span></span></span>\n<span class=\"line\"><span>List-Unsubscribe=One-Click</span></span></code></pre></figure><p>A few things in RFC 8058 shape the server side. The POST “MUST NOT include cookies, HTTP authorization, or any other context information,” so the URL itself has to identify the subscriber. That’s what the token is for. The sender “MUST NOT return an HTTPS redirect,” so the answer to this request has to be a plain response. And the RFC never defines a body or a specific status for that response, so any 2xx is a success.</p>\n<h2 id=\"why-a-get-cant-unsubscribe-anyone\" data-level=\"3\">Why A GET Can’t Unsubscribe Anyone</h2>\n<p>The tempting version of an unsubscribe link deletes the subscriber the moment anyone opens it. The problem is who opens links. RFC 8058’s introduction explains it: “anti-spam software often fetches all resources in mail header fields automatically, without any action by the user.” Corporate mail filters, security scanners that check every URL in a message, and clients that prefetch links all send GET requests nobody asked for. If GET unsubscribes, a reader at a company with a link scanner gets silently unsubscribed by the first email you send them.</p>\n<p>So GET only shows a page with a button. The page names the address it’s about to remove, and the button submits a form that POSTs back to the same URL. A scanner can fetch that page a hundred times and nothing happens. The link in the email footer goes to this same page, so it’s just as harmless.</p>\n<p>This is also why the one-click POST and the manual link can share a URL. RFC 8058 describes the POST target as “the same as the one in the GET action for a manual unsubscription.” GET asks, POST does it.</p>\n<h2 id=\"the-post-handler\" data-level=\"3\">The POST Handler</h2>\n<p>The POST half handles two different callers: Gmail or Yahoo doing a one-click, and a person pressing the button on that confirm page.</p>\n<figure class=\"code\" data-lang=\"typescript\"><figcaption><span class=\"file\">newsletter/server.ts</span><span class=\"lang\">TypeScript</span></figcaption><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">if</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> (req.method </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">===</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"POST\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">) {</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">  if</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> (sub) { db.</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">run</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"delete from sends where subscriber_id = ?\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">, [sub.id]); db.</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">run</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"delete from subscribers where id = ?\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">, [sub.id]); }</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#6A737D;--shiki-dark:#6A737D\">  // RFC 8058 says senders SHOULD use multipart/form-data (urlencoded MAY), so parse the form either way.</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">  const</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> form</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> =</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> await</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> req.</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">clone</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">().</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">formData</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">().</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">catch</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(() </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=></span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> null</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">  const</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> raw</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> =</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> await</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> req.</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">text</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">().</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">catch</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(() </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=></span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">  if</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> (form?.</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">get</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"List-Unsubscribe\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">) </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">===</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"One-Click\"</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> ||</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> raw.</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">includes</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"List-Unsubscribe=One-Click\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">)) </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">return</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> new</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> Response</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"Unsubscribed\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">  return</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> redirect</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"/subscribe/unsubscribed\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">}</span></span></code></pre></figure><p>The delete happens first, for any POST with a valid token, whatever the body says. Then the response depends on who’s asking. The mail provider gets a bare <code>200 Unsubscribed</code>, because the RFC forbids a redirect and nobody is looking at the result anyway. A person in a browser gets a <code>303</code> to a friendly “You’re unsubscribed” page on the blog.</p>\n<p>It’s also safe to repeat. If the token doesn’t match anyone (because the address is already gone, or the same tap got retried), the delete just doesn’t find a row and the response is the same. A provider retrying a request never sees an error, and neither does someone who taps the link in an older email after they’ve left.</p>\n<p>Gmail sends <code>application/x-www-form-urlencoded</code>, where the body is literally <code>List-Unsubscribe=One-Click</code>. RFC 8058 says the body “SHOULD be sent as multipart/form-data,” and in a multipart body the name and value sit on separate lines with a boundary around them, so a plain substring check misses it. That’s why the handler parses the form first (on a clone, since a request body can only be read once) and keeps the substring check for senders that leave off the <code>Content-Type</code>. The button on my confirm page has no name, so its POST carries an empty form and falls through to the redirect.</p>\n<h2 id=\"tokens\" data-level=\"3\">Tokens</h2>\n<p>Each subscriber gets one random token when they sign up:</p>\n<figure class=\"code\" data-lang=\"typescript\"><figcaption><span class=\"file\">newsletter/server.ts</span><span class=\"lang\">TypeScript</span></figcaption><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">const</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> token</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> =</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> () </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=></span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> Buffer.</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">from</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(crypto.</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">getRandomValues</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">new</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> Uint8Array</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">24</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">))).</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">toString</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"base64url\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">);</span></span></code></pre></figure><p>That’s 192 bits, stored in a unique column. Tokens don’t expire, because an unsubscribe link in a two-year-old email should still work. A token can only confirm a pending signup or remove that one address, so there’s no need for a signature on top of it.</p>\n<h2 id=\"dkim-has-to-cover-the-headers\" data-level=\"3\">DKIM Has To Cover The Headers</h2>\n<p>This is the part that’s easy to miss. RFC 8058 requires a valid DKIM signature “that covers at least the List-Unsubscribe and List-Unsubscribe-Post headers,” and on the receiving side it says that without one, “the mail receiver SHOULD NOT offer a one-click unsubscribe.” The reasoning is good: if the headers weren’t signed, anyone relaying the message could swap in their own URL and turn the button into something else.</p>\n<p>In my setup the headers go into the same API call as the subject and body, so they’re part of the message before it’s signed and sent. You don’t have to take that on faith, though. Open a received copy in Gmail, choose “Show original”, and find the <code>DKIM-Signature</code> header. Its <code>h=</code> tag lists every header the signature covers. Both <code>List-Unsubscribe</code> and <code>List-Unsubscribe-Post</code> need to be in that list. If they aren’t, the button can quietly disappear even though your server handles the POST perfectly.</p>\n<h2 id=\"deleting-the-address\" data-level=\"3\">Deleting The Address</h2>\n<p>A lot of email tools flag an unsubscribed address and keep it. Mine deletes the subscriber row and the rows recording which posts went to them, because my <a href=\"https://deallocatedobjects.com/privacy\">privacy page</a> promises that. The usual argument for keeping a flagged row is a suppression list, and double opt-in covers that here: if someone types a former subscriber’s address into the form, that person gets one confirmation email they can ignore, and the unconfirmed row is deleted after seven days.</p>\n<p>Sends pick recipients from <code>subscribers where status = 'active'</code> when the job runs, so once the row is gone, the next post can’t go to that address. That also covers Yahoo’s two-day rule.</p>","created_at":"2026-11-07T14:00:00.000Z","updated_at":"2026-11-07T14:00:00.000Z"}