修复邮件系统问题:1. 优化邮件存储逻辑确保mailbox_id正确赋值 2. 修复SMTP服务处理逻辑 3. 添加系统诊断接口 4. 新增测试脚本

This commit is contained in:
huangzhenpc
2025-02-26 13:36:40 +08:00
parent 34b1047481
commit a99d59823c
4 changed files with 729 additions and 200 deletions

View File

@@ -1,15 +1,155 @@
from flask import request, jsonify, current_app
import base64
import re
import os
import email
from email import policy
import re
import base64
from datetime import datetime, timedelta
import psutil
import time
from flask import jsonify, request, current_app
from sqlalchemy import or_, func, desc
from . import api_bp
from ..models import get_session, Domain, Mailbox, Email
from ..models import Email, Domain, Mailbox, get_session
from ..services import get_mail_store
# 调试接口 - 检查邮件接收状态
@api_bp.route('/debug_email', methods=['GET'])
def debug_email():
"""
调试接口:检查某个邮箱的邮件状态并提供详细信息
查询参数:
- email: 邮箱地址 (例如: newsadd1test@nosqli.com)
"""
try:
# 获取查询参数
email_address = request.args.get('email')
# 验证邮箱地址是否有效
if not email_address or '@' not in email_address:
return jsonify({
'success': False,
'error': '无效的邮箱地址',
'message': '请提供有效的邮箱地址格式为user@domain.com'
}), 400
# 解析邮箱地址
username, domain_name = email_address.split('@', 1)
result = {
'email_address': email_address,
'username': username,
'domain': domain_name,
'system_info': {},
'logs': [],
'files': []
}
# 查询数据库
db = get_session()
try:
# 查找域名
domain = db.query(Domain).filter_by(name=domain_name).first()
if domain:
result['system_info']['domain'] = {
'id': domain.id,
'name': domain.name,
'active': domain.active,
'created_at': str(domain.created_at) if hasattr(domain, 'created_at') else None
}
# 查找邮箱
mailbox = db.query(Mailbox).filter_by(
domain_id=domain.id,
address=username
).first()
if mailbox:
result['system_info']['mailbox'] = {
'id': mailbox.id,
'address': mailbox.address,
'full_address': f"{mailbox.address}@{domain_name}",
'created_at': str(mailbox.created_at) if hasattr(mailbox, 'created_at') else None
}
# 获取邮件
emails = db.query(Email).filter_by(mailbox_id=mailbox.id).all()
result['system_info']['emails_count'] = len(emails)
result['system_info']['emails'] = []
for email_obj in emails:
email_info = {
'id': email_obj.id,
'subject': email_obj.subject,
'sender': email_obj.sender,
'received_at': str(email_obj.received_at),
'verification_code': email_obj.verification_code if hasattr(email_obj, 'verification_code') else None
}
result['system_info']['emails'].append(email_info)
else:
result['system_info']['mailbox'] = "未找到邮箱记录"
else:
result['system_info']['domain'] = "未找到域名记录"
# 查找文件
email_data_dir = current_app.config.get('MAIL_STORAGE_PATH', 'email_data')
emails_dir = os.path.join(email_data_dir, 'emails')
if os.path.exists(emails_dir):
for file_name in os.listdir(emails_dir):
if file_name.endswith('.eml'):
file_path = os.path.join(emails_dir, file_name)
file_info = {
'name': file_name,
'path': file_path,
'size': os.path.getsize(file_path),
'modified': datetime.fromtimestamp(os.path.getmtime(file_path)).isoformat()
}
# 尝试读取文件内容并检查是否包含收件人地址
try:
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
content = f.read(10000) # 只读取前10000个字符用于检查
if email_address.lower() in content.lower():
file_info['contains_address'] = True
result['files'].append(file_info)
except Exception as e:
file_info['error'] = str(e)
result['files'].append(file_info)
# 检查日志
log_file = current_app.config.get('LOG_FILE', os.path.join('logs', 'email_system.log'))
if os.path.exists(log_file):
try:
with open(log_file, 'r', encoding='utf-8', errors='replace') as f:
# 从日志尾部读取最后200行
lines = f.readlines()[-200:]
for line in lines:
if email_address.lower() in line.lower():
result['logs'].append(line.strip())
except Exception as e:
result['logs'] = [f"读取日志出错: {str(e)}"]
return jsonify({
'success': True,
'debug_info': result
}), 200
finally:
db.close()
except Exception as e:
current_app.logger.error(f"调试邮件出错: {str(e)}")
return jsonify({
'success': False,
'error': '服务器错误',
'message': str(e)
}), 500
# 创建邮箱接口
@api_bp.route('/add_mailbox', methods=['POST', 'GET'])
def add_mailbox():
@@ -408,4 +548,161 @@ def extract_verification_code(body_text, body_html):
if matches:
return matches[0].strip()
return None
return None
# 系统诊断接口
@api_bp.route('/system_check', methods=['GET'])
def system_check():
"""
系统诊断接口:检查邮件系统各组件状态
"""
try:
result = {
'timestamp': datetime.now().isoformat(),
'system_status': 'normal',
'components': {},
'recent_activity': {},
'mailboxes': [],
'storage': {}
}
# 检查系统资源
try:
result['components']['system'] = {
'cpu_percent': psutil.cpu_percent(),
'memory_percent': psutil.virtual_memory().percent,
'disk_usage': psutil.disk_usage('/').percent
}
except Exception as e:
result['components']['system'] = {'error': str(e)}
# 检查数据库状态
db = get_session()
try:
# 获取域名数量
domain_count = db.query(Domain).count()
# 获取邮箱数量
mailbox_count = db.query(Mailbox).count()
# 获取邮件数量
email_count = db.query(Email).count()
# 获取最新邮件
latest_emails = db.query(Email).order_by(Email.received_at.desc()).limit(5).all()
result['components']['database'] = {
'status': 'connected',
'domain_count': domain_count,
'mailbox_count': mailbox_count,
'email_count': email_count
}
# 最近活动
result['recent_activity']['latest_emails'] = [
{
'id': email.id,
'subject': email.subject,
'sender': email.sender,
'received_at': email.received_at.isoformat() if email.received_at else None
} for email in latest_emails
]
# 获取所有活跃邮箱
active_mailboxes = db.query(Mailbox).order_by(Mailbox.id).limit(10).all()
result['mailboxes'] = [
{
'id': mb.id,
'address': mb.address,
'domain_id': mb.domain_id,
'full_address': f"{mb.address}@{mb.domain.name}" if hasattr(mb, 'domain') and mb.domain else f"{mb.address}@unknown",
'email_count': db.query(Email).filter_by(mailbox_id=mb.id).count()
} for mb in active_mailboxes
]
except Exception as e:
result['components']['database'] = {'status': 'error', 'error': str(e)}
finally:
db.close()
# 检查存储状态
email_data_dir = current_app.config.get('MAIL_STORAGE_PATH', 'email_data')
try:
emails_dir = os.path.join(email_data_dir, 'emails')
attachments_dir = os.path.join(email_data_dir, 'attachments')
# 检查目录是否存在
emails_dir_exists = os.path.exists(emails_dir)
attachments_dir_exists = os.path.exists(attachments_dir)
# 计算文件数量和大小
email_files_count = 0
email_files_size = 0
if emails_dir_exists:
for file_name in os.listdir(emails_dir):
if file_name.endswith('.eml'):
email_files_count += 1
email_files_size += os.path.getsize(os.path.join(emails_dir, file_name))
attachment_files_count = 0
attachment_files_size = 0
if attachments_dir_exists:
for file_name in os.listdir(attachments_dir):
attachment_files_count += 1
attachment_files_size += os.path.getsize(os.path.join(attachments_dir, file_name))
result['storage'] = {
'emails_dir': {
'exists': emails_dir_exists,
'path': emails_dir,
'file_count': email_files_count,
'size_bytes': email_files_size,
'size_mb': round(email_files_size / (1024 * 1024), 2) if email_files_size > 0 else 0
},
'attachments_dir': {
'exists': attachments_dir_exists,
'path': attachments_dir,
'file_count': attachment_files_count,
'size_bytes': attachment_files_size,
'size_mb': round(attachment_files_size / (1024 * 1024), 2) if attachment_files_size > 0 else 0
}
}
# 检查最近的邮件文件
if emails_dir_exists and email_files_count > 0:
files = [(os.path.getmtime(os.path.join(emails_dir, f)), f)
for f in os.listdir(emails_dir) if f.endswith('.eml')]
files.sort(reverse=True)
result['recent_activity']['latest_files'] = [
{
'filename': f,
'modified': datetime.fromtimestamp(t).isoformat(),
'age_seconds': int(time.time() - t)
} for t, f in files[:5]
]
except Exception as e:
result['storage'] = {'error': str(e)}
# 整体状态评估
if ('database' in result['components'] and result['components']['database'].get('status') != 'connected'):
result['system_status'] = 'warning'
if not emails_dir_exists or not attachments_dir_exists:
result['system_status'] = 'warning'
return jsonify({
'success': True,
'status': result['system_status'],
'diagnostics': result
}), 200
except Exception as e:
current_app.logger.error(f"系统诊断出错: {str(e)}")
return jsonify({
'success': False,
'error': '系统诊断失败',
'message': str(e)
}), 500

