资讯动态

搭建统一邮件短信通知服务:SMTP、队列与回执实践

发布时间:2026/10/16 14:25:06 来源:尧图企业网站定制
最近在给公司内部系统搭告警通知的时候开发同学普遍反馈一个问题业务报错了不知道找谁运营同学每次要手动给用户群发通知验证码短信经常超时甚至还发不出去。于是我们把邮件和短信发送抽成了一个小型通知服务把邮件、短信两条通道统一管起来。整个过程踩了不少坑也有一些设计上的取舍我把完整的实践过程整理成这篇博文后端开发、运维同学以及准备给业务系统加通知能力的朋友都可以直接参考。1. 项目整体拆解邮件和短信发送到底要解决什么问题1.1 需求场景与核心痛点任何系统做到一定规模都会碰到“通知”这件事。监控系统报警了需要发邮件给值班的人用户注册后要收验证码得走短信订单状态变化了客户希望收到一条短信告知内部后台的一些任务跑完了也需要一封邮件带上统计结果。把这些需求拉通来看它们的本质完全一致——都是“把一段内容通过某个渠道递给目标用户”。但在很多项目里邮件和短信的实现是割裂的。邮件可能是某位同学很早之前写的一个脚本用的是个人邮箱的SMTP配置发信频率一高就被服务商限流短信则可能是另一坨代码散落在订单服务、用户服务、营销后台里每个地方各调各的供应商接口签名方式还不一样。时间一长出问题的时候根本不知道从哪里查只能在各个服务日志里翻来翻去。我这个项目的核心诉求很简单把“发送消息”这个动作收敛成一个独立服务对外提供统一接口内部再去对接具体的邮件通道和短信通道。业务方不需要关心邮件用的什么协议、短信走哪家供应商只需要告诉我“发给谁、发什么内容、什么模板”剩下的活归这个服务管。1.2 方案选型邮件服务与短信通道的取舍选型的核心原则只有一个在可控成本内优先保证到达率和稳定性。邮件这一侧首选的方案是企业邮箱的SMTP服务或者信誉度较高的邮件发送服务。自己搭建Postfix等邮件服务器不是不行但对大多数业务场景来说没必要因为自建邮局最大的问题是IP信誉。你没有历史发信记录没有正向的反馈回路发出的邮件很容易被对方邮件服务商判为垃圾邮件到达率反而比托管邮件服务差。用现成的SMTP服务本质上就是借用对方已经建立好的发信IP信誉和退信处理机制。如果要走大量营销类邮件还得考虑专门的邮件发送服务因为普通企业邮箱对发信频率、单日总量有严格限制超出之后账号会被临时冻结这个坑我后面会详细讲。短信这一侧需要先看有没有真实的发送场景。业务通知类短信必须走具备《增值电信业务经营许可证》的正规服务商个人开发者能申请的通常是测试签名和测试模板只适合联调不适合跑真实业务。选择供应商时不要只比较单价重点要关注三个东西是否提供发送状态回执、是否支持自定义签名模板、是否有完善的频率控制接口。有状态回执是硬性条件否则你发出的短信到底到没到、被运营商拦截还是用户手机号空号你全部无从得知。没有状态回执的短信服务便宜也不建议选。1.3 统一抽象把两种渠道收编成一个通知服务把多种发送渠道收编成一个服务最简单的做法是抽象出一个通知接口。我的设计里定义了一个发送请求对象至少包含以下字段channel渠道类型枚举值是email或smsto收件人邮件对应邮箱地址短信对应手机号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 noticeexample.com password your-auth-code msg MIMEText(系统监控提醒CPU 使用率超过 90%, plain, utf-8) msg[Subject] Header(【提醒】服务器负载异常, utf-8) msg[From] username msg[To] opsexample.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(), _subtypepdf) 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, dataparams, timeout5) 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%的重复骚扰问题。发送回执的处理是整个短信链路里最容易忽略的部分。供应商接口返回code0只代表“我已经受理了这个请求”不代表用户手机真的收到了短信。真正的发送结果是通过异步回调推送给你的。以验证码场景为例我配置的回执接口会接收供应商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分钟还没收到最终状态的短信请求主动调用供应商查询接口确认状态。这样做虽然增加了一点调用成本但换来的是每一次验证码请求都“有始有终”排查问题时不会再出现互相推诿的情况。通知服务看起来是个很小的模块真正做好需要花心思的地方其实比想象中多得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