那封一直搜不到的邮件,终于出现在结果里了。打开一看,行距却变大了,原来紧凑的段落之间多出不少空白。
邮件里存着技术往来、现场截图和处理记录。搜索恢复了,原来的排版也得留下。
后来保留下来的是整封邮件的头部、普通样式和正文,处理范围缩到了两处:Word 的特定 XML 条件块,以及样式中的 behavior:url(#default#VML) 声明。 那封样本从 64,706 个字符变成 64,301 个字符,减少 405 个字符,进入了索引,之前的行距问题也在复查中消失了。
这篇就从这 405 个字符说起:改了哪里、哪些地方必须保留,以及怎样拿一封自己的邮件做对照。完整代码、批次数字和迁移细节放在文末备忘,方便以后查。
实验发生在 2026 年 11 月 3 日至 8 日,环境是 Windows 经典版 Outlook + 本地 PST。业务名称、人员、单号及私人路径已匿名化。这是一组样本的兼容处理记录,适用效果需要在自己的邮件上验证。
可以直接跳到XML/VML 的具体改动、单封复现流程或最终的日常搜索设置。
先确认:邮件在,搜索结果里没有
最初是搜一个工单号。网页版能找到一串来往邮件,本地只找到几封。退出搜索,按日期翻到 10 月 30 日,漏掉的那封就在列表里,标题也包含那个单号。
在线邮箱只有约 1 GB,需要定期清理;长期资料靠规则复制到本地。主 PST 当时约 24.6 GB,收件箱约 2.45 万封。重建索引、调整范围、高级查找、分文件夹、拆成独立 PST,都试过,那封邮件还是没有稳定地回来。
换成 Outlook 对象模型,从实际文件夹按日期枚举邮件、直接比较 Subject,却能列出同一工单的 14 封往来。随后与 SystemIndex 对账,也发现了缺口。
这个核验要绕开全文索引查询:直接读对象属性,在程序里比较字符串。否则换了 API,可能仍在问同一个索引。确认邮件实体存在后,我留下原件,只拿副本做实验。
改成纯文本能搜到了,代价是原来的格式
同一封邮件做了几种副本:
| 副本处理 | 当次观察 |
|---|---|
| 原样复制,保留原 HTML | 观察窗口内未查到目标索引记录 |
| 保留 HTML,移除附件 | 仍未查到 |
| 改为纯文本 | 约 3.6 秒后查到 |
| 重写为较简单的 HTML | 约 4 秒后查到 |
附件移除后没有改善,改正文表示却有变化,于是继续拆细:只删 XML、删条件注释、重新包装 <head>、仅保留 <body>。
只留正文的副本能入库,但行距变了。只删 XML 的方案,在两次短时观察里结果也不一致。Windows Search 异步处理,表中的几秒只是记录,后续复现还需要留足观察时间和不改正文的对照件。
行距为什么变了
一封 Word 生成的邮件,正文可能是:
1
2
<p class="MsoNormal">第一段技术说明</p>
<p class="MsoNormal">第二段技术说明</p>
让这两段保持原来间距的样式,却在 <head> 中:
1
2
3
4
p.MsoNormal {
margin: 0cm;
margin-bottom: .0001pt;
}
把头部整段扔掉,正文两个 <p> 一个没少,显示照样会变。字体、表格边框、单元格间距、签名样式也是同样的道理。有的测试件还出现了“在 Web 浏览器中查看”的提示。
所以我把原来的 <head>、普通样式、生成器信息和正文结构都留了下来,再从里面挑出候选片段。
Outlook 使用 Word 处理邮件 HTML;微软记录过编辑、发送时修改 HTMLBody 导致格式变化的情况。这里同样需要在保存后检查实际显示。微软说明:编辑 HTMLBody 时的格式丢失。
核心改动:保留原来的 head,只处理选中的 XML 块和 VML 声明
把范围写成一张表,就很清楚了:
| 位置 | 处理对象 | 留下什么 |
|---|---|---|
| Word 条件注释 | 完整识别选定的 XML 数据块及其条件外壳 | 非 XML 的条件内容,例如表格 |
<style> |
已确认的 behavior:url(#default#VML) 声明 |
同一块里的其他 CSS、注释和字符串 |
| 其余邮件内容 | 原样保留 | 头部、正文、表格、图片引用、附件和主题 |
下面两段代码,就是这次处理要找的位置。
第一类:条件注释包着的 Word XML 数据块
样本中有这样的内容,以下保留技术结构,省去业务正文:
1
2
3
4
5
6
7
8
9
<!--[if gte mso 9]><xml>
<o:shapedefaults v:ext="edit" spidmax="1026" />
</xml><![endif]-->
<!--[if gte mso 9]><xml>
<o:shapelayout v:ext="edit">
<o:idmap v:ext="edit" data="1" />
</o:shapelayout>
</xml><![endif]-->
可以从外往里读:
| 部分 | 这里表达的内容 |
|---|---|
if gte mso 9 |
面向特定 Office 版本的条件分支;它本身不是错误标记 |
<xml>…</xml> |
分支中的 XML 数据块 |
o:shapedefaults |
Office 图形的默认设置信息 |
o:shapelayout、o:idmap |
图形布局及标识映射信息 |
Office 会按条件处理这些内容。外面包着注释,并不表示它对邮件显示一定没有作用。
在这批样本的兼容实验中,处理对象收敛到了这些 XML 块。操作单位是完整的条件块,连同对应的结束标记一起处理,避免只删一半留下残片。
要特别分清另一个长得很像的例子:
1
2
3
<!--[if gte mso 9]>
<table><tr><td>给 Outlook 准备的排版内容</td></tr></table>
<![endif]-->
这个分支要保留:它装着实际排版表格。匹配规则需要看清条件块内部,不能只查到 mso 9 就整段删除。
第二类:样式中的 VML 行为声明
另一处候选是:
1
2
3
<!--[if !mso]><style>
v\:* { behavior:url(#default#VML); display:inline-block; }
</style><![endif]-->
VML 是微软早期的矢量图形格式,behavior:url(#default#VML) 用于关联它的行为。微软的 VML 文档就使用了这类样式写法。VML 结构与行为声明。
处理这个声明时,同一 <style> 中的其他属性要留下。否则又会回到行距走样的问题:
1
2
3
4
5
<style>
-v\:* { behavior:url(#default#VML); display:inline-block; }
+v\:* { display:inline-block; }
p.MsoNormal { margin:0cm; margin-bottom:.0001pt; }
</style>
<v:shape>、VML 背景和对应的图片回退先保留。遇到实际依赖 VML 显示图形的邮件,需要单独做显示对照,不能直接套用这条处理。
少了 405 个字符之后
最终重做 20 封样本时,采用的方向是:保留原来的头部、普通 CSS、正文、表格和图片引用,处理选定的 XML 与行为声明,放弃整封 HTML 重建。
两封样本的记录很直观:
| 样本 | 原 HTML 字符数 | 处理后字符数 | 减少 |
|---|---|---|---|
| 最初遗漏的技术往来邮件 | 64,706 | 64,301 | 405 |
| 另一封图文周报 | 59,363 | 59,145 | 218 |
这里是程序记录的字符串长度,不是 PST 占用空间,也不是附件减少了多少字节。
rerepair_20.log 记录了随后 20/20 样本在 SystemIndex 中查到记录。复查阅读窗格后,没有再看到之前的行距差异,才继续扩大处理。
其中有 4 封 HTML 没有变化,也进入了索引。早期检查过正文属性、结尾 NUL、重复保存和变更通知,后面也确实遇到了重新保存就补录的邮件。因此,这组结果验证了样本处理流程,还没有把 XML、VML 和保存通知各自的影响完全分离。
为了便于复用,本文示例又收窄了一次规则。三个阶段要分开:
| 代码阶段 | 实际范围 |
|---|---|
| 得到上述样本结果的实验脚本 | 仍包含独立 XML、整段 VML 兼容样式、其他 behavior:url(...) 的处理;无样式时还会尝试补段落样式 |
| 后来的批量原型 | 扩大到全部匹配的 gte mso 9、!mso 条件块,产生了新的误报问题 |
| 本文离线示例 | 只处理下面列明的两种 XML 结构,以及样式中的特定 VML 声明;其他内容保留 |
405 是历史样本的改动量,不是下面示例应当删掉的固定字符数。
用一封自己的邮件复现
整条操作路径是:保存原件 → 建对照件和实验件 → 导出 HTMLBody → 离线预览 → 只写回实验件 → 对比显示与搜索。
需要 Windows 经典版 Outlook、Windows PowerShell 5.1(powershell.exe)和 Python 3。示例只用标准库,不需要安装 Python 包。三个脚本的完整代码放在文末复现代码。
1. 准备两个副本,取得原始 HTML
先将目标邮件另存为 Unicode MSG 留底。在 Outlook 中新建测试 PST,例如 C:\MailTest\RepairTest.pst,在其中建立 HTML修复对照 和 HTML修复实验 两个文件夹,各复制一封原邮件。主题保持不变,用文件夹区分。测试库先排除自动清洗和移动规则,避免其他操作同时改动副本。
复制完成后,先用相同条件检查两份副本。若原样副本已经能搜到,就记录“复制后恢复”;后续再修改 HTML,无法单凭成功结果判断是哪项改动起了作用。
选中对照件,运行导出脚本;再选中实验件,换一个目录运行一次:
1
2
powershell.exe -STA -File .\Export-SelectedMailHtml.ps1 -OutDir .\control
powershell.exe -STA -File .\Export-SelectedMailHtml.ps1 -OutDir .\experiment
两条命令要分开运行,中间先在 Outlook 切换选中的邮件。每次会生成 original.html、original.msg 和包含 StoreID + EntryID 的 target.json。
这里直接读取 MailItem.HTMLBody。Outlook 的“另存为 HTML”可能重新生成结构,不适合充当这次逐字符比较的输入。导出脚本要求选中一封本地 PST 中的普通 HTML 邮件,已有输出目录会拒绝覆盖。
2. 预览具体删了什么
对实验件运行:
1
python preview_word_html.py experiment/original.html experiment/preview.html
脚本输出实际替换数和 diff。如果把前文两块 XML、一条 VML 声明放进同一份合成 HTML,预期是:
1
{'xml_blocks': 2, 'vml_declarations': 1} would_change = True
真实邮件的数量可以不同。would_change = False 表示这份输入不在示例的处理范围内,脚本不会写预览文件。
这版代码保留非 XML 条件内容、!mso 分支的其他部分和 <v:shape>,不把 <xml> 解码成标签;处理 VML 时跳过 CSS 注释和字符串。它只覆盖列明的生成格式,不解析嵌套条件。包含嵌套的样本先人工检查,不直接运行;其他 XML、内联样式和不同的 VML 写法另行处理。
审查 diff 时,确认删除集中在目标 XML 块和声明。p.MsoNormal、表格、普通 CSS、正文与 cid: 引用应留在原处。若出现整段正文或图片消失,先停在预览这一步。
3. 两份邮件分别保存,再比较
检查回写脚本中的 $testPst 是否指向自己新建的测试文件,然后运行:
1
2
powershell.exe -STA -File .\Test-MailHtml.ps1 -WorkDir .\control -Mode control
powershell.exe -STA -File .\Test-MailHtml.ps1 -WorkDir .\experiment -Mode experiment
对照件仅调用 Save();实验件读取 preview.html,赋给 HTMLBody 后保存。脚本按导出时的标识找邮件,同时检查测试 PST 路径、文件夹和原正文,防止写到另一个对象。两次都会将保存后读回的 HTML 留在对应目录。
HTMLBody 赋值还会立即更新 Body,所以要检查读回结果。MailItem.HTMLBody 属性说明。
| 验收项 | 看什么 |
|---|---|
| 实际保存内容 | 读回 HTML,检查是否发生额外结构变化 |
| Outlook 外观 | 阅读窗格和独立窗口中的行距、表格、图片、签名 |
| 附件与 CID | 引用对应的附件存在,数量和内容一致 |
| 邮件主索引记录 | 找到该项目本身,不能用附件条目代替 |
| 原来失败的查询 | 同一范围内,用原来的主题或正文条件再次搜索 |
| 会话关系 | “此对话中的邮件”能否仍找到相关往来 |
如果验证的是 Windows Search,实验全程保持该路径启用。关闭索引依赖后搜到了,验证的是另一种搜索方式。
历史里还做过两份 HTML 的 Edge 无头截图,前后 PNG 哈希相同。它只能补充说明那两次浏览器渲染没有差异;经典 Outlook 使用 Word,且导出的 CID 图片未必完整还原,外观验收仍要回到 Outlook 做。
批量处理:清单里有邮件,不等于每封都要改
20 封样本复查后,我把处理范围扩大。原样副本留在“待修复邮件”,处理结果进入“已修复邮件”,核对对应关系后再替换回原位置。
其中一轮实际处理 1,584 项:1,030 项发生处理,554 项原样放行。最终对应替换了 1,626 项,主收件箱项目数前后不变,备份集合也保留着。其他历史库和归档目录随后分别处理,详细数字留在批次记录。
这一步比清洗函数更容易出错的是“对应到哪封”。EntryID 跨 PST 移动后会变化,Message-ID 和 PR_SEARCH_KEY 又可能被多个归档副本共享。EntryID 的变化条件、MAPI 记录键与搜索键。
我需要留下原存储、原文件夹、原件与备份、处理后副本及目标位置之间的映射;定位至少包含 StoreID + EntryID,并用时间、主题和消息属性交叉核对。找出多个对应项时,先单独列出来。
HTML 备份只能恢复正文。附件、原位置和完整 MAPI 属性还要靠 MSG 或 PST 备份。替换工作流通过后,索引覆盖率仍然需要另查。
“索引完成”以后,仍然有邮件搜不到
批量替换后,一封 11 月 6 日收到的技术回复又搜不到了。我重新把收件箱实际项目与索引逐项对应。
一轮快照是 4,949 个实际项目、4,148 个对应的邮件主记录,仍有明显缺口。
重新保存后,一部分邮件补进了索引,目标技术回复也能搜到了。保存动作和索引时机同样会改变结果,因此不能把所有遗漏都归到 XML/VML。
11 月 8 日 05:31 的逐项核验又留下这组记录:
| 本轮主收件箱核验 | 数量 |
|---|---|
| 实际项目 | 4,948 |
| 在 SystemIndex 中精确匹配的项目主记录 | 4,842 |
| 未匹配项目 | 106 |
其中普通邮件 IPM.Note |
84 |
| 其中会议请求 | 19 |
| 其中会议取消通知 | 3 |
表中的分子、分母来自同一时刻、同一文件夹。22 个会议项目单独保留,继续核对类型、索引类别和搜索范围。
附件有记录,邮件主记录却没有
剩下 84 封普通邮件,平均约 2,810 KB,其中 74 封带附件,42 封超过 1 MB。大附件占比高,但清单中也有很小、没有附件的邮件。
其中 39 封找到了相关的附件索引条目,却仍没有精确匹配到邮件主记录。
例如,索引中可以存在概念上这样的两个层次:
1
2
邮件项目 URL
邮件项目 URL /at=附件标识
后一行存在,不等于前一行存在。把附件 URL 截掉 /at=…,再将所得前缀塞进“已索引邮件集合”,会凭空补出一封已经入库的父邮件。
落到实际使用中,这批主记录缺失的大附件邮件明明还在收件箱里,用标题里的文字却搜不到。附件条目已经入库,也补不上邮件本身的搜索结果。
这是让我放弃 Windows 索引的最后一根稻草。 到这一步,我仍没有找到能把这些主记录稳定补齐的办法。搜索慢一点还可以等,但明知道邮件在,却连标题都搜不到,我不能接受。XML/VML 修复解决了那批正文样本的问题,没能补上这个缺口,所以我最终决定让 Outlook 切回内置搜索。
至于这些主记录为什么迟迟缺失,是否涉及超时、具体阈值是多少,以及小邮件为何也会遗漏,这次没有查清。
对账时,固定同一 PST、文件夹、时间点和项目类型,枚举实际对象,再核对完整的主项目 URL。结果分成“精确匹配、未匹配、只有附件条目、特殊项目、查询错误”。查询异常记为未知,最后再用已知遗漏样本搜索。只读查询入口和映射注意事项放在文末。
会话查找是另外一层
那封技术回复恢复主题搜索后,右键查找同一对话却只能找到它自己;从其他相关邮件查找,又没有它。
排查涉及 Subject、规范化主题、ConversationTopic、ConversationIndex,以及消息头中的 References、In-Reply-To。中途试改过中英文混合的回复前缀,后来又按原件恢复。正文修复应把这些属性留在原处;若会话异常,就读取实际属性和原件对照,不与全文搜索混为一谈。
重复邮件也要看消息头、Message-ID、正文、附件和在线记录。相近时间的同标题邮件,既可能是对方补发,也可能是本地规则复制,不能仅凭标题去重。
工具化时,最需要防的是规则悄悄变宽
后来把样本处理整理成了增量插件和批量工具,新的问题出在匹配范围上。
为什么又报出 2,721 封
一次全库检查又报出 2,721 个候选。我先以为修复失效,查代码和扫描范围后才发现,判据已经变了。
早期先对账,找未匹配到索引的邮件,再检查它们的 HTML。后来的工具扩大到更多文件夹,也检查原先就能入库的邮件;同时,它的一个判断条件变成了:
1
html.IndexOf("mso 9", StringComparison.OrdinalIgnoreCase) >= 0
这个表达式只能证明字符串出现过。正常模板、正文里的技术说明、转义后的标签文本,都可能命中。
不同时间窗口、收件箱与全部文件夹、是否包含在线邮箱,还出现过 9、29、58、145 等结果,范围对照留在批次记录。后来固定为只查本地 PST 副本,在线企业邮箱不进入检查和修改。
注释说“只删 XML”,正则不一定真的这样做
再看历史包里的最终工具,清理规则实际包括:
1
2
3
删除所有匹配的 !mso 条件块
删除所有匹配的 gte mso 9 条件块
在整篇 HTML 中删除 VML behavior 字串
第二条没有要求里面必须是 XML,第一条也没有要求里面只有 VML 样式。这比“只针对选中的 XML 条件块和 VML 行为”宽得多。
注释中的“只删 XML”没有落实到匹配条件里。这里需要检查实际正则和 diff。
58 条成功记录,实际有 57 份 HTML 变化
拿当月 58 封备份,按原工具规则在内存中重放,结果是:
| 原备份中的特征 | 数量 | 原规则重放结果 |
|---|---|---|
| 同时含 VML 与目标 Word XML | 45 | HTML 有变化 |
| 含目标 Word XML | 12 | HTML 有变化 |
| 只被宽松字符串检查命中,相关结构已转义 | 1 | HTML 没有变化 |
最后那封里能看到转义文本,不等于存在同样的可执行 XML 结构。旧工具仍然保存、记了一条 SUCCESS,于是日志数量比实际改变的 HTML 多一份。
这组记录删掉的 114 个目标条件块,正好是 57 个 shapedefaults 和 57 个 shapelayout。本文示例选取这两类结构,就是依据这些样本。是否需要改某封邮件,还要看它本身的显示和检索问题。
工具报告至少应区分四个数字:扫描了多少、命中特征多少、正文实际改了多少、另行验证搜索恢复了多少。 如果只读扫描没有查询索引,最后一个数就应留空,而不是填成命中数。
留下两个工具入口:增量插件和按需批量检查
方案 A 监听本地目标文件夹的 Items.ItemAdd:在线邮箱收信,既有规则复制到本地后,才检查新副本。方案 B 处理存量和补查,按需运行,不开后台定时轮询。两者的操作范围和记录查询整理进独立 Skill。
1
2
3
4
5
规则复制到允许的本地 PST
↓ ItemAdd
检查类型、正文状态和是否实际有改动
↓
备份 → 保存 → 读回 → 记账
签名、加密、会议和撤回消息先排除;VPN 断开、正文不完整或 COM 访问失败时跳过,避免反复重试。存量扫描和未监听期间的邮件交给方案 B。
微软明确说明,大量项目同时加入时 ItemAdd 可能不触发;新建目录也需要核对监听。ItemAdd 的限制。原型只挂了根目录下第一层文件夹,保留的 567 条处理日志又全部来自 BATCH_TOOL,所以当时能确认插件启动和挂钩,但还没有一份真实新信从触发、处理到保存的完整日志。
插件乱码、勾选后又掉、接口和运行状态的排查放在插件备忘。方案 A 的原型同步执行,方案 B 也依赖 Outlook COM,大量处理仍可能让客户端等待。
每次写回都要能找到原件
原型里还发现过备份失败仍写回、同月覆盖旧备份、恢复定位不足的问题。后续整理时补上了这些约束:
- 原文、定位记录和预写日志都成功落盘,再允许回写;备份失败就停止该项。
- 每次操作使用独立编号,不覆盖更早的原文。
- 保存
StoreID、EntryID、原文哈希和保存后读回的哈希。 - 回滚前检查当前正文是否又被修改;邮件已迁移或匹配不唯一时停止自动恢复。
- 检测与清理共用同一套边界,只有
WouldChange为真才进入修改。
后续整理的源码放在 outlook-mail-cleaner。它将一般 MSO 标记与可处理候选分开,保留非 XML 条件内容和图片回退,默认只读检查,自动处理需指定允许的本地 PST。
公开整理版已做离线验证,目标机器上的 COM 加载、显示和索引效果仍需实测。使用时记录版本,按前面的单封流程先验证。
日常最后采用的方案:分卷,切回内置搜索
因为始终没能解决上述邮件按标题漏搜的问题,我决定放弃 Windows 索引,接受内置搜索较慢的速度。
前面的分卷这时派上了用场。仅在同一 PST 中分文件夹,物理上仍是原来的大文件;后来拆成 2020及以前.pst、2021-2024.pst、2025-现在.pst,近期收件箱降到约五千项。迁移中的日期、规则、分类、S/MIME 和存储路径问题,另留在迁移备忘。
11 月 8 日早上,我把 PreventIndexingOutlook 设为 1,切回内置搜索。近期库里的等待时间可以接受,日常使用就先这样稳定下来。
微软文档说明了这个策略的作用:禁用 Outlook 的 Windows Desktop Search 依赖后,改用内置搜索。该文档涉及另一次已修复的故障,这里只引用开关用法。微软的搜索替代方案说明。
操作前先记录这个值原本是否存在、数值是多少。管理员终端中设置:
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v PreventIndexingOutlook /t REG_DWORD /d 1 /f
然后重启 Outlook。搜索时可能看到组策略关闭 Windows Search、性能受影响的提示。这里调整的是 Outlook 相关策略,不是停止整台电脑的 Windows Search 服务。
需要恢复时,恢复操作前的状态。如果原来没有这个值,可以删除自己新增的这一项:
reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v PreventIndexingOutlook /f
如果原来已有值,就恢复原值;企业策略统一管理的环境应以组织配置为准。
约五千封时的速度是我这台机器的体验。主题、正文和附件相关查询仍要分别实测,搜索范围和下载完整性也要核对。
高级查找在这次实验中也漏过同一封目标邮件,查询中还见到过 ci_phrasematch 这类内容索引运算符。是否绕过索引,要以所用条件和环境的实际结果判断。
那封邮件最后少了 405 个字符,原来的正文和排版留住了。下次遇到同类问题,我会先保存样本、比较改动,再逐项检查显示和搜索结果。有了这份记录,就不必从头猜起。
附一:单封复现代码
将以下三个代码块分别保存为对应文件名。它们用于测试 PST 中的单封实验,不遍历邮箱。PowerShell 脚本保存为 UTF-8 with BOM,用与 Outlook 相同的普通用户运行 Windows PowerShell 5.1;Python 脚本保存为 UTF-8 即可。
Export-SelectedMailHtml.ps1:导出当前选中的单封邮件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
param([string]$OutDir = '.\experiment')
$ErrorActionPreference = 'Stop'
$app = [Runtime.InteropServices.Marshal]::GetActiveObject('Outlook.Application')
$selection = $app.ActiveExplorer().Selection
if ($selection.Count -ne 1) { throw '请在 Outlook 中只选中一封测试邮件。' }
$mail = $selection.Item(1)
$folder = $mail.Parent
$store = $folder.Store
if (-not $store.IsDataFileStore -or $store.ExchangeStoreType -ne 3 -or
[IO.Path]::GetExtension($store.FilePath) -ine '.pst' -or
$mail.MessageClass -ne 'IPM.Note' -or $mail.BodyFormat -ne 2) {
throw '仅接受本地非 Exchange PST 中的普通 HTML 邮件。'
}
$dir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutDir)
if (Test-Path -LiteralPath $dir) { throw '输出目录已存在,请换一个新目录。' }
$null = New-Item -ItemType Directory -Path $dir
$utf8 = [Text.UTF8Encoding]::new($false)
$mail.SaveAs((Join-Path $dir 'original.msg'), 9) # olMSGUnicode
[IO.File]::WriteAllText((Join-Path $dir 'original.html'), $mail.HTMLBody, $utf8)
$meta = [ordered]@{
StoreID = $store.StoreID
EntryID = $mail.EntryID
PstPath = $store.FilePath
Folder = $folder.Name
}
[IO.File]::WriteAllText((Join-Path $dir 'target.json'),
($meta | ConvertTo-Json), $utf8)
Write-Output "已导出:$dir"
导出代码中的 SaveAs(..., 9) 使用 Unicode MSG 格式,枚举值见微软 OlSaveAsType 定义。
preview_word_html.py:只生成预览,不连接 Outlook
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
from pathlib import Path
import difflib
import re
import sys
FLAGS = re.IGNORECASE | re.DOTALL
# 只接受这里列明的两种 XML 内部结构。
SHAPE_DEFAULTS = r'<o:shapedefaults\b[^<>]*/>'
SHAPE_LAYOUT = (
r'<o:shapelayout\b[^<>]*>\s*'
r'<o:idmap\b[^<>]*/>\s*</o:shapelayout>'
)
WORD_XML = re.compile(
r'<!--\s*\[if\s+gte\s+mso\s+9\]>\s*<xml>\s*'
r'(?:' + SHAPE_DEFAULTS + r'|' + SHAPE_LAYOUT + r')'
r'\s*</xml>\s*<!\[endif\]\s*-->',
FLAGS,
)
# 只处理内容完整且仅含一个 style 的已知 !mso 外壳。
VML_STYLE_WRAPPER = re.compile(
r'<!--\s*\[if\s+!mso\]>\s*'
r'<style\b[^>]*>.*?</style\s*>\s*<!\[endif\]\s*-->',
FLAGS,
)
HTML_TOKEN = re.compile(
r'(?P<style><style\b[^>]*>.*?</style\s*>)'
r'|<script\b[^>]*>.*?</script\s*>'
r'|(?P<comment><!--.*?-->)',
FLAGS,
)
# 先完整匹配注释、引号字符串;只删除最后的命名组。
# 这样正文样式中的 content:"{behavior:...}" 会原样保留。
CSS_TOKEN = re.compile(
r'/\*.*?\*/'
r'|"(?:\\.|[^"\\])*"'
r"|'(?:\\.|[^'\\])*'"
r'|(?P<vml>(?<=[{;])\s*behavior\s*:\s*'
r'url\(\s*#default#VML\s*\)\s*;?)',
FLAGS,
)
def preview(original):
# 示例的处理上限;更大的输入交给人工检查。
if len(original) > 2_000_000:
raise ValueError('样本超过此示例的字符上限')
xml_count = 0
vml_count = 0
def edit_token(token):
nonlocal vml_count
if token.group('vml') is not None:
vml_count += 1
return ''
return token.group(0)
def edit_html(token):
nonlocal xml_count
text = token.group(0)
if token.group('comment') is not None and WORD_XML.fullmatch(text):
xml_count += 1
return ''
if token.group('style') is not None or VML_STYLE_WRAPPER.fullmatch(text):
return CSS_TOKEN.sub(edit_token, text)
return text
after = HTML_TOKEN.sub(edit_html, original)
return after, {'xml_blocks': xml_count, 'vml_declarations': vml_count}
if __name__ == '__main__':
if len(sys.argv) != 3:
raise SystemExit(
'用法: python preview_word_html.py original.html preview.html'
)
# 不猜编码,不改换行符;编码不符就停止。
original = Path(sys.argv[1]).read_bytes().decode('utf-8')
after, counts = preview(original)
print(counts, 'would_change =', after != original)
if after == original:
raise SystemExit('没有匹配到此示例会处理的结构,未写文件。')
# x 模式拒绝覆盖已有文件,包括原输入文件。
with Path(sys.argv[2]).open('x', encoding='utf-8', newline='') as out:
out.write(after)
print('\n'.join(difflib.unified_diff(
original.splitlines(), after.splitlines(),
fromfile='original', tofile='preview', lineterm='',
)))
这段正则只覆盖已列明的生成格式,不是完整的 HTML/CSS 解析器。HTML 层区分样式、脚本和注释;只对完整匹配的 XML 条件块、普通样式块,以及仅包着样式的已知 !mso 外壳进入处理。处理 VML 时,CSS 注释和单双引号字符串先整体匹配、原样返回。嵌套条件或其他未覆盖格式,先人工检查。
Test-MailHtml.ps1:在指定测试 PST 中保存对照件或实验件
先将 $testPst 改为测试 PST 的实际绝对路径。脚本要求邮件位于根目录下的 HTML修复对照 或 HTML修复实验 文件夹;路径、位置或内容对不上就停止。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
param(
[Parameter(Mandatory=$true)][string]$WorkDir,
[ValidateSet('control','experiment')][string]$Mode = 'experiment'
)
$ErrorActionPreference = 'Stop'
$testPst = [IO.Path]::GetFullPath('C:\MailTest\RepairTest.pst')
$dir = (Resolve-Path -LiteralPath $WorkDir).Path
$meta = Get-Content -LiteralPath (Join-Path $dir 'target.json') -Raw -Encoding UTF8 |
ConvertFrom-Json
$original = [IO.File]::ReadAllText((Join-Path $dir 'original.html'))
if (-not (Test-Path -LiteralPath (Join-Path $dir 'original.msg'))) {
throw '缺少导出时的完整 MSG 备份。'
}
$afterPath = Join-Path $dir 'after-saved.html'
if (Test-Path -LiteralPath $afterPath) { throw '本目录已有保存结果,请勿重复执行。' }
$app = [Runtime.InteropServices.Marshal]::GetActiveObject('Outlook.Application')
$mail = $app.Session.GetItemFromID($meta.EntryID, $meta.StoreID)
$folder = $mail.Parent
$store = $folder.Store
$expectedFolder = if ($Mode -eq 'control') { 'HTML修复对照' } else { 'HTML修复实验' }
if (-not $store.IsDataFileStore -or $store.ExchangeStoreType -ne 3 -or
[IO.Path]::GetExtension($store.FilePath) -ine '.pst' -or
-not [string]::Equals([IO.Path]::GetFullPath($store.FilePath), $testPst,
[StringComparison]::OrdinalIgnoreCase) -or
$folder.Name -cne $expectedFolder -or
$folder.Parent.EntryID -ne $store.GetRootFolder().EntryID -or
$mail.MessageClass -ne 'IPM.Note' -or $mail.BodyFormat -ne 2) {
throw '邮件不在指定测试 PST 的目标文件夹,或不属于普通 HTML 邮件。'
}
if ($mail.HTMLBody -cne $original) { throw '正文与导出时不同,请重新导出、预览。' }
if ($Mode -eq 'experiment') {
$preview = [IO.File]::ReadAllText((Join-Path $dir 'preview.html'))
if ($preview -ceq $original) { throw '预览没有改动。' }
$mail.HTMLBody = $preview
}
$mail.Save()
[IO.File]::WriteAllText($afterPath, $mail.HTMLBody, [Text.UTF8Encoding]::new($false))
Write-Output "已保存并读回:$afterPath;显示与搜索效果仍需另行检查。"
如果回写或读回报错,先保留现场,检查测试副本与 MSG 备份;报错并不保证保存尚未发生。对照结束后,按原来的查询条件、同一搜索范围比较两份副本,记录观察时间。
附二:批次数字,按当时范围留档
这些记录来自不同快照和清单,存在时间变化、重复归档和范围交集,不能相加为“修好多少封坏邮件”。
| 阶段或范围 | 记录 | 备注 |
|---|---|---|
| 首轮主收件箱对账 | 实项 4,946,匹配 3,324,差额 1,622 | 后续待处理集合成为 1,626,不倒填此轮算式 |
| 20 封之后的继续批处理 | 计划 1,606,启动时实际剩余 1,584;1,030 处理、554 放行、该轮报告 0 失败 | 已处理及已有副本影响了待办集合 |
| 替换前的副本核对 | 一度 1,627 对 1,626,清掉多出的测试项 | 数量一致后再继续 |
| 主收件箱对应替换 | 1,626 项;收件箱前后均 4,951;待修复备份 1,626,已修复目录清空 | 验证替换流程,索引另验 |
| 主力库备份归档 | 437 个未匹配项,其中 169 命中 HTML 特征,430 找到主收件箱对应副本 | 169 是子集;该轮部分分类汇总未闭合,未声称 437 全部同法成功 |
| 2021—2024 历史库 | 一轮 311 个待处理项 | 候选清单 |
| 2020 及以前历史库 | 一轮 56 个待处理项 | 候选清单 |
| 后续主收件箱快照 | 实项 4,949,主记录匹配 4,148 | 不是最终快照 |
| 11 月 8 日 05:31 主收件箱 | 实项 4,948,主记录匹配 4,842;剩余 106 | 84 普通邮件、19 会议请求、3 会议取消;先前口头报告中的 4,865 不适用于这份原始记录 |
后续体检还有一组范围变化:
| 检查范围 | 扫描项目 | 旧规则命中 |
|---|---|---|
| 主力库收件箱,近期窗口 | 59 | 9 |
| 主力库收件箱,当月 | 121 | 29 |
| 主力库全部当月文件夹 | 215 | 58 |
| 包含在线邮箱的更大范围 | 不与前列直接比较 | 145,其中本地 58、在线 87 |
另有一份抽查报告把查询列错误、E_FAIL 异常写成“仍在索引中”,末尾又写“全部核验完成”。这些项只能记为查询未知,不能放进已通过的分子。
附三:只读查询索引与系统层线索
Windows Search 用带协议的 URL 表示项目;Outlook 条目常见 mapi16://。URL 还涉及用户和存储身份,不是磁盘文件名的简单拼接。Windows Search 团队对项目 URL 的说明。
下面是一个只读的入口,用来先看看当前用户实际有哪些 Outlook 索引 URL。请在运行 Outlook 的同一用户下使用,输出可能包含私人邮件标题,不要原样贴到公开问题单。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$conn = New-Object -ComObject ADODB.Connection
$rs = $null
try {
$conn.Open("Provider=Search.CollatorDSO;Extended Properties='Application=Windows';")
$sql = @"
SELECT TOP 20 System.ItemUrl, System.ItemName
FROM SystemIndex
WHERE System.ItemUrl LIKE 'mapi16:%'
"@
$rs = $conn.Execute($sql)
while (-not $rs.EOF) {
[pscustomobject]@{
Url = $rs.Fields.Item('System.ItemUrl').Value
Name = $rs.Fields.Item('System.ItemName').Value
}
$rs.MoveNext()
}
}
finally {
if ($null -ne $rs) {
if ($rs.State -ne 0) { $rs.Close() }
[void][Runtime.InteropServices.Marshal]::ReleaseComObject($rs)
}
if ($conn.State -ne 0) { $conn.Close() }
[void][Runtime.InteropServices.Marshal]::ReleaseComObject($conn)
}
它只是观察入口,不是覆盖率脚本。不同环境要先确认实际协议、可查询字段、索引是否启用,以及当前存储前缀。没有查到 mapi16:,不等于邮件已丢失。Windows Search SQL 语法。
真正的覆盖率,需要固定同一 PST、同一文件夹、同一时间点和项目类型,枚举实际对象,再逐一核对对应的完整主项目 URL。先用几封已知邮件验证映射;迁移或重新挂载后重新识别存储身份,不照搬旧 SID、旧前缀或实验中硬编码的 URL 转换。
记录中至少分出:精确匹配、未匹配、只有附件条目、特殊项目、查询错误。最后再拿已知遗漏样本做实际搜索。
系统日志出现过事件 ID 10024,提示筛选进程无响应并被终止;也检查过组件、进程和二进制字符串。现有记录缺少稳定的单变量复现与调用栈,尚不能定位到某个 Windows 版本的 AI 语义模块,更不能证明每个遗漏项都有相同的失败原因。
附四:PST 迁移备忘
迁移时,新旧 PST 同时挂载,核对完成后再调整规则与日常使用位置。以下问题都实际碰到过。
| 问题 | 当时怎么暴露 | 下次要落实的检查 |
|---|---|---|
| 已发送邮件漏迁 | 只用收件日期筛选,覆盖不了发送目录的全部情况 | 收件箱与已发送分别定义日期口径,检查异常日期和边界时刻 |
| 文件夹架构不完整 | 新库邮件有了,某些自建归档目录没跟上 | 迁移前后对比完整目录树及各目录项目数 |
| 规则仍指向旧库 | 新 PST 建好,复制动作还落在原来的位置 | 逐条核对规则目标,并用一封实际新邮件验证落点 |
| 分类名称、颜色不一致 | 邮件上的分类字段与新存储中的分类列表不完全对应 | 比较存储级分类清单和实际显示 |
| 公告规则误收业务回复 | 业务邮件引用了系统通知标题,同样命中主题条件 | 加入明确的系统发件人条件,核对复制和移动的区别 |
| 删除邮件后文件仍很大 | PST 仍占原来的磁盘空间 | 逻辑删除不等于物理压缩;整理完再考虑压缩或新库迁移 |
公告搬运还曾卡在约第 1,298 项附近。后续样本涉及 IPM.Note.SMIME.MultipartSigned 等签名邮件,以及撤回通知。对它们调用复制、移动或读取,可能进入不同的处理路径;当时怀疑过证书校验、网络等待和交互提示,原始记录不足以把每次停顿都归到同一个原因。
处理思路后来改为区分消息类型、缩小批次,并使用 Outlook 规则完成部分移动。先识别特殊项目,再选操作路径;保留证书校验设置。
还有一次,PST 改名、重新挂载后,Outlook 可以正常打开邮件,索引却一项也没有。排查碰到了路径中 MailBox 与 Mailbox 的大小写差异,也调整过挂载和搜索范围登记,之后索引恢复。
由于路径表示、重新挂载和范围登记同时变化,单独的大小写因素没有被隔离。再遇到新 PST 零入库,应一起检查实际路径、存储身份、搜索范围与通知状态。
附五:插件加载排查
原型先遇到加载项名称乱码,随后又出现勾选、确定后再次打开仍未勾选。
编码问题要分清 powershell.exe 与 pwsh.exe;安装了 PowerShell 7,不表示启动前者会自动使用后者。加载问题则要查接口、位数、依赖和注册。IDTExtensibility2 的接口 GUID 是 B65AD801-ABAF-11D0-BB8B-00A0C90F2744,与加载项自己的 CLSID 不是一回事,可以直接对照微软接口定义。
查看状态也要分开:注册存在、启动加载配置、运行时 COMAddIn.Connect、实际监听文件夹,以及有没有一封新副本走完整个处理链。前几项正确,不代表最后一项已经发生。