Files
vscode-workbench/.trae/documents/plan-fix-email-bounce-error.md
T

7.5 KiB
Raw Blame History

修复邮件发送异常处理:MX记录验证 + 后端日志记录

摘要

当用户输入不存在域名的邮箱(如 sync4@example.com)时,QQ SMTP 服务器会接受邮件但后续产生 bounce,退信被发回 FROM 地址(cs10086086@qq.com)。由于 FROM 地址就是用户自己的 QQ 邮箱,用户收到了退信而非验证码。需要:

  1. 在发送邮件前验证邮箱域名有效性(MX 记录)
  2. 将异常信息记录到后端 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 表

根因

  1. SendUserEmailAPIView 缺少 MX 记录验证 — ForgotPasswordSendCodeAPIView 已有但未复用到其他端点
  2. QQ SMTP 对无效域名不会立即报错,而是在异步退信中通知 FROM 地址
  3. FROM 地址 (cs10086086@qq.com) 就是用户自己的邮箱,退信直接发给了用户
  4. _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 异常捕获和日志

验证步骤

  1. 用 sync4@example.com 测试 POST /user/send_user_email/ → 应返回 20023 EMAIL_DOMAIN_INVALID 而非 200/201
  2. 用 sync4@example.com 测试 POST /user/send_email_code/ → 应返回 20023 EMAIL_DOMAIN_INVALID
  3. 确认后端 ErrorLog 表中记录了 [WARNING] Domain MX check failed 日志
  4. 确认用户不再收到 SMTP 退信
  5. 用正常邮箱(如 testuser@qq.com)测试 → 功能不受影响,正常发送验证码