feat: 迁移 emails 模块到 controllers 层
- 迁移 4 个 emails 相关 API 到 controllers/emails.py: - api_get_emails - api_delete_emails - api_get_email_detail - api_extract_verification - 更新 routes/emails.py 移除 impl 参数 - 更新测试用例以 mock repositories 和 services 模块 - 保持 Graph API → IMAP 回退机制 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
d7c274e5eb
commit
4c1920d7c1
4 files changed
+432
-12
No files matched your search
+1
-1
@@ -73,7 +73,7 @@ def create_app(*, autostart_scheduler: Optional[bool] = None):
|
||||
app.register_blueprint(groups.create_blueprint())
|
||||
app.register_blueprint(tags.create_blueprint())
|
||||
app.register_blueprint(accounts.create_blueprint(impl=legacy))
|
||||
app.register_blueprint(emails.create_blueprint(impl=legacy))
|
||||
app.register_blueprint(emails.create_blueprint())
|
||||
app.register_blueprint(temp_emails.create_blueprint())
|
||||
app.register_blueprint(oauth.create_blueprint())
|
||||
app.register_blueprint(settings.create_blueprint())
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import jsonify, request
|
||||
|
||||
from outlook_web import config
|
||||
from outlook_web.audit import log_audit
|
||||
from outlook_web.db import get_db
|
||||
from outlook_web.errors import build_error_payload
|
||||
from outlook_web.repositories import accounts as accounts_repo
|
||||
from outlook_web.repositories import groups as groups_repo
|
||||
from outlook_web.security.auth import login_required
|
||||
from outlook_web.services import email_delete as email_delete_service
|
||||
from outlook_web.services import graph as graph_service
|
||||
from outlook_web.services import imap as imap_service
|
||||
|
||||
|
||||
# IMAP 服务器配置
|
||||
IMAP_SERVER_OLD = "outlook.office365.com"
|
||||
IMAP_SERVER_NEW = "outlook.live.com"
|
||||
|
||||
|
||||
# ==================== 邮件 API ====================
|
||||
|
||||
|
||||
@login_required
|
||||
def api_get_emails(email_addr: str) -> Any:
|
||||
"""获取邮件列表(支持分页,不使用缓存)"""
|
||||
account = accounts_repo.get_account_by_email(email_addr)
|
||||
|
||||
if not account:
|
||||
error_payload = build_error_payload(
|
||||
"ACCOUNT_NOT_FOUND",
|
||||
"账号不存在",
|
||||
"NotFoundError",
|
||||
404,
|
||||
f"email={email_addr}"
|
||||
)
|
||||
return jsonify({'success': False, 'error': error_payload})
|
||||
|
||||
folder = request.args.get('folder', 'inbox') # inbox, junkemail, deleteditems
|
||||
skip = int(request.args.get('skip', 0))
|
||||
top = int(request.args.get('top', 20))
|
||||
|
||||
# 获取分组代理设置
|
||||
proxy_url = ''
|
||||
if account.get('group_id'):
|
||||
group = groups_repo.get_group_by_id(account['group_id'])
|
||||
if group:
|
||||
proxy_url = group.get('proxy_url', '') or ''
|
||||
|
||||
# 收集所有错误信息
|
||||
all_errors = {}
|
||||
|
||||
# 1. 尝试 Graph API
|
||||
graph_result = graph_service.get_emails_graph(account['client_id'], account['refresh_token'], folder, skip, top, proxy_url)
|
||||
if graph_result.get("success"):
|
||||
emails = graph_result.get("emails", [])
|
||||
# 更新刷新时间
|
||||
db = get_db()
|
||||
db.execute('''
|
||||
UPDATE accounts
|
||||
SET last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE email = ?
|
||||
''', (email_addr,))
|
||||
db.commit()
|
||||
|
||||
# 格式化 Graph API 返回的数据
|
||||
formatted = []
|
||||
for e in emails:
|
||||
formatted.append({
|
||||
'id': e.get('id'),
|
||||
'subject': e.get('subject', '无主题'),
|
||||
'from': e.get('from', {}).get('emailAddress', {}).get('address', '未知'),
|
||||
'date': e.get('receivedDateTime', ''),
|
||||
'is_read': e.get('isRead', False),
|
||||
'has_attachments': e.get('hasAttachments', False),
|
||||
'body_preview': e.get('bodyPreview', '')
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'emails': formatted,
|
||||
'method': 'Graph API',
|
||||
'has_more': len(formatted) >= top
|
||||
})
|
||||
else:
|
||||
graph_error = graph_result.get("error")
|
||||
all_errors["graph"] = graph_error
|
||||
|
||||
# 如果是代理错误,不再回退 IMAP
|
||||
if isinstance(graph_error, dict) and graph_error.get('type') in ('ProxyError', 'ConnectionError'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '代理连接失败,请检查分组代理设置',
|
||||
'details': all_errors
|
||||
})
|
||||
|
||||
imap_new_result = imap_service.get_emails_imap_with_server(
|
||||
account['email'], account['client_id'], account['refresh_token'],
|
||||
folder, skip, top, IMAP_SERVER_NEW
|
||||
)
|
||||
if imap_new_result.get("success"):
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'emails': imap_new_result.get("emails", []),
|
||||
'method': 'IMAP (New)',
|
||||
'has_more': False # IMAP 分页暂未完全实现
|
||||
})
|
||||
else:
|
||||
all_errors["imap_new"] = imap_new_result.get("error")
|
||||
|
||||
# 3. 尝试旧版 IMAP (outlook.office365.com)
|
||||
imap_old_result = imap_service.get_emails_imap_with_server(
|
||||
account['email'], account['client_id'], account['refresh_token'],
|
||||
folder, skip, top, IMAP_SERVER_OLD
|
||||
)
|
||||
if imap_old_result.get("success"):
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'emails': imap_old_result.get("emails", []),
|
||||
'method': 'IMAP (Old)',
|
||||
'has_more': False
|
||||
})
|
||||
else:
|
||||
all_errors["imap_old"] = imap_old_result.get("error")
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '无法获取邮件,所有方式均失败',
|
||||
'details': all_errors
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def api_delete_emails() -> Any:
|
||||
"""批量删除邮件(永久删除)"""
|
||||
data = request.json
|
||||
email_addr = data.get('email', '')
|
||||
message_ids = data.get('ids', [])
|
||||
|
||||
if not email_addr or not message_ids:
|
||||
return jsonify({'success': False, 'error': '参数不完整'})
|
||||
|
||||
account = accounts_repo.get_account_by_email(email_addr)
|
||||
if not account:
|
||||
return jsonify({'success': False, 'error': '账号不存在'})
|
||||
|
||||
# 获取分组代理设置
|
||||
proxy_url = ''
|
||||
if account.get('group_id'):
|
||||
group = groups_repo.get_group_by_id(account['group_id'])
|
||||
if group:
|
||||
proxy_url = group.get('proxy_url', '') or ''
|
||||
|
||||
response_data, method_used = email_delete_service.delete_emails_with_fallback(
|
||||
email_addr=email_addr,
|
||||
client_id=account["client_id"],
|
||||
refresh_token=account["refresh_token"],
|
||||
message_ids=message_ids,
|
||||
proxy_url=proxy_url,
|
||||
delete_emails_graph=graph_service.delete_emails_graph,
|
||||
delete_emails_imap=imap_service.delete_emails_imap,
|
||||
imap_server_new=IMAP_SERVER_NEW,
|
||||
imap_server_old=IMAP_SERVER_OLD,
|
||||
)
|
||||
|
||||
if method_used == "graph":
|
||||
log_audit("delete", "email", email_addr, f"删除邮件 {len(message_ids)} 封(Graph API)")
|
||||
elif method_used == "imap_new":
|
||||
log_audit("delete", "email", email_addr, f"删除邮件 {len(message_ids)} 封(IMAP New)")
|
||||
elif method_used == "imap_old":
|
||||
log_audit("delete", "email", email_addr, f"删除邮件 {len(message_ids)} 封(IMAP Old)")
|
||||
|
||||
return jsonify(response_data)
|
||||
|
||||
|
||||
@login_required
|
||||
def api_get_email_detail(email_addr: str, message_id: str) -> Any:
|
||||
"""获取邮件详情"""
|
||||
account = accounts_repo.get_account_by_email(email_addr)
|
||||
|
||||
if not account:
|
||||
return jsonify({'success': False, 'error': '账号不存在'})
|
||||
|
||||
method = request.args.get('method', 'graph')
|
||||
folder = request.args.get('folder', 'inbox')
|
||||
|
||||
if method == 'graph':
|
||||
# 获取分组代理设置
|
||||
proxy_url = ''
|
||||
if account.get('group_id'):
|
||||
group = groups_repo.get_group_by_id(account['group_id'])
|
||||
if group:
|
||||
proxy_url = group.get('proxy_url', '') or ''
|
||||
|
||||
detail = graph_service.get_email_detail_graph(account['client_id'], account['refresh_token'], message_id, proxy_url)
|
||||
if detail:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'email': {
|
||||
'id': detail.get('id'),
|
||||
'subject': detail.get('subject', '无主题'),
|
||||
'from': detail.get('from', {}).get('emailAddress', {}).get('address', '未知'),
|
||||
'to': ', '.join([r.get('emailAddress', {}).get('address', '') for r in detail.get('toRecipients', [])]),
|
||||
'cc': ', '.join([r.get('emailAddress', {}).get('address', '') for r in detail.get('ccRecipients', [])]),
|
||||
'date': detail.get('receivedDateTime', ''),
|
||||
'body': detail.get('body', {}).get('content', ''),
|
||||
'body_type': detail.get('body', {}).get('contentType', 'text')
|
||||
}
|
||||
})
|
||||
|
||||
# 如果 Graph API 失败,尝试 IMAP
|
||||
detail = imap_service.get_email_detail_imap(account['email'], account['client_id'], account['refresh_token'], message_id, folder)
|
||||
if detail:
|
||||
return jsonify({'success': True, 'email': detail})
|
||||
|
||||
return jsonify({'success': False, 'error': '获取邮件详情失败'})
|
||||
|
||||
|
||||
@login_required
|
||||
def api_extract_verification(email_addr: str) -> Any:
|
||||
"""
|
||||
提取验证码和链接接口
|
||||
|
||||
功能:从指定邮箱的最新邮件中提取验证码和链接
|
||||
|
||||
实现策略(多 API 回退机制):
|
||||
1. Graph API (inbox) - 优先从收件箱获取
|
||||
2. Graph API (junkemail) - 从垃圾邮件获取
|
||||
3. IMAP (新服务器) - Graph API 失败时回退
|
||||
4. IMAP (旧服务器) - 最后的回退方案
|
||||
"""
|
||||
from outlook_web.services.verification_extractor import extract_verification_info
|
||||
|
||||
# 获取账号信息
|
||||
account = accounts_repo.get_account_by_email(email_addr)
|
||||
|
||||
if not account:
|
||||
error_payload = build_error_payload(
|
||||
"ACCOUNT_NOT_FOUND",
|
||||
"邮箱不存在",
|
||||
"NotFoundError",
|
||||
404,
|
||||
f"email={email_addr}"
|
||||
)
|
||||
return jsonify({'success': False, 'error': error_payload}), 404
|
||||
|
||||
# 获取分组代理设置
|
||||
proxy_url = ''
|
||||
if account.get('group_id'):
|
||||
group = groups_repo.get_group_by_id(account['group_id'])
|
||||
if group:
|
||||
proxy_url = group.get('proxy_url', '') or ''
|
||||
|
||||
# 收集邮件(同时从收件箱和垃圾邮件获取)
|
||||
emails = []
|
||||
graph_success = False
|
||||
|
||||
# 1. 尝试 Graph API 从收件箱获取最新邮件
|
||||
try:
|
||||
inbox_result = graph_service.get_emails_graph(
|
||||
account['client_id'],
|
||||
account['refresh_token'],
|
||||
folder='inbox',
|
||||
skip=0,
|
||||
top=1,
|
||||
proxy_url=proxy_url
|
||||
)
|
||||
if inbox_result.get("success"):
|
||||
emails.extend(inbox_result.get("emails", []))
|
||||
graph_success = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. 尝试 Graph API 从垃圾邮件获取最新邮件
|
||||
try:
|
||||
junk_result = graph_service.get_emails_graph(
|
||||
account['client_id'],
|
||||
account['refresh_token'],
|
||||
folder='junkemail',
|
||||
skip=0,
|
||||
top=1,
|
||||
proxy_url=proxy_url
|
||||
)
|
||||
if junk_result.get("success"):
|
||||
emails.extend(junk_result.get("emails", []))
|
||||
graph_success = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. 如果 Graph API 失败,尝试 IMAP 回退
|
||||
if not graph_success or not emails:
|
||||
# 尝试新版 IMAP 服务器
|
||||
try:
|
||||
imap_new_result = imap_service.get_emails_imap_with_server(
|
||||
account['email'], account['client_id'], account['refresh_token'],
|
||||
folder='inbox', skip=0, top=1, server=IMAP_SERVER_NEW
|
||||
)
|
||||
if imap_new_result.get("success"):
|
||||
emails.extend(imap_new_result.get("emails", []))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 尝试旧版 IMAP 服务器
|
||||
try:
|
||||
imap_old_result = imap_service.get_emails_imap_with_server(
|
||||
account['email'], account['client_id'], account['refresh_token'],
|
||||
folder='inbox', skip=0, top=1, server=IMAP_SERVER_OLD
|
||||
)
|
||||
if imap_old_result.get("success"):
|
||||
emails.extend(imap_old_result.get("emails", []))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not emails:
|
||||
error_payload = build_error_payload(
|
||||
"EMAIL_NOT_FOUND",
|
||||
"未找到邮件",
|
||||
"NotFoundError",
|
||||
404,
|
||||
f"email={email_addr}"
|
||||
)
|
||||
return jsonify({'success': False, 'error': error_payload}), 404
|
||||
|
||||
# 按时间排序,取最新的一封
|
||||
emails.sort(key=lambda x: x.get('receivedDateTime', '') or x.get('date', ''), reverse=True)
|
||||
latest_email = emails[0]
|
||||
|
||||
# 获取邮件详情以获取完整内容
|
||||
email_detail = None
|
||||
|
||||
# 尝试 Graph API 获取详情
|
||||
try:
|
||||
email_detail = graph_service.get_email_detail_graph(
|
||||
account['client_id'],
|
||||
account['refresh_token'],
|
||||
latest_email.get('id'),
|
||||
proxy_url
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 如果 Graph API 失败,尝试 IMAP 获取详情
|
||||
if not email_detail:
|
||||
try:
|
||||
email_detail = imap_service.get_email_detail_imap(
|
||||
account['email'],
|
||||
account['client_id'],
|
||||
account['refresh_token'],
|
||||
latest_email.get('id'),
|
||||
'inbox'
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 构建邮件对象用于提取
|
||||
email_obj = {
|
||||
'subject': latest_email.get('subject', ''),
|
||||
'body_preview': latest_email.get('bodyPreview', '') or latest_email.get('body_preview', '')
|
||||
}
|
||||
|
||||
if email_detail:
|
||||
# Graph API 格式
|
||||
if 'body' in email_detail:
|
||||
body_content = email_detail.get('body', {})
|
||||
email_obj['body'] = body_content.get('content', '') if body_content.get('contentType') == 'text' else ''
|
||||
email_obj['body_html'] = body_content.get('content', '') if body_content.get('contentType') == 'html' else ''
|
||||
email_obj['bodyContent'] = body_content.get('content', '')
|
||||
email_obj['bodyContentType'] = body_content.get('contentType', 'text')
|
||||
# IMAP 格式
|
||||
elif 'body' in email_detail or 'body_html' in email_detail:
|
||||
email_obj['body'] = email_detail.get('body', '')
|
||||
email_obj['body_html'] = email_detail.get('body_html', '')
|
||||
|
||||
try:
|
||||
# 尝试从邮件详情提取验证信息
|
||||
result = extract_verification_info(email_obj)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': result,
|
||||
'message': '提取成功'
|
||||
})
|
||||
|
||||
except ValueError as e:
|
||||
# 未找到验证信息
|
||||
error_payload = build_error_payload(
|
||||
"VERIFICATION_NOT_FOUND",
|
||||
str(e),
|
||||
"NotFoundError",
|
||||
404,
|
||||
f"email={email_addr}"
|
||||
)
|
||||
return jsonify({'success': False, 'error': error_payload}), 404
|
||||
|
||||
except Exception as e:
|
||||
# 其他错误
|
||||
error_payload = build_error_payload(
|
||||
"EXTRACT_ERROR",
|
||||
"提取失败",
|
||||
"ExtractError",
|
||||
500,
|
||||
str(e)
|
||||
)
|
||||
return jsonify({'success': False, 'error': error_payload}), 500
|
||||
@@ -2,12 +2,15 @@ from __future__ import annotations
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from outlook_web.controllers import emails as emails_controller
|
||||
|
||||
def create_blueprint(*, impl) -> Blueprint:
|
||||
|
||||
def create_blueprint() -> Blueprint:
|
||||
"""创建 emails Blueprint"""
|
||||
bp = Blueprint("emails", __name__)
|
||||
bp.add_url_rule("/api/emails/<email_addr>", view_func=impl.api_get_emails, methods=["GET"])
|
||||
bp.add_url_rule("/api/emails/<email_addr>/extract-verification", view_func=impl.api_extract_verification, methods=["GET"])
|
||||
bp.add_url_rule("/api/emails/delete", view_func=impl.api_delete_emails, methods=["POST"])
|
||||
bp.add_url_rule("/api/email/<email_addr>/<path:message_id>", view_func=impl.api_get_email_detail, methods=["GET"])
|
||||
bp.add_url_rule("/api/emails/<email_addr>", view_func=emails_controller.api_get_emails, methods=["GET"])
|
||||
bp.add_url_rule("/api/emails/<email_addr>/extract-verification", view_func=emails_controller.api_extract_verification, methods=["GET"])
|
||||
bp.add_url_rule("/api/emails/delete", view_func=emails_controller.api_delete_emails, methods=["POST"])
|
||||
bp.add_url_rule("/api/email/<email_addr>/<path:message_id>", view_func=emails_controller.api_get_email_detail, methods=["GET"])
|
||||
return bp
|
||||
|
||||
@@ -93,16 +93,21 @@ class ErrorAndTraceTests(unittest.TestCase):
|
||||
"trace_id": "",
|
||||
}
|
||||
|
||||
# 控制器现在使用 repositories 和 services,需要 mock 这些模块
|
||||
from outlook_web.repositories import accounts as accounts_repo
|
||||
from outlook_web.services import graph as graph_service
|
||||
from outlook_web.services import imap as imap_service
|
||||
|
||||
with patch.object(
|
||||
self.module.impl,
|
||||
accounts_repo,
|
||||
"get_account_by_email",
|
||||
return_value={"email": email_addr, "client_id": "cid", "refresh_token": "rt", "group_id": None},
|
||||
), patch.object(
|
||||
self.module.impl,
|
||||
graph_service,
|
||||
"delete_emails_graph",
|
||||
return_value={"success": False, "error": graph_error, "success_count": 0, "failed_count": 2, "errors": ["e1"]},
|
||||
), patch.object(
|
||||
self.module.impl,
|
||||
imap_service,
|
||||
"delete_emails_imap",
|
||||
side_effect=[
|
||||
{"success": False, "error": "imap_new_failed"},
|
||||
@@ -149,16 +154,21 @@ class ErrorAndTraceTests(unittest.TestCase):
|
||||
"errors": ["ProxyError: connect failed"],
|
||||
}
|
||||
|
||||
# 控制器现在使用 repositories 和 services,需要 mock 这些模块
|
||||
from outlook_web.repositories import accounts as accounts_repo
|
||||
from outlook_web.services import graph as graph_service
|
||||
from outlook_web.services import imap as imap_service
|
||||
|
||||
with patch.object(
|
||||
self.module.impl,
|
||||
accounts_repo,
|
||||
"get_account_by_email",
|
||||
return_value={"email": email_addr, "client_id": "cid", "refresh_token": "rt", "group_id": None},
|
||||
), patch.object(
|
||||
self.module.impl,
|
||||
graph_service,
|
||||
"delete_emails_graph",
|
||||
return_value=graph_res,
|
||||
), patch.object(
|
||||
self.module.impl,
|
||||
imap_service,
|
||||
"delete_emails_imap",
|
||||
) as imap_mock:
|
||||
resp = client.post(
|
||||
|
||||
Reference in new issue
Block a user