日常开发中,发邮件的需求比想象中更频繁——监控告警、定时报表、用户通知,甚至 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_host、sender、password 为你的实际邮箱配置,并确保附件文件存在。
注意事项与踩坑清单
- 端口选择: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 已经足够可靠,且零依赖的优势在受限环境里尤为明显。