news 2026/11/2 2:31:30

Django邮件发送全链路:SMTP配置、异步与排错实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Django邮件发送全链路:SMTP配置、异步与排错实战

上周有个朋友半夜发消息问我,说他项目里的注册激活邮件“有时候能发出去,有时候石沉大海”,日志里就一行SMTPServerDisconnected,看得他一脸茫然。其实这个场景我太熟了——Django 配置邮箱服务、实现把信息发送到指定邮箱,看起来就是往settings.py里填几行参数的事,可真跑起来,端口选错、授权码当成登录密码、同步发信把请求线程堵死、邮件进了垃圾箱收件人根本收不到……坑一个接一个。这篇就按我自己的排查顺序来写:先把需求拆清楚,再逐个参数讲明白它到底在干什么,然后从零跑通第一封信,接着处理稳定性和可观测,最后把我遇到过的高频问题整理成速查表。

这篇内容适合三类人:刚接触 Django、第一次要接发信功能的新人;项目已经能发信但老是掉链子、想彻底搞明白的开发者;以及需要把发信做成公共服务模块、以后别的业务线也能直接调的工程师。不要求你对 SMTP 有多深的理解,但至少写过一点 Django 视图、知道什么是settings。文中所有主机地址、账号都以占位符形式出现,你换成自己服务商给的信息就能直接跑。

1. 先把需求拆明白:邮箱服务在 Django 项目里到底承担什么

1.1 发信不是一个功能,而是一条完整的链路

很多人第一次接邮件功能,脑子里想的是“调个函数把信发出去”。真上手才发现,从你敲下send_mail()到收件人点开邮件,中间要经过五六个环节,任何一个环节断掉,用户看到的就是“没收到”。

第一环是业务代码,它决定发什么内容、发给谁、什么时候发。第二环是邮件对象构造,也就是把主题、正文、收件人列表、附件组装成一个符合 MIME 规范的东西,这一步容易在中文编码和附件名上翻车。第三环是 Django 的 Email Backend,它负责把组装好的对象翻译成 SMTP 协议能懂的指令。第四环是发信服务器,也就是你从服务商那里拿到的那台 SMTP 主机,它对你的身份做认证,然后接过这封信。第五环是收件方的邮件系统,它要做反垃圾判定、病毒扫描,决定是投进收件箱、扔进垃圾箱,还是直接拒收。最后一环才是用户打开客户端。

我之所以先把这条链路摆出来,是因为后面所有排查都要用到它。你看到的报错信息,永远只能定位到其中某一环。SMTPAuthenticationError是第四环的认证没过,邮件发出去但用户说没收到,问题大概率在第五环,而UnicodeEncodeError这种,则往往卡在第二环。搞清楚自己在哪一环,比背报错码有用得多。

还有一点值得提前说清楚:Django 本身不会发邮件,它只是个“适配层”。真正干活的是你配置的那台 SMTP 服务器。所以很多时候你以为是 Django 的问题,实际上是服务商的策略限制、端口封锁或者配额用完了。我把这条链路分成“代码侧”和“服务侧”两半来看,能省掉大量无意义的调试时间。

1.2 三种接入方式的取舍:直连、API、自建

接入邮件能力,市面上主要有三条路:SMTP 直连、云厂商的邮件推送 API、以及自己搭一套邮件服务器。三者的差别不是“高级不高级”,而是运维成本、可控性和送达率之间的权衡。

方式接入成本送达率可控性适合场景
SMTP 直连服务商低,改几个配置就行中到高,取决于域名信誉中,受服务商策略约束中小项目、内部通知、验证码
云厂商推送 API中,要写请求封装高,服务商帮你做信誉维护中低,黑盒较多营销邮件、大批量通知
自建邮件服务器高,要维护 IP 信誉、反垃圾策略初期很低,需要长期养最高有专门运维团队、发信量极大

我自己的默认选择永远是第一种。原因很实际:中小项目的发信量通常在每天几十到几千封,SMTP 直连完全扛得住,而且 Django 原生支持,连第三方库都不用装。等到发信量涨到每天几万封,或者你需要精细的投递统计、退信处理、点击追踪,再考虑换成 API 方案。

自建这条路我劝绝大多数人别碰。不是技术难,而是“养 IP 信誉”这件事太耗时间——新 IP 直接发信,被主流邮箱判定为垃圾的概率高得离谱,你得慢慢预热,先小量发、等高打开率的用户互动,再逐步放量,整个周期以周甚至月计。除非你有专门的团队盯着这块,否则投入产出比极低。

