用 Python 标准库发邮件:从纯文本到附件、HTML 与回复路由

2026-05-27 34 预计阅读时间:1 分钟
来源:realpython.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:9 分钟

日常开发中,发邮件的需求比想象中更频繁——监控告警、定时报表、用户通知,甚至 CI 流程里的构建结果推送。很多人第一时间想到第三方 SDK,但 Python 标准库的 smtplib + email 已经覆盖了绝大多数场景,不需要额外依赖,也不受服务商 API 限制。

下面从最简单的纯文本邮件起步,逐步叠加 HTML、附件和回复路由,每一步都给出可直接运行的代码。

最小可用的 SMTP 连接

Python 内置的 smtplib 负责"把邮件递交给服务器",email 包负责"组装邮件内容"。两者分工明确。

先看一个最精简的纯文本发送:

import smtplib
from email.message import EmailMessage

msg = EmailMessage()
msg["Subject"] = "服务器监控告警"
msg["From"] = "alert@example.com"
msg["To"] = "admin@example.com"
msg.set_content("CPU 使用率超过 90%,请立即检查。")

# 使用 SMTP_SSL 连接(端口 465),适用于大多数现代邮箱
with smtplib.SMTP_SSL("smtp.example.com", 465) as smtp:
    smtp.login("alert@example.com", "your_password_or_app_token")
    smtp.send_message(msg)

几点说明:

  • SMTP_SSL 直接建立加密连接,端口一般是 465。如果你的服务商要求先明文连接再升级加密(端口 587),改用 SMTP + smtp.starttls()
  • send_message 会自动处理编码和头部格式,比老式的 sendmail 更省心。
  • 密码字段建议使用应用专用令牌(App Password),而非账号主密码。Gmail、Outlook 等都已强制要求。

加入 HTML 内容:让邮件不再单调

纯文本告警可以凑合,但报表、通知类邮件往往需要排版。EmailMessage 支持 add_alternative,同时保留纯文本版本——这对不支持 HTML 的客户端是必要的兜底。

msg = EmailMessage()
msg["Subject"] = "每周数据报表"
msg["From"] = "report@example.com"
msg["To"] = "team@example.com"

# 纯文本兜底
msg.set_content("请查看附件中的本周数据报表。")

# HTML 版本
html_content = """
<h2>本周数据概览</h2>
<table border="1" cellpadding="4">
  <tr><th>指标</th><th>数值</th></tr>
  <tr><td>活跃用户</td><td>12,340</td></tr>
  <tr><td>新增订单</td><td>856</td></tr>
</table>
<p>详细数据见附件。</p>
"""
msg.add_alternative(html_content, subtype="html")

set_content 设置的是主内容(纯文本),add_alternative 添加 HTML 作为替代版本。邮件客户端会自动选择能渲染的版本。顺序很重要:先纯文本,后 HTML。

添加附件:文件与内嵌图片

附件是邮件里最容易踩坑的部分——编码、MIME 类型、文件名,手动处理很繁琐。EmailMessage 提供了 add_attachment 方法,一行搞定。

import mimetypes

msg = EmailMessage()
msg["Subject"] = "月度财务报告"
msg["From"] = "finance@example.com"
msg["To"] = "boss@example.com"
msg.set_content("请查收本月财务报告附件。")

# 添加 PDF 附件
file_path = "report_2024_06.pdf"
with open(file_path, "rb") as f:
    file_data = f.read()
    maintype, subtype = mimetypes.guess_type(file_path)[0].split("/")
    msg.add_attachment(
        file_data,
        maintype=maintype,
        subtype=subtype,
        filename=file_path,
    )

mimetypes.guess_type 根据文件扩展名推断 MIME 类型,避免手动写 "application/pdf" 这类字符串。如果推断失败,可以硬编码 maintype="application", subtype="octet-stream" 作为通用兜底。

内嵌图片(邮件正文里直接显示的图片)稍有不同——需要用 add_attachment 并设置 cid,然后在 HTML 里用 <img src="cid:xxx"> 引用:

