fix: align external api behavior with PRD-00008

This commit is contained in:
胡绍康 committed 2026-03-10 21:24:55 +08:00
1 parent 2c4ca1f0cb
commit 1d4d848702
7 files changed
+330 -33

No files matched your search

+26 -5
View File
@@ -581,7 +581,7 @@ def api_extract_verification(email_addr: str) -> Any:
# ==================== External Emails API ====================
def _parse_external_common_args() -> dict:
def _parse_external_common_args(*, default_since_minutes: int | None = None) -> dict:
"""解析 external API 通用 query 参数(按 TDD-00008 做基础校验)。"""
email_addr = (request.args.get("email") or "").strip()
if not email_addr or "@" not in email_addr:
@@ -608,7 +608,7 @@ def _parse_external_common_args() -> dict:
raise external_api_service.InvalidParamError("top 参数无效")
since_minutes_raw = request.args.get("since_minutes", None)
since_minutes = None
since_minutes = default_since_minutes
if since_minutes_raw not in (None, ""):
try:
since_minutes = int(since_minutes_raw)
@@ -763,6 +763,13 @@ def api_external_get_message_raw(message_id: str) -> Any:
message_id=message_id,
folder=args["folder"],
)
external_api_service.audit_external_api_access(
action="external_api_access",
email_addr=args["email"] or "",
endpoint="/api/external/messages/{message_id}/raw",
status="ok",
details={"method": detail.get("method")},
)
return jsonify(
external_api_service.ok(
{
@@ -774,15 +781,29 @@ def api_external_get_message_raw(message_id: str) -> Any:
)
)
except external_api_service.ExternalApiError as exc:
external_api_service.audit_external_api_access(
action="external_api_access",
email_addr=(request.args.get("email") or "").strip(),
endpoint="/api/external/messages/{message_id}/raw",
status="error",
details={"code": exc.code},
)
return _external_error_response(exc)
except Exception:
except Exception as exc:
external_api_service.audit_external_api_access(
action="external_api_access",
email_addr=(request.args.get("email") or "").strip(),
endpoint="/api/external/messages/{message_id}/raw",
status="error",
details={"code": "INTERNAL_ERROR", "err": type(exc).__name__},
)
return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500
@api_key_required
def api_external_get_verification_code() -> Any:
try:
args = _parse_external_common_args()
args = _parse_external_common_args(default_since_minutes=10)
code_length = (request.args.get("code_length") or "").strip() or None
code_regex = (request.args.get("code_regex") or "").strip() or None
code_source = (request.args.get("code_source") or "all").strip().lower()
@@ -828,7 +849,7 @@ def api_external_get_verification_code() -> Any:
@api_key_required
def api_external_get_verification_link() -> Any:
try:
args = _parse_external_common_args()
args = _parse_external_common_args(default_since_minutes=10)
result = external_api_service.get_verification_result(
email_addr=args["email"],
folder=args["folder"],
+55 -14
View File
@@ -272,7 +272,23 @@ def api_external_health() -> Any:
"server_time_utc": utcnow().isoformat() + "Z",
"database": "ok" if db_ok else "error",
}
external_api_service.audit_external_api_access(
action="external_api_access",
email_addr="",
endpoint="/api/external/health",
status="ok",
details={"database": data["database"]},
)
return jsonify(external_api_service.ok(data))
except Exception as exc:
external_api_service.audit_external_api_access(
action="external_api_access",
email_addr="",
endpoint="/api/external/health",
status="error",
details={"code": "INTERNAL_ERROR", "err": type(exc).__name__},
)
return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500
finally:
conn.close()
@@ -292,6 +308,13 @@ def api_external_capabilities() -> Any:
"wait_message",
],
}
external_api_service.audit_external_api_access(
action="external_api_access",
email_addr="",
endpoint="/api/external/capabilities",
status="ok",
details={"feature_count": len(data["features"])},
)
return jsonify(external_api_service.ok(data))
@@ -300,10 +323,24 @@ def api_external_account_status() -> Any:
"""对外账号状态检查"""
email_addr = (request.args.get("email") or "").strip()
if not email_addr or "@" not in email_addr:
external_api_service.audit_external_api_access(
action="external_api_access",
email_addr=email_addr,
endpoint="/api/external/account-status",
status="error",
details={"code": "INVALID_PARAM"},
)
return jsonify(external_api_service.fail("INVALID_PARAM", "email 参数不合法")), 400
account = accounts_repo.get_account_by_email(email_addr)
if not account:
external_api_service.audit_external_api_access(
action="external_api_access",
email_addr=email_addr,
endpoint="/api/external/account-status",
status="error",
details={"code": "ACCOUNT_NOT_FOUND"},
)
return jsonify(external_api_service.fail("ACCOUNT_NOT_FOUND", "账号不存在", data={"email": email_addr})), 404
account_type = (account.get("account_type") or "outlook").strip().lower()
@@ -317,18 +354,22 @@ def api_external_account_status() -> Any:
else:
can_read = bool((account.get("client_id") or "").strip()) and bool((account.get("refresh_token") or "").strip())
return jsonify(
external_api_service.ok(
{
"email": email_addr,
"exists": True,
"account_type": account_type,
"provider": provider,
"group_id": account.get("group_id"),
"status": account.get("status"),
"last_refresh_at": account.get("last_refresh_at"),
"preferred_method": preferred_method,
"can_read": can_read,
}
)
data = {
"email": email_addr,
"exists": True,
"account_type": account_type,
"provider": provider,
"group_id": account.get("group_id"),
"status": account.get("status"),
"last_refresh_at": account.get("last_refresh_at"),
"preferred_method": preferred_method,
"can_read": can_read,
}
external_api_service.audit_external_api_access(
action="external_api_access",
email_addr=email_addr,
endpoint="/api/external/account-status",
status="ok",
details={"preferred_method": preferred_method, "can_read": can_read},
)
return jsonify(external_api_service.ok(data))
+13 -2
View File
@@ -384,6 +384,14 @@ def get_message_detail_for_external(
proxy_url,
)
method_label = "Graph API"
graph_raw_content = None
if detail:
graph_raw_content = graph_service.get_email_raw_graph(
account.get("client_id") or "",
account.get("refresh_token") or "",
message_id,
proxy_url,
)
if not detail:
detail = imap_service.get_email_detail_imap_with_server(
email_addr,
@@ -420,7 +428,7 @@ def get_message_detail_for_external(
html_content = body_content if body_type == "html" else ""
content = body_content if body_type == "text" else extract_email_text({"body_html": html_content})
raw_content = body_content
raw_content = str(graph_raw_content or body_content)
from_address = (detail.get("from") or {}).get("emailAddress", {}).get("address", "")
to_address = ",".join([r.get("emailAddress", {}).get("address", "") for r in (detail.get("toRecipients") or [])])
@@ -519,17 +527,20 @@ def wait_for_message(
if poll_interval <= 0 or poll_interval > timeout_seconds:
raise InvalidParamError("poll_interval 参数无效")
baseline_timestamp = int(time.time())
start = time.time()
last_error: Optional[ExternalApiError] = None
while True:
try:
return get_latest_message_for_external(
latest_message = get_latest_message_for_external(
email_addr=email_addr,
folder=folder,
from_contains=from_contains,
subject_contains=subject_contains,
since_minutes=since_minutes,
)
if int(latest_message.get("timestamp") or 0) >= baseline_timestamp:
return latest_message
except MailNotFoundError as exc:
last_error = exc
+29
View File
@@ -183,6 +183,35 @@ def get_email_detail_graph(
return None
def get_email_raw_graph(
client_id: str,
refresh_token: str,
message_id: str,
proxy_url: str = None,
) -> Optional[str]:
"""使用 Graph API 获取邮件 MIME RAW 内容。"""
access_token = get_access_token_graph(client_id, refresh_token, proxy_url)
if not access_token:
return None
try:
url = f"https://graph.microsoft.com/v1.0/me/messages/{message_id}/$value"
headers = {
"Authorization": f"Bearer {access_token}",
}
proxies = build_proxies(proxy_url)
res = requests.get(url, headers=headers, timeout=30, proxies=proxies)
if res.status_code != 200:
return None
res.encoding = res.encoding or "utf-8"
return res.text
except Exception:
return None
def test_refresh_token(client_id: str, refresh_token: str, proxy_url: str = None) -> tuple[bool, str | None]:
"""测试 refresh token 是否有效,返回 (是否成功, 错误信息)"""
ok, err, _new_refresh_token = test_refresh_token_with_rotation(client_id, refresh_token, proxy_url)