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(明文,很多云厂商默认封禁)、465(SSL加密)、587(STARTTLS)。我强烈建议用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, timeout=10) server.login(username, password) server.sendmail(username, to_list, msg.as_string()) server.quit()调用方式就是填上你自己的邮箱服务器参数:
send_email( smtp_host="smtp.qq.com", smtp_port=465, username="yourname@qq.com", password="授权码", from_name="监控告警系统", to_list=["admin@example.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、Action=SendSms、Version=2017-05-25、Format=JSON、SignatureMethod=HMAC-SHA1等;业务参数包括PhoneNumbers、SignName、TemplateCode、TemplateParam。 - 生成签名。对请求参数按照字典序排序,拼接成规范化字符串,再用
HMAC-SHA1加密,最后Base64编码。服务端会用同样的算法校验签名,防止请求被篡改。 - 发起HTTP请求。把参数放在请求体里发送到
dysmsapi.aliyuncs.com。 - 解析返回结果。返回
Code=OK表示受理成功,否则根据错误码排查。
很多人图省事直接用官方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 = f"POST&%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_ascii=False), "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/", data=params, timeout=10 ) result = resp.json() if result.get("Code") == "OK": return True, result.get("Message") return False, result.get("Message")调用示例:
ok, msg = send_sms( phone_number="13800138000", 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请求都给了
timeout=10。千万别用默认值无限等,否则一条通知卡半天,连父任务都拖垮。 - 重试要有退避(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 "email"5. 高频故障与排查实录(含速查表)
5.1 邮件发不出去?按这七个原因逐一排查
邮件发不出去的原因往往不在你的代码里,而在环境、账号、收件方策略之间。我按出现的频率列一下:
- 错误一:SMTPAuthenticationError。最常见,授权码抄错、授权码过期、邮箱服务未开SMTP。先在本地用单行命令验证:
python -c里直接server.login()一次,能过就说明不是账号问题。 - 错误二:超时。大概率是25端口被云厂商封了。换465或587端口,再看看安全组规则是否放行。
- 错误三:554/501 发件人被拒。发件人参数与登录账号不一致造成的。很多邮箱服务器规定,登录认证的账号必须和
MAIL FROM是同一个,别图省事在代码里硬写一个不存在的发件箱。 - 错误四:收件人收不到但无报错。去检查 SPF、DKIM、DMARC 记录。你的发送域名如果没有SPF记录,收件方很可能会判为垃圾邮件。内部系统可用企业邮箱服务商解决这个问题。
- 错误五:附件的文件名全是乱码。用了 RFC2231 方案就没事;直接用
filename=filename的必乱。 - 错误六:内容被拦截。“验证码”“密码”等关键词加上发送频率高,容易被风控。尽量走模板化的内容,别写“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_ascii=False)确保输出是双引号合法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调用返回InvalidAccessKeyId | AccessKeyID错误 | 去RAM控制台核对,注意区分中划线 |
5.4 我从实际项目里总结的避坑经验
最后分享几条压箱底的经验,算不上多高深,但都是我拿真金白银换来的:
经验一:日志要留全链路追踪信息。每次邮件/短信调用,都记录一个唯一 request_id,把收件人、模板ID、服务商返回码、耗时全打出来。出了问题,查日志能省一半时间。别问为什么,你凌晨三点被叫起来排查短信丢失时,就知道这话有多重要。
经验二:敏感信息务必脱敏。日志里不要打完整的手机号,打138****8000就行。虽然内部系统可能信任度高,但日志会同步到日志平台、错误监控系统,甚至可能被人截屏。保护用户手机号是基本修养。
经验三:告警的最终兜底,永远是人。短信通道挂了,就算降级邮件,邮件也可能晚到。所以我在巡检脚本里加了“拨测”逻辑:每天凌晨自动给运维负责人发一条测试短信,收不到就证明通道有问题,提前发现就不用在关键时刻被动。
经验四:把发送逻辑和业务逻辑解耦。别把邮件发送直接写在注册接口里,用户点“注册”三秒才返回,体验极差。正确做法是业务接口只负责落库,把“发送验证码”丢到消息队列或线程池异步执行。发送结果用回调或状态表记录。这样代码结构清爽,性能也不用担心。
这些经验在我参与过的多个项目里反复被验证——通知模块看着简单,细节却格外磨人。刚入门的朋友,建议先把上面的代码跑通、把每个报错都亲手复现一遍,再去做高可用和降级设计。基础扎实了,遇到问题心里就有数。