短信邮件

前言

  • 这一篇只讲两件事:用 SMTP 发邮件(SMTP 是「发信」这一步的网络协议名,smtplib 就是它的客户端)和调短信平台接口发短信。原来记在同一篇里的 eval、hashlib、urllib.parse、http.client 已经挪到《函数使用(七)》去了,那才是它们该待的地方。
  • 邮件和短信都涉及真实账号与外部服务,所以本文的示例只做本地可验证的部分:邮件构造出完整报文并打印关键字段(不真的发出去),短信只演示参数拼装与签名计算(原笔记用的秒滴平台早已停运)。发送那几行代码给出来,换成你自己的账号即可。
  • 所有输出都在 Python 3.13.9 / Windows 上实跑,随机器或时间会变的东西(boundary、字节数)一律只打印布尔结论。

邮件发送

  • 说明

    • 场景: 注册激活、密码找回、系统通知、营销邮件。
    • 前提: 邮箱要先在网页设置里开启 SMTP 服务并生成授权码(163、QQ 邮箱都是这样)——授权码是邮箱专门发给第三方程序用的密码,和你网页登录的密码是两码事;这里填登录密码会被服务器拒绝。
    • 需要的标准库:smtplib(发)+ email(构造报文)。
  • 三个端口,别混用

    用法 端口 对应类
    明文 SMTP 25 smtplib.SMTP(host, 25)
    SMTPS(TLS 从连接就开始) 465 smtplib.SMTP_SSL(host, 465)
    STARTTLS(先明文再升级) 587 smtplib.SMTP(host, 587) 之后 starttls()
    1
    2
    3
    4
    5
    import smtplib

    print('模块里的默认端口:', smtplib.SMTP_PORT, smtplib.SMTP_SSL_PORT)
    print('SMTP 支持 with:', hasattr(smtplib.SMTP, '__enter__'), '| 有 SMTP_SSL:', hasattr(smtplib, 'SMTP_SSL'))
    print('send_message 存在(3.6+):', hasattr(smtplib.SMTP, 'send_message'))

    输出:

    1
    2
    3
    模块里的默认端口: 25 465
    SMTP 支持 with: True | 有 SMTP_SSL: True
    send_message 存在(3.6+): True

    国内云厂商的邮箱服务经常封 25 端口(防垃圾邮件),连不上时先怀疑端口,换 465 或 587。

  • 构造邮件:老写法 MIMEText

    MIME 是给邮件「分段贴标签」的标准:正文是 text/plain、HTML 版是 text/html、附件是 application/octet-stream 之类,收件端据此知道每段该怎么显示。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    from email.mime.text import MIMEText

    message = '你好,欢迎注册 xxx 平台,激活请点击 <a href="https://www.baidu.com/">这里</a>'
    msg = MIMEText(message, 'html', 'utf-8') # 第二个参数是子类型,HTML 邮件就写 html
    msg['Subject'] = '账户激活'
    msg['From'] = 'sender@163.com'
    msg['To'] = 'rcpt@163.com'

    raw = msg.as_string()
    subject = [l for l in raw.split('\n') if l.startswith('Subject')][0]
    ctype = [l for l in raw.split('\n') if l.startswith('Content-Type')][0]
    print('主题行:', subject)
    print('内容类型行:', ctype)
    print('中文正文是裸 UTF-8 吗:', '你好' in raw)

    输出:

    1
    2
    3
    主题行: Subject: =?utf-8?b?6LSm5oi35r+A5rS7?=
    内容类型行: Content-Type: text/html; charset="utf-8"
    中文正文是裸 UTF-8 吗: False

    三个实测结论:

    • 中文主题不用自己处理:as_string() 会按 RFC 2047 编成 =?utf-8?b?...?=,直接写 msg['Subject'] = '账户激活' 就行。
    • 正文会被编码(base64 / quoted-printable)后传输,所以原始报文里看不到中文,别以为出错。
    • charset 建议显式写 'utf-8':MIMEText('你好', 'html') 会因为内容非 ASCII 自动升级成 utf-8,但 MIMEText('hello', 'html') 会留成 us-ascii——纯英文正文加上中文主题这种组合,收信端的显示就可能不一致。
    1
    2
    3
    4
    5
    6
    7
    8
    from email.mime.text import MIMEText

    def ctype_of(text):
    return [l for l in MIMEText(text, 'html').as_string().split('\n') if l.startswith('Content-Type')][0]

    print('中文正文:', ctype_of('你好'))
    print('英文正文:', ctype_of('hello'))
    print('显式指定后固定:', 'utf-8' in MIMEText('hello', 'html', 'utf-8').as_string())

    输出:

    1
    2
    3
    中文正文: Content-Type: text/html; charset="utf-8"
    英文正文: Content-Type: text/html; charset="us-ascii"
    显式指定后固定: True
  • 构造邮件:新写法 EmailMessage(3.6+,推荐)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    from email.message import EmailMessage

    msg = EmailMessage()
    msg['Subject'] = '账户激活'
    msg['From'] = 'sender@163.com'
    msg['To'] = 'rcpt@163.com'
    msg['Cc'] = 'boss@163.com'
    msg.set_content('纯文本版本:点击 https://www.baidu.com/ 激活')
    msg.add_alternative('<b>HTML 版本</b>:点击 <a href="https://www.baidu.com/">这里</a>', subtype='html')

    print('是 multipart:', msg.get_content_type())
    print('子部件数量:', len(msg.get_payload()))
    print('主题已编码:', '=?utf-8?' in msg.as_string())

    输出:

    1
    2
    3
    是 multipart: multipart/alternative
    子部件数量: 2
    主题已编码: True

    EmailMessage 的好处:set_content / add_attachment 自动处理编码和 Content-Disposition,而且 send_message() 会自己把收件人从头部取出来。

  • 带附件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    from email.mime.multipart import MIMEMultipart
    from email.mime.text import MIMEText

    msg = MIMEMultipart()
    msg['Subject'] = '报表'
    msg.attach(MIMEText('正文见附件', 'plain', 'utf-8'))

    part = MIMEText('print(1)\n', 'plain', 'utf-8')
    part.add_header('Content-Disposition', 'attachment', filename='demo.py')
    msg.attach(part)

    raw = msg.as_string()
    print('顶层类型:', msg.get_content_type())
    print('有附件头:', 'attachment' in raw, '| 带文件名:', 'demo.py' in raw)
    print('boundary 是随机生成的:', 'boundary=' in [l for l in raw.split('\n') if l.startswith('Content-Type')][0])

    输出:

    1
    2
    3
    顶层类型: multipart/mixed
    有附件头: True | 带文件名: True
    boundary 是随机生成的: True

    中文文件名要编码:filename=('utf-8', '', '报表.xlsx') 这种三元组写法,或者直接用 EmailMessage.add_attachment(data, maintype='application', subtype='octet-stream', filename='报表.xlsx')。

  • 收件人:头里的 To 和真正投递是两回事

    sendmail(from, to_addrs, msg) 的第二个参数决定投给谁,msg['To'] 只是显示。只写头不传参,或者反过来,都会出现「收不到」或「显示不对」。抄送 Cc、密送 Bcc 同理:密送更要从投递列表里带上、但从头部删掉。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    from email.message import EmailMessage

    msg = EmailMessage()
    msg['From'] = 'sender@163.com'
    msg['To'] = 'a@163.com, b@163.com' # 只是显示用
    msg['Cc'] = 'boss@163.com'
    msg.set_content('hi')
    rcpts = ['a@163.com', 'b@163.com', 'boss@163.com'] # 真正要投的地址
    print('头里的 To:', msg['To'])
    print('投递列表:', rcpts)
    print('两者数量不同(密送就是这种情况):', len(rcpts) != len(msg['To'].split(',')))

    输出:

    1
    2
    3
    头里的 To: a@163.com, b@163.com
    投递列表: ['a@163.com', 'b@163.com', 'boss@163.com']
    两者数量不同(密送就是这种情况): True
  • 发送:完整写法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    import os
    import smtplib
    from email.message import EmailMessage

    MAIL_SERVER = 'smtp.163.com'
    MAIL_USER = '166****@163.com'
    MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD', '') # 授权码放环境变量,别写进代码

    msg = EmailMessage()
    msg['Subject'] = '账户激活'
    msg['From'] = MAIL_USER
    msg['To'] = '155****@163.com'
    msg.set_content('点击链接完成激活:https://example.com/activate', subtype='html')

    # 真正发送时就取消下面这段的注释;这里只演示结构,不往外发
    # with smtplib.SMTP_SSL(MAIL_SERVER, 465, timeout=10) as smtp:
    # smtp.login(MAIL_USER, MAIL_PASSWORD)
    # smtp.send_message(msg) # 收件人从头部自动取
    print('报文就绪:', msg['Subject'], '| 大小 > 100 字节:', len(msg.as_string()) > 100)

    输出:

    1
    报文就绪: 账户激活 | 大小 > 100 字节: True

    要点:

    • with 块结束自动 quit()。
    • send_message() 比 sendmail() 省事(它认识 EmailMessage 的头部)。
    • 老代码里的 mail.quit() 如果中途抛异常就不会执行,用 with 就没这个问题。
  • 整体示例(邮件)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    import os
    from email.message import EmailMessage

    # 1) 一封「正文 + HTML + 附件」的完整邮件
    msg = EmailMessage()
    msg['Subject'] = '账户激活'
    msg['From'] = 'sender@163.com'
    msg['To'] = 'rcpt@163.com'
    msg.set_content('点击激活:https://example.com/activate')
    msg.add_alternative('<p>点击 <a href="https://example.com/activate">这里</a> 激活</p>', subtype='html')
    msg.add_attachment('print("hi")\n'.encode(), maintype='text', subtype='plain', filename='activate.py')

    # 2) 报文里能看到什么
    raw = msg.as_string()
    print('顶层类型:', msg.get_content_type())
    print('中文主题已编码:', '=?utf-8?b?' in raw)
    print('附件名在报文里:', 'activate.py' in raw)
    print('子部件数量:', len(msg.get_payload()))

    # 3) 授权码从环境变量取,取不到就只打印不发送
    password = os.environ.get('MAIL_PASSWORD')
    print('没配 MAIL_PASSWORD 时:', password, '| 所以示例不会真的发出去')

    # 4) 真实发送的写法(结构演示,注释状态)
    # with __import__('smtplib').SMTP_SSL('smtp.163.com', 465, timeout=10) as smtp:
    # smtp.login('sender@163.com', password)
    # smtp.send_message(msg)

    实跑输出:

    1
    2
    3
    4
    5
    顶层类型: multipart/mixed
    中文主题已编码: True
    附件名在报文里: True
    子部件数量: 2
    没配 MAIL_PASSWORD 时: None | 所以示例不会真的发出去

