diff --git a/outlook_mail_reader.py b/outlook_mail_reader.py index 66de65f..5a69ce6 100644 --- a/outlook_mail_reader.py +++ b/outlook_mail_reader.py @@ -23,7 +23,7 @@ PASSWORD = "" # OAuth2 refresh_token CLIENT_ID = "" # OAuth2 client_id -REFRESH_TOKEN= "" +REFRESH_TOKEN = "" # 代理地址(可选,格式: host:port 或 http://host:port) PROXY = None # 例如: "127.0.0.1:7890" # ================================================ @@ -57,9 +57,11 @@ def decode_header_value(header_value: str) -> str: for part, charset in decoded_parts: if isinstance(part, bytes): try: - decoded_string += part.decode(charset if charset else 'utf-8', 'replace') + decoded_string += part.decode( + charset if charset else "utf-8", "replace" + ) except (LookupError, UnicodeDecodeError): - decoded_string += part.decode('utf-8', 'replace') + decoded_string += part.decode("utf-8", "replace") else: decoded_string += str(part) return decoded_string @@ -101,7 +103,10 @@ def print_email_info(emails: List[Any], method_name: str): # ==================== 方式1: 旧版 IMAP 方式 ==================== -def get_access_token_old(account: str, client_id: str, refresh_token: str) -> Optional[str]: + +def get_access_token_old( + account: str, client_id: str, refresh_token: str +) -> Optional[str]: """ 旧版方式获取 access_token 使用 login.live.com 端点 @@ -110,9 +115,9 @@ def get_access_token_old(account: str, client_id: str, refresh_token: str) -> Op try: data = { - 'client_id': client_id, - 'grant_type': 'refresh_token', - 'refresh_token': refresh_token + "client_id": client_id, + "grant_type": "refresh_token", + "refresh_token": refresh_token, } ret = requests.post(TOKEN_URL_LIVE, data=data, timeout=30) @@ -124,7 +129,7 @@ def get_access_token_old(account: str, client_id: str, refresh_token: str) -> Op print(" ⚠️ 账号被封禁!") return None - access_token = ret.json().get('access_token') + access_token = ret.json().get("access_token") if access_token: print(f" ✅ 成功获取 access_token,长度: {len(access_token)}") return access_token @@ -134,7 +139,9 @@ def get_access_token_old(account: str, client_id: str, refresh_token: str) -> Op return None -def read_emails_imap_old(account: str, client_id: str, refresh_token: str, top: int = 10) -> Optional[List]: +def read_emails_imap_old( + account: str, client_id: str, refresh_token: str, top: int = 10 +) -> Optional[List]: """ 方式1: 旧版 IMAP 方式读取邮件 使用 outlook.office365.com 服务器 @@ -154,15 +161,15 @@ def read_emails_imap_old(account: str, client_id: str, refresh_token: str, top: # 3. XOAUTH2 认证 auth_string = f"user={account}\1auth=Bearer {access_token}\1\1" - connection.authenticate('XOAUTH2', lambda x: auth_string) + connection.authenticate("XOAUTH2", lambda x: auth_string) print(" ✅ IMAP 认证成功") # 4. 选择收件箱 connection.select("INBOX") # 5. 搜索邮件 - status, messages = connection.search(None, 'ALL') - if status != 'OK' or not messages or not messages[0]: + status, messages = connection.search(None, "ALL") + if status != "OK" or not messages or not messages[0]: print(" ⚠️ 收件箱为空") return [] @@ -175,8 +182,8 @@ def read_emails_imap_old(account: str, client_id: str, refresh_token: str, top: emails = [] for msg_id in recent_ids: try: - status, msg_data = connection.fetch(msg_id, '(RFC822)') - if status == 'OK' and msg_data and msg_data[0]: + status, msg_data = connection.fetch(msg_id, "(RFC822)") + if status == "OK" and msg_data and msg_data[0]: raw_email = msg_data[0][1] msg = email.message_from_bytes(raw_email) emails.append(msg) @@ -199,6 +206,7 @@ def read_emails_imap_old(account: str, client_id: str, refresh_token: str, top: # ==================== 方式2: 新版 IMAP 方式 ==================== + def get_access_token_imap(client_id: str, refresh_token: str) -> Optional[str]: """ 新版方式获取 IMAP access_token @@ -209,7 +217,9 @@ def get_access_token_imap(client_id: str, refresh_token: str) -> Optional[str]: try: proxies = None if PROXY: - proxies = {"all": f"http://{PROXY}" if not PROXY.startswith("http") else PROXY} + proxies = { + "all": f"http://{PROXY}" if not PROXY.startswith("http") else PROXY + } res = requests.post( TOKEN_URL_IMAP, @@ -217,10 +227,10 @@ def get_access_token_imap(client_id: str, refresh_token: str) -> Optional[str]: "client_id": client_id, "grant_type": "refresh_token", "refresh_token": refresh_token, - "scope": "https://outlook.office.com/IMAP.AccessAsUser.All offline_access" + "scope": "https://outlook.office.com/IMAP.AccessAsUser.All offline_access", }, proxies=proxies, - timeout=30 + timeout=30, ) if res.status_code != 200: @@ -240,7 +250,9 @@ def get_access_token_imap(client_id: str, refresh_token: str) -> Optional[str]: return None -def read_emails_imap_new(account: str, client_id: str, refresh_token: str, top: int = 10) -> Optional[List]: +def read_emails_imap_new( + account: str, client_id: str, refresh_token: str, top: int = 10 +) -> Optional[List]: """ 方式2: 新版 IMAP 方式读取邮件 使用 outlook.live.com 服务器 @@ -259,16 +271,16 @@ def read_emails_imap_new(account: str, client_id: str, refresh_token: str, top: connection = imaplib.IMAP4_SSL(IMAP_SERVER_NEW, IMAP_PORT) # 3. XOAUTH2 认证 - auth_string = f"user={account}\1auth=Bearer {access_token}\1\1".encode('utf-8') - connection.authenticate('XOAUTH2', lambda x: auth_string) + auth_string = f"user={account}\1auth=Bearer {access_token}\1\1".encode("utf-8") + connection.authenticate("XOAUTH2", lambda x: auth_string) print(" ✅ IMAP 认证成功") # 4. 选择收件箱 connection.select('"INBOX"') # 5. 搜索邮件 - status, messages = connection.search(None, 'ALL') - if status != 'OK' or not messages or not messages[0]: + status, messages = connection.search(None, "ALL") + if status != "OK" or not messages or not messages[0]: print(" ⚠️ 收件箱为空") return [] @@ -281,8 +293,8 @@ def read_emails_imap_new(account: str, client_id: str, refresh_token: str, top: emails = [] for msg_id in recent_ids: try: - status, msg_data = connection.fetch(msg_id, '(RFC822)') - if status == 'OK' and msg_data and msg_data[0]: + status, msg_data = connection.fetch(msg_id, "(RFC822)") + if status == "OK" and msg_data and msg_data[0]: raw_email = msg_data[0][1] msg = email.message_from_bytes(raw_email) emails.append(msg) @@ -305,6 +317,7 @@ def read_emails_imap_new(account: str, client_id: str, refresh_token: str, top: # ==================== 方式3: Graph API 方式 ==================== + def get_access_token_graph(client_id: str, refresh_token: str) -> Optional[str]: """ Graph API 方式获取 access_token @@ -315,7 +328,9 @@ def get_access_token_graph(client_id: str, refresh_token: str) -> Optional[str]: try: proxies = None if PROXY: - proxies = {"all": f"http://{PROXY}" if not PROXY.startswith("http") else PROXY} + proxies = { + "all": f"http://{PROXY}" if not PROXY.startswith("http") else PROXY + } res = requests.post( TOKEN_URL_GRAPH, @@ -323,10 +338,10 @@ def get_access_token_graph(client_id: str, refresh_token: str) -> Optional[str]: "client_id": client_id, "grant_type": "refresh_token", "refresh_token": refresh_token, - "scope": "https://graph.microsoft.com/.default" + "scope": "https://graph.microsoft.com/.default", }, proxies=proxies, - timeout=30 + timeout=30, ) if res.status_code != 200: @@ -346,7 +361,9 @@ def get_access_token_graph(client_id: str, refresh_token: str) -> Optional[str]: return None -def read_emails_graph(client_id: str, refresh_token: str, top: int = 10) -> Optional[List[Dict]]: +def read_emails_graph( + client_id: str, refresh_token: str, top: int = 10 +) -> Optional[List[Dict]]: """ 方式3: Graph API 方式读取邮件 使用 Microsoft Graph API @@ -371,14 +388,16 @@ def read_emails_graph(client_id: str, refresh_token: str, top: int = 10) -> Opti "$top": top, "$select": "id,subject,from,receivedDateTime,isRead,hasAttachments,bodyPreview", "$orderby": "receivedDateTime desc", - "$count": "true" + "$count": "true", } headers = { "Authorization": f"Bearer {access_token}", - "Prefer": "outlook.body-content-type='text'" + "Prefer": "outlook.body-content-type='text'", } - res = requests.get(url, headers=headers, params=params, proxies=proxies, timeout=30) + res = requests.get( + url, headers=headers, params=params, proxies=proxies, timeout=30 + ) if res.status_code != 200: print(f" ❌ Graph API 调用失败: {res.status_code}") @@ -399,6 +418,7 @@ def read_emails_graph(client_id: str, refresh_token: str, top: int = 10) -> Opti # ==================== 主函数 ==================== + def main(): """主函数:使用三种方式读取邮件""" print("\n" + "🚀 Outlook 邮件读取测试工具") @@ -455,4 +475,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/outlook_web/__init__.py b/outlook_web/__init__.py index 15ae151..2e27e3f 100644 --- a/outlook_web/__init__.py +++ b/outlook_web/__init__.py @@ -1,4 +1,3 @@ from outlook_web.app import create_app __all__ = ["create_app"] - diff --git a/outlook_web/app.py b/outlook_web/app.py index c820980..4964001 100644 --- a/outlook_web/app.py +++ b/outlook_web/app.py @@ -57,7 +57,11 @@ def create_app(*, autostart_scheduler: Optional[bool] = None): data_dir = config.get_database_path() if data_dir: import os - os.makedirs(os.path.dirname(data_dir) if os.path.dirname(data_dir) else ".", exist_ok=True) + + os.makedirs( + os.path.dirname(data_dir) if os.path.dirname(data_dir) else ".", + exist_ok=True, + ) # 初始化数据库 init_db() @@ -77,7 +81,9 @@ def create_app(*, autostart_scheduler: Optional[bool] = None): # ProxyFix 中间件(仅在配置启用时应用) # 注意:启用前必须配置 TRUSTED_PROXIES 环境变量 if config.get_proxy_fix_enabled(): - app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) + app.wsgi_app = ProxyFix( + app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 + ) # DB teardown(请求结束释放连接) register_db(app) @@ -119,11 +125,15 @@ def create_app(*, autostart_scheduler: Optional[bool] = None): from outlook_web.services import graph as graph_service if scheduler_service.should_autostart_scheduler(): - scheduler_service.init_scheduler(_APP_INSTANCE, graph_service.test_refresh_token) + scheduler_service.init_scheduler( + _APP_INSTANCE, graph_service.test_refresh_token + ) elif autostart_scheduler: from outlook_web.services import scheduler as scheduler_service from outlook_web.services import graph as graph_service - scheduler_service.init_scheduler(_APP_INSTANCE, graph_service.test_refresh_token) + scheduler_service.init_scheduler( + _APP_INSTANCE, graph_service.test_refresh_token + ) return _APP_INSTANCE diff --git a/outlook_web/audit.py b/outlook_web/audit.py index ce6895f..0b84042 100644 --- a/outlook_web/audit.py +++ b/outlook_web/audit.py @@ -8,7 +8,9 @@ from outlook_web.db import get_db from outlook_web.security.auth import get_client_ip -def log_audit(action: str, resource_type: str, resource_id: str = None, details: str = None): +def log_audit( + action: str, resource_type: str, resource_id: str = None, details: str = None +): """ 记录审计日志 :param action: 操作类型(如 'export', 'delete', 'update') @@ -62,7 +64,9 @@ def query_audit_logs( where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else "" - total_row = db.execute(f"SELECT COUNT(*) as c FROM audit_logs {where_sql}", params).fetchone() + total_row = db.execute( + f"SELECT COUNT(*) as c FROM audit_logs {where_sql}", params + ).fetchone() total = total_row["c"] if total_row else 0 rows = db.execute( @@ -100,4 +104,3 @@ def query_audit_logs( "limit": limit, "offset": offset, } - diff --git a/outlook_web/config.py b/outlook_web/config.py index a54e634..ddb48ce 100644 --- a/outlook_web/config.py +++ b/outlook_web/config.py @@ -22,7 +22,10 @@ def require_secret_key() -> str: def get_database_path() -> str: - return _getenv("DATABASE_PATH", "data/outlook_accounts.db") or "data/outlook_accounts.db" + return ( + _getenv("DATABASE_PATH", "data/outlook_accounts.db") + or "data/outlook_accounts.db" + ) def get_login_password_default() -> str: @@ -30,7 +33,10 @@ def get_login_password_default() -> str: def get_gptmail_base_url() -> str: - return _getenv("GPTMAIL_BASE_URL", "https://mail.chatgpt.org.uk") or "https://mail.chatgpt.org.uk" + return ( + _getenv("GPTMAIL_BASE_URL", "https://mail.chatgpt.org.uk") + or "https://mail.chatgpt.org.uk" + ) def get_gptmail_api_key_default() -> str: @@ -38,18 +44,26 @@ def get_gptmail_api_key_default() -> str: def get_oauth_client_id() -> str: - return _getenv("OAUTH_CLIENT_ID", "24d9a0ed-8787-4584-883c-2fd79308940a") or "24d9a0ed-8787-4584-883c-2fd79308940a" + return ( + _getenv("OAUTH_CLIENT_ID", "24d9a0ed-8787-4584-883c-2fd79308940a") + or "24d9a0ed-8787-4584-883c-2fd79308940a" + ) def get_oauth_redirect_uri() -> str: - return _getenv("OAUTH_REDIRECT_URI", "http://localhost:8080") or "http://localhost:8080" + return ( + _getenv("OAUTH_REDIRECT_URI", "http://localhost:8080") + or "http://localhost:8080" + ) def env_true(key: str, default: bool) -> bool: """ 与旧实现保持一致:只有值为 'true'(忽略大小写)才视为 True;其它值均为 False。 """ - value = _getenv(key, "true" if default else "false") or ("true" if default else "false") + value = _getenv(key, "true" if default else "false") or ( + "true" if default else "false" + ) return value.lower() == "true" @@ -83,4 +97,3 @@ def get_proxy_fix_enabled() -> bool: 默认:False(安全默认) """ return env_true("PROXY_FIX_ENABLED", False) - diff --git a/outlook_web/controllers/accounts.py b/outlook_web/controllers/accounts.py index f1800d6..217dd9d 100644 --- a/outlook_web/controllers/accounts.py +++ b/outlook_web/controllers/accounts.py @@ -19,7 +19,10 @@ from outlook_web.repositories import tags as tags_repo from outlook_web.repositories import refresh_logs as refresh_logs_repo from outlook_web.repositories import settings as settings_repo from outlook_web.repositories.refresh_runs import create_refresh_run, finish_refresh_run -from outlook_web.repositories.distributed_locks import acquire_distributed_lock, release_distributed_lock +from outlook_web.repositories.distributed_locks import ( + acquire_distributed_lock, + release_distributed_lock, +) from outlook_web.security.auth import get_client_ip, get_user_agent, login_required from outlook_web.security.crypto import decrypt_data from outlook_web.services import graph as graph_service @@ -40,7 +43,7 @@ def sanitize_input(text: str, max_length: int = 500) -> str: text = text[:max_length] # 移除控制字符(保留换行和制表符) - text = ''.join(char for char in text if char.isprintable() or char in '\n\t') + text = "".join(char for char in text if char.isprintable() or char in "\n\t") # 转义HTML特殊字符 text = html.escape(text, quote=True) @@ -54,21 +57,22 @@ def sanitize_input(text: str, max_length: int = 500) -> str: @login_required def api_get_accounts() -> Any: """获取所有账号""" - group_id = request.args.get('group_id', type=int) + group_id = request.args.get("group_id", type=int) accounts = accounts_repo.load_accounts(group_id) # 获取每个账号的最后刷新状态(批量查询,避免 N+1) db = get_db() last_log_by_account: Dict[int, Dict[str, Any]] = {} try: - account_ids = [int(a.get('id')) for a in accounts if a.get('id') is not None] + account_ids = [int(a.get("id")) for a in accounts if a.get("id") is not None] except Exception: account_ids = [] if account_ids: try: placeholders = ",".join(["?"] * len(account_ids)) - rows = db.execute(f''' + rows = db.execute( + f""" SELECT l.account_id, l.status, l.error_message, l.created_at FROM account_refresh_logs l JOIN ( @@ -78,10 +82,12 @@ def api_get_accounts() -> Any: GROUP BY account_id ) latest ON l.account_id = latest.account_id AND l.id = latest.max_id - ''', account_ids).fetchall() + """, + account_ids, + ).fetchall() for r in rows: try: - last_log_by_account[int(r['account_id'])] = dict(r) + last_log_by_account[int(r["account_id"])] = dict(r) except Exception: continue except Exception: @@ -90,30 +96,42 @@ def api_get_accounts() -> Any: # 返回时隐藏敏感信息 safe_accounts = [] for acc in accounts: - acc_id = acc.get('id') + acc_id = acc.get("id") try: acc_id_int = int(acc_id) except Exception: acc_id_int = None - last_refresh_log = last_log_by_account.get(acc_id_int) if acc_id_int is not None else None + last_refresh_log = ( + last_log_by_account.get(acc_id_int) if acc_id_int is not None else None + ) - safe_accounts.append({ - 'id': acc['id'], - 'email': acc['email'], - 'client_id': acc['client_id'][:8] + '...' if len(acc['client_id']) > 8 else acc['client_id'], - 'group_id': acc.get('group_id'), - 'group_name': acc.get('group_name', '默认分组'), - 'group_color': acc.get('group_color', '#666666'), - 'remark': acc.get('remark', ''), - 'status': acc.get('status', 'active'), - 'last_refresh_at': acc.get('last_refresh_at', ''), - 'last_refresh_status': last_refresh_log.get('status') if last_refresh_log else None, - 'last_refresh_error': last_refresh_log.get('error_message') if last_refresh_log else None, - 'created_at': acc.get('created_at', ''), - 'updated_at': acc.get('updated_at', ''), - 'tags': acc.get('tags', []) - }) - return jsonify({'success': True, 'accounts': safe_accounts}) + safe_accounts.append( + { + "id": acc["id"], + "email": acc["email"], + "client_id": ( + acc["client_id"][:8] + "..." + if len(acc["client_id"]) > 8 + else acc["client_id"] + ), + "group_id": acc.get("group_id"), + "group_name": acc.get("group_name", "默认分组"), + "group_color": acc.get("group_color", "#666666"), + "remark": acc.get("remark", ""), + "status": acc.get("status", "active"), + "last_refresh_at": acc.get("last_refresh_at", ""), + "last_refresh_status": ( + last_refresh_log.get("status") if last_refresh_log else None + ), + "last_refresh_error": ( + last_refresh_log.get("error_message") if last_refresh_log else None + ), + "created_at": acc.get("created_at", ""), + "updated_at": acc.get("updated_at", ""), + "tags": acc.get("tags", []), + } + ) + return jsonify({"success": True, "accounts": safe_accounts}) @login_required @@ -121,68 +139,70 @@ def api_get_account(account_id: int) -> Any: """获取单个账号详情""" account = accounts_repo.get_account_by_id(account_id) if not account: - return jsonify({'success': False, 'error': '账号不存在'}) + return jsonify({"success": False, "error": "账号不存在"}) - return jsonify({ - 'success': True, - 'account': { - 'id': account['id'], - 'email': account['email'], - # 敏感字段默认不回显(避免泄露);如需查看请走"导出+二次验证" - 'password': '', - 'client_id': account['client_id'], - 'refresh_token': '', - 'has_password': bool(account.get('password')), - 'has_refresh_token': bool(account.get('refresh_token')), - 'group_id': account.get('group_id'), - 'group_name': account.get('group_name', '默认分组'), - 'remark': account.get('remark', ''), - 'status': account.get('status', 'active'), - 'created_at': account.get('created_at', ''), - 'updated_at': account.get('updated_at', '') + return jsonify( + { + "success": True, + "account": { + "id": account["id"], + "email": account["email"], + # 敏感字段默认不回显(避免泄露);如需查看请走"导出+二次验证" + "password": "", + "client_id": account["client_id"], + "refresh_token": "", + "has_password": bool(account.get("password")), + "has_refresh_token": bool(account.get("refresh_token")), + "group_id": account.get("group_id"), + "group_name": account.get("group_name", "默认分组"), + "remark": account.get("remark", ""), + "status": account.get("status", "active"), + "created_at": account.get("created_at", ""), + "updated_at": account.get("updated_at", ""), + }, } - }) + ) @login_required def api_add_account() -> Any: """添加账号""" data = request.json - account_str = data.get('account_string', '') - group_id = data.get('group_id', 1) + account_str = data.get("account_string", "") + group_id = data.get("group_id", 1) if not account_str: - return jsonify({'success': False, 'error': '请输入账号信息'}) + return jsonify({"success": False, "error": "请输入账号信息"}) # 校验分组 target_group = groups_repo.get_group_by_id(group_id) if not target_group: - return jsonify({'success': False, 'error': '分组不存在'}) - if target_group.get('is_system'): - return jsonify({'success': False, 'error': '不能导入到系统分组'}) + return jsonify({"success": False, "error": "分组不存在"}) + if target_group.get("is_system"): + return jsonify({"success": False, "error": "不能导入到系统分组"}) def sanitize_credential_field(value: Any, max_length: int) -> str: if value is None: - return '' + return "" text = str(value) - text = text.replace('\r', '').replace('\n', '').replace('\t', '') + text = text.replace("\r", "").replace("\n", "").replace("\t", "") text = text.strip() if len(text) > max_length: text = text[:max_length] # 移除不可见控制字符 - text = ''.join(ch for ch in text if ch.isprintable()) + text = "".join(ch for ch in text if ch.isprintable()) return text def parse_account_string(line: str) -> Optional[Dict[str, str]]: """解析账号字符串(格式:email----password----client_id----refresh_token)""" - parts = line.strip().split('----') + parts = line.strip().split("----") if len(parts) >= 4: return { - 'email': parts[0].strip(), - 'password': parts[1], - 'client_id': parts[2].strip(), + "email": parts[0].strip(), + "password": parts[1], + "client_id": parts[2].strip(), # refresh_token 可能包含 '----',这里把剩余部分合并回去 - 'refresh_token': '----'.join(parts[3:]).strip() + "refresh_token": "----".join(parts[3:]).strip(), } return None @@ -196,7 +216,7 @@ def api_add_account() -> Any: db = get_db() for line_no, raw in enumerate(raw_lines, start=1): - line = (raw or '').strip() + line = (raw or "").strip() if not line: continue @@ -205,57 +225,80 @@ def api_add_account() -> Any: failed += 1 errors_total += 1 if len(errors) < max_error_details: - errors.append({'line': line_no, 'error': '格式错误,应为:邮箱----密码----client_id----refresh_token'}) + errors.append( + { + "line": line_no, + "error": "格式错误,应为:邮箱----密码----client_id----refresh_token", + } + ) continue - email_addr = sanitize_credential_field(parsed.get('email'), 320) - password = sanitize_credential_field(parsed.get('password'), 500) - client_id = sanitize_credential_field(parsed.get('client_id'), 200) - refresh_token = sanitize_credential_field(parsed.get('refresh_token'), 4096) + email_addr = sanitize_credential_field(parsed.get("email"), 320) + password = sanitize_credential_field(parsed.get("password"), 500) + client_id = sanitize_credential_field(parsed.get("client_id"), 200) + refresh_token = sanitize_credential_field(parsed.get("refresh_token"), 4096) if not email_addr or not client_id or not refresh_token: failed += 1 errors_total += 1 if len(errors) < max_error_details: - errors.append({'line': line_no, 'email': email_addr, 'error': '邮箱、Client ID、Refresh Token 不能为空'}) + errors.append( + { + "line": line_no, + "email": email_addr, + "error": "邮箱、Client ID、Refresh Token 不能为空", + } + ) continue # 基础邮箱格式校验 - if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email_addr): + if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email_addr): failed += 1 errors_total += 1 if len(errors) < max_error_details: - errors.append({'line': line_no, 'email': email_addr, 'error': '邮箱格式不正确'}) + errors.append( + {"line": line_no, "email": email_addr, "error": "邮箱格式不正确"} + ) continue - ok = accounts_repo.add_account(email_addr, password, client_id, refresh_token, group_id, db=db, commit=False) + ok = accounts_repo.add_account( + email_addr, + password, + client_id, + refresh_token, + group_id, + db=db, + commit=False, + ) if ok: imported += 1 continue failed += 1 errors_total += 1 - reason = '写入失败' + reason = "写入失败" try: - exists = db.execute('SELECT 1 FROM accounts WHERE email = ? LIMIT 1', (email_addr,)).fetchone() + exists = db.execute( + "SELECT 1 FROM accounts WHERE email = ? LIMIT 1", (email_addr,) + ).fetchone() if exists: - reason = '邮箱已存在' + reason = "邮箱已存在" except Exception: pass if len(errors) < max_error_details: - errors.append({'line': line_no, 'email': email_addr, 'error': reason}) + errors.append({"line": line_no, "email": email_addr, "error": reason}) summary = { - 'group_id': group_id, - 'total_lines': len(raw_lines), - 'imported': imported, - 'failed': failed, - 'errors_total': errors_total, - 'errors_returned': len(errors), - 'errors_truncated': errors_total > len(errors) + "group_id": group_id, + "total_lines": len(raw_lines), + "imported": imported, + "failed": failed, + "errors_total": errors_total, + "errors_returned": len(errors), + "errors_truncated": errors_total > len(errors), } - message = f'导入完成:成功 {imported} 个,失败 {failed} 个' + message = f"导入完成:成功 {imported} 个,失败 {failed} 个" if imported > 0: try: @@ -265,11 +308,15 @@ def api_add_account() -> Any: db.rollback() except Exception: pass - return jsonify({'success': False, 'error': '数据库写入失败,请重试'}) - log_audit('import', 'account', None, f"{message},目标分组ID={group_id}") - return jsonify({'success': True, 'message': message, 'summary': summary, 'errors': errors}) + return jsonify({"success": False, "error": "数据库写入失败,请重试"}) + log_audit("import", "account", None, f"{message},目标分组ID={group_id}") + return jsonify( + {"success": True, "message": message, "summary": summary, "errors": errors} + ) - return jsonify({'success': False, 'error': message, 'summary': summary, 'errors': errors}) + return jsonify( + {"success": False, "error": message, "summary": summary, "errors": errors} + ) @login_required @@ -278,23 +325,23 @@ def api_update_account(account_id: int) -> Any: data = request.json # 检查是否只更新状态 - if 'status' in data and len(data) == 1: + if "status" in data and len(data) == 1: # 只更新状态 - return _api_update_account_status(account_id, data['status']) + return _api_update_account_status(account_id, data["status"]) - email_addr = (data.get('email') or '').strip() - password = data.get('password') - client_id = data.get('client_id') - refresh_token = data.get('refresh_token') + email_addr = (data.get("email") or "").strip() + password = data.get("password") + client_id = data.get("client_id") + refresh_token = data.get("refresh_token") try: - group_id = int(data.get('group_id', 1) or 1) + group_id = int(data.get("group_id", 1) or 1) except Exception: group_id = 1 - remark = sanitize_input(data.get('remark', ''), max_length=200) - status = data.get('status', 'active') + remark = sanitize_input(data.get("remark", ""), max_length=200) + status = data.get("status", "active") if not email_addr: - return jsonify({'success': False, 'error': '邮箱不能为空'}) + return jsonify({"success": False, "error": "邮箱不能为空"}) target_group = groups_repo.get_group_by_id(group_id) if not target_group: @@ -305,9 +352,9 @@ def api_update_account(account_id: int) -> Any: status=404, details=f"group_id={group_id}", ) - return jsonify({'success': False, 'error': error_payload}), 404 + return jsonify({"success": False, "error": error_payload}), 404 - if target_group.get('is_system'): + if target_group.get("is_system"): error_payload = build_error_payload( code="SYSTEM_GROUP_PROTECTED", message="不能移动到系统分组", @@ -315,68 +362,90 @@ def api_update_account(account_id: int) -> Any: status=403, details=f"group_id={group_id}", ) - return jsonify({'success': False, 'error': error_payload}), 403 + return jsonify({"success": False, "error": error_payload}), 403 - if accounts_repo.update_account(account_id, email_addr, password, client_id, refresh_token, group_id, remark, status): + if accounts_repo.update_account( + account_id, + email_addr, + password, + client_id, + refresh_token, + group_id, + remark, + status, + ): changed_fields = [] if isinstance(client_id, str) and client_id.strip(): - changed_fields.append('client_id') + changed_fields.append("client_id") if isinstance(password, str) and password.strip(): - changed_fields.append('password') + changed_fields.append("password") if isinstance(refresh_token, str) and refresh_token.strip(): - changed_fields.append('refresh_token') - details = json.dumps({ - "email": email_addr, - "group_id": group_id, - "status": status, - "changed_fields": changed_fields - }, ensure_ascii=False) - log_audit('update', 'account', str(account_id), details) - return jsonify({'success': True, 'message': '账号更新成功'}) + changed_fields.append("refresh_token") + details = json.dumps( + { + "email": email_addr, + "group_id": group_id, + "status": status, + "changed_fields": changed_fields, + }, + ensure_ascii=False, + ) + log_audit("update", "account", str(account_id), details) + return jsonify({"success": True, "message": "账号更新成功"}) else: - return jsonify({'success': False, 'error': '更新失败'}) + return jsonify({"success": False, "error": "更新失败"}) def _api_update_account_status(account_id: int, status: str) -> Any: """只更新账号状态""" db = get_db() try: - db.execute(''' + db.execute( + """ UPDATE accounts SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? - ''', (status, account_id)) + """, + (status, account_id), + ) db.commit() - return jsonify({'success': True, 'message': '状态更新成功'}) + return jsonify({"success": True, "message": "状态更新成功"}) except Exception: - return jsonify({'success': False, 'error': '更新失败'}) + return jsonify({"success": False, "error": "更新失败"}) @login_required def api_delete_account(account_id: int) -> Any: """删除账号""" - email_addr = '' + email_addr = "" try: db = get_db() - row = db.execute('SELECT email FROM accounts WHERE id = ?', (account_id,)).fetchone() - email_addr = row['email'] if row else '' + row = db.execute( + "SELECT email FROM accounts WHERE id = ?", (account_id,) + ).fetchone() + email_addr = row["email"] if row else "" except Exception: - email_addr = '' + email_addr = "" if accounts_repo.delete_account_by_id(account_id): - log_audit('delete', 'account', str(account_id), f"删除账号:{email_addr}" if email_addr else "删除账号") - return jsonify({'success': True}) + log_audit( + "delete", + "account", + str(account_id), + f"删除账号:{email_addr}" if email_addr else "删除账号", + ) + return jsonify({"success": True}) else: - return jsonify({'success': False, 'error': '删除失败'}) + return jsonify({"success": False, "error": "删除失败"}) @login_required def api_delete_account_by_email(email_addr: str) -> Any: """根据邮箱地址删除账号""" if accounts_repo.delete_account_by_email(email_addr): - log_audit('delete', 'account', email_addr, f"删除账号:{email_addr}") - return jsonify({'success': True}) + log_audit("delete", "account", email_addr, f"删除账号:{email_addr}") + return jsonify({"success": True}) else: - return jsonify({'success': False, 'error': '删除失败'}) + return jsonify({"success": False, "error": "删除失败"}) @login_required @@ -387,13 +456,13 @@ def api_batch_delete_accounts() -> Any: 功能:支持一次性删除多个账号,记录审计日志 """ data = request.get_json() - account_ids = data.get('account_ids', []) + account_ids = data.get("account_ids", []) if not account_ids: - return jsonify({'success': False, 'error': '请选择要删除的账号'}) + return jsonify({"success": False, "error": "请选择要删除的账号"}) if not isinstance(account_ids, list): - return jsonify({'success': False, 'error': '参数格式错误'}) + return jsonify({"success": False, "error": "参数格式错误"}) deleted_count = 0 failed_count = 0 @@ -402,23 +471,33 @@ def api_batch_delete_accounts() -> Any: try: # 获取邮箱地址用于审计日志 db = get_db() - row = db.execute('SELECT email FROM accounts WHERE id = ?', (account_id,)).fetchone() - email_addr = row['email'] if row else '' + row = db.execute( + "SELECT email FROM accounts WHERE id = ?", (account_id,) + ).fetchone() + email_addr = row["email"] if row else "" if accounts_repo.delete_account_by_id(account_id): - log_audit('delete', 'account', str(account_id), f"批量删除账号:{email_addr}" if email_addr else "批量删除账号") + log_audit( + "delete", + "account", + str(account_id), + f"批量删除账号:{email_addr}" if email_addr else "批量删除账号", + ) deleted_count += 1 else: failed_count += 1 except Exception: failed_count += 1 - return jsonify({ - 'success': True, - 'message': f'成功删除 {deleted_count} 个账号' + (f',失败 {failed_count} 个' if failed_count > 0 else ''), - 'deleted_count': deleted_count, - 'failed_count': failed_count - }) + return jsonify( + { + "success": True, + "message": f"成功删除 {deleted_count} 个账号" + + (f",失败 {failed_count} 个" if failed_count > 0 else ""), + "deleted_count": deleted_count, + "failed_count": failed_count, + } + ) # ==================== 批量操作 API ==================== @@ -428,81 +507,100 @@ def api_batch_delete_accounts() -> Any: def api_batch_manage_tags() -> Any: """批量管理账号标签""" data = request.json - account_ids: List[int] = data.get('account_ids', []) - tag_id = data.get('tag_id') - action = data.get('action') # add, remove + account_ids: List[int] = data.get("account_ids", []) + tag_id = data.get("tag_id") + action = data.get("action") # add, remove if not account_ids or not tag_id or not action: - return jsonify({'success': False, 'error': '参数不完整'}) + return jsonify({"success": False, "error": "参数不完整"}) count = 0 for acc_id in account_ids: - if action == 'add': + if action == "add": if tags_repo.add_account_tag(acc_id, tag_id): count += 1 - elif action == 'remove': + elif action == "remove": if tags_repo.remove_account_tag(acc_id, tag_id): count += 1 try: - details = json.dumps({'action': action, 'tag_id': tag_id, 'accounts': len(account_ids), 'affected': count}, ensure_ascii=False) + details = json.dumps( + { + "action": action, + "tag_id": tag_id, + "accounts": len(account_ids), + "affected": count, + }, + ensure_ascii=False, + ) except Exception: details = f"action={action} tag_id={tag_id} accounts={len(account_ids)} affected={count}" - log_audit('update', 'account_tags', str(tag_id), details) - return jsonify({'success': True, 'message': f'成功处理 {count} 个账号'}) + log_audit("update", "account_tags", str(tag_id), details) + return jsonify({"success": True, "message": f"成功处理 {count} 个账号"}) @login_required def api_batch_update_account_group() -> Any: """批量更新账号分组""" data = request.json - account_ids = data.get('account_ids', []) - group_id = data.get('group_id') + account_ids = data.get("account_ids", []) + group_id = data.get("group_id") if not account_ids: - return jsonify({'success': False, 'error': '请选择要修改的账号'}) + return jsonify({"success": False, "error": "请选择要修改的账号"}) if not group_id: - return jsonify({'success': False, 'error': '请选择目标分组'}) + return jsonify({"success": False, "error": "请选择目标分组"}) # 验证分组存在 group = groups_repo.get_group_by_id(group_id) if not group: - return jsonify({'success': False, 'error': '目标分组不存在'}) + return jsonify({"success": False, "error": "目标分组不存在"}) # 检查是否是临时邮箱分组(系统保留分组) - if group.get('is_system'): - return jsonify({'success': False, 'error': '不能移动到系统分组'}) + if group.get("is_system"): + return jsonify({"success": False, "error": "不能移动到系统分组"}) # 批量更新 db = get_db() try: - placeholders = ','.join('?' * len(account_ids)) - db.execute(f''' + placeholders = ",".join("?" * len(account_ids)) + db.execute( + f""" UPDATE accounts SET group_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders}) - ''', [group_id] + account_ids) + """, + [group_id] + account_ids, + ) db.commit() - log_audit('update', 'account_group', str(group_id), f"批量移动分组:账号数={len(account_ids)}") - return jsonify({ - 'success': True, - 'message': f'已将 {len(account_ids)} 个账号移动到「{group["name"]}」分组' - }) + log_audit( + "update", + "account_group", + str(group_id), + f"批量移动分组:账号数={len(account_ids)}", + ) + return jsonify( + { + "success": True, + "message": f'已将 {len(account_ids)} 个账号移动到「{group["name"]}」分组', + } + ) except Exception as e: - return jsonify({'success': False, 'error': str(e)}) + return jsonify({"success": False, "error": str(e)}) @login_required def api_search_accounts() -> Any: """全局搜索账号""" - query = request.args.get('q', '').strip() + query = request.args.get("q", "").strip() if not query: - return jsonify({'success': True, 'accounts': []}) + return jsonify({"success": True, "accounts": []}) db = get_db() # 支持搜索邮箱、备注和标签 - cursor = db.execute(''' + cursor = db.execute( + """ SELECT DISTINCT a.*, g.name as group_name, g.color as group_color FROM accounts a LEFT JOIN groups g ON a.group_id = g.id @@ -510,14 +608,18 @@ def api_search_accounts() -> Any: LEFT JOIN tags t ON at.tag_id = t.id WHERE a.email LIKE ? OR a.remark LIKE ? OR t.name LIKE ? ORDER BY a.created_at DESC - ''', (f'%{query}%', f'%{query}%', f'%{query}%')) + """, + (f"%{query}%", f"%{query}%", f"%{query}%"), + ) rows = cursor.fetchall() # 批量加载标签与最后刷新状态,避免 N+1 查询 account_rows: List[Dict[str, Any]] = [dict(r) for r in rows] try: - account_ids = [int(a.get('id')) for a in account_rows if a.get('id') is not None] + account_ids = [ + int(a.get("id")) for a in account_rows if a.get("id") is not None + ] except Exception: account_ids = [] @@ -526,16 +628,19 @@ def api_search_accounts() -> Any: if account_ids: try: placeholders = ",".join(["?"] * len(account_ids)) - tag_rows = db.execute(f''' + tag_rows = db.execute( + f""" SELECT at.account_id as account_id, t.* FROM account_tags at JOIN tags t ON t.id = at.tag_id WHERE at.account_id IN ({placeholders}) ORDER BY at.account_id ASC, t.created_at DESC - ''', account_ids).fetchall() + """, + account_ids, + ).fetchall() for tr in tag_rows: tag_dict = dict(tr) - acc_id = tag_dict.pop('account_id', None) + acc_id = tag_dict.pop("account_id", None) if acc_id is None: continue tags_by_account.setdefault(int(acc_id), []).append(tag_dict) @@ -544,7 +649,8 @@ def api_search_accounts() -> Any: try: placeholders = ",".join(["?"] * len(account_ids)) - log_rows = db.execute(f''' + log_rows = db.execute( + f""" SELECT l.account_id, l.status, l.error_message, l.created_at FROM account_refresh_logs l JOIN ( @@ -554,10 +660,12 @@ def api_search_accounts() -> Any: GROUP BY account_id ) latest ON l.account_id = latest.account_id AND l.id = latest.max_id - ''', account_ids).fetchall() + """, + account_ids, + ).fetchall() for lr in log_rows: try: - last_log_by_account[int(lr['account_id'])] = dict(lr) + last_log_by_account[int(lr["account_id"])] = dict(lr) except Exception: continue except Exception: @@ -565,32 +673,44 @@ def api_search_accounts() -> Any: safe_accounts = [] for acc in account_rows: - acc_id = acc.get('id') + acc_id = acc.get("id") try: acc_id_int = int(acc_id) except Exception: acc_id_int = None tags = tags_by_account.get(acc_id_int, []) if acc_id_int is not None else [] - last_refresh_log = last_log_by_account.get(acc_id_int) if acc_id_int is not None else None + last_refresh_log = ( + last_log_by_account.get(acc_id_int) if acc_id_int is not None else None + ) - safe_accounts.append({ - 'id': acc['id'], - 'email': acc['email'], - 'client_id': acc['client_id'][:8] + '...' if len(acc['client_id']) > 8 else acc['client_id'], - 'group_id': acc['group_id'], - 'group_name': acc['group_name'] if acc['group_name'] else '默认分组', - 'group_color': acc['group_color'] if acc['group_color'] else '#666666', - 'remark': acc['remark'] if acc['remark'] else '', - 'status': acc['status'] if acc['status'] else 'active', - 'created_at': acc['created_at'] if acc['created_at'] else '', - 'updated_at': acc['updated_at'] if acc['updated_at'] else '', - 'tags': tags, - 'last_refresh_status': last_refresh_log.get('status') if last_refresh_log else None, - 'last_refresh_error': last_refresh_log.get('error_message') if last_refresh_log else None - }) + safe_accounts.append( + { + "id": acc["id"], + "email": acc["email"], + "client_id": ( + acc["client_id"][:8] + "..." + if len(acc["client_id"]) > 8 + else acc["client_id"] + ), + "group_id": acc["group_id"], + "group_name": acc["group_name"] if acc["group_name"] else "默认分组", + "group_color": acc["group_color"] if acc["group_color"] else "#666666", + "remark": acc["remark"] if acc["remark"] else "", + "status": acc["status"] if acc["status"] else "active", + "created_at": acc["created_at"] if acc["created_at"] else "", + "updated_at": acc["updated_at"] if acc["updated_at"] else "", + "tags": tags, + "last_refresh_status": ( + last_refresh_log.get("status") if last_refresh_log else None + ), + "last_refresh_error": ( + last_refresh_log.get("error_message") if last_refresh_log else None + ), + } + ) - return jsonify({'success': True, 'accounts': safe_accounts}) + return jsonify({"success": True, "accounts": safe_accounts}) # ==================== 导出功能 API ==================== @@ -599,25 +719,32 @@ def api_search_accounts() -> Any: @login_required def api_export_all_accounts() -> Any: """导出所有邮箱账号为 TXT 文件(需要二次验证)""" - from outlook_web.security.auth import consume_export_verify_token, get_client_ip, get_user_agent + from outlook_web.security.auth import ( + consume_export_verify_token, + get_client_ip, + get_user_agent, + ) # 从请求头获取二次验证 token(避免 URL 泄露) - verify_token = request.headers.get('X-Export-Token') + verify_token = request.headers.get("X-Export-Token") client_ip = get_client_ip() user_agent = get_user_agent() ok, error_message = consume_export_verify_token(verify_token, client_ip, user_agent) if not ok: - return jsonify({'success': False, 'error': error_message, 'need_verify': True}), 401 + return ( + jsonify({"success": False, "error": error_message, "need_verify": True}), + 401, + ) # 使用 load_accounts 获取所有账号(自动解密) accounts = accounts_repo.load_accounts() if not accounts: - return jsonify({'success': False, 'error': '没有邮箱账号'}) + return jsonify({"success": False, "error": "没有邮箱账号"}) # 记录审计日志 - log_audit('export', 'all_accounts', None, f"导出所有账号,共 {len(accounts)} 个") + log_audit("export", "all_accounts", None, f"导出所有账号,共 {len(accounts)} 个") # 生成导出内容(格式:email----password----client_id----refresh_token) lines = [] @@ -625,7 +752,7 @@ def api_export_all_accounts() -> Any: line = f"{acc['email']}----{acc.get('password', '')}----{acc['client_id']}----{acc['refresh_token']}" lines.append(line) - content = '\n'.join(lines) + content = "\n".join(lines) # 生成文件名(使用 URL 编码处理中文) filename = f"all_accounts_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" @@ -634,30 +761,37 @@ def api_export_all_accounts() -> Any: # 返回文件下载响应 return Response( content, - mimetype='text/plain; charset=utf-8', + mimetype="text/plain; charset=utf-8", headers={ - 'Content-Disposition': f"attachment; filename*=UTF-8''{encoded_filename}" - } + "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}" + }, ) @login_required def api_export_selected_accounts() -> Any: """导出选中分组的邮箱账号为 TXT 文件(需要二次验证)""" - from outlook_web.security.auth import consume_export_verify_token, get_client_ip, get_user_agent + from outlook_web.security.auth import ( + consume_export_verify_token, + get_client_ip, + get_user_agent, + ) data = request.json - group_ids = data.get('group_ids', []) - verify_token = data.get('verify_token') + group_ids = data.get("group_ids", []) + verify_token = data.get("verify_token") client_ip = get_client_ip() user_agent = get_user_agent() ok, error_message = consume_export_verify_token(verify_token, client_ip, user_agent) if not ok: - return jsonify({'success': False, 'error': error_message, 'need_verify': True}), 401 + return ( + jsonify({"success": False, "error": error_message, "need_verify": True}), + 401, + ) if not group_ids: - return jsonify({'success': False, 'error': '请选择要导出的分组'}) + return jsonify({"success": False, "error": "请选择要导出的分组"}) # 获取选中分组下的所有账号(使用 load_accounts 自动解密) all_accounts = [] @@ -666,10 +800,15 @@ def api_export_selected_accounts() -> Any: all_accounts.extend(accounts) if not all_accounts: - return jsonify({'success': False, 'error': '选中的分组下没有邮箱账号'}) + return jsonify({"success": False, "error": "选中的分组下没有邮箱账号"}) # 记录审计日志 - log_audit('export', 'selected_groups', ','.join(map(str, group_ids)), f"导出选中分组的 {len(all_accounts)} 个账号") + log_audit( + "export", + "selected_groups", + ",".join(map(str, group_ids)), + f"导出选中分组的 {len(all_accounts)} 个账号", + ) # 生成导出内容 lines = [] @@ -677,7 +816,7 @@ def api_export_selected_accounts() -> Any: line = f"{acc['email']}----{acc.get('password', '')}----{acc['client_id']}----{acc['refresh_token']}" lines.append(line) - content = '\n'.join(lines) + content = "\n".join(lines) # 生成文件名 filename = f"selected_accounts_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" @@ -686,33 +825,37 @@ def api_export_selected_accounts() -> Any: # 返回文件下载响应 return Response( content, - mimetype='text/plain; charset=utf-8', + mimetype="text/plain; charset=utf-8", headers={ - 'Content-Disposition': f"attachment; filename*=UTF-8''{encoded_filename}" - } + "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}" + }, ) @login_required def api_generate_export_verify_token() -> Any: """生成导出验证token(二次验证)""" - from outlook_web.security.auth import issue_export_verify_token, get_client_ip, get_user_agent + from outlook_web.security.auth import ( + issue_export_verify_token, + get_client_ip, + get_user_agent, + ) from outlook_web.repositories import settings as settings_repo from outlook_web.security.crypto import verify_password data = request.json - password = data.get('password', '') + password = data.get("password", "") # 验证密码 stored_password = settings_repo.get_login_password() if not verify_password(password, stored_password): - return jsonify({'success': False, 'error': '密码错误'}) + return jsonify({"success": False, "error": "密码错误"}) # 生成一次性 token client_ip = get_client_ip() user_agent = get_user_agent() verify_token = issue_export_verify_token(client_ip, user_agent) - return jsonify({'success': True, 'verify_token': verify_token}) + return jsonify({"success": True, "verify_token": verify_token}) # ==================== Token 刷新 API ==================== @@ -724,7 +867,10 @@ REFRESH_LOCK_NAME = "refresh_all_tokens" def api_refresh_account(account_id: int) -> Any: """刷新单个账号的 token""" db = get_db() - cursor = db.execute('SELECT id, email, client_id, refresh_token, group_id FROM accounts WHERE id = ?', (account_id,)) + cursor = db.execute( + "SELECT id, email, client_id, refresh_token, group_id FROM accounts WHERE id = ?", + (account_id,), + ) account = cursor.fetchone() if not account: @@ -733,54 +879,64 @@ def api_refresh_account(account_id: int) -> Any: "账号不存在", "NotFoundError", 404, - f"account_id={account_id}" + f"account_id={account_id}", ) - return jsonify({'success': False, 'error': error_payload}) + return jsonify({"success": False, "error": error_payload}) - account_id = account['id'] - account_email = account['email'] - client_id = account['client_id'] - encrypted_refresh_token = account['refresh_token'] + account_id = account["id"] + account_email = account["email"] + client_id = account["client_id"] + encrypted_refresh_token = account["refresh_token"] # 获取分组代理设置 - proxy_url = '' - if account['group_id']: - group = groups_repo.get_group_by_id(account['group_id']) + proxy_url = "" + if account["group_id"]: + group = groups_repo.get_group_by_id(account["group_id"]) if group: - proxy_url = group.get('proxy_url', '') or '' + proxy_url = group.get("proxy_url", "") or "" # 解密 refresh_token try: - refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token + refresh_token = ( + decrypt_data(encrypted_refresh_token) + if encrypted_refresh_token + else encrypted_refresh_token + ) except Exception as e: error_msg = f"解密 token 失败: {str(e)}" - refresh_logs_repo.log_refresh_result(account_id, account_email, 'manual', 'failed', error_msg) - error_payload = build_error_payload( - "TOKEN_DECRYPT_FAILED", - "Token 解密失败", - "DecryptionError", - 500, - error_msg + refresh_logs_repo.log_refresh_result( + account_id, account_email, "manual", "failed", error_msg ) - return jsonify({'success': False, 'error': error_payload}) + error_payload = build_error_payload( + "TOKEN_DECRYPT_FAILED", "Token 解密失败", "DecryptionError", 500, error_msg + ) + return jsonify({"success": False, "error": error_payload}) # 测试 refresh token - success, error_msg = graph_service.test_refresh_token(client_id, refresh_token, proxy_url) + success, error_msg = graph_service.test_refresh_token( + client_id, refresh_token, proxy_url + ) # 记录刷新结果 - refresh_logs_repo.log_refresh_result(account_id, account_email, 'manual', 'success' if success else 'failed', error_msg) + refresh_logs_repo.log_refresh_result( + account_id, + account_email, + "manual", + "success" if success else "failed", + error_msg, + ) if success: - return jsonify({'success': True, 'message': 'Token 刷新成功'}) + return jsonify({"success": True, "message": "Token 刷新成功"}) error_payload = build_error_payload( "TOKEN_REFRESH_FAILED", "Token 刷新失败", "RefreshTokenError", 400, - error_msg or "未知错误" + error_msg or "未知错误", ) - return jsonify({'success': False, 'error': error_payload}) + return jsonify({"success": False, "error": error_payload}) @login_required @@ -788,7 +944,7 @@ def api_refresh_all_accounts() -> Any: """刷新所有账号的 token(流式响应,实时返回进度)""" trace_id_value = None try: - trace_id_value = getattr(g, 'trace_id', None) + trace_id_value = getattr(g, "trace_id", None) except Exception: trace_id_value = None requested_by_ip = get_client_ip() @@ -803,7 +959,7 @@ def api_refresh_all_accounts() -> Any: test_refresh_token=graph_service.test_refresh_token, ) - return Response(generate(), mimetype='text/event-stream') + return Response(generate(), mimetype="text/event-stream") @login_required @@ -818,7 +974,7 @@ def api_refresh_failed_accounts() -> Any: db = get_db() trace_id_value = None try: - trace_id_value = getattr(g, 'trace_id', None) + trace_id_value = getattr(g, "trace_id", None) except Exception: trace_id_value = None requested_by_ip = get_client_ip() @@ -838,18 +994,20 @@ def api_refresh_failed_accounts() -> Any: @login_required def api_trigger_scheduled_refresh() -> Any: """手动触发定时刷新(支持强制刷新)""" - force = request.args.get('force', 'false').lower() == 'true' + force = request.args.get("force", "false").lower() == "true" trace_id_value = None try: - trace_id_value = getattr(g, 'trace_id', None) + trace_id_value = getattr(g, "trace_id", None) except Exception: trace_id_value = None requested_by_ip = get_client_ip() requested_by_user_agent = get_user_agent() # 获取配置 - refresh_interval_days = int(settings_repo.get_setting('refresh_interval_days', '30')) - use_cron = settings_repo.get_setting('use_cron_schedule', 'false').lower() == 'true' + refresh_interval_days = int( + settings_repo.get_setting("refresh_interval_days", "30") + ) + use_cron = settings_repo.get_setting("use_cron_schedule", "false").lower() == "true" # 执行刷新(使用流式响应) def generate(): @@ -864,7 +1022,7 @@ def api_trigger_scheduled_refresh() -> Any: test_refresh_token=graph_service.test_refresh_token, ) - return Response(generate(), mimetype='text/event-stream') + return Response(generate(), mimetype="text/event-stream") # ==================== 刷新日志 API ==================== @@ -874,10 +1032,11 @@ def api_trigger_scheduled_refresh() -> Any: def api_get_refresh_logs() -> Any: """获取所有账号的刷新历史(近半年)""" db = get_db() - limit = int(request.args.get('limit', 1000)) - offset = int(request.args.get('offset', 0)) + limit = int(request.args.get("limit", 1000)) + offset = int(request.args.get("offset", 0)) - cursor = db.execute(''' + cursor = db.execute( + """ SELECT l.*, a.email as account_email FROM account_refresh_logs l LEFT JOIN accounts a ON l.account_id = a.id @@ -885,50 +1044,59 @@ def api_get_refresh_logs() -> Any: AND l.created_at >= datetime('now', '-6 months') ORDER BY l.created_at DESC LIMIT ? OFFSET ? - ''', (limit, offset)) + """, + (limit, offset), + ) logs = [] for row in cursor.fetchall(): - logs.append({ - 'id': row['id'], - 'account_id': row['account_id'], - 'account_email': row['account_email'] or row['account_email'], - 'refresh_type': row['refresh_type'], - 'status': row['status'], - 'error_message': row['error_message'], - 'created_at': row['created_at'] - }) + logs.append( + { + "id": row["id"], + "account_id": row["account_id"], + "account_email": row["account_email"] or row["account_email"], + "refresh_type": row["refresh_type"], + "status": row["status"], + "error_message": row["error_message"], + "created_at": row["created_at"], + } + ) - return jsonify({'success': True, 'logs': logs}) + return jsonify({"success": True, "logs": logs}) @login_required def api_get_account_refresh_logs(account_id: int) -> Any: """获取单个账号的刷新历史""" db = get_db() - limit = int(request.args.get('limit', 50)) - offset = int(request.args.get('offset', 0)) + limit = int(request.args.get("limit", 50)) + offset = int(request.args.get("offset", 0)) - cursor = db.execute(''' + cursor = db.execute( + """ SELECT * FROM account_refresh_logs WHERE account_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ? - ''', (account_id, limit, offset)) + """, + (account_id, limit, offset), + ) logs = [] for row in cursor.fetchall(): - logs.append({ - 'id': row['id'], - 'account_id': row['account_id'], - 'account_email': row['account_email'], - 'refresh_type': row['refresh_type'], - 'status': row['status'], - 'error_message': row['error_message'], - 'created_at': row['created_at'] - }) + logs.append( + { + "id": row["id"], + "account_id": row["account_id"], + "account_email": row["account_email"], + "refresh_type": row["refresh_type"], + "status": row["status"], + "error_message": row["error_message"], + "created_at": row["created_at"], + } + ) - return jsonify({'success': True, 'logs': logs}) + return jsonify({"success": True, "logs": logs}) @login_required @@ -937,7 +1105,8 @@ def api_get_failed_refresh_logs() -> Any: db = get_db() # 获取每个账号最近一次失败的刷新记录 - cursor = db.execute(''' + cursor = db.execute( + """ SELECT l.*, a.email as account_email, a.status as account_status FROM account_refresh_logs l INNER JOIN ( @@ -948,22 +1117,25 @@ def api_get_failed_refresh_logs() -> Any: LEFT JOIN accounts a ON l.account_id = a.id WHERE l.status = 'failed' ORDER BY l.created_at DESC - ''') + """ + ) logs = [] for row in cursor.fetchall(): - logs.append({ - 'id': row['id'], - 'account_id': row['account_id'], - 'account_email': row['account_email'] or row['account_email'], - 'account_status': row['account_status'], - 'refresh_type': row['refresh_type'], - 'status': row['status'], - 'error_message': row['error_message'], - 'created_at': row['created_at'] - }) + logs.append( + { + "id": row["id"], + "account_id": row["account_id"], + "account_email": row["account_email"] or row["account_email"], + "account_status": row["account_status"], + "refresh_type": row["refresh_type"], + "status": row["status"], + "error_message": row["error_message"], + "created_at": row["created_at"], + } + ) - return jsonify({'success': True, 'logs': logs}) + return jsonify({"success": True, "logs": logs}) @login_required @@ -971,22 +1143,27 @@ def api_get_refresh_stats() -> Any: """获取刷新统计信息(统计当前失败状态的邮箱数量)""" db = get_db() - cursor = db.execute(''' + cursor = db.execute( + """ SELECT MAX(created_at) as last_refresh_time FROM account_refresh_logs WHERE refresh_type IN ('manual', 'manual_all', 'scheduled', 'retry') - ''') + """ + ) row = cursor.fetchone() - last_refresh_time = row['last_refresh_time'] if row else None + last_refresh_time = row["last_refresh_time"] if row else None - cursor = db.execute(''' + cursor = db.execute( + """ SELECT COUNT(*) as total_accounts FROM accounts WHERE status = 'active' - ''') - total_accounts = cursor.fetchone()['total_accounts'] + """ + ) + total_accounts = cursor.fetchone()["total_accounts"] - cursor = db.execute(''' + cursor = db.execute( + """ SELECT COUNT(DISTINCT l.account_id) as failed_count FROM account_refresh_logs l INNER JOIN ( @@ -996,15 +1173,18 @@ def api_get_refresh_stats() -> Any: ) latest ON l.account_id = latest.account_id AND l.created_at = latest.last_refresh INNER JOIN accounts a ON l.account_id = a.id WHERE l.status = 'failed' AND a.status = 'active' - ''') - failed_count = cursor.fetchone()['failed_count'] + """ + ) + failed_count = cursor.fetchone()["failed_count"] - return jsonify({ - 'success': True, - 'stats': { - 'total': total_accounts, - 'success_count': total_accounts - failed_count, - 'failed_count': failed_count, - 'last_refresh_time': last_refresh_time + return jsonify( + { + "success": True, + "stats": { + "total": total_accounts, + "success_count": total_accounts - failed_count, + "failed_count": failed_count, + "last_refresh_time": last_refresh_time, + }, } - }) + ) diff --git a/outlook_web/controllers/emails.py b/outlook_web/controllers/emails.py index a2fc43e..719e926 100644 --- a/outlook_web/controllers/emails.py +++ b/outlook_web/controllers/emails.py @@ -35,124 +35,156 @@ def api_get_emails(email_addr: str) -> Any: "账号不存在", "NotFoundError", 404, - f"email={email_addr}" + f"email={email_addr}", ) - return jsonify({'success': False, 'error': error_payload}) + 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)) + 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']) + 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 '' + 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) + 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(''' + db.execute( + """ UPDATE accounts SET last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE email = ? - ''', (email_addr,)) + """, + (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', '') - }) + 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 - }) + 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 - }) + 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 + 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 分页暂未完全实现 - }) + 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 + 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 - }) + 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 - }) + 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', []) + email_addr = data.get("email", "") + message_ids = data.get("ids", []) if not email_addr or not message_ids: - return jsonify({'success': False, 'error': '参数不完整'}) + return jsonify({"success": False, "error": "参数不完整"}) account = accounts_repo.get_account_by_email(email_addr) if not account: - return jsonify({'success': False, 'error': '账号不存在'}) + return jsonify({"success": False, "error": "账号不存在"}) # 获取分组代理设置 - proxy_url = '' - if account.get('group_id'): - group = groups_repo.get_group_by_id(account['group_id']) + 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 '' + proxy_url = group.get("proxy_url", "") or "" response_data, method_used = email_delete_service.delete_emails_with_fallback( email_addr=email_addr, @@ -167,11 +199,20 @@ def api_delete_emails() -> Any: ) if method_used == "graph": - log_audit("delete", "email", email_addr, f"删除邮件 {len(message_ids)} 封(Graph API)") + 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)") + 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)") + log_audit( + "delete", "email", email_addr, f"删除邮件 {len(message_ids)} 封(IMAP Old)" + ) return jsonify(response_data) @@ -182,41 +223,63 @@ 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': '账号不存在'}) + return jsonify({"success": False, "error": "账号不存在"}) - method = request.args.get('method', 'graph') - folder = request.args.get('folder', 'inbox') + method = request.args.get("method", "graph") + folder = request.args.get("folder", "inbox") - if method == 'graph': + if method == "graph": # 获取分组代理设置 - proxy_url = '' - if account.get('group_id'): - group = groups_repo.get_group_by_id(account['group_id']) + 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 '' + proxy_url = group.get("proxy_url", "") or "" - detail = graph_service.get_email_detail_graph(account['client_id'], account['refresh_token'], message_id, proxy_url) + 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') + 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) + 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": True, "email": detail}) - return jsonify({'success': False, 'error': '获取邮件详情失败'}) + return jsonify({"success": False, "error": "获取邮件详情失败"}) @login_required @@ -243,16 +306,16 @@ def api_extract_verification(email_addr: str) -> Any: "邮箱不存在", "NotFoundError", 404, - f"email={email_addr}" + f"email={email_addr}", ) - return jsonify({'success': False, 'error': error_payload}), 404 + 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']) + 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 '' + proxy_url = group.get("proxy_url", "") or "" # 收集邮件(同时从收件箱和垃圾邮件获取) emails = [] @@ -261,12 +324,12 @@ def api_extract_verification(email_addr: str) -> Any: # 1. 尝试 Graph API 从收件箱获取最新邮件 try: inbox_result = graph_service.get_emails_graph( - account['client_id'], - account['refresh_token'], - folder='inbox', + account["client_id"], + account["refresh_token"], + folder="inbox", skip=0, top=1, - proxy_url=proxy_url + proxy_url=proxy_url, ) if inbox_result.get("success"): emails.extend(inbox_result.get("emails", [])) @@ -277,12 +340,12 @@ def api_extract_verification(email_addr: str) -> Any: # 2. 尝试 Graph API 从垃圾邮件获取最新邮件 try: junk_result = graph_service.get_emails_graph( - account['client_id'], - account['refresh_token'], - folder='junkemail', + account["client_id"], + account["refresh_token"], + folder="junkemail", skip=0, top=1, - proxy_url=proxy_url + proxy_url=proxy_url, ) if junk_result.get("success"): emails.extend(junk_result.get("emails", [])) @@ -295,8 +358,13 @@ def api_extract_verification(email_addr: str) -> Any: # 尝试新版 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 + 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", [])) @@ -306,8 +374,13 @@ def api_extract_verification(email_addr: str) -> Any: # 尝试旧版 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 + 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", [])) @@ -316,16 +389,14 @@ def api_extract_verification(email_addr: str) -> Any: if not emails: error_payload = build_error_payload( - "EMAIL_NOT_FOUND", - "未找到邮件", - "NotFoundError", - 404, - f"email={email_addr}" + "EMAIL_NOT_FOUND", "未找到邮件", "NotFoundError", 404, f"email={email_addr}" ) - return jsonify({'success': False, 'error': error_payload}), 404 + return jsonify({"success": False, "error": error_payload}), 404 # 按时间排序,取最新的一封 - emails.sort(key=lambda x: x.get('receivedDateTime', '') or x.get('date', ''), reverse=True) + emails.sort( + key=lambda x: x.get("receivedDateTime", "") or x.get("date", ""), reverse=True + ) latest_email = emails[0] # 获取邮件详情以获取完整内容 @@ -334,10 +405,10 @@ def api_extract_verification(email_addr: str) -> Any: # 尝试 Graph API 获取详情 try: email_detail = graph_service.get_email_detail_graph( - account['client_id'], - account['refresh_token'], - latest_email.get('id'), - proxy_url + account["client_id"], + account["refresh_token"], + latest_email.get("id"), + proxy_url, ) except Exception: pass @@ -346,43 +417,48 @@ def api_extract_verification(email_addr: str) -> Any: 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' + 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', '') + "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') + 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', '') + 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': '提取成功' - }) + return jsonify({"success": True, "data": result, "message": "提取成功"}) except ValueError as e: # 未找到验证信息 @@ -391,17 +467,13 @@ def api_extract_verification(email_addr: str) -> Any: str(e), "NotFoundError", 404, - f"email={email_addr}" + f"email={email_addr}", ) - return jsonify({'success': False, 'error': error_payload}), 404 + return jsonify({"success": False, "error": error_payload}), 404 except Exception as e: # 其他错误 error_payload = build_error_payload( - "EXTRACT_ERROR", - "提取失败", - "ExtractError", - 500, - str(e) + "EXTRACT_ERROR", "提取失败", "ExtractError", 500, str(e) ) - return jsonify({'success': False, 'error': error_payload}), 500 + return jsonify({"success": False, "error": error_payload}), 500 diff --git a/outlook_web/controllers/groups.py b/outlook_web/controllers/groups.py index e44701e..7a9ba46 100644 --- a/outlook_web/controllers/groups.py +++ b/outlook_web/controllers/groups.py @@ -35,7 +35,7 @@ def sanitize_input(text: str, max_length: int = 500) -> str: text = text[:max_length] # 移除控制字符(保留换行和制表符) - text = ''.join(char for char in text if char.isprintable() or char in '\n\t') + text = "".join(char for char in text if char.isprintable() or char in "\n\t") # 转义HTML特殊字符 text = html.escape(text, quote=True) @@ -52,12 +52,12 @@ def api_get_groups() -> Any: groups = groups_repo.load_groups() # 添加每个分组的邮箱数量 for group in groups: - if group['name'] == '临时邮箱': + if group["name"] == "临时邮箱": # 临时邮箱分组从 temp_emails 表获取数量 - group['account_count'] = temp_emails_repo.get_temp_email_count() + group["account_count"] = temp_emails_repo.get_temp_email_count() else: - group['account_count'] = groups_repo.get_group_account_count(group['id']) - return jsonify({'success': True, 'groups': groups}) + group["account_count"] = groups_repo.get_group_account_count(group["id"]) + return jsonify({"success": True, "groups": groups}) @login_required @@ -65,42 +65,44 @@ def api_get_group(group_id: int) -> Any: """获取单个分组""" group = groups_repo.get_group_by_id(group_id) if not group: - return jsonify({'success': False, 'error': '分组不存在'}) - group['account_count'] = groups_repo.get_group_account_count(group_id) - return jsonify({'success': True, 'group': group}) + return jsonify({"success": False, "error": "分组不存在"}) + group["account_count"] = groups_repo.get_group_account_count(group_id) + return jsonify({"success": True, "group": group}) @login_required def api_add_group() -> Any: """添加分组""" data = request.json - name = sanitize_input(data.get('name', '').strip(), max_length=100) - description = sanitize_input(data.get('description', ''), max_length=500) - color = data.get('color', '#1a1a1a') - proxy_url = data.get('proxy_url', '').strip() + name = sanitize_input(data.get("name", "").strip(), max_length=100) + description = sanitize_input(data.get("description", ""), max_length=500) + color = data.get("color", "#1a1a1a") + proxy_url = data.get("proxy_url", "").strip() if not name: - return jsonify({'success': False, 'error': '分组名称不能为空'}) + return jsonify({"success": False, "error": "分组名称不能为空"}) group_id = groups_repo.add_group(name, description, color, proxy_url) if group_id: - log_audit('create', 'group', str(group_id), f"创建分组:{name}") - return jsonify({'success': True, 'message': '分组创建成功', 'group_id': group_id}) + log_audit("create", "group", str(group_id), f"创建分组:{name}") + return jsonify( + {"success": True, "message": "分组创建成功", "group_id": group_id} + ) else: - return jsonify({'success': False, 'error': '分组名称已存在'}) + return jsonify({"success": False, "error": "分组名称已存在"}) @login_required def api_update_group(group_id: int) -> Any: """更新分组""" data = request.json - name = sanitize_input(data.get('name', '').strip(), max_length=100) - description = sanitize_input(data.get('description', ''), max_length=500) - color = data.get('color', '#1a1a1a') - proxy_url = data.get('proxy_url', '').strip() + name = sanitize_input(data.get("name", "").strip(), max_length=100) + description = sanitize_input(data.get("description", ""), max_length=500) + color = data.get("color", "#1a1a1a") + proxy_url = data.get("proxy_url", "").strip() if not name: - return jsonify({'success': False, 'error': '分组名称不能为空'}) + return jsonify({"success": False, "error": "分组名称不能为空"}) existing = groups_repo.get_group_by_id(group_id) if not existing: @@ -111,10 +113,10 @@ def api_update_group(group_id: int) -> Any: status=404, details=f"group_id={group_id}", ) - return jsonify({'success': False, 'error': error_payload}), 404 + return jsonify({"success": False, "error": error_payload}), 404 # 系统分组保护:不允许重命名(避免破坏系统逻辑) - if existing.get('is_system') and name != existing.get('name'): + if existing.get("is_system") and name != existing.get("name"): error_payload = build_error_payload( code="SYSTEM_GROUP_PROTECTED", message="系统分组不允许重命名", @@ -122,20 +124,23 @@ def api_update_group(group_id: int) -> Any: status=403, details=f"group_id={group_id}", ) - return jsonify({'success': False, 'error': error_payload}), 403 + return jsonify({"success": False, "error": error_payload}), 403 if groups_repo.update_group(group_id, name, description, color, proxy_url): # 不记录 proxy_url 明文(可能包含代理账号/密码) - details = json.dumps({ - "name": name, - "has_description": bool(description), - "color": color, - "proxy_configured": bool(proxy_url) - }, ensure_ascii=False) - log_audit('update', 'group', str(group_id), details) - return jsonify({'success': True, 'message': '分组更新成功'}) + details = json.dumps( + { + "name": name, + "has_description": bool(description), + "color": color, + "proxy_configured": bool(proxy_url), + }, + ensure_ascii=False, + ) + log_audit("update", "group", str(group_id), details) + return jsonify({"success": True, "message": "分组更新成功"}) else: - return jsonify({'success': False, 'error': '更新失败'}) + return jsonify({"success": False, "error": "更新失败"}) @login_required @@ -150,9 +155,9 @@ def api_delete_group(group_id: int) -> Any: status=404, details=f"group_id={group_id}", ) - return jsonify({'success': False, 'error': error_payload}), 404 + return jsonify({"success": False, "error": error_payload}), 404 - if group.get('is_system'): + if group.get("is_system"): error_payload = build_error_payload( code="SYSTEM_GROUP_PROTECTED", message="系统分组不能删除", @@ -160,10 +165,10 @@ def api_delete_group(group_id: int) -> Any: status=403, details=f"group_id={group_id}", ) - return jsonify({'success': False, 'error': error_payload}), 403 + return jsonify({"success": False, "error": error_payload}), 403 default_group_id = groups_repo.get_default_group_id() - if group_id == default_group_id or group.get('name') == '默认分组': + if group_id == default_group_id or group.get("name") == "默认分组": error_payload = build_error_payload( code="DEFAULT_GROUP_PROTECTED", message="默认分组不能删除", @@ -171,39 +176,47 @@ def api_delete_group(group_id: int) -> Any: status=403, details=f"group_id={group_id}", ) - return jsonify({'success': False, 'error': error_payload}), 403 + return jsonify({"success": False, "error": error_payload}), 403 if groups_repo.delete_group(group_id): - log_audit('delete', 'group', str(group_id), "删除分组并迁移账号到默认分组") - return jsonify({'success': True, 'message': '分组已删除,邮箱已移至默认分组'}) + log_audit("delete", "group", str(group_id), "删除分组并迁移账号到默认分组") + return jsonify({"success": True, "message": "分组已删除,邮箱已移至默认分组"}) else: - return jsonify({'success': False, 'error': '删除失败'}) + return jsonify({"success": False, "error": "删除失败"}) @login_required def api_export_group(group_id: int) -> Any: """导出分组下的所有邮箱账号为 TXT 文件(需要二次验证)""" # 从请求头获取二次验证 token(避免 URL 泄露) - verify_token = request.headers.get('X-Export-Token') + verify_token = request.headers.get("X-Export-Token") client_ip = get_client_ip() user_agent = get_user_agent() ok, error_message = consume_export_verify_token(verify_token, client_ip, user_agent) if not ok: - return jsonify({'success': False, 'error': error_message, 'need_verify': True}), 401 + return ( + jsonify({"success": False, "error": error_message, "need_verify": True}), + 401, + ) group = groups_repo.get_group_by_id(group_id) if not group: - return jsonify({'success': False, 'error': '分组不存在'}) + return jsonify({"success": False, "error": "分组不存在"}) # 使用 load_accounts 获取该分组下的所有账号(自动解密) accounts = accounts_repo.load_accounts(group_id) if not accounts: - return jsonify({'success': False, 'error': '该分组下没有邮箱账号'}) + return jsonify({"success": False, "error": "该分组下没有邮箱账号"}) # 记录审计日志 - log_audit('export', 'group', str(group_id), f"导出分组 '{group['name']}' 的 {len(accounts)} 个账号") + log_audit( + "export", + "group", + str(group_id), + f"导出分组 '{group['name']}' 的 {len(accounts)} 个账号", + ) # 生成导出内容(格式:email----password----client_id----refresh_token) lines = [] @@ -211,17 +224,19 @@ def api_export_group(group_id: int) -> Any: line = f"{acc['email']}----{acc.get('password', '')}----{acc['client_id']}----{acc['refresh_token']}" lines.append(line) - content = '\n'.join(lines) + content = "\n".join(lines) # 生成文件名(使用 URL 编码处理中文) - filename = f"{group['name']}_accounts_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" + filename = ( + f"{group['name']}_accounts_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" + ) encoded_filename = quote(filename) # 返回文件下载响应 return Response( content, - mimetype='text/plain; charset=utf-8', + mimetype="text/plain; charset=utf-8", headers={ - 'Content-Disposition': f"attachment; filename*=UTF-8''{encoded_filename}" - } + "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}" + }, ) diff --git a/outlook_web/controllers/oauth.py b/outlook_web/controllers/oauth.py index 394f4f4..912dd18 100644 --- a/outlook_web/controllers/oauth.py +++ b/outlook_web/controllers/oauth.py @@ -20,7 +20,7 @@ OAUTH_SCOPES = [ "offline_access", "https://graph.microsoft.com/Mail.Read", "https://graph.microsoft.com/Mail.ReadWrite", - "https://graph.microsoft.com/User.Read" + "https://graph.microsoft.com/User.Read", ] @@ -40,16 +40,18 @@ def api_get_oauth_auth_url() -> Any: "redirect_uri": oauth_redirect_uri, "response_mode": "query", "scope": " ".join(OAUTH_SCOPES), - "state": "12345" + "state": "12345", } auth_url = f"{base_auth_url}?{urllib.parse.urlencode(params)}" - return jsonify({ - 'success': True, - 'auth_url': auth_url, - 'client_id': oauth_client_id, - 'redirect_uri': oauth_redirect_uri - }) + return jsonify( + { + "success": True, + "auth_url": auth_url, + "client_id": oauth_client_id, + "redirect_uri": oauth_redirect_uri, + } + ) @login_required @@ -59,24 +61,29 @@ def api_exchange_oauth_token() -> Any: oauth_redirect_uri = config.get_oauth_redirect_uri() data = request.json - redirected_url = data.get('redirected_url', '').strip() - verify_token = data.get('verify_token') + redirected_url = data.get("redirected_url", "").strip() + verify_token = data.get("verify_token") if not redirected_url: - return jsonify({'success': False, 'error': '请提供授权后的完整 URL'}) + return jsonify({"success": False, "error": "请提供授权后的完整 URL"}) # 从 URL 中提取 code try: parsed_url = urllib.parse.urlparse(redirected_url) query_params = urllib.parse.parse_qs(parsed_url.query) - auth_code = query_params['code'][0] + auth_code = query_params["code"][0] except (KeyError, IndexError): - return jsonify({'success': False, 'error': '无法从 URL 中提取授权码,请检查 URL 是否正确'}) + return jsonify( + {"success": False, "error": "无法从 URL 中提取授权码,请检查 URL 是否正确"} + ) # 二次验证(敏感信息:refresh_token 不默认明文返回) ok, error_message = check_export_verify_token(verify_token) if not ok: - return jsonify({'success': False, 'error': error_message, 'need_verify': True}), 401 + return ( + jsonify({"success": False, "error": error_message, "need_verify": True}), + 401, + ) # 使用 Code 换取 Token (Public Client 不需要 client_secret) token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" @@ -85,37 +92,50 @@ def api_exchange_oauth_token() -> Any: "code": auth_code, "redirect_uri": oauth_redirect_uri, "grant_type": "authorization_code", - "scope": " ".join(OAUTH_SCOPES) + "scope": " ".join(OAUTH_SCOPES), } try: response = requests.post(token_url, data=token_data, timeout=30) except Exception as e: - return jsonify({'success': False, 'error': f'请求失败: {str(e)}'}) + return jsonify({"success": False, "error": f"请求失败: {str(e)}"}) if response.status_code == 200: tokens = response.json() - refresh_token = tokens.get('refresh_token') + refresh_token = tokens.get("refresh_token") if not refresh_token: - return jsonify({'success': False, 'error': '未能获取 Refresh Token'}) + return jsonify({"success": False, "error": "未能获取 Refresh Token"}) # 成功后再消费一次性验证 token(避免失败时消耗 token) ok, error_message = consume_export_verify_token(verify_token) if not ok: - return jsonify({'success': False, 'error': error_message, 'need_verify': True}), 401 + return ( + jsonify( + {"success": False, "error": error_message, "need_verify": True} + ), + 401, + ) - log_audit('oauth_exchange', 'oauth', None, "换取 Refresh Token 成功(已二次验证)") + log_audit( + "oauth_exchange", "oauth", None, "换取 Refresh Token 成功(已二次验证)" + ) - return jsonify({ - 'success': True, - 'refresh_token': refresh_token, - 'client_id': oauth_client_id, - 'token_type': tokens.get('token_type'), - 'expires_in': tokens.get('expires_in'), - 'scope': tokens.get('scope') - }) + return jsonify( + { + "success": True, + "refresh_token": refresh_token, + "client_id": oauth_client_id, + "token_type": tokens.get("token_type"), + "expires_in": tokens.get("expires_in"), + "scope": tokens.get("scope"), + } + ) else: - error_data = response.json() if response.headers.get('content-type', '').startswith('application/json') else {} - error_msg = error_data.get('error_description', response.text) - return jsonify({'success': False, 'error': f'获取令牌失败: {error_msg}'}) + error_data = ( + response.json() + if response.headers.get("content-type", "").startswith("application/json") + else {} + ) + error_msg = error_data.get("error_description", response.text) + return jsonify({"success": False, "error": f"获取令牌失败: {error_msg}"}) diff --git a/outlook_web/controllers/pages.py b/outlook_web/controllers/pages.py index 97e8736..93e11bb 100644 --- a/outlook_web/controllers/pages.py +++ b/outlook_web/controllers/pages.py @@ -21,7 +21,7 @@ from outlook_web.security.crypto import verify_password def login() -> Any: """登录页面""" - if request.method == 'POST': + if request.method == "POST": try: # 获取客户端 IP client_ip = get_client_ip() @@ -31,7 +31,7 @@ def login() -> Any: if not allowed: trace_id_value = None try: - trace_id_value = getattr(g, 'trace_id', None) + trace_id_value = getattr(g, "trace_id", None) except Exception: trace_id_value = None error_payload = build_error_payload( @@ -40,12 +40,12 @@ def login() -> Any: err_type="RateLimitError", status=429, details=f"ip={client_ip}", - trace_id=trace_id_value + trace_id=trace_id_value, ) - return jsonify({'success': False, 'error': error_payload}), 429 + return jsonify({"success": False, "error": error_payload}), 429 data = request.json if request.is_json else request.form - password = data.get('password', '') + password = data.get("password", "") # 从数据库获取密码哈希 stored_password = settings_repo.get_login_password() @@ -54,16 +54,16 @@ def login() -> Any: if verify_password(password, stored_password): # 登录成功,重置失败记录 reset_login_attempts(client_ip) - session['logged_in'] = True + session["logged_in"] = True session.permanent = True session.modified = True # 确保 Flask-Session 保存 session - return jsonify({'success': True, 'message': '登录成功'}) + return jsonify({"success": True, "message": "登录成功"}) else: # 登录失败,记录失败次数 record_login_failure(client_ip) trace_id_value = None try: - trace_id_value = getattr(g, 'trace_id', None) + trace_id_value = getattr(g, "trace_id", None) except Exception: trace_id_value = None error_payload = build_error_payload( @@ -72,18 +72,21 @@ def login() -> Any: err_type="AuthError", status=401, details=f"ip={client_ip}", - trace_id=trace_id_value + trace_id=trace_id_value, ) - return jsonify({'success': False, 'error': error_payload}), 401 + return jsonify({"success": False, "error": error_payload}), 401 except Exception as e: trace_id_value = None try: - trace_id_value = getattr(g, 'trace_id', None) + trace_id_value = getattr(g, "trace_id", None) except Exception: trace_id_value = None from flask import current_app + try: - current_app.logger.exception("Login error trace_id=%s", trace_id_value or "unknown") + current_app.logger.exception( + "Login error trace_id=%s", trace_id_value or "unknown" + ) except Exception: pass error_payload = build_error_payload( @@ -92,24 +95,24 @@ def login() -> Any: err_type="AuthError", status=500, details=str(e), - trace_id=trace_id_value + trace_id=trace_id_value, ) - return jsonify({'success': False, 'error': error_payload}), 500 + return jsonify({"success": False, "error": error_payload}), 500 # GET 请求返回登录页面 - return render_template('login.html') + return render_template("login.html") def logout() -> Any: """退出登录""" - session.pop('logged_in', None) - return redirect(url_for('pages.login')) + session.pop("logged_in", None) + return redirect(url_for("pages.login")) @login_required def index() -> Any: """主页""" - return render_template('index.html') + return render_template("index.html") def get_csrf_token() -> Any: @@ -118,6 +121,6 @@ def get_csrf_token() -> Any: if CSRF_AVAILABLE: token = generate_csrf() - return jsonify({'csrf_token': token}) + return jsonify({"csrf_token": token}) else: - return jsonify({'csrf_token': None, 'csrf_disabled': True}) + return jsonify({"csrf_token": None, "csrf_disabled": True}) diff --git a/outlook_web/controllers/scheduler.py b/outlook_web/controllers/scheduler.py index b6124a8..9ecce43 100644 --- a/outlook_web/controllers/scheduler.py +++ b/outlook_web/controllers/scheduler.py @@ -30,68 +30,88 @@ def api_get_scheduler_status() -> Any: """获取调度器/定时刷新状态(用于验证"看起来已开启但实际未运行"的问题)""" conn = create_sqlite_connection() try: - enable_scheduled = settings_repo.get_setting('enable_scheduled_refresh', 'true').lower() == 'true' - use_cron = settings_repo.get_setting('use_cron_schedule', 'false').lower() == 'true' - refresh_interval_days = int(settings_repo.get_setting('refresh_interval_days', '30')) - refresh_cron = settings_repo.get_setting('refresh_cron', '0 2 * * *') + enable_scheduled = ( + settings_repo.get_setting("enable_scheduled_refresh", "true").lower() + == "true" + ) + use_cron = ( + settings_repo.get_setting("use_cron_schedule", "false").lower() == "true" + ) + refresh_interval_days = int( + settings_repo.get_setting("refresh_interval_days", "30") + ) + refresh_cron = settings_repo.get_setting("refresh_cron", "0 2 * * *") # 心跳 - heartbeat_row = conn.execute(''' + heartbeat_row = conn.execute( + """ SELECT value, updated_at FROM settings WHERE key = 'scheduler_heartbeat' - ''').fetchone() + """ + ).fetchone() heartbeat = None heartbeat_age_seconds = None if heartbeat_row: try: - heartbeat = json.loads(heartbeat_row['value']) if heartbeat_row['value'] else None + heartbeat = ( + json.loads(heartbeat_row["value"]) + if heartbeat_row["value"] + else None + ) except Exception: - heartbeat = {"raw": heartbeat_row['value']} + heartbeat = {"raw": heartbeat_row["value"]} try: - hb_time = datetime.fromisoformat(heartbeat_row['updated_at']) + hb_time = datetime.fromisoformat(heartbeat_row["updated_at"]) heartbeat_age_seconds = int((utcnow() - hb_time).total_seconds()) except Exception: heartbeat_age_seconds = None # 锁状态 - lock_row = conn.execute(''' + lock_row = conn.execute( + """ SELECT owner_id, acquired_at, expires_at FROM distributed_locks WHERE name = ? - ''', (REFRESH_LOCK_NAME,)).fetchone() + """, + (REFRESH_LOCK_NAME,), + ).fetchone() now_ts = time.time() lock_info = None - if lock_row and lock_row['expires_at'] and lock_row['expires_at'] > now_ts: + if lock_row and lock_row["expires_at"] and lock_row["expires_at"] > now_ts: lock_info = { "locked": True, - "owner_id": lock_row['owner_id'], - "acquired_at": lock_row['acquired_at'], - "expires_at": lock_row['expires_at'] + "owner_id": lock_row["owner_id"], + "acquired_at": lock_row["acquired_at"], + "expires_at": lock_row["expires_at"], } else: lock_info = {"locked": False} # 最近一次定时刷新(含手动触发 scheduled_manual) - last_scheduled_run = conn.execute(''' + last_scheduled_run = conn.execute( + """ SELECT id, trigger_source, status, started_at, finished_at, total, success_count, failed_count, message, trace_id, requested_by_ip FROM refresh_runs WHERE trigger_source IN ('scheduled', 'scheduled_manual') ORDER BY started_at DESC LIMIT 1 - ''').fetchone() + """ + ).fetchone() last_scheduled = dict(last_scheduled_run) if last_scheduled_run else None - running_run = conn.execute(''' + running_run = conn.execute( + """ SELECT id, trigger_source, status, started_at, total, success_count, failed_count, trace_id FROM refresh_runs WHERE status = 'running' ORDER BY started_at DESC LIMIT 1 - ''').fetchone() + """ + ).fetchone() running = dict(running_run) if running_run else None @@ -102,6 +122,7 @@ def api_get_scheduler_status() -> Any: if use_cron: try: from croniter import croniter + base_time = datetime.now() it = croniter(refresh_cron, base_time) for _ in range(5): @@ -110,7 +131,8 @@ def api_get_scheduler_status() -> Any: future_runs = [] else: # 按天数策略:基于最近一次已完成的 scheduled/scheduled_manual 计算 next_due - row = conn.execute(''' + row = conn.execute( + """ SELECT finished_at FROM refresh_runs WHERE trigger_source IN ('scheduled', 'scheduled_manual') @@ -118,10 +140,15 @@ def api_get_scheduler_status() -> Any: AND finished_at IS NOT NULL ORDER BY finished_at DESC LIMIT 1 - ''').fetchone() - last_finished_at = row['finished_at'] if row else None + """ + ).fetchone() + last_finished_at = row["finished_at"] if row else None try: - last_time = datetime.fromisoformat(last_finished_at) if last_finished_at else None + last_time = ( + datetime.fromisoformat(last_finished_at) + if last_finished_at + else None + ) except Exception: last_time = None @@ -130,25 +157,29 @@ def api_get_scheduler_status() -> Any: next_due = next_due_dt.isoformat() future_runs.append(next_due_dt.isoformat()) - return jsonify({ - 'success': True, - 'scheduler': { - 'autostart': config.get_scheduler_autostart_default(), - 'enabled': enable_scheduled, - 'use_cron': use_cron, - 'refresh_cron': refresh_cron, - 'refresh_interval_days': refresh_interval_days, - 'future_runs': future_runs, - 'next_due': next_due, - 'heartbeat': heartbeat, - 'heartbeat_updated_at': heartbeat_row['updated_at'] if heartbeat_row else None, - 'heartbeat_age_seconds': heartbeat_age_seconds - }, - 'refresh': { - 'lock': lock_info, - 'running': running, - 'last_scheduled': last_scheduled + return jsonify( + { + "success": True, + "scheduler": { + "autostart": config.get_scheduler_autostart_default(), + "enabled": enable_scheduled, + "use_cron": use_cron, + "refresh_cron": refresh_cron, + "refresh_interval_days": refresh_interval_days, + "future_runs": future_runs, + "next_due": next_due, + "heartbeat": heartbeat, + "heartbeat_updated_at": ( + heartbeat_row["updated_at"] if heartbeat_row else None + ), + "heartbeat_age_seconds": heartbeat_age_seconds, + }, + "refresh": { + "lock": lock_info, + "running": running, + "last_scheduled": last_scheduled, + }, } - }) + ) finally: conn.close() diff --git a/outlook_web/controllers/settings.py b/outlook_web/controllers/settings.py index ef1500e..e90d522 100644 --- a/outlook_web/controllers/settings.py +++ b/outlook_web/controllers/settings.py @@ -22,33 +22,42 @@ def api_get_settings() -> Any: def mask_secret_value(value: str, head: int = 4, tail: int = 4) -> str: if not value: - return '' + return "" safe_value = str(value) if len(safe_value) <= head + tail: - return '*' * len(safe_value) - return safe_value[:head] + ('*' * (len(safe_value) - head - tail)) + safe_value[-tail:] + return "*" * len(safe_value) + return ( + safe_value[:head] + + ("*" * (len(safe_value) - head - tail)) + + safe_value[-tail:] + ) # 仅返回前端需要的设置项(避免把敏感字段/内部状态直接返回) safe_settings = { - 'refresh_interval_days': all_settings.get('refresh_interval_days', '30'), - 'refresh_delay_seconds': all_settings.get('refresh_delay_seconds', '5'), - 'refresh_cron': all_settings.get('refresh_cron', '0 2 * * *'), - 'use_cron_schedule': all_settings.get('use_cron_schedule', 'false'), - 'enable_scheduled_refresh': all_settings.get('enable_scheduled_refresh', 'true'), + "refresh_interval_days": all_settings.get("refresh_interval_days", "30"), + "refresh_delay_seconds": all_settings.get("refresh_delay_seconds", "5"), + "refresh_cron": all_settings.get("refresh_cron", "0 2 * * *"), + "use_cron_schedule": all_settings.get("use_cron_schedule", "false"), + "enable_scheduled_refresh": all_settings.get( + "enable_scheduled_refresh", "true" + ), # 轮询配置 - 'enable_auto_polling': all_settings.get('enable_auto_polling', 'false') == 'true', - 'polling_interval': int(all_settings.get('polling_interval', '10')), - 'polling_count': int(all_settings.get('polling_count', '5')) + "enable_auto_polling": all_settings.get("enable_auto_polling", "false") + == "true", + "polling_interval": int(all_settings.get("polling_interval", "10")), + "polling_count": int(all_settings.get("polling_count", "5")), } # 敏感字段:不返回明文/哈希,仅提供"是否已设置/脱敏展示" - login_password_value = all_settings.get('login_password') or '' - gptmail_api_key_value = all_settings.get('gptmail_api_key') or '' - safe_settings['login_password_set'] = bool(login_password_value) - safe_settings['gptmail_api_key_set'] = bool(gptmail_api_key_value) - safe_settings['gptmail_api_key_masked'] = mask_secret_value(gptmail_api_key_value) if gptmail_api_key_value else '' + login_password_value = all_settings.get("login_password") or "" + gptmail_api_key_value = all_settings.get("gptmail_api_key") or "" + safe_settings["login_password_set"] = bool(login_password_value) + safe_settings["gptmail_api_key_set"] = bool(gptmail_api_key_value) + safe_settings["gptmail_api_key_masked"] = ( + mask_secret_value(gptmail_api_key_value) if gptmail_api_key_value else "" + ) - return jsonify({'success': True, 'settings': safe_settings}) + return jsonify({"success": True, "settings": safe_settings}) @login_required @@ -65,132 +74,133 @@ def api_update_settings() -> Any: scheduler_reload_needed = False # 更新登录密码 - if 'login_password' in data: - new_password = data['login_password'].strip() + if "login_password" in data: + new_password = data["login_password"].strip() if new_password: if len(new_password) < 8: - errors.append('密码长度至少为 8 位') + errors.append("密码长度至少为 8 位") else: # 哈希新密码 hashed_password = hash_password(new_password) - if settings_repo.set_setting('login_password', hashed_password): - updated.append('登录密码') + if settings_repo.set_setting("login_password", hashed_password): + updated.append("登录密码") else: - errors.append('更新登录密码失败') + errors.append("更新登录密码失败") # 更新 GPTMail API Key - if 'gptmail_api_key' in data: - new_api_key = data['gptmail_api_key'].strip() + if "gptmail_api_key" in data: + new_api_key = data["gptmail_api_key"].strip() if new_api_key: - if settings_repo.set_setting('gptmail_api_key', new_api_key): - updated.append('GPTMail API Key') + if settings_repo.set_setting("gptmail_api_key", new_api_key): + updated.append("GPTMail API Key") else: - errors.append('更新 GPTMail API Key 失败') + errors.append("更新 GPTMail API Key 失败") # 更新刷新周期 - if 'refresh_interval_days' in data: + if "refresh_interval_days" in data: try: - days = int(data['refresh_interval_days']) + days = int(data["refresh_interval_days"]) if days < 1 or days > 90: - errors.append('刷新周期必须在 1-90 天之间') - elif settings_repo.set_setting('refresh_interval_days', str(days)): - updated.append('刷新周期') + errors.append("刷新周期必须在 1-90 天之间") + elif settings_repo.set_setting("refresh_interval_days", str(days)): + updated.append("刷新周期") else: - errors.append('更新刷新周期失败') + errors.append("更新刷新周期失败") except ValueError: - errors.append('刷新周期必须是数字') + errors.append("刷新周期必须是数字") # 更新刷新间隔 - if 'refresh_delay_seconds' in data: + if "refresh_delay_seconds" in data: try: - seconds = int(data['refresh_delay_seconds']) + seconds = int(data["refresh_delay_seconds"]) if seconds < 0 or seconds > 60: - errors.append('刷新间隔必须在 0-60 秒之间') - elif settings_repo.set_setting('refresh_delay_seconds', str(seconds)): - updated.append('刷新间隔') + errors.append("刷新间隔必须在 0-60 秒之间") + elif settings_repo.set_setting("refresh_delay_seconds", str(seconds)): + updated.append("刷新间隔") else: - errors.append('更新刷新间隔失败') + errors.append("更新刷新间隔失败") except ValueError: - errors.append('刷新间隔必须是数字') + errors.append("刷新间隔必须是数字") # 更新 Cron 表达式 - if 'refresh_cron' in data: - cron_expr = data['refresh_cron'].strip() + if "refresh_cron" in data: + cron_expr = data["refresh_cron"].strip() if cron_expr: try: from croniter import croniter + croniter(cron_expr, datetime.now()) - if settings_repo.set_setting('refresh_cron', cron_expr): - updated.append('Cron 表达式') + if settings_repo.set_setting("refresh_cron", cron_expr): + updated.append("Cron 表达式") scheduler_reload_needed = True else: - errors.append('更新 Cron 表达式失败') + errors.append("更新 Cron 表达式失败") except ImportError: - errors.append('croniter 库未安装') + errors.append("croniter 库未安装") except Exception as e: - errors.append(f'Cron 表达式无效: {str(e)}') + errors.append(f"Cron 表达式无效: {str(e)}") # 更新刷新策略 - if 'use_cron_schedule' in data: - use_cron = str(data['use_cron_schedule']).lower() - if use_cron in ('true', 'false'): - if settings_repo.set_setting('use_cron_schedule', use_cron): - updated.append('刷新策略') + if "use_cron_schedule" in data: + use_cron = str(data["use_cron_schedule"]).lower() + if use_cron in ("true", "false"): + if settings_repo.set_setting("use_cron_schedule", use_cron): + updated.append("刷新策略") scheduler_reload_needed = True else: - errors.append('更新刷新策略失败') + errors.append("更新刷新策略失败") else: - errors.append('刷新策略必须是 true 或 false') + errors.append("刷新策略必须是 true 或 false") # 更新定时刷新开关 - if 'enable_scheduled_refresh' in data: - enable = str(data['enable_scheduled_refresh']).lower() - if enable in ('true', 'false'): - if settings_repo.set_setting('enable_scheduled_refresh', enable): - updated.append('定时刷新开关') + if "enable_scheduled_refresh" in data: + enable = str(data["enable_scheduled_refresh"]).lower() + if enable in ("true", "false"): + if settings_repo.set_setting("enable_scheduled_refresh", enable): + updated.append("定时刷新开关") scheduler_reload_needed = True else: - errors.append('更新定时刷新开关失败') + errors.append("更新定时刷新开关失败") else: - errors.append('定时刷新开关必须是 true 或 false') + errors.append("定时刷新开关必须是 true 或 false") # 更新轮询配置 - if 'enable_auto_polling' in data: - enable_polling = str(data['enable_auto_polling']).lower() - if enable_polling in ('true', 'false'): - if settings_repo.set_setting('enable_auto_polling', enable_polling): - updated.append('自动轮询开关') + if "enable_auto_polling" in data: + enable_polling = str(data["enable_auto_polling"]).lower() + if enable_polling in ("true", "false"): + if settings_repo.set_setting("enable_auto_polling", enable_polling): + updated.append("自动轮询开关") else: - errors.append('更新自动轮询开关失败') + errors.append("更新自动轮询开关失败") else: - errors.append('自动轮询开关必须是 true 或 false') + errors.append("自动轮询开关必须是 true 或 false") - if 'polling_interval' in data: + if "polling_interval" in data: try: - interval = int(data['polling_interval']) + interval = int(data["polling_interval"]) if interval < 5 or interval > 300: - errors.append('轮询间隔必须在 5-300 秒之间') - elif settings_repo.set_setting('polling_interval', str(interval)): - updated.append('轮询间隔') + errors.append("轮询间隔必须在 5-300 秒之间") + elif settings_repo.set_setting("polling_interval", str(interval)): + updated.append("轮询间隔") else: - errors.append('更新轮询间隔失败') + errors.append("更新轮询间隔失败") except ValueError: - errors.append('轮询间隔必须是数字') + errors.append("轮询间隔必须是数字") - if 'polling_count' in data: + if "polling_count" in data: try: - count = int(data['polling_count']) + count = int(data["polling_count"]) if count < 0 or count > 100: - errors.append('轮询次数必须在 0-100 次之间(0 表示持续轮询)') - elif settings_repo.set_setting('polling_count', str(count)): - updated.append('轮询次数') + errors.append("轮询次数必须在 0-100 次之间(0 表示持续轮询)") + elif settings_repo.set_setting("polling_count", str(count)): + updated.append("轮询次数") else: - errors.append('更新轮询次数失败') + errors.append("更新轮询次数失败") except ValueError: - errors.append('轮询次数必须是数字') + errors.append("轮询次数必须是数字") if errors: - return jsonify({'success': False, 'error': ';'.join(errors)}) + return jsonify({"success": False, "error": ";".join(errors)}) if updated: scheduler_reloaded = None @@ -208,21 +218,26 @@ def api_update_settings() -> Any: scheduler_reloaded = False try: - details = json.dumps({ - "updated": updated, - "scheduler_reload_needed": scheduler_reload_needed, - "scheduler_reloaded": scheduler_reloaded - }, ensure_ascii=False) + details = json.dumps( + { + "updated": updated, + "scheduler_reload_needed": scheduler_reload_needed, + "scheduler_reloaded": scheduler_reloaded, + }, + ensure_ascii=False, + ) except Exception: details = f"updated={','.join(updated)}" - log_audit('update', 'settings', None, details) - return jsonify({ - 'success': True, - 'message': f'已更新:{", ".join(updated)}', - 'scheduler_reloaded': scheduler_reloaded - }) + log_audit("update", "settings", None, details) + return jsonify( + { + "success": True, + "message": f'已更新:{", ".join(updated)}', + "scheduler_reloaded": scheduler_reloaded, + } + ) else: - return jsonify({'success': False, 'error': '没有需要更新的设置'}) + return jsonify({"success": False, "error": "没有需要更新的设置"}) @login_required @@ -231,13 +246,18 @@ def api_validate_cron() -> Any: try: from croniter import croniter except ImportError: - return jsonify({'success': False, 'error': 'croniter 库未安装,请运行: pip install croniter'}) + return jsonify( + { + "success": False, + "error": "croniter 库未安装,请运行: pip install croniter", + } + ) data = request.json - cron_expr = data.get('cron_expression', '').strip() + cron_expr = data.get("cron_expression", "").strip() if not cron_expr: - return jsonify({'success': False, 'error': 'Cron 表达式不能为空'}) + return jsonify({"success": False, "error": "Cron 表达式不能为空"}) try: base_time = datetime.now() @@ -250,15 +270,15 @@ def api_validate_cron() -> Any: for _ in range(5): future_runs.append(temp_cron.get_next(datetime).isoformat()) - return jsonify({ - 'success': True, - 'valid': True, - 'next_run': next_run.isoformat(), - 'future_runs': future_runs - }) + return jsonify( + { + "success": True, + "valid": True, + "next_run": next_run.isoformat(), + "future_runs": future_runs, + } + ) except Exception as e: - return jsonify({ - 'success': False, - 'valid': False, - 'error': f'Cron 表达式无效: {str(e)}' - }) + return jsonify( + {"success": False, "valid": False, "error": f"Cron 表达式无效: {str(e)}"} + ) diff --git a/outlook_web/controllers/system.py b/outlook_web/controllers/system.py index d184820..fd758ff 100644 --- a/outlook_web/controllers/system.py +++ b/outlook_web/controllers/system.py @@ -32,7 +32,7 @@ def utcnow() -> datetime: def healthz() -> Any: """基础健康检查(用于容器/反代探活)""" - return jsonify({'status': 'ok'}), 200 + return jsonify({"status": "ok"}), 200 @login_required @@ -48,58 +48,74 @@ def api_system_health() -> Any: db_ok = False # Scheduler 心跳 - heartbeat_row = conn.execute(''' + heartbeat_row = conn.execute( + """ SELECT updated_at FROM settings WHERE key = 'scheduler_heartbeat' - ''').fetchone() + """ + ).fetchone() heartbeat_age_seconds = None - if heartbeat_row and heartbeat_row['updated_at']: + if heartbeat_row and heartbeat_row["updated_at"]: try: - hb_time = datetime.fromisoformat(heartbeat_row['updated_at']) + hb_time = datetime.fromisoformat(heartbeat_row["updated_at"]) heartbeat_age_seconds = int((utcnow() - hb_time).total_seconds()) except Exception: heartbeat_age_seconds = None - scheduler_enabled = settings_repo.get_setting('enable_scheduled_refresh', 'true').lower() == 'true' + scheduler_enabled = ( + settings_repo.get_setting("enable_scheduled_refresh", "true").lower() + == "true" + ) scheduler_autostart = config.get_scheduler_autostart_default() - scheduler_healthy = (heartbeat_age_seconds is not None) and (heartbeat_age_seconds <= 120) + scheduler_healthy = (heartbeat_age_seconds is not None) and ( + heartbeat_age_seconds <= 120 + ) # 刷新锁/运行中 - lock_row = conn.execute(''' + lock_row = conn.execute( + """ SELECT owner_id, expires_at FROM distributed_locks WHERE name = ? - ''', (REFRESH_LOCK_NAME,)).fetchone() - locked = bool(lock_row and lock_row['expires_at'] and lock_row['expires_at'] > time.time()) + """, + (REFRESH_LOCK_NAME,), + ).fetchone() + locked = bool( + lock_row and lock_row["expires_at"] and lock_row["expires_at"] > time.time() + ) - running_run = conn.execute(''' + running_run = conn.execute( + """ SELECT id, trigger_source, started_at, trace_id FROM refresh_runs WHERE status = 'running' ORDER BY started_at DESC LIMIT 1 - ''').fetchone() + """ + ).fetchone() - return jsonify({ - 'success': True, - 'health': { - 'service': 'ok', - 'database': 'ok' if db_ok else 'error', - 'scheduler': { - 'enabled': scheduler_enabled, - 'autostart': scheduler_autostart, - 'heartbeat_age_seconds': heartbeat_age_seconds, - 'healthy': scheduler_healthy if scheduler_enabled else True + return jsonify( + { + "success": True, + "health": { + "service": "ok", + "database": "ok" if db_ok else "error", + "scheduler": { + "enabled": scheduler_enabled, + "autostart": scheduler_autostart, + "heartbeat_age_seconds": heartbeat_age_seconds, + "healthy": scheduler_healthy if scheduler_enabled else True, + }, + "refresh": { + "locked": locked, + "running": dict(running_run) if running_run else None, + }, + "server_time_utc": utcnow().isoformat() + "Z", }, - 'refresh': { - 'locked': locked, - 'running': dict(running_run) if running_run else None - }, - 'server_time_utc': utcnow().isoformat() + 'Z' } - }) + ) finally: conn.close() @@ -111,77 +127,95 @@ def api_system_diagnostics() -> Any: try: now_ts = time.time() - export_tokens_count = conn.execute(''' + export_tokens_count = conn.execute( + """ SELECT COUNT(*) as c FROM export_verify_tokens WHERE expires_at > ? - ''', (now_ts,)).fetchone()['c'] + """, + (now_ts,), + ).fetchone()["c"] - locked_ip_count = conn.execute(''' + locked_ip_count = conn.execute( + """ SELECT COUNT(*) as c FROM login_attempts WHERE locked_until_at IS NOT NULL AND locked_until_at > ? - ''', (now_ts,)).fetchone()['c'] + """, + (now_ts,), + ).fetchone()["c"] - running_runs = conn.execute(''' + running_runs = conn.execute( + """ SELECT id, trigger_source, started_at, trace_id FROM refresh_runs WHERE status = 'running' ORDER BY started_at DESC LIMIT 5 - ''').fetchall() + """ + ).fetchall() - last_runs = conn.execute(''' + last_runs = conn.execute( + """ SELECT id, trigger_source, status, started_at, finished_at, total, success_count, failed_count, trace_id FROM refresh_runs ORDER BY started_at DESC LIMIT 10 - ''').fetchall() + """ + ).fetchall() - locks = conn.execute(''' + locks = conn.execute( + """ SELECT name, owner_id, acquired_at, expires_at FROM distributed_locks ORDER BY name ASC - ''').fetchall() + """ + ).fetchall() # 数据库升级状态(可验证) schema_version_row = conn.execute( "SELECT value, updated_at FROM settings WHERE key = ?", - (DB_SCHEMA_VERSION_KEY,) + (DB_SCHEMA_VERSION_KEY,), ).fetchone() try: - schema_version = int(schema_version_row['value']) if schema_version_row else 0 + schema_version = ( + int(schema_version_row["value"]) if schema_version_row else 0 + ) except Exception: schema_version = 0 last_migration = None try: - mig = conn.execute(''' + mig = conn.execute( + """ SELECT id, from_version, to_version, status, started_at, finished_at, error, trace_id FROM schema_migrations ORDER BY started_at DESC LIMIT 1 - ''').fetchone() + """ + ).fetchone() last_migration = dict(mig) if mig else None except Exception: last_migration = None - return jsonify({ - 'success': True, - 'diagnostics': { - 'export_verify_tokens_active': export_tokens_count, - 'login_locked_ip_count': locked_ip_count, - 'running_runs': [dict(r) for r in running_runs], - 'last_runs': [dict(r) for r in last_runs], - 'locks': [dict(r) for r in locks], - 'schema': { - 'version': schema_version, - 'target_version': DB_SCHEMA_VERSION, - 'up_to_date': schema_version >= DB_SCHEMA_VERSION, - 'last_migration': last_migration - } + return jsonify( + { + "success": True, + "diagnostics": { + "export_verify_tokens_active": export_tokens_count, + "login_locked_ip_count": locked_ip_count, + "running_runs": [dict(r) for r in running_runs], + "last_runs": [dict(r) for r in last_runs], + "locks": [dict(r) for r in locks], + "schema": { + "version": schema_version, + "target_version": DB_SCHEMA_VERSION, + "up_to_date": schema_version >= DB_SCHEMA_VERSION, + "last_migration": last_migration, + }, + }, } - }) + ) finally: conn.close() @@ -195,30 +229,34 @@ def api_system_upgrade_status() -> Any: try: row = conn.execute( "SELECT value, updated_at FROM settings WHERE key = ?", - (DB_SCHEMA_VERSION_KEY,) + (DB_SCHEMA_VERSION_KEY,), ).fetchone() try: - schema_version = int(row['value']) if row and row['value'] is not None else 0 + schema_version = ( + int(row["value"]) if row and row["value"] is not None else 0 + ) except Exception: schema_version = 0 last_trace_row = conn.execute( "SELECT value FROM settings WHERE key = ?", - (DB_SCHEMA_LAST_UPGRADE_TRACE_ID_KEY,) + (DB_SCHEMA_LAST_UPGRADE_TRACE_ID_KEY,), ).fetchone() last_error_row = conn.execute( "SELECT value FROM settings WHERE key = ?", - (DB_SCHEMA_LAST_UPGRADE_ERROR_KEY,) + (DB_SCHEMA_LAST_UPGRADE_ERROR_KEY,), ).fetchone() last_migration = None try: - mig = conn.execute(''' + mig = conn.execute( + """ SELECT id, from_version, to_version, status, started_at, finished_at, error, trace_id FROM schema_migrations ORDER BY started_at DESC LIMIT 1 - ''').fetchone() + """ + ).fetchone() last_migration = dict(mig) if mig else None except Exception: last_migration = None @@ -226,21 +264,27 @@ def api_system_upgrade_status() -> Any: database_path = app_config.get_database_path() backup_hint = { "database_path": database_path, - "linux_example": f"cp \"{database_path}\" \"{database_path}.backup\"", - "windows_example": f"copy \"{database_path}\" \"{database_path}.backup\"" + "linux_example": f'cp "{database_path}" "{database_path}.backup"', + "windows_example": f'copy "{database_path}" "{database_path}.backup"', } - return jsonify({ - "success": True, - "upgrade": { - "schema_version": schema_version, - "target_version": DB_SCHEMA_VERSION, - "up_to_date": schema_version >= DB_SCHEMA_VERSION, - "last_upgrade_trace_id": (last_trace_row['value'] if last_trace_row else ""), - "last_upgrade_error": (last_error_row['value'] if last_error_row else ""), - "last_migration": last_migration, - "backup_hint": backup_hint + return jsonify( + { + "success": True, + "upgrade": { + "schema_version": schema_version, + "target_version": DB_SCHEMA_VERSION, + "up_to_date": schema_version >= DB_SCHEMA_VERSION, + "last_upgrade_trace_id": ( + last_trace_row["value"] if last_trace_row else "" + ), + "last_upgrade_error": ( + last_error_row["value"] if last_error_row else "" + ), + "last_migration": last_migration, + "backup_hint": backup_hint, + }, } - }) + ) finally: conn.close() diff --git a/outlook_web/controllers/tags.py b/outlook_web/controllers/tags.py index ded2cfa..57ec629 100644 --- a/outlook_web/controllers/tags.py +++ b/outlook_web/controllers/tags.py @@ -25,7 +25,7 @@ def sanitize_input(text: str, max_length: int = 500) -> str: text = text[:max_length] # 移除控制字符(保留换行和制表符) - text = ''.join(char for char in text if char.isprintable() or char in '\n\t') + text = "".join(char for char in text if char.isprintable() or char in "\n\t") # 转义HTML特殊字符 text = html.escape(text, quote=True) @@ -39,35 +39,42 @@ def sanitize_input(text: str, max_length: int = 500) -> str: @login_required def api_get_tags() -> Any: """获取所有标签""" - return jsonify({'success': True, 'tags': tags_repo.get_tags()}) + return jsonify({"success": True, "tags": tags_repo.get_tags()}) @login_required def api_add_tag() -> Any: """添加标签""" data = request.json - name = sanitize_input(data.get('name', '').strip(), max_length=50) - color = data.get('color', '#1a1a1a') + name = sanitize_input(data.get("name", "").strip(), max_length=50) + color = data.get("color", "#1a1a1a") if not name: - return jsonify({'success': False, 'error': '标签名称不能为空'}) + return jsonify({"success": False, "error": "标签名称不能为空"}) tag_id = tags_repo.add_tag(name, color) if tag_id: - log_audit('create', 'tag', str(tag_id), json.dumps({'name': name, 'color': color}, ensure_ascii=False)) - return jsonify({'success': True, 'tag': {'id': tag_id, 'name': name, 'color': color}}) + log_audit( + "create", + "tag", + str(tag_id), + json.dumps({"name": name, "color": color}, ensure_ascii=False), + ) + return jsonify( + {"success": True, "tag": {"id": tag_id, "name": name, "color": color}} + ) else: - return jsonify({'success': False, 'error': '标签名称已存在'}) + return jsonify({"success": False, "error": "标签名称已存在"}) @login_required def api_delete_tag(tag_id: int) -> Any: """删除标签""" if tags_repo.delete_tag(tag_id): - log_audit('delete', 'tag', str(tag_id), "删除标签") - return jsonify({'success': True, 'message': '标签已删除'}) + log_audit("delete", "tag", str(tag_id), "删除标签") + return jsonify({"success": True, "message": "标签已删除"}) else: - return jsonify({'success': False, 'error': '删除失败'}) + return jsonify({"success": False, "error": "删除失败"}) # 注意: api_batch_manage_tags 将在 accounts 模块迁移时处理 diff --git a/outlook_web/controllers/temp_emails.py b/outlook_web/controllers/temp_emails.py index 344c4b2..34c7d21 100644 --- a/outlook_web/controllers/temp_emails.py +++ b/outlook_web/controllers/temp_emails.py @@ -21,15 +21,15 @@ logger = logging.getLogger(__name__) def api_get_temp_emails() -> Any: """获取所有临时邮箱""" emails = temp_emails_repo.load_temp_emails() - return jsonify({'success': True, 'emails': emails}) + return jsonify({"success": True, "emails": emails}) @login_required def api_generate_temp_email() -> Any: """生成新的临时邮箱""" data = request.json or {} - prefix = data.get('prefix') - domain = data.get('domain') + prefix = data.get("prefix") + domain = data.get("domain") # 调用改进后的 generate_temp_email,返回 (email_addr, error_msg) email_addr, error_msg = gptmail.generate_temp_email(prefix, domain) @@ -37,26 +37,30 @@ def api_generate_temp_email() -> Any: if email_addr: # 成功生成邮箱地址 if temp_emails_repo.add_temp_email(email_addr): - log_audit('create', 'temp_email', email_addr, "生成临时邮箱") + log_audit("create", "temp_email", email_addr, "生成临时邮箱") logger.info(f"临时邮箱生成成功: {email_addr}") - return jsonify({'success': True, 'email': email_addr, 'message': '临时邮箱创建成功'}) + return jsonify( + {"success": True, "email": email_addr, "message": "临时邮箱创建成功"} + ) else: logger.warning(f"临时邮箱已存在: {email_addr}") - return jsonify({'success': False, 'error': '邮箱已存在'}) + return jsonify({"success": False, "error": "邮箱已存在"}) else: # 生成失败,返回详细错误信息 logger.error(f"临时邮箱生成失败: {error_msg}, prefix={prefix}, domain={domain}") - return jsonify({'success': False, 'error': error_msg or '生成临时邮箱失败,请稍后重试'}) + return jsonify( + {"success": False, "error": error_msg or "生成临时邮箱失败,请稍后重试"} + ) @login_required def api_delete_temp_email(email_addr: str) -> Any: """删除临时邮箱""" if temp_emails_repo.delete_temp_email(email_addr): - log_audit('delete', 'temp_email', email_addr, "删除临时邮箱") - return jsonify({'success': True, 'message': '临时邮箱已删除'}) + log_audit("delete", "temp_email", email_addr, "删除临时邮箱") + return jsonify({"success": True, "message": "临时邮箱已删除"}) else: - return jsonify({'success': False, 'error': '删除失败'}) + return jsonify({"success": False, "error": "删除失败"}) @login_required @@ -71,22 +75,26 @@ def api_get_temp_email_messages(email_addr: str) -> Any: formatted = [] for msg in messages: - formatted.append({ - 'id': msg.get('message_id'), - 'from': msg.get('from_address', '未知'), - 'subject': msg.get('subject', '无主题'), - 'body_preview': (msg.get('content', '') or '')[:200], - 'date': msg.get('created_at', ''), - 'timestamp': msg.get('timestamp', 0), - 'has_html': msg.get('has_html', 0) - }) + formatted.append( + { + "id": msg.get("message_id"), + "from": msg.get("from_address", "未知"), + "subject": msg.get("subject", "无主题"), + "body_preview": (msg.get("content", "") or "")[:200], + "date": msg.get("created_at", ""), + "timestamp": msg.get("timestamp", 0), + "has_html": msg.get("has_html", 0), + } + ) - return jsonify({ - 'success': True, - 'emails': formatted, - 'count': len(formatted), - 'method': 'GPTMail' - }) + return jsonify( + { + "success": True, + "emails": formatted, + "count": len(formatted), + "method": "GPTMail", + } + ) @login_required @@ -101,21 +109,27 @@ def api_get_temp_email_message_detail(email_addr: str, message_id: str) -> Any: msg = temp_emails_repo.get_temp_email_message_by_id(message_id) if msg: - return jsonify({ - 'success': True, - 'email': { - 'id': msg.get('message_id'), - 'from': msg.get('from_address', '未知'), - 'to': email_addr, - 'subject': msg.get('subject', '无主题'), - 'body': msg.get('html_content') if msg.get('has_html') else msg.get('content', ''), - 'body_type': 'html' if msg.get('has_html') else 'text', - 'date': msg.get('created_at', ''), - 'timestamp': msg.get('timestamp', 0) + return jsonify( + { + "success": True, + "email": { + "id": msg.get("message_id"), + "from": msg.get("from_address", "未知"), + "to": email_addr, + "subject": msg.get("subject", "无主题"), + "body": ( + msg.get("html_content") + if msg.get("has_html") + else msg.get("content", "") + ), + "body_type": "html" if msg.get("has_html") else "text", + "date": msg.get("created_at", ""), + "timestamp": msg.get("timestamp", 0), + }, } - }) + ) else: - return jsonify({'success': False, 'error': '邮件不存在'}) + return jsonify({"success": False, "error": "邮件不存在"}) @login_required @@ -123,10 +137,15 @@ def api_delete_temp_email_message(email_addr: str, message_id: str) -> Any: """删除临时邮件""" gptmail.delete_temp_email_from_api(message_id) if temp_emails_repo.delete_temp_email_message(message_id): - log_audit('delete', 'temp_email_message', message_id, f"删除临时邮件(email={email_addr})") - return jsonify({'success': True, 'message': '邮件已删除'}) + log_audit( + "delete", + "temp_email_message", + message_id, + f"删除临时邮件(email={email_addr})", + ) + return jsonify({"success": True, "message": "邮件已删除"}) else: - return jsonify({'success': False, 'error': '删除失败'}) + return jsonify({"success": False, "error": "删除失败"}) @login_required @@ -136,16 +155,23 @@ def api_clear_temp_email_messages(email_addr: str) -> Any: db = get_db() try: row = db.execute( - 'SELECT COUNT(*) as c FROM temp_email_messages WHERE email_address = ?', - (email_addr,) + "SELECT COUNT(*) as c FROM temp_email_messages WHERE email_address = ?", + (email_addr,), ).fetchone() - deleted_count = row['c'] if row else 0 - db.execute('DELETE FROM temp_email_messages WHERE email_address = ?', (email_addr,)) + deleted_count = row["c"] if row else 0 + db.execute( + "DELETE FROM temp_email_messages WHERE email_address = ?", (email_addr,) + ) db.commit() - log_audit('delete', 'temp_email_messages', email_addr, f"清空临时邮箱邮件(count={deleted_count})") - return jsonify({'success': True, 'message': '邮件已清空'}) + log_audit( + "delete", + "temp_email_messages", + email_addr, + f"清空临时邮箱邮件(count={deleted_count})", + ) + return jsonify({"success": True, "message": "邮件已清空"}) except Exception: - return jsonify({'success': False, 'error': '清空失败'}) + return jsonify({"success": False, "error": "清空失败"}) @login_required @@ -159,22 +185,26 @@ def api_refresh_temp_email_messages(email_addr: str) -> Any: formatted = [] for msg in messages: - formatted.append({ - 'id': msg.get('message_id'), - 'from': msg.get('from_address', '未知'), - 'subject': msg.get('subject', '无主题'), - 'body_preview': (msg.get('content', '') or '')[:200], - 'date': msg.get('created_at', ''), - 'timestamp': msg.get('timestamp', 0), - 'has_html': msg.get('has_html', 0) - }) + formatted.append( + { + "id": msg.get("message_id"), + "from": msg.get("from_address", "未知"), + "subject": msg.get("subject", "无主题"), + "body_preview": (msg.get("content", "") or "")[:200], + "date": msg.get("created_at", ""), + "timestamp": msg.get("timestamp", 0), + "has_html": msg.get("has_html", 0), + } + ) - return jsonify({ - 'success': True, - 'emails': formatted, - 'count': len(formatted), - 'new_count': saved, - 'method': 'GPTMail' - }) + return jsonify( + { + "success": True, + "emails": formatted, + "count": len(formatted), + "new_count": saved, + "method": "GPTMail", + } + ) else: - return jsonify({'success': False, 'error': '获取邮件失败'}) + return jsonify({"success": False, "error": "获取邮件失败"}) diff --git a/outlook_web/db.py b/outlook_web/db.py index daaef09..9ebca1d 100644 --- a/outlook_web/db.py +++ b/outlook_web/db.py @@ -128,7 +128,9 @@ def init_db(database_path: Optional[str] = None): (DB_SCHEMA_VERSION_KEY,), ).fetchone() try: - current_version = int(row["value"]) if row and row["value"] is not None else 0 + current_version = ( + int(row["value"]) if row and row["value"] is not None else 0 + ) except Exception: current_version = 0 @@ -138,9 +140,11 @@ def init_db(database_path: Optional[str] = None): if db_existed: try: print("=" * 60) - print(f"[升级提示] 检测到数据库需要升级:v{current_version} -> v{DB_SCHEMA_VERSION}") + print( + f"[升级提示] 检测到数据库需要升级:v{current_version} -> v{DB_SCHEMA_VERSION}" + ) print(f"[升级提示] 强烈建议先备份数据库文件:{path}") - print(f"[升级提示] 示例:cp \"{path}\" \"{path}.backup\"") + print(f'[升级提示] 示例:cp "{path}" "{path}.backup"') print(f"[升级提示] trace_id={migration_trace_id}") print("=" * 60) except Exception: @@ -381,9 +385,13 @@ def init_db(database_path: Optional[str] = None): if "remark" not in columns: cursor.execute("ALTER TABLE accounts ADD COLUMN remark TEXT") if "status" not in columns: - cursor.execute("ALTER TABLE accounts ADD COLUMN status TEXT DEFAULT 'active'") + cursor.execute( + "ALTER TABLE accounts ADD COLUMN status TEXT DEFAULT 'active'" + ) if "updated_at" not in columns: - cursor.execute("ALTER TABLE accounts ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP") + cursor.execute( + "ALTER TABLE accounts ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP" + ) if "last_refresh_at" not in columns: cursor.execute("ALTER TABLE accounts ADD COLUMN last_refresh_at TIMESTAMP") @@ -669,4 +677,3 @@ def migrate_sensitive_data(conn: sqlite3.Connection): if migrated_count > 0: print(f"已迁移 {migrated_count} 个账号的敏感数据为加密存储") - diff --git a/outlook_web/errors.py b/outlook_web/errors.py index 1a12d12..ab2dde9 100644 --- a/outlook_web/errors.py +++ b/outlook_web/errors.py @@ -77,7 +77,11 @@ def build_error_payload( # - 5xx: ERROR(服务端错误) # - 4xx: WARNING(客户端错误,如验证失败、权限不足等,属于正常业务流程) # - 其他: INFO - log_level = logging.ERROR if status >= 500 else (logging.WARNING if status >= 400 else logging.INFO) + log_level = ( + logging.ERROR + if status >= 500 + else (logging.WARNING if status >= 400 else logging.INFO) + ) try: current_app.logger.log( diff --git a/outlook_web/middleware/error_handler.py b/outlook_web/middleware/error_handler.py index 8005659..f3e2cd9 100644 --- a/outlook_web/middleware/error_handler.py +++ b/outlook_web/middleware/error_handler.py @@ -6,6 +6,7 @@ - 处理未捕获的异常 - 返回统一错误结构 """ + from __future__ import annotations from flask import g, jsonify, request @@ -30,7 +31,7 @@ def handle_http_exception(error: HTTPException): trace_id_value = None try: - trace_id_value = getattr(g, 'trace_id', None) + trace_id_value = getattr(g, "trace_id", None) except Exception: trace_id_value = None @@ -40,11 +41,11 @@ def handle_http_exception(error: HTTPException): err_type="HttpError", status=status_code, details=str(error), - trace_id=trace_id_value + trace_id=trace_id_value, ) - if request.path.startswith('/api/') or request.is_json: - return jsonify({'success': False, 'error': error_payload}), status_code + if request.path.startswith("/api/") or request.is_json: + return jsonify({"success": False, "error": error_payload}), status_code return f"{message} (trace_id={error_payload.get('trace_id')})", status_code @@ -53,13 +54,16 @@ def handle_exception(error): """处理未捕获的异常""" trace_id_value = None try: - trace_id_value = getattr(g, 'trace_id', None) + trace_id_value = getattr(g, "trace_id", None) except Exception: trace_id_value = None try: from flask import current_app - current_app.logger.exception("Unhandled exception trace_id=%s", trace_id_value or "unknown") + + current_app.logger.exception( + "Unhandled exception trace_id=%s", trace_id_value or "unknown" + ) except Exception: pass @@ -69,10 +73,10 @@ def handle_exception(error): err_type="UnhandledException", status=500, details=str(error), - trace_id=trace_id_value + trace_id=trace_id_value, ) - if request.path.startswith('/api/') or request.is_json: - return jsonify({'success': False, 'error': error_payload}), 500 + if request.path.startswith("/api/") or request.is_json: + return jsonify({"success": False, "error": error_payload}), 500 return f"服务器内部错误 (trace_id={error_payload.get('trace_id')})", 500 diff --git a/outlook_web/middleware/trace.py b/outlook_web/middleware/trace.py index a007a1d..7c4bf7f 100644 --- a/outlook_web/middleware/trace.py +++ b/outlook_web/middleware/trace.py @@ -6,6 +6,7 @@ Trace ID 中间件 - 在响应中添加 X-Trace-Id 头 - 将 legacy 字符串错误格式标准化为结构化错误 """ + from __future__ import annotations import json @@ -18,7 +19,7 @@ from outlook_web.errors import build_error_payload, generate_trace_id def ensure_trace_id(): """为每个请求生成/透传 trace_id,便于前后端统一追踪""" - incoming = request.headers.get('X-Trace-Id') or request.headers.get('X-Request-Id') + incoming = request.headers.get("X-Trace-Id") or request.headers.get("X-Request-Id") if incoming: incoming = incoming.strip() g.trace_id = incoming[:64] @@ -30,9 +31,9 @@ def attach_trace_id_and_normalize_errors(response): """统一写入 X-Trace-Id,并把 legacy 的字符串错误格式标准化为结构化错误""" trace_id_value = None try: - trace_id_value = getattr(g, 'trace_id', None) + trace_id_value = getattr(g, "trace_id", None) if trace_id_value: - response.headers.setdefault('X-Trace-Id', trace_id_value) + response.headers.setdefault("X-Trace-Id", trace_id_value) except Exception: trace_id_value = None @@ -40,60 +41,68 @@ def attach_trace_id_and_normalize_errors(response): if response.is_streamed: return response - content_type = response.headers.get('Content-Type', '') or '' - if not content_type.startswith('application/json'): + content_type = response.headers.get("Content-Type", "") or "" + if not content_type.startswith("application/json"): return response data = response.get_json(silent=True) if not isinstance(data, dict): return response - if data.get('success') is not False: + if data.get("success") is not False: return response # 统一补齐 trace_id/status 等字段 - if isinstance(data.get('error'), dict): - error_obj = dict(data['error']) + if isinstance(data.get("error"), dict): + error_obj = dict(data["error"]) mutated = False - if not error_obj.get('trace_id') and trace_id_value: - error_obj['trace_id'] = trace_id_value + if not error_obj.get("trace_id") and trace_id_value: + error_obj["trace_id"] = trace_id_value mutated = True - if not error_obj.get('status'): - error_obj['status'] = response.status_code if response.status_code >= 400 else 400 + if not error_obj.get("status"): + error_obj["status"] = ( + response.status_code if response.status_code >= 400 else 400 + ) mutated = True - if not error_obj.get('code'): - error_obj['code'] = 'UNKNOWN_ERROR' + if not error_obj.get("code"): + error_obj["code"] = "UNKNOWN_ERROR" mutated = True - if not error_obj.get('message'): - error_obj['message'] = '请求失败' + if not error_obj.get("message"): + error_obj["message"] = "请求失败" mutated = True # 如果 error 中有 status 字段,且当前 HTTP 状态码是 200,则修正为正确的状态码 - error_status = error_obj.get('status') - if error_status and isinstance(error_status, int) and response.status_code == 200: + error_status = error_obj.get("status") + if ( + error_status + and isinstance(error_status, int) + and response.status_code == 200 + ): response.status_code = error_status mutated = True if mutated: new_data = dict(data) - new_data['error'] = error_obj + new_data["error"] = error_obj response.set_data(json.dumps(new_data, ensure_ascii=False)) return response # legacy:error 为字符串 - if isinstance(data.get('error'), str): - legacy_message = data.get('error') or '请求失败' - status_for_payload = response.status_code if response.status_code >= 400 else 400 + if isinstance(data.get("error"), str): + legacy_message = data.get("error") or "请求失败" + status_for_payload = ( + response.status_code if response.status_code >= 400 else 400 + ) error_payload = build_error_payload( - code='LEGACY_ERROR', + code="LEGACY_ERROR", message=legacy_message, - err_type='LegacyError', + err_type="LegacyError", status=status_for_payload, - details='', - trace_id=trace_id_value + details="", + trace_id=trace_id_value, ) new_data = dict(data) - new_data['error'] = error_payload + new_data["error"] = error_payload # legacy 错误默认使用 400 状态码(如果当前是 200) if response.status_code == 200: response.status_code = 400 diff --git a/outlook_web/repositories/__init__.py b/outlook_web/repositories/__init__.py index a446863..2bf78a4 100644 --- a/outlook_web/repositories/__init__.py +++ b/outlook_web/repositories/__init__.py @@ -2,4 +2,3 @@ 目标:把 SQL 与数据映射集中到本目录,routes/services 不直接写 SQL。 """ - diff --git a/outlook_web/repositories/accounts.py b/outlook_web/repositories/accounts.py index a286b34..036f7cc 100644 --- a/outlook_web/repositories/accounts.py +++ b/outlook_web/repositories/accounts.py @@ -82,7 +82,11 @@ def load_accounts(group_id: int = None) -> List[Dict]: except Exception: account_id_value = None - account["tags"] = tags_by_account.get(account_id_value, []) if account_id_value is not None else [] + account["tags"] = ( + tags_by_account.get(account_id_value, []) + if account_id_value is not None + else [] + ) accounts.append(account) return accounts @@ -151,14 +155,23 @@ def add_account( db = db or get_db() try: encrypted_password = encrypt_data(password) if password else password - encrypted_refresh_token = encrypt_data(refresh_token) if refresh_token else refresh_token + encrypted_refresh_token = ( + encrypt_data(refresh_token) if refresh_token else refresh_token + ) db.execute( """ INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark) VALUES (?, ?, ?, ?, ?, ?) """, - (email_addr, encrypted_password, client_id, encrypted_refresh_token, group_id, remark), + ( + email_addr, + encrypted_password, + client_id, + encrypted_refresh_token, + group_id, + remark, + ), ) if commit: db.commit() @@ -193,7 +206,11 @@ def update_account( if not existing: return False - new_client_id = client_id.strip() if isinstance(client_id, str) and client_id.strip() else existing["client_id"] + new_client_id = ( + client_id.strip() + if isinstance(client_id, str) and client_id.strip() + else existing["client_id"] + ) encrypted_password = existing["password"] if isinstance(password, str) and password.strip(): @@ -213,7 +230,16 @@ def update_account( group_id = ?, remark = ?, status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? """, - (email_addr, encrypted_password, new_client_id, encrypted_refresh_token, group_id, remark, status, account_id), + ( + email_addr, + encrypted_password, + new_client_id, + encrypted_refresh_token, + group_id, + remark, + status, + account_id, + ), ) db.commit() return True @@ -241,4 +267,3 @@ def delete_account_by_email(email_addr: str) -> bool: return True except Exception: return False - diff --git a/outlook_web/repositories/distributed_locks.py b/outlook_web/repositories/distributed_locks.py index 57bcf22..5a1bd97 100644 --- a/outlook_web/repositories/distributed_locks.py +++ b/outlook_web/repositories/distributed_locks.py @@ -68,10 +68,15 @@ def acquire_distributed_lock( return False, {"error": str(e)} -def release_distributed_lock(conn: sqlite3.Connection, name: str, owner_id: str) -> bool: +def release_distributed_lock( + conn: sqlite3.Connection, name: str, owner_id: str +) -> bool: try: conn.execute("BEGIN IMMEDIATE") - conn.execute("DELETE FROM distributed_locks WHERE name = ? AND owner_id = ?", (name, owner_id)) + conn.execute( + "DELETE FROM distributed_locks WHERE name = ? AND owner_id = ?", + (name, owner_id), + ) conn.commit() return True except Exception: @@ -80,4 +85,3 @@ def release_distributed_lock(conn: sqlite3.Connection, name: str, owner_id: str) except Exception: pass return False - diff --git a/outlook_web/repositories/groups.py b/outlook_web/repositories/groups.py index dc541d8..b620c57 100644 --- a/outlook_web/repositories/groups.py +++ b/outlook_web/repositories/groups.py @@ -78,7 +78,9 @@ def get_default_group_id() -> int: """获取默认分组 ID(不依赖固定 id=1,增强兼容性)""" db = get_db() try: - row = db.execute("SELECT id FROM groups WHERE name = '默认分组' LIMIT 1").fetchone() + row = db.execute( + "SELECT id FROM groups WHERE name = '默认分组' LIMIT 1" + ).fetchone() return row["id"] if row else 1 except Exception: return 1 @@ -88,7 +90,9 @@ def delete_group(group_id: int) -> bool: """删除分组(将该分组下的邮箱移到默认分组)""" db = get_db() try: - row = db.execute("SELECT id, name, is_system FROM groups WHERE id = ?", (group_id,)).fetchone() + row = db.execute( + "SELECT id, name, is_system FROM groups WHERE id = ?", (group_id,) + ).fetchone() if not row: return False if row["is_system"]: @@ -112,7 +116,8 @@ def delete_group(group_id: int) -> bool: def get_group_account_count(group_id: int) -> int: """获取分组下的邮箱数量""" db = get_db() - cursor = db.execute("SELECT COUNT(*) as count FROM accounts WHERE group_id = ?", (group_id,)) + cursor = db.execute( + "SELECT COUNT(*) as count FROM accounts WHERE group_id = ?", (group_id,) + ) row = cursor.fetchone() return row["count"] if row else 0 - diff --git a/outlook_web/repositories/refresh_logs.py b/outlook_web/repositories/refresh_logs.py index ec33975..e749d98 100644 --- a/outlook_web/repositories/refresh_logs.py +++ b/outlook_web/repositories/refresh_logs.py @@ -38,4 +38,3 @@ def log_refresh_result( return True except Exception: return False - diff --git a/outlook_web/repositories/refresh_runs.py b/outlook_web/repositories/refresh_runs.py index 1228ecd..592f9be 100644 --- a/outlook_web/repositories/refresh_runs.py +++ b/outlook_web/repositories/refresh_runs.py @@ -23,7 +23,15 @@ def create_refresh_run( trace_id ) VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?) """, - (run_id, trigger_source, "running", requested_by_ip, requested_by_user_agent, total, trace_id), + ( + run_id, + trigger_source, + "running", + requested_by_ip, + requested_by_user_agent, + total, + trace_id, + ), ) conn.commit() return run_id @@ -48,4 +56,3 @@ def finish_refresh_run( (status, total, success_count, failed_count, message, run_id), ) conn.commit() - diff --git a/outlook_web/repositories/settings.py b/outlook_web/repositories/settings.py index 72948f8..aaa70c5 100644 --- a/outlook_web/repositories/settings.py +++ b/outlook_web/repositories/settings.py @@ -49,4 +49,3 @@ def get_gptmail_api_key() -> str: """获取 GPTMail API Key(优先从数据库读取)""" api_key = get_setting("gptmail_api_key") return api_key if api_key else config.get_gptmail_api_key_default() - diff --git a/outlook_web/repositories/tags.py b/outlook_web/repositories/tags.py index c4bb868..05b6b42 100644 --- a/outlook_web/repositories/tags.py +++ b/outlook_web/repositories/tags.py @@ -17,7 +17,9 @@ def add_tag(name: str, color: str) -> Optional[int]: """添加标签""" db = get_db() try: - cursor = db.execute("INSERT INTO tags (name, color) VALUES (?, ?)", (name, color)) + cursor = db.execute( + "INSERT INTO tags (name, color) VALUES (?, ?)", (name, color) + ) db.commit() return cursor.lastrowid except sqlite3.IntegrityError: @@ -65,7 +67,9 @@ def add_account_tag(account_id: int, tag_id: int) -> bool: def remove_account_tag(account_id: int, tag_id: int) -> bool: """移除账号标签""" db = get_db() - db.execute("DELETE FROM account_tags WHERE account_id = ? AND tag_id = ?", (account_id, tag_id)) + db.execute( + "DELETE FROM account_tags WHERE account_id = ? AND tag_id = ?", + (account_id, tag_id), + ) db.commit() return True - diff --git a/outlook_web/repositories/temp_emails.py b/outlook_web/repositories/temp_emails.py index 7e42792..adc37c3 100644 --- a/outlook_web/repositories/temp_emails.py +++ b/outlook_web/repositories/temp_emails.py @@ -45,7 +45,9 @@ def delete_temp_email(email_addr: str) -> bool: """删除临时邮箱及其所有邮件""" db = get_db() try: - db.execute("DELETE FROM temp_email_messages WHERE email_address = ?", (email_addr,)) + db.execute( + "DELETE FROM temp_email_messages WHERE email_address = ?", (email_addr,) + ) db.execute("DELETE FROM temp_emails WHERE email = ?", (email_addr,)) db.commit() return True @@ -101,7 +103,9 @@ def get_temp_email_messages(email_addr: str) -> List[Dict]: def get_temp_email_message_by_id(message_id: str) -> Optional[Dict]: """根据 ID 获取临时邮件""" db = get_db() - cursor = db.execute("SELECT * FROM temp_email_messages WHERE message_id = ?", (message_id,)) + cursor = db.execute( + "SELECT * FROM temp_email_messages WHERE message_id = ?", (message_id,) + ) row = cursor.fetchone() return dict(row) if row else None @@ -110,7 +114,9 @@ def delete_temp_email_message(message_id: str) -> bool: """删除临时邮件""" db = get_db() try: - db.execute("DELETE FROM temp_email_messages WHERE message_id = ?", (message_id,)) + db.execute( + "DELETE FROM temp_email_messages WHERE message_id = ?", (message_id,) + ) db.commit() return True except Exception: @@ -123,4 +129,3 @@ def get_temp_email_count() -> int: cursor = db.execute("SELECT COUNT(*) as count FROM temp_emails") row = cursor.fetchone() return row["count"] if row else 0 - diff --git a/outlook_web/routes/__init__.py b/outlook_web/routes/__init__.py index c3236f5..bb01a90 100644 --- a/outlook_web/routes/__init__.py +++ b/outlook_web/routes/__init__.py @@ -2,4 +2,3 @@ 目标:按领域拆分 routes,并在 create_app 内集中注册,保持 URL 不变。 """ - diff --git a/outlook_web/routes/accounts.py b/outlook_web/routes/accounts.py index b729c68..e1dc222 100644 --- a/outlook_web/routes/accounts.py +++ b/outlook_web/routes/accounts.py @@ -9,36 +9,119 @@ def create_blueprint() -> Blueprint: bp = Blueprint("accounts", __name__) # 基础 CRUD(已迁移到 controllers) - bp.add_url_rule("/api/accounts", view_func=accounts_controller.api_get_accounts, methods=["GET"]) - bp.add_url_rule("/api/accounts", view_func=accounts_controller.api_add_account, methods=["POST"]) - bp.add_url_rule("/api/accounts/", view_func=accounts_controller.api_get_account, methods=["GET"]) - bp.add_url_rule("/api/accounts/", view_func=accounts_controller.api_update_account, methods=["PUT"]) - bp.add_url_rule("/api/accounts/", view_func=accounts_controller.api_delete_account, methods=["DELETE"]) - bp.add_url_rule("/api/accounts/email/", view_func=accounts_controller.api_delete_account_by_email, methods=["DELETE"]) + bp.add_url_rule( + "/api/accounts", view_func=accounts_controller.api_get_accounts, methods=["GET"] + ) + bp.add_url_rule( + "/api/accounts", view_func=accounts_controller.api_add_account, methods=["POST"] + ) + bp.add_url_rule( + "/api/accounts/", + view_func=accounts_controller.api_get_account, + methods=["GET"], + ) + bp.add_url_rule( + "/api/accounts/", + view_func=accounts_controller.api_update_account, + methods=["PUT"], + ) + bp.add_url_rule( + "/api/accounts/", + view_func=accounts_controller.api_delete_account, + methods=["DELETE"], + ) + bp.add_url_rule( + "/api/accounts/email/", + view_func=accounts_controller.api_delete_account_by_email, + methods=["DELETE"], + ) # 批量操作(已迁移到 controllers) - bp.add_url_rule("/api/accounts/search", view_func=accounts_controller.api_search_accounts, methods=["GET"]) - bp.add_url_rule("/api/accounts/batch-update-group", view_func=accounts_controller.api_batch_update_account_group, methods=["POST"]) - bp.add_url_rule("/api/accounts/batch-delete", view_func=accounts_controller.api_batch_delete_accounts, methods=["POST"]) - bp.add_url_rule("/api/accounts/tags", view_func=accounts_controller.api_batch_manage_tags, methods=["POST"]) + bp.add_url_rule( + "/api/accounts/search", + view_func=accounts_controller.api_search_accounts, + methods=["GET"], + ) + bp.add_url_rule( + "/api/accounts/batch-update-group", + view_func=accounts_controller.api_batch_update_account_group, + methods=["POST"], + ) + bp.add_url_rule( + "/api/accounts/batch-delete", + view_func=accounts_controller.api_batch_delete_accounts, + methods=["POST"], + ) + bp.add_url_rule( + "/api/accounts/tags", + view_func=accounts_controller.api_batch_manage_tags, + methods=["POST"], + ) # 导出功能(已迁移到 controllers) - bp.add_url_rule("/api/accounts/export", view_func=accounts_controller.api_export_all_accounts, methods=["GET"]) - bp.add_url_rule("/api/accounts/export-selected", view_func=accounts_controller.api_export_selected_accounts, methods=["POST"]) - bp.add_url_rule("/api/export/verify", view_func=accounts_controller.api_generate_export_verify_token, methods=["POST"]) + bp.add_url_rule( + "/api/accounts/export", + view_func=accounts_controller.api_export_all_accounts, + methods=["GET"], + ) + bp.add_url_rule( + "/api/accounts/export-selected", + view_func=accounts_controller.api_export_selected_accounts, + methods=["POST"], + ) + bp.add_url_rule( + "/api/export/verify", + view_func=accounts_controller.api_generate_export_verify_token, + methods=["POST"], + ) # Token 刷新(已迁移到 controllers) - bp.add_url_rule("/api/accounts//refresh", view_func=accounts_controller.api_refresh_account, methods=["POST"]) - bp.add_url_rule("/api/accounts/refresh-all", view_func=accounts_controller.api_refresh_all_accounts, methods=["GET"]) - bp.add_url_rule("/api/accounts//retry-refresh", view_func=accounts_controller.api_retry_refresh_account, methods=["POST"]) - bp.add_url_rule("/api/accounts/refresh-failed", view_func=accounts_controller.api_refresh_failed_accounts, methods=["POST"]) - bp.add_url_rule("/api/accounts/trigger-scheduled-refresh", view_func=accounts_controller.api_trigger_scheduled_refresh, methods=["GET"]) + bp.add_url_rule( + "/api/accounts//refresh", + view_func=accounts_controller.api_refresh_account, + methods=["POST"], + ) + bp.add_url_rule( + "/api/accounts/refresh-all", + view_func=accounts_controller.api_refresh_all_accounts, + methods=["GET"], + ) + bp.add_url_rule( + "/api/accounts//retry-refresh", + view_func=accounts_controller.api_retry_refresh_account, + methods=["POST"], + ) + bp.add_url_rule( + "/api/accounts/refresh-failed", + view_func=accounts_controller.api_refresh_failed_accounts, + methods=["POST"], + ) + bp.add_url_rule( + "/api/accounts/trigger-scheduled-refresh", + view_func=accounts_controller.api_trigger_scheduled_refresh, + methods=["GET"], + ) # 刷新日志(已迁移到 controllers) - bp.add_url_rule("/api/accounts/refresh-logs", view_func=accounts_controller.api_get_refresh_logs, methods=["GET"]) - bp.add_url_rule("/api/accounts//refresh-logs", view_func=accounts_controller.api_get_account_refresh_logs, methods=["GET"]) - bp.add_url_rule("/api/accounts/refresh-logs/failed", view_func=accounts_controller.api_get_failed_refresh_logs, methods=["GET"]) - bp.add_url_rule("/api/accounts/refresh-stats", view_func=accounts_controller.api_get_refresh_stats, methods=["GET"]) + bp.add_url_rule( + "/api/accounts/refresh-logs", + view_func=accounts_controller.api_get_refresh_logs, + methods=["GET"], + ) + bp.add_url_rule( + "/api/accounts//refresh-logs", + view_func=accounts_controller.api_get_account_refresh_logs, + methods=["GET"], + ) + bp.add_url_rule( + "/api/accounts/refresh-logs/failed", + view_func=accounts_controller.api_get_failed_refresh_logs, + methods=["GET"], + ) + bp.add_url_rule( + "/api/accounts/refresh-stats", + view_func=accounts_controller.api_get_refresh_stats, + methods=["GET"], + ) return bp - diff --git a/outlook_web/routes/audit.py b/outlook_web/routes/audit.py index 14402c9..debf768 100644 --- a/outlook_web/routes/audit.py +++ b/outlook_web/routes/audit.py @@ -8,5 +8,9 @@ from outlook_web.controllers import audit as audit_controller def create_blueprint() -> Blueprint: """创建 audit Blueprint""" bp = Blueprint("audit", __name__) - bp.add_url_rule("/api/audit-logs", view_func=audit_controller.api_get_audit_logs, methods=["GET"]) + bp.add_url_rule( + "/api/audit-logs", + view_func=audit_controller.api_get_audit_logs, + methods=["GET"], + ) return bp diff --git a/outlook_web/routes/emails.py b/outlook_web/routes/emails.py index f02a65f..f5d31de 100644 --- a/outlook_web/routes/emails.py +++ b/outlook_web/routes/emails.py @@ -8,9 +8,24 @@ from outlook_web.controllers import emails as emails_controller def create_blueprint() -> Blueprint: """创建 emails Blueprint""" bp = Blueprint("emails", __name__) - 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"]) + 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/outlook_web/routes/groups.py b/outlook_web/routes/groups.py index db88633..d53416c 100644 --- a/outlook_web/routes/groups.py +++ b/outlook_web/routes/groups.py @@ -8,11 +8,30 @@ from outlook_web.controllers import groups as groups_controller def create_blueprint() -> Blueprint: """创建 groups Blueprint""" bp = Blueprint("groups", __name__) - bp.add_url_rule("/api/groups", view_func=groups_controller.api_get_groups, methods=["GET"]) - bp.add_url_rule("/api/groups/", view_func=groups_controller.api_get_group, methods=["GET"]) - bp.add_url_rule("/api/groups", view_func=groups_controller.api_add_group, methods=["POST"]) - bp.add_url_rule("/api/groups/", view_func=groups_controller.api_update_group, methods=["PUT"]) - bp.add_url_rule("/api/groups/", view_func=groups_controller.api_delete_group, methods=["DELETE"]) - bp.add_url_rule("/api/groups//export", view_func=groups_controller.api_export_group, methods=["GET"]) + bp.add_url_rule( + "/api/groups", view_func=groups_controller.api_get_groups, methods=["GET"] + ) + bp.add_url_rule( + "/api/groups/", + view_func=groups_controller.api_get_group, + methods=["GET"], + ) + bp.add_url_rule( + "/api/groups", view_func=groups_controller.api_add_group, methods=["POST"] + ) + bp.add_url_rule( + "/api/groups/", + view_func=groups_controller.api_update_group, + methods=["PUT"], + ) + bp.add_url_rule( + "/api/groups/", + view_func=groups_controller.api_delete_group, + methods=["DELETE"], + ) + bp.add_url_rule( + "/api/groups//export", + view_func=groups_controller.api_export_group, + methods=["GET"], + ) return bp - diff --git a/outlook_web/routes/oauth.py b/outlook_web/routes/oauth.py index c5c785a..bf7974a 100644 --- a/outlook_web/routes/oauth.py +++ b/outlook_web/routes/oauth.py @@ -8,7 +8,14 @@ from outlook_web.controllers import oauth as oauth_controller def create_blueprint() -> Blueprint: """创建 oauth Blueprint""" bp = Blueprint("oauth", __name__) - bp.add_url_rule("/api/oauth/auth-url", view_func=oauth_controller.api_get_oauth_auth_url, methods=["GET"]) - bp.add_url_rule("/api/oauth/exchange-token", view_func=oauth_controller.api_exchange_oauth_token, methods=["POST"]) + bp.add_url_rule( + "/api/oauth/auth-url", + view_func=oauth_controller.api_get_oauth_auth_url, + methods=["GET"], + ) + bp.add_url_rule( + "/api/oauth/exchange-token", + view_func=oauth_controller.api_exchange_oauth_token, + methods=["POST"], + ) return bp - diff --git a/outlook_web/routes/pages.py b/outlook_web/routes/pages.py index ad4c5dd..442b131 100644 --- a/outlook_web/routes/pages.py +++ b/outlook_web/routes/pages.py @@ -23,4 +23,3 @@ def create_blueprint(csrf_exempt: Optional[Callable] = None) -> Blueprint: bp.add_url_rule("/", view_func=pages_controller.index, methods=["GET"]) bp.add_url_rule("/api/csrf-token", view_func=csrf_token_view, methods=["GET"]) return bp - diff --git a/outlook_web/routes/scheduler.py b/outlook_web/routes/scheduler.py index 60faffb..969d9cf 100644 --- a/outlook_web/routes/scheduler.py +++ b/outlook_web/routes/scheduler.py @@ -8,5 +8,9 @@ from outlook_web.controllers import scheduler as scheduler_controller def create_blueprint() -> Blueprint: """创建 scheduler Blueprint""" bp = Blueprint("scheduler", __name__) - bp.add_url_rule("/api/scheduler/status", view_func=scheduler_controller.api_get_scheduler_status, methods=["GET"]) + bp.add_url_rule( + "/api/scheduler/status", + view_func=scheduler_controller.api_get_scheduler_status, + methods=["GET"], + ) return bp diff --git a/outlook_web/routes/settings.py b/outlook_web/routes/settings.py index 1d6ad0a..6a0e003 100644 --- a/outlook_web/routes/settings.py +++ b/outlook_web/routes/settings.py @@ -8,8 +8,17 @@ from outlook_web.controllers import settings as settings_controller def create_blueprint() -> Blueprint: """创建 settings Blueprint""" bp = Blueprint("settings", __name__) - bp.add_url_rule("/api/settings/validate-cron", view_func=settings_controller.api_validate_cron, methods=["POST"]) - bp.add_url_rule("/api/settings", view_func=settings_controller.api_get_settings, methods=["GET"]) - bp.add_url_rule("/api/settings", view_func=settings_controller.api_update_settings, methods=["PUT"]) + bp.add_url_rule( + "/api/settings/validate-cron", + view_func=settings_controller.api_validate_cron, + methods=["POST"], + ) + bp.add_url_rule( + "/api/settings", view_func=settings_controller.api_get_settings, methods=["GET"] + ) + bp.add_url_rule( + "/api/settings", + view_func=settings_controller.api_update_settings, + methods=["PUT"], + ) return bp - diff --git a/outlook_web/routes/system.py b/outlook_web/routes/system.py index 0a4ee05..647ef67 100644 --- a/outlook_web/routes/system.py +++ b/outlook_web/routes/system.py @@ -9,8 +9,19 @@ def create_blueprint() -> Blueprint: """创建 system Blueprint""" bp = Blueprint("system", __name__) bp.add_url_rule("/healthz", view_func=system_controller.healthz, methods=["GET"]) - bp.add_url_rule("/api/system/health", view_func=system_controller.api_system_health, methods=["GET"]) - bp.add_url_rule("/api/system/diagnostics", view_func=system_controller.api_system_diagnostics, methods=["GET"]) - bp.add_url_rule("/api/system/upgrade-status", view_func=system_controller.api_system_upgrade_status, methods=["GET"]) + bp.add_url_rule( + "/api/system/health", + view_func=system_controller.api_system_health, + methods=["GET"], + ) + bp.add_url_rule( + "/api/system/diagnostics", + view_func=system_controller.api_system_diagnostics, + methods=["GET"], + ) + bp.add_url_rule( + "/api/system/upgrade-status", + view_func=system_controller.api_system_upgrade_status, + methods=["GET"], + ) return bp - diff --git a/outlook_web/routes/tags.py b/outlook_web/routes/tags.py index bdc54ad..65e8883 100644 --- a/outlook_web/routes/tags.py +++ b/outlook_web/routes/tags.py @@ -8,8 +8,15 @@ from outlook_web.controllers import tags as tags_controller def create_blueprint() -> Blueprint: """创建 tags Blueprint""" bp = Blueprint("tags", __name__) - bp.add_url_rule("/api/tags", view_func=tags_controller.api_get_tags, methods=["GET"]) - bp.add_url_rule("/api/tags", view_func=tags_controller.api_add_tag, methods=["POST"]) - bp.add_url_rule("/api/tags/", view_func=tags_controller.api_delete_tag, methods=["DELETE"]) + bp.add_url_rule( + "/api/tags", view_func=tags_controller.api_get_tags, methods=["GET"] + ) + bp.add_url_rule( + "/api/tags", view_func=tags_controller.api_add_tag, methods=["POST"] + ) + bp.add_url_rule( + "/api/tags/", + view_func=tags_controller.api_delete_tag, + methods=["DELETE"], + ) return bp - diff --git a/outlook_web/routes/temp_emails.py b/outlook_web/routes/temp_emails.py index b8fed9a..bb35a02 100644 --- a/outlook_web/routes/temp_emails.py +++ b/outlook_web/routes/temp_emails.py @@ -12,13 +12,41 @@ def create_blueprint(csrf_exempt=None) -> Blueprint: # 定义路由处理函数 handlers = [ ("/api/temp-emails", temp_emails_controller.api_get_temp_emails, ["GET"]), - ("/api/temp-emails/generate", temp_emails_controller.api_generate_temp_email, ["POST"]), - ("/api/temp-emails/", temp_emails_controller.api_delete_temp_email, ["DELETE"]), - ("/api/temp-emails//messages", temp_emails_controller.api_get_temp_email_messages, ["GET"]), - ("/api/temp-emails//messages/", temp_emails_controller.api_get_temp_email_message_detail, ["GET"]), - ("/api/temp-emails//messages/", temp_emails_controller.api_delete_temp_email_message, ["DELETE"]), - ("/api/temp-emails//clear", temp_emails_controller.api_clear_temp_email_messages, ["DELETE"]), - ("/api/temp-emails//refresh", temp_emails_controller.api_refresh_temp_email_messages, ["POST"]), + ( + "/api/temp-emails/generate", + temp_emails_controller.api_generate_temp_email, + ["POST"], + ), + ( + "/api/temp-emails/", + temp_emails_controller.api_delete_temp_email, + ["DELETE"], + ), + ( + "/api/temp-emails//messages", + temp_emails_controller.api_get_temp_email_messages, + ["GET"], + ), + ( + "/api/temp-emails//messages/", + temp_emails_controller.api_get_temp_email_message_detail, + ["GET"], + ), + ( + "/api/temp-emails//messages/", + temp_emails_controller.api_delete_temp_email_message, + ["DELETE"], + ), + ( + "/api/temp-emails//clear", + temp_emails_controller.api_clear_temp_email_messages, + ["DELETE"], + ), + ( + "/api/temp-emails//refresh", + temp_emails_controller.api_refresh_temp_email_messages, + ["POST"], + ), ] # 注册路由,如果提供了 csrf_exempt 则应用到所有路由 @@ -27,4 +55,3 @@ def create_blueprint(csrf_exempt=None) -> Blueprint: bp.add_url_rule(path, view_func=view_func, methods=methods) return bp - diff --git a/outlook_web/security/__init__.py b/outlook_web/security/__init__.py index 80c9217..67c16f1 100644 --- a/outlook_web/security/__init__.py +++ b/outlook_web/security/__init__.py @@ -15,4 +15,3 @@ __all__ = [ "is_password_hashed", "verify_password", ] - diff --git a/outlook_web/security/auth.py b/outlook_web/security/auth.py index 2c96978..4d8aa8a 100644 --- a/outlook_web/security/auth.py +++ b/outlook_web/security/auth.py @@ -162,7 +162,12 @@ def login_required(f): details="need_login", trace_id=trace_id_value, ) - return jsonify({"success": False, "error": error_payload, "need_login": True}), 401 + return ( + jsonify( + {"success": False, "error": error_payload, "need_login": True} + ), + 401, + ) return redirect(url_for("pages.login")) return f(*args, **kwargs) @@ -257,7 +262,9 @@ def issue_export_verify_token(client_ip: str, user_agent: str) -> str: return verify_token -def consume_export_verify_token(verify_token: str, client_ip: str = "", user_agent: str = "") -> tuple[bool, str]: +def consume_export_verify_token( + verify_token: str, client_ip: str = "", user_agent: str = "" +) -> tuple[bool, str]: """ 校验并消费一次性导出验证 token(成功则删除) @@ -288,7 +295,9 @@ def consume_export_verify_token(verify_token: str, client_ip: str = "", user_age expires_at = row["expires_at"] or 0 if float(expires_at) < now_ts: - db.execute("DELETE FROM export_verify_tokens WHERE token = ?", (verify_token,)) + db.execute( + "DELETE FROM export_verify_tokens WHERE token = ?", (verify_token,) + ) db.commit() return False, "验证已过期,请重新验证" diff --git a/outlook_web/security/crypto.py b/outlook_web/security/crypto.py index bc35dc6..d8dab79 100644 --- a/outlook_web/security/crypto.py +++ b/outlook_web/security/crypto.py @@ -30,7 +30,11 @@ def verify_password(password: str, hashed: str) -> bool: def is_password_hashed(password: str) -> bool: """检查密码是否已经是 bcrypt 哈希值""" - return password.startswith("$2b$") or password.startswith("$2a$") or password.startswith("$2y$") + return ( + password.startswith("$2b$") + or password.startswith("$2a$") + or password.startswith("$2y$") + ) def get_encryption_key() -> bytes: @@ -104,11 +108,13 @@ def decrypt_data(encrypted_data: str) -> str: error_msg = f"Failed to decrypt data: {str(e)}" print(f"[ERROR] {error_msg}", file=sys.stderr) print(f"[ERROR] Data preview: {encrypted_data[:50]}...", file=sys.stderr) - print("[ERROR] This usually means SECRET_KEY has changed or data is corrupted", file=sys.stderr) + print( + "[ERROR] This usually means SECRET_KEY has changed or data is corrupted", + file=sys.stderr, + ) raise RuntimeError(error_msg) def is_encrypted(data: str) -> bool: """检查数据是否已加密""" return bool(data) and data.startswith("enc:") - diff --git a/outlook_web/security/csrf.py b/outlook_web/security/csrf.py index 541d716..78cc8fa 100644 --- a/outlook_web/security/csrf.py +++ b/outlook_web/security/csrf.py @@ -11,7 +11,9 @@ except ImportError: # pragma: no cover CSRFProtect = None # type: ignore _generate_csrf = None # type: ignore CSRF_AVAILABLE = False - print("Warning: flask-wtf not installed. CSRF protection is disabled. Install with: pip install flask-wtf") + print( + "Warning: flask-wtf not installed. CSRF protection is disabled. Install with: pip install flask-wtf" + ) def generate_csrf(): @@ -46,4 +48,3 @@ def init_csrf(app) -> Tuple[Optional[object], Callable, Optional[Callable]]: return f return None, csrf_exempt, None - diff --git a/outlook_web/services/__init__.py b/outlook_web/services/__init__.py index d38491c..84480da 100644 --- a/outlook_web/services/__init__.py +++ b/outlook_web/services/__init__.py @@ -3,4 +3,3 @@ 目标:集中对外部系统(Graph/IMAP/GPTMail)与业务回退编排(刷新、删除等)的逻辑, routes 只负责参数校验与 HTTP 协议层适配。 """ - diff --git a/outlook_web/services/email_delete.py b/outlook_web/services/email_delete.py index b1b8879..4f339a4 100644 --- a/outlook_web/services/email_delete.py +++ b/outlook_web/services/email_delete.py @@ -6,7 +6,9 @@ from typing import Any, Callable, Dict, List, Optional, Tuple from outlook_web.errors import build_error_payload -def summarize_fallback_failures(method_errors: Dict[str, Any], labels: Dict[str, str]) -> str: +def summarize_fallback_failures( + method_errors: Dict[str, Any], labels: Dict[str, str] +) -> str: """将多方式回退的失败原因聚合成“中文可理解”的摘要文本(用于 error.details 展示)。""" lines: List[str] = [] @@ -69,7 +71,10 @@ def delete_emails_with_fallback( graph_error = graph_res.get("error") graph_errors_list = graph_res.get("errors") or [] is_proxy_error = ( - (isinstance(graph_error, dict) and graph_error.get("type") in ("ProxyError", "ConnectionError")) + ( + isinstance(graph_error, dict) + and graph_error.get("type") in ("ProxyError", "ConnectionError") + ) or (isinstance(graph_error, str) and "ProxyError" in graph_error) or any("ProxyError" in str(x) for x in graph_errors_list[:5]) ) @@ -80,12 +85,16 @@ def delete_emails_with_fallback( "graph": graph_error or graph_errors_list or "Graph API 删除失败", } - imap_res = delete_emails_imap(email_addr, client_id, refresh_token, message_ids, imap_server_new) + imap_res = delete_emails_imap( + email_addr, client_id, refresh_token, message_ids, imap_server_new + ) if imap_res.get("success"): return imap_res, "imap_new" method_errors["imap_new"] = imap_res.get("error") or imap_res - imap_old_res = delete_emails_imap(email_addr, client_id, refresh_token, message_ids, imap_server_old) + imap_old_res = delete_emails_imap( + email_addr, client_id, refresh_token, message_ids, imap_server_old + ) if imap_old_res.get("success"): return imap_old_res, "imap_old" method_errors["imap_old"] = imap_old_res.get("error") or imap_old_res @@ -118,4 +127,3 @@ def delete_emails_with_fallback( } ) return response_data, None - diff --git a/outlook_web/services/gptmail.py b/outlook_web/services/gptmail.py index c9abc61..0e5e25d 100644 --- a/outlook_web/services/gptmail.py +++ b/outlook_web/services/gptmail.py @@ -31,7 +31,7 @@ def gptmail_request( "success": False, "error": "GPTMail API Key 未配置", "error_type": "CONFIG_ERROR", - "details": "请在系统设置中配置 GPTMail API Key" + "details": "请在系统设置中配置 GPTMail API Key", } headers = { @@ -51,7 +51,7 @@ def gptmail_request( "success": False, "error": f"不支持的请求方法: {method}", "error_type": "METHOD_ERROR", - "details": "仅支持 GET、POST、DELETE 方法" + "details": "仅支持 GET、POST、DELETE 方法", } # 处理响应 @@ -62,35 +62,35 @@ def gptmail_request( "success": False, "error": "API Key 无效或已过期", "error_type": "AUTH_ERROR", - "details": f"HTTP {response.status_code}: 请检查 API Key 是否正确" + "details": f"HTTP {response.status_code}: 请检查 API Key 是否正确", } elif response.status_code == 403: return { "success": False, "error": "API 访问被拒绝", "error_type": "PERMISSION_ERROR", - "details": f"HTTP {response.status_code}: 请检查 API Key 权限" + "details": f"HTTP {response.status_code}: 请检查 API Key 权限", } elif response.status_code == 429: return { "success": False, "error": "API 请求频率超限", "error_type": "RATE_LIMIT_ERROR", - "details": f"HTTP {response.status_code}: 请稍后重试" + "details": f"HTTP {response.status_code}: 请稍后重试", } elif response.status_code >= 500: return { "success": False, "error": "GPTMail 服务暂时不可用", "error_type": "SERVER_ERROR", - "details": f"HTTP {response.status_code}: 服务器错误,请稍后重试" + "details": f"HTTP {response.status_code}: 服务器错误,请稍后重试", } else: return { "success": False, "error": f"API 请求失败", "error_type": "HTTP_ERROR", - "details": f"HTTP {response.status_code}: {response.text[:200]}" + "details": f"HTTP {response.status_code}: {response.text[:200]}", } except requests.exceptions.Timeout: @@ -98,32 +98,34 @@ def gptmail_request( "success": False, "error": "API 请求超时", "error_type": "TIMEOUT_ERROR", - "details": "请求超过 30 秒未响应,请检查网络连接或稍后重试" + "details": "请求超过 30 秒未响应,请检查网络连接或稍后重试", } except requests.exceptions.ConnectionError as e: return { "success": False, "error": "无法连接到 GPTMail 服务", "error_type": "CONNECTION_ERROR", - "details": f"网络连接失败: {str(e)[:200]}" + "details": f"网络连接失败: {str(e)[:200]}", } except requests.exceptions.RequestException as e: return { "success": False, "error": "网络请求异常", "error_type": "REQUEST_ERROR", - "details": f"请求失败: {str(e)[:200]}" + "details": f"请求失败: {str(e)[:200]}", } except Exception as e: return { "success": False, "error": "未知错误", "error_type": "UNKNOWN_ERROR", - "details": f"异常: {str(e)[:200]}" + "details": f"异常: {str(e)[:200]}", } -def generate_temp_email(prefix: str = None, domain: str = None) -> Tuple[Optional[str], Optional[str]]: +def generate_temp_email( + prefix: str = None, domain: str = None +) -> Tuple[Optional[str], Optional[str]]: """ 生成临时邮箱地址 @@ -190,5 +192,7 @@ def delete_temp_email_from_api(message_id: str) -> bool: def clear_temp_emails_from_api(email_addr: str) -> bool: """清空 GPTMail 邮箱的所有邮件""" - result = gptmail_request("DELETE", "/api/emails/clear", params={"email": email_addr}) + result = gptmail_request( + "DELETE", "/api/emails/clear", params={"email": email_addr} + ) return bool(result and result.get("success", False)) diff --git a/outlook_web/services/graph.py b/outlook_web/services/graph.py index f8da05a..2baf212 100644 --- a/outlook_web/services/graph.py +++ b/outlook_web/services/graph.py @@ -18,7 +18,9 @@ def build_proxies(proxy_url: str) -> Optional[Dict[str, str]]: return {"http": proxy_url, "https": proxy_url} -def get_access_token_graph_result(client_id: str, refresh_token: str, proxy_url: str = None) -> Dict[str, Any]: +def get_access_token_graph_result( + client_id: str, refresh_token: str, proxy_url: str = None +) -> Dict[str, Any]: """获取 Graph API access_token(包含错误详情)""" try: proxies = build_proxies(proxy_url) @@ -75,7 +77,9 @@ def get_access_token_graph_result(client_id: str, refresh_token: str, proxy_url: } -def get_access_token_graph(client_id: str, refresh_token: str, proxy_url: str = None) -> Optional[str]: +def get_access_token_graph( + client_id: str, refresh_token: str, proxy_url: str = None +) -> Optional[str]: """获取 Graph API access_token""" result = get_access_token_graph_result(client_id, refresh_token, proxy_url) if result.get("success"): @@ -120,7 +124,9 @@ def get_emails_graph( } proxies = build_proxies(proxy_url) - res = requests.get(url, headers=headers, params=params, timeout=30, proxies=proxies) + res = requests.get( + url, headers=headers, params=params, timeout=30, proxies=proxies + ) if res.status_code != 200: details = get_response_details(res) @@ -162,14 +168,18 @@ def get_email_detail_graph( try: url = f"https://graph.microsoft.com/v1.0/me/messages/{message_id}" - params = {"$select": "id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,body,bodyPreview"} + params = { + "$select": "id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,body,bodyPreview" + } headers = { "Authorization": f"Bearer {access_token}", "Prefer": "outlook.body-content-type='html'", } proxies = build_proxies(proxy_url) - res = requests.get(url, headers=headers, params=params, timeout=30, proxies=proxies) + res = requests.get( + url, headers=headers, params=params, timeout=30, proxies=proxies + ) if res.status_code != 200: return None @@ -179,7 +189,9 @@ def get_email_detail_graph( return None -def test_refresh_token(client_id: str, refresh_token: str, proxy_url: str = None) -> tuple[bool, str | None]: +def test_refresh_token( + client_id: str, refresh_token: str, proxy_url: str = None +) -> tuple[bool, str | None]: """测试 refresh token 是否有效,返回 (是否成功, 错误信息)""" try: proxies = build_proxies(proxy_url) @@ -199,7 +211,9 @@ def test_refresh_token(client_id: str, refresh_token: str, proxy_url: str = None return True, None error_data = res.json() - error_msg = error_data.get("error_description", error_data.get("error", "未知错误")) + error_msg = error_data.get( + "error_description", error_data.get("error", "未知错误") + ) return False, error_msg except Exception as e: return False, f"请求异常: {str(e)}" @@ -245,7 +259,9 @@ def delete_emails_graph( batch_requests = [] for idx, msg_id in enumerate(batch): - batch_requests.append({"id": str(idx), "method": "DELETE", "url": f"/me/messages/{msg_id}"}) + batch_requests.append( + {"id": str(idx), "method": "DELETE", "url": f"/me/messages/{msg_id}"} + ) try: proxies = build_proxies(proxy_url) @@ -265,7 +281,9 @@ def delete_emails_graph( else: failed_count += 1 try: - errors.append(f"Msg ID: {batch[int(res['id'])]}, Status: {res.get('status')}") + errors.append( + f"Msg ID: {batch[int(res['id'])]}, Status: {res.get('status')}" + ) except Exception: errors.append(f"Status: {res.get('status')}") else: diff --git a/outlook_web/services/http.py b/outlook_web/services/http.py index 2852076..8b80316 100644 --- a/outlook_web/services/http.py +++ b/outlook_web/services/http.py @@ -10,4 +10,3 @@ def get_response_details(response: requests.Response) -> Any: return response.json() except Exception: return response.text or response.reason - diff --git a/outlook_web/services/imap.py b/outlook_web/services/imap.py index 51c2da8..2100726 100644 --- a/outlook_web/services/imap.py +++ b/outlook_web/services/imap.py @@ -29,7 +29,9 @@ def decode_header_value(header_value: str) -> str: for part, charset in decoded_parts: if isinstance(part, bytes): try: - decoded_string += part.decode(charset if charset else "utf-8", "replace") + decoded_string += part.decode( + charset if charset else "utf-8", "replace" + ) except (LookupError, UnicodeDecodeError): decoded_string += part.decode("utf-8", "replace") else: @@ -55,7 +57,11 @@ def get_email_body(msg) -> str: break except Exception: continue - elif content_type == "text/html" and "attachment" not in content_disposition and not body: + elif ( + content_type == "text/html" + and "attachment" not in content_disposition + and not body + ): try: payload = part.get_payload(decode=True) charset = part.get_content_charset() or "utf-8" @@ -145,7 +151,9 @@ def get_emails_imap( top: int = 20, ) -> Dict[str, Any]: """使用 IMAP 获取邮件列表(支持分页和文件夹选择)- 默认使用新版服务器""" - return get_emails_imap_with_server(account, client_id, refresh_token, folder, skip, top, IMAP_SERVER_NEW) + return get_emails_imap_with_server( + account, client_id, refresh_token, folder, skip, top, IMAP_SERVER_NEW + ) def get_emails_imap_with_server( @@ -173,8 +181,20 @@ def get_emails_imap_with_server( folder_map = { "inbox": ['"INBOX"', "INBOX"], "junkemail": ['"Junk"', '"Junk Email"', "Junk", '"垃圾邮件"'], - "deleteditems": ['"Deleted"', '"Deleted Items"', '"Trash"', "Deleted", '"已删除邮件"'], - "trash": ['"Deleted"', '"Deleted Items"', '"Trash"', "Deleted", '"已删除邮件"'], + "deleteditems": [ + '"Deleted"', + '"Deleted Items"', + '"Trash"', + "Deleted", + '"已删除邮件"', + ], + "trash": [ + '"Deleted"', + '"Deleted Items"', + '"Trash"', + "Deleted", + '"已删除邮件"', + ], } possible_folders = folder_map.get((folder or "").lower(), ['"INBOX"']) @@ -198,7 +218,9 @@ def get_emails_imap_with_server( if status == "OK" and folder_list: for folder_item in folder_list: if isinstance(folder_item, bytes): - available_folders.append(folder_item.decode("utf-8", errors="ignore")) + available_folders.append( + folder_item.decode("utf-8", errors="ignore") + ) else: available_folders.append(str(folder_item)) @@ -208,7 +230,10 @@ def get_emails_imap_with_server( "available_folders": available_folders[:10], } except Exception: - error_details = {"last_error": last_error, "tried_folders": possible_folders} + error_details = { + "last_error": last_error, + "tried_folders": possible_folders, + } return { "success": False, @@ -257,11 +282,21 @@ def get_emails_imap_with_server( body_preview = get_email_body(msg) emails_data.append( { - "id": msg_id.decode() if isinstance(msg_id, bytes) else str(msg_id), - "subject": decode_header_value(msg.get("Subject", "无主题")), + "id": ( + msg_id.decode() + if isinstance(msg_id, bytes) + else str(msg_id) + ), + "subject": decode_header_value( + msg.get("Subject", "无主题") + ), "from": decode_header_value(msg.get("From", "未知发件人")), "date": msg.get("Date", "未知时间"), - "body_preview": body_preview[:200] + "..." if len(body_preview) > 200 else body_preview, + "body_preview": ( + body_preview[:200] + "..." + if len(body_preview) > 200 + else body_preview + ), } ) except Exception: @@ -308,8 +343,20 @@ def get_email_detail_imap( folder_map = { "inbox": ['"INBOX"', "INBOX"], "junkemail": ['"Junk"', '"Junk Email"', "Junk", '"垃圾邮件"'], - "deleteditems": ['"Deleted"', '"Deleted Items"', '"Trash"', "Deleted", '"已删除邮件"'], - "trash": ['"Deleted"', '"Deleted Items"', '"Trash"', "Deleted", '"已删除邮件"'], + "deleteditems": [ + '"Deleted"', + '"Deleted Items"', + '"Trash"', + "Deleted", + '"已删除邮件"', + ], + "trash": [ + '"Deleted"', + '"Deleted Items"', + '"Trash"', + "Deleted", + '"已删除邮件"', + ], } possible_folders = folder_map.get((folder or "").lower(), ['"INBOX"']) diff --git a/outlook_web/services/refresh.py b/outlook_web/services/refresh.py index 38bc24c..3c5e736 100644 --- a/outlook_web/services/refresh.py +++ b/outlook_web/services/refresh.py @@ -9,7 +9,10 @@ from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple from outlook_web.db import create_sqlite_connection from outlook_web.errors import build_error_payload, generate_trace_id from outlook_web.security.crypto import decrypt_data -from outlook_web.repositories.distributed_locks import acquire_distributed_lock, release_distributed_lock +from outlook_web.repositories.distributed_locks import ( + acquire_distributed_lock, + release_distributed_lock, +) from outlook_web.repositories.refresh_runs import create_refresh_run, finish_refresh_run @@ -51,13 +54,21 @@ def stream_refresh_all_accounts( run_id = None try: - delay_row = conn.execute("SELECT value FROM settings WHERE key = 'refresh_delay_seconds'").fetchone() + delay_row = conn.execute( + "SELECT value FROM settings WHERE key = 'refresh_delay_seconds'" + ).fetchone() delay_seconds = int(delay_row["value"]) if delay_row else 5 try: - conn.execute("DELETE FROM account_refresh_logs WHERE created_at < datetime('now', '-6 months')") - conn.execute("DELETE FROM refresh_runs WHERE started_at < datetime('now', '-6 months')") - conn.execute("DELETE FROM distributed_locks WHERE expires_at < ?", (time.time(),)) + conn.execute( + "DELETE FROM account_refresh_logs WHERE created_at < datetime('now', '-6 months')" + ) + conn.execute( + "DELETE FROM refresh_runs WHERE started_at < datetime('now', '-6 months')" + ) + conn.execute( + "DELETE FROM distributed_locks WHERE expires_at < ?", (time.time(),) + ) conn.commit() except Exception: pass @@ -77,9 +88,13 @@ def stream_refresh_all_accounts( ) ttl_seconds = compute_refresh_lock_ttl_seconds(total, delay_seconds) - ok, lock_info = acquire_distributed_lock(conn, lock_name, lock_owner_id, ttl_seconds) + ok, lock_info = acquire_distributed_lock( + conn, lock_name, lock_owner_id, ttl_seconds + ) if not ok: - finish_refresh_run(conn, run_id, "skipped", total, 0, 0, "刷新任务冲突:已有刷新在执行") + finish_refresh_run( + conn, run_id, "skipped", total, 0, 0, "刷新任务冲突:已有刷新在执行" + ) error_payload = build_error_payload( code="REFRESH_CONFLICT", message="当前已有刷新任务执行中,请稍后再试", @@ -119,18 +134,31 @@ def stream_refresh_all_accounts( encrypted_refresh_token = account["refresh_token"] try: - refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token + refresh_token = ( + decrypt_data(encrypted_refresh_token) + if encrypted_refresh_token + else encrypted_refresh_token + ) except Exception as e: failed_count += 1 error_msg = f"解密 token 失败: {str(e)}" - failed_list.append({"id": account_id, "email": account_email, "error": error_msg}) + failed_list.append( + {"id": account_id, "email": account_email, "error": error_msg} + ) try: conn.execute( """ INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message, run_id) VALUES (?, ?, ?, ?, ?, ?) """, - (account_id, account_email, "manual_all", "failed", error_msg, run_id), + ( + account_id, + account_email, + "manual_all", + "failed", + error_msg, + run_id, + ), ) conn.commit() except Exception: @@ -157,7 +185,9 @@ def stream_refresh_all_accounts( group_id = account["group_id"] if group_id: try: - group_row = conn.execute("SELECT proxy_url FROM groups WHERE id = ?", (group_id,)).fetchone() + group_row = conn.execute( + "SELECT proxy_url FROM groups WHERE id = ?", (group_id,) + ).fetchone() if group_row: proxy_url = group_row["proxy_url"] or "" except Exception: @@ -198,7 +228,9 @@ def stream_refresh_all_accounts( success_count += 1 else: failed_count += 1 - failed_list.append({"id": account_id, "email": account_email, "error": error_msg}) + failed_list.append( + {"id": account_id, "email": account_email, "error": error_msg} + ) if index < total and delay_seconds > 0: yield f"data: {json.dumps({'type': 'delay', 'seconds': delay_seconds}, ensure_ascii=False)}\n\n" @@ -274,11 +306,15 @@ def stream_trigger_scheduled_refresh( failed_count = 0 try: - delay_row = conn.execute("SELECT value FROM settings WHERE key = 'refresh_delay_seconds'").fetchone() + delay_row = conn.execute( + "SELECT value FROM settings WHERE key = 'refresh_delay_seconds'" + ).fetchone() delay_seconds = int(delay_row["value"]) if delay_row else 5 try: - conn.execute("DELETE FROM account_refresh_logs WHERE created_at < datetime('now', '-6 months')") + conn.execute( + "DELETE FROM account_refresh_logs WHERE created_at < datetime('now', '-6 months')" + ) conn.commit() except Exception: pass @@ -344,9 +380,13 @@ def stream_trigger_scheduled_refresh( return ttl_seconds = compute_refresh_lock_ttl_seconds(total, delay_seconds) - ok, lock_info = acquire_distributed_lock(conn, lock_name, lock_owner_id, ttl_seconds) + ok, lock_info = acquire_distributed_lock( + conn, lock_name, lock_owner_id, ttl_seconds + ) if not ok: - finish_refresh_run(conn, run_id, "skipped", total, 0, 0, "刷新任务冲突:已有刷新在执行") + finish_refresh_run( + conn, run_id, "skipped", total, 0, 0, "刷新任务冲突:已有刷新在执行" + ) error_payload = build_error_payload( code="REFRESH_CONFLICT", message="当前已有刷新任务执行中,请稍后再试", @@ -386,18 +426,31 @@ def stream_trigger_scheduled_refresh( encrypted_refresh_token = account["refresh_token"] try: - refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token + refresh_token = ( + decrypt_data(encrypted_refresh_token) + if encrypted_refresh_token + else encrypted_refresh_token + ) except Exception as e: failed_count += 1 error_msg = f"解密 token 失败: {str(e)}" - failed_list.append({"id": account_id, "email": account_email, "error": error_msg}) + failed_list.append( + {"id": account_id, "email": account_email, "error": error_msg} + ) try: conn.execute( """ INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message, run_id) VALUES (?, ?, ?, ?, ?, ?) """, - (account_id, account_email, "scheduled", "failed", error_msg, run_id), + ( + account_id, + account_email, + "scheduled", + "failed", + error_msg, + run_id, + ), ) conn.commit() except Exception: @@ -423,7 +476,9 @@ def stream_trigger_scheduled_refresh( proxy_url = "" group_id = account["group_id"] if group_id: - group_row = conn.execute("SELECT proxy_url FROM groups WHERE id = ?", (group_id,)).fetchone() + group_row = conn.execute( + "SELECT proxy_url FROM groups WHERE id = ?", (group_id,) + ).fetchone() if group_row: proxy_url = group_row["proxy_url"] or "" @@ -463,7 +518,9 @@ def stream_trigger_scheduled_refresh( success_count += 1 else: failed_count += 1 - failed_list.append({"id": account_id, "email": account_email, "error": error_msg}) + failed_list.append( + {"id": account_id, "email": account_email, "error": error_msg} + ) if index < total and delay_seconds > 0: yield f"data: {json.dumps({'type': 'delay', 'seconds': delay_seconds}, ensure_ascii=False)}\n\n" @@ -497,7 +554,9 @@ def stream_trigger_scheduled_refresh( except Exception as e: try: if run_id: - finish_refresh_run(conn, run_id, "failed", total, success_count, failed_count, str(e)) + finish_refresh_run( + conn, run_id, "failed", total, success_count, failed_count, str(e) + ) except Exception: pass error_payload = build_error_payload( @@ -558,7 +617,9 @@ def refresh_failed_accounts( ttl_seconds = compute_refresh_lock_ttl_seconds(total, 0) ok, lock_info = acquire_distributed_lock(db, lock_name, lock_owner_id, ttl_seconds) if not ok: - finish_refresh_run(db, run_id, "skipped", total, 0, 0, "刷新任务冲突:已有刷新在执行") + finish_refresh_run( + db, run_id, "skipped", total, 0, 0, "刷新任务冲突:已有刷新在执行" + ) error_payload = build_error_payload( code="REFRESH_CONFLICT", message="当前已有刷新任务执行中,请稍后再试", @@ -584,22 +645,37 @@ def refresh_failed_accounts( group_id = account["group_id"] if group_id: try: - group_row = db.execute("SELECT proxy_url FROM groups WHERE id = ?", (group_id,)).fetchone() + group_row = db.execute( + "SELECT proxy_url FROM groups WHERE id = ?", (group_id,) + ).fetchone() if group_row: proxy_url = group_row["proxy_url"] or "" except Exception: proxy_url = "" try: - refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token + refresh_token = ( + decrypt_data(encrypted_refresh_token) + if encrypted_refresh_token + else encrypted_refresh_token + ) except Exception as e: failed_count += 1 error_msg = f"解密 token 失败: {str(e)}" - failed_list.append({"id": account_id, "email": account_email, "error": error_msg}) + failed_list.append( + {"id": account_id, "email": account_email, "error": error_msg} + ) try: from outlook_web.repositories.refresh_logs import log_refresh_result - log_refresh_result(account_id, account_email, "retry", "failed", error_msg, run_id=run_id) + log_refresh_result( + account_id, + account_email, + "retry", + "failed", + error_msg, + run_id=run_id, + ) except Exception: pass continue @@ -623,7 +699,9 @@ def refresh_failed_accounts( success_count += 1 else: failed_count += 1 - failed_list.append({"id": account_id, "email": account_email, "error": error_msg}) + failed_list.append( + {"id": account_id, "email": account_email, "error": error_msg} + ) finally: release_distributed_lock(db, lock_name, lock_owner_id) @@ -648,4 +726,3 @@ def refresh_failed_accounts( }, 200, ) - diff --git a/outlook_web/services/scheduler.py b/outlook_web/services/scheduler.py index 54ff630..ea14d03 100644 --- a/outlook_web/services/scheduler.py +++ b/outlook_web/services/scheduler.py @@ -7,6 +7,7 @@ - 心跳任务 - 定时刷新任务 """ + from __future__ import annotations import json @@ -20,7 +21,10 @@ from outlook_web import config from outlook_web.db import create_sqlite_connection from outlook_web.errors import generate_trace_id from outlook_web.repositories import settings as settings_repo -from outlook_web.repositories.distributed_locks import acquire_distributed_lock, release_distributed_lock +from outlook_web.repositories.distributed_locks import ( + acquire_distributed_lock, + release_distributed_lock, +) from outlook_web.repositories.refresh_runs import create_refresh_run, finish_refresh_run from outlook_web.security.crypto import decrypt_data @@ -39,16 +43,16 @@ def utcnow() -> datetime: def scheduler_heartbeat_task(): """调度器心跳,用于验证后台任务是否真实运行""" try: - payload = { - "at": utcnow().isoformat() + "Z", - "pid": os.getpid() - } + payload = {"at": utcnow().isoformat() + "Z", "pid": os.getpid()} conn = create_sqlite_connection() try: - conn.execute(''' + conn.execute( + """ INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('scheduler_heartbeat', ?, CURRENT_TIMESTAMP) - ''', (json.dumps(payload, ensure_ascii=False),)) + """, + (json.dumps(payload, ensure_ascii=False),), + ) conn.commit() finally: conn.close() @@ -64,31 +68,38 @@ def configure_scheduler_jobs(scheduler, app, test_refresh_token) -> None: return with app.app_context(): - enable_scheduled = settings_repo.get_setting('enable_scheduled_refresh', 'true').lower() == 'true' - use_cron = settings_repo.get_setting('use_cron_schedule', 'false').lower() == 'true' - refresh_interval_days = int(settings_repo.get_setting('refresh_interval_days', '30')) - cron_expr = settings_repo.get_setting('refresh_cron', '0 2 * * *') + enable_scheduled = ( + settings_repo.get_setting("enable_scheduled_refresh", "true").lower() + == "true" + ) + use_cron = ( + settings_repo.get_setting("use_cron_schedule", "false").lower() == "true" + ) + refresh_interval_days = int( + settings_repo.get_setting("refresh_interval_days", "30") + ) + cron_expr = settings_repo.get_setting("refresh_cron", "0 2 * * *") # 心跳 Job:始终存在(可服务/可刷新可验证) try: - scheduler.remove_job('scheduler_heartbeat') + scheduler.remove_job("scheduler_heartbeat") except Exception: pass scheduler.add_job( func=scheduler_heartbeat_task, - trigger='interval', + trigger="interval", seconds=60, - id='scheduler_heartbeat', - name='Scheduler Heartbeat', + id="scheduler_heartbeat", + name="Scheduler Heartbeat", replace_existing=True, max_instances=1, coalesce=True, - misfire_grace_time=60 + misfire_grace_time=60, ) # 刷新 Job:根据 enable_scheduled 决定是否启用 try: - scheduler.remove_job('token_refresh') + scheduler.remove_job("token_refresh") except Exception: pass @@ -103,21 +114,24 @@ def configure_scheduler_jobs(scheduler, app, test_refresh_token) -> None: if use_cron: try: from croniter import croniter + croniter(cron_expr, datetime.now()) parts = cron_expr.split() if len(parts) != 5: raise ValueError("Cron 表达式格式错误,应为 5 段") minute, hour, day, month, day_of_week = parts - trigger = CronTrigger(minute=minute, hour=hour, day=day, month=month, day_of_week=day_of_week) + trigger = CronTrigger( + minute=minute, hour=hour, day=day, month=month, day_of_week=day_of_week + ) scheduler.add_job( func=scheduled_task, trigger=trigger, - id='token_refresh', - name='Token 定时刷新', + id="token_refresh", + name="Token 定时刷新", replace_existing=True, max_instances=1, coalesce=True, - misfire_grace_time=600 + misfire_grace_time=600, ) print(f"✓ 定时任务已配置:Cron 表达式 '{cron_expr}'") return @@ -127,14 +141,16 @@ def configure_scheduler_jobs(scheduler, app, test_refresh_token) -> None: scheduler.add_job( func=scheduled_task, trigger=CronTrigger(hour=2, minute=0), - id='token_refresh', - name='Token 定时刷新', + id="token_refresh", + name="Token 定时刷新", replace_existing=True, max_instances=1, coalesce=True, - misfire_grace_time=600 + misfire_grace_time=600, + ) + print( + f"✓ 定时任务已配置:每天凌晨 2:00 检查刷新(周期:{refresh_interval_days} 天)" ) - print(f"✓ 定时任务已配置:每天凌晨 2:00 检查刷新(周期:{refresh_interval_days} 天)") def init_scheduler(app, test_refresh_token): @@ -176,20 +192,29 @@ def scheduled_refresh_task(app, test_refresh_token): try: with app.app_context(): - enable_scheduled = settings_repo.get_setting('enable_scheduled_refresh', 'true').lower() == 'true' - use_cron = settings_repo.get_setting('use_cron_schedule', 'false').lower() == 'true' - refresh_interval_days = int(settings_repo.get_setting('refresh_interval_days', '30')) - delay_seconds = int(settings_repo.get_setting('refresh_delay_seconds', '5')) + enable_scheduled = ( + settings_repo.get_setting("enable_scheduled_refresh", "true").lower() + == "true" + ) + use_cron = ( + settings_repo.get_setting("use_cron_schedule", "false").lower() + == "true" + ) + refresh_interval_days = int( + settings_repo.get_setting("refresh_interval_days", "30") + ) + delay_seconds = int(settings_repo.get_setting("refresh_delay_seconds", "5")) - run_id = create_refresh_run(conn, 'scheduled', trace_id, total=0) + run_id = create_refresh_run(conn, "scheduled", trace_id, total=0) if not enable_scheduled: - finish_refresh_run(conn, run_id, 'skipped', 0, 0, 0, "定时刷新已禁用") + finish_refresh_run(conn, run_id, "skipped", 0, 0, 0, "定时刷新已禁用") return # 按天数策略:未到周期则跳过(不产生账号级刷新日志) if not use_cron: - row = conn.execute(''' + row = conn.execute( + """ SELECT finished_at FROM refresh_runs WHERE trigger_source = 'scheduled' @@ -197,11 +222,12 @@ def scheduled_refresh_task(app, test_refresh_token): AND finished_at IS NOT NULL ORDER BY finished_at DESC LIMIT 1 - ''').fetchone() + """ + ).fetchone() - if row and row['finished_at']: + if row and row["finished_at"]: try: - last_time = datetime.fromisoformat(row['finished_at']) + last_time = datetime.fromisoformat(row["finished_at"]) except Exception: last_time = None @@ -209,24 +235,37 @@ def scheduled_refresh_task(app, test_refresh_token): next_due = last_time + timedelta(days=refresh_interval_days) if utcnow() < next_due: finish_refresh_run( - conn, run_id, 'skipped', 0, 0, 0, - f"距离上次刷新未满 {refresh_interval_days} 天,下次最早:{next_due.strftime('%Y-%m-%d %H:%M:%S')}" + conn, + run_id, + "skipped", + 0, + 0, + 0, + f"距离上次刷新未满 {refresh_interval_days} 天,下次最早:{next_due.strftime('%Y-%m-%d %H:%M:%S')}", ) return # 获取刷新间隔配置 - delay_row = conn.execute("SELECT value FROM settings WHERE key = 'refresh_delay_seconds'").fetchone() - delay_seconds = int(delay_row['value']) if delay_row else 5 + delay_row = conn.execute( + "SELECT value FROM settings WHERE key = 'refresh_delay_seconds'" + ).fetchone() + delay_seconds = int(delay_row["value"]) if delay_row else 5 # 清理超过半年的刷新记录 try: - conn.execute("DELETE FROM account_refresh_logs WHERE created_at < datetime('now', '-6 months')") - conn.execute("DELETE FROM distributed_locks WHERE expires_at < ?", (time.time(),)) + conn.execute( + "DELETE FROM account_refresh_logs WHERE created_at < datetime('now', '-6 months')" + ) + conn.execute( + "DELETE FROM distributed_locks WHERE expires_at < ?", (time.time(),) + ) conn.commit() except Exception: pass - accounts = conn.execute("SELECT id, email, client_id, refresh_token, group_id FROM accounts WHERE status = 'active'").fetchall() + accounts = conn.execute( + "SELECT id, email, client_id, refresh_token, group_id FROM accounts WHERE status = 'active'" + ).fetchall() total = len(accounts) # 更新 run_id 的 total @@ -238,9 +277,13 @@ def scheduled_refresh_task(app, test_refresh_token): ttl_seconds = max(60 * 60 * 2, estimated) ttl_seconds = min(ttl_seconds, 60 * 60 * 24) - ok, lock_info = acquire_distributed_lock(conn, REFRESH_LOCK_NAME, lock_owner_id, ttl_seconds) + ok, lock_info = acquire_distributed_lock( + conn, REFRESH_LOCK_NAME, lock_owner_id, ttl_seconds + ) if not ok: - finish_refresh_run(conn, run_id, 'skipped', total, 0, 0, "刷新任务冲突:已有刷新在执行") + finish_refresh_run( + conn, run_id, "skipped", total, 0, 0, "刷新任务冲突:已有刷新在执行" + ) return lock_acquired = True @@ -248,52 +291,81 @@ def scheduled_refresh_task(app, test_refresh_token): failed_count = 0 for index, account in enumerate(accounts, 1): - account_id = account['id'] - account_email = account['email'] - client_id = account['client_id'] - encrypted_refresh_token = account['refresh_token'] + account_id = account["id"] + account_email = account["email"] + client_id = account["client_id"] + encrypted_refresh_token = account["refresh_token"] # 解密 refresh_token try: - refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token + refresh_token = ( + decrypt_data(encrypted_refresh_token) + if encrypted_refresh_token + else encrypted_refresh_token + ) except Exception as e: failed_count += 1 error_msg = f"解密 token 失败: {str(e)}" try: - conn.execute(''' + conn.execute( + """ INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message, run_id) VALUES (?, ?, ?, ?, ?, ?) - ''', (account_id, account_email, 'scheduled', 'failed', error_msg, run_id)) + """, + ( + account_id, + account_email, + "scheduled", + "failed", + error_msg, + run_id, + ), + ) conn.commit() except Exception: pass continue # 获取分组代理设置 - proxy_url = '' - group_id = account['group_id'] + proxy_url = "" + group_id = account["group_id"] if group_id: try: - group_row = conn.execute('SELECT proxy_url FROM groups WHERE id = ?', (group_id,)).fetchone() + group_row = conn.execute( + "SELECT proxy_url FROM groups WHERE id = ?", (group_id,) + ).fetchone() if group_row: - proxy_url = group_row['proxy_url'] or '' + proxy_url = group_row["proxy_url"] or "" except Exception: - proxy_url = '' + proxy_url = "" success, error_msg = test_refresh_token(client_id, refresh_token, proxy_url) try: - conn.execute(''' + conn.execute( + """ INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message, run_id) VALUES (?, ?, ?, ?, ?, ?) - ''', (account_id, account_email, 'scheduled', 'success' if success else 'failed', error_msg, run_id)) + """, + ( + account_id, + account_email, + "scheduled", + "success" if success else "failed", + error_msg, + run_id, + ), + ) if success: - conn.execute(''' + conn.execute( + """ UPDATE accounts SET last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? - ''', (account_id,)) + """, + (account_id,), + ) conn.commit() except Exception: @@ -308,14 +380,19 @@ def scheduled_refresh_task(app, test_refresh_token): time.sleep(delay_seconds) finish_refresh_run( - conn, run_id, 'completed', total, success_count, failed_count, - f"完成:成功 {success_count},失败 {failed_count}" + conn, + run_id, + "completed", + total, + success_count, + failed_count, + f"完成:成功 {success_count},失败 {failed_count}", ) except Exception as e: try: if run_id: - finish_refresh_run(conn, run_id, 'failed', 0, 0, 0, str(e)) + finish_refresh_run(conn, run_id, "failed", 0, 0, 0, str(e)) except Exception: pass try: @@ -335,7 +412,9 @@ def should_autostart_scheduler() -> bool: return False # Flask CLI (flask run) + reloader:父进程不启动 - if config.env_true('FLASK_RUN_FROM_CLI', False) and not config.env_true('WERKZEUG_RUN_MAIN', False): + if config.env_true("FLASK_RUN_FROM_CLI", False) and not config.env_true( + "WERKZEUG_RUN_MAIN", False + ): return False return True diff --git a/outlook_web/services/verification_extractor.py b/outlook_web/services/verification_extractor.py index 3ab8ac0..9a4ad21 100644 --- a/outlook_web/services/verification_extractor.py +++ b/outlook_web/services/verification_extractor.py @@ -18,14 +18,25 @@ from typing import List, Optional, Dict, Any # 验证码关键词列表(支持中英文) VERIFICATION_KEYWORDS = [ - "验证码", "code", "验证", "verification", - "OTP", "动态码", "校验码", "verify code", - "confirmation code", "security code", "验证码是", - "your code", "code is", "激活码", "短信验证码" + "验证码", + "code", + "验证", + "verification", + "OTP", + "动态码", + "校验码", + "verify code", + "confirmation code", + "security code", + "验证码是", + "your code", + "code is", + "激活码", + "短信验证码", ] # 验证码模式(4-8位数字或字母,必须包含至少一个数字) -VERIFICATION_PATTERN = r'\b[A-Z0-9]{4,8}\b' +VERIFICATION_PATTERN = r"\b[A-Z0-9]{4,8}\b" # 链接正则表达式 LINK_PATTERN = r'https?://[^\s<>"{}|\\^`\[\]]+' @@ -37,7 +48,7 @@ class HTMLTextExtractor(HTMLParser): def __init__(self): super().__init__() self.text_parts = [] - self._skip_tags = {'style', 'script', 'head', 'meta', 'link'} + self._skip_tags = {"style", "script", "head", "meta", "link"} self._current_skip = False def handle_starttag(self, tag, attrs): @@ -177,7 +188,7 @@ def extract_links(email_content: str) -> List[str]: cleaned_links = [] for link in matches: # 移除末尾的标点符号 - cleaned = link.rstrip('.,;:!?)>\'"') + cleaned = link.rstrip(".,;:!?)>'\"") cleaned_links.append(cleaned) # 去重并保持顺序 @@ -283,7 +294,7 @@ def extract_verification_info_from_text(email_content: str) -> Dict[str, Any]: return { "verification_code": verification_code, "links": links, - "formatted": formatted + "formatted": formatted, } diff --git a/start.py b/start.py index c77e2cd..8b294b2 100644 --- a/start.py +++ b/start.py @@ -11,14 +11,13 @@ from dotenv import load_dotenv load_dotenv() # 确保环境变量已加载 -if not os.getenv('SECRET_KEY'): +if not os.getenv("SECRET_KEY"): print("错误:SECRET_KEY 环境变量未设置") sys.exit(1) # 直接运行 web_outlook_app.py -if __name__ == '__main__': +if __name__ == "__main__": # 使用 exec 运行 web_outlook_app.py,保持 __name__ == "__main__" - with open('web_outlook_app.py', 'r', encoding='utf-8') as f: + with open("web_outlook_app.py", "r", encoding="utf-8") as f: code = f.read() - exec(code, {'__name__': '__main__', '__file__': 'web_outlook_app.py'}) - + exec(code, {"__name__": "__main__", "__file__": "web_outlook_app.py"}) diff --git a/tests/__init__.py b/tests/__init__.py index 7fce339..2af83b9 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,2 +1 @@ # 使 tests 成为可导入的包,便于复用测试夹具。 - diff --git a/tests/_import_app.py b/tests/_import_app.py index b455f1b..65e1a17 100644 --- a/tests/_import_app.py +++ b/tests/_import_app.py @@ -37,10 +37,10 @@ def import_web_app_module(): def clear_login_attempts(): """清理登录限制记录,避免测试间互相影响""" from outlook_web.db import get_db + try: db = get_db() db.execute("DELETE FROM login_attempts") db.commit() except Exception: pass - diff --git a/tests/test_api_extract_verification.py b/tests/test_api_extract_verification.py index 435c315..7405238 100644 --- a/tests/test_api_extract_verification.py +++ b/tests/test_api_extract_verification.py @@ -14,7 +14,7 @@ import os import time # 添加项目根目录到 Python 路径 -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) class TestExtractVerificationAPI(unittest.TestCase): @@ -233,5 +233,5 @@ class TestExtractVerificationAPI(unittest.TestCase): self.assertTrue(True, "待实现:轮询次数参数验证") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_core_features.py b/tests/test_core_features.py index 44dd274..5f704f9 100644 --- a/tests/test_core_features.py +++ b/tests/test_core_features.py @@ -13,6 +13,7 @@ class CoreFeatureTests(unittest.TestCase): cls.app = cls.module.app # 导入 graph_service 用于 mock from outlook_web.services import graph as graph_service + cls.graph_service = graph_service def setUp(self): @@ -49,7 +50,12 @@ class CoreFeatureTests(unittest.TestCase): create = client.post( "/api/groups", - json={"name": group_name, "description": "desc", "color": "#123456", "proxy_url": ""}, + json={ + "name": group_name, + "description": "desc", + "color": "#123456", + "proxy_url": "", + }, ) self.assertEqual(create.status_code, 200) create_data = create.get_json() @@ -69,7 +75,12 @@ class CoreFeatureTests(unittest.TestCase): update = client.put( f"/api/groups/{group_id}", - json={"name": group_name, "description": "desc2", "color": "#654321", "proxy_url": ""}, + json={ + "name": group_name, + "description": "desc2", + "color": "#654321", + "proxy_url": "", + }, ) self.assertEqual(update.status_code, 200) self.assertEqual(update.get_json().get("success"), True) @@ -111,7 +122,9 @@ class CoreFeatureTests(unittest.TestCase): self.assertEqual(listing.status_code, 200) listing_data = listing.get_json() self.assertEqual(listing_data.get("success"), True) - self.assertIn(tag_name, [t.get("name") for t in (listing_data.get("tags") or [])]) + self.assertIn( + tag_name, [t.get("name") for t in (listing_data.get("tags") or [])] + ) delete = client.delete(f"/api/tags/{tag_id}") self.assertEqual(delete.status_code, 200) @@ -147,7 +160,9 @@ class CoreFeatureTests(unittest.TestCase): self.assertTrue(verify_token) # 使用请求头传递 token(避免 URL/日志泄露) - export = client.get("/api/accounts/export", headers={"X-Export-Token": verify_token}) + export = client.get( + "/api/accounts/export", headers={"X-Export-Token": verify_token} + ) self.assertEqual(export.status_code, 200) self.assertIn("text/plain", export.headers.get("Content-Type", "")) body = export.get_data(as_text=True) @@ -184,8 +199,13 @@ class CoreFeatureTests(unittest.TestCase): # Mock generate_temp_email 返回元组 (email_addr, None) # 注意:控制器现在直接使用 gptmail service,需要 mock outlook_web.services.gptmail from outlook_web.services import gptmail as gptmail_service - with patch.object(gptmail_service, "generate_temp_email", return_value=(email_addr, None)): - created = client.post("/api/temp-emails/generate", json={"prefix": "x", "domain": "y"}) + + with patch.object( + gptmail_service, "generate_temp_email", return_value=(email_addr, None) + ): + created = client.post( + "/api/temp-emails/generate", json={"prefix": "x", "domain": "y"} + ) self.assertEqual(created.status_code, 200) created_data = created.get_json() self.assertEqual(created_data.get("success"), True) @@ -195,7 +215,9 @@ class CoreFeatureTests(unittest.TestCase): self.assertEqual(listing.status_code, 200) listing_data = listing.get_json() self.assertEqual(listing_data.get("success"), True) - self.assertIn(email_addr, [e.get("email") for e in (listing_data.get("emails") or [])]) + self.assertIn( + email_addr, [e.get("email") for e in (listing_data.get("emails") or [])] + ) def test_refresh_all_stream_has_start_and_complete_events(self): client = self.app.test_client() @@ -211,7 +233,9 @@ class CoreFeatureTests(unittest.TestCase): finally: conn.close() - with patch.object(self.graph_service, "test_refresh_token", return_value=(True, None)): + with patch.object( + self.graph_service, "test_refresh_token", return_value=(True, None) + ): resp = client.get("/api/accounts/refresh-all") self.assertEqual(resp.status_code, 200) diff --git a/tests/test_distributed_lock.py b/tests/test_distributed_lock.py index d502749..6d05d04 100644 --- a/tests/test_distributed_lock.py +++ b/tests/test_distributed_lock.py @@ -17,11 +17,15 @@ class DistributedLockTests(unittest.TestCase): owner_a = "a_" + uuid.uuid4().hex owner_b = "b_" + uuid.uuid4().hex - ok, info = self.module.acquire_distributed_lock(conn, lock_name, owner_a, ttl_seconds=60) + ok, info = self.module.acquire_distributed_lock( + conn, lock_name, owner_a, ttl_seconds=60 + ) self.assertTrue(ok) self.assertIsNone(info) - ok2, info2 = self.module.acquire_distributed_lock(conn, lock_name, owner_b, ttl_seconds=60) + ok2, info2 = self.module.acquire_distributed_lock( + conn, lock_name, owner_b, ttl_seconds=60 + ) self.assertFalse(ok2) self.assertIsInstance(info2, dict) self.assertEqual(info2.get("owner_id"), owner_a) @@ -29,7 +33,9 @@ class DistributedLockTests(unittest.TestCase): released = self.module.release_distributed_lock(conn, lock_name, owner_a) self.assertTrue(released) - ok3, info3 = self.module.acquire_distributed_lock(conn, lock_name, owner_b, ttl_seconds=60) + ok3, info3 = self.module.acquire_distributed_lock( + conn, lock_name, owner_b, ttl_seconds=60 + ) self.assertTrue(ok3) self.assertIsNone(info3) finally: @@ -42,14 +48,17 @@ class DistributedLockTests(unittest.TestCase): owner_a = "a_" + uuid.uuid4().hex owner_b = "b_" + uuid.uuid4().hex - ok, _ = self.module.acquire_distributed_lock(conn, lock_name, owner_a, ttl_seconds=1) + ok, _ = self.module.acquire_distributed_lock( + conn, lock_name, owner_a, ttl_seconds=1 + ) self.assertTrue(ok) time.sleep(1.2) - ok2, info2 = self.module.acquire_distributed_lock(conn, lock_name, owner_b, ttl_seconds=60) + ok2, info2 = self.module.acquire_distributed_lock( + conn, lock_name, owner_b, ttl_seconds=60 + ) self.assertTrue(ok2) self.assertIsInstance(info2, dict) self.assertEqual(info2.get("previous_owner_id"), owner_a) finally: conn.close() - diff --git a/tests/test_error_and_trace.py b/tests/test_error_and_trace.py index 64762de..e644532 100644 --- a/tests/test_error_and_trace.py +++ b/tests/test_error_and_trace.py @@ -56,7 +56,9 @@ class ErrorAndTraceTests(unittest.TestCase): def test_trace_id_can_be_propagated_from_request(self): client = self.app.test_client() - resp = client.get("/api/__not_found__", headers={"X-Trace-Id": "trace_from_test"}) + resp = client.get( + "/api/__not_found__", headers={"X-Trace-Id": "trace_from_test"} + ) data = resp.get_json() self.assertEqual(resp.headers.get("X-Trace-Id"), "trace_from_test") self.assertEqual(data["error"].get("trace_id"), "trace_from_test") @@ -67,7 +69,9 @@ class ErrorAndTraceTests(unittest.TestCase): ) self.assertIn("Bearer ***", sanitized) self.assertIn("refresh_token=***", sanitized) - self.assertIn('"123456"', 'Bearer abcdefg refresh_token=xyz password: "123456"') # sanity + self.assertIn( + '"123456"', 'Bearer abcdefg refresh_token=xyz password: "123456"' + ) # sanity self.assertNotIn("123456", sanitized) def test_build_error_payload_sanitizes_message_and_details(self): @@ -111,11 +115,22 @@ class ErrorAndTraceTests(unittest.TestCase): with patch.object( accounts_repo, "get_account_by_email", - return_value={"email": email_addr, "client_id": "cid", "refresh_token": "rt", "group_id": None}, + return_value={ + "email": email_addr, + "client_id": "cid", + "refresh_token": "rt", + "group_id": None, + }, ), patch.object( graph_service, "delete_emails_graph", - return_value={"success": False, "error": graph_error, "success_count": 0, "failed_count": 2, "errors": ["e1"]}, + return_value={ + "success": False, + "error": graph_error, + "success_count": 0, + "failed_count": 2, + "errors": ["e1"], + }, ), patch.object( imap_service, "delete_emails_imap", @@ -172,7 +187,12 @@ class ErrorAndTraceTests(unittest.TestCase): with patch.object( accounts_repo, "get_account_by_email", - return_value={"email": email_addr, "client_id": "cid", "refresh_token": "rt", "group_id": None}, + return_value={ + "email": email_addr, + "client_id": "cid", + "refresh_token": "rt", + "group_id": None, + }, ), patch.object( graph_service, "delete_emails_graph", @@ -233,7 +253,9 @@ class ErrorAndTraceTests(unittest.TestCase): login = client.post("/login", json={"password": "testpass123"}) self.assertEqual(login.status_code, 200) - resp = client.post("/api/settings/validate-cron", json={"cron_expression": "0 2 * * *"}) + resp = client.post( + "/api/settings/validate-cron", json={"cron_expression": "0 2 * * *"} + ) data = resp.get_json() self.assertEqual(resp.status_code, 200) self.assertEqual(data.get("success"), True) @@ -332,10 +354,10 @@ class ErrorAndTraceTests(unittest.TestCase): try: # 插入账号 cur = conn.execute( - ''' + """ INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark, status) VALUES (?, ?, ?, ?, ?, ?, ?) - ''', + """, (email_addr, "", client_id, refresh_token, 1, "remark", "active"), ) account_id = cur.lastrowid @@ -353,10 +375,10 @@ class ErrorAndTraceTests(unittest.TestCase): # 插入刷新日志(用于 last_refresh_status/error) conn.execute( - ''' + """ INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message) VALUES (?, ?, ?, ?, ?) - ''', + """, (account_id, email_addr, "manual", "failed", "network_error"), ) @@ -397,7 +419,9 @@ class ErrorAndTraceTests(unittest.TestCase): conn = self.module.create_sqlite_connection() try: - row = conn.execute("SELECT id FROM groups WHERE is_system = 1 LIMIT 1").fetchone() + row = conn.execute( + "SELECT id FROM groups WHERE is_system = 1 LIMIT 1" + ).fetchone() self.assertIsNotNone(row) system_group_id = row["id"] finally: @@ -421,15 +445,17 @@ class ErrorAndTraceTests(unittest.TestCase): conn = self.module.create_sqlite_connection() try: - system_row = conn.execute("SELECT id FROM groups WHERE is_system = 1 LIMIT 1").fetchone() + system_row = conn.execute( + "SELECT id FROM groups WHERE is_system = 1 LIMIT 1" + ).fetchone() self.assertIsNotNone(system_row) system_group_id = system_row["id"] cur = conn.execute( - ''' + """ INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark, status) VALUES (?, ?, ?, ?, ?, ?, ?) - ''', + """, (email_addr, "", "client", "rt", 1, "", "active"), ) account_id = cur.lastrowid diff --git a/tests/test_frontend_manual.py b/tests/test_frontend_manual.py index 1cc0dd3..ff3a931 100644 --- a/tests/test_frontend_manual.py +++ b/tests/test_frontend_manual.py @@ -11,9 +11,9 @@ - 设置界面测试(3 个用例) """ - # ==================== 全选功能测试 ==================== + class FrontendSelectAllTests: """ 全选功能测试用例集 @@ -137,24 +137,24 @@ class FrontendSelectAllTests: def test_ft_006_reset_on_group_change(self): """ - 测试用例 FT-006:切换分组重置全选状态 + 测试用例 FT-006:切换分组重置全选状态 - 测试步骤: - 1. 选中部分或全部邮箱 - 2. 切换到其他分组 - 3. 观察全选复选框状态 + 测试步骤: + 1. 选中部分或全部邮箱 + 2. 切换到其他分组 + 3. 观察全选复选框状态 - 预期结果: - - 全选复选框显示为未选中状态 - - 批量操作栏隐藏 + 预期结果: + - 全选复选框显示为未选中状态 + - 批量操作栏隐藏 - 验证方法: - ```javascript - // 切换分组后 - const selectAllCheckbox = document.getElementById('selectAllAccounts'); - console.log('全选框状态:', selectAllCheckbox.checked); // 应该为 false - console.log('半选状态:', selectAllCheckbox.indeterminate); // 应该为 false - ``` + 验证方法: + ```javascript + // 切换分组后 + const selectAllCheckbox = document.getElementById('selectAllAccounts'); + console.log('全选框状态:', selectAllCheckbox.checked); // 应该为 false + console.log('半选状态:', selectAllCheckbox.indeterminate); // 应该为 false + ``` """ pass @@ -185,6 +185,7 @@ class FrontendSelectAllTests: # ==================== 复制功能测试 ==================== + class FrontendCopyTests: """ 复制功能测试用例集 @@ -303,6 +304,7 @@ class FrontendCopyTests: # ==================== 轮询功能测试 ==================== + class FrontendPollingTests: """ 轮询功能测试用例集 @@ -429,6 +431,7 @@ class FrontendPollingTests: # ==================== 设置界面测试 ==================== + class FrontendSettingsTests: """ 设置界面测试用例集 diff --git a/tests/test_gptmail_error_handling.py b/tests/test_gptmail_error_handling.py index bf855a4..79cc28d 100644 --- a/tests/test_gptmail_error_handling.py +++ b/tests/test_gptmail_error_handling.py @@ -10,150 +10,169 @@ class TestGPTMailErrorHandling(unittest.TestCase): def test_gptmail_request_no_api_key(self): """测试 API Key 未配置的情况""" - with patch('outlook_web.services.gptmail.get_gptmail_api_key', return_value=''): - result = gptmail.gptmail_request('GET', '/api/test') + with patch("outlook_web.services.gptmail.get_gptmail_api_key", return_value=""): + result = gptmail.gptmail_request("GET", "/api/test") - self.assertFalse(result['success']) - self.assertEqual(result['error_type'], 'CONFIG_ERROR') - self.assertIn('未配置', result['error']) + self.assertFalse(result["success"]) + self.assertEqual(result["error_type"], "CONFIG_ERROR") + self.assertIn("未配置", result["error"]) def test_gptmail_request_401_unauthorized(self): """测试 API Key 无效的情况""" mock_response = MagicMock() mock_response.status_code = 401 - with patch('outlook_web.services.gptmail.get_gptmail_api_key', return_value='invalid_key'): - with patch('requests.get', return_value=mock_response): - result = gptmail.gptmail_request('GET', '/api/test') + with patch( + "outlook_web.services.gptmail.get_gptmail_api_key", + return_value="invalid_key", + ): + with patch("requests.get", return_value=mock_response): + result = gptmail.gptmail_request("GET", "/api/test") - self.assertFalse(result['success']) - self.assertEqual(result['error_type'], 'AUTH_ERROR') - self.assertIn('无效', result['error']) + self.assertFalse(result["success"]) + self.assertEqual(result["error_type"], "AUTH_ERROR") + self.assertIn("无效", result["error"]) def test_gptmail_request_timeout(self): """测试请求超时的情况""" - with patch('outlook_web.services.gptmail.get_gptmail_api_key', return_value='test_key'): - with patch('requests.get', side_effect=requests.exceptions.Timeout()): - result = gptmail.gptmail_request('GET', '/api/test') + with patch( + "outlook_web.services.gptmail.get_gptmail_api_key", return_value="test_key" + ): + with patch("requests.get", side_effect=requests.exceptions.Timeout()): + result = gptmail.gptmail_request("GET", "/api/test") - self.assertFalse(result['success']) - self.assertEqual(result['error_type'], 'TIMEOUT_ERROR') - self.assertIn('超时', result['error']) + self.assertFalse(result["success"]) + self.assertEqual(result["error_type"], "TIMEOUT_ERROR") + self.assertIn("超时", result["error"]) def test_gptmail_request_connection_error(self): """测试网络连接失败的情况""" - with patch('outlook_web.services.gptmail.get_gptmail_api_key', return_value='test_key'): - with patch('requests.get', side_effect=requests.exceptions.ConnectionError('Connection refused')): - result = gptmail.gptmail_request('GET', '/api/test') + with patch( + "outlook_web.services.gptmail.get_gptmail_api_key", return_value="test_key" + ): + with patch( + "requests.get", + side_effect=requests.exceptions.ConnectionError("Connection refused"), + ): + result = gptmail.gptmail_request("GET", "/api/test") - self.assertFalse(result['success']) - self.assertEqual(result['error_type'], 'CONNECTION_ERROR') - self.assertIn('连接', result['error']) + self.assertFalse(result["success"]) + self.assertEqual(result["error_type"], "CONNECTION_ERROR") + self.assertIn("连接", result["error"]) def test_gptmail_request_500_server_error(self): """测试服务器错误的情况""" mock_response = MagicMock() mock_response.status_code = 500 - with patch('outlook_web.services.gptmail.get_gptmail_api_key', return_value='test_key'): - with patch('requests.get', return_value=mock_response): - result = gptmail.gptmail_request('GET', '/api/test') + with patch( + "outlook_web.services.gptmail.get_gptmail_api_key", return_value="test_key" + ): + with patch("requests.get", return_value=mock_response): + result = gptmail.gptmail_request("GET", "/api/test") - self.assertFalse(result['success']) - self.assertEqual(result['error_type'], 'SERVER_ERROR') - self.assertIn('不可用', result['error']) + self.assertFalse(result["success"]) + self.assertEqual(result["error_type"], "SERVER_ERROR") + self.assertIn("不可用", result["error"]) def test_gptmail_request_429_rate_limit(self): """测试请求频率超限的情况""" mock_response = MagicMock() mock_response.status_code = 429 - with patch('outlook_web.services.gptmail.get_gptmail_api_key', return_value='test_key'): - with patch('requests.get', return_value=mock_response): - result = gptmail.gptmail_request('GET', '/api/test') + with patch( + "outlook_web.services.gptmail.get_gptmail_api_key", return_value="test_key" + ): + with patch("requests.get", return_value=mock_response): + result = gptmail.gptmail_request("GET", "/api/test") - self.assertFalse(result['success']) - self.assertEqual(result['error_type'], 'RATE_LIMIT_ERROR') - self.assertIn('频率超限', result['error']) + self.assertFalse(result["success"]) + self.assertEqual(result["error_type"], "RATE_LIMIT_ERROR") + self.assertIn("频率超限", result["error"]) def test_generate_temp_email_success(self): """测试成功生成临时邮箱""" - mock_result = { - 'success': True, - 'data': {'email': 'test@example.com'} - } + mock_result = {"success": True, "data": {"email": "test@example.com"}} - with patch('outlook_web.services.gptmail.gptmail_request', return_value=mock_result): + with patch( + "outlook_web.services.gptmail.gptmail_request", return_value=mock_result + ): email, error = gptmail.generate_temp_email() - self.assertEqual(email, 'test@example.com') + self.assertEqual(email, "test@example.com") self.assertIsNone(error) def test_generate_temp_email_api_key_not_configured(self): """测试 API Key 未配置时生成临时邮箱""" mock_result = { - 'success': False, - 'error': 'GPTMail API Key 未配置', - 'error_type': 'CONFIG_ERROR', - 'details': '请在系统设置中配置 GPTMail API Key' + "success": False, + "error": "GPTMail API Key 未配置", + "error_type": "CONFIG_ERROR", + "details": "请在系统设置中配置 GPTMail API Key", } - with patch('outlook_web.services.gptmail.gptmail_request', return_value=mock_result): + with patch( + "outlook_web.services.gptmail.gptmail_request", return_value=mock_result + ): email, error = gptmail.generate_temp_email() self.assertIsNone(email) - self.assertIn('未配置', error) - self.assertIn('系统设置', error) + self.assertIn("未配置", error) + self.assertIn("系统设置", error) def test_generate_temp_email_timeout(self): """测试请求超时时生成临时邮箱""" mock_result = { - 'success': False, - 'error': 'API 请求超时', - 'error_type': 'TIMEOUT_ERROR', - 'details': '请求超过 30 秒未响应,请检查网络连接或稍后重试' + "success": False, + "error": "API 请求超时", + "error_type": "TIMEOUT_ERROR", + "details": "请求超过 30 秒未响应,请检查网络连接或稍后重试", } - with patch('outlook_web.services.gptmail.gptmail_request', return_value=mock_result): + with patch( + "outlook_web.services.gptmail.gptmail_request", return_value=mock_result + ): email, error = gptmail.generate_temp_email() self.assertIsNone(email) - self.assertIn('超时', error) - self.assertIn('30 秒', error) + self.assertIn("超时", error) + self.assertIn("30 秒", error) def test_generate_temp_email_missing_email_field(self): """测试 API 返回数据缺少 email 字段""" - mock_result = { - 'success': True, - 'data': {} # 缺少 email 字段 - } + mock_result = {"success": True, "data": {}} # 缺少 email 字段 - with patch('outlook_web.services.gptmail.gptmail_request', return_value=mock_result): + with patch( + "outlook_web.services.gptmail.gptmail_request", return_value=mock_result + ): email, error = gptmail.generate_temp_email() self.assertIsNone(email) - self.assertIn('格式错误', error) - self.assertIn('email', error) + self.assertIn("格式错误", error) + self.assertIn("email", error) def test_generate_temp_email_with_prefix_and_domain(self): """测试使用 prefix 和 domain 生成临时邮箱""" - mock_result = { - 'success': True, - 'data': {'email': 'custom@example.com'} - } + mock_result = {"success": True, "data": {"email": "custom@example.com"}} - with patch('outlook_web.services.gptmail.gptmail_request', return_value=mock_result) as mock_request: - email, error = gptmail.generate_temp_email(prefix='custom', domain='example.com') + with patch( + "outlook_web.services.gptmail.gptmail_request", return_value=mock_result + ) as mock_request: + email, error = gptmail.generate_temp_email( + prefix="custom", domain="example.com" + ) - self.assertEqual(email, 'custom@example.com') + self.assertEqual(email, "custom@example.com") self.assertIsNone(error) # 验证调用参数 mock_request.assert_called_once() call_args = mock_request.call_args - self.assertEqual(call_args[0][0], 'POST') - self.assertEqual(call_args[1]['json_data'], {'prefix': 'custom', 'domain': 'example.com'}) + self.assertEqual(call_args[0][0], "POST") + self.assertEqual( + call_args[1]["json_data"], {"prefix": "custom", "domain": "example.com"} + ) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_masking_audit_and_import.py b/tests/test_masking_audit_and_import.py index 8d31553..e5152ed 100644 --- a/tests/test_masking_audit_and_import.py +++ b/tests/test_masking_audit_and_import.py @@ -24,7 +24,9 @@ class MaskingAuditAndImportTests(unittest.TestCase): def _default_group_id(self) -> int: conn = self.module.create_sqlite_connection() try: - row = conn.execute("SELECT id FROM groups WHERE name = '默认分组' LIMIT 1").fetchone() + row = conn.execute( + "SELECT id FROM groups WHERE name = '默认分组' LIMIT 1" + ).fetchone() return row["id"] if row else 1 finally: conn.close() @@ -58,7 +60,15 @@ class MaskingAuditAndImportTests(unittest.TestCase): INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark, status) VALUES (?, ?, ?, ?, ?, ?, ?) """, - (email_addr, password, client_id, refresh_token, self._default_group_id(), "", "active"), + ( + email_addr, + password, + client_id, + refresh_token, + self._default_group_id(), + "", + "active", + ), ) account_id = cur.lastrowid conn.commit() @@ -90,7 +100,9 @@ class MaskingAuditAndImportTests(unittest.TestCase): conn = self.module.create_sqlite_connection() try: - row = conn.execute("SELECT id, name FROM groups WHERE is_system = 1 LIMIT 1").fetchone() + row = conn.execute( + "SELECT id, name FROM groups WHERE is_system = 1 LIMIT 1" + ).fetchone() self.assertIsNotNone(row) system_group_id = row["id"] system_group_name = row["name"] @@ -99,7 +111,11 @@ class MaskingAuditAndImportTests(unittest.TestCase): resp = client.put( f"/api/groups/{system_group_id}", - json={"name": system_group_name + "_new", "description": "x", "color": "#111111"}, + json={ + "name": system_group_name + "_new", + "description": "x", + "color": "#111111", + }, ) self.assertEqual(resp.status_code, 403) data = resp.get_json() @@ -108,7 +124,9 @@ class MaskingAuditAndImportTests(unittest.TestCase): self.assertEqual(data["error"].get("code"), "SYSTEM_GROUP_PROTECTED") self.assertEqual(data["error"].get("status"), 403) - def test_batch_import_returns_line_errors_and_parses_refresh_token_with_delimiter(self): + def test_batch_import_returns_line_errors_and_parses_refresh_token_with_delimiter( + self, + ): client = self.app.test_client() self._login(client) @@ -126,7 +144,10 @@ class MaskingAuditAndImportTests(unittest.TestCase): resp = client.post( "/api/accounts", - json={"account_string": account_string, "group_id": self._default_group_id()}, + json={ + "account_string": account_string, + "group_id": self._default_group_id(), + }, ) self.assertEqual(resp.status_code, 200) data = resp.get_json() @@ -163,7 +184,12 @@ class MaskingAuditAndImportTests(unittest.TestCase): group_name = f"审计测试_{unique}" create = client.post( "/api/groups", - json={"name": group_name, "description": "", "color": "#222222", "proxy_url": ""}, + json={ + "name": group_name, + "description": "", + "color": "#222222", + "proxy_url": "", + }, ) self.assertEqual(create.status_code, 200) created = create.get_json() @@ -180,7 +206,8 @@ class MaskingAuditAndImportTests(unittest.TestCase): matched = [ r for r in logs - if str(r.get("resource_id")) == str(group_id) and group_name in (r.get("details") or "") + if str(r.get("resource_id")) == str(group_id) + and group_name in (r.get("details") or "") ] self.assertTrue(matched) self.assertTrue(matched[0].get("trace_id")) @@ -195,19 +222,26 @@ class MaskingAuditAndImportTests(unittest.TestCase): # Mock generate_temp_email 返回元组 (email_addr, None) # 注意:控制器现在直接使用 gptmail service,需要 mock outlook_web.services.gptmail from outlook_web.services import gptmail as gptmail_service - with patch.object(gptmail_service, "generate_temp_email", return_value=(email_addr, None)): + + with patch.object( + gptmail_service, "generate_temp_email", return_value=(email_addr, None) + ): resp = client.post("/api/temp-emails/generate", json={}) self.assertEqual(resp.status_code, 200) self.assertEqual(resp.get_json().get("success"), True) - create_audit = client.get("/api/audit-logs?resource_type=temp_email&action=create&limit=200") + create_audit = client.get( + "/api/audit-logs?resource_type=temp_email&action=create&limit=200" + ) self.assertEqual(create_audit.status_code, 200) create_data = create_audit.get_json() self.assertEqual(create_data.get("success"), True) create_logs = create_data.get("logs") or [] self.assertTrue([r for r in create_logs if r.get("resource_id") == email_addr]) - with patch.object(gptmail_service, "clear_temp_emails_from_api", return_value=True): + with patch.object( + gptmail_service, "clear_temp_emails_from_api", return_value=True + ): conn = self.module.create_sqlite_connection() try: conn.execute( @@ -225,7 +259,9 @@ class MaskingAuditAndImportTests(unittest.TestCase): self.assertEqual(clear_resp.status_code, 200) self.assertEqual(clear_resp.get_json().get("success"), True) - audit_resp = client.get("/api/audit-logs?resource_type=temp_email_messages&action=delete&limit=200") + audit_resp = client.get( + "/api/audit-logs?resource_type=temp_email_messages&action=delete&limit=200" + ) self.assertEqual(audit_resp.status_code, 200) audit = audit_resp.get_json() self.assertEqual(audit.get("success"), True) diff --git a/tests/test_module_boundaries.py b/tests/test_module_boundaries.py index cc48145..a91b4b5 100644 --- a/tests/test_module_boundaries.py +++ b/tests/test_module_boundaries.py @@ -18,12 +18,16 @@ class ModuleBoundaryTests(unittest.TestCase): modules.add(node.module) return modules - def _assert_forbidden_imports(self, *, path: Path, forbidden_prefixes: tuple[str, ...]): + def _assert_forbidden_imports( + self, *, path: Path, forbidden_prefixes: tuple[str, ...] + ): imports = self._collect_import_modules(path) for module in sorted(imports): for prefix in forbidden_prefixes: if module == prefix or module.startswith(prefix + "."): - self.fail(f"模块边界违规:{path} 导入了禁止依赖 {module!r}(匹配前缀 {prefix!r})") + self.fail( + f"模块边界违规:{path} 导入了禁止依赖 {module!r}(匹配前缀 {prefix!r})" + ) def test_routes_do_not_import_services_repos_or_legacy(self): repo_root = Path(__file__).resolve().parents[1] @@ -71,4 +75,3 @@ class ModuleBoundaryTests(unittest.TestCase): if path.name == "__init__.py": continue self._assert_forbidden_imports(path=path, forbidden_prefixes=forbidden) - diff --git a/tests/test_performance.py b/tests/test_performance.py index c3dacc9..8e7a44c 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -13,7 +13,7 @@ import os import time # 添加项目根目录到 Python 路径 -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) class TestPerformance(unittest.TestCase): @@ -279,6 +279,6 @@ class TestMemoryUsage(unittest.TestCase): self.assertTrue(True, "前端内存测试,需要在浏览器中执行") -if __name__ == '__main__': +if __name__ == "__main__": # 运行性能测试 unittest.main(verbosity=2) diff --git a/tests/test_verification_extractor.py b/tests/test_verification_extractor.py index 959d34b..4969c0a 100644 --- a/tests/test_verification_extractor.py +++ b/tests/test_verification_extractor.py @@ -13,7 +13,7 @@ import sys import os # 添加项目根目录到 Python 路径 -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from outlook_web.services.verification_extractor import ( smart_extract_verification_code, @@ -21,7 +21,7 @@ from outlook_web.services.verification_extractor import ( extract_links, extract_email_text, extract_verification_info, - extract_verification_info_from_text + extract_verification_info_from_text, ) @@ -338,7 +338,7 @@ class TestVerificationExtractor(unittest.TestCase): links = extract_links(email_body) self.assertEqual(len(links), 1) - formatted = ' '.join(links) + formatted = " ".join(links) self.assertEqual(formatted, "https://example.com/verify?token=abc123def456") # ==================== extract_verification_info 函数测试 ==================== @@ -347,9 +347,7 @@ class TestVerificationExtractor(unittest.TestCase): """ 测试用例:完整提取函数 - 成功提取 """ - email = { - "body": "您的验证码是 123456,点击 https://example.com/verify 激活。" - } + email = {"body": "您的验证码是 123456,点击 https://example.com/verify 激活。"} result = extract_verification_info(email) self.assertEqual(result["verification_code"], "123456") @@ -412,14 +410,11 @@ class TestVerificationExtractor(unittest.TestCase): """ 测试用例:邮件文本提取 - bodyContent 格式 """ - email = { - "bodyContent": "

Your code is 555666

", - "bodyContentType": "html" - } + email = {"bodyContent": "

Your code is 555666

", "bodyContentType": "html"} text = extract_email_text(email) self.assertIn("555666", text) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main()