跳转至

title: "[ISSUE-0023] mailto 链接 URL 编码 body 污染邮箱字段" description: 邮箱正则把 mailto: 链接的 ?body=%xx 编码字符串当邮箱本地部分抓进来 tags: [issue, scraping, email, regex] created: 2026-05-26 updated: 2026-05-26 type: issue status: fixed severity: high fixed_version: v0.10.33

v0.10.51 Tool ③:列「修复后全仓应消失的旧模式」,commit hook 反查防漏修姊妹

audit_grep: - pattern: "\[a-zA-Z0-9\._%\+-\]\+@\[a-zA-Z0-9\.-\]\+\\\.\[a-zA-Z\]" description: "旧 EMAIL_REGEX 字符类含 % + 量词 + 无上限。修复后 src/ 应 0 命中。v0.10.47 才发现 storage-data.ts 漏修就是这种姊妹漂移。" related: - "解析层开关"


[ISSUE-0023] mailto 链接 URL 编码 body 污染邮箱字段

相关源码src/utils/scraper.ts(邮箱正则 + 提取逻辑)

用户感知的现象

截图展示邮箱列表中出现一条 168 字符的怪邮箱:

%20i%20encountered%20an%20error%20and%20need%20support.%0d%0a%0d%0a966df647f3badc9e28832fdc03580ecb%0d%0a%0d%0a%3a%0d%0adigitalcare@dollargeneral.com

URL 解码后:

 i encountered an error and need support.
966df647f3badc9e28832fdc03580ecb
:
digitalcare@dollargeneral.com

明显是 mailto: 链接的 ?body=... 参数被当成邮箱本地部分。

根因分析

源 HTML 大概率是这种"举报错误"模板按钮:

<a href="mailto:?subject=...&body=i encountered an error and need support.%0d%0a%0d%0a
   966df647...(错误 ID)%0d%0a%0d%0a:%0d%0adigitalcare@dollargeneral.com">举报错误</a>

注意 mailto 的 to 字段为空,邮箱地址塞在 body 末尾(用户写邮件时模板带的联系方式)。

三个叠加的设计缺陷(src/utils/scraper.ts:64):

const EMAIL_REGEX = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
//                              ^ 缺陷①    ^^ 缺陷②
# 缺陷 后果
字符类含 % URL 编码 %20 %0a %3a 全被当合法本地部分
量词 + 无上限 贪婪匹配到 64+ 字符也不停(RFC 5321 限 64)
不解 mailto: 直接对原始 HTML 跑正则,错过结构化机会

修复方案(三层防御)

Layer 1:收紧 EMAIL_REGEX

// 旧
const EMAIL_REGEX = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;

// 新
const EMAIL_REGEX = /[a-zA-Z0-9._+\-]{1,64}@[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?){1,5}/g;

变化: - 移除 % —— 邮箱实际用 % 概率 < 百万分之一,移除避免 URL 编码污染 - 加 {1,64} —— RFC 5321 local part 上限 - 域名分段限制 ≤ 63 字符(RFC 1035)

Layer 2:先解 mailto:(结构化提取)

新增 extractMailtoEmails(html):扫 href="mailto:...",只取 ? 之前的 to 字段, 支持多收件人(逗号分隔),URL decode 后用 STRICT_RE 验证整段。

在主提取流程之前调用,给结构化邮箱最高优先级。

Layer 3:脏邮箱兜底过滤

新增 isEmailLikelyBroken(email):硬性拒绝特征: - 本地部分 > 64 - 本地部分含 %xx URL 编码 - 含控制字符 / HTML 残留 `<>"'`` - 域名无点 / 连续点

加进 filterEmail() 入口;data-view 派生 emails 时也调用(运行时双保险)。

存量数据清洗

新建 src/utils/email-cleanup.tssrc/sections/data/email-cleanup-button.tsx: - 邮箱 tab 头部按钮"清理脏邮箱" - 扫全表 → 剔除符合 isEmailLikelyBroken 的 → 写回 IndexedDB - 给用户展示扫描结果 + 样例

改动文件

文件 改了什么
src/utils/scraper.ts EMAIL_REGEX 收紧 + extractMailtoEmails + isEmailLikelyBroken + 主流程接入
src/utils/email-cleanup.ts 🆕 存量清洗工具 cleanupBrokenEmails
src/sections/data/email-cleanup-button.tsx 🆕 邮箱 tab 头部清理按钮 + 结果对话框
src/sections/data/data-view.tsx 邮箱派生时调用 isEmailLikelyBroken;邮箱 tab 标题右侧加按钮

验证方式

  1. 进邮箱列表 → 168 字符脏邮箱不再显示(运行时过滤)
  2. 点"清理脏邮箱" → 对话框扫描 → 显示"剔除 N 个" + 样例
  3. 后续抓取含相同 mailto 模板的网站 → 不再写入脏数据
  4. 单元测试用例(待补):
  5. 输入:<a href="mailto:hi@x.com"> → 提取 hi@x.com
  6. 输入:<a href="mailto:?body=%20...@dollar.com"> → 提取空(因 STRICT_RE 拒)
  7. 输入:自由文本 Contact us at hello@acme.com. → 提取 hello@acme.com

如何避免再犯

  • 邮箱正则字符类不要含 % —— %xx URL 编码遍地,污染源远多于合法邮箱
  • 凡是有 + * 的字符类要加上限 —— {1,N} 而非 +,避免贪婪吃跨段
  • 结构化优先:HTML 里 mailto: tel: 链接是已知结构,先用 href 锚点抽取,再用正则扫散落文本
  • 写入前过滤 + 渲染时过滤:两道防线都要有,新规则上线时即时生效,存量数据靠清洗按钮

相关问题

  • ~~同类风险:手机正则也可能误捕 mailto 的 body= 数字(待排查)~~ 已排查(v0.10.37 后):3 个手机正则 TEL_LINK_REGEX / PHONE_CTX_REGEX / PHONE_TEXT_REGEX 字符类分别是 [\d\s\-().][\s.\-]都不含 %, 遇 URL 编码 %xx 立刻停。无 mailto 污染风险
  • v0.10.33 修复后,可能少抓几个含 % 的合法邮箱(概率极低,可接受)