style: 运行 Black 格式化所有 Python 代码

- 格式化 66 个 Python 文件以符合 Black 代码风格
- 修复 GitHub Actions Code Quality 工作流失败问题

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
胡绍康andClaude Opus 4.6 committed 2026-02-25 18:23:57 +08:00
1 parent 5af073caa8
commit 0eb36f5175
66 files changed
+2432 -1340

No files matched your search

+52 -32
View File
@@ -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()
main()
-1
View File
@@ -1,4 +1,3 @@
from outlook_web.app import create_app
__all__ = ["create_app"]
+14 -4
View File
@@ -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
+6 -3
View File
@@ -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,
}
+19 -6
View File
@@ -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)
File diff suppressed because it is too large. Load diff
+219 -147
View File
@@ -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
+66 -51
View File
@@ -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}"
},
)
+51 -31
View File
@@ -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}"})
+23 -20
View File
@@ -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})
+73 -42
View File
@@ -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()
+131 -111
View File
@@ -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)}"}
)
+120 -76
View File
@@ -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()
+18 -11
View File
@@ -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 模块迁移时处理
+95 -65
View File
@@ -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": "获取邮件失败"})
+13 -6
View File
@@ -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} 个账号的敏感数据为加密存储")
+5 -1
View File
@@ -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(
+13 -9
View File
@@ -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
+36 -27
View File
@@ -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
-1
View File
@@ -2,4 +2,3 @@
目标:把 SQL 与数据映射集中到本目录,routes/services 不直接写 SQL。
"""
+31 -6
View File
@@ -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
@@ -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
+9 -4
View File
@@ -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
-1
View File
@@ -38,4 +38,3 @@ def log_refresh_result(
return True
except Exception:
return False
+9 -2
View File
@@ -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()
-1
View File
@@ -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()
+7 -3
View File
@@ -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
+9 -4
View File
@@ -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
-1
View File
@@ -2,4 +2,3 @@
目标:按领域拆分 routes,并在 create_app 内集中注册,保持 URL 不变。
"""
+106 -23
View File
@@ -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/<int:account_id>", view_func=accounts_controller.api_get_account, methods=["GET"])
bp.add_url_rule("/api/accounts/<int:account_id>", view_func=accounts_controller.api_update_account, methods=["PUT"])
bp.add_url_rule("/api/accounts/<int:account_id>", view_func=accounts_controller.api_delete_account, methods=["DELETE"])
bp.add_url_rule("/api/accounts/email/<email_addr>", 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/<int:account_id>",
view_func=accounts_controller.api_get_account,
methods=["GET"],
)
bp.add_url_rule(
"/api/accounts/<int:account_id>",
view_func=accounts_controller.api_update_account,
methods=["PUT"],
)
bp.add_url_rule(
"/api/accounts/<int:account_id>",
view_func=accounts_controller.api_delete_account,
methods=["DELETE"],
)
bp.add_url_rule(
"/api/accounts/email/<email_addr>",
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/<int:account_id>/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/<int:account_id>/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/<int:account_id>/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/<int:account_id>/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/<int:account_id>/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/<int:account_id>/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
+5 -1
View File
@@ -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
+20 -5
View File
@@ -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/<email_addr>", view_func=emails_controller.api_get_emails, methods=["GET"])
bp.add_url_rule("/api/emails/<email_addr>/extract-verification", view_func=emails_controller.api_extract_verification, methods=["GET"])
bp.add_url_rule("/api/emails/delete", view_func=emails_controller.api_delete_emails, methods=["POST"])
bp.add_url_rule("/api/email/<email_addr>/<path:message_id>", view_func=emails_controller.api_get_email_detail, methods=["GET"])
bp.add_url_rule(
"/api/emails/<email_addr>",
view_func=emails_controller.api_get_emails,
methods=["GET"],
)
bp.add_url_rule(
"/api/emails/<email_addr>/extract-verification",
view_func=emails_controller.api_extract_verification,
methods=["GET"],
)
bp.add_url_rule(
"/api/emails/delete",
view_func=emails_controller.api_delete_emails,
methods=["POST"],
)
bp.add_url_rule(
"/api/email/<email_addr>/<path:message_id>",
view_func=emails_controller.api_get_email_detail,
methods=["GET"],
)
return bp
+26 -7
View File
@@ -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/<int:group_id>", 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/<int:group_id>", view_func=groups_controller.api_update_group, methods=["PUT"])
bp.add_url_rule("/api/groups/<int:group_id>", view_func=groups_controller.api_delete_group, methods=["DELETE"])
bp.add_url_rule("/api/groups/<int:group_id>/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/<int:group_id>",
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/<int:group_id>",
view_func=groups_controller.api_update_group,
methods=["PUT"],
)
bp.add_url_rule(
"/api/groups/<int:group_id>",
view_func=groups_controller.api_delete_group,
methods=["DELETE"],
)
bp.add_url_rule(
"/api/groups/<int:group_id>/export",
view_func=groups_controller.api_export_group,
methods=["GET"],
)
return bp
+10 -3
View File
@@ -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
-1
View File
@@ -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
+5 -1
View File
@@ -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
+13 -4
View File
@@ -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
+15 -4
View File
@@ -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
+11 -4
View File
@@ -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/<int:tag_id>", 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/<int:tag_id>",
view_func=tags_controller.api_delete_tag,
methods=["DELETE"],
)
return bp
+35 -8
View File
@@ -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/<path:email_addr>", temp_emails_controller.api_delete_temp_email, ["DELETE"]),
("/api/temp-emails/<path:email_addr>/messages", temp_emails_controller.api_get_temp_email_messages, ["GET"]),
("/api/temp-emails/<path:email_addr>/messages/<path:message_id>", temp_emails_controller.api_get_temp_email_message_detail, ["GET"]),
("/api/temp-emails/<path:email_addr>/messages/<path:message_id>", temp_emails_controller.api_delete_temp_email_message, ["DELETE"]),
("/api/temp-emails/<path:email_addr>/clear", temp_emails_controller.api_clear_temp_email_messages, ["DELETE"]),
("/api/temp-emails/<path:email_addr>/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/<path:email_addr>",
temp_emails_controller.api_delete_temp_email,
["DELETE"],
),
(
"/api/temp-emails/<path:email_addr>/messages",
temp_emails_controller.api_get_temp_email_messages,
["GET"],
),
(
"/api/temp-emails/<path:email_addr>/messages/<path:message_id>",
temp_emails_controller.api_get_temp_email_message_detail,
["GET"],
),
(
"/api/temp-emails/<path:email_addr>/messages/<path:message_id>",
temp_emails_controller.api_delete_temp_email_message,
["DELETE"],
),
(
"/api/temp-emails/<path:email_addr>/clear",
temp_emails_controller.api_clear_temp_email_messages,
["DELETE"],
),
(
"/api/temp-emails/<path:email_addr>/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
-1
View File
@@ -15,4 +15,3 @@ __all__ = [
"is_password_hashed",
"verify_password",
]
+12 -3
View File
@@ -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, "验证已过期,请重新验证"
+9 -3
View File
@@ -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:")
+3 -2
View File
@@ -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
-1
View File
@@ -3,4 +3,3 @@
目标:集中对外部系统(Graph/IMAP/GPTMail)与业务回退编排(刷新、删除等)的逻辑,
routes 只负责参数校验与 HTTP 协议层适配。
"""
+13 -5
View File
@@ -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
+17 -13
View File
@@ -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))
+27 -9
View File
@@ -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:
-1
View File
@@ -10,4 +10,3 @@ def get_response_details(response: requests.Response) -> Any:
return response.json()
except Exception:
return response.text or response.reason
+59 -12
View File
@@ -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"'])
+106 -29
View File
@@ -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,
)
+143 -64
View File
@@ -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
+19 -8
View File
@@ -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,
}
+4 -5
View File
@@ -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"})
-1
View File
@@ -1,2 +1 @@
# 使 tests 成为可导入的包,便于复用测试夹具。
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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()
+32 -8
View File
@@ -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)
+15 -6
View File
@@ -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()
+40 -14
View File
@@ -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
+19 -16
View File
@@ -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:
"""
设置界面测试用例集
+91 -72
View File
@@ -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()
+48 -12
View File
@@ -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)
+6 -3
View File
@@ -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)
+2 -2
View File
@@ -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)
+6 -11
View File
@@ -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": "<p>Your code is 555666</p>",
"bodyContentType": "html"
}
email = {"bodyContent": "<p>Your code is 555666</p>", "bodyContentType": "html"}
text = extract_email_text(email)
self.assertIn("555666", text)
if __name__ == '__main__':
if __name__ == "__main__":
unittest.main()