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
- RFC 2369 section 3.2 for the URI syntax and RFC 8058 for one-click, in particular section 3.1, section 3.2 and section 4.
guard.jsand the certificate from How to test email sending in a test environment.unsub-http.js, the endpoint under test. It logs the method, the content type and the body of every request:
// 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);
});
build-unsub.js, which sends three messages with three versions of the header:
// 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();
})();
unsub-check.js, which reads the captured messages:
// 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
- 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 45024The endpoint serves TLS with the same self-signed certificate the SMTP server uses, because RFC 8058 section 3.1 requires an https URI.
- Step 2.
Send the three header variants.
node build-unsub.jsone-click 250 OK: message queued no-brackets 250 OK: message queued mailto-only 250 OK: message queuedAll three are accepted. No mail server validates this header, which is why a broken one survives to the recipient.
- Step 3.
Parse the header out of the three captured messages.
node unsub-check.jsWeekly 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.
- 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/9f2c1ab4HTTP/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 unsubscribedThe 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. - 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/9f2c1ab4HTTP/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 changedSame URL, same key and value,
200 OK, and nobody was unsubscribed. RFC 8058 section 3.2 says the POST content SHOULD bemultipart/form-dataand MAY beapplication/x-www-form-urlencoded. This endpoint handles only the second. The provider sees a success and the sender sees nothing at all. - 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/9f2c1ab4HTTP/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 changedA 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.
- Step 7.
Read what the endpoint received.
cat unsub.logunsubscribe 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.
- 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
What to check next
- How to test transactional email rendering: the rest of the captured message, checked the same way.
- How to test email sending in a test environment: the catch-all these three messages went to.
- How to verify DKIM signature: the signature that has to cover both unsubscribe headers.
- How to check DMARC record: the policy a bulk sender is judged on alongside the unsubscribe path.
- Email testing checklist: where this sits in a release that touches mail.
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.
Related on this site
intermediate12 minpublished updated Maks Verny