☰
搭建统一邮件短信通知服务:SMTP、队列与回执实践
2026/10/16 14:24:52 网站建设 项目流程

最近在给公司内部系统搭告警通知的时候,开发同学普遍反馈一个问题:业务报错了不知道找谁,运营同学每次要手动给用户群发通知,验证码短信经常超时甚至还发不出去。于是我们把邮件和短信发送抽成了一个小型通知服务,把邮件、短信两条通道统一管起来。整个过程踩了不少坑,也有一些设计上的取舍,我把完整的实践过程整理成这篇博文,后端开发、运维同学,以及准备给业务系统加通知能力的朋友都可以直接参考。

1. 项目整体拆解:邮件和短信发送到底要解决什么问题

1.1 需求场景与核心痛点

任何系统做到一定规模,都会碰到“通知”这件事。监控系统报警了,需要发邮件给值班的人;用户注册后要收验证码,得走短信;订单状态变化了,客户希望收到一条短信告知;内部后台的一些任务跑完了,也需要一封邮件带上统计结果。把这些需求拉通来看,它们的本质完全一致——都是“把一段内容通过某个渠道递给目标用户”。

但在很多项目里,邮件和短信的实现是割裂的。邮件可能是某位同学很早之前写的一个脚本,用的是个人邮箱的SMTP配置,发信频率一高就被服务商限流;短信则可能是另一坨代码,散落在订单服务、用户服务、营销后台里,每个地方各调各的供应商,接口签名方式还不一样。时间一长,出问题的时候根本不知道从哪里查,只能在各个服务日志里翻来翻去。

我这个项目的核心诉求很简单:把“发送消息”这个动作收敛成一个独立服务,对外提供统一接口,内部再去对接具体的邮件通道和短信通道。业务方不需要关心邮件用的什么协议、短信走哪家供应商,只需要告诉我“发给谁、发什么内容、什么模板”,剩下的活归这个服务管。

1.2 方案选型:邮件服务与短信通道的取舍

选型的核心原则只有一个:在可控成本内,优先保证到达率和稳定性。

邮件这一侧,首选的方案是企业邮箱的SMTP服务,或者信誉度较高的邮件发送服务。自己搭建Postfix等邮件服务器不是不行,但对大多数业务场景来说没必要,因为自建邮局最大的问题是IP信誉。你没有历史发信记录,没有正向的反馈回路,发出的邮件很容易被对方邮件服务商判为垃圾邮件,到达率反而比托管邮件服务差。用现成的SMTP服务,本质上就是借用对方已经建立好的发信IP信誉和退信处理机制。如果要走大量营销类邮件,还得考虑专门的邮件发送服务,因为普通企业邮箱对发信频率、单日总量有严格限制,超出之后账号会被临时冻结,这个坑我后面会详细讲。

短信这一侧,需要先看有没有真实的发送场景。业务通知类短信必须走具备《增值电信业务经营许可证》的正规服务商,个人开发者能申请的通常是测试签名和测试模板,只适合联调,不适合跑真实业务。选择供应商时不要只比较单价,重点要关注三个东西:是否提供发送状态回执、是否支持自定义签名模板、是否有完善的频率控制接口。有状态回执是硬性条件,否则你发出的短信到底到没到、被运营商拦截还是用户手机号空号,你全部无从得知。没有状态回执的短信服务,便宜也不建议选。

1.3 统一抽象:把两种渠道收编成一个通知服务

把多种发送渠道收编成一个服务,最简单的做法是抽象出一个通知接口。我的设计里定义了一个发送请求对象,至少包含以下字段:

  • channel:渠道类型,枚举值是email或sms
  • to:收件人,邮件对应邮箱地址,短信对应手机号
  • template_id:模板编号,业务方只能选模板,不能随意拼内容
  • template_params:模板变量,JSON对象,填充模板占位符
  • biz_id:业务方自己生成的幂等ID,用于去重和追踪

