邮件群发的技术难点与实现:一对一附件、模板变量与投递成功率

3 阅读10分钟

面向后端 / 运维 / 数据处理的工程师。聊的是怎么把「给几百上千个人各发一封、内容还不一样的邮件」这件事做对。

关键词:邮件群发软件、批量发送邮件、SMTP 群发、邮件附件一对一、邮件模板变量、邮件群发工具


一、为什么群发邮件比想象中难

写代码的人第一次做群发,往往会写成这样:

for addr in addresses:
    server.sendmail(from_addr, [addr], msg.as_string())

能发出去。发到第 200 封的时候开始退信,第 600 封进了垃圾箱,用户投诉才发现问题。而这个功能真正的难点从来不是"把邮件发出去",是这四件事:

  1. 每封信的内容不一样。要给张三发 A 合同、给李四发 B 报价,附件也不能一样;
  2. 不能被判定为垃圾邮件。发信方域名要能被验证,频率得像个人而不是像机器;
  3. 发到一半断了要能续上。第 4000 封时笔记本合盖了,不能从头再来;
  4. 要知道哪些真的发出去了。销售要一份 Excel 对账,不是日志文件。

下面按难度从低到高拆。


二、SMTP 连接:两个容易踩的端口

连接 SMTP 有两种方式,填错端口的表现是"连上了但发不出去":

方式端口流程
隐式 TLS465TCP 连上就直接握手加密,协议是 SMTPS
显式 TLS587先明文连上,再用 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 个人"。真要实现只有两条路:

  1. 群发 50 封,每封一个附件(唯一可行的做法);
  2. 发一封带全部附件的信,让收件人自己找(等于没有个性化,且附件总大小很快触顶)。

第 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,发信域名的三件套必须配齐。剩下的就是选一个现成的还是自己维护。