From c2c8f934c537c3a93e2194d05f28133bb9c00d6e Mon Sep 17 00:00:00 2001 From: abp Date: Tue, 11 Nov 2025 14:18:50 +0100 Subject: [PATCH] add support for Q-encoded list unsubscribe headers We now successfully parse MIME Q-encoded list unsubscribe headers and can unsubscribe from such newsletters. --- src/mail-app/mail/view/MailViewerViewModel.ts | 34 ++++++++++++------- .../mail/view/MailViewerViewModelTest.ts | 12 +++++++ 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/mail-app/mail/view/MailViewerViewModel.ts b/src/mail-app/mail/view/MailViewerViewModel.ts index 991145f410..bd5e561c80 100644 --- a/src/mail-app/mail/view/MailViewerViewModel.ts +++ b/src/mail-app/mail/view/MailViewerViewModel.ts @@ -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> { const mailHeaders = await this.getHeaders() const unsubscribeActions: Array = [] @@ -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(":") diff --git a/test/tests/mail/view/MailViewerViewModelTest.ts b/test/tests/mail/view/MailViewerViewModelTest.ts index 5b75061ef0..ecdf47d3bd 100644 --- a/test/tests/mail/view/MailViewerViewModelTest.ts +++ b/test/tests/mail/view/MailViewerViewModelTest.ts @@ -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)