206 lines
7.5 KiB
Markdown
206 lines
7.5 KiB
Markdown
# 修复邮件发送异常处理: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`)测试 → 功能不受影响,正常发送验证码
|