☰
发信服务重构实战:从散落脚本到可靠邮件投递系统
2026/9/28 22:34:34 网站建设 项目流程

做技术的人都有过这种经历:手里维护的那个发信脚本,从“能发出去就行”,到被业务反复加需求,最后变成一坨谁都不敢动的代码。我这边的情况更典型——老 sendmail 脚本跑了一年多,散落在三四个服务里各自调 SMTP,日志格式五花八门,队列和重试逻辑全靠自己写,坏了一封邮件要找半天。于是有了 sendmail_v2 这个重构项目,从零把发信能力收拢成一个独立服务,补齐了模板、队列、重试、认证和观测。如果你现在也是直接用 smtplib 裸发、或者在送达率上反复吃瘪,这篇东西应该能帮上忙。

做这个 v2 之前,我把需求翻来覆去想了很久。核心就一件事:让业务方调一个 HTTP 接口就能把信发出去,不用关心下面用的是谁家的 SMTP、要不要签名、发没发成功。下面我把整个项目的设计思路、实操细节、踩过的坑都写出来,给准备折腾发信服务的朋友一个参考。

1. 先捋清楚:老 sendmail 到底哪里不行了

1.1 代码散落与重复造轮子的代价

旧方案最大的问题不是慢,而是乱。用户注册要发验证码,订单要发通知,营销活动要发批量邮件,于是登录服务里有一段发信函数,订单服务里也有一段,内容高度相似但不完全一样。A 服务用了超时时间 10 秒,B 服务用了 30 秒,这个差异平时看不出来,一遇到 SMTP 抖动就分头报错,查问题的时候要在好几个服务日志之间来回跳。

更麻烦的是模板。老写法里邮件正文是字符串拼接,HTML 和纯文本各有各的坑——变量没转义、中文编码错乱、链接被截断,这些我都踩过。后来加了几个公共函数,但因为是跨服务复制粘贴,改一处漏一处。v2 的思路就简单了:所有发信逻辑收敛到一个服务,别的服务只传结构化参数,不碰邮件内容。

1.2 送达率问题的根因:认证与信誉

老脚本的送达率其实一直不高,我最初以为是内容垃圾被拦截,后来用退信日志分析才发现,大部分是身份认证问题。很多小项目发信直接拿账号密码往 SMTP 一丢就完事,没有 SPF 记录、没有 DKIM 签名、DMARC 更是别提。收件方邮件服务器看到一封既没有域名授权、又没有签名校验的邮件,最稳妥的做法就是丢进垃圾箱或者直接退信。

这个认知很关键:发信通道只是管道,信誉才是命门。同样的 IP、同样的内容,有没有正确配置 SPF/DKIM/DMARC,送达率能差出两三个数量级。这也是 sendmail_v2 里我坚持把认证信息做成可配置、可自动生成的原因,后面第 3 部分会专门说具体做法。

1.3 v2 的目标定义与取舍

立项的时候我把目标收敛成四条,避免无限扩张:

第一,统一入口。只暴露一个 HTTP 接口,任何业务都能调。第二,可靠投递。失败必须进队列重试,不能发完就忘。第三,模板外置。邮件正文用模板文件描述,业务方禁止拼接 HTML。第四,观测可视。每一封邮件从进来到送达,全链路有日志和状态可查。

哪些事不做?不自己解析退信内容,不把服务做成完全的邮件营销平台。退信解析水太深,各邮件服务商的退信格式差异很大,v2 只需要把原始退信内容和状态码存下来,后续人工或规则决策。这样项目才能在一两周内落地,而不是陷入无限复杂的泥潭。

2. 架构设计与核心链路

2.1 模块划分:发送、模板、队列、观测

sendmail_v2 是单体服务,但我从代码层面做了严格分区。发送器只负责和 SMTP/API 通信,模板器负责把参数和模板渲染成最终正文,队列器负责存储和调度待发任务,观测模块负责记录日志和指标。有人可能会问,一个发信服务为什么要把队列单独拆出来?答案是削峰和重试。