还有一个中间选项值得提一句:很多云厂商既提供 SMTP 接口,也提供 HTTP API,用的是同一套账号。这意味着你可以先用 SMTP 快速跑通,后面量大了再平滑切到 API,配置层换个 backend 就行,业务代码基本不动。这种“留后路”的设计思路,我在做技术选型时一直很看重。

1.3 一份最小的信息清单:开搞之前先把这些凑齐

在动settings.py之前,先确认你手上有这几样东西,缺一个都跑不通:

  • SMTP 服务器地址:形如smtp.xxxx.com,注意不是网页版的地址,也不是 IMAP 地址
  • 端口号:常见 465 或 587,个别服务商还会给 25
  • 加密方式:SSL 还是 STARTTLS,这个必须和端口匹配
  • 登录账号:通常是你的完整邮箱地址
  • 授权码或专用密码:注意,绝大多数服务商现在都不允许用网页登录密码来发信
  • 发件人地址:可以和登录账号一致,也可以是不一致的别名(需要在服务商后台先验证)
  • 发件人显示名:用户收件时看到的名字,中文没问题

最后一项经常被忽略。同样是发验证码,noreply@xxx.com和一个有明确中文名的发件人,用户的信任度完全不一样,后者被当成垃圾邮件的概率也低一些。这个细节后面讲垃圾邮件的时候还会提到。

提示:如果你的服务商后台有“客户端专用密码”“授权码”“应用密码”这类选项,优先用这个,而不是账号的登录密码。原因很简单——登录密码一旦泄露是全账号沦陷,而授权码可以单独吊销、单独限权,出事了只影响发信这一件事。

2. settings 里的每一个参数都不是摆设

2.1 EMAIL_BACKEND:换个值,业务代码一行不用改

Django 把发信抽象成了一个 backend 接口,EMAIL_BACKEND指向哪个类,send_mail()就交给谁处理。这个设计的妙处在于,你可以用同一套业务代码,在开发、测试、生产三种环境下走完全不同的发信路径。

常用的几个 backend:

# 开发环境:不真的发信,把内容打到控制台 EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" # 测试环境:只存在内存里,测试用例可以断言发了几封 EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" # 本地调试:把邮件写成一个 .eml 文件,可以用客户端打开看 EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend" EMAIL_FILE_PATH = BASE_DIR / "sent_emails" # 生产环境:真发 EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"

我强烈建议在开发环境用console。原因很实在:本地开发时频繁触发注册、找回密码,如果真往 SMTP 发,一来浪费服务商配额,二来你根本没配好就得先解决配置问题,三来测试跑一遍就是几十封邮件,很容易触发限流。用 console,邮件内容直接打在终端里,改模板、调格式都方便。

locmem这个 backend 很多人不知道,但写单元测试特别好用。django.core.mail.outbox会收集所有“发出去”的邮件,你可以直接断言len(mail.outbox) == 1、mail.outbox[0].subject是什么,完全不产生网络请求,测试跑得飞快。

至于生产环境,就老老实实用smtp.EmailBackend。有些人想自己写一个 backend 加日志、加重试,我的建议是先别,用现成的 backend 加中间层封装更省事,后面第 4 节会讲怎么包。

2.2 端口与加密:465 和 587 到底差在哪

这是新手最容易栽的地方,也是“配置看起来都对但就是连不上”的头号原因。三个端口的区别必须搞清楚:

端口加密方式Django 配置说明
465隐式 SSL(连接建立即加密)EMAIL_USE_SSL = True老牌配置,兼容性最好,很多服务商主推
587显式 STARTTLS(先明文再升级)EMAIL_USE_TLS = True现代标准,部分服务商只开放这个
25通常不加密或 STARTTLS视情况大量环境封禁,能不用就不用

关键点在于:EMAIL_USE_SSL和EMAIL_USE_TLS不能同时为True,Django 会直接抛ValueError。这是有意为之的错误提示,因为这两个代表的是两种互斥的握手方式。

为什么会有这两种?SSL 那一套是历史遗留——连接一建立就套上加密层,协议层根本不知道有明文这回事。STARTTLS 则是后来设计的“先明说我是谁,再协商加密”,好处是可以在明文阶段先做能力协商,防火墙也能识别出这是一条邮件连接。两者功能上等价,只是握手时序不同。

