2026/11/2 2:31:31

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

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这个必须和端口匹配登录账号通常是你的完整邮箱地址授权码或专用密码注意绝大多数服务商现在都不允许用网页登录密码来发信发件人地址可以和登录账号一致也可以是不一致的别名需要在服务商后台先验证发件人显示名用户收件时看到的名字中文没问题最后一项经常被忽略。同样是发验证码noreplyxxx.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不能同时为TrueDjango 会直接抛ValueError。这是有意为之的错误提示因为这两个代表的是两种互斥的握手方式。为什么会有这两种SSL 那一套是历史遗留——连接一建立就套上加密层协议层根本不知道有明文这回事。STARTTLS 则是后来设计的“先明说我是谁再协商加密”好处是可以在明文阶段先做能力协商防火墙也能识别出这是一条邮件连接。两者功能上等价只是握手时序不同。实际操作中我一般是这样判断的服务商文档写 465 就USE_SSLTrue写 587 就USE_TLSTrue别自己发挥。如果文档没写清楚两个都试一遍看哪个能连通。有一个例外值得注意部分企业内网环境下 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是信封上写的发件人用户在邮件客户端里看到的就是这个。它可以和登录身份完全不同——比如你用noreplyxxx.com登录但发出去的信显示成系统通知 noticexxx.com。这里有个坑很多服务商验证你是否有权使用某个发件人地址。如果你设的DEFAULT_FROM_EMAIL和登录账号不一致又没有在服务商后台把这个别名加进白名单邮件会被拒收报错通常是550 Sender address rejected或者553 Mail from must equal authorized user。所以在配置阶段最省事的做法是先让两者保持一致等确认能发通了再去服务商后台添加别名然后改DEFAULT_FROM_EMAIL。一次只改一个变量出问题好定位。SERVER_EMAIL这个字段经常被人忽略。它专门用于发送系统级邮件——比如DEBUGFalse时 Django 自动发给管理员的 500 错误报告。它和DEFAULT_FROM_EMAIL分开的原因很实际系统告警和业务邮件的发件人应该是两个不同的地址方便你在邮箱里做规则分类。我一般会把它设成alert域名这样的形式。2.4 超时、主题前缀与静默失败几个决定体验的小参数EMAIL_TIMEOUT是必须显式设置的一个参数默认值是None意味着永不超时。这在生产环境是灾难性的——一旦 SMTP 服务器卡住你的整个请求线程就跟着挂在那儿一直等到操作系统层面的 TCP 超时可能是几分钟。我的经验值是 10 到 20 秒。太短了网络抖动一下就失败太长了用户等待体验差。如果发信是在同步请求里做的不推荐后面会讲建议压到 8 到 10 秒。EMAIL_TIMEOUT 15EMAIL_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, [userexample.com], fail_silentlyFalse) # 边缘通知失败记日志不打断流程 ok send_mail(周报, ..., None, [userexample.com], fail_silentlyTrue) 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, 系统通知 noreplyexample.com) SERVER_EMAIL os.environ.get(SERVER_EMAIL, alertexample.com)然后在项目根目录放一个.env记得加进.gitignore或者直接在部署平台的密钥管理里配。本地开发时用console.EmailBackend生产环境通过环境变量注入两套配置互不干扰。这里有个小细节EMAIL_PORT从环境变量读出来是字符串必须int()转一下。Django 内部做 socket 连接时如果拿到字符串端口报的错很奇怪什么a bytes-like object is required之类排查起来很浪费时间。3.3 第三步五行代码先跑通连通性配置写完先别急着写业务逻辑用最短的代码验证一次。我习惯在manage.py shell里直接跑python manage.py shellfrom django.core.mail import send_mail send_mail( subject连通性测试, message如果你能看到这封信说明整条链路是通的。, from_emailNone, # None 表示用 DEFAULT_FROM_EMAIL recipient_list[your-addressexample.com], fail_silentlyFalse, )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, ccNone, bccNone, attachmentsNone): 发送 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( subjectsubject, bodytext_body, from_emailsettings.DEFAULT_FROM_EMAIL, toto, cccc or [], bccbcc 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_silentlyFalse) 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允许传字符串也允许传列表调用方不用记类型。别小看这个touserexample.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_workersgetattr(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(bindTrue, max_retries3, default_retry_delay60) def task_send_mail(self, subject, template, context, to): ok send_html_mail(subjectsubject, templatetemplate, contextcontext, toto) if not ok: # 指数退避60s、120s、240s raise self.retry(countdown60 * (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_infoTrue, )exc_infoTrue这一项别省。很多人只记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 都不过你该怎么处理这封信”——是拒收、隔离还是放行但标记。建议初期用pnone只做监控不拦截观察一段时间看有没有误伤再逐步收紧。这三条配齐你的邮件进收件箱的概率会显著提升。不配的话即使能发出去也很可能直接进垃圾箱而用户是不会去翻垃圾箱的。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_silentlyFalse确实会抛但只针对连接和认证层面的错误。有些服务商在协议层返回“已接受”但随后退回这种情况下 Django 拿到的就是成功异常不会抛你也就不会知道。定期核对服务商后台的发送统计是必要的。坑四忘了EMAIL_TIMEOUTworker 集体卡死。服务商某次抖动几十个 worker 全卡在 socket 读上接口全线超时。加上超时配置之后最坏情况也只是发信失败主流程不受影响。这个教训让我在之后所有涉及外部网络调用的地方都强制设超时。坑五附件用相对路径打开。本地调试时open(report.pdf)能跑部署到容器里工作目录变了直接FileNotFoundError。凡是涉及文件路径一律用BASE_DIR拼绝对路径。坑六在 for 循环里逐封调用send_mail发批量通知。一百个用户就是一百次完整的握手接口耗时从 1 秒涨到 40 秒。改成连接复用加后台任务之后接口立刻回到 200 毫秒以内。最后分享一个我最近才养成的习惯给邮件功能建一个独立的测试收件箱在正式上线前用一个脚本把全部模板各发一遍用几个不同域名、不同客户端的邮箱分别接收肉眼确认排版、中文字符、链接跳转、附件名是否都正常。这套流程跑一遍只要五分钟但能挡掉上线后大量的“用户反馈邮件排版错乱”的问题。做好这一步比在文档里写多少遍“注意兼容性”都实在。