接口内部根据channel走相应的处理器。这样做的好处是业务侧感知不到渠道差异,后续如果增加新的渠道(比如站内信、App推送),只需要新增一个处理器,不需要改动业务方代码。对业务方来说,通知的调用方式完全一致,降低了接入成本。

2. 邮件发送:把一封邮件从开发机送到收件人收件箱

2.1 环境准备与基础配置

邮件发送最常见的实现方式是SMTP。我这次用的是Python来写发送客户端,方便快速迭代,后面要集成到现有技术栈里也容易。Python标准库里的smtplib就可以完成这件事,不需要引入重量级框架,简单场景下够用。

第一步是确认SMTP服务的连接参数。不同的邮件服务商参数不太一样,但核心就四项:SMTP服务器地址、端口、账号名和授权码。端口这里要单独说一下:25端口是传统的SMTP端口,很多云服务器出于安全考虑默认封禁25端口,如果你在服务器上部署,发信一直超时,先检查端口是不是被封了。尽可能用465端口(SSL加密)或者587端口(STARTTLS加密),这两个端口在服务器上普遍是放行的,而且传输过程加密,不容易被中间节点篡改内容。

用smtplib发送一封基础文本邮件的代码如下:

import smtplib from email.mime.text import MIMEText from email.header import Header smtp_host = "smtp.example.com" smtp_port = 465 username = "notice@example.com" password = "your-auth-code" msg = MIMEText("系统监控提醒:CPU 使用率超过 90%", "plain", "utf-8") msg["Subject"] = Header("【提醒】服务器负载异常", "utf-8") msg["From"] = username msg["To"] = "ops@example.com" with smtplib.SMTP_SSL(smtp_host, smtp_port) as server: server.login(username, password) server.sendmail(username, [msg["To"]], msg.as_string())

注意From字段是有讲究的。尽量让“发件人显示名”和实际发件账号一致,我见过有人用A账号发送,却把From填成B邮箱,结果部分收件方服务商直接判为伪造发件人,邮件被静默丢弃。授权码也不是邮箱登录密码,需要在邮箱设置里单独开启SMTP服务并生成专用授权码,直接用登录密码容易被服务商拒绝,也容易泄露账号风险。

2.2 模板化的HTML邮件与附件处理

纯文本邮件能覆盖告警类场景,但到了运营通知、周报汇总这类场景,就需要HTML邮件。HTML邮件写起来有一个老生常谈但极其重要的原则:不要用复杂CSS布局,尽量用表格和内联样式。微软Outlook、网易邮箱、QQ邮箱的网页端对CSS支持程度完全不同,<div>布局在某些客户端里会整体错位,而<table>布局虽然老土,但在邮件客户端里的兼容性是最好的。

图片资源尽量用网络URL,或者用MIME的multipart/related方式把图片作为内嵌资源带上。不要使用<img>引用外部服务器上的临时图片链接,因为很多邮件客户端默认不加载外部图片,用户看到的就是一堆裂图。内嵌图片的方式比较复杂,如果只是简单的Logo,直接在模板里用Base64内联是最省事的方案。

带附件的邮件需要用到email.mime.multipart.MIMEMultipart。发送PDF报告、Excel导出结果这类附件时,附件文件名要避免中文直接拼进Content-Disposition头里,否则部分客户端会出现乱码。正确做法是使用Header类对文件名做编码:

from email.mime.application import MIMEApplication with open("report.pdf", "rb") as f: part = MIMEApplication(f.read(), _subtype="pdf") part.add_header("Content-Disposition", "attachment", filename=("utf-8", "", "月度报告.pdf")) msg.attach(part)

这里filename用元组形式传入,邮件库会自动生成符合RFC标准的编码文件名,绝大多数客户端都能正确显示中文名称。

2.3 用队列把发信能力拉稳

最开始的实现里,业务方调用发送接口,我的服务同步去连接SMTP服务器发信,发完才返回。这个设计在低并发时没什么问题,但一旦遇到大批量群发,同步调用的响应时间会拉得非常长,调用方一直在等待,甚至直接超时。更糟糕的是,如果SMTP服务商临时限流,同步调用会大量堆积,把业务服务本身拖垮。

