From 4c1920d7c13a5809c43a0036705b702018e64c76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E7=BB=8D=E5=BA=B7?= <3052147989@qq.com> Date: Wed, 25 Feb 2026 08:58:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=81=E7=A7=BB=20emails=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=88=B0=20controllers=20=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移 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 --- outlook_web/app.py | 2 +- outlook_web/controllers/emails.py | 407 ++++++++++++++++++++++++++++++ outlook_web/routes/emails.py | 13 +- tests/test_error_and_trace.py | 22 +- 4 files changed, 432 insertions(+), 12 deletions(-) create mode 100644 outlook_web/controllers/emails.py diff --git a/outlook_web/app.py b/outlook_web/app.py index 6d6607e..6138344 100644 --- a/outlook_web/app.py +++ b/outlook_web/app.py @@ -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()) diff --git a/outlook_web/controllers/emails.py b/outlook_web/controllers/emails.py new file mode 100644 index 0000000..a2fc43e --- /dev/null +++ b/outlook_web/controllers/emails.py @@ -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 diff --git a/outlook_web/routes/emails.py b/outlook_web/routes/emails.py index 48ca35e..f02a65f 100644 --- a/outlook_web/routes/emails.py +++ b/outlook_web/routes/emails.py @@ -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/", view_func=impl.api_get_emails, methods=["GET"]) - bp.add_url_rule("/api/emails//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//", view_func=impl.api_get_email_detail, methods=["GET"]) + bp.add_url_rule("/api/emails/", view_func=emails_controller.api_get_emails, methods=["GET"]) + bp.add_url_rule("/api/emails//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//", view_func=emails_controller.api_get_email_detail, methods=["GET"]) return bp diff --git a/tests/test_error_and_trace.py b/tests/test_error_and_trace.py index de9a329..4964efa 100644 --- a/tests/test_error_and_trace.py +++ b/tests/test_error_and_trace.py @@ -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(