哎我跟你说哈,python 自动发邮件这玩意儿,我第一次用不是啥“写个脚本多优雅”那种场景,是半夜两点线上报警炸了,群里一堆人@我,说“你那服务又挂了?”我人都没醒透,手边只有一台破电脑……然后我就想,别在群里刷屏了,直接把异常汇总发到邮箱,老板第二天一看邮件主题就知道谁该挨骂,欸不是,谁该修锅对吧。
当时踩的坑也挺像你们平时踩默认配置的坑:你以为“默认就能跑”,结果一到生产环境就开始抽风,比如端口、TLS、超时、编码、附件大小……反正就是那种,平时测试发一封邮件好好的,一到高峰期就卡住线程不动了,跟“连接池默认10个”那味儿很像。
我一般就用标准库 smtplib + email.message,别整太花,能跑最重要。下面这段是我自己常用的骨架,写得有点啰嗦是故意的,线上出问题你至少能看日志定位到哪一步卡了。
import os
import ssl
import smtplib
import socket
import time
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
defsend_mail(
subject: str,
text: str,
to_addrs: list[str],
html: str | None = None,
attachments: list[tuple[str, bytes, str]] | None = None, # (filename, content, mime)
max_retry: int = 3,
):
"""
attachments: e.g. [("error.log", b"...", "text/plain")]
mime: like "text/plain" or "application/octet-stream"
"""
host = os.getenv("SMTP_HOST", "")
port = int(os.getenv("SMTP_PORT", "465")) # 465 SSL, 587 STARTTLS
user = os.getenv("SMTP_USER", "")
password = os.getenv("SMTP_PASS", "")
ifnot (host and user and password):
raise RuntimeError("SMTP config missing, set SMTP_HOST/SMTP_USER/SMTP_PASS")
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = user
msg["To"] = ", ".join(to_addrs)
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid()
# 正文:先放纯文本,HTML可选
msg.set_content(text)
if html:
msg.add_alternative(html, subtype="html")
# 附件
if attachments:
for filename, content, mime in attachments:
maintype, subtype = (mime.split("/", 1) + ["octet-stream"])[:2]
msg.add_attachment(content, maintype=maintype, subtype=subtype, filename=filename)
# 这块我就很“土”,直接重试+退避,别怕丑,救命
last_err = None
for i in range(1, max_retry + 1):
try:
timeout = 15
socket.setdefaulttimeout(timeout)
if port == 465:
context = ssl.create_default_context()
with smtplib.SMTP_SSL(host, port, context=context, timeout=timeout) as server:
server.login(user, password)
server.send_message(msg)
else:
# 587: 先连再 starttls
with smtplib.SMTP(host, port, timeout=timeout) as server:
server.ehlo()
server.starttls(context=ssl.create_default_context())
server.ehlo()
server.login(user, password)
server.send_message(msg)
return# 成功就走
except Exception as e:
last_err = e
# 简单退避:1s, 2s, 4s...
time.sleep(2 ** (i - 1))
raise RuntimeError(f"send mail failed after {max_retry} retries: {last_err}")
你看哈,这里面我故意没把“收件人”写成字符串单个参数,因为真实业务里经常是:报警发给当班同学,日报发给领导,账单发给客户,收件人列表不一样,最后你会感谢自己当初没偷懒。
然后再说几个“看起来小、出事要命”的点,我都是被锤过的:
1)账号密码:现在很多邮箱不是“登录密码”就能 SMTP,得用“授权码/应用专用密码”。你别问我怎么知道的,我当时登录成功率 0%,还以为是我代码写错了……结果是账号策略。 2)主题和正文编码:EmailMessage 默认能处理 UTF-8,别自己去拼 MIME,越拼越像 TCP 拆包那种诡异问题,一字节错位你就找半天。 3)超时:别不设,真的。你一旦把“发邮件”塞到主线程里,SMTP 卡住就跟接口超时那种血案一样,链路上游先断开,下游还在傻跑。 4)重试:也别无限重试,最多 3 次差不多了,不然你报警没发出去,反而把 SMTP 打挂了,尴尬得一批。
最后我再塞一段“像人用的”调用方式,假设你做了个小监控:发现错误就把最近 50 行日志发出去。别笑,这种脚本真能救你命。
deftail_bytes(path: str, max_bytes: int = 20_000) -> bytes:
with open(path, "rb") as f:
f.seek(0, 2)
size = f.tell()
start = max(0, size - max_bytes)
f.seek(start)
return f.read()
if __name__ == "__main__":
log_content = tail_bytes("app.log")
send_mail(
subject="【线上报警】订单服务异常,快看啊",
text="我这边抓到一段日志,先顶一封邮件,你们谁醒着谁处理下…\n",
to_addrs=["oncall@example.com"],
html="<p>别慌,我先把日志贴上。</p>",
attachments=[("app_tail.log", log_content, "text/plain")],
)
行了差不多就这样,我现在说着说着都困了……反正你们真要上生产,记得把 SMTP 配置放环境变量,别硬编码进仓库里,别到时候截图发群里还带密码,社死级别。欸对了我刚才是不是忘了说端口 465/587 的区别……算了你先跑起来再说。