短信发送

  • 说明

    • 场景: 注册验证码、登录提醒、订单通知、营销短信。
    • 平台: 自己搭不了短信通道,都得走服务商。原笔记用的是秒滴(miaodiyun.com),这家早就停运了;现在常见的是阿里云、腾讯云、华为云等。它们的接口细节各不相同,但套路是一样的:
      1. 准备 accountSid / accessKeyId 和 token / secret 两串密钥,放环境变量;
      2. 把业务参数 + 时间戳按平台规则拼成待签名字符串,算出签名(MD5 / SHA / HMAC 各家不同);
      3. 用 application/x-www-form-urlencoded 或 JSON 发一个 HTTPS POST,读回 JSON 结果。
  • 签名这一步(以秒滴的老规则为例)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    import hashlib
    from urllib.parse import urlencode

    account_sid = '你的accountSid'
    auth_token = '你的authToken'
    timestamp = '20191206153957' # 固定下来,签名结果才可复现

    raw = account_sid + auth_token + timestamp # 平台规定的拼接顺序
    sig = hashlib.md5(raw.encode('utf-8')).hexdigest()
    print('签名:', sig)

    form = urlencode({'accountSid': account_sid, 'templateid': '1502437573', 'to': '138****0000',
    'timestamp': timestamp, 'sig': sig, 'param': '632881,5'})
    print('表单长度 > 100:', len(form) > 100)
    print('参数已百分号编码:', '%E4%BD%A0' in form)

    输出:

    1
    2
    3
    签名: e0025daea9f50d442249d697e8aad85e
    表单长度 > 100: True
    参数已百分号编码: True

    验证码和有效期是模板参数(param),格式由平台模板决定,通常是 验证码,分钟数。

  • 原笔记那段代码的三个问题

    1. 接口是 https://api.miaodiyun.com/...,却用 HTTPConnection——那是 80 端口明文连接,TLS 根本没走。要用 HTTPSConnection。
    2. request() 的第二个参数是 path,不是完整 URL。旧代码把整条 https://... 当 path 传进去,请求行就成了 GET https://api... 这种 absolute-form,碰上宽容的服务器还能应答,正经服务器会拒。
    3. accountSid、auth_token、手机号硬编码在源码里——这些是要进仓库的,等于把密钥公开。改用 os.environ。
  • 整体示例(短信)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    import hashlib
    import http.client
    import os
    from urllib.parse import urlencode

    # 1) 密钥从环境变量取,示例给个假值
    account_sid = os.environ.get('SMS_ACCOUNT_SID', 'demo-account-sid')
    auth_token = os.environ.get('SMS_AUTH_TOKEN', 'demo-auth-token')

    # 2) 签名:时间戳固定,结果可复现
    timestamp = '20191206153957'
    sig = hashlib.md5((account_sid + auth_token + timestamp).encode('utf-8')).hexdigest()
    print('签名:', sig)

    # 3) 表单:模板参数是「验证码,有效期」
    body = urlencode({'accountSid': account_sid, 'templateid': '1502437573', 'to': '138****0000',
    'timestamp': timestamp, 'sig': sig, 'param': '632881,5'})

    # 4) 真正发送时的正确姿势:HTTPS + 只给 path(这里不实际请求,平台已停运)
    # import http.client
    # conn = http.client.HTTPSConnection('api.miaodiyun.com', timeout=10)
    # conn.request('POST', '/20150822/industrySMS/sendSMS', body=body,
    # headers={'Content-Type': 'application/x-www-form-urlencoded'})
    # print(conn.getresponse().read().decode()) # 记得把响应读完
    # conn.close()
    print('要发的参数条数:', body.count('&') + 1)
    print('表单里不含整条 URL:', 'https://' not in body) # 域名交给连接对象,请求行只放 path
    print('HTTPSConnection 默认端口:', http.client.HTTPSConnection('api.miaodiyun.com').port) # 443 = 走 TLS

    实跑输出:

    1
    2
    3
    4
    签名: ba5180d802eda731ba1e1b57f31e8d2b
    要发的参数条数: 6
    表单里不含整条 URL: True
    HTTPSConnection 默认端口: 443

    签名值取决于 accountSid / authToken,换机器或改了环境变量就会不同;上面是示例假值跑出来的结果。

参考

本文结束 感谢您的阅读
正在加载今日诗词....