Compliance Deliverability

missing List-Unsubscribe now gets bulk mail rejected outright

This one changed underneath everyone. When Google and Yahoo introduced their bulk-sender rules in February 2024, non-compliant mail was deferred with a 4xx — annoying, retryable, survivable. Since November 2025 it is a permanent 550 rejection. Mail that was merely slow last year does not arrive at all now, and the fix is two headers most templates never had.

RFC 8058 Python and Node.js Check before you send
The short answer

If you send more than 5,000 messages a day to personal Gmail or Yahoo accounts, marketing and promotional mail must carry List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click, per RFC 8058.

The link must work without making the recipient log in, and you must honour it within two days. Transactional mail is exempt. The script checks a rendered message for both headers and for the mistakes that make them non-compliant even when present.

The problem in plain words

The failure is at the gateway, so it never reaches your bounce handling in a form that reads as 'you are missing a header'. You see a rise in hard bounces from two providers who between them are most of your consumer list.

Having the header is not the same as complying. List-Unsubscribe with only a mailto: and no HTTPS URL does not satisfy one-click. An HTTPS URL that lands on a login page does not either. And List-Unsubscribe-Post is a separate header that a lot of templates omit entirely, which turns a valid one-click into an ordinary link.

Why it happens

The requirement arrived quietly and hardened later. February 2024 brought the rules with soft enforcement; November 2025 turned deferrals into rejections. Teams that saw no problem in 2024 concluded they were compliant.

Two headers, not one. List-Unsubscribe alone predates the one-click standard by decades. RFC 8058 adds List-Unsubscribe-Post, and without it the receiver will not treat the link as one-click.

The unsubscribe endpoint is usually built for humans. One-click sends an HTTP POST with no session and no cookies. An endpoint that expects a logged-in user, or a GET with a confirmation page, fails a check nobody tested.

How to fix it

Check a rendered message, not the template

Headers are often added by the sending layer rather than the template, so inspect what actually went out. Send one to a mailbox you control and read the raw source.

grep -i '^List-Unsubscribe' raw-message.eml

Make sure both headers are present and shaped right

List-Unsubscribe needs an HTTPS URL in angle brackets; a mailto: may be included as well but cannot be the only entry. List-Unsubscribe-Post must read exactly List-Unsubscribe=One-Click.

List-Unsubscribe: <https://example.com/u/abc123>, <mailto:unsub@example.com>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

Test the endpoint the way a receiver will

POST to it with no cookies and no auth. It must return a 2xx and actually unsubscribe. If it redirects to a login, or only works as a GET, it fails for Gmail even though it works for a person clicking in a browser.

Do not add it to transactional mail

One-click unsubscribe is required for marketing and promotional messages, not receipts, password resets or security alerts. Putting it on transactional mail invites people to unsubscribe from things they need.

How to check it worked

Send to a Gmail address you control and read the raw source. Then exercise the endpoint the way a receiver does:

curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  -d 'List-Unsubscribe=One-Click' https://example.com/u/abc123
# 200, and the address is actually suppressed afterwards

Watch the bounce rate from Gmail and Yahoo specifically. A 550 naming policy or unsubscribe is the signal that something is still wrong.

The full code

The script parses a raw message and checks both headers, the URL scheme, the exact List-Unsubscribe-Post value, and whether the message looks transactional — in which case the headers should not be there at all. It works on a file or standard input so it can sit in a template test suite.

check_list_unsubscribe.py
"""Check a rendered message for RFC 8058 one-click unsubscribe compliance.

Since November 2025 Gmail and Yahoo reject non-compliant bulk mail with a permanent
550 rather than deferring it. Having the header is not enough: it needs an HTTPS
URL, and it needs the separate List-Unsubscribe-Post header to count as one-click.
"""
import argparse
import re
import sys
from email import policy
from email.parser import BytesParser

ONE_CLICK = "List-Unsubscribe=One-Click"


