Django-发送邮件

这篇讲什么

  • Django 的邮件能力都在 django.core.mail 里,send_mail 与 EmailMessage 两套 API 覆盖从单发到群发。
  • 学习阶段用内存 / 打印 / 写文件这三个「假发信」后端就能全部跑通,不用连真实 SMTP。这篇把假后端、真发信的配置(授权码不是登录密码)、fail_silently 的真实行为和一个「注册 → 激活」完整示例都实测了一遍。基线 Django 5.2.19。

概述

  • Django 内置了邮件发送功能,都在 django.core.mail 模块里。实测可直接导入 EmailMessage、 EmailMultiAlternatives、 send_mail、 send_mass_mail 四个名字。
  • 真发信要走 SMTP 服务器,国内常用的是 163、 126、 QQ 邮箱(需要在网页里开 POP3/SMTP 服务并拿授权码,不是登录密码)。
  • 但学习阶段根本不用真发:Django 提供三个「假发信」后端(内存 / 打印到终端 / 写文件),本篇的实测全部用它们完成,不给任何真实地址发信。

开发期用这三个后端,别连真服务器

  • locmem:留在内存里给测试用

    settings.py
    1
    EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
    1
    2
    3
    4
    5
    6
    from django.core import mail
    from django.core.mail import send_mail

    n = send_mail("注册激活", "正文", "python<a@b.com>", ["c@d.com"])
    print(n) # 1
    print(type(mail.outbox[0])) # <class 'django.core.mail.message.EmailMultiAlternatives'>
    • 实测输出:send_mail 返回: 1,outbox[0] 的类型是 EmailMultiAlternatives,alternatives 里能看到 EmailAlternative(content='<b>粗体</b>', mimetype='text/html')。
    • 单元测试里断言「发了几封、发给谁、正文有没有那个激活链接」就用 mail.outbox,这是最实用的写法。
  • console:直接打到终端

    settings.py
    1
    EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
    • 实测会把整封信(Content-Type、 From、 To、 Subject 等信头 + 正文)打印出来,正文后面跟一行 ---------- 分隔线。本地跑注册流程时最直观。
  • file:写成一个文件

    settings.py
    1
    2
    EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend"
    EMAIL_FILE_PATH = r"E:\Desktop\Test\mail_out"
    • 实测生成的文件名形如 20261104-221452-8439960.log(扩展名是 .log,不是 .eml,内容才是标准 MIME)。
    • 文件里能看到完整的 MIME 结构:multipart/mixed 套 multipart/alternative(纯文本 + HTML),附件用 Content-Disposition: attachment; filename*=utf-8''...(中文文件名走 RFC 2231 编码),自定义头 X-Custom: abc 也原样保留。

真发信时的 settings.py

settings.py
1
2
3
4
5
6
7
# 发送邮件配置
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.163.com"
EMAIL_PORT = 25 # 或 465 + EMAIL_USE_SSL=True,或 587 + EMAIL_USE_TLS=True
EMAIL_HOST_USER = "xxxxxxxxx@163.com"
EMAIL_HOST_PASSWORD = "password" # 填邮箱授权码,不是登录密码;真项目不要把明文提交进仓库
DEFAULT_FROM_EMAIL = "python <xxxxxxxxx@163.com>"
  • 老笔记最后一行写的是 EMAIL_FROM。这不是 Django 的设置项,实测读它会:

    1
    AttributeError: 'Settings' object has no attribute 'EMAIL_FROM'
    • 你可以自己往 settings 里塞这个键当自定义变量用,但 Django 认的是 DEFAULT_FROM_EMAIL(普通邮件的发件人)和 SERVER_EMAIL(系统自动邮件,如报错邮件)的发件人。
  • 实测这 9 个键在 5.2 上都存在(hasattr(settings, k) 全为 True):EMAIL_BACKEND、 EMAIL_HOST、 EMAIL_PORT(默认 25)、EMAIL_HOST_USER、 EMAIL_HOST_PASSWORD、 EMAIL_USE_TLS(默认 False)、EMAIL_USE_SSL、 DEFAULT_FROM_EMAIL、 SERVER_EMAIL。

  • 顺带一条版本相关:MAILERS 这个设置在 5.2 上不存在(hasattr(settings, "MAILERS") 为 False),它是 Django 6.1 才引入的新邮件配置入口,看 6.x 文档时别把它抄进 5.2 项目。

