
做技术的人都有过这种经历手里维护的那个发信脚本从“能发出去就行”到被业务反复加需求最后变成一坨谁都不敢动的代码。我这边的情况更典型——老 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_tlsTrue): 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, timeout10) 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( loaderFileSystemLoader(templates/), autoescapeselect_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(hostlocalhost, port6379, decode_responsesTrue) 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, permanentTrue) def worker_loop(): while True: entries r.xreadgroup(GROUP, worker1, {STREAM: }, count10, block5000) 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.comvspf1 include:spf.example-mta.com ~allinclude:spf.example-mta.com是邮件服务商提供的 SPF 片段具体值看你的服务商。注意结尾的~all是软失败-all是硬失败前期的配置用~all更稳妥避免因为配置错误把合法邮件全部挡掉。等运行稳定了再视情况收紧。DKIM 签名。如果用 OpenDKIM 或 dkimpy生成密钥对之后在 DNS 里加记录类型主机值TXTdefault._domainkey.example.comvDKIM1; krsa; p你的公钥签名选择器我用default这个值可以自定义但要跟发信服务的配置保持一致。邮件发出去之后可以在一些邮箱里点开原始邮件查看Authentication-Results头确认dkimpass。DMARC 策略。上线初期设成只统计不拦截类型主机值TXT_dmarc.example.comvDMARC1; pnone; ruamailto:dmarcexample.com; pct100pnone模式跑两周看反馈数据确认没有明显误报后再改pquarantine。这一步是很多小团队容易跳过的但正是渐进式策略才让身份认证配置不出大事故。我见过有人第一天直接上preject结果把自家正常邮件全卡在对方的垃圾箱门前后续排查非常痛苦。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_size5): 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 上线到现在跑了三个多月我最大的感受是发信这件事表面上是网络协议问题实际上全是工程问题。队列、重试、认证、观测每一项单独拿出来都不复杂但合在一起持续稳定地跑就需要在架构上提前留好位置。最后再提醒一句无论代码写得多完善发信账号的信誉才是真正的命门新域名一开始被限流是正常的不要慌耐心养稳定发送频率比什么优化都管用。