Python email.utils 模块完全指南:地址解析、Message-ID 与日期时间格式处理
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
email.utils是 CPython 标准库email包中提供"杂项工具"的模块:它负责把To/Cc等头部中的地址解析成"姓名 + 邮箱"元组、生成符合 RFC 2822 的Message-ID、在字符串与datetime对象之间转换邮件日期时间,以及对 RFC 2231 编码的头部参数做编解码。无论你是用email包编写发信程序、解析收到的邮件,还是在 HTTP、SMTP、日志告警等场景中复用这些工具,掌握本模块都能让你少踩头部格式的坑。读完本文,你将能独立完成收件人批量提取、RFC 2047/2231 头部编解码、标准日期头生成,并理解其底层实现与安全校验边界。
模块概览:定位与源码结构
email.utils的全部实现位于 Lib/email/utils.py(约 500 行),底层地址与日期解析复用了 Lib/email/_parseaddr.py 中的词法解析器(该类源自 Python 2 时代的rfc822模块,代码头部注释即写明 "Lifted directly from rfc822.py")。模块对外公开的__all__共 15 个名字:
collapse_rfc2231_value、decode_params、decode_rfc2231、encode_rfc2231、formataddr、formatdate、format_datetime、getaddresses、make_msgid、mktime_tz、parseaddr、parsedate、parsedate_tz、parsedate_to_datetime、unquote(quote从email._parseaddr导入但未列入__all__,仍可显式from email.utils import quote取得)。
模块内部还有一个supports_strict_parsing = True标志位(见 Lib/test/test_email/test_email.py 中的test_supports_strict_parsing),标识当前解析器具备严格模式能力。
值得注意的工程细节:
- 延迟导入优化启动速度:
make_msgid()在函数体内才import random与import socket,formataddr()在需要时才惰性导入email.charset.Charset,mktime_tz内部延迟导入calendar。这是为了让email包只被 import 而不使用时避免引入重型模块。Lib/test/test_email/test_utils.py 的TestImportTime专门用ensure_lazy_imports("email.utils", {"random", "socket"})验证了这一点。 - "新 API / 旧 API" 双轨制:官方文档明确指出,从
formataddr往后的函数属于 legacy(Compat32)email API——在使用新的EmailMessage(默认EmailPolicy)时,头部解析与格式化由新 API 自动完成,通常无需手动调用。但事实是它们仍然大量出现在标准库的"非邮件"模块中,属于"旧而不废"的核心工具(见下文仓库内应用实例)。 _sanitize与_has_surrogates:内部还有处理 surrogate-escaped 二进制数据的辅助函数,被 Lib/email/message.py、Lib/email/generator.py 等引用,用于把畸形的字节内容安全地转成可显示文本。
邮箱地址解析与格式化
parseaddr():把地址字段拆成 "姓名 + 邮箱"
from email.utils import parseaddr parseaddr('Guido van Rossum <guido@python.org>') # ('Guido van Rossum', 'guido@python.org') parseaddr('Guido@python.org') # 没有显示名 # ('', 'Guido@python.org') parseaddr('invalid address, no mailbox') # 解析失败 # ('', '')- 输入应为某个地址类头部字段(
To、Cc、From、Resent-To等)的单条值; - 成功时返回
(realname, email_address)二元组;失败时按文档契约返回('', ''); - 从 Lib/email/utils.py 的
parseaddr实现看,它走_AddressList(...).addresslist解析器,并在严格模式下额外执行_pre_parse_validation/_post_parse_validation两层校验。
strict 严格模式(3.13 新增,默认开启):底层是AddrlistClass状态机(见 Lib/email/_parseaddr.py),其"宽松解析"会把畸形的输入拼出不合理的地址。例如源码注释给出的反例:alice@example.com <bob@example.com>会被宽松解析拆成两个地址。为避免这种非法输出:
- 前置校验
_check_parenthesis会剥离引号内内容后检查括号是否配对,不配对则整条替换为('', ''); - 后置校验会把邮箱中含
[(表示域字面量解析失败残留)的结果置空; - 严格模式下若解析出的地址数量与输入中的逗号计数对不上(去掉引号内逗号后按
1 + 逗号数估算),同样整体返回空元组。
因此默认行为是"宁可返回('', ''),也不吐出错误地址"。需要旧版宽松行为时可显式传parseaddr(addr, strict=False)。parsedate需要说明的是:3.13 之前无此参数,行为等同于今天的strict=False。
formataddr():parseaddr 的逆操作
from email.utils import formataddr formataddr(('Guido van Rossum', 'guido@python.org')) # 'Guido van Rossum <guido@python.org>' formataddr(('', 'guido@python.org')) # 姓名为假值时,原样返回地址 # 'guido@python.org'它接收(realname, email_address)二元组,返回可直接放进To/Cc/From头部的字符串,实现细节(Lib/email/utils.pyformataddr):
- 地址必须纯 ASCII:对
address执行address.encode('ascii'),非 ASCII 会抛UnicodeError(测试test_unicode_address_raises_error可验证,邮箱不能做 IDN 直写); - 显示名含特殊字符时自动加引号转义:源码用
specialsre = re.compile(r'[][\\()<>@,:;".]')检测,命中则外层加双引号,并用escapesre把其中的\与"转义为\\、\"; - 非 ASCII 显示名走 RFC 2047 编码:显示名不是 ASCII 时,按charset参数(默认
utf-8)执行header_encode,输出形如=?utf-8?b?...?= <addr>的 encoded-word。Lib/test/test_email/test_email.py 给出的可复现用例:
formataddr(("H\u00e4ns W\u00fcrst", "person@dom.ain")) # '=?utf-8?b?SMOkbnMgV8O8cnN0?= <person@dom.ain>' formataddr(("H\u00e4ns W\u00fcrst", "person@dom.ain"), 'iso-8859-1') # '=?iso-8859-1?q?H=E4ns_W=FCrst?= <person@dom.ain>'charset既可以是str字符集名,也可以是具有header_encode方法的类email.charset.Charset对象(测试test_accepts_any_charset_like_object用一个只实现了header_encode的 mock 验证了鸭子类型要求)。
防头部注入(strict,默认开启):formataddr是收信/发信前把数据"变安全"的最后一道闸。若姓名或地址中含有\r或\n(CR/LF),默认直接抛ValueError,防止通过换行注入伪造头部,例如'person@dom.ain\r\nBcc: victim@dom.ain'这类攻击载荷会被拒绝(对应测试test_crlf_in_parts_raises_error)。显式传strict=False才放行(保留旧行为,测试test_crlf_in_parts_allowed_when_not_strict)。该参数在文档中标记为versionchanged:: next,即本仓库当前开发主线对应的下一个发布版本中加入。
getaddresses():批量提取一条消息的所有收件人
from email.utils import getaddresses tos = msg.get_all('to', []) ccs = msg.get_all('cc', []) resent_tos = msg.get_all('resent-to', []) resent_ccs = msg.get_all('resent-cc', []) all_recipients = getaddresses(tos + ccs + resent_tos + resent_ccs)getaddresses(fieldvalues, *, strict=True)的输入是头部字段值的序列(如Message.get_all()的返回),输出是若干(realname, email)二元组,等价于对每个值执行parseaddr语义。严格模式(3.13 起默认)下同样引入失败保护:先用COMMASPACE = ', '拼接全部字段值再整体交给_AddressList解析,然后做数量对账——如果解析出的地址数与按逗号预估值不符(即输入包含畸形语法),返回[('', '')]而非可能被误解的错误列表。仓库内 Lib/smtplib.py 的mail/sendmail正是用getaddresses把from_addr与to_addrs解析为addr_spec再投递。
quote() 与 unquote():引号与反斜杠转义
from email.utils import quote, unquote quote('a"b\\c') # 反斜杠翻倍、双引号加反斜杠 # 'a\\"b\\\\c' unquote('"quoted"') # 去掉首尾双引号(并还原内部转义) # 'quoted' unquote('<addr@dom>') # 或去掉首尾尖括号 # 'addr@dom'实现约定:quote只负责把字符串准备好用于放进引号串(不负责加外层引号);unquote则去除首尾配对的"..."或<...>定界符。它们是 Compat32 遗留 API,新 API 的头部解析机制会自动完成等价工作,因此一般不需要手动调用——但它们仍是formataddr引号处理与 RFC 2231 参数去引号的底层基石(unquote在本模块内被decode_params/collapse_rfc2231_value使用,quote定义于 Lib/email/_parseaddr.py)。
生成符合 RFC 2822 的 Message-ID:make_msgid()
from email.utils import make_msgid make_msgid() # '<17571231231234.20800.16526388040877946887@nightshade.la.mastaler.com>' make_msgid(idstring='python-list') # 附加字符串增强唯一性 make_msgid(domain='example.org') # 指定 '@' 之后的域 # '<17571231231234.20800.81341234431122123312.example.org>' 中的域部分示例函数返回包裹在尖括号中的字符串,适合直接赋给Message-ID头。三个组成部分(见 Lib/email/utils.pymake_msgid):
- 时间:
int(time.time() * 100)(百分之一秒精度),配合进程号与随机数形成时间线唯一性; - 进程号:
os.getpid(); - 随机位:
random.getrandbits(64)(64 位随机整数,跨进程碰撞概率可忽略)。
参数语义:
idstring:非空时以.拼接进本地部分(如<….<myid>@host>),用于在同一进程连续生成消息时进一步区分;domain:提供@后面的域名部分,默认取socket.getfqdn()(本机全限定域名)。文档特别指出,构建"跨多台主机共享统一域名"的分布式系统时自定义domain才真正有用——这也是该参数的典型动机,一般单机场景不必覆盖默认值。
时间与日期:宽松解析与标准格式化
宽松解析 parsedate() 与 parsedate_tz()
from email.utils import parsedate, parsedate_tz parsedate('Mon, 20 Nov 1995 19:12:08 -0500') # (1995, 11, 20, 19, 12, 8, 0, 1, -1) parsedate_tz('Mon, 20 Nov 1995 19:12:08 -0500') # (1995, 11, 20, 19, 12, 8, 0, 1, -1, -18000)parsedate(date)按 RFC 2822 规则尝试解析,但对不守规矩的邮件头采取"能猜就猜"策略;成功返回 9 元组,可直接传给time.mktime;失败返回None。parsedate_tz(date)功能相同但返回10 元组:前 9 个元素同样可直接交给time.mktime,第 10 个元素是相对 UTC 的时区偏移(单位:秒)。若输入不含时区,则第 10 个元素为0(表示 UTC)。注意9 元组的第 6、7、8 个索引(星期、年内日序、DST 标志)不可用。
底层_parsedate_tz(Lib/email/_parseaddr.py)的"猜"体现在许多宽容细节上:
- 星期名(
Mon–Sun)可缺失;月份支持全拼(january…);兼容 RFC 850 风格的20-Nov-95与点号分隔时间19.12.08; - 两位年份按 POSIX 规则换算:
69–99 → 1969–1999,00–68 → 2000–2068; - 内建时区名表:
UT/UTC/GMT/Z → 0,美加常用缩写EST:-500, EDT:-400, CST:-600, CDT:-500, MST:-700, MDT:-600, PST:-800, PDT:-700与AST/ADT。源码注释说明:出于 RFC 1123 指出的 RFC 822 符号错误问题,不支持军事时区(仅保留Z),RFC 1123 建议优先使用数字偏移; - 特殊语义:
-0000时区被解析为偏移None(后面会看到它对 datetime 转换的意义)。
转成 datetime:parsedate_to_datetime()
from email.utils import parsedate_to_datetime parsedate_to_datetime('Mon, 20 Nov 1995 19:12:08 -0500') # datetime.datetime(1995, 11, 20, 19, 12, 8, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=68400)))parsedate_to_datetime是format_datetime的逆操作,也是把"邮件日期字符串"接入现代datetime生态的推荐入口。它直接调用内部_parsedate_tz而非parsedate_tz,从而能区分两种语义:
-0000→ naivedatetime:-0000表示"时间上是 UTC,但明确不透露来源时区",因此源码在此分支返回不带tzinfo的 naive 对象(对应测试test_parsedate_to_datetime_naive);- 其它有效偏移 → aware
datetime:用datetime.timezone(datetime.timedelta(seconds=tz))构造对应tzinfo(对应测试test_parsedate_to_datetime); - 非法输入 →
ValueError:与parsedate返回None不同,这里对无法解析、越界值(小时 > 23、偏移超出 ±24 小时区间)及构造时OverflowError一律抛ValueError。完整反例清单见测试类DateTimeTests,例如'Tue, 06 Jun 2017 27:39:33 +0600'、'Mon, 20 Nov 2017 12:00:00 +24000000000000'等。
10 元组转时间戳:mktime_tz()
from email.utils import parsedate_tz, mktime_tz stamp = mktime_tz(parsedate_tz('Mon, 20 Nov 1995 19:12:08 -0500')) # 返回 UTC 纪元秒(POSIX timestamp,可直接 time.ctime(stamp) 展示)把parsedate_tz产出的 10 元组换算成 UTC 时间戳。实现约定:若第 10 个元素为None则按本地时间处理(time.mktime);否则按calendar.timegm得到 UTC 基准后再扣除时区偏移。注意该函数的None分支是给直接调用它、绕过parsedate_tz(后者会把缺失时区归一化为0)的调用方保留的。
生成标准日期头:formatdate() 与 format_datetime()
from email.utils import formatdate, format_datetime import datetime, time formatdate() # 当前 UTC 时间 # 'Fri, 09 Nov 2001 01:08:47 -0000' (格式示例,值为当前时刻) formatdate(time.time(), localtime=True) # 本地时区、含 DST 处理 # 'Sat, 01 Jan 2011 18:00:00 +0200' (值随系统时区变化) formatdate(time.time(), localtime=False, usegmt=True) # HTTP 风格 # 'Thu, 01 Dec 2011 15:00:00 GMT'formatdate(timeval=None, localtime=False, usegmt=False):
timeval:浮点时间值(time.time()一类),缺省为当前时间;localtime=True:相对本地时区输出数字偏移,并正确考虑夏令时(实现为datetime.fromtimestamp(...).astimezone(),同时强制usegmt=False);usegmt=True:把时区写成 ASCII 字符串GMT而非数字-0000,供 HTTP 等协议使用;仅当localtime=False时生效。
format_datetime(dt, usegmt=False)与之对应但直接接收datetime:
- naive
datetime被理解为"UTC 但无来源时区信息",输出-0000; - aware
datetime输出数字偏移(dt.strftime("%z"),如-0500); - aware 且为零偏移时,
usegmt=True可输出GMT,用于生成符合 HTTP 规范的Date头。注意源码中的严格检查:usegmt=True要求dt.tzinfo必须是datetime.timezone.utc(dt.tzinfo is None or dt.tzinfo != datetime.timezone.utc即抛ValueError),仅仅"偏移为零的其它 tzinfo"并不满足,测试test_usegmt_with_non_utc_datetime_raises用-0700aware 实例验证了这一约束。
源码注释中一个有趣的细节:格式化没有使用strftime(),因为那会受 locale 影响,而 RFC 2822 硬性要求英文缩写星期与月份名(这正是为何 Lib/email/utils.py 手写['Mon',...]/['Jan',...]两张查表并结合timetuple拼字符串)。测试类FormatDateTests(Lib/test/test_email/test_utils.py)在Europe/Minsk时区下验证了 UTC 与本地模式(含 2011 年 Minsk 从 +0200/带 DST 迁到 +0300/无 DST 的历史案例)。
localtime():拿到带时区的"本地时间"
from email.utils import localtime import datetime localtime() # 当前时刻(aware) localtime(datetime.datetime.now()) # naive 输入按系统本地时间解释返回带本地时区信息的 awaredatetime。实现只有一步dt.astimezone()(Lib/email/utils.py):无参调用以datetime.now()为输入;传入 naive 对象时按"它本就在系统本地时区"解释后补上 tzinfo;传入 aware 对象则换算到本地时区(依赖系统时区数据库)。因此等价效果是:给定任意时刻返回同一时刻的本地墙上时间表示。测试覆盖了 DST 开/关两种time.daylight场景、Europe/Minsk/Europe/Kyiv等历史时区跳变(如 1984 年基辅为MSK、1994 年为EET)。该函数 3.3 加入;文档同时记载旧的isdst参数已在 3.12 弃用、并于 3.14 移除(deprecated-removed:: 3.12 3.14)。
仓库内的真实调用场景
虽然文档把日期函数归入 legacy 邮件工具,标准库却到处在用它们,是最直接的"实战证据":
- Lib/http/server.py 的
date_time_string()用formatdate(timestamp, usegmt=True)生成 HTTPDate响应头,并在If-Modified-Since条件请求里用parsedate_to_datetime解析客户端时间再与文件 mtime 比较(含对"无时区旧格式"的 UTC 兜底处理); - Lib/logging/handlers.py 的
SMTPHandler.emit()构造告警邮件时直接msg['Date'] = email.utils.localtime(); - Lib/smtplib.py 用
getaddresses/parseaddr规范化收件人列表。
RFC 2231 参数编解码:处理 Content-Type 等头的扩展参数
RFC 2231 解决的是头部参数(如Content-Type的filename、name)携带非 ASCII 与超长分段的问题,编码形态如filename*=utf-8''%E4%B8%AD%E6%96%87.txt。email.utils提供四个配套函数。
encode_rfc2231() 与 decode_rfc2231()
from email.utils import encode_rfc2231, decode_rfc2231 encode_rfc2231('中文名.txt', charset='utf-8', language=None) # "utf-8''%E4%B8%AD%E6%96%87%E5%90%8D.txt"(示意:按 utf-8 百分号编码,语言为空) decode_rfc2231("utf-8''%E4%B8%AD%E6%96%87") # ('utf-8', '', '%E4%B8%AD%E6%96%87')encode_rfc2231:对内容做百分号编码(实现用urllib.parse.quote(s, safe='', encoding=charset or 'ascii'))。charset、language均给出时输出charset'language'encoded三字段形式;只给charset时language用空串占位。文档所述的"两者皆缺则原样返回",按实现理解是指不追加charset'language'前缀——纯 ASCII 字母数字串经过编码后与原文一致,含空格等符号的内容仍会被百分号转义,使用时需留意这一细节。decode_rfc2231:按单引号maxsplit=2切分。不足三段时返回(None, None, s),否则返回(charset, language, value)三元组。注意它只负责切分还原三字段,不负责解百分号——解码 octets 的工作交给下文的collapse_rfc2231_value或在读取侧由Message机制完成。
collapse_rfc2231_value():三元组变回字符串
from email.utils import collapse_rfc2231_value collapse_rfc2231_value(('utf-8', '', '%E4%B8%AD%E6%96%87')) # 示意 # '中文'当头部参数按 RFC 2231 编码时,Message.get_param()可能返回 3 元组(charset, language, value)。Lib/email/message.py 的get_param文档与源码直接推荐了收尾姿势:
rawparam = msg.get_param('foo') param = email.utils.collapse_rfc2231_value(rawparam)函数行为(Lib/email/utils.py):
- 若传入的不是三元组,视为普通字符串并
unquote后返回(去掉首尾引号); - 三元组时,
charset为None则回退用fallback_charset(默认'us-ascii'),把 value 按'raw-unicode-escape'转成字节后以指定字符集解码; - 未知字符集:显式
codecs.lookup(charset)查表,查不到(LookupError)则退化为unquote(text)原样返回——也就是说遇到 Python 不认识的字符集声明不会崩溃; errors参数透传给bytes.decode,默认'replace'(非法字节替换为 U+FFFD)。
decode_params():批量还原参数序列
from email.utils import decode_params # params 为 (参数名, 值) 序列,首项是媒体类型本身 params = [('text/plain', ''), ("name*0", "a"), ("name*1", "b")] decode_params(params)decode_params(params)把 RFC 2231 的分段参数列表还原为普通参数列表:params是(name, value)二元组序列,第一项通常即Content-Type的媒体类型(实现上new_params = [params[0]]原样保留它)。随后它用正则rfc2231_continuation = re.compile(r'^(?P<name>\w+)\*((?P<num>[0-9]+)\*?)?$')识别name*、name*0、name*1这类延续片段:
- 按编号排序并拼接分段值(
has_zero逻辑处理"无 0 号段但有未编号段"的兼容情形); - 带
*的段先按latin-1解百分号,再交给decode_rfc2231拆出charset/language,最终产出(name, (charset, language, value))或普通"value"形式; - 普通(非延续)参数仅做去引号并重新加引号的规范化(值经
quote转义后以"…"形式呈现,相当于统一引号风格)。
它主要服务于兼容层(Message._get_params_preserve等内部路径);新EmailPolicyAPI 的用户通常不会直接面对它。
版本演变与安全边界小结
把文档与源码中的版本信息汇总:
| 函数 | 关键版本 | 说明 |
|---|---|---|
localtime | 3.3 加入;isdst3.12 弃用、3.14 移除 | 返回 aware 本地时间 |
make_msgid | domain参数 3.2 加入 | 生成 RFC 2822Message-ID |
parseaddr/getaddresses | strict3.13 加入并默认True | 默认拒绝畸形输入,失败给('', '') |
formataddr | charset3.3 加入;strict文档标记next(当前开发主线) | 默认拒绝含 CR/LF 的输入,防头部注入 |
parsedate_to_datetime/format_datetime/localtime | 3.3 加入 | datetime 与 RFC 2822 字符串互转 |
从安全角度,formataddr的 CR/LF 拦截与parseaddr/getaddresses的严格解析都是 3.13 前后针对"地址伪造 / 头部注入 / 畸形输入导致错误解析"的加固成果——相关回归测试同时存在于 Lib/test/test_email/test_email.py(FormatAddrTests)与 Lib/test/test_email/test_utils.py(DateTimeTests/LocaltimeTests/FormatDateTests)。运行python -m test test_email -k utils或直接python -m unittest test.test_email.test_utils即可在本地复现上述行为。
小结:何时用什么
一句话使用清单:
- 要发信:
EmailMessage配合默认EmailPolicy时头部分析全自动,但仍可借formatdate(usegmt=True)/localtime()填Date、make_msgid()填Message-ID,用formataddr(strict 保持默认)安全写入带显示名的地址; - 要解析收到的邮件:新 API 直接读
msg['to']等即可拿到结构化对象;需要"姓名 + 邮箱"元组时可用parseaddr/getaddresses(保持默认 strict),需要旧式日期元组或时间戳时用parsedate/parsedate_tz/mktime_tz; - 要把邮件日期与
datetime生态打通:parsedate_to_datetime/format_datetime是最佳双向桥梁,HTTP 头日期用format_datetime(dt, usegmt=True); - 要处理
Content-Disposition的filename*等参数:理解encode_rfc2231/decode_rfc2231/decode_params的分段与charset'lang'结构,并让collapse_rfc2231_value收尾还原成可读字符串。
若想深入词法层原理(地址状态机、域字面量、注释剥离、2 位年份换算、时区名表),请直接研读 Lib/email/_parseaddr.py;每个公开函数的可复现断言都能在 Lib/test/test_email/test_email.py 与 Lib/test/test_email/test_utils.py 中找到。
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考