add support for Q-encoded list unsubscribe headers

We now successfully parse MIME Q-encoded list unsubscribe headers and
can unsubscribe from such newsletters.
This commit is contained in:
abp 2025-11-11 14:18:50 +01:00
parent 5293be6a4a
commit c2c8f934c5
No known key found for this signature in database
GPG key ID: 791D4EC38A7AA7C2
2 changed files with 34 additions and 12 deletions

View file

@ -675,6 +675,20 @@ export class MailViewerViewModel {
return !isEmpty(listUnsubscribeHeaders)
}
private decodeMimeHeader(value: string): string {
return value.replace(/=\?([^?]+)\?([QB])\?([^?]+)\?=/gi, (_, _charset, encoding, encodedText) => {
if (encoding.toUpperCase() === "Q") {
return encodedText.replace(/_/g, " ").replace(/=([A-Fa-f0-9]{2})/g, (_: string, hex: string) => String.fromCharCode(parseInt(hex, 16)))
} else if (encoding.toUpperCase() === "B") {
try {
return Buffer.from(encodedText, "base64").toString("utf-8")
} catch {
return encodedText
}
}
return encodedText
})
}
async determineUnsubscribeOrder(): Promise<Array<UnsubscribeAction>> {
const mailHeaders = await this.getHeaders()
const unsubscribeActions: Array<UnsubscribeAction> = []
@ -682,23 +696,19 @@ export class MailViewerViewModel {
return unsubscribeActions
}
const listUnsubscribeHeaders = mailHeaders
.replaceAll(/\r\n/g, "\n") // replace all CR LF with LF
.replaceAll(/\n[ \t]/g, "") // join multiline headers to a single line
.split("\n") // split headers
.filter((headerLine) => headerLine.toLowerCase().startsWith("list-unsubscribe:"))
const normalizedHeaders = mailHeaders
.replaceAll(/\r\n/g, "\n")
.replaceAll(/\n[ \t]/g, "")
.split("\n")
.map((h) => this.decodeMimeHeader(h.trim()))
const listUnsubscribeHeaders = normalizedHeaders.filter((headerLine) => headerLine.toLowerCase().startsWith("list-unsubscribe:"))
if (isEmpty(listUnsubscribeHeaders)) {
return unsubscribeActions
}
const unsubPostHeader = first(
mailHeaders
.replaceAll(/\r\n/g, "\n") // replace all CR LF with LF
.replaceAll(/\n[ \t]/g, "") // join multiline headers to a single line
.split("\n") // split headers
.filter((headerLine) => headerLine.toLowerCase().startsWith("list-unsubscribe-post")),
)
const unsubPostHeader = normalizedHeaders.find((h) => h.toLowerCase().startsWith("list-unsubscribe-post"))
const [_, ...value] = listUnsubscribeHeaders[0].split(":")
const headerValue = value.join(":")

View file

@ -306,6 +306,18 @@ o.spec("MailViewerViewModel", function () {
const expectedPostUrl = "http://unsub.me?id=2134"
await testHeaderUnsubscribePost(headers.join("\r\n"), expectedPostUrl, true)
})
o("with Q encoded header", async function () {
const headers = [
"List-Unsubscribe: ",
" =?us-ascii?Q?=3Chttps=3A=2F=2Fnewsletter=2Eexample=2Ecom=2Funsubscribe=2Fabcd1234efgh?=",
" =?us-ascii?Q?5678ijkl91011mnopqr=3E?=",
"List-Unsubscribe-Post: List-Unsubscribe=One-Click",
]
const expectedPostUrl = "https://newsletter.example.com/unsubscribe/abcd1234efgh5678ijkl91011mnopqr"
await testHeaderUnsubscribePost(headers.join("\r\n"), expectedPostUrl, true)
})
o("no list unsubscribe header", async function () {
const headers = "To: InvalidHeader"
const viewModel = initUnsubscribeHeaders(headers)