解决方式很简单:引入队列。发送请求先写入持久化队列,后台worker从队列中拉取任务然后异步发送。当时项目里没有额外引入消息队列组件,我就用数据库表模拟了一个简单的任务队列,表结构大致是任务ID、渠道类型、接收方、内容模板、状态、创建时间、重试次数、下一次执行时间。worker每秒钟轮询一次,取出到期的待发送任务,发送成功后把状态改为已完成。

这一步的价值不只是削峰,还给了我们重试的能力。邮件服务商偶尔会有5xx临时性错误,比如连接超时、被限流;短信通道偶尔也会抖动。有了持久化队列,这些失败任务可以设置指数退避重试:第一次失败等1分钟重试,第二次失败等5分钟,第三次失败等15分钟,最多重试5次。超过重试次数仍然失败的,状态标记为“最终失败”,并写入失败原因,方便后续人工介入。

3. 短信发送:从申请资质到完成通道对接

3.1 短信供应商的选择与对接准备

短信和邮件有一个很大的区别:短信通道不是你自己搭出来的,而是完全依赖供应商。所以你选哪家供应商、怎么配置签名模板,几乎决定了短信到达率的上限。

正规的短信服务对接前要做三件准备工作:申请签名、申请模板、确认回执地址。签名是指短信内容里那一对【】里的名字,比如【某某科技】,代表发送方身份。签名必须经过供应商和运营商审核,企业类签名通常需要提供营业执照信息,个人开发者则较难通过企业签名的审核。模板则是短信正文的格式定义,比如“您的验证码为${code},${minutes}分钟内有效,请勿泄露。”云服务商平台一般要求模板通过审核后才能发送。

这里有个经验:模板变量尽量少,文案尽量固定。有些平台会把“验证码类”和“通知类”短信区分审核,而且强变量模板(比如整段正文都是用户自定义内容)几乎不可能过审。这是出于安全考虑,也意味着业务方不能把短信当作自由文本通道来用,只能在模板的框架内填变量。

选供应商的时候,不要只看官网页面上写的价格。实际计算成本时,要把到达率、回执及时性、故障时的工单响应速度都算进去。按验证码场景来算,如果A平台便宜1分钱但到达率只有95%,B平台贵1分钱但到达率99.5%,对用户来说发丢的验证码造成的流失率,完全值得多付那1分钱。

3.2 封装一个短信客户端

供应商接口风格大同小异,基本流程是组织参数、签名、发送HTTP请求、解析响应。我用Python封装了一个简单的客户端,把签名逻辑和回调解析逻辑都封装在内部,业务侧暴露两个方法:发送单条、批量发送。

以某主流短信服务商的API风格为参考,签名方式通常是使用AccessKey和AccessSecret,把请求参数按字典序排序拼接,再用HMAC-SHA256算法计算签名。核心逻辑如下:

import requests import hashlib import hmac import json import time class SmsClient: def __init__(self, endpoint, access_key, access_secret): self.endpoint = endpoint.rstrip("/") self.access_key = access_key self.access_secret = access_secret def _sign(self, params): sorted_keys = sorted(params.keys()) query_string = "&".join( f"{k}={params[k]}" for k in sorted_keys ) sign_string = f"{self.access_key}{query_string}{self.access_secret}" return hashlib.sha256(sign_string.encode("utf-8")).hexdigest() def send_sms(self, phone, template_code, template_params): timestamp = str(int(time.time())) params = { "access_key": self.access_key, "phone": phone, "template_code": template_code, "template_params": json.dumps(template_params), "timestamp": timestamp, } params["sign"] = self._sign(params) resp = requests.post(f"{self.endpoint}/api/sms/send", data=params, timeout=5) data = resp.json() if data.get("code") != "0": raise RuntimeError(f"短信发送失败: {data}") return data["request_id"]

