OpenMed 邮件 PHI 脱敏实战:EML/MSG 解析、头部与正文清洗、附件安全重写的本地化实现
【免费下载链接】openmedLocal-first healthcare AI: clinical NER & HIPAA PII de-identification that runs 100% on-device. 2,200+ medical models, 21 languages, Apple MLX + Python, no cloud, no patient data leaving your network. Apache-2.0项目地址: https://gitcode.com/GitHub_Trending/ope/openmed
导读
本文讲解 OpenMed 多模态子系统中针对电子邮件(.eml与可选.msg)的 PHI 提取与脱敏能力:如何用纯 Python 标准库解析 RFC 5322/MIME 邮件、把解码后的头部与正文文本映射回源段(source spans),并输出一份从头部、纯文本、HTML、附件文件名到附件内容全部清洗干净的 EML。读完本文你将掌握extract_email/redact_email/redact_document的完整用法、底层脱敏管线(用户检测器 + 确定性安全扫尾)的运作方式,以及 Outlook MSG 隔离桥接的合规边界,可直接用于构建本地、无网络、不落盘临时文件的邮件 PHI 处理流水线。
功能定位与适用范围
OpenMed 可以解析 RFC 5322/MIME 格式的.eml消息,将解码后的头部与正文文本映射回源段,并产出一份干净的 EML——PHI 会从头部、纯文本、HTML、附件文件名以及受支持的附件内容中被移除。EML 解析仅依赖 Python 标准库,因此基础安装即可使用(requires_multimodal=False,见 email.py 的处理器注册);只有消息中可能携带 PDF、DOCX、PPTX 或位图附件时,才需要安装multimodal附加依赖。
从源码结构看,整个能力收敛在 email.py 一个模块中,对外暴露三个核心 API(模块导出列表):
| API | 作用 | 关键返回类型 |
|---|---|---|
extract_email(source) | 提取解码后的头部与正文,返回带字符偏移映射的归一化文档 | ExtractedDocument |
redact_email(source, output_path=..., models=..., lang=...) | 脱敏头部、正文与附件,序列化干净 EML | RedactedEmail |
redact_document(path, ...) | 通用文档分发器,识别.eml/.msg扩展名并路由到邮件处理器 | ExtractedDocument |
三者都接受路径、原始字节(bytes/bytearray)或带name属性的可寻址二进制流作为输入(_read_source_bytes),这与多模态子系统的通用摄取契约 ExtractedDocument 完全一致。
提取归一化文本:extract_email
基础用法
from openmed.multimodal import extract_email message = extract_email("synthetic-message.eml") print(len(message.text), message.metadata["body_part_count"]) for span in message.spans: print(span.start, span.end, span.metadata["block_type"])返回的归一化文档包括解码后的From、To、Cc、Bcc、Reply-To、Sender、Subject、路由/引用类头部以及Date头部,再加上text/plain正文和可见的text/html内容。HTML 段落的每个 span 都带有本地源码偏移(html_source_start/html_source_end),可用于将归一化文本中的任意字符位置映射回原始 HTML 字节位置。
实际提取的头部集合
源码中的_EXTRACTED_HEADERS常量(email.py)明确了提取范围,共 13 类头部:
From、To、Cc、Bcc、Reply-To、Sender、Subject、Received、Return-Path、Message-ID、In-Reply-To、References、Date
在归一化文档中,每个头部段落的metadata携带block_type: "header"、header_name(小写)和header_index(同名头部出现多次时的序号);正文段落则标记为block_type: "body",并记录part_index与content_type(_document_from_message)。
HTML 可见文本抽取的细节
script、style、template三类标签的内容被完全排除在可见文本之外(_HTML_IGNORED_TAGS,email.py);- 块级标签(
p、div、table、li、h1-h6等,共 37 个,email.py)之间插入换行分隔符,保证抽取出的文本段落结构可读; - 字符实体(
&name;/&#code;)被反转义为真实字符,同时记录原始字节范围,确保偏移映射不因实体编码而漂移(_HtmlTextParser)。
畸形头部的容错
extract_email对损坏的邮件头不会崩溃:测试test_malformed_address_header_is_extracted_and_redacted_without_crashing(tests/unit/multimodal/test_email.py)验证了From: Synthetic Clinic <clinic@这类残缺地址仍能被原样提取、参与脱敏,并在最后被替换为安全的占位地址。
输出脱敏 EML:redact_email
安装多模态附加依赖
当邮件可能携带 PDF、DOCX、PPTX 或栅格图片附件时,先安装多模态附加依赖:
uv pip install "openmed[multimodal]"该附加依赖在 pyproject.toml 中声明,包含pdfplumber、python-docx、python-pptx、Pillow、pikepdf、pydicom、pytesseract、easyocr、onnx等解析与 OCR 后端。
基础用法
from openmed import extract_pii from openmed.multimodal import redact_email result = redact_email( "synthetic-message.eml", output_path="synthetic-message.clean.eml", models={"detector": extract_pii}, lang="en", ) print(result.header_redaction_count) print(result.body_redaction_count) print([attachment.to_dict() for attachment in result.attachments])models参数接受 OpenMed PII 检测器/脱敏器对象,或一个映射,映射中可提供detector/extract_pii键,并可选用text_redactor键(_resolve_detector、_resolve_text_redactor)。lang为 OpenMed 语言代码,默认"en"。policy参数则接受策略名(字符串)或映射,映射中的method/deidentify_method与deidentify_policy会被解析(_redaction_policy)。
RedactedEmail返回对象的字段(email.py):
| 字段 | 含义 |
|---|---|
email_bytes | 序列化后的干净 EML 字节 |
document | 干净消息对应的归一化文档(ExtractedDocument) |
source_format | 输入格式,"eml"或"msg" |
header_redaction_count | 头部发生的脱敏/移除次数 |
body_redaction_count | 正文部分发生变化的次数 |
attachments | 附件处理证据元组(EmailAttachmentReport) |
output_path | 实际写出的输出路径(未指定则为None) |
用户检测器 + 确定性安全扫尾
关键设计:你提供的检测器之后总是跟着 OpenMed 的确定性安全扫尾(safety sweep)。即使自定义检测器漏检,结构化标识符(如 8 位病历号、电话号码)仍会被兜底命中。测试test_supplied_detector_still_gets_deterministic_safety_sweep(tests/unit/multimodal/test_email.py)验证:检测器返回空实体时,正文中的12345678与415-555-1212依然被替换为[MEDICAL_RECORD_NUMBER]和[PHONE_NUMBER]。
扫尾逻辑位于_swept_detector,最终调用openmed.core.safety_sweep.safety_sweep,对文本与实体做二次确定性检测。文本处理的核心入口是_TextProcessor.redact,其调用链按优先级为:
- 显式
text_redactor优先; - 否则用
detector检测实体并按 span 应用替换; - 否则回退到
openmed.core.pii.deidentify(此时policy/method/model_name生效); - 无论走哪条路,最终都会再跑一遍 safety sweep 作为残余结构化标识符兜底。
头部脱敏规则
头部处理由_redact_headers完成,规则可归纳为四类:
- 结构性头部原样保留:
content-type、content-transfer-encoding、content-disposition、content-id、content-location、mime-version(_STRUCTURAL_HEADERS),它们是 MIME 语义必需项; - 直接移除:认证类头部(
authentication-results、dkim-signature、domainkey-signature、received-spf)、日期类头部(date、delivery-date、orig-date、resent-date)、完整性类头部(content-length、content-md5)以及所有arc-前缀头部; - 仅保留白名单消息头部:
_PRESERVED_MESSAGE_HEADERS(_EXTRACTED_HEADERS中除date外的全部),其余任意自定义头部(如X-Patient-Note、X-Alice-Patient-ID)一律删除; - Message-ID 类哈希化:
message-id、in-reply-to、references中的<...>片段被替换为SHA-256 摘要前 16 位构成的伪 ID,例如<message-<digest>@openmed.invalid>(_redact_message_ids)。
地址类头部(bcc/cc/from/reply-to/sender/to)脱敏后若无法被标准库地址解析器接受(含畸形输入),一律替换为redacted-address@openmed.invalid,且任何赋值异常都会回退为安全占位值,绝不把不可解析或可能私密的原始值写回干净邮件(email.py)。
正文与 HTML 脱敏规则
正文处理由_redact_body_parts完成:
text/plain:直接走_TextProcessor.redact;text/html:走专用的_HtmlRedactor,核心规则是保留 HTML 标签结构,只对可见文本、注释和安全属性做脱敏:- 标签白名单
_HTML_SAFE_TAGS(约 70 个常见标签),白名单外的标签降级为span; - 属性白名单
_HTML_SAFE_ATTRIBUTES(href、src、class、id、alt、aria-*等 19 项),所有on*事件属性与srcdoc、style属性被丢弃; - 可见文本的脱敏编辑会反向映射回原始 HTML 片段(含实体转义处理),因此干净 HTML 中既能看到
<strong>[PERSON]</strong>这样的占位符,也能看到<script></script>这样的空壳标签; - 注释(
<!-- ... -->)内容同样经过脱敏; DOCTYPE被归一化为<!DOCTYPE html>,处理指令被丢弃。
- 标签白名单
远程内容与不安全链接的移除
HTML 清洗中还包含三类主动的安全移除(_redact_attribute):
- 远程图片:
src属性一律移除——远程图源会泄露"这封干净邮件被打开过"的事实,仅保留指向本地再生 Content-ID 的cid:引用; - 未知 Content-ID 引用:
cid:若不在附件重映射表(cid_map)中,该属性整体移除; - 不安全链接协议:
href仅允许#、http://、https://、mailto:前缀,javascript:等协议直接移除。
测试夹具 synthetic_phi.eml 中就包含了远程追踪器<img src="https://tracker.example.test/opened">与javascript:alert(1)链接,测试断言脱敏后两者均不出现(test_email.py)。
MIME 元数据重建
_sanitize_mime_metadata(email.py)对整棵 MIME 树做最终清理:
- multipart 的 preamble/epilogue 置空(消除边界外的残余文本);
- multipart 子类型统一收敛为
alternative/digest/mixed/related之一,其余归并为multipart/mixed; - 非附件的正文部分删除
Content-Description、Content-Disposition、Content-Location,Content-ID统一替换为<body-0000@openmed.invalid>形式的伪 ID; - MIME 边界字符串全部由标准库按新内容重新生成——原始边界名不会出现在干净输出中(测试断言
synthetic-outer-boundary不存在,test_email.py)。
附件处理:内存分发、失败关闭与只读证据
支持范围
_ATTACHMENT_MIME_SUFFIXES与_MATERIALIZABLE_ATTACHMENT_SUFFIXES(email.py)共同定义了受支持的附件类型:
| 后缀 | 内容类型 | 说明 |
|---|---|---|
.pdf | application/pdf | 重写为纯图片型 PDF |
.docx | OOXML Word | 文本脱敏 + 元数据擦除 |
.pptx | OOXML PPT | 文本脱敏 + 元数据擦除 |
.jpg/.jpeg/.png/.tiff | 栅格图片 | 像素脱敏 + 元数据剥离 |
.eml | message/rfc822 | 嵌套消息递归脱敏 |
.msg | application/vnd.ms-outlook | 经 MSG 桥接后按 EML 处理 |
全内存分发,绝不落盘
附件通过redact_document在内存中分发(_redact_attachments):每个附件被包装成带安全文件名的_NamedBytesIO可寻址缓冲区(attachment-0001.pdf),输出写入另一个内存缓冲区。原始消息或附件的字节永远不会写入临时文件——测试test_redact_email_redacts_headers_plain_html_and_attachment_metadata通过伪造的redact_document验证了输入流名、字节内容与lang传递(test_email.py)。
- PDF 附件:通过真实 PDF 处理器输出为全新纯图片型 PDF,不透明黑框直接烧入页面像素,原始可搜索文本层与源元数据无法存续(
test_attached_pdf_is_redacted_through_real_pdf_handler断言脱敏后页面extract_text()为空,test_email.py); - DOCX/PPTX 附件:脱敏后额外调用
_scrub_office_metadata擦除 OOXML core/app/custom 属性(email.py),测试验证 ZIP 内任何成员都不再含 PHI 字符串(test_email.py); - 图片附件:像素区域覆盖 + 元数据剥离,PNG 测试断言脱敏后
comment元数据消失、覆盖区域像素为纯黑(test_email.py); - 嵌套 EML 附件:递归脱敏后仍保持可读的干净 RFC 822 消息(test_email.py)。
失败关闭原则
不支持的附件直接失败关闭(fail closed),而不是被原样复制到干净邮件中。当附件扩展名不在可物化白名单内时,抛出UnsupportedDocumentError,且错误消息不会回显附件原始文件名中的 PHI(测试test_unsupported_attachment_fails_closed_without_echoing_filename断言错误信息中不含 "Alice Patient",test_email.py)。
附件报告只含安全证据
每个附件产出一条EmailAttachmentReport(email.py),其to_dict()只暴露:附件序号(attachment_index)、扩展名(extension)、内容类型(content_type)、处理器格式(handler_format)、检出 span 数(detected_span_count)以及输出 SHA-256 摘要(output_sha256)——不含文件名、路径或任何内容。附件自身也被整体重写为安全的最小 MIME 表示:原始头部全部删除,仅重建Content-Type(按安全后缀映射)、Content-Disposition(带attachment-NNNN.<ext>安全文件名)、可选的新Content-ID(attachment-NNNN@openmed.invalid),内容以 base64 编码(_replace_attachment_payload)。
通用分发器也认识邮件扩展名
除了专用 API,通用文档分发器同样支持邮件格式:
from openmed.multimodal import redact_document document = redact_document("synthetic-message.eml")redact_document依据扩展名路由:.eml与.msg注册在register_handler((".eml", ".msg"), _email_handler, requires_multimodal=False)(email.py)。因为requires_multimodal=False,纯 EML 提取不要求安装multimodal附加依赖——测试test_named_memory_stream_dispatches_eml_without_multimodal_extra在模拟缺失 pdfplumber 的情况下仍能完成 EML 提取(test_email.py)。分发器还支持带name属性的二进制流输入,仅用该安全文件名做扩展名路由(base.py)。
处理器_email_handler的策略:policy映射中若指定了output_path/redacted_path/destination_path,则自动进入脱敏模式;否则退化为纯提取(email.py)。
可选 Outlook MSG 桥接:隔离子进程与许可证边界
为什么是显式可选的
Outlook.msg解析依赖extract-msg库,该库采用GPL-3.0许可证。因此它被隔离在显式的附加依赖中,只有在你确认部署环境可以接受时才安装:
uv pip install "openmed[email-msg-gpl,multimodal]"pyproject.toml中该附加依赖固定为extract-msg>=0.56,<0.57,并明确注释"该显式附加依赖使 GPL 代码远离基础与通用 multimodal 环境"(pyproject.toml)。
隔离子进程桥的实现
OpenMed不会把extract-msg导入自己的进程。桥接实现(_convert_msg_to_eml_bytes)的执行方式:
- 通过
importlib.util.find_spec("extract_msg")探测依赖,缺失时抛出带安装指引的MissingDependencyError(email.py); - 用
sys.executable -I -c启动隔离的 Python 子进程,桥接脚本在子进程内openMsg(BytesIO(...))解析 MSG,调用asEmailMessage()转成 RFC 5322 消息; - MSG 原始字节通过标准输入管道送入子进程,规范化后的 EML 字节经标准输出管道收回;
- 全程不创建任何携带 PHI 的临时文件(测试断言
synthetic-msg-bytes不会出现在命令行参数中,test_email.py); - 子进程超时上限为 60 秒(
_MSG_BRIDGE_TIMEOUT_SECONDS),解析失败或退出码非零都会抛出UnsupportedDocumentError。
MSG 输入总是输出为 EML
由于extract-msg是只读解析器,MSG 输入一律以 EML 形式输出(source_format="msg"),干净输出的文件扩展名也统一为.eml。未安装email-msg-gpl附加依赖时,任何.msg输入都会抛出带可操作指引的MissingDependencyError——测试test_missing_extract_msg_dependency_has_actionable_extra验证错误信息同时包含extract-msg与openmed[email-msg-gpl](test_email.py)。
隐私边界:全本地处理,零遥测
所有处理都在本地完成。OpenMed不执行任何遥测或网络调用;离线运行时,模型工件必须已预先就位。这与仓库的整体隐私设计一致——邮件正文、附件字节在内存中流转完毕即被替换,中间产物不会以原始形式出现在磁盘、进程参数或错误消息中。
端到端验证:一份带 PHI 的合成邮件
仓库测试夹具 tests/unit/multimodal/fixtures/synthetic_phi.eml 是一份典型的"最坏情况"邮件,包含:人名(Alice Patient)、邮箱、8 位病历号(12345678)、电话号码(415-555-1212)、SSN(999-99-9999)、自定义 PHI 头部(X-Patient-Note、X-Alice-Patient-ID)、认证头部、HTML 中的远程追踪图、javascript:链接、未知标签(<alice-patient>)、带私有数据的属性、脚本中的 SSN、带 PHI 文件名/描述/CID 的 PDF 附件、以及 preamble/epilogue 中的残余文本。
对应的集成测试断言了完整验收标准(test_email.py):
- 所有原始标识符不再出现在序列化字节与归一化文档中;
Authentication-Results、X-Alice-Patient-ID、Date均被移除;- MIME 边界被重新生成,preamble/epilogue 清空;
- HTML 保留结构(
<strong>[PERSON]</strong>、空<script>),但mailto:中的邮箱、javascript:、远程 tracker、未知标签与私有属性全部消失; - 附件被重命名为
attachment-0001.pdf,Content-ID 重建,Content-Description与自定义附件头部被删除,载荷为干净的替换 PDF。
这段测试可以作为你自己的邮件脱敏验收清单的起点。
引用与进一步阅读
- 核心实现:openmed/multimodal/email.py
- 多模态公共契约与分发器:openmed/multimodal/base.py
- 多模态导出面:openmed/multimodal/init.py
- 端到端测试:tests/unit/multimodal/test_email.py 与 测试夹具
- 附加依赖声明:pyproject.toml
- 相关多模态文档:PDF 脱敏保真度、媒体类型探测、资产清单与批次
【免费下载链接】openmedLocal-first healthcare AI: clinical NER & HIPAA PII de-identification that runs 100% on-device. 2,200+ medical models, 21 languages, Apple MLX + Python, no cloud, no patient data leaving your network. Apache-2.0项目地址: https://gitcode.com/GitHub_Trending/ope/openmed
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考