Files

350 lines
11 KiB
Python

from __future__ import annotations
from typing import Any, Dict, List, Optional
import requests
from outlook_web.errors import build_error_payload
from outlook_web.services.http import get_response_details
# Token 端点
TOKEN_URL_GRAPH = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
def build_proxies(proxy_url: str) -> Optional[Dict[str, str]]:
"""构建 requests 的 proxies 参数"""
if not proxy_url:
return None
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]:
"""获取 Graph API access_token(包含错误详情)"""
try:
proxies = build_proxies(proxy_url)
res = requests.post(
TOKEN_URL_GRAPH,
data={
"client_id": client_id,
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": "https://graph.microsoft.com/.default",
},
timeout=30,
proxies=proxies,
)
if res.status_code != 200:
details = get_response_details(res)
return {
"success": False,
"error": build_error_payload(
"GRAPH_TOKEN_FAILED",
"获取访问令牌失败",
"GraphAPIError",
res.status_code,
details,
),
}
payload = res.json()
access_token = payload.get("access_token")
if not access_token:
return {
"success": False,
"error": build_error_payload(
"GRAPH_TOKEN_MISSING",
"获取访问令牌失败",
"GraphAPIError",
res.status_code,
payload,
),
}
# 根据 Microsoft Learn 文档:refresh token 可能会在每次使用时“自我替换”,应保存新的 refresh_token(如有)。
new_refresh_token = payload.get("refresh_token")
return {"success": True, "access_token": access_token, "refresh_token": new_refresh_token}
except Exception as exc:
return {
"success": False,
"error": build_error_payload(
"GRAPH_TOKEN_EXCEPTION",
"获取访问令牌失败",
type(exc).__name__,
500,
str(exc),
),
}
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"):
return result.get("access_token")
return None
def get_emails_graph(
client_id: str,
refresh_token: str,
folder: str = "inbox",
skip: int = 0,
top: int = 20,
proxy_url: str = None,
) -> Dict[str, Any]:
"""使用 Graph API 获取邮件列表(支持分页和文件夹选择)"""
token_result = get_access_token_graph_result(client_id, refresh_token, proxy_url)
if not token_result.get("success"):
return {"success": False, "error": token_result.get("error")}
access_token = token_result.get("access_token")
try:
folder_map = {
"inbox": "inbox",
"junkemail": "junkemail",
"deleteditems": "deleteditems",
"trash": "deleteditems",
}
folder_name = folder_map.get((folder or "").lower(), "inbox")
url = f"https://graph.microsoft.com/v1.0/me/mailFolders/{folder_name}/messages"
params = {
"$top": top,
"$skip": skip,
"$select": "id,subject,from,receivedDateTime,isRead,hasAttachments,bodyPreview",
"$orderby": "receivedDateTime desc",
}
headers = {
"Authorization": f"Bearer {access_token}",
"Prefer": "outlook.body-content-type='text'",
}
proxies = build_proxies(proxy_url)
res = requests.get(url, headers=headers, params=params, timeout=30, proxies=proxies)
if res.status_code != 200:
details = get_response_details(res)
return {
"success": False,
"error": build_error_payload(
"EMAIL_FETCH_FAILED",
"获取邮件失败,请检查账号配置",
"GraphAPIError",
res.status_code,
details,
),
}
return {"success": True, "emails": res.json().get("value", [])}
except Exception as exc:
return {
"success": False,
"error": build_error_payload(
"EMAIL_FETCH_FAILED",
"获取邮件失败,请检查账号配置",
type(exc).__name__,
500,
str(exc),
),
}
def get_email_detail_graph(
client_id: str,
refresh_token: str,
message_id: str,
proxy_url: str = None,
) -> Optional[Dict]:
"""使用 Graph API 获取邮件详情"""
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}"
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)
if res.status_code != 200:
return None
return res.json()
except Exception:
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)
return ok, err
def test_refresh_token_with_rotation(
client_id: str, refresh_token: str, proxy_url: str = None
) -> tuple[bool, str | None, str | None]:
"""测试 refresh token 是否有效;如服务端返回新的 refresh_token,则一并返回(用于滚动更新)。"""
try:
proxies = build_proxies(proxy_url)
res = requests.post(
TOKEN_URL_GRAPH,
data={
"client_id": client_id,
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": "https://graph.microsoft.com/.default",
},
timeout=30,
proxies=proxies,
)
if res.status_code == 200:
try:
payload = res.json()
except Exception:
payload = {}
new_refresh_token = payload.get("refresh_token")
return True, None, new_refresh_token
try:
error_data = res.json()
except Exception:
error_data = {}
error_msg = None
if isinstance(error_data, dict):
error_msg = error_data.get("error_description") or error_data.get("error")
if not error_msg:
details = get_response_details(res)
error_msg = str(details)[:800] if details is not None else "未知错误"
return False, str(error_msg), None
except Exception as e:
return False, f"请求异常: {str(e)}", None
def delete_emails_graph(
client_id: str,
refresh_token: str,
message_ids: List[str],
proxy_url: str = None,
) -> Dict[str, Any]:
"""通过 Graph API 批量删除邮件(永久删除)"""
token_result = get_access_token_graph_result(client_id, refresh_token, proxy_url)
if not token_result.get("success"):
return {"success": False, "error": token_result.get("error")}
access_token = token_result.get("access_token")
if not access_token:
return {
"success": False,
"error": build_error_payload(
"GRAPH_TOKEN_FAILED",
"获取访问令牌失败",
"GraphAPIError",
500,
"empty_access_token",
),
}
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
# Graph API batch 请求每次最多 20
batch_size = 20
success_count = 0
failed_count = 0
errors: List[str] = []
for i in range(0, len(message_ids), batch_size):
batch = message_ids[i : i + batch_size]
batch_requests = []
for idx, msg_id in enumerate(batch):
batch_requests.append({"id": str(idx), "method": "DELETE", "url": f"/me/messages/{msg_id}"})
try:
proxies = build_proxies(proxy_url)
response = requests.post(
"https://graph.microsoft.com/v1.0/$batch",
headers=headers,
json={"requests": batch_requests},
timeout=30,
proxies=proxies,
)
if response.status_code == 200:
results = response.json().get("responses", [])
for res in results:
if res.get("status") in [200, 204]:
success_count += 1
else:
failed_count += 1
try:
errors.append(f"Msg ID: {batch[int(res['id'])]}, Status: {res.get('status')}")
except Exception:
errors.append(f"Status: {res.get('status')}")
else:
failed_count += len(batch)
errors.append(f"Batch request failed: {response.text}")
except Exception as e:
failed_count += len(batch)
errors.append(f"Network error: {str(e)}")
result: Dict[str, Any] = {
"success": success_count > 0,
"partial_success": success_count > 0 and failed_count > 0,
"success_count": success_count,
"failed_count": failed_count,
"errors": errors,
}
if not result["success"]:
result["error"] = build_error_payload(
"EMAIL_DELETE_FAILED",
"删除邮件失败",
"GraphAPIError",
502,
{"failed_count": failed_count, "errors": errors[:10]},
)
return result