面向后端 / 运维 / 数据处理的工程师。聊的是怎么把「给几百上千个人各发一封、内容还不一样的邮件」这件事做对。**关键词**:邮件群发软件、批量发送邮件、SMTP 群发、邮件附件一对一、邮件模板变量、邮件群发工具
写代码的人第一次做群发,往往会写成这样:
https://geojsoncn.com/smailer/ 项目代码片段
for addr in addresses:
server.sendmail(from\_addr, [addr], msg.as\_string())能发出去。发到第 200 封的时候开始退信,第 600 封进了垃圾箱,用户投诉才发现问题。而这个功能真正的难点从来不是"把邮件发出去",是这四件事:
下面按难度从低到高拆。
连接 SMTP 有两种方式,填错端口的表现是"连上了但发不出去":
| 方式 | 端口 | 流程 |
|---|---|---|
| 隐式 TLS | 465 | TCP 连上就直接握手加密,协议是 SMTPS |
| 显式 TLS | 587 | 先明文连上,再用 STARTTLS 命令升级 |
import smtplib
# 587 + STARTTLS:先普通连接,握手成功后升级
server = smtplib.SMTP("smtp.exmail.qq.com", 587, timeout=30)
server.starttls() # 不做这行就是明文发密码
server.login(user, auth\_code) # 用授权码,不是登录密码注意登录用的是授权码 / 应用专用密码,不是邮箱登录密码。企业邮箱基本都开了二次验证,原始密码直接 SMTP 登录必然失败,这一条每年都有人重新踩一遍。
连接复用也有讲究:单条 TCP 连接连续发几百封容易被服务端按"单连接流量"掐断,实务上每发 50~100 封重连一次是比较稳的做法。
一封带附件的邮件其实是嵌套结构,搞混 multipart 的三个子类型,客户端里附件就会显示成正文或者内联图片:
multipart/alternative:纯文本 + HTML 两选一,收件人客户端挑一个渲染;multipart/related:HTML + 内嵌资源(图片、图标跟着正文走);multipart/mixed:正文 + 附件,最外层通常是它。Python 的 EmailMessage 会帮你按正确顺序拼装,不用手写 boundary:
https://geojsoncn.com/smailer/ 项目代码片段
from email.message import EmailMessage
msg = EmailMessage()
msg["From"] = "sales@example.com"
msg["To"] = addr
msg["Subject"] = "报价单 - {company}"
msg.set\_content("附件是本次询价的正式报价单。") # 纯文本兜底
msg.add\_alternative(html\_body, subtype="html") # HTML 版本
msg.add\_attachment(pdf\_bytes, maintype="application",
subtype="pdf", filename="报价单.pdf")set\_content 必须在 add\_alternative 之前,顺序反了客户端解析会出问题。
这是群发和普通发邮件的分水岭。**没有捷径:每封邮件的 MIME 内容都不一样,必须逐封构造 message 对象。**
有人会想"先把 50 个附件都塞进一封信发给所有人",这在 MIME 层面不成立:一份邮件只有一套 Content-Type 头和一个附件列表,没法表达"附件 A 属于第 1 个人"。真要实现只有两条路:
第 1 条带来真正的难点:怎么知道张三该收哪份附件。实践中要处理三种匹配强度,从严到宽:
def pick\_attachments(key, inbox, exact, fuzzy, by\_company):
"""按 精确 → 模糊 → 公司名 依次回退匹配,宁可多匹配也不要漏发。"""
if key in exact: # 邮箱完全一致,最可靠
return exact[key]
hit = fuzzy.get(key)
if hit: # 姓名、去掉标点的邮箱前缀等
return hit
return by\_company.get(company\_of(key), []) # 最后兜底:同公司同一批匹配策略上有个实战结论值得说:**宁可多发也不要漏发**。报价单漏发给一个客户的代价,远大于多发一份出去被对方无视。所以匹配要设计成逐级回退,而不是"匹配不上就跳过"。
另外两个容易忽略的点:
.exe、.bat、.scr 会被大多数网关直接拦截,用户看到的现象是"邮件收到了但附件没了",还得专门提示;open() 同一个文件。先建索引 {收件人: 附件路径},同一个文件复用已读的字节。模板里填变量,最直白的写法是:
body = TEMPLATE.format(name=name, company=company) # 有坑坑在于:模板里只要出现一个 CSS 的 { },或者用户公司名里带花括号,整个发送任务就抛异常中断。而且 HTML 正文里直接插值等于放任注入。
用正则替换 + 转义更稳:
import html
import re
TOKEN = re.compile(r"\{\{\s\*(\w+)\s\*\}\}")
def render(template, context):
def sub(m):
key = m.group(1)
if key not in context: # 变量不存在就原样留着,方便排查
return m.group(0)
return html.escape(str(context[key]))
return TOKEN.sub(sub, template)三个细节:未知变量保留原样而不是替换成空字符串,发送失败时能一眼看出是哪个变量没喂;HTML 正文走 html.escape 防止注入,纯文本正文则不用转义;用 {{ }} 而不是 {},和 CSS、JSON 的括号冲突概率低很多。
发太快是进垃圾箱的头号原因。两个手段一起上:
import random
import time
for i, item in enumerate(items):
try:
send\_one(item)
except SMTPResponseException as e:
code = e.smtp\_code
if 400 <= code < 500: # 4xx 临时失败:该退避重试
delay = 2 \*\* min(attempts, 5) + random.uniform(0, 3)
time.sleep(delay)
continue
# 5xx 永久失败:地址不存在或被拒,重试一万次也没用
mark\_failed(item, e.smtp\_error)
continue
time.sleep(random.uniform(interval \* 0.7, interval \* 1.3))
if i % 100 == 0:
save\_checkpoint() # 每 100 封落一次盘,见第八节**间隔一定要加随机抖动**。固定 3 秒一封在协议层是规整的机器行为,间隔在 2.1~3.9 秒之间波动则像真人。这是投入产出比最高的一个改动。
4xx 重试、5xx 不重试这条要记牢:
| 状态码 | 含义 | 处理 |
|---|---|---|
| 421 / 450 / 451 | 限流、忙、暂时不可用 | 指数退避后重试 |
| 450 邮箱临时不可用 | 对方邮箱忙 | 退避重试 |
| 550 / 551 / 553 | 邮箱不存在、语法错误 | 不要重试,直接标记失败 |
把 5xx 当成临时失败重试,会让你的发送队列在末尾空转很久,还会平白触发更多限流。
这三项不在你的代码里,但决定了你发出去的信进不进收件箱,配错了上面所有优化都白做:
TXT 记录里的 v=spf1 include:...);p=reject 最严格),同时给你提供一份报告告诉你谁在伪造你的域名。三个都配了,邮件才会稳定进收件箱而不是垃圾箱。发信域名一定要和 From 头里的域名一致,用第三方服务代发却在 From 里写自己域名,是 SPF 校验失败最常见的原因。
还有一个合规问题:用 BCC 把几百个地址塞进同一封信,收件人互相看不见(各自隐私上是好事),但不少地区对这种群发有明确的告知和退订要求,做营销触达前先确认合规要求。纯事务性通知类邮件则应尽量让用户能配置通知频率。
群发任务跑几十分钟,中间断网、关机、进程被杀都很常见。续传的关键是每封发完就把状态落盘,而不是每封都查一次数据库:
# 已发送集合:存 Message-ID,断点后据此跳过已完成的
sent\_ids = load\_sent\_ids() # 从本地文件/DB 读回
for item in items:
if item.message\_id in sent\_ids:
continue # 之前已经发成功了
msg = build\_message(item) # 每封独立构造
server.send\_message(msg)
sent\_ids.add(item.message\_id)
save\_sent\_ids(sent\_ids) # 立刻落盘关键是 Message-ID 要按收件人唯一(比如 <订单号.收件人序号@你的域名>),这样"已发送"这个判断才有可靠的依据。只记一个"发到第几封"的计数器在并发或失败重排后会错位。
重试时也别重新生成 Message-ID:内容相同的邮件重复投递,客户端可能显示成两封。
排查问题时你会感谢一份结构化的报告。至少要有:收件人、发送状态(成功/临时失败/永久失败)、SMTP 返回码与信息、发送时间、重试次数、附件文件名。导出成 Excel,业务方自己就能筛。
这也是判断"到底发出去没有"最直接的办法。发到第 3500 封断了,重启后从 Excel 就能一眼看到断点。
【】、全角空格、过多 emoji,部分网关会直接判垃圾;From 显示名和服务端 MAIL FROM 不一致,会被当作伪造;邮件群发的难点不在发出去,而在每封都不一样还能稳稳送达。挑几个最影响结果的:附件一对一匹配要做成逐级回退,间隔要加随机抖动,4xx 才重试,断点续传靠按收件人唯一的 Message-ID,发信域名的三件套必须配齐。剩下的就是选一个现成的还是自己维护。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。