fix: keep anchor hrefs when stripping HTML and accept bare text/html fragments

Forwarded mails (e.g. Yandex webmail) arrive as a single text/html part
with no <html>/<body> 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 &amp; decoded.
- extractMailBodies only recognised single-part HTML wrapped in
  <html>/<body>, 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.
This commit is contained in:
TooonyChen committed 2026-07-09 15:43:18 +12:00
1 parent 5508c14663
commit b6a1ad7d9d
2 files changed
+55 -7

No files matched your search

+26 -7
View File
@@ -100,12 +100,25 @@ export function decodeMimeHeader(value: string | null): string | null {
}
export function stripHtmlTags(text: string): string {
return text
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
return (
text
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<script[\s\S]*?<\/script>/gi, "")
// 保留链接: <a href="url">text</a> → "text [url]"。
// 验证码邮件常常只有一个按钮链接 (如 Netflix 的 Get Code), 剥掉 href 会让
// LLM 无码可提取。仅 html-only 邮件走到这里, 多部分邮件优先用 text/plain。
.replace(
/<a\b[^>]*?href\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>([\s\S]*?)<\/a>/gi,
(_match, doubleQuoted, singleQuoted, inner) => {
const url = (doubleQuoted ?? singleQuoted ?? "").replace(/&amp;/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(/<html[\s\S]*<\/html>/i) ?? decodedBody.match(/<body[\s\S]*<\/body>/i);
const htmlBody = htmlMatch ? htmlMatch[0].trim() : null;
// Content-Type 明确是 text/html 时整个正文就是 HTML, 即使没有 <html>/<body>
// 包裹 (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;
+29
View File
@@ -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 = '<p>Hi Thomas</p><a class="h5" href="https://www.netflix.com/verify?a=1&amp;b=2" style="x">Get Code</a>';
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('<a href="mailto:x@y.com">contact us</a>')).toBe("contact us");
});
});
describe("extractMailBodies single-part html", () => {
it("treats a bare text/html fragment (no <html> wrapper) as the html body", () => {
const rawEmail = [
"Subject: code",
"Content-Type: text/html; charset=utf-8",
"Content-Transfer-Encoding: base64",
"",
Buffer.from('<div>Your code: <a href="https://x.com/v?t=1">Get Code</a></div>').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]");
});
});