def check(headers, is_transactional=False):
    """Pure decision function over a mapping of headers.

    Returns a list of problems. Transactional mail is checked in reverse: it should
    NOT carry these headers, because inviting someone to unsubscribe from a password
    reset is its own kind of failure.
    """
    lu = headers.get("List-Unsubscribe", "") or ""
    lup = (headers.get("List-Unsubscribe-Post", "") or "").strip()

    if is_transactional:
        return ([] if not lu else
                ["transactional mail carries List-Unsubscribe; one-click is for "
                 "marketing and promotional mail only"])

    problems = []
    if not lu:
        problems.append("no List-Unsubscribe header")
    else:
        urls = re.findall(r"<([^>]+)>", lu)
        if not urls:
            problems.append("List-Unsubscribe has no value in angle brackets")
        elif not any(u.lower().startswith("https://") for u in urls):
            problems.append("List-Unsubscribe has no HTTPS URL; a mailto: alone is "
                            "not one-click")
    if not lup:
        problems.append("no List-Unsubscribe-Post header; without it the link is not "
                        "treated as one-click")
    elif lup != ONE_CLICK:
        problems.append(f"List-Unsubscribe-Post is {lup!r}, must be exactly {ONE_CLICK!r}")
    return problems


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("message", nargs="?", help="raw .eml file; defaults to stdin")
    ap.add_argument("--transactional", action="store_true")
    args = ap.parse_args()

    raw = open(args.message, "rb").read() if args.message else sys.stdin.buffer.read()
    msg = BytesParser(policy=policy.default).parsebytes(raw)

    problems = check(msg, args.transactional)
    for p in problems:
        print(f"FAIL {p}", file=sys.stderr)
    if not problems:
        print("OK   one-click unsubscribe headers are compliant")
    return 1 if problems else 0


if __name__ == "__main__":
    sys.exit(main())
check-list-unsubscribe.mjs
/**
 * Check a rendered message for RFC 8058 one-click unsubscribe compliance.
 *
 * Since November 2025 Gmail and Yahoo reject non-compliant bulk mail with a
 * permanent 550 rather than deferring it. Having the header is not enough.
 */
import { readFile } from 'node:fs/promises';

const ONE_CLICK = 'List-Unsubscribe=One-Click';

/**
 * Pure decision function over a mapping of headers.
 *
 * Transactional mail is checked in reverse: it should NOT carry these headers,
 * because inviting someone to unsubscribe from a password reset is its own failure.
 */
export function check(headers, isTransactional = false) {
  const get = (k) => headers[k] ?? headers[k.toLowerCase()] ?? '';
  const lu = get('List-Unsubscribe');
  const lup = get('List-Unsubscribe-Post').trim();

  if (isTransactional) {
    return lu ? ['transactional mail carries List-Unsubscribe; one-click is for '
      + 'marketing and promotional mail only'] : [];
  }

  const problems = [];
  if (!lu) problems.push('no List-Unsubscribe header');
  else {
    const urls = [...lu.matchAll(/<([^>]+)>/g)].map((m) => m[1]);
    if (!urls.length) problems.push('List-Unsubscribe has no value in angle brackets');
    else if (!urls.some((u) => u.toLowerCase().startsWith('https://'))) {
      problems.push('List-Unsubscribe has no HTTPS URL; a mailto: alone is not one-click');
    }
  }
  if (!lup) {
    problems.push('no List-Unsubscribe-Post header; without it the link is not treated as one-click');
  } else if (lup !== ONE_CLICK) {
    problems.push(`List-Unsubscribe-Post is "${lup}", must be exactly "${ONE_CLICK}"`);
  }
  return problems;
}

function parseHeaders(raw) {
  const head = raw.split(/\r?\n\r?\n/)[0];
  const out = {};
  for (const line of head.split(/\r?\n(?![ \t])/)) {
    const i = line.indexOf(':');
    if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/\r?\n[ \t]+/g, ' ');
  }
  return out;
}

