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:
1 parent
5af073caa8
commit
0eb36f5175
66 files changed
+2432
-1340
No files matched your search
+52
-32
@@ -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,4 +1,3 @@
|
||||
from outlook_web.app import create_app
|
||||
|
||||
__all__ = ["create_app"]
|
||||
|
||||
+14
-4
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+486
-306
File diff suppressed because it is too large.
Load diff
+219
-147
@@ -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
|
||||
@@ -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}"
|
||||
},
|
||||
)
|
||||
@@ -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}"})
|
||||
@@ -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})
|
||||
@@ -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
@@ -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)}"}
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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 模块迁移时处理
|
||||
|
||||
@@ -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
@@ -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} 个账号的敏感数据为加密存储")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -2,4 +2,3 @@
|
||||
|
||||
目标:把 SQL 与数据映射集中到本目录,routes/services 不直接写 SQL。
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,4 +38,3 @@ def log_refresh_result(
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,4 +2,3 @@
|
||||
|
||||
目标:按领域拆分 routes,并在 create_app 内集中注册,保持 URL 不变。
|
||||
"""
|
||||
|
||||
+106
-23
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,4 +15,3 @@ __all__ = [
|
||||
"is_password_hashed",
|
||||
"verify_password",
|
||||
]
|
||||
|
||||
@@ -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, "验证已过期,请重新验证"
|
||||
|
||||
|
||||
@@ -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:")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,4 +3,3 @@
|
||||
目标:集中对外部系统(Graph/IMAP/GPTMail)与业务回退编排(刷新、删除等)的逻辑,
|
||||
routes 只负责参数校验与 HTTP 协议层适配。
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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:
|
||||
|
||||
@@ -10,4 +10,3 @@ def get_response_details(response: requests.Response) -> Any:
|
||||
return response.json()
|
||||
except Exception:
|
||||
return response.text or response.reason
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,2 +1 @@
|
||||
# 使 tests 成为可导入的包,便于复用测试夹具。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
设置界面测试用例集
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user