实际操作中,我一般是这样判断的:服务商文档写 465 就USE_SSL=True,写 587 就USE_TLS=True,别自己发挥。如果文档没写清楚,两个都试一遍,看哪个能连通。有一个例外值得注意:部分企业内网环境下 465 会被安全设备拦截,而 587 通常放行,这种时候就只能用 587。

注意:25 端口在大批云主机的出入站规则里默认是被封的,这是为了抑制垃圾邮件。你如果拿到的是 25 端口的配置,先别急着改代码,先去服务商控制台看端口是否开放,很多人在这里白白调了两小时。

2.3 认证信息与默认发件人:最容易混淆的两个字段

EMAIL_HOST_USER和DEFAULT_FROM_EMAIL这两个字段,名字看起来都是“发件人”,但干的完全不是一回事。

EMAIL_HOST_USER是你的登录身份,用于向 SMTP 服务器证明“我是这台机器的合法使用者”。它通常就是你的完整邮箱地址,配合EMAIL_HOST_PASSWORD做认证。有些服务商还支持用一组单独的 API Key 做用户名,具体看文档。

DEFAULT_FROM_EMAIL是信封上写的发件人,用户在邮件客户端里看到的就是这个。它可以和登录身份完全不同——比如你用noreply@xxx.com登录,但发出去的信显示成系统通知 <notice@xxx.com>。

这里有个坑:很多服务商验证你是否有权使用某个发件人地址。如果你设的DEFAULT_FROM_EMAIL和登录账号不一致,又没有在服务商后台把这个别名加进白名单,邮件会被拒收,报错通常是550 Sender address rejected或者553 Mail from must equal authorized user。

所以在配置阶段,最省事的做法是先让两者保持一致,等确认能发通了,再去服务商后台添加别名,然后改DEFAULT_FROM_EMAIL。一次只改一个变量,出问题好定位。

SERVER_EMAIL这个字段经常被人忽略。它专门用于发送系统级邮件——比如DEBUG=False时 Django 自动发给管理员的 500 错误报告。它和DEFAULT_FROM_EMAIL分开的原因很实际:系统告警和业务邮件的发件人应该是两个不同的地址,方便你在邮箱里做规则分类。我一般会把它设成alert@域名这样的形式。

2.4 超时、主题前缀与静默失败:几个决定体验的小参数

EMAIL_TIMEOUT是必须显式设置的一个参数,默认值是None,意味着永不超时。这在生产环境是灾难性的——一旦 SMTP 服务器卡住,你的整个请求线程就跟着挂在那儿,一直等到操作系统层面的 TCP 超时,可能是几分钟。

我的经验值是 10 到 20 秒。太短了网络抖动一下就失败,太长了用户等待体验差。如果发信是在同步请求里做的(不推荐,后面会讲),建议压到 8 到 10 秒。

EMAIL_TIMEOUT = 15

EMAIL_SUBJECT_PREFIX默认是"[Django] ",会被自动加到所有mail_admins()和mail_managers()发出的邮件主题前面。这个前缀设计的初衷是方便你在收件箱里做过滤规则,我一般会改成项目名缩写,比如"[订单系统] ",比"[Django] "有辨识度得多。

fail_silently这个参数在send_mail()里有,不在 settings 里,但它决定了一件重要的事:发信失败时是抛异常还是返回False。默认是False,也就是抛异常。这个默认值是对的——至少你第一时间知道出问题了。但在某些场景下,比如批量通知里某一封失败不该影响整体,就该传True然后自己记日志。

from django.core.mail import send_mail # 关键通知:失败要立刻知道 send_mail("验证码", "123456", None, ["user@example.com"], fail_silently=False) # 边缘通知:失败记日志,不打断流程 ok = send_mail("周报", "...", None, ["user@example.com"], fail_silently=True) if not ok: logger.warning("周报发送失败")

3. 从零到一发:完整实操流程与代码落地

3.1 第一步:拿到授权码,而不是登录密码

这一步看起来是“去后台点点鼠标”,但它是整条链路能否成立的前提。流程通常是这样的:登录服务商的邮箱管理后台,找到“账号与安全”或“客户端设置”这一类入口,开启 SMTP 服务,然后生成一个专用的授权码。

生成出来的通常是一串十六位左右的字母数字,只显示一次,关掉页面就看不到了,记得先存下来。有些服务商还会要求你先绑定手机、完成实名,才能开启这个功能,提前准备好。

这个授权码就是你后面要填进EMAIL_HOST_PASSWORD的东西。不要填网页登录密码,绝大多数服务商已经禁用了这种用法,填错了会直接返回认证失败。而且从安全角度看,授权码可以随时吊销重发,泄露了损失可控。

