# 修复邮件发送异常处理: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 异常并记录日志 ```python # 修改前 (第 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 记录验证 ```python # 修改前 (第 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 记录验证 ```python # 修改前 (第 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`: ```python 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` ```python # 新建文件: 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` 中导入: ```python 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`)测试 → 功能不受影响,正常发送验证码