From b6a1ad7d9d44a12ebedec4b8bf44b1c19a1f9d3b Mon Sep 17 00:00:00 2001 From: TooonyChen <26252739+TooonyChen@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:43:18 +1200 Subject: [PATCH] fix: keep anchor hrefs when stripping HTML and accept bare text/html fragments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forwarded mails (e.g. Yandex webmail) arrive as a single text/html part with no / wrapper and no text/plain alternative. Two bugs made their codes unextractable: - stripHtmlTags dropped href attributes, so button-only mails (Netflix 'Get Code') reached the LLM with no URL at all → codeExist=0. Anchors now render as 'label [url]' with & decoded. - extractMailBodies only recognised single-part HTML wrapped in /, so htmlBody was null and the Rendered preview was empty. A bare fragment with Content-Type text/html now counts as the html body. Verified against the real forwarded Netflix mail from production raw_mails: content now carries the travel/verify link and classifies 5/5 as login_code. --- src/services/mime.ts | 33 ++++++++++++++++++++++++++------- test/mime.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/services/mime.ts b/src/services/mime.ts index 8ef3437..8499903 100644 --- a/src/services/mime.ts +++ b/src/services/mime.ts @@ -100,12 +100,25 @@ export function decodeMimeHeader(value: string | null): string | null { } export function stripHtmlTags(text: string): string { - return text - .replace(//gi, "") - .replace(//gi, "") - .replace(/<[^>]+>/g, " ") - .replace(/\s+/g, " ") - .trim(); + return ( + text + .replace(//gi, "") + .replace(//gi, "") + // 保留链接: text → "text [url]"。 + // 验证码邮件常常只有一个按钮链接 (如 Netflix 的 Get Code), 剥掉 href 会让 + // LLM 无码可提取。仅 html-only 邮件走到这里, 多部分邮件优先用 text/plain。 + .replace( + /]*?href\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>([\s\S]*?)<\/a>/gi, + (_match, doubleQuoted, singleQuoted, inner) => { + const url = (doubleQuoted ?? singleQuoted ?? "").replace(/&/gi, "&").trim(); + const label = inner.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); + return /^https?:\/\//i.test(url) ? ` ${label} [${url}] ` : ` ${label} `; + }, + ) + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim() + ); } // 主题看起来像验证码/事务性邮件 (verification, OTP, sign-in, reset, 验证码…)。 @@ -199,7 +212,13 @@ export function extractMailBodies(rawEmail: string): { const decodedBody = decodeBodyPart(topBody, encoding, getCharset(topContentType)); const topMimeType = topContentType?.split(";")[0]?.trim().toLowerCase() ?? ""; const htmlMatch = decodedBody.match(//i) ?? decodedBody.match(//i); - const htmlBody = htmlMatch ? htmlMatch[0].trim() : null; + // Content-Type 明确是 text/html 时整个正文就是 HTML, 即使没有 / + // 包裹 (Yandex 等客户端转发出的就是裸片段), 否则 Rendered 预览会拿不到内容。 + const htmlBody = htmlMatch + ? htmlMatch[0].trim() + : topMimeType.includes("text/html") && decodedBody.trim() + ? decodedBody.trim() + : null; const decodedText = topMimeType.includes("text/html") ? stripHtmlTags(decodedBody) : decodedBody.trim(); const textBody = decodedText ? decodedText : htmlBody ? stripHtmlTags(htmlBody) : null; diff --git a/test/mime.test.ts b/test/mime.test.ts index 53d1a91..93214e6 100644 --- a/test/mime.test.ts +++ b/test/mime.test.ts @@ -4,6 +4,7 @@ import { decodeQuotedPrintable, extractMailBodies, isPromotionalEmail, + stripHtmlTags, } from "../src/services/mime"; describe("mime decoding", () => { @@ -92,3 +93,31 @@ describe("isPromotionalEmail", () => { expect(isPromotionalEmail(headers, "Subject: Hello\r\n\r\nbody")).toBe(false); }); }); + +describe("stripHtmlTags", () => { + it("preserves anchor hrefs so button-only mails keep their link", () => { + const html = '

Hi Thomas

Get Code'; + expect(stripHtmlTags(html)).toContain("Get Code [https://www.netflix.com/verify?a=1&b=2]"); + }); + + it("drops non-http hrefs but keeps the label", () => { + expect(stripHtmlTags('contact us')).toBe("contact us"); + }); +}); + +describe("extractMailBodies single-part html", () => { + it("treats a bare text/html fragment (no wrapper) as the html body", () => { + const rawEmail = [ + "Subject: code", + "Content-Type: text/html; charset=utf-8", + "Content-Transfer-Encoding: base64", + "", + Buffer.from('
Your code: Get Code
').toString("base64"), + "", + ].join("\r\n"); + + const bodies = extractMailBodies(rawEmail); + expect(bodies.htmlBody).toContain("https://x.com/v?t=1"); + expect(bodies.textBody).toContain("Get Code [https://x.com/v?t=1]"); + }); +});