顺便说一句,授权码这种机制本质上是把“长期凭证”换成了“可撤销的专用凭证”,思路和很多系统的 API Key 是一致的。你如果在别的项目里也遇到“为什么要单独申请密钥”的疑问,道理是一样的。

3.2 第二步:敏感配置走环境变量,别硬编码在 settings 里

我见过太多项目把授权码直接写在settings.py然后提交到了代码仓库。这种事故一旦发生,唯一的补救办法就是去后台吊销重发,但如果仓库是公开的,泄露期间可能已经被人拿去滥发垃圾邮件,账号信誉直接归零。

正确的做法是用环境变量,配合django-environ或者直接os.environ读取:

# settings.py import os EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" EMAIL_HOST = os.environ.get("EMAIL_HOST", "smtp.example.com") EMAIL_PORT = int(os.environ.get("EMAIL_PORT", "465")) EMAIL_USE_SSL = os.environ.get("EMAIL_USE_SSL", "true").lower() == "true" EMAIL_USE_TLS = False EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") EMAIL_TIMEOUT = 15 DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "系统通知 <noreply@example.com>") SERVER_EMAIL = os.environ.get("SERVER_EMAIL", "alert@example.com")

然后在项目根目录放一个.env(记得加进.gitignore),或者直接在部署平台的密钥管理里配。本地开发时用console.EmailBackend,生产环境通过环境变量注入,两套配置互不干扰。

这里有个小细节:EMAIL_PORT从环境变量读出来是字符串,必须int()转一下。Django 内部做 socket 连接时如果拿到字符串端口,报的错很奇怪,什么a bytes-like object is required之类,排查起来很浪费时间。

3.3 第三步:五行代码先跑通连通性

配置写完先别急着写业务逻辑,用最短的代码验证一次。我习惯在manage.py shell里直接跑:

