面向后端 / 运维 / 数据处理的工程师。聊的是怎么把「给几百上千个人各发一封、内容还不一样的邮件」这件事做对。
关键词:邮件群发软件、批量发送邮件、SMTP 群发、邮件附件一对一、邮件模板变量、邮件群发工具
一、为什么群发邮件比想象中难
写代码的人第一次做群发,往往会写成这样:
for addr in addresses:
server.sendmail(from_addr, [addr], msg.as_string())
能发出去。发到第 200 封的时候开始退信,第 600 封进了垃圾箱,用户投诉才发现问题。而这个功能真正的难点从来不是"把邮件发出去",是这四件事:
- 每封信的内容不一样。要给张三发 A 合同、给李四发 B 报价,附件也不能一样;
- 不能被判定为垃圾邮件。发信方域名要能被验证,频率得像个人而不是像机器;
- 发到一半断了要能续上。第 4000 封时笔记本合盖了,不能从头再来;
- 要知道哪些真的发出去了。销售要一份 Excel 对账,不是日志文件。
下面按难度从低到高拆。
二、SMTP 连接:两个容易踩的端口
连接 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 封重连一次是比较稳的做法。
三、MIME 结构决定了邮件长什么样
一封带附件的邮件其实是嵌套结构,搞混 multipart 的三个子类型,客户端里附件就会显示成正文或者内联图片:
multipart/alternative:纯文本 + HTML 两选一,收件人客户端挑一个渲染;multipart/related:HTML + 内嵌资源(图片、图标跟着正文走);multipart/mixed:正文 + 附件,最外层通常是它。
Python 的 EmailMessage 会帮你按正确顺序拼装,不用手写 boundary:
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 个人"。真要实现只有两条路:
- 群发 50 封,每封一个附件(唯一可行的做法);
- 发一封带全部附件的信,让收件人自己找(等于没有个性化,且附件总大小很快触顶)。
第 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会被大多数网关直接拦截,用户看到的现象是"邮件收到了但附件没了",还得专门提示; - 附件逐封读取要缓存。如果 5000 个收件人对应 5000 个不同附件,别在循环里反复
open()同一个文件。先建索引{收件人: 附件路径},同一个文件复用已读的字节。
五、模板变量替换:别用 str.format
模板里填变量,最直白的写法是:
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 当成临时失败重试,会让你的发送队列在末尾空转很久,还会平白触发更多限流。
七、投递基础:SPF / DKIM / DMARC
这三项不在你的代码里,但决定了你发出去的信进不进收件箱,配错了上面所有优化都白做:
- SPF:在 DNS 里声明哪些服务器可以用你的域名发信(
TXT记录里的v=spf1 include:...); - DKIM:用私钥给邮件内容签名,收件方服务器用 DNS 里的公钥验签,能证明这封信没被改过;
- DMARC:告诉收件方服务器遇到 SPF/DKIM 都失败的邮件怎么处理(
p=reject最严格),同时给你提供一份报告告诉你谁在伪造你的域名。
三个都配了,邮件才会稳定进收件箱而不是垃圾箱。发信域名一定要和 From 头里的域名一致,用第三方服务代发却在 From 里写自己域名,是 SPF 校验失败最常见的原因。
还有一个合规问题:用 BCC 把几百个地址塞进同一封信,收件人互相看不见(各自隐私上是好事),但不少地区对这种群发有明确的告知和退订要求,做营销触达前先确认合规要求。纯事务性通知类邮件则应尽量让用户能配置通知频率。
八、断点续传:让 5000 封的任务可中断
群发任务跑几十分钟,中间断网、关机、进程被杀都很常见。续传的关键是每封发完就把状态落盘,而不是每封都查一次数据库:
# 已发送集合:存 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:内容相同的邮件重复投递,客户端可能显示成两封。
九、发送报告:销售要的是 Excel 不是日志
排查问题时你会感谢一份结构化的报告。至少要有:收件人、发送状态(成功/临时失败/永久失败)、SMTP 返回码与信息、发送时间、重试次数、附件文件名。导出成 Excel,业务方自己就能筛。
这也是判断"到底发出去没有"最直接的办法。发到第 3500 封断了,重启后从 Excel 就能一眼看到断点。
十、几个真实踩过的坑
- 主题里带
【】、全角空格、过多 emoji,部分网关会直接判垃圾; - 正文只有图片、纯文本少于 100 字,垃圾过滤权重明显更高;
From显示名和服务端MAIL FROM不一致,会被当作伪造;- 一个连接发超过 1000 封,服务端可能悄悄掐断且不报错;
- 导出 CSV 用 Excel 打开时中文乱码,加 UTF-8 BOM;
- 附件名里有空格或中文,某些老网关会截断,发送前先做文件名规范化。
十一、不想自己写这套逻辑?
上面这些加起来是一个不小的工程:MIME 构造、三级附件匹配、模板渲染、退避重试、断点续传、Excel 报告,再加上发信域名的 SPF/DKIM/DMARC 配置。
如果你的目标是"把这批邮件发出去",而不是"造一个邮件群发软件",SmartMailer 把上面这些都做完了:Excel 导入收件人、精确/模糊/公司三种附件匹配方式、模板变量自动替换、间隔发送策略、断点续传、Excel 发送报告,买断制 ¥79 永久使用。下载后不注册可以先免费试 5 次完整发送,够把一轮群发从头跑到尾再决定。
自己搭这套的话,光是发信域名的 SPF/DKIM/DMARC 三件套配错就够排查两天,而且换邮箱服务商就得重来一遍。
十二、小结
邮件群发的难点不在发出去,而在每封都不一样还能稳稳送达。挑几个最影响结果的:附件一对一匹配要做成逐级回退,间隔要加随机抖动,4xx 才重试,断点续传靠按收件人唯一的 Message-ID,发信域名的三件套必须配齐。剩下的就是选一个现成的还是自己维护。