async function main() {
  const file = process.argv.slice(2).find((a) => !a.startsWith('--'));
  const raw = await readFile(file, 'utf8');
  const problems = check(parseHeaders(raw), process.argv.includes('--transactional'));
  problems.forEach((p) => console.error(`FAIL ${p}`));
  if (!problems.length) console.log('OK   one-click unsubscribe headers are compliant');
  process.exit(problems.length ? 1 : 0);
}

if (import.meta.url === `file://${process.argv[1]}`) main();

Add a test

Three near-misses are worth locking down, because each one looks compliant at a glance: a mailto-only header, a missing Post header, and a Post header with the right idea but the wrong text.

test_check_list_unsubscribe.py
from check_list_unsubscribe import check

GOOD = {
    "List-Unsubscribe": "<https://example.com/u/abc>, <mailto:u@example.com>",
    "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}


def test_compliant_message_passes():
    assert check(GOOD) == []


def test_mailto_alone_is_not_one_click():
    headers = GOOD | {"List-Unsubscribe": "<mailto:u@example.com>"}
    assert any("HTTPS" in p for p in check(headers))


def test_missing_post_header_fails():
    headers = {"List-Unsubscribe": GOOD["List-Unsubscribe"]}
    assert any("List-Unsubscribe-Post" in p for p in check(headers))


def test_post_header_with_wrong_text_fails():
    """Right idea, wrong string. Receivers compare exactly."""
    headers = GOOD | {"List-Unsubscribe-Post": "One-Click"}
    assert any("must be exactly" in p for p in check(headers))


def test_transactional_mail_should_not_carry_it():
    assert check(GOOD, is_transactional=True)


def test_transactional_without_the_header_is_fine():
    assert check({}, is_transactional=True) == []
check-list-unsubscribe.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { check } from './check-list-unsubscribe.mjs';

const GOOD = {
  'List-Unsubscribe': '<https://example.com/u/abc>, <mailto:u@example.com>',
  'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
};

test('a compliant message passes', () => {
  assert.deepEqual(check(GOOD), []);
});

test('mailto alone is not one-click', () => {
  const h = { ...GOOD, 'List-Unsubscribe': '<mailto:u@example.com>' };
  assert.ok(check(h).some((p) => p.includes('HTTPS')));
});

test('a missing Post header fails', () => {
  const h = { 'List-Unsubscribe': GOOD['List-Unsubscribe'] };
  assert.ok(check(h).some((p) => p.includes('List-Unsubscribe-Post')));
});

test('a Post header with the wrong text fails', () => {
  const h = { ...GOOD, 'List-Unsubscribe-Post': 'One-Click' };
  assert.ok(check(h).some((p) => p.includes('must be exactly')));
});

test('transactional mail should not carry it', () => {
  assert.ok(check(GOOD, true).length);
});

FAQ

What changed in November 2025?

Enforcement hardened. Google and Yahoo introduced the bulk-sender rules in February 2024 with soft failures — 4xx deferrals that retried and often got through. Since November 2025 non-compliant mail gets a permanent 550, so it does not arrive at all.

Who has to comply?

Senders of more than 5,000 messages a day to personal Gmail or Yahoo accounts. The requirement applies to marketing and promotional mail; transactional messages such as receipts and password resets are exempt.

I have List-Unsubscribe already. Is that enough?

No. That header predates one-click by decades. RFC 8058 requires a second header, List-Unsubscribe-Post: List-Unsubscribe=One-Click, and without it receivers treat the link as an ordinary unsubscribe rather than one-click.

Can the unsubscribe link require a login?

No. One-click sends an HTTP POST with no session and no cookies. An endpoint that expects a logged-in user, or only works as a GET with a confirmation page, fails the check even though it works fine for a person in a browser.

How quickly must an unsubscribe be honoured?

Within two days. Continuing to send after that is what drives the spam complaint rate, which has its own threshold — 0.3% measured as a rolling rate.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.