Python quopri 的 header 开关:同一个下划线,为什么解码后有时变成空格

昨天 3阅读

修复邮件展示问题时,看到标题里的下划线像空格,便给所有quoted-printable内容开启header=True。标题似乎正常了,正文中的文件名却悄悄改变。这个开关说明输入采用哪一种语法,并不是“更完整地解码”;同一字节在不同语法中可以有不同意义。

下面在Linux、CPython 3.12.14实跑,只处理程序自建的短字节串,不读取或发送邮件。保存为demo.py,执行python3 demo.py。实验先对照两种模式,再做头字段往返、软换行检查,最后演示完整encoded-word的解析入口。

Python quopri 的 header 开关:同一个下划线,为什么解码后有时变成空格

AI模型生成概念插图:相同纸带经过不同解析入口,连接标记在一侧保留、另一侧化为空隙;不是邮件客户端截图。

完整程序与本地结果

完整可运行程序

import quopri
from email.header import decode_header

payload = b"build_report=3A_ready"
body = quopri.decodestring(payload)
header = quopri.decodestring(payload, header=True)
assert body == b"build_report:_ready"
assert header == b"build report: ready"
print("body mode:", repr(body))
print("header mode:", repr(header))

original = b"build_report: ready"
encoded = quopri.encodestring(original, header=True)
assert encoded == b"build=5Freport:_ready"
assert quopri.decodestring(encoded, header=True) == original
print("header encoded:", repr(encoded))
print("round trip:", repr(quopri.decodestring(encoded, header=True)))

soft = quopri.decodestring(b"long=\r\nline")
hard = quopri.decodestring(b"long\r\nline")
assert soft == b"longline"
assert hard == b"long\r\nline"
print("soft break:", repr(soft))
print("hard break:", repr(hard))

parts = decode_header("=?utf-8?q?build=5Freport=3A_ready?=")
assert parts == [(original, "utf-8")]
print("complete encoded word:", parts[0][0].decode(parts[0][1]))

本次实际输出(以下为结果,不是程序)

body mode: b'build_report:_ready'
header mode: b'build report: ready'
header encoded: b'build=5Freport:_ready'
round trip: b'build_report: ready'
soft break: b'longline'
hard break: b'long\r\nline'
complete encoded word: build_report: ready

下划线的含义由模式决定

默认解码得到build_report:_ready,下划线仍是下划线;header=True得到build report: ready,两处下划线都变为空格。两种模式都会把=3A还原为冒号,说明变化集中在头字段Q编码约定,不能据此认为默认模式漏解了一部分。

因此,模式不能通过“文本看起来像标题”临时猜测。应从邮件结构和字段编码标识取得依据。日志、文件名或普通正文即使含有下划线,也不构成开启header的理由;否则原始区分已经在字节层丢失,后面再转成字符串无法知道哪里被改过。

字面下划线在头字段中需要保护

编码器把原始build_report: ready变成build=5Freport:_ready。文件名中真正的下划线写成=5F,空格写成下划线;按同一模式解码才能恢复原字节。这个往返断言比肉眼看一段结果更可靠,因为它同时保护了两个容易混淆的字符。

不要在解码前一律替换下划线,也不要在解码后全局把空格换回下划线。前者忽略语法层次,后者会破坏真正的空格。修复流程应保留输入字节、确认所属部位,再选择对应解码入口,并用包含字面下划线和真实空格的样本验收。

软换行不代表正文真的换了一行

long后紧跟等号和CRLF时,解码结果为longline;没有等号的CRLF则保留在结果中。quoted-printable用软换行解决传输行长度问题,显示内容可以继续连接。只按物理行逐条解码再随手补换行,可能把传输折行误变成正文内容。

这里用repr展示字节,所以控制字符不会被终端视觉效果遮住。实际管线还应先确认输入是否完整,尤其不能把最后一行暂时缺少后续字节当成确定结束。示例是固定完整输入,没有实现网络分块接收,也没有声称能修复损坏报文。

完整邮件头还包含字符集与封装

最后的decode_header接收带有utf-8和q标记的完整encoded-word,返回字节与字符集。程序随后按该字符集解码成文字。quopri本身处理的是传输编码字节,不会替你分析整封邮件结构,也不会仅凭内容猜出所有正文的字符集。

若输入是一整份邮件,应交给email包按结构解析,再处理相应部分;复杂头字段还可能包含多段不同编码。本文只用一个已知UTF-8片段说明层次。把结构解析、传输解码与字符解码分开记录,能避免“显示正常”掩盖文件名已经被改写的问题。

把最小复现接到真实排查

如果一条日志只保存了解码后的文字,调查时可能已经无法确定下划线原来是字面字符还是空格的传输表示。更有帮助的记录是来源字段、声明的编码与一个不含私密内容的最小字节样本,而不是把整封邮件复制进调试日志。

字符集转换也应在传输解码之后进行。等号后面的十六进制表示字节,多个字节才可能共同组成一个非ASCII字符。直接把编码文本当成最终文字处理,或先把它按猜测字符集解码再全局替换,都会混合本应独立的阶段。

回归用例可以从本文扩展成四组:只有下划线、只有真实空格、两者同时出现,以及带有软换行的连续内容。每组都保存明确预期字节,并在正确模式下往返;错误模式的输出也要写入对照,证明测试确实能发现配置偏差。

quoted-printable是传输表示方式,不提供来源认证、保密或内容安全保证。解码成功只说明得到了字节;若这些字节将作为文件名、链接或其他业务输入,仍应由对应流程校验。这里没有把解析完成等同于允许执行其中的内容。

参考资料

资料核验日期:2026年10月2日。以上输出来自固定输入的本地实跑,程序退出码为0。

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。