- Aug 10, 2026
- 3 min read
The Python Email Header Bug That Silently Breaks One-Click Unsubscribe
My drip emails had everything Gmail and Yahoo ask of bulk senders: a visible
unsubscribe link in the footer, a List-Unsubscribe header, and
List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058). Tests passed.
The email rendered fine in Mailpit.
Then I read the raw headers Mailpit had received, and the unsubscribe header looked like this:
List-Unsubscribe: =?utf-8?q?=3Chttps=3A//www=2Ejalcocertech=2Ecom/api/forms/?=
=?utf-8?q?unsubscribe=3Ft=3DeyJhIjoidW5zdWIiLCJzIjoiamFsY29jZXJ0ZWNoLmNvbSI?=
...
That is not a URL. It’s an RFC 2047 encoded-word: the mechanism for putting
non-ASCII text in headers. A mail client looking for <https://...> finds
nothing usable, so the one-click button either doesn’t appear or doesn’t
work. Nothing errors. The email still arrives.
Why it happens
The unsubscribe URL carries a signed token, so it’s long: well over the 78-character line length that Python’s default email policy aims for, and it has no spaces.
When a header is too long, the email package tries to fold it: break the
line at whitespace and continue on the next line with leading whitespace.
That is fine for Subject: a long sentence with spaces. A URL has no
whitespace to break at. Faced with an unbreakable token longer than the
limit, the default policy falls back to encoding the value as encoded-words,
which can be split anywhere.
So the very property that makes the link secure (a long opaque token) is what
triggers the mangling. Short test URLs like https://e/u?t=1 never hit it,
which is why my first tests passed.
How I caught it
Not with a unit test. I sent a preview of a real sequence, with a real signed token, into Mailpit and then fetched the headers through Mailpit’s API to open the unsubscribe link programmatically:
h = json.load(urllib.request.urlopen(f"http://localhost:8025/api/v1/message/{mid}/headers"))
url = h["List-Unsubscribe"][0].strip("<>")
urllib.request.urlopen(url) # ValueError: unknown url type: '=?utf-8?q?=3Chttps...'
urllib refused it as a URL, which is exactly what a mail client would do
quietly. The lesson I keep relearning: render the real thing, with
production-sized values, and inspect what actually went over the wire.
The fix
RFC 5322 allows header lines up to 998 characters. My headers fit well within that, so the simplest correct fix is to stop folding at 78:
from email.message import EmailMessage
from email.policy import SMTP
# The default policy folds long headers; a long unbroken URL then becomes an
# RFC 2047 encoded-word that mail clients can't use for one-click unsubscribe.
UNFOLDED = SMTP.clone(max_line_length=998)
mail = EmailMessage(policy=UNFOLDED)
mail["List-Unsubscribe"] = f"<{url}>"
mail["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
email.policy.SMTP also uses CRLF line endings, which is what SMTP expects.
A test that looks at bytes
The regression test checks the serialised message, not the header object (which happily returns the decoded value and hides the problem):
url = "https://www.jalcocertech.com/api/forms/unsubscribe?t=" + "A" * 180 + ".sig"
SmtpSender("h", 1025).send(Message(..., unsubscribe_url=url))
assert f"List-Unsubscribe: <{url}>".encode() in sent[0], "header must not be folded/encoded"
A 180-character token reproduces the real length. Asserting on
msg["List-Unsubscribe"] would have passed even with the bug.
Why the API sender was never affected
The same drip engine can also send through Mailtrap’s HTTP API. There, headers are a JSON object:
"headers": {
"List-Unsubscribe": "<https://www.jalcocertech.com/api/forms/unsubscribe?t=...>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
}
The provider builds the MIME message and handles folding itself, so the bug only existed on the SMTP path, the one I used for local testing with Mailpit. Which is a little ironic: the path meant to make testing realistic was the broken one.
Takeaways
- Python’s default email policy turns long, unbreakable headers into encoded-words. URLs with signed tokens are exactly that.
- Use
email.policy.SMTP.clone(max_line_length=998)for messages carryingList-Unsubscribe. - Test with production-length values and assert on raw bytes.
- Open the link from the received headers, not from your own variable.
This came up while building a drip email engine in standard-library Python. Next: Mailpit vs Mailtrap vs listmonk: what each one is actually for.