这里有一个容易被忽视的细节:template_params必须是JSON字符串,而不是Python对象。很多接口文档里写的是“Map类型”,但实际HTTP传输时是一段序列化后的JSON文本,变量值里如果含有&、=等字符,必须对参数值做URL编码,否则拼接签名时参数会被截断,服务端验签永远失败。

3.3 频控、幂等与发送回执的处理

短信接口对接完成之后,真正的考验是发送策略。业务验证码场景有个高频问题:用户连续点了多次“获取验证码”,如果每次都真的发出去一条短信,用户手机瞬间收到三四条验证码,既影响体验又浪费成本。

所以在短信发送服务里,我加了两个控制层。第一层是频控:同一手机号发送间隔最小60秒,同一手机号单日发送上限20条,验证码模板单日上限10条。第二层是幂等:业务方每次请求都带着biz_id,服务端在数据库中记录该biz_id是否已经处理过,如果重复请求直接返回之前的结果,不产生新的发送动作。这两个控制看起来简单,但能避免掉90%的重复骚扰问题。

发送回执的处理是整个短信链路里最容易忽略的部分。供应商接口返回code=0只代表“我已经受理了这个请求”,不代表用户手机真的收到了短信。真正的发送结果是通过异步回调推送给你的。以验证码场景为例,我配置的回执接口会接收供应商POST过来的状态推送,状态码为DELIVRD代表用户收到,UNDELIV代表无法送达,还有各种运营商侧的拦截状态码。

这里强烈建议做一个“最终状态逾期未达”的兜底逻辑。假设发送请求发出后5分钟内,既没有收到成功回执,也没有收到失败回执,你需要主动调用供应商的查询接口,核对这条短信的最终状态。因为回调消息本身也可能丢失,不主动查询你就永远不知道这条短信到底怎么了,用户那边等不到验证码,你这边看日志却显示“已受理”,问题排查无从下手。

4. 上线后的常见问题与排查技巧实录

4.1 邮件进了垃圾箱怎么破

这个问题的出现频率极高,基本每个搭过邮件发送服务的人都遇到过。明明用企业邮箱的SMTP发信,发送方、收件方邮箱都正常,但就是有一批邮件被对方收进了垃圾箱。

第一反应先检查发信域名有没有配置SPF和DKIM记录。SPF记录告诉收件方“我这个域名只允许指定的服务器代发邮件”,DKIM记录则是对邮件内容做数字签名,两者都是邮件身份验证的核心机制。没有这两个记录,收件方邮件服务商很容易把来源判定为伪造邮件。可以在DNS管理平台里加一条TXT记录,内容指向你的发信服务商提供的那段授权文本。配置完成后,最好用在线邮件诊断工具测试一下评分,顺便检查是否还有其它扣分项。

第二个常见原因是发信频率。同一账号短时间内向同一邮件服务商发送大量邮件,比如每分钟超过几十封,对方的反垃圾策略会直接触发限流。表现就是前面几封能正常到达,后面突然全部进了垃圾箱,甚至直接被退信。解决方式就是前面提到的队列削峰,同时把发信账号分散开,多个账号轮询发送,把单账号的发送压力降下来。

第三个原因是正文内容。包含大量营销词汇(“免费”“促销”“立即购买”)、没有退订链接、纯图片无文字,这些都会增加被判定为垃圾邮件的概率。业务内容用词尽量克制,能发文本就不发HTML,能发简单布局就不堆复杂样式。

4.2 短信发送失败但接口返回成功

这是短信场景下最让人抓狂的问题。一开始我也以为供应商返回发送成功就万事大吉,直到有一次运营反馈用户收不到验证码,排查到供应商后台才发现,那条短信处理结果是“模板变量格式错误”。

造成这种局面通常有三种原因。第一种是模板变量和实际内容不匹配,比如模板定义的是${code},你传的JSON里变量名写成了${verify_code},服务端受理时忽略不了这条消息,返回业务错误,但有些平台的响应体里这个错误码不显眼,容易被误读为“已进入发送队列”。第二种是通道侧内容审核失败,比如短信中包含“转账”“汇款”等金融敏感词,被运营商拦截。这种情况通常要等状态回执推回来才知道,而不是发送接口返回时就知道。第三种是手机号本身的问题,空号、停机、携号转网未同步,都会导致下行失败,也只有回执能揭示真实原因。