业务方调用发信接口时,如果 SMTP 刚好在抖动,同步阻塞会让调用方页面卡死。有了队列,请求进来先落库/落 Redis,立刻返回“已受理”,后台 worker 慢慢消费。这一设计带来的另一个好处是重试自然发生:失败的任务留在队列里,按策略重新投递,不需要业务方关心。

2.2 发信通道抽象:SMTP 与 API 的统一

市面上发信渠道很多,SMTP 是最基础通用的,但一些邮件服务商也提供 HTTP API。v2 做的抽象层其实很简单:定义一个发信通道接口,包含 send(mail) 方法,底下各有 SMTPChannel 和 ApiChannel 两个实现。配置里指定默认走哪条通道,也可以按邮件类型动态选择——比如触发类邮件走自家 SMTP,营销类走第三方 API,各有各的用途。

这个抽象带来的好处在后来的日常维护中非常明显。某服务商调整了接口签名,或者某个 SMTP 账号要迁移,我只需要动对应通道的实现,业务方完全无感。如果你也打算做发信服务,这一步千万别省,哪怕现在只有一个通道也要把接口隔离做出来。

2.3 为什么必须要有队列:削峰与重试

关于队列,再展开细说一层。发信场景跟普通接口请求不一样,它的失败模式特别多:连接超时、TLS 握手失败、对方服务器 450/451 临时错误、554 永久拒绝、连接被重置,等等。这些失败不全是真失败,相当一部分是临时状态,过几分钟重试就能成功。没有队列,这些逻辑都得写在业务代码里,最后每个调用方都写一套重试,谁也不统一。

队列在 v2 里选型绕了一下。一开始想用数据库表,简单可靠,但轮询起来压力不小。后来选了 Redis Stream,它天然支持消息确认(ACK)和 pending 队列,配合 consumer group 很容易实现并发消费。生产环境实测下来很稳,出问题的时候也能用XINFO、XPENDING命令快速看清积压状态。如果你没有 Redis,用 MySQL/SQLite 表加状态字段也完全够用,核心是状态机要清晰。

3. 亲手把 sendmail_v2 搭起来

3.1 基础环境与目录规划

我用的技术栈是 Python 3.11 + FastAPI + Redis + SQLite(邮件记录持久化)。目录结构大概是这样的:

sendmail_v2/ ├── app/ │ ├── api.py # HTTP 入口 │ ├── channel/ # SMTP/API 通道实现 │ ├── template/ # 模板加载与渲染 │ ├── queue/ # Redis Stream 生产/消费 │ ├── dns/ # SPF/DKIM/DMARC 配置辅助 │ └── metrics.py # 日志和指标记录 ├── templates/ # 邮件模板目录 ├── config.yaml # 主配置 └── main.py # 启动入口

目录规划这一步看起来不起眼,但对项目健康度影响很大。做的时候想清楚哪里放什么,后面扩功能就不用搬家了。

3.2 SMTP 发送器实现:TLS、认证与超时

SMTP 发送器是整个服务的心脏,里面最容易出问题的是超时时间。老脚本经常出现 CPU 占满、连接僵死,多半是 socket 层超时没设好。v2 里所有网络操作都显式指定了超时:

import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart class SMTPChannel: def __init__(self, host, port, username, password, use_tls=True): self.host = host self.port = port self.username = username self.password = password self.use_tls = use_tls def send(self, mail): msg = MIMEMultipart("alternative") msg["Subject"] = mail.subject msg["From"] = mail.from_addr msg["To"] = mail.to_addr msg["Message-ID"] = mail.message_id # 先附加纯文本,再附加 HTML msg.attach(MIMEText(mail.plain_body, "plain", "utf-8")) msg.attach(MIMEText(mail.html_body, "html", "utf-8")) with smtplib.SMTP(self.host, self.port, timeout=10) as server: server.ehlo() if self.use_tls: server.starttls() server.ehlo() server.login(self.username, self.password) server.sendmail(mail.from_addr, [mail.to_addr], msg.as_string())