View File

@@ -32,115 +32,36 @@ class MailStore:
os.makedirs(self.storage_path)
async def save_email(self, message, sender, recipients, raw_data=None):
"""
保存邮件到数据库
Args:
message: 已解析的邮件对象
sender: 发件人邮箱
recipients: 收件人邮箱列表
raw_data: 原始邮件数据
Returns:
(bool, str): 成功标志和错误信息
"""
"""保存邮件到数据库和文件系统"""
logging.info(f"开始保存邮件: 发件人={sender}, 收件人={recipients}")
try:
logging.info(f"开始保存邮件: 发件人={sender}, 收件人={recipients}")
# 从消息对象中提取主题
subject = message.get('Subject', '')
if subject is None:
subject = ''
logging.info(f"邮件主题: {subject}")
# 获取邮件内容文本和HTML
# 解析邮件内容
email_subject = None
body_text = ""
body_html = ""
attachments = []
# 处理多部分邮件
if message.is_multipart():
logging.info("处理多部分邮件")
for part in message.walk():
content_type = part.get_content_type()
content_disposition = str(part.get("Content-Disposition") or "")
logging.debug(f"处理邮件部分: 类型={content_type}, 处置={content_disposition}")
# 处理附件
if "attachment" in content_disposition:
try:
filename = part.get_filename()
if filename:
payload = part.get_payload(decode=True)
if payload and len(payload) > 0:
logging.info(f"发现附件: {filename}, 大小={len(payload)}字节")
# 将附件信息添加到列表,稍后处理
attachments.append({
'filename': filename,
'content_type': content_type,
'data': payload
})
except Exception as e:
logging.error(f"处理附件时出错: {str(e)}")
continue
# 处理内容部分
elif content_type == "text/plain" and not body_text:
try:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or 'utf-8'
try:
body_text = payload.decode(charset, errors='replace')
logging.info(f"提取到纯文本内容: {len(body_text)}字节")
except Exception as e:
logging.error(f"解码纯文本内容失败: {e}")
body_text = payload.decode('utf-8', errors='replace')
except Exception as e:
logging.error(f"获取纯文本部分时出错: {str(e)}")
elif content_type == "text/html" and not body_html:
try:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or 'utf-8'
try:
body_html = payload.decode(charset, errors='replace')
logging.info(f"提取到HTML内容: {len(body_html)}字节")
except Exception as e:
logging.error(f"解码HTML内容失败: {e}")
body_html = payload.decode('utf-8', errors='replace')
except Exception as e:
logging.error(f"获取HTML部分时出错: {str(e)}")
# 提取邮件主题
if hasattr(message, 'subject') and message.subject:
email_subject = message.subject
# 处理单部分邮件
else:
logging.info("处理单部分邮件")
content_type = message.get_content_type()
logging.debug(f"单部分邮件类型: {content_type}")
# 提取邮件内容
if hasattr(message, 'get_body'):
try:
payload = message.get_payload(decode=True)
if payload:
charset = message.get_content_charset() or 'utf-8'
logging.debug(f"邮件编码: {charset}")
try:
decoded_content = payload.decode(charset, errors='replace')
except Exception as e:
logging.error(f"解码内容失败: {e}")
decoded_content = payload.decode('utf-8', errors='replace')
for part in message.walk():
content_type = part.get_content_type()
if content_type == 'text/plain':
body_text = decoded_content
logging.info(f"提取到纯文本内容: {len(body_text)}字节")
elif content_type == 'text/html':
body_html = decoded_content
logging.info(f"提取到HTML内容: {len(body_html)}字节")
else:
logging.warning(f"未知内容类型: {content_type}")
# 假设为纯文本
body_text = decoded_content
if content_type == "text/plain":
body_text = part.get_payload(decode=True).decode(part.get_content_charset() or 'utf-8', errors='replace')
elif content_type == "text/html":
body_html = part.get_payload(decode=True).decode(part.get_content_charset() or 'utf-8', errors='replace')
# 处理附件
if part.get_filename():
file_name = part.get_filename()
content = part.get_payload(decode=True)
attachments.append((file_name, content))
except Exception as e:
logging.error(f"获取邮件内容时出错: {str(e)}")
@@ -165,87 +86,126 @@ class MailStore:
mailbox_id = None
recipients_list = recipients if isinstance(recipients, list) else [recipients]
# 确保收件人列表不为空
if not recipients_list:
logging.error("收件人列表为空,无法确定邮箱")
return False, "收件人列表为空"
# 尝试找到或创建收件人邮箱
for recipient in recipients_list:
# 跳过空收件人
if not recipient or '@' not in recipient:
continue
# 提取域名和用户名
if '@' in recipient:
username, domain = recipient.split('@', 1)
logging.info(f"查找邮箱: 用户名={username}, 域名={domain}")
# 查询域名
domain_obj = session.query(Domain).filter(Domain.name == domain).first()
if domain_obj:
# 查询邮箱
mailbox = session.query(Mailbox).filter(
Mailbox.domain_id == domain_obj.id,
Mailbox.address == username
).first()
if mailbox:
mailbox_id = mailbox.id
logging.info(f"找到邮箱ID: {mailbox_id}")
break
username, domain = recipient.lower().split('@', 1)
logging.info(f"处理收件人: 用户名={username}, 域名={domain}")
# 1. 先查找域名
domain_obj = session.query(Domain).filter(Domain.name.ilike(domain)).first()
# 如果域名不存在,创建它
if not domain_obj:
logging.info(f"创建新域名: {domain}")
domain_obj = Domain(
name=domain.lower(),
description=f"系统自动创建的域名 ({domain})",
active=True
)
session.add(domain_obj)
session.flush()
# 2. 查找或创建邮箱
mailbox = session.query(Mailbox).filter(
Mailbox.domain_id == domain_obj.id,
Mailbox.address.ilike(username)
).first()
# 如果邮箱不存在,创建它
if not mailbox:
logging.info(f"创建新邮箱: {username}@{domain}")
mailbox = Mailbox(
domain_id=domain_obj.id,
address=username.lower(),
description=f"系统自动创建的邮箱 ({username}@{domain})"
)
session.add(mailbox)
session.flush()
# 设置邮箱ID并结束循环
mailbox_id = mailbox.id
logging.info(f"使用邮箱ID: {mailbox_id} ({username}@{domain})")
break
if not mailbox_id:
logging.error(f"收件人 {recipients} 没有对应的邮箱记录")
return False, "收件邮箱不存在"
# 最终检查是否获取到了邮箱ID
if mailbox_id is None:
error_msg = f"无法确定有效的收件邮箱,无法保存邮件。收件人: {recipients}"
logging.error(error_msg)
return False, error_msg
# 创建邮件记录
new_email = Email(
mailbox_id=mailbox_id, # 设置邮箱ID
subject=subject,
# 创建邮件记录
email_obj = Email(
mailbox_id=mailbox_id, # 确保始终有邮箱ID
sender=sender,
recipients=','.join(recipients_list) if len(recipients_list) > 1 else recipients_list[0],
recipients=str(recipients),
subject=email_subject,
body_text=body_text,
body_html=body_html,
received_at=datetime.now()
)
# 提取验证码和验证链接(如果有)
new_email.extract_verification_data()
# 提取验证码和验证链接
email_obj.extract_verification_data()
session.add(email_obj)
session.flush() # 获取新创建邮件的ID
# 保存附件
for file_name, content in attachments:
attachment = Attachment(
email_id=email_obj.id,
filename=file_name,
content_type="application/octet-stream",
size=len(content)
)
session.add(attachment)
# 保存附件内容到文件
attachment_path = os.path.join(self.storage_path, 'attachments', f"attachment_{attachment.id}")
os.makedirs(os.path.dirname(attachment_path), exist_ok=True)
with open(attachment_path, 'wb') as f:
f.write(content)
# 保存原始邮件数据
raw_path = os.path.join(self.storage_path, 'emails', f"email_{email_obj.id}.eml")
os.makedirs(os.path.dirname(raw_path), exist_ok=True)
# 写入原始邮件
with open(raw_path, 'w', encoding='utf-8', errors='replace') as f:
if isinstance(message, str):
f.write(message)
else:
# 如果是邮件对象,尝试获取原始文本
try:
f.write(message.as_string())
except Exception:
# 如果失败,使用提供的原始数据
if raw_data:
f.write(raw_data)
# 保存邮件
session.add(new_email)
session.commit()
email_id = new_email.id
logging.info(f"邮件保存到数据库, ID={email_id}")
# 处理附件
if attachments:
for attachment_data in attachments:
attachment = Attachment(
email_id=email_id,
filename=attachment_data['filename'],
content_type=attachment_data['content_type'],
size=len(attachment_data['data']),
data=attachment_data['data']
)
session.add(attachment)
session.commit()
logging.info(f"保存了{len(attachments)}个附件")
# 保存原始邮件到文件系统
try:
if raw_data and email_id:
email_dir = os.path.join(self.storage_path, 'emails')
os.makedirs(email_dir, exist_ok=True)
email_path = os.path.join(email_dir, f'email_{email_id}.eml')
with open(email_path, 'w', encoding='utf-8') as f:
f.write(raw_data)
logging.info(f"原始邮件保存到: {email_path}")
except Exception as e:
logging.error(f"保存原始邮件到文件系统失败: {str(e)}")
return True, f"邮件保存成功ID: {email_id}"
logging.info(f"邮件保存成功: ID={email_obj.id}")
return True, f"邮件保存: ID={email_obj.id}"
except Exception as e:
logging.error(f"保存邮件到数据库失败: {str(e)}")
return False, f"保存邮件失败: {str(e)}"
session.rollback()
logging.error(f"数据库操作失败: {str(e)}")
raise
finally:
session.close()
except Exception as e:
logging.error(f"保存邮件时出现未处理异常: {str(e)}")
import traceback
traceback.print_exc()
return False, f"保存邮件过程中出错: {str(e)}"
logging.error(f"邮件保存失败: {str(e)}")
return False, f"保存邮件失败: {str(e)}"
def get_emails_for_mailbox(self, mailbox_id, limit=50, offset=0, unread_only=False):
"""获取指定邮箱的邮件列表"""

View File

@@ -37,45 +37,38 @@ class EmailHandler(Message):
return
async def handle_DATA(self, server, session, envelope):
"""处理接收到的邮件数据"""
"""处理邮件数据"""
try:
logging.info(f"收到邮件: 发件人={envelope.mail_from}, 收件人={envelope.rcpt_tos}")
# 获取邮件数据
message_data = envelope.content.decode('utf-8', errors='replace')
# 保存原始邮件数据
data = envelope.content.decode('utf-8', errors='replace')
# 记录接收到的邮件
sender = envelope.mail_from
recipients = envelope.rcpt_tos
# 解析邮件数据
message = email_parser.Parser().parsestr(data)
subject = message.get('Subject', '')
logging.info(f"邮件主题: {subject}")
logging.info(f"SMTP服务收到邮件: 发件人={sender}, 收件人={recipients}")
# 记录邮件结构和内容
logging.debug(f"邮件结构: is_multipart={message.is_multipart()}")
if message.is_multipart():
logging.debug(f"多部分邮件: 部分数量={len(list(message.walk()))}")
for i, part in enumerate(message.walk()):
content_type = part.get_content_type()
logging.debug(f"部分 {i+1}: 内容类型={content_type}")
# 使用email.parser解析邮件
parser = email.parser.Parser(policy=default)
message = parser.parsestr(message_data)
# 使用邮件存储服务保存邮件
success, error_msg = await self.mail_store.save_email(
message,
envelope.mail_from,
envelope.rcpt_tos,
raw_data=data
)
# 保存邮件
success, result_message = await self.mail_store.save_email(message, sender, recipients, message_data)
if success:
logging.info(f"邮件保存成功: 来自 {envelope.mail_from} 发送给 {envelope.rcpt_tos}")
return '250 消息接收完成'
logging.info(f"邮件已成功保存: {result_message}")
return '250 Message accepted for delivery'
else:
logging.error(f"邮件保存失败: {error_msg}")
return '451 处理邮件时出现错误,请稍后重试'
logging.error(f"邮件保存失败: {result_message}")
# 注意:即使保存失败,我们仍然返回成功,避免发件方重试
# 这是因为问题通常在我们的系统中,重试不会解决问题
return '250 Message accepted for delivery (warning: internal processing error)'
except Exception as e:
logging.error(f"处理邮件时出错: {str(e)}")
traceback.print_exc()
return '451 处理邮件时出现错误,请稍后重试'
# 返回临时错误,让发件方可以重试
return '451 Requested action aborted: local error in processing'
# 为Windows环境自定义SMTP控制器