How to check List-Unsubscribe header

Read List-Unsubscribe and List-Unsubscribe-Post from the delivered message, not from the template. node unsub-check.js reports which URIs parse and whether one-click is usable. Every URI needs angle brackets, and one-click needs an https URI plus a POST the endpoint acts on.

Why check this

Run this on any message sent to a list, and again whenever the unsubscribe endpoint is redeployed. Two independent things have to work: a header a machine can parse, and an endpoint that performs the unsubscribe on the request the client actually sends.

The failure it prevents: the header carries a mailto: URI and List-Unsubscribe-Post, the sender believes one-click is enabled, and the mailbox provider finds no https URI to post to. The button does not appear, complaints rise instead of unsubscribes, and the domain's reputation falls. The header parses. It is still unusable, and step 3 says so in one line.

Prerequisites

// The unsubscribe endpoint under test, over TLS because RFC 8058 requires https.
// Logs the method, the content type and the body of every request, and never
// acts on a GET.
const fs = require('fs');
const https = require('https');
const opts = { key: fs.readFileSync('smtp.key'), cert: fs.readFileSync('smtp.crt') };
https.createServer(opts, (req, res) => {
  const chunks = [];
  req.on('data', (c) => chunks.push(c));
  req.on('end', () => {
    const body = Buffer.concat(chunks).toString();
    console.log(req.method + ' ' + req.url +
      '  content-type=' + (req.headers['content-type'] || '-') +
      '  body=' + JSON.stringify(body));
    if (req.method === 'POST' && body === 'List-Unsubscribe=One-Click') {
      console.log('  -> unsubscribed');
      res.writeHead(200, { 'content-type': 'text/plain' });
      return res.end('unsubscribed\n');
    }
    res.writeHead(200, { 'content-type': 'text/plain' });
    res.end('confirmation page, nothing changed\n');
  });
}).listen(9713, '127.0.0.1', function () {
  console.log('unsubscribe endpoint on https://127.0.0.1:9713, pid ' + process.pid);
});
// Three messages with three List-Unsubscribe headers: one that follows RFC 8058,
// one missing the angle brackets, one with mailto only.
const nodemailer = require('nodemailer');
const t = nodemailer.createTransport({ host: '127.0.0.1', port: 2531, tls: { rejectUnauthorized: false } });
const variants = [
  ['one-click', {
    'List-Unsubscribe': '<mailto:unsub@build.test?subject=unsubscribe>, <https://127.0.0.1:9713/u/9f2c1ab4>',
    'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
  }],
  ['no-brackets', { 'List-Unsubscribe': 'https://127.0.0.1:9713/u/9f2c1ab4' }],
  ['mailto-only', { 'List-Unsubscribe': '<mailto:unsub@build.test>', 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click' }],
];
(async () => {
  for (const [name, headers] of variants) {
    const i = await t.sendMail({
      from: 'Build Test <news@build.test>', to: 'qa@build.test',
      subject: 'Weekly digest (' + name + ')', text: 'digest', headers,
    });
    console.log(name.padEnd(12) + i.response);
  }
  t.close();
})();
// Reads List-Unsubscribe and List-Unsubscribe-Post from every captured message
// and checks them against RFC 2369 and RFC 8058.
const fs = require('fs');
const path = require('path');

function headerOf(text, name) {
  const re = new RegExp('^' + name + ':([^]*?)(?=\r\n[^ \t])', 'im');
  const m = re.exec(text);
  return m ? m[1].replace(/\r\n[ \t]+/g, ' ').trim() : null;
}

for (const file of fs.readdirSync('capture').sort()) {
  const text = fs.readFileSync(path.join('capture', file), 'utf8');
  const subject = headerOf(text, 'Subject');
  const lu = headerOf(text, 'List-Unsubscribe');
  const post = headerOf(text, 'List-Unsubscribe-Post');
  console.log(subject);
  console.log('  List-Unsubscribe      : ' + lu);
  console.log('  List-Unsubscribe-Post : ' + post);
  if (!lu) { console.log('  verdict               : no header'); continue; }
  const uris = lu.split(',').map((s) => s.trim());
  const bad = uris.filter((u) => !/^<[a-z]+:[^>]*>$/i.test(u));
  const parsed = uris.filter((u) => /^<[a-z]+:[^>]*>$/i.test(u)).map((u) => u.slice(1, -1));
  console.log('  URIs that parse       : ' + (parsed.length ? parsed.join('  ') : 'none'));
  if (bad.length) console.log('  URIs that do not      : ' + bad.join('  ') + '   (RFC 2369: angle brackets are required)');
  const https = parsed.find((u) => /^https:/i.test(u));
  const dkim = headerOf(text, 'DKIM-Signature');
  const covered = dkim && /h=([^;]+)/.exec(dkim);
  const hTag = covered ? covered[1].toLowerCase() : '';
  console.log('  DKIM covers both      : ' + (!dkim ? 'no DKIM-Signature at all'
    : (hTag.includes('list-unsubscribe') && hTag.includes('list-unsubscribe-post')
      ? 'yes' : 'NO (RFC 8058 section 4 requires both in h=)')));
  if (post === 'List-Unsubscribe=One-Click') {
    console.log('  one-click             : ' + (https
      ? 'usable, POST to ' + https
      : 'ADVERTISED BUT UNUSABLE, no https URI in the header (RFC 8058 section 3.1)'));
  } else {
    console.log('  one-click             : not offered, the client opens the link or sends the mail');
  }
  console.log();
}

Steps

  1. Step 1.

    Start the catch-all and the unsubscribe endpoint, and keep both process ids.

    node guard.js & node unsub-http.js &
    
    guarded catch-all on 127.0.0.1:2531, pid 38116
    unsubscribe endpoint on https://127.0.0.1:9713, pid 45024

    The endpoint serves TLS with the same self-signed certificate the SMTP server uses, because RFC 8058 section 3.1 requires an https URI.

  2. Step 2.

    Send the three header variants.

    node build-unsub.js
    
    one-click   250 OK: message queued
    no-brackets 250 OK: message queued
    mailto-only 250 OK: message queued

    All three are accepted. No mail server validates this header, which is why a broken one survives to the recipient.

  3. Step 3.

    Parse the header out of the three captured messages.

    node unsub-check.js
    
    Weekly digest (one-click)
    List-Unsubscribe      : <mailto:unsub@build.test?subject=unsubscribe>, <https://127.0.0.1:9713/u/9f2c1ab4>
    List-Unsubscribe-Post : List-Unsubscribe=One-Click
    URIs that parse       : mailto:unsub@build.test?subject=unsubscribe  https://127.0.0.1:9713/u/9f2c1ab4
    DKIM covers both      : no DKIM-Signature at all
    one-click             : usable, POST to https://127.0.0.1:9713/u/9f2c1ab4
    
    Weekly digest (no-brackets)
    List-Unsubscribe      : https://127.0.0.1:9713/u/9f2c1ab4
    List-Unsubscribe-Post : null
    URIs that parse       : none
    URIs that do not      : https://127.0.0.1:9713/u/9f2c1ab4   (RFC 2369: angle brackets are required)
    DKIM covers both      : no DKIM-Signature at all
    one-click             : not offered, the client opens the link or sends the mail
    
    Weekly digest (mailto-only)
    List-Unsubscribe      : <mailto:unsub@build.test>
    List-Unsubscribe-Post : List-Unsubscribe=One-Click
    URIs that parse       : mailto:unsub@build.test
    DKIM covers both      : no DKIM-Signature at all
    one-click             : ADVERTISED BUT UNUSABLE, no https URI in the header (RFC 8058 section 3.1)

    Three verdicts. The first is correct and offers both a mailbox and a URL. The second has a URI a person can read and a parser cannot: without angle brackets there is nothing to extract. The third advertises one-click with no https URI to post to, so the provider has nothing to call. All three messages lack the DKIM signature that RFC 8058 section 4 requires over both headers.

  4. Step 4.

    Call the endpoint the way a one-click client does.

    curl -sk -i -X POST -H "Content-Type: application/x-www-form-urlencoded" --data "List-Unsubscribe=One-Click" https://127.0.0.1:9713/u/9f2c1ab4
    
    HTTP/1.1 200 OK
    content-type: text/plain
    Date: Fri, 11 Sep 2026 21:23:16 GMT
    Connection: keep-alive
    Keep-Alive: timeout=5
    Transfer-Encoding: chunked
    
    unsubscribed

    The body is the key and value from List-Unsubscribe-Post, exactly as written in the header. This is the encoding RFC 8058 section 3.2 allows.

  5. Step 5.

    Repeat with the encoding the same section prefers.

    curl -sk -i -F "List-Unsubscribe=One-Click" https://127.0.0.1:9713/u/9f2c1ab4
    
    HTTP/1.1 200 OK
    content-type: text/plain
    Date: Fri, 11 Sep 2026 21:23:16 GMT
    Connection: keep-alive
    Keep-Alive: timeout=5
    Transfer-Encoding: chunked
    
    confirmation page, nothing changed

    Same URL, same key and value, 200 OK, and nobody was unsubscribed. RFC 8058 section 3.2 says the POST content SHOULD be multipart/form-data and MAY be application/x-www-form-urlencoded. This endpoint handles only the second. The provider sees a success and the sender sees nothing at all.

  6. Step 6.

    Fetch the same URL with a GET, the way a link scanner does.

    curl -sk -i https://127.0.0.1:9713/u/9f2c1ab4
    
    HTTP/1.1 200 OK
    content-type: text/plain
    Date: Fri, 11 Sep 2026 21:23:16 GMT
    Connection: keep-alive
    Keep-Alive: timeout=5
    Transfer-Encoding: chunked
    
    confirmation page, nothing changed

    A GET must not unsubscribe anybody. Filters and security appliances fetch every URL in a message before it is opened, and an endpoint that acts on GET unsubscribes readers who never clicked.

  7. Step 7.

    Read what the endpoint received.

    cat unsub.log
    
    unsubscribe endpoint on https://127.0.0.1:9713, pid 45024
    POST /u/9f2c1ab4  content-type=application/x-www-form-urlencoded  body="List-Unsubscribe=One-Click"
    -> unsubscribed
    POST /u/9f2c1ab4  content-type=multipart/form-data; boundary=------------------------ZE9fvUIEHeGXEY7BMoExqZ  body="--------------------------ZE9fvUIEHeGXEY7BMoExqZ\r\nContent-Disposition: form-data; name=\"List-Unsubscribe\"\r\n\r\nOne-Click\r\n--------------------------ZE9fvUIEHeGXEY7BMoExqZ--\r\n"
    GET /u/9f2c1ab4  content-type=-  body=""

    The multipart body carries the same key and value inside a part, which is why a handler that compares the raw body to a string misses it. Parse the body by content type.

  8. Step 8.

    Stop both processes by their own process ids.

    powershell -Command "Stop-Process -Id 38116 -Force; Stop-Process -Id 45024 -Force"
    
    $ netstat -ano | grep LISTENING | grep -E ":(2531|9713) "
    $

    Both ports are free.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | URIs that parse : none | The URIs have no angle brackets | Wrap each URI in < and >, comma separated. | | Only a mailto: URI | The client shows a link that opens a mail composer | Add an https URI if you want the provider's own button. | | one-click : ADVERTISED BUT UNUSABLE | List-Unsubscribe-Post is present with no https URI | Add the https URI, or remove the Post header. | | DKIM covers both : NO | The two headers are outside the h= tag | Add them to the signer's header list, see How to verify DKIM signature. | | A multipart POST changes nothing | The endpoint reads only urlencoded bodies | Parse by content type. The multipart form is the preferred encoding. | | A GET changes the subscription | Scanners will unsubscribe readers | Act only on POST. Return a confirmation page to a GET. | | Endpoint answers 4xx or redirects | The provider records a failure | Answer 200 from the URL in the header, with no redirect. |

Common mistakes

Sign: One-click passes every test and real unsubscribes never arrive.Cause: Step 5: the endpoint accepts application/x-www-form-urlencoded and ignores multipart/form-data, which RFC 8058 section 3.2 names first. Both return 200, so nothing anywhere reports a failure. Test both encodings against the endpoint, not only the one your own test client sends.
Sign: Subscribers disappear in batches minutes after a campaign is sent.Cause: A security appliance or a spam filter fetched every URL in the message, the unsubscribe URL among them. An endpoint that acts on GET treats those fetches as clicks. Step 6 keeps the GET inert and the POST effective.
Sign: The header looks right in the template and no client shows the button.Cause: A URI without angle brackets is not a URI for RFC 2369, and mail servers accept the message anyway, as step 2 shows. Read the header from the delivered message with a parser, because the template is not what is being judged.
Sign: One-click is configured and the provider ignores it.Cause: RFC 8058 section 4 requires List-Unsubscribe and List-Unsubscribe-Post to be covered by a valid DKIM signature and named in its h= tag. A signer configured before those headers existed does not sign them, and the unsubscribe check fails without any change to the header itself.

What to check next

FAQ

What is List-Unsubscribe-Post?

A header whose only permitted value is List-Unsubscribe=One-Click. It tells the mail client that the https URI in List-Unsubscribe accepts a POST and performs the unsubscribe without a confirmation page, which is what RFC 8058 defines.

Do I need both the mailto and the https form?

The https URI is what one-click needs. The mailto: URI covers clients that have no HTTP path to the sender. Sending both costs one header and is what the first variant in step 3 does.

Why must the unsubscribe URL ignore GET requests?

Because machines fetch links. Filters, previews and security scanners issue GET requests for every URL in a message. Acting on GET turns those into unsubscribes from people who never saw the mail.

Where do I read the header from?

From the delivered message, as captured in step 3. Reading it from the template skips the mail library and the sending platform, either of which can rewrite or drop the header.

Verified

Verified by Maks Vernynode 22.23.2curl 8.21.0nodemailer 10.0.8

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate12 minpublished updated Maks Verny