函数式与对象式两套 API

  • 便捷函数 send_mail

    实测签名:

    1
    2
    (subject, message, from_email, recipient_list, fail_silently=False,
    auth_user=None, auth_password=None, connection=None, html_message=None)
    • 注意 recipient_list 是列表,html_message 是可选的 HTML 正文(老笔记用的就是这个参数)。
    • send_mass_mail((t1, t2, ...), fail_silently=False) 是「一个连接发多封」,t 是 (subject, message, from_email, recipient_list) 四元组。
  • 对象式 EmailMessage

    实测签名:

    1
    2
    (self, subject='', body='', from_email=None, to=None, bcc=None, connection=None,
    attachments=None, headers=None, cc=None, reply_to=None)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    from django.core.mail import EmailMultiAlternatives
    from django.conf import settings

    mail = EmailMultiAlternatives(
    subject="注册激活",
    body="请点这个链接激活(纯文本兜底)",
    from_email=settings.DEFAULT_FROM_EMAIL,
    to=["c@d.com"],
    cc=["e@f.com"],
    headers={"X-Custom": "abc"},
    )
    mail.attach_alternative("<b>粗体</b>", "text/html") # HTML 版本
    mail.attach_file(r"E:\Desktop\Test\说明.txt") # 附件
    mail.send(fail_silently=False)
    • EmailMultiAlternatives 比 EmailMessage 多一个 alternatives 参数,其余一致。
    • reply_to 在老笔记时代经常被忽略,实测 5.2 有。

fail_silently 到底干什么(实测)

  • 把 EMAIL_HOST/EMAIL_PORT 指向一个没人监听的端口(127.0.0.1:18825),实测两种取值:

    1
    2
    fail_silently=False -> ConnectionRefusedError: [WinError 10061] 由于目标计算机积极拒绝,无法连接。
    fail_silently=True -> 返回值: 0
  • 所以:

    • True 是「发不出去就算了,别炸」,返回值 0 表示成功 0 封 —— 线上用它要谨慎,否则用户收不到信而你在日志里什么都看不到。
    • False(默认)会把异常抛给你,配合日志/告警才是可运维的做法。
  • 老笔记里那句 # longIO.delay() / # sendMail.delay() 的注释其实点到了一个真问题:发信是网络 IO,放在注册请求里同步做会拖慢甚至拖死这个请求,正式项目要丢给 Celery 之类的队列(见《Django-Celery》)。

一个完整可跑的「注册 → 激活」示例

  • 老笔记这段有三个问题:register 里调 send,send 里又调 sendMail,函数名混乱;urls.py 用的是 re_path;最关键的是 checkRegister 把 session 里的 token 直接回显出来当作「验证成功」,等于没有校验(任何人请求 /checkRegister/ 都会拿到自己的 token 并显示成功)。下面这版把校验补上。

  • 路由

    myapp/urls.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    from django.urls import path

    from myapp import views

    app_name = "myapp"

    urlpatterns = [
    path("register/", views.register, name="register"),
    path("check_register/", views.check_register, name="check_register"),
    ]
  • 视图

    myapp/views.py
    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
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    import uuid

    from django.conf import settings
    from django.core.mail import send_mail
    from django.http import HttpResponse
    from django.shortcuts import render
    from django.urls import reverse

    from myapp.models import User


    def register(request):
    if request.method == "GET":
    return render(request, "myapp/register.html")

    username = request.POST.get("username")
    password = request.POST.get("password")
    user = User.objects.create(username=username, password=password) # 真实项目要哈希密码

    check_str = uuid.uuid4().hex
    request.session["check"] = check_str

    # 用绝对地址,收件人点开才有效
    activate_url = request.build_absolute_uri(
    reverse("myapp:check_register") + "?token=" + check_str
    )
    send_mail(
    subject="注册激活",
    message="点击激活:%s" % activate_url,
    from_email=settings.DEFAULT_FROM_EMAIL,
    recipient_list=[user.email],
    html_message='<a href="%s">点击激活</a>' % activate_url,
    )
    return HttpResponse("注册成功,请查收激活邮件")


    def check_register(request):
    token = request.GET.get("token")
    saved = request.session.get("check")
    if token and saved and token == saved: # 必须比对,不能把 token 回显当成功
    del request.session["check"]
    User.objects.filter(pk=request.session["uid"]).update(is_active=True)
    return HttpResponse("验证成功")
    return HttpResponse("验证失败")
    • request.build_absolute_uri(...) 比老笔记里写死的 http://127.0.0.1:8000/... 靠谱:换域名/端口不用改代码。
    • 更规范的做法是用 Django 自带的签名 token(django.contrib.auth.tokens.default_token_generator)+ signing.dumps/loads,把用户 ID 和过期时间一起签进去,就不用依赖 session 了(session 会过期,而且换浏览器就失效)。

新手卡点清单

  • 都是实测过的:

    卡点 症状 正确做法
    用 settings.EMAIL_FROM AttributeError: 'Settings' object has no attribute 'EMAIL_FROM' 用 DEFAULT_FROM_EMAIL / SERVER_EMAIL
    学习期就配真 SMTP 发不出去还以为是代码错 先用 locmem / console / file 后端
    fail_silently=True 当默认 发信失败静默返回 0,日志里查不到 生产用 False + 异常日志
    在注册请求里同步发信 请求被 SMTP 拖慢 丢给 Celery 异步任务
    激活链接写死 127.0.0.1:8000 换环境就失效 request.build_absolute_uri(reverse(...))
    只回显 token 不比对 任何人都能「验证成功」 必须比对 token(或用签名 token)
    抄 6.x 文档里的 MAILERS 5.2 上不存在该设置 5.2 用 EMAIL_* 系列

参考

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