排查的时候不要只看自己的服务日志,要看三个地方:自己的日志、供应商后台的发送记录、供应商回调记录。三方数据比对,能快速定位问题出在哪个环节。从我的经验来看,大量的“假成功”问题都要靠回执数据分析才能发现,所以回执接口的稳定性极其重要。

4.3 消息丢失与延迟排查

有一次线上收到反馈说验证码短信延迟了将近十分钟才收到,但查看生产环境日志,发送请求和供应商受理都在秒级完成。进一步排查才发现,消息卡在了队列消费环节:worker进程处理完一批邮件发送任务后,线程池里的线程数设置得过大,数据库连接池被占满,后续的短信任务排队等着拿连接。

延迟问题的排查思路一般是分段打点。我把整条链路的耗时拆成四段:业务方请求到达时间、任务入队时间、worker消费时间、第三方响应时间。每一段都记录下来,定位延迟到底发生在哪一段。如果业务方请求到达时间和入队时间差很多,说明队列积压;如果入队时间和消费时间差很多,说明worker消费能力不足;如果消费到第三方响应时间差很多,那就要找供应商沟通了。

另一个容易忽略的问题是进程重启时的任务丢失。如果worker从数据库表里取到任务后马上标记为“发送中”,然后进程在这个节点崩溃,那么这条消息就永远停留在“发送中”状态,不会再被消费。我的解决方法是定期扫描“发送中”超过5分钟的任务,把它们重新标记为“待发送”并增加重试次数,保证消息不会因为进程重启而彻底丢失。

4.4 监控与日志的设计要点

通知服务本身如果挂了,业务方甚至都不会注意到,但用户收不到验证码的投诉会直接涌过来。所以对通知服务做监控,比做业务接口监控的意义更大。

我在通知服务里加了三个基础监控:发送成功率、发送延迟、队列积压数量。发送成功率按渠道和模板两个维度统计,低于阈值就发告警邮件——注意这里的告警邮件走的是另一套独立的发送通道,不能和业务发信用同一套通道,否则通道故障的时候你根本收不到告警。

日志字段也要刻意设计。每条发送记录需要有biz_id、channel、template_id、to、状态、结束时间、第三方消息ID。每一个字段都不是随便加的:biz_id用来幂等去重,template_id用来统计模板使用情况,to帮你定位特定用户的问题,第三方消息ID是找供应商客服时最关键的索引字段。日志字段不带上下文,等到排查问题的时候再去找那就被动了。

把排查过程中常见的现象和原因做成一个速查表,放在团队文档里,能省掉大量重复沟通的时间。这里整理一份精简版供参考:

现象常见原因排查方向
邮件发送成功但收不到SPF/DKIM未配置,或账号被限流查DNS解析记录、邮件服务商发信记录
邮件退信率高收件地址无效,或者发件IP被列入黑名单查退信代码,用诊断工具检查域名信誉
短信接口返回成功但状态失败模板变量不匹配、内容含敏感词、手机号异常查询供应商后台发送详情、回执数据
短信延迟严重业务侧队列堆积、供应商通道拥堵对比分段耗时日志,确认延迟发生段
消息重复发送业务方未传幂等ID,或重试逻辑未做去重检查biz_id是否全程传递一致

最后再分享一个实际踩坑后的调整:我们最初把短信发送成功回执当作终点,后来发现回执也有到达率问题,部分回调会延迟甚至丢失。现在我会在服务里加一个“回执超时监测”定时任务,对每个发出超过10分钟还没收到最终状态的短信请求,主动调用供应商查询接口确认状态。这样做虽然增加了一点调用成本,但换来的是每一次验证码请求都“有始有终”,排查问题时不会再出现互相推诿的情况。通知服务看起来是个很小的模块,真正做好,需要花心思的地方其实比想象中多得多。

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

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

立即咨询