msg.add_alternative(
    '<p>趋势图如下:</p><img src="cid:trend_chart">',
    subtype="html",
)

with open("trend.png", "rb") as f:
    msg.add_attachment(
        f.read(),
        maintype="image",
        subtype="png",
        filename="trend.png",
        cid="trend_chart",  # 与 HTML 中的 cid 对应
    )

回复路由:让收件人回复到指定地址

默认情况下,收件人点击"回复"会发到 From 地址。但很多场景需要分离发送地址和回复地址——比如用 noreply@example.com 发出,但回复路由到 support@example.com

msg["From"] = "noreply@example.com"
msg["To"] = "user@example.com"
msg["Reply-To"] = "support@example.com"

一行 Reply-To 头就解决问题。如果需要多个回复目标,用逗号分隔地址列表。

实战组合:一封完整的告警邮件

把上面所有能力组合起来,写一个可以直接放进项目里的发送函数:

import smtplib
import mimetypes
from email.message import EmailMessage
from pathlib import Path


def send_alert_email(
    smtp_host: str,
    smtp_port: int,
    sender: str,
    password: str,
    recipients: list[str],
    subject: str,
    text_body: str,
    html_body: str | None = None,
    attachments: list[Path] | None = None,
    reply_to: str | None = None,
) -> None:
    msg = EmailMessage()
    msg["Subject"] = subject
    msg["From"] = sender
    msg["To"] = ", ".join(recipients)
    if reply_to:
        msg["Reply-To"] = reply_to

    msg.set_content(text_body)
    if html_body:
        msg.add_alternative(html_body, subtype="html")

    for path in (attachments or []):
        with open(path, "rb") as f:
            file_data = f.read()
            mime = mimetypes.guess_type(str(path))[0]
            if mime:
                maintype, subtype = mime.split("/")
            else:
                maintype, subtype = "application", "octet-stream"
            msg.add_attachment(
                file_data,
                maintype=maintype,
                subtype=subtype,
                filename=path.name,
            )

    with smtplib.SMTP_SSL(smtp_host, smtp_port) as smtp:
        smtp.login(sender, password)
        smtp.send_message(msg)


# 调用示例
send_alert_email(
    smtp_host="smtp.example.com",
    smtp_port=465,
    sender="alert@example.com",
    password="your_app_token",
    recipients=["admin@example.com", "ops@example.com"],
    subject="生产环境异常告警",
    text_body="服务 svc-a 响应时间超过 5s,请排查。",
    html_body="<h2>告警详情</h2><p>服务 <b>svc-a</b> 响应时间超过 5s。</p>",
    attachments=[Path("error_log.txt")],
    reply_to="support@example.com",
)

运行前需要替换 smtp_hostsenderpassword 为你的实际邮箱配置,并确保附件文件存在。

注意事项与踩坑清单

  • 端口选择:465 对应 SMTP_SSL(全程加密),587 对应 SMTP + starttls()(先明文后升级)。搞混会导致连接超时或认证失败。
  • 认证凭据:Gmail 需要开启"应用专用密码";Outlook/Office 365 如果启用了现代认证,可能需要 OAuth2 流程,标准库不直接支持,可考虑 oauthlib 辅助。
  • 编码陷阱EmailMessage 默认使用 UTF-8,中文主题和正文通常没问题。但老式 MIMEText 手动构造时容易漏掉 charset 参数,导致乱码。
  • 发送频率:大多数 SMTP 服务商有频率限制(如 Gmail 每天约 500 封)。批量发送应考虑队列 + 延时,或切换到专业邮件服务(SendGrid、Mailgun 等)。
  • 不要在代码里硬编码密码:生产环境应从环境变量或配置文件读取,例如 os.environ["SMTP_PASSWORD"]

标准库发邮件的能力边界在"单次递交"——它不负责队列、重试、退信处理和送达追踪。如果业务对送达率有严格要求,标准库适合做原型和低频场景,高频或关键通知应迁移到专业服务。但对于内部告警、定时报表、小规模通知,smtplib + email 已经足够可靠,且零依赖的优势在受限环境里尤为明显。


相关推荐