这里有几个细节值得说。Message-ID必须显式生成,格式建议是<唯一标识@发信域名>,它不仅是邮件标头规范,也是去重和退信追踪的依据。starttls之后一定要再ehlo()一次,这是 smtplib 的老坑,不重新打招呼某些服务器会拒绝发信。超时设 10 秒够用,重试逻辑里已经考虑到长耗时问题。

3.3 模板渲染与变量注入

模板部分我用 Jinja2,但加了一个约束:业务方只能传扁平化的字典参数,模板里不允许执行任意代码。这样既保证了灵活性,又避免了模板注入导致的潜在风险。

模板文件长这样:

Subject: [示例应用] 你的验证码是 {{ code }} Hi {{ name }}, 你的注册验证码是 {{ code }},5 分钟内有效。 如果这不是你的操作,请忽略本邮件。

渲染函数需要对变量做 HTML 转义,防止调用方传入的内容破坏页面结构。Jinja2 默认的autoescape只对 HTML 文件开启,我显式处理了:

from jinja2 import Environment, FileSystemLoader, select_autoescape env = Environment( loader=FileSystemLoader("templates/"), autoescape=select_autoescape(("html", "htm")) ) def render_template(template_name, params: dict): template = env.get_template(template_name) return template.render(**params)

模板目录下的文件按用途分文件夹,比如auth/、order/、marketing/,每个模板第一行用注释标明需要哪些参数,这样业务方对接时看着模板就能知道传什么。别小看这个约定,它让我后续接新业务时几乎不需要问需求细节。

3.4 队列与重试机制实现

进入队列的部分。我把发信请求先写进 Redis Stream,然后由消费者处理。用 Redis Stream 的好处是消息可以被确认,消费者崩了之后消息会回到 PENDING 列表,重启后继续处理。

消费者核心逻辑:

import json, time import redis r = redis.Redis(host="localhost", port=6379, decode_responses=True) STREAM = "email:queue" GROUP = "sendmail_v2_workers" def process_message(entry): mail = json.loads(entry["data"]) try: channel = get_channel(mail["channel"]) channel.send(mail) r.xack(STREAM, GROUP, entry["id"]) record_success(mail) except TemporaryFailureError: # 临时失败,重试 schedule_retry(mail, entry["id"]) except PermanentFailureError: record_failure(mail, permanent=True) def worker_loop(): while True: entries = r.xreadgroup(GROUP, "worker1", {STREAM: ">"}, count=10, block=5000) for stream, messages in entries: for msg in messages: process_message(msg)

重试策略我用指数退避。退避序列是 1 分钟、5 分钟、15 分钟、1 小时、6 小时、24 小时,最多重试 6 次。具体的实现是在原始消息里带上retry_count,重试时把id写进另外一个延迟队列email:retry,到达执行时间后再塞回主队列。延迟队列的功能足够简单,我在 Redis 里用 Sorted Set 实现——score是执行时间戳,消费者轮询当前时间到了哪些消息,把它们挪回主队列。

重试条件我做了明确区分。连接超时、5xx 以外的暂时错误(450、451、452 等)都算临时失败。而 554、账号认证失败、收件人地址格式非法,属于永久失败,直接记录原因,不再重试。这个区分特别重要,否则一个非法地址会在队列里占着位置反复重试,严重拖慢整体发送速度。

3.5 SPF、DKIM、DMARC 配置实战

这块可能是对送达率提升最明显的部分。老项目完全没有身份认证,v2 上线前第一件事就是补齐三条 DNS 记录。下面我用 example.com 做示例。

SPF 记录,在 DNS 的 TXT 记录里加一行:

类型主机值
TXTexample.comv=spf1 include:spf.example-mta.com ~all

include:spf.example-mta.com是邮件服务商提供的 SPF 片段,具体值看你的服务商。注意结尾的~all是软失败,-all是硬失败,前期的配置用~all更稳妥,避免因为配置错误把合法邮件全部挡掉。等运行稳定了再视情况收紧。

DKIM 签名。如果用 OpenDKIM 或 dkimpy,生成密钥对之后在 DNS 里加记录:

类型主机值
TXTdefault._domainkey.example.comv=DKIM1; k=rsa; p=你的公钥

签名选择器我用default,这个值可以自定义但要跟发信服务的配置保持一致。邮件发出去之后,可以在一些邮箱里点开原始邮件查看Authentication-Results头,确认dkim=pass。

DMARC 策略。上线初期设成只统计不拦截:

类型主机值
TXT_dmarc.example.comv=DMARC1; p=none; rua=mailto:dmarc@example.com; pct=100

p=none模式跑两周,看反馈数据,确认没有明显误报后再改p=quarantine。这一步是很多小团队容易跳过的,但正是渐进式策略,才让身份认证配置不出大事故。我见过有人第一天直接上p=reject,结果把自家正常邮件全卡在对方的垃圾箱门前,后续排查非常痛苦。

4. 避坑实录:我在 sendmail_v2 里踩过的坑

4.1 连接超时与连接池:一个差点翻车的问题

服务上线第二周,我收到报警:发信耗时从平均 300ms 涨到了 5 秒以上,队列积压迅速。排查下来发现是 SMTP 连接没有复用,每封邮件都新建连接,而服务商那边限制了单 IP 的连接频率,导致大量 TCP 握手超时。解决办法是加连接池,把同一个 SMTP 账号的连接复用起来。

from smtplib import SMTP import threading class SMTPPool: def __init__(self, host, port, username, password, pool_size=5): self._pool = [] self._lock = threading.Lock() self._config = (host, port, username, password, pool_size) def get_connection(self): with self._lock: if self._pool: return self._pool.pop() return self._new_connection() def release(self, conn): with self._lock: if len(self._pool) < self._config[4]: self._pool.append(conn) else: conn.quit()

这个池子的实现不算复杂,但带来的收益立竿见影,发信耗时回到了 300ms 以内。如果你用 Java 或 Go,相应的库也有成熟的连接池组件,不建议自己从头写。

4.2 退信与投诉处理:不要只看状态码

队列跑了一阵子,我开始接到收件方的投诉,说某些邮件被判定为垃圾邮件。查日志发现状态码全是 250,说明 SMTP 层面投递成功,但对方内容过滤器在收下邮件之后又做了二次判定。这时候再改 DNS 配置已经没有意义,问题出在内容质量。

我总结了三个方向的整改。第一,纯文本和 HTML 必须都提供,只发 HTML 的邮件垃圾特征太明显。第二,HTML 结构要简洁,图片不要直接远程引用,重要信息用文字呈现。第三,控制营销类邮件的发送频率和内容相似度,大量内容相同的邮件很容易触发批量检测。另外,一定要设置退信处理入口,收件人的postmaster或mailer-daemon会给你发 NDR,里面带着退信原因。

4.3 批量发送性能调优:并发数与限流

营销邮件批量发送时,我一开始图快,直接把 worker 并发开到 20,结果 SMTP 服务商开始拒绝连接。后来了解到同一账号并发连接数通常有限制,一般的 SMTP 服务商只允许 2~5 个并发连接。我把 worker 并发控制在 3,并根据目标服务商的限制进行配置化调整。

关于批量发送,还有一个容易忽略的点:发件人信誉是慢慢养出来的。新域名刚开始一天发几百封可能就触发速率限制,但跑两周之后日发送量可以逐步提高到几万封。所以 v2 的配置里加了一个daily_quota和sending_rate字段,用令牌桶做限流,宁可慢一点,也不要一口气把信誉打没。

最后再分享一个实际操作中的体会

sendmail_v2 上线到现在跑了三个多月,我最大的感受是:发信这件事,表面上是网络协议问题,实际上全是工程问题。队列、重试、认证、观测,每一项单独拿出来都不复杂,但合在一起持续稳定地跑,就需要在架构上提前留好位置。最后再提醒一句,无论代码写得多完善,发信账号的信誉才是真正的命门,新域名一开始被限流是正常的,不要慌,耐心养,稳定发送频率比什么优化都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询