首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >邮件群发的技术难点与实现:一对一附件、模板变量与投递成功率

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

原创
作者头像
用户12787353
发布于 2026-10-05 18:10:51
发布于 2026-10-05 18:10:51
450
举报

面向后端 / 运维 / 数据处理的工程师。聊的是怎么把「给几百上千个人各发一封、内容还不一样的邮件」这件事做对。**关键词**:邮件群发软件、批量发送邮件、SMTP 群发、邮件附件一对一、邮件模板变量、邮件群发工具


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

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

代码语言:python
复制
https://geojsoncn.com/smailer/ 项目代码片段
for addr in addresses:

    server.sendmail(from\_addr, [addr], msg.as\_string())

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

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

下面按难度从低到高拆。


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

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

| 方式 | 端口 | 流程 |

|---|---|---|

| 隐式 TLS | 465 | TCP 连上就直接握手加密,协议是 SMTPS |

| 显式 TLS | 587 | 先明文连上,再用 STARTTLS 命令升级 |

代码语言:python
复制
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:

代码语言:python
复制
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. 群发 50 封,每封一个附件(唯一可行的做法);
  2. 发一封带全部附件的信,让收件人自己找(等于没有个性化,且附件总大小很快触顶)。

第 1 条带来真正的难点:怎么知道张三该收哪份附件。实践中要处理三种匹配强度,从严到宽:

代码语言:python
复制
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

模板里填变量,最直白的写法是:

代码语言:python
复制
body = TEMPLATE.format(name=name, company=company)   # 有坑

坑在于:模板里只要出现一个 CSS 的 { },或者用户公司名里带花括号,整个发送任务就抛异常中断。而且 HTML 正文里直接插值等于放任注入。

用正则替换 + 转义更稳:

代码语言:python
复制
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 的括号冲突概率低很多。


六、限速:既要慢,又要不规律

发太快是进垃圾箱的头号原因。两个手段一起上:

代码语言:python
复制
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 封的任务可中断

群发任务跑几十分钟,中间断网、关机、进程被杀都很常见。续传的关键是每封发完就把状态落盘,而不是每封都查一次数据库:

代码语言:python
复制
# 已发送集合:存 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;
  • 附件名里有空格或中文,某些老网关会截断,发送前先做文件名规范化。

十一、小结

邮件群发的难点不在发出去,而在每封都不一样还能稳稳送达。挑几个最影响结果的:附件一对一匹配要做成逐级回退,间隔要加随机抖动,4xx 才重试,断点续传靠按收件人唯一的 Message-ID,发信域名的三件套必须配齐。剩下的就是选一个现成的还是自己维护。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、为什么群发邮件比想象中难
  • 二、SMTP 连接:两个容易踩的端口
  • 三、MIME 结构决定了邮件长什么样
  • 四、核心难点:附件一对一匹配
  • 五、模板变量替换:别用 str.format
  • 六、限速:既要慢,又要不规律
  • 七、投递基础:SPF / DKIM / DMARC
  • 八、断点续传:让 5000 封的任务可中断
  • 九、发送报告:销售要的是 Excel 不是日志
  • 十、几个真实踩过的坑
  • 十一、小结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档