The List-Unsubscribe header: implementing one-click unsubscribe (RFC 8058) correctly

What List-Unsubscribe and List-Unsubscribe-Post do, why Gmail and Yahoo now require them, what your endpoint must accept, which messages should carry them and which must not — with working code for Laravel, Node.js and Python.

The Mailclutch team6 min read

List-Unsubscribe is a header that tells a mail client how to unsubscribe the recipient without their having to find the link at the bottom of the message. It has existed since 1998. Since 2024, Gmail and Yahoo require its modern form — one-click unsubscribe, RFC 8058 — on subscribed mail from bulk senders, and treat its absence as a signal.

Implementing it is two headers and one endpoint. Implementing it correctly means getting the endpoint's semantics right, deciding which messages carry it, and verifying that the headers actually survive to the receiver. This article covers all three.

What the headers do

List-Unsubscribe: <https://example.com/unsubscribe/7f3a9c>, <mailto:unsubscribe@example.com?subject=7f3a9c>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

List-Unsubscribe (RFC 2369) lists one or more URIs, in angle brackets, comma-separated. An https: URI, a mailto: URI, or both. The client shows an "Unsubscribe" control near the sender's name and, when the user clicks it, uses one of the URIs.

List-Unsubscribe-Post (RFC 8058) is what makes it one-click. Its value is always the literal string List-Unsubscribe=One-Click. Its presence tells the client that it may send an HTTP POST to the https: URI with that string as the body, and that doing so unsubscribes the user immediately — no page, no confirmation, no login.

Without the second header, a client that wants to unsubscribe the user has to open the URL in a browser, which means a landing page, which means the user might have to confirm, log in, or scroll. Gmail will not do that on the user's behalf. With it, the whole interaction is one tap in the mail client and one POST to you.

The endpoint

The POST the client sends looks like this:

POST /unsubscribe/7f3a9c HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 26
User-Agent: Mozilla/5.0 (compatible; Google-Unsubscribe)

List-Unsubscribe=One-Click

Your endpoint must:

  • Accept POST at the exact URL in the header. No redirects — the client will not follow one.
  • Not require authentication. There is no session, no cookie, no bearer. The token in the URL is the whole authorization. It must be unguessable and tied to one recipient and one list.
  • Unsubscribe on receipt, and respond 200. Do not return a page that asks for confirmation — the client is not a browser and nobody will see it.
  • Be idempotent. The same POST may arrive twice.
  • Respond fast. Under a few seconds. Do the database work synchronously; do the "we're sorry to see you go" email — if you must — later.
  • Also serve GET at the same URL, for the fallback where a client opens it in a browser. That one can show a page.

The request body is the fixed string List-Unsubscribe=One-Click. Checking for it is reasonable; rejecting a POST that lacks it is not, because some clients send an empty body.

routes/web.php (Laravel)
Route::match(['get', 'post'], '/unsubscribe/{token}', function (Request $request, string $token) {
    $subscription = Subscription::findByUnsubscribeToken($token);

    if (! $subscription) {
        return response('', 404);
    }

    $subscription->unsubscribe(source: $request->isMethod('post') ? 'one-click' : 'link');

    if ($request->isMethod('post')) {
        return response('', 200);   // The client is a mail server. No page.
    }

    return view('unsubscribed', ['list' => $subscription->list]);
})->withoutMiddleware([VerifyCsrfToken::class]);

Note the CSRF exclusion. A one-click POST carries no CSRF token and never can. The unguessable token in the path is the protection.

Setting the headers

The headers go on the message. Where that happens depends on the stack.

Laravel, in a Mailable:

app/Mail/WeeklyDigest.php
public function headers(): Headers
{
    $url = route('unsubscribe', $this->subscription->unsubscribe_token);

    return new Headers(text: [
        'List-Unsubscribe'      => "<{$url}>, <mailto:unsubscribe@example.com?subject={$this->subscription->unsubscribe_token}>",
        'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click',
    ]);
}

Node.js, with Nodemailer:

mailer.js
await transport.sendMail({
  from: 'Example <news@example.com>',
  to: subscriber.email,
  subject: 'Your weekly digest',
  html,
  text,
  list: {
    unsubscribe: {
      url: `https://example.com/unsubscribe/${subscriber.unsubscribeToken}`,
      comment: 'Unsubscribe',
    },
  },
  headers: {
    'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
  },
});

