ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Python实现邮件与短信通知:SMTP协议与HTTP API全解析

Python实现邮件与短信通知:SMTP协议与HTTP API全解析 1. 先看本质发送邮件和发短信其实是两类完全不同的代码1.1 业务场景谁的代码里需要“发邮件”和“发短信”说句实在话你系统里其他功能做得再花哨用户可能都感知不到但一封验证码邮件没到、一条告警短信没收到用户立刻就会找上门。所以“发送邮件”和“发送短信”的代码往往是整个项目里最不显眼、却最不能出差错的部分。哪些场景需要这类代码我粗粗列一下你肯定不陌生用户注册、找回密码、二次验证时发验证码系统监控报警服务器CPU飙高、磁盘快满、服务宕机时通知运维电商订单状态变更下单成功、发货、退款到账定时任务执行完毕后把报表或结果推给负责人用户触达账单提醒、活动通知、日报周报这类需求几乎是所有后端系统的“基础设施”。不管你是做电商、做物联网平台、做企业内部系统还是写爬虫脚本和量化策略早晚都要碰。特别是这两年物联网设备普及短信通道成了设备上下线、异常报警的主要通知手段代码里少不了一个稳定的发送模块。1.2 技术路径差异SMTP协议 vs HTTP API很多新手容易把“发邮件”和“发短信”混为一谈以为都是一个函数搞定的事。实际上这两者的技术路径完全不同理解这一点对后面写代码至关重要。邮件走的是SMTP协议Simple Mail Transfer Protocol简单邮件传输协议。你的代码扮演的是一个“发件客户端”的角色通过TCP连接到邮件服务商的SMTP服务器经过认证后把邮件内容和收件人地址交给服务器由它负责投递到对方的邮箱系统。这个过程是标准化的你只要选一家邮件服务商比如QQ邮箱、网易邮箱、Outlook或者自己运维的Postfix拿到服务器地址、端口和账号凭证代码就能跟它对话。短信则完全不一样。国内个人用户几乎不可能直接对接运营商的短信网关——因为那是电信级的协议CMPP、SGIP等需要企业资质、通道备案还得跟运营商对接审核个人根本租不到通道。所以实际项目中我们走的都是服务商提供的HTTP API你把手机号、短信内容、签名、模板ID发给服务商服务商内部的系统再帮你向运营商通道提交下发。对你来说代码层面就是一次普通的HTTP请求只是多了一套签名鉴权和内容审核机制。一句话总结写发邮件的代码你是在跟SMTP服务器打交道写发短信的代码你是在跟云服务商的HTTP接口打交道。协议、鉴权方式、错误处理逻辑完全不同别指望一套代码通吃。2. 把邮件发出去SMTP、授权码与Python完整代码2.1 邮件发送的最小链路先讲邮件。SMTP发送邮件的完整链路并不复杂你的代码只需要完成三件事建立TCP连接用SSL或TLS加密用账号密码或授权码完成认证调用 sendmail 或类似方法提交发件人、收件人和邮件内容选型上我推荐Python的smtplib加email标准库。smtplib负责跟SMTP服务器对话email.mime系列负责构造邮件内容正文、附件、HTML。这两个全是Python自带的不需要额外安装第三方包线上环境部署最省心。服务器地址和端口需要记住QQ邮箱是smtp.qq.com网易是smtp.163.com常见端口有25明文很多云厂商默认封禁、465SSL加密、587STARTTLS。我强烈建议用465或587别用25。25端口在云服务器上被运营商和云平台广泛封禁你本地调试能通、上了服务器就超时多半就是这个原因。2.2 完整代码支持HTML、附件、多个收件人下面给一个可以直接抄走的函数。我特意把支持项做全了文本/HTML正文、附件、多收件人、中文文件名覆盖绝大多数业务需求。import smtplib import os from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.mime.application import MIMEApplication from email.header import Header from email.utils import formataddr def send_email( smtp_host: str, smtp_port: int, username: str, password: str, # 这里传授权码不是登录密码 from_name: str, to_list: list, subject: str, body_text: str , body_html: str , attachments: list None # 元素为本地文件路径 ): msg MIMEMultipart(mixed) # 发件人和收件人信息 msg[From] formataddr((str(Header(from_name, utf-8)), username)) msg[To] ,.join(to_list) # 中文标题必须用 Header 处理否则必现乱码 msg[Subject] Header(subject, utf-8) # 纯文本部分 if body_text: msg.attach(MIMEText(body_text, plain, utf-8)) # HTML部分有的客户端优先展示 HTML if body_html: msg.attach(MIMEText(body_html, html, utf-8)) # 附件处理 for file_path in (attachments or []): if not os.path.exists(file_path): continue filename os.path.basename(file_path) with open(file_path, rb) as f: part MIMEApplication(f.read()) # 中文附件名要用 RFC2231 格式避免乱码 part.add_header( Content-Disposition, attachment, filename(utf-8, , filename) ) msg.attach(part) # 建立SSL连接并发送 server smtplib.SMTP_SSL(smtp_host, smtp_port, timeout10) server.login(username, password) server.sendmail(username, to_list, msg.as_string()) server.quit()调用方式就是填上你自己的邮箱服务器参数send_email( smtp_hostsmtp.qq.com, smtp_port465, usernameyournameqq.com, password授权码, from_name监控告警系统, to_list[adminexample.com], subject磁盘使用率超过90%, body_text请尽快处理。/n服务器 /root 使用率 91%。, )2.3 为什么是“授权码”而不是密码我见过太多人第一次写邮件发送代码时直接拿邮箱登录密码去认证结果报SMTPAuthenticationError然后一脸懵。原因很简单主流邮箱服务商早就禁止第三方客户端使用登录密码登录SMTP了。你需要去邮箱设置里开启SMTP服务然后生成一个专属的“授权码”。这个授权码只用于邮件客户端和第三方程序登录泄露了也能单独吊销不影响邮箱主密码安全。拿QQ邮箱举例设置 → 账号 → 开启POP3/SMTP服务会给你一串16位的授权码。网易类似在设置里找“客户端授权密码”。这里有个实践细节授权码里有空格还是没空格复制粘贴时极易出错。我习惯在代码里先打印出来做一次登录测试确认无误后再写进真正的配置而且授权码不要写死在代码里放环境变量或配置中心否则下次别人拿到你仓库源码你的邮箱就被“裸奔”了。2.4 中文乱码与编码细节中文乱码是邮件发送里最常见的坑根源在于MIME协议默认只支持ASCII字符。解决手段就是我在上面代码里用到的两个关键点Header(subject, utf-8)对邮件主题做Base64编码这样即使收件人客户端是英文环境也能正确显示中文主题。formataddr((str(Header(from_name, utf-8)), username))发件人昵称也得单独编码不然“张三”会变成“?utf-8?B?...?”一堆乱码。附件中文名用filename(utf-8, , filename)这种RFC2231格式而不是直接塞原始文件名。很多人踩过这个坑正文没问题、附件名全是乱码多半就是这里没处理。另外提醒一句发给日韩用户时正文用纯文本就好带HTML时最好顺带附一个纯文本版本有些老旧邮件系统渲染不了HTML纯文本备胎能兜底。3. 把短信发出去服务商API、签名与模板3.1 为什么个人不能直连运营商网关先别急着写代码这个问题不搞清楚你会一直在错误方向上折腾。短信发送和邮件的最大区别是邮件是你自己跟SMTP服务器对接而短信的最后一公里从运营商下发到用户手机是被严管的。个人开发者没有企业资质、没有渠道号、没有内容审核资质运营商根本不会给你开通道。所以现实是所有人都要通过短信服务商来发你在代码里调用服务商的HTTP接口把你的短信内容、目标号码传过去服务商内部完成内容审核、号码校验、运营商提交、状态回执这一整套动作。国内主流服务商包括阿里云、腾讯云、华为云还有大量中小型短信平台。他们的API风格大同小异一个HTTP POST请求带上AccessKeyId、SignName签名、TemplateCode模板ID、TemplateParam模板变量、PhoneNumbers目标号码再用一套签名算法做鉴权。3.2 HTTP API调用链路分解以阿里云短信服务为例一次成功的短信发送在代码层面要经历这四步拼接公共参数和业务参数。公共参数包括AccessKeyId、ActionSendSms、Version2017-05-25、FormatJSON、SignatureMethodHMAC-SHA1等业务参数包括PhoneNumbers、SignName、TemplateCode、TemplateParam。生成签名。对请求参数按照字典序排序拼接成规范化字符串再用HMAC-SHA1加密最后Base64编码。服务端会用同样的算法校验签名防止请求被篡改。发起HTTP请求。把参数放在请求体里发送到dysmsapi.aliyuncs.com。解析返回结果。返回CodeOK表示受理成功否则根据错误码排查。很多人图省事直接用官方SDK这没问题。但我强烈建议你至少亲手写一次纯HTTP版本真正理解签名怎么来的。因为你一旦日后要接其他服务商或者SDK升级导致行为变化不懂签名只会干瞪眼。3.3 完整代码阿里云短信发送Python下面是一个基于官方HTTP接口、不依赖SDK的完整样例。使用前请把ACCESS_KEY_ID、ACCESS_KEY_SECRET替换成你自己账号的密钥并保证签名、模板都已通过审核。import json import uuid import time import hmac import hashlib import base64 import urllib.parse import requests ACCESS_KEY_ID 你的AccessKeyId ACCESS_KEY_SECRET 你的AccessKeySecret # 短信签名和模板ID需提前在控制台申请并审核通过 SIGN_NAME 你的签名 TEMPLATE_CODE SMS_123456789 def sms_sign(params: dict) - str: # 1. 参数排序 sorted_keys sorted(params.keys()) query_string for key in sorted_keys: value str(params[key]) query_string f{key}{urllib.parse.quote(str(value), safe)} query_string query_string[:-1] # 去掉末尾 # 2. 构造待签名串 string_to_sign fPOST%2F{urllib.parse.quote(query_string, safe)} # 3. HMAC-SHA1 Base64 signature base64.b64encode( hmac.new( (ACCESS_KEY_SECRET ).encode(utf-8), string_to_sign.encode(utf-8), hashlib.sha1 ).digest() ).decode(utf-8) return signature def send_sms(phone_number: str, template_param: dict): params { AccessKeyId: ACCESS_KEY_ID, Action: SendSms, Format: JSON, PhoneNumbers: phone_number, RegionId: cn-hangzhou, SignName: SIGN_NAME, SignatureMethod: HMAC-SHA1, SignatureNonce: str(uuid.uuid4()), # 防重放每次请求必须唯一 SignatureVersion: 1.0, TemplateCode: TEMPLATE_CODE, TemplateParam: json.dumps(template_param, ensure_asciiFalse), Timestamp: time.strftime(%Y-%m-%dT%H:%M:%SZ, time.gmtime()), Version: 2017-05-25 } params[Signature] sms_sign(params) resp requests.post( https://dysmsapi.aliyuncs.com/, dataparams, timeout10 ) result resp.json() if result.get(Code) OK: return True, result.get(Message) return False, result.get(Message)调用示例ok, msg send_sms( phone_number13800138000, template_param{code: 123456, product: 监控预警} ) if ok: print(短信发送受理成功) else: print(失败原因:, msg)TemplateParam里的键必须和你的短信模板里的变量完全一致。比如你的模板是“您的验证码为${code}有效期${minute}分钟”那template_param就得写{code: 123456, minute: 5}。多一个、少一个、名字不匹配服务端都会直接拒绝。3.4 签名、模板、变量的三条军规做短信开发有三条规则是“军规”级别的违反一条就会在审核或发送环节卡壳。第一条签名必须是公司在服务商后台申请审核通过的。签名就是短信开头那一截比如“【某某科技】”。里面不能含有“测试”“test”这类字眼也不能是纯个人昵称。审核要人工过一般几小时到一天。你要是图方便在代码里随意传个签名返回的错误码大概率是isv.SMS_SIGNATURE_ILLEGAL。第二条短信内容必须走模板。国内短信强制要求模板化不允许直接在接口里拼任意文案。模板也要审核像“您的订单${goods}已发货”这样变量部分用${}占位。为什么这么严主要防止诈骗短信和垃圾广告宁可审核流程麻烦一点也别在合规上踩线。第三条变量参数要做类型和长度校验。模板变量是字符串但如果你塞了很长的 JSON服务端可能拒绝。手机号必须先做正则校验再提交。我见过一个项目把带“86”前缀的国内号码原样传上去结果被服务端判定为国际号码费用翻了三倍还不自知。4. 实操搭一个邮件短信双通道通知模块4.1 模块设计与配置项单个函数能发邮件、发短信不代表项目里就能直接用。真正的工程化做法是把这两个能力封装成一个独立的notifier模块统一暴露send_by_email()和send_by_sms()两个方法外部业务只需要调用不用关心内部细节。我推荐的目录结构是这样的notifier/ ├── __init__.py ├── config.py # 所有配置集中管理 ├── email_sender.py # 邮件发送实现 ├── sms_sender.py # 短信发送实现 ├── errors.py # 自定义异常 └── tests/ └── test_sender.py配置项统一放config.py而且从环境变量读取不写死import os EMAIL_CONFIG { host: os.getenv(SMTP_HOST, smtp.qq.com), port: int(os.getenv(SMTP_PORT, 465)), username: os.getenv(SMTP_USER, ), password: os.getenv(SMTP_AUTH_CODE, ), from_name: os.getenv(MAIL_FROM_NAME, 系统通知), } SMS_CONFIG { access_key_id: os.getenv(SMS_ACCESS_KEY_ID, ), access_key_secret: os.getenv(SMS_ACCESS_KEY_SECRET, ), sign_name: os.getenv(SMS_SIGN_NAME, ), template_code: os.getenv(SMS_TEMPLATE_CODE, ), }把敏感信息放环境变量而不是settings.py里硬编码是为了防止源码泄露导致密钥被滥用。这个习惯从第一个项目就应该养成。4.2 核心代码实现email_sender.py直接复用上一节那段完整的send_email函数在这里只做一层薄封装。真正有价值的是在设计异常和返回结构上统一用自定义异常抛出class NotifierError(Exception): 通知模块统一异常基类 pass class EmailSendError(NotifierError): pass封装后的email_sender.py内部逻辑取配置 → 调send_email→ 发生SMTPAuthenticationError就转成EmailSendError抛出。这样上层业务用一套try / except NotifierError就能统一兜住所有通知异常日志也好归类。sms_sender.py里我通常会在调用真正的API之前先做三件事手机号格式校验国内11位数字以1开头模板变量字段完整性校验缺一个字段直接抛异常避免浪费一次远程调用频率控制同一个手机号60秒内只允许发一条用内存里的字典记录时间戳即可4.3 测试要点与预期输出测试环节我踩过几次坑总结下来有三点最关键本地测试时别用真实手机号狂刷短信。我在开发环境发过几十条测试短信结果被服务商限流了半天差点影响线上业务。后来学乖了本地一律用沙箱号码或邮箱通道代替验证。阿里云等控制台里有测试专用号码只在联调时用。邮件测试要检查“这封邮件到底进没进收件箱”。我见过开发环境发邮件成功率100%但收件人就是收不到——因为测试时用了一个被收件方反垃圾策略误判的域名。联调时建议用一个专门测试域名并在真实收件箱里翻开看看有没有进“垃圾邮件”。把期望输出写进断言。单元测试里我的做法是对邮件模块做“不真发”测试用mock替换SMTP连接断言sendmail是否被正确调用、附件是否正确拼装。短信模块同理mock掉requests.post返回固定JSON断言API参数、签名字段是否齐全。这样CI环境里不会依赖外部网络。4.4 失败重试、限流与通道降级通知类操作的可靠性设计往往比发送本身更值得写。我在生产环境总结出的三个原则网络请求必须设超时。SMTP和HTTP请求都给了timeout10。千万别用默认值无限等否则一条通知卡半天连父任务都拖垮。重试要有退避backoff。失败后立刻重试没有意义我习惯用1s - 3s - 9s这种间隔最多重试三次。短信和邮件通道连续失败三次后就该告警给负责人而不是死磕。双通道降级。我做的监控系统里短信不通就降级发邮件邮件不通就降级发短信。两边都失败就写入本地日志文件等通道恢复后补发。实际项目中这一条救过我好几次——有一次短信通道整体异常全靠邮件兜底运维才知道线上服务出了问题。def send_notification(phone: str, email: str, content: str): # 默认走短信失败自动降级邮件 try: sms_sender.send(phone, content) return sms except NotifierError: # 记录降级日志 logger.warning(短信发送失败降级到邮件通道) email_sender.send(email, [降级通知] content) return email5. 高频故障与排查实录含速查表5.1 邮件发不出去按这七个原因逐一排查邮件发不出去的原因往往不在你的代码里而在环境、账号、收件方策略之间。我按出现的频率列一下错误一SMTPAuthenticationError。最常见授权码抄错、授权码过期、邮箱服务未开SMTP。先在本地用单行命令验证python -c里直接server.login()一次能过就说明不是账号问题。错误二超时。大概率是25端口被云厂商封了。换465或587端口再看看安全组规则是否放行。错误三554/501 发件人被拒。发件人参数与登录账号不一致造成的。很多邮箱服务器规定登录认证的账号必须和MAIL FROM是同一个别图省事在代码里硬写一个不存在的发件箱。错误四收件人收不到但无报错。去检查 SPF、DKIM、DMARC 记录。你的发送域名如果没有SPF记录收件方很可能会判为垃圾邮件。内部系统可用企业邮箱服务商解决这个问题。错误五附件的文件名全是乱码。用了 RFC2231 方案就没事直接用filenamefilename的必乱。错误六内容被拦截。“验证码”“密码”等关键词加上发送频率高容易被风控。尽量走模板化的内容别写“hello my friend good morning”这种英文垃圾邮件常见句式。错误七smtplib 报SMTPServerDisconnected。连接早期被服务器断开可能是TLS版本不兼容。升级Python版本到3.7以上一般能解决。5.2 短信发不出去这五个坑最隐蔽短信的问题比邮件更“黑盒”一些因为服务商返回的错误码经常让人摸不着头脑。实际项目中我绕过的坑坑一SignatureDoesNotMatch。90%是签名串拼接的编码问题特别是参数值里含中文时BASE64编码前必须用UTF-8。还有排序必须按每个参数的key做字典序不是按拼好的query string排。坑二isv.SMS_TEMPLATE_ILLEGAL。模板还没审核通过或者模板变量名与你传的TemplateParam不一致。去控制台仔细核对模板里的${}变量确信一个不差。坑三isv.BUSINESS_LIMIT_CONTROL。限流了。同一个号码验证码类短信通常限制为1条/分钟、5条/小时、10条/天。排查时注意看是不是测试时刷太猛。坑四阿里云短信api发不出去的隐藏根因。我见过最多的情况是TemplateParam传成了字符串而不是合法的JSON字典。比如直接传{code:123}Python的单引号JSON在服务端解析直接失败。请用json.dumps(template_param, ensure_asciiFalse)确保输出是双引号合法JSON中文不要被转义成unicode。坑五号码带了国际前缀。国内号码必须不带86国际号码要带国家码否则走国际通道计费还可能发送失败。手机号校验正则别只写\d{11}。5.3 问题与解决对照速查表我把上面的坑整理成一张表方便你贴在工位旁边或者存进 Wiki。排查的时候照着序号来基本能解决80%的问题。现象可能原因处理方式邮件认证失败授权码错误/未开启SMTP重新生成授权码逐字符核对邮件连接超时25端口被封/安全组未放行换465或587端口邮件发送后进垃圾箱SPF/DKIM缺失配置域名DNS的SPF记录邮件中文主题乱码未使用Header编码用Header(subject, utf-8)附件中文名乱码未使用RFC2231用filename(utf-8, , fn)短信SignatureDoesNotMatch签名串拼接错误重点检查排序、URL编码、UTF-8短信TEMPLATE_ILLEGAL模板未过审或变量名不匹配控制台核对模板变量短信BUSINESS_LIMIT_CONTROL触发限流降低发送频率加本地限流短信报OK但用户没收到手机号格式/被运营商拦截核对号码、检查模板合规API调用返回InvalidAccessKeyIdAccessKeyID错误去RAM控制台核对注意区分中划线5.4 我从实际项目里总结的避坑经验最后分享几条压箱底的经验算不上多高深但都是我拿真金白银换来的经验一日志要留全链路追踪信息。每次邮件/短信调用都记录一个唯一 request_id把收件人、模板ID、服务商返回码、耗时全打出来。出了问题查日志能省一半时间。别问为什么你凌晨三点被叫起来排查短信丢失时就知道这话有多重要。经验二敏感信息务必脱敏。日志里不要打完整的手机号打138****8000就行。虽然内部系统可能信任度高但日志会同步到日志平台、错误监控系统甚至可能被人截屏。保护用户手机号是基本修养。经验三告警的最终兜底永远是人。短信通道挂了就算降级邮件邮件也可能晚到。所以我在巡检脚本里加了“拨测”逻辑每天凌晨自动给运维负责人发一条测试短信收不到就证明通道有问题提前发现就不用在关键时刻被动。经验四把发送逻辑和业务逻辑解耦。别把邮件发送直接写在注册接口里用户点“注册”三秒才返回体验极差。正确做法是业务接口只负责落库把“发送验证码”丢到消息队列或线程池异步执行。发送结果用回调或状态表记录。这样代码结构清爽性能也不用担心。这些经验在我参与过的多个项目里反复被验证——通知模块看着简单细节却格外磨人。刚入门的朋友建议先把上面的代码跑通、把每个报错都亲手复现一遍再去做高可用和降级设计。基础扎实了遇到问题心里就有数。
返回列表