7.5 KiB
7.5 KiB
修复邮件发送异常处理:MX记录验证 + 后端日志记录
摘要
当用户输入不存在域名的邮箱(如 sync4@example.com)时,QQ SMTP 服务器会接受邮件但后续产生 bounce,退信被发回 FROM 地址(cs10086086@qq.com)。由于 FROM 地址就是用户自己的 QQ 邮箱,用户收到了退信而非验证码。需要:
- 在发送邮件前验证邮箱域名有效性(MX 记录)
- 将异常信息记录到后端 ErrorLog,而不是让用户收到退信
当前状态分析
邮件发送流程
前端 → SendUserEmailAPIView → 储存验证码到 Redis → 返回成功 → submit_task (Celery) → _send_html_email → email.send() (QQ SMTP)
↓
SMTP 返回退信到 cs10086086@qq.com
现有 MX 验证情况
| API 端点 | 文件位置 | MX 验证 |
|---|---|---|
SendUserEmailAPIView |
user/views/user.py:89 |
无 ❌ |
ForgotPasswordSendCodeAPIView |
user/views/user.py:276 |
有 ✅ (第 287 行) |
SendChangeEmailCodeAPIView |
user/views/email.py:37 |
无 ❌ |
现有错误处理
| 层级 | 文件 | 行为 |
|---|---|---|
| View try/except | user.py:132-137, email.py:96-102 |
返回 HTTP 500 + "邮件发送失败" |
safe_task.py:submit_task |
safe_task.py:28-45 |
超时降级同步;失败只记录 warning |
_send_html_email |
tasks.py:62-69 |
没有任何 try/except - SMTP 异常直接穿透 |
| Django LOGGING | settings.py:298-338 |
ERROR 级别 → DatabaseErrorHandler → ErrorLog 表 |
根因
SendUserEmailAPIView缺少 MX 记录验证 —ForgotPasswordSendCodeAPIView已有但未复用到其他端点- QQ SMTP 对无效域名不会立即报错,而是在异步退信中通知 FROM 地址
- FROM 地址 (
cs10086086@qq.com) 就是用户自己的邮箱,退信直接发给了用户 _send_html_email中email.send()没有任何异常捕获,即使 SMTP 抛异常也无法被妥善记录
修改方案
文件 1: chunyu_project\user\tasks.py
改动:在 _send_html_email 中增加 try/except 捕获 SMTP 异常并记录日志
# 修改前 (第 41-69 行)
def _send_html_email(to_email, code, config_key):
config = EMAIL_CONFIG[config_key]
# ... 构建 email ...
email.send()
# 修改后
def _send_html_email(to_email, code, config_key):
import logging
logger = logging.getLogger(__name__)
config = EMAIL_CONFIG[config_key]
# ... 构建 email (不变) ...
try:
email.send()
except Exception as e:
logger.error(
f'[Email] SMTP send failed: to={to_email}, type={config_key}, error={str(e)}',
exc_info=True
)
raise # 重新抛出,让 safe_task 的降级逻辑感知失败
文件 2: chunyu_project\user\views\user.py
改动:在 SendUserEmailAPIView 中增加 MX 记录验证
# 修改前 (第 89-137 行) - 直接发送,无 MX 验证
def post(self, request):
to_email = request.data.get('to_email', None)
if to_email is None or to_email == "":
return ...
try:
# 直接开始发送邮件
...
# 修改后 - 在发送前增加 MX 验证
def post(self, request):
to_email = request.data.get('to_email', None)
if to_email is None or to_email == "":
return create_standardized_error_response(
code=ResponseCode.EMAIL_EMPTY,
status_code=status.HTTP_400_BAD_REQUEST
)
# 新增:验证 MX 记录
if not validate_email_mx(to_email):
logger.warning(f'[Email] Domain MX check failed: email={to_email}')
return create_standardized_error_response(
code=ResponseCode.EMAIL_DOMAIN_INVALID,
status_code=status.HTTP_400_BAD_REQUEST
)
try:
# 原有发送逻辑不变
...
改动:统一 ForgotPasswordSendCodeAPIView 的 MX 验证位置,保持在发送前
此 API 已有 MX 验证(第 287-291 行),保持不动。无需修改。
文件 3: chunyu_project\user\views\email.py
改动:在 SendChangeEmailCodeAPIView 中增加 MX 记录验证
# 修改前 (第 37-102 行) - 无 MX 验证
def post(self, request):
# captcha验证...
serializer = SendEmailCodeSerializer(...)
if not serializer.is_valid():
return ...
try:
email = serializer.save() # save() 中调用 submit_task 发邮件
...
# 修改后 - 在 serializer 验证后、save 发送前增加 MX 验证
def post(self, request):
# captcha验证 (不变)...
serializer = SendEmailCodeSerializer(data=request.data, context={'request': request})
if not serializer.is_valid():
return ...
# 新增:MX 验证
to_email = serializer.validated_data.get('email')
if to_email and not validate_email_mx(to_email):
logger.warning(f'[ChangeEmail] Domain MX check failed: email={to_email}')
record_failure(operation, identifier)
return create_standardized_error_response(
code=ResponseCode.EMAIL_DOMAIN_INVALID,
status_code=status.HTTP_400_BAD_REQUEST
)
try:
email = serializer.save()
...
注意:需要在 email.py 顶部导入 validate_email_mx:
from ..views.user import validate_email_mx
# 或将该函数提取到独立模块 utils/email_utils.py
文件 4 (可选优化): chunyu_project\utils\safe_task.py
改动:增加 email-specific 的错误日志上下文
当前 submit_task 降级失败时只记录 logger.error('submit_task eager fallback failed...'),可以增加邮件相关的上下文(发送目标、类型)。此项为可选优化,本次不做强制要求。
文件 5 (推荐): 提取 validate_email_mx 到独立模块
改动:将 validate_email_mx 从 user/views/user.py 提取到 utils/email_utils.py
# 新建文件: chunyu_project/utils/email_utils.py
import socket
import logging
logger = logging.getLogger(__name__)
def validate_email_mx(email):
"""验证邮箱域名是否有MX记录"""
try:
domain = email.split('@')[1]
socket.getaddrinfo(domain, None, socket.AF_INET, socket.SOCK_STREAM)
return True
except (socket.gaierror, IndexError, Exception):
logger.warning(f'MX record check failed for domain: {email}')
return False
然后在 user/views/user.py 和 user/views/email.py 中导入:
from utils.email_utils import validate_email_mx
修改汇总
| 文件 | 改动类型 | 具体内容 |
|---|---|---|
utils/email_utils.py |
新建 | 提取 validate_email_mx 独立模块 |
user/views/user.py |
修改 | 删除本地 validate_email_mx;SendUserEmailAPIView 增加 MX 验证 |
user/views/email.py |
修改 | SendChangeEmailCodeAPIView 增加 MX 验证 |
user/tasks.py |
修改 | _send_html_email 增加 SMTP 异常捕获和日志 |
验证步骤
- 用
sync4@example.com测试POST /user/send_user_email/→ 应返回20023 EMAIL_DOMAIN_INVALID而非 200/201 - 用
sync4@example.com测试POST /user/send_email_code/→ 应返回20023 EMAIL_DOMAIN_INVALID - 确认后端 ErrorLog 表中记录了
[WARNING] Domain MX check failed日志 - 确认用户不再收到 SMTP 退信
- 用正常邮箱(如
testuser@qq.com)测试 → 功能不受影响,正常发送验证码