Nodemailer's list.unsubscribe produces the first header; the second has to be added by hand.

Python, with the standard library:

mailer.py
from email.message import EmailMessage

msg = EmailMessage()
msg["From"] = "Example <news@example.com>"
msg["To"] = subscriber.email
msg["Subject"] = "Your weekly digest"
msg["List-Unsubscribe"] = (
    f"<https://example.com/unsubscribe/{subscriber.unsubscribe_token}>, "
    f"<mailto:unsubscribe@example.com?subject={subscriber.unsubscribe_token}>"
)
msg["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
msg.set_content(text)
msg.add_alternative(html, subtype="html")

Two things the code has to get right, in every stack:

  • The token is per recipient, per list. One token that unsubscribes an address from everything is a token that a forwarded email can use to unsubscribe someone from everything. Scope it.
  • The mailto: fallback should work. Some clients — and some receivers evaluating you — use it. Point it at an address that is actually processed.

Which messages carry it

This is where teams get it wrong in both directions.

Subscribed mail — newsletters, digests, product updates, promotional mail, anything the recipient opted into and could reasonably opt out of — must carry both headers. Gmail and Yahoo require it above 5,000 messages a day, and score its absence below that.

Transactional mail — receipts, password resets, security alerts, shipping notifications, two-factor codes — should not. The recipient cannot meaningfully unsubscribe from a password reset. Adding the header invites clients to offer an unsubscribe control on a message where "unsubscribing" makes no sense, and some receivers treat a List-Unsubscribe on obviously transactional content as a mismatch.

The grey zone is notifications — "someone commented on your post", "your weekly usage summary". If the user can turn them off in settings, the message is subscribed mail and the header should be present and should do exactly what the setting does.

The engineering consequence: your mailer needs to know, per message, which kind it is sending. A base Mailable/message class with a subscription property that, when set, adds the headers — and when null, does not — is the shape that works. A global "add List-Unsubscribe to everything" middleware is the shape that puts an unsubscribe link on your invoices.

What receivers do with it

  • Gmail shows "Unsubscribe" next to the sender name in the web and mobile clients, uses one-click when List-Unsubscribe-Post is present, and counts the header's presence in its sender evaluation.
  • Yahoo / AOL likewise, and additionally require one-click for bulk senders.
  • Outlook.com shows an unsubscribe control; Microsoft's own guidance recommends both headers.
  • Apple Mail shows a banner ("This message is from a mailing list. Unsubscribe") on messages with the header.

The behaviour to understand is that these clients now offer unsubscribe before the user reaches "report spam". Every unsubscribe that happens through the header is a spam complaint that did not — and the complaint rate is the number Gmail filters your domain on.

Verifying the headers survive

Headers set in code do not always reach the receiver. A queue serialiser drops custom headers; an ESP's API silently discards headers it does not recognise; a gateway rewrites them. The way to know is to capture the message as a receiver would and look.

On a Mailclutch capture, the headers panel shows both lines as received, and the deliverability score reports the result as one of three states: both headers present (one-click), List-Unsubscribe alone (link only), or neither. Because the check runs on every message your staging environment sends, the case where a refactor of the base mailer drops the second header shows up on the next staging send, as a score change with a named cause — not three weeks later as a complaint-rate change in Postmaster Tools.

The same capture is where you check the other direction: that the receipt email does not carry the header, because someone made the base class add it unconditionally.

Summary

  • Two headers: List-Unsubscribe with an https: URI (and ideally a mailto:), and List-Unsubscribe-Post: List-Unsubscribe=One-Click.
  • One endpoint: POST to the exact URL, no auth, no redirect, no confirmation, 200, idempotent, fast. GET for the browser fallback.
  • Per-recipient, per-list tokens.
  • On subscribed mail, always. On transactional mail, never. The mailer has to know which is which.
  • Verify on a captured message, not in the code that sets them.

Related articles

Keep reading

All articles

Read about it here, check it on your staging.

See it on your own mail

Point your staging SMTP at Mailclutch and every message comes back with its headers, its spam score and its authentication results — before a customer sees it.

Free plan · no credit card · running in under a minute