python manage.py shell
from django.core.mail import send_mail send_mail( subject="连通性测试", message="如果你能看到这封信,说明整条链路是通的。", from_email=None, # None 表示用 DEFAULT_FROM_EMAIL recipient_list=["your-address@example.com"], fail_silently=False, )

from_email传None是很多新人不清楚的一点,它等价于使用DEFAULT_FROM_EMAIL。这个传参设计挺省事,你在业务代码里基本不用关心发件人是谁,统一在 settings 里配。

跑通的表现是终端返回1,表示成功发送了一封。如果抛异常,报错信息会告诉你卡在哪一环,对照第 5 节的速查表基本能定位。

这一步特别重要,因为它把“配置问题”和“业务代码问题”彻底隔离开了。如果这一步就不通,你后面写多少业务逻辑都是白费;如果这一步通了,那后面出问题就一定是代码逻辑或者触发条件的问题,排查范围一下子缩小了一半。

3.4 第四步:封装成服务层,别到处散落 send_mail

连通性验证完之后,大部分人的做法是在视图里直接调send_mail。小项目这么干没问题,但只要发信点超过三个,你就会开始遇到这些麻烦:主题格式不统一、HTML 和纯文本版本不一致、失败没日志、改一个发件人要全局搜索替换。

我的做法是抽一个轻量的服务层出来。不用搞什么设计模式的复杂度,一个模块加几个函数就够了:

# apps/notify/mailer.py import logging from django.core.mail import EmailMultiAlternatives from django.conf import settings from django.template.loader import render_to_string logger = logging.getLogger(__name__) def send_html_mail(*, subject, template, context, to, cc=None, bcc=None, attachments=None): """ 发送 HTML + 纯文本双版本邮件。 to 支持 str 或 list,统一转成 list。 """ if isinstance(to, str): to = [to] html_body = render_to_string(template, context) # 纯文本版本建议单独建模板,实在没有就用 HTML 去标签的简易版 text_body = render_to_string(template.replace(".html", ".txt"), context) msg = EmailMultiAlternatives( subject=subject, body=text_body, from_email=settings.DEFAULT_FROM_EMAIL, to=to, cc=cc or [], bcc=bcc or [], ) msg.attach_alternative(html_body, "text/html") for name, content, mimetype in (attachments or []): msg.attach(name, content, mimetype) try: sent = msg.send(fail_silently=False) logger.info("mail sent subject=%s to=%s count=%s", subject, to, sent) return True except Exception: logger.exception("mail send failed subject=%s to=%s", subject, to) return False

这个封装解决了几个具体问题。

第一,强制双版本。只发 HTML 的邮件,在部分客户端里会显示得很奇怪,甚至被判为可疑。同时提供text/plain和text/html两个 alternative,客户端会挑它支持的最好的那个渲染,这是标准做法。

第二,收件人参数统一。to允许传字符串也允许传列表,调用方不用记类型。别小看这个,to="user@example.com"传成字符串时,Django 会把它当成字符序列,一个字母一个收件人,报错信息还特别隐晦。

第三,统一日志。所有发信都走这一个出口,成功失败都有记录,以后排查的时候不用满项目找哪里发了信。

第四,附件统一处理。msg.attach()接受三种签名,封装之后调用方只需要传(文件名, 内容, MIME类型)三元组,不用关心细节。中文文件名需要做编码处理,Django 的attach内部已经处理好了,但你得确保传进去的是str而不是bytes。

3.5 第五步:模板邮件的组织方式

邮件模板和网页模板虽然都是 Django 模板,但约束完全不一样。网页可以随便用现代 CSS,邮件不行——主流邮件客户端对 CSS 的支持停留在大约十年前的水平,flex、grid、外部样式表统统不可靠。

我踩过的具体坑:用 flex 布局做的按钮,在某个客户端里直接塌成一坨;用background-image做的头部,在另一个客户端里一片空白;外部 CSS 文件?基本全军覆没。最后的结论是:邮件 HTML 只能用 table 布局加行内样式,而且尽量简单。

模板目录我一般这样组织:

templates/ emails/ verify_code.html verify_code.txt weekly_report.html weekly_report.txt _base.html

_base.html放公用的头部、尾部、配色,具体模板{% extends %}它。文件名成对出现,.html给富客户端,.txt给纯文本客户端和反垃圾系统。很多反垃圾规则会检查“这封邮件有没有纯文本版本”,只有 HTML 的邮件会被扣分。

内联样式的写法建议直接在模板里写style="...",别指望<style>标签,有些客户端会把它整个剥掉。图片尽量用绝对 URL,而且必须是公网可访问的地址,相对路径在邮件里没有任何意义——收件人的客户端可不知道你的域名。

3.6 第六步:把发信接进真实业务场景

配好了发信能力,接下来是怎么用。根据我这几年做过的项目,发信场景基本可以归成四类,每类的设计要点完全不同:

场景触发频率实时性要求失败处理策略
验证码、找回密码中,与用户量正相关极高,用户等着立即重试一次,仍失败则提示换方式
订单、支付通知中高,但可容忍几十秒延迟进队列重试,最多三次
系统告警低中重试并降级到其他通知渠道
周报、营销可批量低批量发送,失败记录不重试

验证码这类场景有个特殊要求:必须让用户知道发没发出去。我的做法是把发信结果直接反馈到接口返回值里,前端根据这个显示“已发送”或者“发送失败,请稍后重试”。如果后端吞掉异常返回成功,用户就会一直等一封永远不来的邮件,体验极差。

订单通知就可以宽松一些。它不影响主流程,失败了重试即可,用户也不需要立刻知道。这类场景适合扔进队列,用后台任务慢慢发。

系统告警比较特殊,它要求“一定能送到”,所以我的做法是邮件只是其中一路,同时接站内信或者别的渠道,任何一路成功就算成功。单靠邮件一路,万一邮件服务商挂了,告警就彻底失联了。

批量场景要注意的是分批和限速。一次send_mass_mail发五千封,服务商那边很可能直接把你限流甚至临时封禁账号。后面 4.2 节会具体算这个。

4. 让它稳定:异步、限流、重试与可观测

4.1 为什么同步发信是性能陷阱

先说结论:任何在 HTTP 请求周期内同步发邮件的代码,都是定时炸弹。

道理不难算。一次 SMTP 发信包含 TCP 连接建立、TLS 握手、身份认证、邮件投递、连接关闭,正常情况 300 毫秒到 1 秒。你的接口本来 50 毫秒返回,加上发信就变成 1 秒,用户感知非常明显。如果服务商网络抖动,加上 15 秒超时,你的 worker 就被占住 15 秒。假设你有 4 个 worker 进程,并发进来 5 个注册请求,第 5 个就得排队等前面某个超时结束——这就是典型的雪崩前兆。

更糟的是,Django 的 SMTP backend 默认每次发信都新建连接。你要是循环发 100 封通知,就是 100 次 TCP + TLS 握手,光握手开销就能吃掉好几秒。

解决办法有两条路。轻量一点的,用线程池把发信扔到后台:

# 简易版:适合小项目,进程重启会丢任务 from concurrent.futures import ThreadPoolExecutor from django.conf import settings _executor = ThreadPoolExecutor(max_workers=getattr(settings, "MAIL_WORKERS", 4)) def send_async(func, *args, **kwargs): _executor.submit(func, *args, **kwargs)

稳妥一点的,用任务队列,把发信包装成一个任务,由 worker 消费:

# tasks.py(以常见的分布式任务框架为例,伪代码示意结构) from celery import shared_task from .mailer import send_html_mail @shared_task(bind=True, max_retries=3, default_retry_delay=60) def task_send_mail(self, subject, template, context, to): ok = send_html_mail(subject=subject, template=template, context=context, to=to) if not ok: # 指数退避:60s、120s、240s raise self.retry(countdown=60 * (2 ** self.request.retries))

线程池方案的问题在于进程重启会丢任务,而且没法跨机器扩展。但它在改造成本上几乎为零,项目只有几百个用户的时候完全够用。等业务量上来了再换队列,接口层不用动。

队列方案的关键是重试策略。我一般设三次重试,间隔按指数增长。为什么是指数而不是固定间隔?因为发信失败往往是服务商侧的问题,固定间隔重试只是把同样的失败重复三次,而指数退避给了对方恢复的时间窗口。

4.2 配额与限流:被服务商摁住的几种典型表现

服务商给你的账号都是有配额的,只是多数人不去看文档。常见的限制维度有三类:

  • 日发送量上限:个人邮箱账号常见是几百封/天,企业邮箱可能到几千
  • 单连接收件人上限:一封邮件最多多少个收件人,通常是几十
  • 频率限制:每分钟或每小时最多多少封,超了会临时拒绝连接

超限的表现各不相同。有的是直接返回421 Too many connections,有的是返回550加一段说明,还有的更隐蔽——不报错,但邮件被静默丢弃。最后这种最坑,你的日志里全是发送成功,用户就是收不到。

所以我一直强调要记录服务商侧的返回信息。Django 的send_mail只返回成功封数,拿不到详细响应。要看到更细的信息,需要用msg.send()配合底层的connection,或者干脆看服务商后台的发送记录。

批量发送的实践是分批加间隔。假设你要发 2000 封周报,服务商限制是每分钟 200 封,那可以按每批 100 封、批间隔 35 秒来发,2000 封大约需要 12 分钟。这个算法很简单但要算清楚,宁可慢一点,别触发限流被封号——账号一旦进了黑名单,解封流程比你想的麻烦得多。

顺带提一句连接复用。Django 支持在一次会话里复用同一个 SMTP 连接发多封:

from django.core import mail with mail.get_connection() as connection: for user in users: msg = build_message(user) msg.connection = connection msg.send()

这样 N 封信只需要 1 次握手,速度能快好几倍。缺点是连接保持期间如果出错,后续的信都会受影响,所以要配合 try/except 逐封处理。

4.3 日志该记什么:为三个月后的自己留证据

发信功能的日志,我建议至少记这几项:

  • 发送时间:精确到秒,方便和服务商后台的记录对齐
  • 收件人:完整地址,脱敏的话保留域名部分
  • 主题:能快速判断是哪类邮件
  • 结果:成功/失败,失败的话记异常类型和异常信息
  • 耗时:从发起到返回的毫秒数,用来发现性能退化
  • 消息 ID:如果服务商返回了Message-ID,一定要记下来,这是跟服务商扯皮时唯一的凭证
import logging import time logger = logging.getLogger("apps.notify") start = time.monotonic() try: sent = msg.send() logger.info( "mail ok subject=%s to=%s sent=%s cost_ms=%d", subject, to, sent, (time.monotonic() - start) * 1000, ) except Exception as exc: logger.error( "mail fail subject=%s to=%s exc=%s cost_ms=%d", subject, to, type(exc).__name__, (time.monotonic() - start) * 1000, exc_info=True, )

exc_info=True这一项别省。很多人只记str(exc),结果日志里只有一句Connection refused,完全不知道是哪个地址、哪个端口被拒。带上堆栈,至少能看到是socket.create_connection还是ssl.wrap_socket出的问题。

还有一个容易忽略的点:别把授权码写进日志。有些调试代码图省事,把整个 settings 打出来,密码就跟着进日志文件了。日志文件的访问权限通常比配置文件宽松得多,等于把密码公开了。

4.4 投递之后的环节:为什么发了不等于到了

这一节的内容经常被跳过,但它决定了你的邮件能不能真正被用户看到。信从你的 SMTP 服务器发出去,到收件方的邮件系统,对方会做一系列检查:发信 IP 的信誉、域名的 SPF 记录、DKIM 签名是否有效、DMARC 策略是否匹配、内容里有没有可疑关键词。

SPF是一条 DNS 记录,声明“哪些服务器有权用我这个域名发信”。你的服务商一般会给你一段配置值,你加到域名解析里就行。少了这一条,收件方无法确认这封信是不是你发的,扣分很重。

DKIM是用私钥给邮件加签名,收件方用 DNS 里的公钥验证。服务商后台通常有开关,打开之后给你一段 DNS 记录,配好就生效。它能证明邮件内容在传输过程中没被篡改。

DMARC是最上层的策略,告诉收件方“如果 SPF 和 DKIM 都不过,你该怎么处理这封信”——是拒收、隔离,还是放行但标记。建议初期用p=none只做监控不拦截,观察一段时间看有没有误伤,再逐步收紧。

这三条配齐,你的邮件进收件箱的概率会显著提升。不配的话,即使能发出去,也很可能直接进垃圾箱,而用户是不会去翻垃圾箱的。

5. 排查实录:那些年发不出去的邮件

5.1 报错速查:看到这行字就知道往哪查

报错关键字大概率原因处理方向
SMTPAuthenticationError/535密码填了登录密码而不是授权码重新生成授权码
SMTPAuthenticationError/534服务商开启了二次验证,需要专用密码去后台开应用专用密码
ConnectionRefusedError端口不对,或者被防火墙拦了换 465/587 试,检查出站规则
TimeoutError/SMTPServerDisconnected网络不通或服务商侧限流加超时、降频率、看服务商状态
socket.gaierror主机地址写错,或者 DNS 解析不了核对EMAIL_HOST,注意别带协议前缀
550 Sender address rejected发件人地址未经授权在服务商后台验证别名
552 Message size exceeds附件太大压缩附件或改用下载链接
UnicodeEncodeError主题或附件名编码问题用EmailMessage而不是裸字符串
ValueError: EMAIL_USE_TLS and EMAIL_USE_SSL are mutually exclusive两个开关同时开了只留一个

这张表覆盖了我实际遇到过的大约八成问题。剩下两成往往是组合问题,得逐项排除。

5.2 邮件发成功了但用户说没收到

这种情况最需要耐心,因为你的系统里一切正常。排查顺序我一般是这样的:

第一步,确认到底发出了没有。看日志里的sent返回值,看服务商后台的发送记录。如果服务商后台有记录,说明信确实交出去了,问题在收件方。

第二步,问收件人查一下垃圾箱。很多客户端的垃圾箱和“推广”标签是分开的,用户可能只翻了收件箱。这一步能解决相当一部分“没收到”。

第三步,换一个收件地址测试。同一封邮件发给另一个域名的邮箱,如果那个能收到,说明问题在特定收件方的判定规则上,而不是你的发信配置。

第四步,检查内容。主题里带感叹号、正文里纯图片、链接指向不明域名、附件是.exe或者压缩包——这些都容易被判垃圾。验证码邮件里如果用了“立即领取”“限时”这类营销词,被判垃圾的概率也会上升。

第五步,查 SPF/DKIM/DMARC。用第三方的检测工具查一下域名配置,看有没有缺失或者冲突。这一步经常能发现“记录配了但是值写错了”的低级问题。

我印象很深的一次,是折腾了半天发现对方的邮件系统对发信频率做了限制,同一发件人一分钟内超过若干封就静默丢弃。降低频率之后立刻正常。这种规则你在任何文档里都查不到,只能靠观察规律。

5.3 我踩过的几个坑,写下来给后来人

坑一:把EMAIL_HOST写成了完整的 URL。有人填https://smtp.example.com,有人填smtp://smtp.example.com:465。这两个都不对,这里只要纯主机名smtp.example.com,协议和端口由后端根据EMAIL_USE_SSL处理。填错之后报错是gaierror,提示信息完全看不出来是格式问题。

坑二:在settings.py里对布尔值用了字符串。EMAIL_USE_SSL = "True"是能跑的,因为非空字符串在 Python 里为真;但EMAIL_USE_SSL = "False"同样也为真,于是 SSL 被意外开启,连接直接失败。这个 bug 藏得极深,因为配置文件看起来“写的是 False”。用环境变量的时候尤其要注意,一定做显式转换。

坑三:以为send_mail会抛异常就没管返回值。fail_silently=False确实会抛,但只针对连接和认证层面的错误。有些服务商在协议层返回“已接受”但随后退回,这种情况下 Django 拿到的就是成功,异常不会抛,你也就不会知道。定期核对服务商后台的发送统计是必要的。

坑四:忘了EMAIL_TIMEOUT,worker 集体卡死。服务商某次抖动,几十个 worker 全卡在 socket 读上,接口全线超时。加上超时配置之后,最坏情况也只是发信失败,主流程不受影响。这个教训让我在之后所有涉及外部网络调用的地方都强制设超时。

坑五:附件用相对路径打开。本地调试时open("report.pdf")能跑,部署到容器里工作目录变了,直接FileNotFoundError。凡是涉及文件路径,一律用BASE_DIR拼绝对路径。

坑六:在 for 循环里逐封调用send_mail发批量通知。一百个用户就是一百次完整的握手,接口耗时从 1 秒涨到 40 秒。改成连接复用加后台任务之后,接口立刻回到 200 毫秒以内。

最后分享一个我最近才养成的习惯:给邮件功能建一个独立的测试收件箱,在正式上线前,用一个脚本把全部模板各发一遍,用几个不同域名、不同客户端的邮箱分别接收,肉眼确认排版、中文字符、链接跳转、附件名是否都正常。这套流程跑一遍只要五分钟,但能挡掉上线后大量的“用户反馈邮件排版错乱”的问题。做好这一步,比在文档里写多少遍“注意兼容性”都实在。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 2:31:26

AIX 7.1/7.2安装介质获取、校验与装机避坑指南

前几天夜里&#xff0c;一个做运维的老哥给我发消息&#xff0c;说手头一台跑了好几年的业务机系统盘出了问题&#xff0c;业务要赶紧拉起来&#xff0c;结果翻出手里那份 AIX 7.1 的安装镜像&#xff0c;校验一看是坏的&#xff0c;于是开始满世界找 "AIX 7.2 和 AIX 7.1…

作者头像 李华
网站建设 2026/11/2 2:30:39

CS231n Assignment1深度解析:手写KNN与Softmax的底层原理与工程价值

简介&#xff1a;本资源是斯坦福大学CS231n《计算机视觉中的卷积神经网络》课程Assignment 1的完整实践包&#xff0c;面向深度学习初学者、高校学生及自学研究者&#xff0c;聚焦图像分类核心任务——KNN、SVM与两层全连接网络的实现与调优。压缩包共54个文件&#xff0c;含7个…

作者头像 李华
网站建设 2026/11/2 2:30:28

间断有限元求解声波方程:从源码解析到二次开发实战

简介&#xff1a;面向数值分析与偏微分方程求解方向的学习者&#xff0c;这份资源聚焦二维声波方程的间断有限元&#xff08;DG&#xff09;数值实现&#xff0c;将空间离散与三阶龙格库塔时间推进相结合&#xff0c;适合已具备Matlab基础、希望深入理解间断伽辽金方法的中高级…

作者头像 李华
网站建设 2026/11/2 2:27:55

CoreDNS v1.8.0 源码拆包、插件裁剪与 k8s DNS 替换排错

简介&#xff1a;面向 Kubernetes 集群运维与容器网络调试场景&#xff0c;这份 CoreDNS v1.8.0 离线镜像包解决的是集群内 DNS 服务组件在无外网或受限网络环境下的部署与升级问题&#xff0c;适用于正在搭建 k8s v1.21.2 集群、需要固定组件版本的开发与运维人员。压缩包共 8…

作者头像 李华
网站建设 2026/11/2 2:27:51

JVM内存管理:从永久代到元空间的演进与优化

1. 从永久代到元空间&#xff1a;JVM内存管理的进化之路记得2014年JDK8发布时&#xff0c;最让我震惊的改动莫过于永久代&#xff08;PermGen&#xff09;的移除。当时手头维护的几个老系统还在用JDK6&#xff0c;升级过程中频繁遇到"java.lang.OutOfMemoryError: PermGen…

作者头像 李华
网站建设 2026/11/2 2:25:26

概率方法第三版:从存在性证明到局部引理与随机图相变

简介&#xff1a;《The Probabilistic Method》第三版英文原版PDF&#xff0c;面向数学与计算机科学方向的研究生、教师及科研人员&#xff0c;系统讲解如何借助概率论工具证明组合对象的存在性并分析随机算法&#xff0c;适合组合数学、图论、理论计算机与复杂性理论等方向的学…

作者头像 李华