mirror of
https://github.com/cnlimiter/codex-register.git
synced 2026-09-08 08:56:58 +08:00
Merge branch 'fix1' into feat/auto-disable-failed-proxies
This commit is contained in:
+18
-18
@@ -21,13 +21,13 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- os: windows-latest
|
- os: windows-latest
|
||||||
artifact_name: codex-register.exe
|
artifact_name: codex-register.exe
|
||||||
asset_name: codex-register-windows-x64.exe
|
asset_name: codex-register-v2-windows-x64.exe
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
artifact_name: codex-register
|
artifact_name: codex-register
|
||||||
asset_name: codex-register-linux-x64
|
asset_name: codex-register-v2-linux-x64
|
||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
artifact_name: codex-register
|
artifact_name: codex-register
|
||||||
asset_name: codex-register-macos-arm64
|
asset_name: codex-register-v2-macos-arm64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 检出代码
|
- name: 检出代码
|
||||||
@@ -74,19 +74,19 @@ jobs:
|
|||||||
- name: 整理文件并打包 zip
|
- name: 整理文件并打包 zip
|
||||||
run: |
|
run: |
|
||||||
mkdir -p release
|
mkdir -p release
|
||||||
# 为每个平台二进制文件打包成 zip
|
# download-artifact@v4 将每个 artifact 放在 dist/<asset_name>/ 子目录下
|
||||||
find dist/ -type f | while read f; do
|
# 遍历子目录,用目录名作为平台标识(即 matrix.asset_name)
|
||||||
name=$(basename "$f")
|
for artifact_dir in dist/*/; do
|
||||||
# 根据文件名确定平台标识
|
platform=$(basename "$artifact_dir")
|
||||||
case "$name" in
|
# 找到该目录下的二进制文件(只有一个)
|
||||||
*windows*) platform=$(echo "$name" | sed 's/\.[^.]*$//') ;;
|
binary=$(find "$artifact_dir" -maxdepth 1 -type f | head -n1)
|
||||||
*linux*) platform="$name" ;;
|
if [ -z "$binary" ]; then
|
||||||
*macos*) platform="$name" ;;
|
echo "警告:$artifact_dir 下没有找到文件,跳过"
|
||||||
*) platform="$name" ;;
|
continue
|
||||||
esac
|
fi
|
||||||
tmpdir="tmp_${platform}"
|
tmpdir="tmp_${platform}"
|
||||||
mkdir -p "$tmpdir"
|
mkdir -p "$tmpdir"
|
||||||
cp "$f" "$tmpdir/"
|
cp "$binary" "$tmpdir/"
|
||||||
cp README.md "$tmpdir/README.md"
|
cp README.md "$tmpdir/README.md"
|
||||||
cp .env.example "$tmpdir/.env.example"
|
cp .env.example "$tmpdir/.env.example"
|
||||||
[ -f LICENSE ] && cp LICENSE "$tmpdir/LICENSE" || true
|
[ -f LICENSE ] && cp LICENSE "$tmpdir/LICENSE" || true
|
||||||
@@ -103,14 +103,14 @@ jobs:
|
|||||||
files: release/*
|
files: release/*
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
body: |
|
body: |
|
||||||
## OpenAI 自动注册系统 v2
|
## OpenAI 账号管理系统 v2
|
||||||
|
|
||||||
### 下载说明
|
### 下载说明
|
||||||
| 平台 | 文件 |
|
| 平台 | 文件 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| Windows x64 | `codex-register-windows-x64.exe` |
|
| Windows x64 | `codex-register-v2-windows-x64.exe` |
|
||||||
| Linux x64 | `codex-register-linux-x64` |
|
| Linux x64 | `codex-register-v2-linux-x64` |
|
||||||
| macOS ARM64 | `codex-register-macos-arm64` |
|
| macOS ARM64 | `codex-register-v2-macos-arm64` |
|
||||||
|
|
||||||
### 使用方法
|
### 使用方法
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -64,5 +64,6 @@ jobs:
|
|||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
+9
-17
@@ -65,15 +65,19 @@ OPENAI_API_ENDPOINTS = {
|
|||||||
"signup": "https://auth.openai.com/api/accounts/authorize/continue",
|
"signup": "https://auth.openai.com/api/accounts/authorize/continue",
|
||||||
"register": "https://auth.openai.com/api/accounts/user/register",
|
"register": "https://auth.openai.com/api/accounts/user/register",
|
||||||
"send_otp": "https://auth.openai.com/api/accounts/email-otp/send",
|
"send_otp": "https://auth.openai.com/api/accounts/email-otp/send",
|
||||||
|
"passwordless_send_otp": "https://auth.openai.com/api/accounts/passwordless/send-otp",
|
||||||
"validate_otp": "https://auth.openai.com/api/accounts/email-otp/validate",
|
"validate_otp": "https://auth.openai.com/api/accounts/email-otp/validate",
|
||||||
"create_account": "https://auth.openai.com/api/accounts/create_account",
|
"create_account": "https://auth.openai.com/api/accounts/create_account",
|
||||||
|
"add_phone" : "https://auth.openai.com/add-phone",
|
||||||
"select_workspace": "https://auth.openai.com/api/accounts/workspace/select",
|
"select_workspace": "https://auth.openai.com/api/accounts/workspace/select",
|
||||||
|
"password_verify" : "https://auth.openai.com/api/accounts/password/verify"
|
||||||
}
|
}
|
||||||
|
|
||||||
# OpenAI 页面类型(用于判断账号状态)
|
# OpenAI 页面类型(用于判断账号状态)
|
||||||
OPENAI_PAGE_TYPES = {
|
OPENAI_PAGE_TYPES = {
|
||||||
|
"LOGIN_PASSWORD": "login_password",
|
||||||
"EMAIL_OTP_VERIFICATION": "email_otp_verification", # 已注册账号,需要 OTP 验证
|
"EMAIL_OTP_VERIFICATION": "email_otp_verification", # 已注册账号,需要 OTP 验证
|
||||||
"PASSWORD_REGISTRATION": "password", # 新账号,需要设置密码
|
"PASSWORD_REGISTRATION": "create_account_password", # 新账号,需要设置密码
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -378,20 +382,8 @@ MICROSOFT_TOKEN_ENDPOINTS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# IMAP 服务器配置
|
# IMAP 服务器配置
|
||||||
OUTLOOK_IMAP_SERVERS = {
|
OUTLOOK_IMAP_SERVER = "outlook.live.com"
|
||||||
"OLD": "outlook.office365.com", # 旧版 IMAP
|
OUTLOOK_IMAP_PORT = 993
|
||||||
"NEW": "outlook.live.com", # 新版 IMAP
|
|
||||||
}
|
|
||||||
|
|
||||||
# Microsoft OAuth2 Scopes
|
# Microsoft OAuth2 Scope(IMAP_NEW)
|
||||||
MICROSOFT_SCOPES = {
|
OUTLOOK_IMAP_SCOPE = "https://outlook.office.com/IMAP.AccessAsUser.All offline_access"
|
||||||
# 旧版 IMAP 不需要特定 scope
|
|
||||||
"IMAP_OLD": "",
|
|
||||||
# 新版 IMAP 需要的 scope
|
|
||||||
"IMAP_NEW": "https://outlook.office.com/IMAP.AccessAsUser.All offline_access",
|
|
||||||
# Graph API 需要的 scope
|
|
||||||
"GRAPH_API": "https://graph.microsoft.com/.default",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Outlook 提供者默认优先级
|
|
||||||
OUTLOOK_PROVIDER_PRIORITY = ["imap_new", "imap_old", "graph_api"]
|
|
||||||
|
|||||||
@@ -358,12 +358,6 @@ SETTING_DEFINITIONS: Dict[str, SettingDefinition] = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
# Outlook 配置
|
# Outlook 配置
|
||||||
"outlook_provider_priority": SettingDefinition(
|
|
||||||
db_key="outlook.provider_priority",
|
|
||||||
default_value=["imap_old", "imap_new", "graph_api"],
|
|
||||||
category=SettingCategory.EMAIL,
|
|
||||||
description="Outlook 提供者优先级"
|
|
||||||
),
|
|
||||||
"outlook_health_failure_threshold": SettingDefinition(
|
"outlook_health_failure_threshold": SettingDefinition(
|
||||||
db_key="outlook.health_failure_threshold",
|
db_key="outlook.health_failure_threshold",
|
||||||
default_value=5,
|
default_value=5,
|
||||||
@@ -382,6 +376,12 @@ SETTING_DEFINITIONS: Dict[str, SettingDefinition] = {
|
|||||||
category=SettingCategory.EMAIL,
|
category=SettingCategory.EMAIL,
|
||||||
description="Outlook OAuth 默认 Client ID"
|
description="Outlook OAuth 默认 Client ID"
|
||||||
),
|
),
|
||||||
|
"outlook_use_idle": SettingDefinition(
|
||||||
|
db_key="outlook.use_idle",
|
||||||
|
default_value=True,
|
||||||
|
category=SettingCategory.EMAIL,
|
||||||
|
description="使用 IMAP IDLE 替代轮询获取验证码(降低延迟,默认开启)"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
# 属性名到数据库键名的映射(用于向后兼容)
|
# 属性名到数据库键名的映射(用于向后兼容)
|
||||||
@@ -407,9 +407,9 @@ SETTING_TYPES: Dict[str, Type] = {
|
|||||||
"cpa_enabled": bool,
|
"cpa_enabled": bool,
|
||||||
"email_code_timeout": int,
|
"email_code_timeout": int,
|
||||||
"email_code_poll_interval": int,
|
"email_code_poll_interval": int,
|
||||||
"outlook_provider_priority": list,
|
|
||||||
"outlook_health_failure_threshold": int,
|
"outlook_health_failure_threshold": int,
|
||||||
"outlook_health_disable_duration": int,
|
"outlook_health_disable_duration": int,
|
||||||
|
"outlook_use_idle": bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 需要作为 SecretStr 处理的字段
|
# 需要作为 SecretStr 处理的字段
|
||||||
@@ -694,10 +694,10 @@ class Settings(BaseModel):
|
|||||||
email_code_poll_interval: int = 3
|
email_code_poll_interval: int = 3
|
||||||
|
|
||||||
# Outlook 配置
|
# Outlook 配置
|
||||||
outlook_provider_priority: List[str] = ["imap_old", "imap_new", "graph_api"]
|
|
||||||
outlook_health_failure_threshold: int = 5
|
outlook_health_failure_threshold: int = 5
|
||||||
outlook_health_disable_duration: int = 60
|
outlook_health_disable_duration: int = 60
|
||||||
outlook_default_client_id: str = "24d9a0ed-8787-4584-883c-2fd79308940a"
|
outlook_default_client_id: str = "24d9a0ed-8787-4584-883c-2fd79308940a"
|
||||||
|
outlook_use_idle: bool = True
|
||||||
|
|
||||||
|
|
||||||
# 全局配置实例
|
# 全局配置实例
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from .http_client import (
|
|||||||
create_openai_client,
|
create_openai_client,
|
||||||
)
|
)
|
||||||
from .register import RegistrationEngine, RegistrationResult
|
from .register import RegistrationEngine, RegistrationResult
|
||||||
|
from .login import LoginEngine
|
||||||
from .utils import setup_logging, get_data_dir
|
from .utils import setup_logging, get_data_dir
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -27,6 +28,7 @@ __all__ = [
|
|||||||
'create_openai_client',
|
'create_openai_client',
|
||||||
'RegistrationEngine',
|
'RegistrationEngine',
|
||||||
'RegistrationResult',
|
'RegistrationResult',
|
||||||
|
'LoginEngine',
|
||||||
'setup_logging',
|
'setup_logging',
|
||||||
'get_data_dir',
|
'get_data_dir',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ class OpenAIHTTPClient(HTTPClient):
|
|||||||
loc = loc_match.group(1) if loc_match else None
|
loc = loc_match.group(1) if loc_match else None
|
||||||
|
|
||||||
# 检查是否支持
|
# 检查是否支持
|
||||||
if loc in ["CN", "HK", "MO", "TW"]:
|
if loc in ["CN", "HK", "MO"]:
|
||||||
return False, loc
|
return False, loc
|
||||||
return True, loc
|
return True, loc
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
"""
|
||||||
|
登录流程引擎
|
||||||
|
从 register.py 中拆分的登录专属方法
|
||||||
|
"""
|
||||||
|
|
||||||
|
import urllib.parse
|
||||||
|
import base64
|
||||||
|
import json as json_module
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
from .register import RegistrationEngine, RegistrationResult
|
||||||
|
from ..config.constants import OPENAI_API_ENDPOINTS
|
||||||
|
|
||||||
|
|
||||||
|
class LoginEngine(RegistrationEngine):
|
||||||
|
"""
|
||||||
|
登录引擎
|
||||||
|
继承 RegistrationEngine,包含登录流程专属方法:
|
||||||
|
- _follow_login_redirects
|
||||||
|
- _submit_login_form
|
||||||
|
- _send_verification_code_passwordless
|
||||||
|
- _get_workspace_id
|
||||||
|
- _select_workspace
|
||||||
|
- _follow_redirects
|
||||||
|
- _handle_oauth_callback
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _follow_login_redirects(self, start_url: str) -> bool:
|
||||||
|
"""跟随重定向链,寻找回调 URL"""
|
||||||
|
try:
|
||||||
|
current_url = start_url
|
||||||
|
max_redirects = 6
|
||||||
|
|
||||||
|
for i in range(max_redirects):
|
||||||
|
self._log(f"重定向 {i+1}/{max_redirects}: {current_url[:100]}...")
|
||||||
|
|
||||||
|
response = self.session.get(
|
||||||
|
current_url,
|
||||||
|
allow_redirects=False,
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
|
||||||
|
location = response.headers.get("Location") or ""
|
||||||
|
|
||||||
|
# 如果不是重定向状态码,停止
|
||||||
|
if response.status_code == 200:
|
||||||
|
self._log(f"非重定向状态码: {response.status_code}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not location:
|
||||||
|
self._log("重定向响应缺少 Location 头")
|
||||||
|
break
|
||||||
|
|
||||||
|
# 构建下一个 URL
|
||||||
|
next_url = urllib.parse.urljoin(current_url, location)
|
||||||
|
|
||||||
|
# 检查是否包含回调参数
|
||||||
|
if "code=" in next_url and "state=" in next_url:
|
||||||
|
self._log(f"找到回调 URL: {next_url[:100]}...")
|
||||||
|
|
||||||
|
current_url = next_url
|
||||||
|
|
||||||
|
self._log("未能在重定向链中找到最终 URL")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._log(f"跟随重定向失败: {e}", "error")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _submit_login_form(self, did: str, sen_token) -> bool:
|
||||||
|
"""处理 免密登录"""
|
||||||
|
try:
|
||||||
|
self._log("处理免密登录...")
|
||||||
|
login_body = f'{{"username":{{"value":"{self.email}","kind":"email"}}}}'
|
||||||
|
headers = {
|
||||||
|
"referer": "https://auth.openai.com/log-in",
|
||||||
|
"accept": "application/json",
|
||||||
|
"content-type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
if sen_token:
|
||||||
|
sentinel = f'{{"p": "", "t": "", "c": "{sen_token}", "id": "{did}", "flow": "authorize_continue"}}'
|
||||||
|
headers["openai-sentinel-token"] = sentinel
|
||||||
|
|
||||||
|
response = self.session.post(
|
||||||
|
OPENAI_API_ENDPOINTS["signup"],
|
||||||
|
headers=headers,
|
||||||
|
data=login_body,
|
||||||
|
)
|
||||||
|
self._log(f"提交登录表单状态: {response.status_code}")
|
||||||
|
if response.status_code == 200:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._log(f"处理登录失败: {e}", "error")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _send_verification_code_passwordless(self) -> bool:
|
||||||
|
"""发送验证码"""
|
||||||
|
try:
|
||||||
|
import time
|
||||||
|
# 记录发送时间戳
|
||||||
|
self._otp_sent_at = time.time()
|
||||||
|
response = self.session.post(
|
||||||
|
OPENAI_API_ENDPOINTS["passwordless_send_otp"],
|
||||||
|
headers={
|
||||||
|
"referer": "https://auth.openai.com/log-in/password",
|
||||||
|
"accept": "application/json"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self._log(f"验证码发送状态: {response.status_code}")
|
||||||
|
return response.status_code == 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._log(f"发送验证码失败: {e}", "error")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_workspace_id(self) -> Optional[str]:
|
||||||
|
"""获取 Workspace ID"""
|
||||||
|
try:
|
||||||
|
auth_cookie = self.session.cookies.get("oai-client-auth-session")
|
||||||
|
if not auth_cookie:
|
||||||
|
self._log("未能获取到授权 Cookie", "error")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
segments = auth_cookie.split(".")
|
||||||
|
if len(segments) < 1:
|
||||||
|
self._log("授权 Cookie 格式错误", "error")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 解码第一个 segment
|
||||||
|
payload = segments[0]
|
||||||
|
pad = "=" * ((4 - (len(payload) % 4)) % 4)
|
||||||
|
decoded = base64.urlsafe_b64decode((payload + pad).encode("ascii"))
|
||||||
|
auth_json = json_module.loads(decoded.decode("utf-8"))
|
||||||
|
|
||||||
|
workspaces = auth_json.get("workspaces") or []
|
||||||
|
if not workspaces:
|
||||||
|
self._log("授权 Cookie 里没有 workspace 信息", "error")
|
||||||
|
return None
|
||||||
|
|
||||||
|
workspace_id = str((workspaces[0] or {}).get("id") or "").strip()
|
||||||
|
if not workspace_id:
|
||||||
|
self._log("无法解析 workspace_id", "error")
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._log(f"Workspace ID: {workspace_id}")
|
||||||
|
return workspace_id
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._log(f"解析授权 Cookie 失败: {e}", "error")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._log(f"获取 Workspace ID 失败: {e}", "error")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _select_workspace(self, workspace_id: str) -> Optional[str]:
|
||||||
|
"""选择 Workspace"""
|
||||||
|
try:
|
||||||
|
select_body = f'{{"workspace_id":"{workspace_id}"}}'
|
||||||
|
|
||||||
|
response = self.session.post(
|
||||||
|
OPENAI_API_ENDPOINTS["select_workspace"],
|
||||||
|
headers={
|
||||||
|
"referer": "https://auth.openai.com/sign-in-with-chatgpt/codex/consent",
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
data=select_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
self._log(f"选择 workspace 失败: {response.status_code}", "error")
|
||||||
|
self._log(f"响应: {response.text[:200]}", "warning")
|
||||||
|
return None
|
||||||
|
|
||||||
|
continue_url = str((response.json() or {}).get("continue_url") or "").strip()
|
||||||
|
if not continue_url:
|
||||||
|
self._log("workspace/select 响应里缺少 continue_url", "error")
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._log(f"Continue URL: {continue_url[:100]}...")
|
||||||
|
return continue_url
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._log(f"选择 Workspace 失败: {e}", "error")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _follow_redirects(self, start_url: str) -> Optional[str]:
|
||||||
|
"""跟随重定向链,寻找回调 URL"""
|
||||||
|
try:
|
||||||
|
current_url = start_url
|
||||||
|
max_redirects = 6
|
||||||
|
|
||||||
|
for i in range(max_redirects):
|
||||||
|
self._log(f"重定向 {i+1}/{max_redirects}: {current_url[:100]}...")
|
||||||
|
|
||||||
|
response = self.session.get(
|
||||||
|
current_url,
|
||||||
|
allow_redirects=False,
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
|
||||||
|
location = response.headers.get("Location") or ""
|
||||||
|
|
||||||
|
# 如果不是重定向状态码,停止
|
||||||
|
if response.status_code not in [301, 302, 303, 307, 308]:
|
||||||
|
self._log(f"非重定向状态码: {response.status_code}")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not location:
|
||||||
|
self._log("重定向响应缺少 Location 头")
|
||||||
|
break
|
||||||
|
|
||||||
|
# 构建下一个 URL
|
||||||
|
next_url = urllib.parse.urljoin(current_url, location)
|
||||||
|
|
||||||
|
# 检查是否包含回调参数
|
||||||
|
if "code=" in next_url and "state=" in next_url:
|
||||||
|
self._log(f"找到回调 URL: {next_url[:100]}...")
|
||||||
|
return next_url
|
||||||
|
|
||||||
|
current_url = next_url
|
||||||
|
|
||||||
|
self._log("未能在重定向链中找到回调 URL", "error")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._log(f"跟随重定向失败: {e}", "error")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _handle_oauth_callback(self, callback_url: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""处理 OAuth 回调"""
|
||||||
|
try:
|
||||||
|
if not self.oauth_start:
|
||||||
|
self._log("OAuth 流程未初始化", "error")
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._log("处理 OAuth 回调...")
|
||||||
|
token_info = self.oauth_manager.handle_callback(
|
||||||
|
callback_url=callback_url,
|
||||||
|
expected_state=self.oauth_start.state,
|
||||||
|
code_verifier=self.oauth_start.code_verifier
|
||||||
|
)
|
||||||
|
|
||||||
|
self._log("OAuth 授权成功")
|
||||||
|
return token_info
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._log(f"处理 OAuth 回调失败: {e}", "error")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def run(self) -> RegistrationResult:
|
||||||
|
"""
|
||||||
|
执行完整的注册流程
|
||||||
|
|
||||||
|
支持已注册账号自动登录:
|
||||||
|
- 如果检测到邮箱已注册,自动切换到登录流程
|
||||||
|
- 已注册账号跳过:设置密码、发送验证码、创建用户账户
|
||||||
|
- 共用步骤:获取验证码、验证验证码、Workspace 和 OAuth 回调
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RegistrationResult: 注册结果
|
||||||
|
"""
|
||||||
|
result = RegistrationResult(success=False, logs=self.logs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._log("=" * 60)
|
||||||
|
self._log("开始注册流程")
|
||||||
|
self._log("=" * 60)
|
||||||
|
|
||||||
|
# 1. 检查 IP 地理位置
|
||||||
|
self._log("1. 检查 IP 地理位置...")
|
||||||
|
ip_ok, location = self._check_ip_location()
|
||||||
|
if not ip_ok:
|
||||||
|
result.error_message = f"IP 地理位置不支持: {location}"
|
||||||
|
self._log(f"IP 检查失败: {location}", "error")
|
||||||
|
return result
|
||||||
|
|
||||||
|
self._log(f"IP 位置: {location}")
|
||||||
|
|
||||||
|
# 2. 创建邮箱
|
||||||
|
self._log("2. 创建邮箱...")
|
||||||
|
if not self._create_email():
|
||||||
|
result.error_message = "创建邮箱失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
result.email = self.email
|
||||||
|
|
||||||
|
# 3. 初始化会话
|
||||||
|
self._log("3. 初始化会话...")
|
||||||
|
if not self._init_session():
|
||||||
|
result.error_message = "初始化会话失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 4. 开始 OAuth 流程
|
||||||
|
self._log("4. 开始 OAuth 授权流程...")
|
||||||
|
if not self._start_oauth():
|
||||||
|
result.error_message = "开始 OAuth 流程失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 5. 获取 Device ID
|
||||||
|
self._log("5. 获取 Device ID...")
|
||||||
|
did = self._get_device_id()
|
||||||
|
if not did:
|
||||||
|
result.error_message = "获取 Device ID 失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 6. 检查 Sentinel 拦截
|
||||||
|
self._log("6. 检查 Sentinel 拦截...")
|
||||||
|
sen_token = self._check_sentinel(did)
|
||||||
|
if sen_token:
|
||||||
|
self._log("Sentinel 检查通过")
|
||||||
|
else:
|
||||||
|
self._log("Sentinel 检查失败或未启用", "warning")
|
||||||
|
|
||||||
|
# 7. 提交注册表单 + 解析响应判断账号状态
|
||||||
|
self._log("7. 提交注册表单...")
|
||||||
|
signup_result = self._submit_signup_form(did, sen_token)
|
||||||
|
if not signup_result.success:
|
||||||
|
result.error_message = f"提交注册表单失败: {signup_result.error_message}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 8. 检测到已注册账号 → 直接终止任务
|
||||||
|
if self._is_existing_account:
|
||||||
|
self._log(f"8. 邮箱 {self.email} 在 OpenAI 已注册,跳过注册流程", "warning")
|
||||||
|
result.error_message = f"邮箱 {self.email} 已在 OpenAI 注册"
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
self._log("8. 注册密码...")
|
||||||
|
password_ok, password = self._register_password()
|
||||||
|
if not password_ok:
|
||||||
|
result.error_message = "注册密码失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 9. 发送验证码
|
||||||
|
self._log("9. 发送验证码...")
|
||||||
|
if not self._send_verification_code():
|
||||||
|
result.error_message = "发送验证码失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 10. 获取验证码(超时后重发一次)
|
||||||
|
self._log("10. 等待验证码...")
|
||||||
|
code = self._get_verification_code()
|
||||||
|
if not code:
|
||||||
|
self._log("10. 验证码超时,重新发送...")
|
||||||
|
if self._send_verification_code():
|
||||||
|
code = self._get_verification_code()
|
||||||
|
if not code:
|
||||||
|
result.error_message = "获取验证码失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 11. 验证验证码
|
||||||
|
self._log("11. 验证验证码...")
|
||||||
|
if not self._validate_verification_code(code):
|
||||||
|
result.error_message = "验证验证码失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 12. 创建用户账户
|
||||||
|
self._log("12. 创建用户账户...")
|
||||||
|
if not self._create_user_account():
|
||||||
|
result.error_message = "创建用户账户失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
self._log("13-1. 结束注册,启用登录流程...")
|
||||||
|
if not self._follow_login_redirects(self.oauth_start.auth_url):
|
||||||
|
result.error_message = "跟随重定向链失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
self._log("13-2. 提交登陆表单")
|
||||||
|
if not self._submit_login_form(did, sen_token):
|
||||||
|
result.error_message = "提交登陆表单失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
self._log("14. 发送验证码...")
|
||||||
|
if not self._send_verification_code_passwordless():
|
||||||
|
result.error_message = "发送验证码失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
self._log("15. 等待验证码...")
|
||||||
|
code = self._get_verification_code()
|
||||||
|
if not code:
|
||||||
|
self._log("15. 验证码超时,重新发送...")
|
||||||
|
if self._send_verification_code_passwordless():
|
||||||
|
code = self._get_verification_code()
|
||||||
|
if not code:
|
||||||
|
result.error_message = "获取验证码失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
self._log("16. 验证验证码...")
|
||||||
|
if not self._validate_verification_code(code):
|
||||||
|
result.error_message = "验证验证码失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 13. 获取 Workspace ID
|
||||||
|
self._log("17. 获取 Workspace ID...")
|
||||||
|
workspace_id = self._get_workspace_id()
|
||||||
|
if not workspace_id:
|
||||||
|
result.error_message = "获取 Workspace ID 失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
result.workspace_id = workspace_id
|
||||||
|
|
||||||
|
# 14. 选择 Workspace
|
||||||
|
self._log("18. 选择 Workspace...")
|
||||||
|
continue_url = self._select_workspace(workspace_id)
|
||||||
|
if not continue_url:
|
||||||
|
result.error_message = "选择 Workspace 失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 15. 跟随重定向链
|
||||||
|
self._log("19. 跟随重定向链...")
|
||||||
|
callback_url = self._follow_redirects(continue_url)
|
||||||
|
if not callback_url:
|
||||||
|
result.error_message = "跟随重定向链失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 16. 处理 OAuth 回调
|
||||||
|
self._log("20. 处理 OAuth 回调...")
|
||||||
|
token_info = self._handle_oauth_callback(callback_url)
|
||||||
|
if not token_info:
|
||||||
|
result.error_message = "处理 OAuth 回调失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 提取账户信息
|
||||||
|
result.account_id = token_info.get("account_id", "")
|
||||||
|
result.access_token = token_info.get("access_token", "")
|
||||||
|
result.refresh_token = token_info.get("refresh_token", "")
|
||||||
|
result.id_token = token_info.get("id_token", "")
|
||||||
|
result.password = self.password or "" # 保存密码(已注册账号为空)
|
||||||
|
|
||||||
|
# 设置来源标记
|
||||||
|
result.source = "register"
|
||||||
|
|
||||||
|
# 尝试获取 session_token 从 cookie
|
||||||
|
session_cookie = self.session.cookies.get("__Secure-next-auth.session-token")
|
||||||
|
if session_cookie:
|
||||||
|
self.session_token = session_cookie
|
||||||
|
result.session_token = session_cookie
|
||||||
|
self._log(f"获取到 Session Token")
|
||||||
|
|
||||||
|
# 17. 完成
|
||||||
|
self._log("=" * 60)
|
||||||
|
self._log("注册成功!")
|
||||||
|
self._log(f"邮箱: {result.email}")
|
||||||
|
self._log(f"Account ID: {result.account_id}")
|
||||||
|
self._log(f"Workspace ID: {result.workspace_id}")
|
||||||
|
self._log("=" * 60)
|
||||||
|
|
||||||
|
result.success = True
|
||||||
|
result.metadata = {
|
||||||
|
"email_service": self.email_service.service_type.value,
|
||||||
|
"proxy_used": self.proxy_url,
|
||||||
|
"registered_at": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._log(f"注册过程中发生未预期错误: {e}", "error")
|
||||||
|
result.error_message = str(e)
|
||||||
|
return result
|
||||||
+53
-358
@@ -12,7 +12,6 @@ import string
|
|||||||
from typing import Optional, Dict, Any, Tuple, Callable
|
from typing import Optional, Dict, Any, Tuple, Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from curl_cffi import requests as cffi_requests
|
from curl_cffi import requests as cffi_requests
|
||||||
|
|
||||||
from .openai.oauth import OAuthManager, OAuthStart
|
from .openai.oauth import OAuthManager, OAuthStart
|
||||||
@@ -155,9 +154,9 @@ class RegistrationEngine:
|
|||||||
logger.warning(f"记录任务日志失败: {e}")
|
logger.warning(f"记录任务日志失败: {e}")
|
||||||
|
|
||||||
# 根据级别记录到日志系统
|
# 根据级别记录到日志系统
|
||||||
if level == "error":
|
if level == 'error':
|
||||||
logger.error(message)
|
logger.error(message)
|
||||||
elif level == "warning":
|
elif level == 'warning':
|
||||||
logger.warning(message)
|
logger.warning(message)
|
||||||
else:
|
else:
|
||||||
logger.info(message)
|
logger.info(message)
|
||||||
@@ -171,7 +170,7 @@ class RegistrationEngine:
|
|||||||
try:
|
try:
|
||||||
return self.http_client.check_ip_location()
|
return self.http_client.check_ip_location()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log(f"检查 IP 地理位置失败: {e}", "error")
|
self._log(f"检查 IP 地理位置失败: {e}", 'error')
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
def _create_email(self) -> bool:
|
def _create_email(self) -> bool:
|
||||||
@@ -181,7 +180,7 @@ class RegistrationEngine:
|
|||||||
self.email_info = self.email_service.create_email()
|
self.email_info = self.email_service.create_email()
|
||||||
|
|
||||||
if not self.email_info or "email" not in self.email_info:
|
if not self.email_info or "email" not in self.email_info:
|
||||||
self._log("创建邮箱失败: 返回信息不完整", "error")
|
self._log("创建邮箱失败: 返回信息不完整", 'error')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self.email = self.email_info["email"]
|
self.email = self.email_info["email"]
|
||||||
@@ -189,7 +188,7 @@ class RegistrationEngine:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log(f"创建邮箱失败: {e}", "error")
|
self._log(f"创建邮箱失败: {e}", 'error')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _start_oauth(self) -> bool:
|
def _start_oauth(self) -> bool:
|
||||||
@@ -200,7 +199,7 @@ class RegistrationEngine:
|
|||||||
self._log(f"OAuth URL 已生成: {self.oauth_start.auth_url[:80]}...")
|
self._log(f"OAuth URL 已生成: {self.oauth_start.auth_url[:80]}...")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log(f"生成 OAuth URL 失败: {e}", "error")
|
self._log(f"生成 OAuth URL 失败: {e}", 'error')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _init_session(self) -> bool:
|
def _init_session(self) -> bool:
|
||||||
@@ -209,7 +208,7 @@ class RegistrationEngine:
|
|||||||
self.session = self.http_client.session
|
self.session = self.http_client.session
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log(f"初始化会话失败: {e}", "error")
|
self._log(f"初始化会话失败: {e}", 'error')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _get_device_id(self) -> Optional[str]:
|
def _get_device_id(self) -> Optional[str]:
|
||||||
@@ -235,12 +234,12 @@ class RegistrationEngine:
|
|||||||
|
|
||||||
self._log(
|
self._log(
|
||||||
f"获取 Device ID 失败: 未返回 oai-did Cookie (HTTP {response.status_code}, 第 {attempt}/{max_attempts} 次)",
|
f"获取 Device ID 失败: 未返回 oai-did Cookie (HTTP {response.status_code}, 第 {attempt}/{max_attempts} 次)",
|
||||||
"warning" if attempt < max_attempts else "error"
|
'warning' if attempt < max_attempts else 'error'
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log(
|
self._log(
|
||||||
f"获取 Device ID 失败: {e} (第 {attempt}/{max_attempts} 次)",
|
f"获取 Device ID 失败: {e} (第 {attempt}/{max_attempts} 次)",
|
||||||
"warning" if attempt < max_attempts else "error"
|
'warning' if attempt < max_attempts else 'error'
|
||||||
)
|
)
|
||||||
|
|
||||||
if attempt < max_attempts:
|
if attempt < max_attempts:
|
||||||
@@ -270,11 +269,11 @@ class RegistrationEngine:
|
|||||||
self._log(f"Sentinel token 获取成功")
|
self._log(f"Sentinel token 获取成功")
|
||||||
return sen_token
|
return sen_token
|
||||||
else:
|
else:
|
||||||
self._log(f"Sentinel 检查失败: {response.status_code}", "warning")
|
self._log(f"Sentinel 检查失败: {response.status_code}", 'warning')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log(f"Sentinel 检查异常: {e}", "warning")
|
self._log(f"Sentinel 检查异常: {e}", 'warning')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _submit_signup_form(self, did: str, sen_token: Optional[str]) -> SignupFormResult:
|
def _submit_signup_form(self, did: str, sen_token: Optional[str]) -> SignupFormResult:
|
||||||
@@ -286,6 +285,7 @@ class RegistrationEngine:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
signup_body = f'{{"username":{{"value":"{self.email}","kind":"email"}},"screen_hint":"signup"}}'
|
signup_body = f'{{"username":{{"value":"{self.email}","kind":"email"}},"screen_hint":"signup"}}'
|
||||||
|
#signup_body = f'{{"username":{{"value":"{self.email}","kind":"email"}},"screen_hint":"login_or_signup"}}'
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"referer": "https://auth.openai.com/create-account",
|
"referer": "https://auth.openai.com/create-account",
|
||||||
@@ -318,7 +318,7 @@ class RegistrationEngine:
|
|||||||
self._log(f"响应页面类型: {page_type}")
|
self._log(f"响应页面类型: {page_type}")
|
||||||
|
|
||||||
# 判断是否为已注册账号
|
# 判断是否为已注册账号
|
||||||
is_existing = page_type == OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]
|
is_existing = page_type != OPENAI_PAGE_TYPES["PASSWORD_REGISTRATION"]
|
||||||
|
|
||||||
if is_existing:
|
if is_existing:
|
||||||
self._log(f"检测到已注册账号,将自动切换到登录流程")
|
self._log(f"检测到已注册账号,将自动切换到登录流程")
|
||||||
@@ -332,12 +332,12 @@ class RegistrationEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as parse_error:
|
except Exception as parse_error:
|
||||||
self._log(f"解析响应失败: {parse_error}", "warning")
|
self._log(f"解析响应失败: {parse_error}", 'warning')
|
||||||
# 无法解析,默认成功
|
# 无法解析,默认成功
|
||||||
return SignupFormResult(success=True)
|
return SignupFormResult(success=True)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log(f"提交注册表单失败: {e}", "error")
|
self._log(f"提交注册表单失败: {e}", 'error')
|
||||||
return SignupFormResult(success=False, error_message=str(e))
|
return SignupFormResult(success=False, error_message=str(e))
|
||||||
|
|
||||||
def _register_password(self) -> Tuple[bool, Optional[str]]:
|
def _register_password(self) -> Tuple[bool, Optional[str]]:
|
||||||
@@ -367,18 +367,17 @@ class RegistrationEngine:
|
|||||||
self._log(f"提交密码状态: {response.status_code}")
|
self._log(f"提交密码状态: {response.status_code}")
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
error_text = response.text[:500]
|
self._log(f"密码注册失败: {response.json().get('error', {}).get('message', "")}", 'warning')
|
||||||
self._log(f"密码注册失败: {error_text}", "warning")
|
|
||||||
|
|
||||||
# 解析错误信息,判断是否是邮箱已注册
|
# 解析错误信息,判断是否是邮箱已注册
|
||||||
try:
|
try:
|
||||||
error_json = response.json()
|
error_json = response.json()
|
||||||
error_msg = error_json.get("error", {}).get("message", "")
|
error_msg = error_json.get('error', {}).get('message', "")
|
||||||
error_code = error_json.get("error", {}).get("code", "")
|
error_code = error_json.get('error', {}).get('code', "")
|
||||||
|
|
||||||
# 检测邮箱已注册的情况
|
# 检测邮箱已注册的情况
|
||||||
if "already" in error_msg.lower() or "exists" in error_msg.lower() or error_code == "user_exists":
|
if "already" in error_msg.lower() or "exists" in error_msg.lower() or "again" in error_msg.lower() or error_code == "user_exists" or error_code == "invalid_request":
|
||||||
self._log(f"邮箱 {self.email} 可能已在 OpenAI 注册过", "error")
|
self._log(f"邮箱 {self.email} 可能已在 OpenAI 注册过", 'error')
|
||||||
# 标记此邮箱为已注册状态
|
# 标记此邮箱为已注册状态
|
||||||
self._mark_email_as_registered()
|
self._mark_email_as_registered()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -389,27 +388,38 @@ class RegistrationEngine:
|
|||||||
return True, password
|
return True, password
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log(f"密码注册失败: {e}", "error")
|
self._log(f"密码注册失败: {e}", 'error')
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _mark_email_as_registered(self):
|
def _mark_email_as_registered(self):
|
||||||
"""标记邮箱为已注册状态(用于防止重复尝试)"""
|
"""标记邮箱为已注册状态(OpenAI 侧已存在该账号)"""
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
# 检查是否已存在该邮箱的记录
|
|
||||||
existing = crud.get_account_by_email(db, self.email)
|
existing = crud.get_account_by_email(db, self.email)
|
||||||
if not existing:
|
if existing:
|
||||||
# 创建一个失败记录,标记该邮箱已注册过
|
# 数据库中已有该账号,更新 extra_data 标记
|
||||||
|
extra = existing.extra_data or {}
|
||||||
|
extra["openai_already_registered"] = True
|
||||||
|
crud.update_account(
|
||||||
|
db,
|
||||||
|
existing.id,
|
||||||
|
extra_data=extra
|
||||||
|
)
|
||||||
|
self._log(f"已更新数据库中 {self.email} 的已注册标记")
|
||||||
|
else:
|
||||||
|
# 数据库中不存在,创建失败记录并标记
|
||||||
crud.create_account(
|
crud.create_account(
|
||||||
db,
|
db,
|
||||||
email=self.email,
|
email=self.email,
|
||||||
password="", # 空密码表示未成功注册
|
password="",
|
||||||
email_service=self.email_service.service_type.value,
|
email_service=self.email_service.service_type.value,
|
||||||
email_service_id=self.email_info.get("service_id") if self.email_info else None,
|
email_service_id=self.email_info.get("service_id") if self.email_info else None,
|
||||||
status="failed",
|
status="failed",
|
||||||
extra_data={"register_failed_reason": "email_already_registered_on_openai"}
|
extra_data={"openai_already_registered": True}
|
||||||
)
|
)
|
||||||
self._log(f"已在数据库中标记邮箱 {self.email} 为已注册状态")
|
self._log(f"已在数据库中标记 {self.email} 为已注册状态")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"标记邮箱状态失败: {e}")
|
logger.warning(f"标记邮箱状态失败: {e}")
|
||||||
|
|
||||||
@@ -431,7 +441,7 @@ class RegistrationEngine:
|
|||||||
return response.status_code == 200
|
return response.status_code == 200
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log(f"发送验证码失败: {e}", "error")
|
self._log(f"发送验证码失败: {e}", 'error')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _get_verification_code(self) -> Optional[str]:
|
def _get_verification_code(self) -> Optional[str]:
|
||||||
@@ -452,11 +462,11 @@ class RegistrationEngine:
|
|||||||
self._log(f"成功获取验证码: {code}")
|
self._log(f"成功获取验证码: {code}")
|
||||||
return code
|
return code
|
||||||
else:
|
else:
|
||||||
self._log("等待验证码超时", "error")
|
self._log("等待验证码超时", 'error')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log(f"获取验证码失败: {e}", "error")
|
self._log(f"获取验证码失败: {e}", 'error')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _validate_verification_code(self, code: str) -> bool:
|
def _validate_verification_code(self, code: str) -> bool:
|
||||||
@@ -478,7 +488,7 @@ class RegistrationEngine:
|
|||||||
return response.status_code == 200
|
return response.status_code == 200
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log(f"验证验证码失败: {e}", "error")
|
self._log(f"验证验证码失败: {e}", 'error')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _create_user_account(self) -> bool:
|
def _create_user_account(self) -> bool:
|
||||||
@@ -501,343 +511,28 @@ class RegistrationEngine:
|
|||||||
self._log(f"账户创建状态: {response.status_code}")
|
self._log(f"账户创建状态: {response.status_code}")
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
self._log(f"账户创建失败: {response.text[:200]}", "warning")
|
self._log(f"账户创建失败: {response.text[:200]}", 'warning')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log(f"创建账户失败: {e}", "error")
|
self._log(f"创建账户失败: {e}", 'error')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _get_workspace_id(self) -> Optional[str]:
|
def _add_phone(self) -> bool:
|
||||||
"""获取 Workspace ID"""
|
"""获取 手机验证码"""
|
||||||
try:
|
phone_body = f'{{"code":"{code}"}}'
|
||||||
auth_cookie = self.session.cookies.get("oai-client-auth-session")
|
|
||||||
if not auth_cookie:
|
|
||||||
self._log("未能获取到授权 Cookie", "error")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 解码 JWT
|
|
||||||
import base64
|
|
||||||
import json as json_module
|
|
||||||
|
|
||||||
try:
|
|
||||||
segments = auth_cookie.split(".")
|
|
||||||
if len(segments) < 1:
|
|
||||||
self._log("授权 Cookie 格式错误", "error")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 解码第一个 segment
|
|
||||||
payload = segments[0]
|
|
||||||
pad = "=" * ((4 - (len(payload) % 4)) % 4)
|
|
||||||
decoded = base64.urlsafe_b64decode((payload + pad).encode("ascii"))
|
|
||||||
auth_json = json_module.loads(decoded.decode("utf-8"))
|
|
||||||
|
|
||||||
workspaces = auth_json.get("workspaces") or []
|
|
||||||
if not workspaces:
|
|
||||||
self._log("授权 Cookie 里没有 workspace 信息", "error")
|
|
||||||
return None
|
|
||||||
|
|
||||||
workspace_id = str((workspaces[0] or {}).get("id") or "").strip()
|
|
||||||
if not workspace_id:
|
|
||||||
self._log("无法解析 workspace_id", "error")
|
|
||||||
return None
|
|
||||||
|
|
||||||
self._log(f"Workspace ID: {workspace_id}")
|
|
||||||
return workspace_id
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self._log(f"解析授权 Cookie 失败: {e}", "error")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self._log(f"获取 Workspace ID 失败: {e}", "error")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _select_workspace(self, workspace_id: str) -> Optional[str]:
|
|
||||||
"""选择 Workspace"""
|
|
||||||
try:
|
|
||||||
select_body = f'{{"workspace_id":"{workspace_id}"}}'
|
|
||||||
|
|
||||||
response = self.session.post(
|
response = self.session.post(
|
||||||
OPENAI_API_ENDPOINTS["select_workspace"],
|
OPENAI_API_ENDPOINTS["add_phone"],
|
||||||
headers={
|
headers={
|
||||||
"referer": "https://auth.openai.com/sign-in-with-chatgpt/codex/consent",
|
"referer": "https://auth.openai.com/api/accounts/create_account",
|
||||||
|
"accept": "application/json",
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
},
|
},
|
||||||
data=select_body,
|
data=create_account_body,
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
self._log(f"选择 workspace 失败: {response.status_code}", "error")
|
|
||||||
self._log(f"响应: {response.text[:200]}", "warning")
|
|
||||||
return None
|
|
||||||
|
|
||||||
continue_url = str((response.json() or {}).get("continue_url") or "").strip()
|
|
||||||
if not continue_url:
|
|
||||||
self._log("workspace/select 响应里缺少 continue_url", "error")
|
|
||||||
return None
|
|
||||||
|
|
||||||
self._log(f"Continue URL: {continue_url[:100]}...")
|
|
||||||
return continue_url
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self._log(f"选择 Workspace 失败: {e}", "error")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _follow_redirects(self, start_url: str) -> Optional[str]:
|
|
||||||
"""跟随重定向链,寻找回调 URL"""
|
|
||||||
try:
|
|
||||||
current_url = start_url
|
|
||||||
max_redirects = 6
|
|
||||||
|
|
||||||
for i in range(max_redirects):
|
|
||||||
self._log(f"重定向 {i+1}/{max_redirects}: {current_url[:100]}...")
|
|
||||||
|
|
||||||
response = self.session.get(
|
|
||||||
current_url,
|
|
||||||
allow_redirects=False,
|
|
||||||
timeout=15
|
|
||||||
)
|
|
||||||
|
|
||||||
location = response.headers.get("Location") or ""
|
|
||||||
|
|
||||||
# 如果不是重定向状态码,停止
|
|
||||||
if response.status_code not in [301, 302, 303, 307, 308]:
|
|
||||||
self._log(f"非重定向状态码: {response.status_code}")
|
|
||||||
break
|
|
||||||
|
|
||||||
if not location:
|
|
||||||
self._log("重定向响应缺少 Location 头")
|
|
||||||
break
|
|
||||||
|
|
||||||
# 构建下一个 URL
|
|
||||||
import urllib.parse
|
|
||||||
next_url = urllib.parse.urljoin(current_url, location)
|
|
||||||
|
|
||||||
# 检查是否包含回调参数
|
|
||||||
if "code=" in next_url and "state=" in next_url:
|
|
||||||
self._log(f"找到回调 URL: {next_url[:100]}...")
|
|
||||||
return next_url
|
|
||||||
|
|
||||||
current_url = next_url
|
|
||||||
|
|
||||||
self._log("未能在重定向链中找到回调 URL", "error")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self._log(f"跟随重定向失败: {e}", "error")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _handle_oauth_callback(self, callback_url: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""处理 OAuth 回调"""
|
|
||||||
try:
|
|
||||||
if not self.oauth_start:
|
|
||||||
self._log("OAuth 流程未初始化", "error")
|
|
||||||
return None
|
|
||||||
|
|
||||||
self._log("处理 OAuth 回调...")
|
|
||||||
token_info = self.oauth_manager.handle_callback(
|
|
||||||
callback_url=callback_url,
|
|
||||||
expected_state=self.oauth_start.state,
|
|
||||||
code_verifier=self.oauth_start.code_verifier
|
|
||||||
)
|
|
||||||
|
|
||||||
self._log("OAuth 授权成功")
|
|
||||||
return token_info
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self._log(f"处理 OAuth 回调失败: {e}", "error")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def run(self) -> RegistrationResult:
|
|
||||||
"""
|
|
||||||
执行完整的注册流程
|
|
||||||
|
|
||||||
支持已注册账号自动登录:
|
|
||||||
- 如果检测到邮箱已注册,自动切换到登录流程
|
|
||||||
- 已注册账号跳过:设置密码、发送验证码、创建用户账户
|
|
||||||
- 共用步骤:获取验证码、验证验证码、Workspace 和 OAuth 回调
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
RegistrationResult: 注册结果
|
|
||||||
"""
|
|
||||||
result = RegistrationResult(success=False, logs=self.logs)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._log("=" * 60)
|
|
||||||
self._log("开始注册流程")
|
|
||||||
self._log("=" * 60)
|
|
||||||
|
|
||||||
# 1. 检查 IP 地理位置
|
|
||||||
self._log("1. 检查 IP 地理位置...")
|
|
||||||
ip_ok, location = self._check_ip_location()
|
|
||||||
if not ip_ok:
|
|
||||||
result.error_message = f"IP 地理位置不支持: {location}"
|
|
||||||
self._log(f"IP 检查失败: {location}", "error")
|
|
||||||
return result
|
|
||||||
|
|
||||||
self._log(f"IP 位置: {location}")
|
|
||||||
|
|
||||||
# 2. 创建邮箱
|
|
||||||
self._log("2. 创建邮箱...")
|
|
||||||
if not self._create_email():
|
|
||||||
result.error_message = "创建邮箱失败"
|
|
||||||
return result
|
|
||||||
|
|
||||||
result.email = self.email
|
|
||||||
|
|
||||||
# 3. 初始化会话
|
|
||||||
self._log("3. 初始化会话...")
|
|
||||||
if not self._init_session():
|
|
||||||
result.error_message = "初始化会话失败"
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 4. 开始 OAuth 流程
|
|
||||||
self._log("4. 开始 OAuth 授权流程...")
|
|
||||||
if not self._start_oauth():
|
|
||||||
result.error_message = "开始 OAuth 流程失败"
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 5. 获取 Device ID
|
|
||||||
self._log("5. 获取 Device ID...")
|
|
||||||
did = self._get_device_id()
|
|
||||||
if not did:
|
|
||||||
result.error_message = "获取 Device ID 失败"
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 6. 检查 Sentinel 拦截
|
|
||||||
self._log("6. 检查 Sentinel 拦截...")
|
|
||||||
sen_token = self._check_sentinel(did)
|
|
||||||
if sen_token:
|
|
||||||
self._log("Sentinel 检查通过")
|
|
||||||
else:
|
|
||||||
self._log("Sentinel 检查失败或未启用", "warning")
|
|
||||||
|
|
||||||
# 7. 提交注册表单 + 解析响应判断账号状态
|
|
||||||
self._log("7. 提交注册表单...")
|
|
||||||
signup_result = self._submit_signup_form(did, sen_token)
|
|
||||||
if not signup_result.success:
|
|
||||||
result.error_message = f"提交注册表单失败: {signup_result.error_message}"
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 8. [已注册账号跳过] 注册密码
|
|
||||||
if self._is_existing_account:
|
|
||||||
self._log("8. [已注册账号] 跳过密码设置,OTP 已自动发送")
|
|
||||||
else:
|
|
||||||
self._log("8. 注册密码...")
|
|
||||||
password_ok, password = self._register_password()
|
|
||||||
if not password_ok:
|
|
||||||
result.error_message = "注册密码失败"
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 9. [已注册账号跳过] 发送验证码
|
|
||||||
if self._is_existing_account:
|
|
||||||
self._log("9. [已注册账号] 跳过发送验证码,使用自动发送的 OTP")
|
|
||||||
# 已注册账号的 OTP 在提交表单时已自动发送,记录时间戳
|
|
||||||
self._otp_sent_at = time.time()
|
|
||||||
else:
|
|
||||||
self._log("9. 发送验证码...")
|
|
||||||
if not self._send_verification_code():
|
|
||||||
result.error_message = "发送验证码失败"
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 10. 获取验证码
|
|
||||||
self._log("10. 等待验证码...")
|
|
||||||
code = self._get_verification_code()
|
|
||||||
if not code:
|
|
||||||
result.error_message = "获取验证码失败"
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 11. 验证验证码
|
|
||||||
self._log("11. 验证验证码...")
|
|
||||||
if not self._validate_verification_code(code):
|
|
||||||
result.error_message = "验证验证码失败"
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 12. [已注册账号跳过] 创建用户账户
|
|
||||||
if self._is_existing_account:
|
|
||||||
self._log("12. [已注册账号] 跳过创建用户账户")
|
|
||||||
else:
|
|
||||||
self._log("12. 创建用户账户...")
|
|
||||||
if not self._create_user_account():
|
|
||||||
result.error_message = "创建用户账户失败"
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 13. 获取 Workspace ID
|
|
||||||
self._log("13. 获取 Workspace ID...")
|
|
||||||
workspace_id = self._get_workspace_id()
|
|
||||||
if not workspace_id:
|
|
||||||
result.error_message = "获取 Workspace ID 失败"
|
|
||||||
return result
|
|
||||||
|
|
||||||
result.workspace_id = workspace_id
|
|
||||||
|
|
||||||
# 14. 选择 Workspace
|
|
||||||
self._log("14. 选择 Workspace...")
|
|
||||||
continue_url = self._select_workspace(workspace_id)
|
|
||||||
if not continue_url:
|
|
||||||
result.error_message = "选择 Workspace 失败"
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 15. 跟随重定向链
|
|
||||||
self._log("15. 跟随重定向链...")
|
|
||||||
callback_url = self._follow_redirects(continue_url)
|
|
||||||
if not callback_url:
|
|
||||||
result.error_message = "跟随重定向链失败"
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 16. 处理 OAuth 回调
|
|
||||||
self._log("16. 处理 OAuth 回调...")
|
|
||||||
token_info = self._handle_oauth_callback(callback_url)
|
|
||||||
if not token_info:
|
|
||||||
result.error_message = "处理 OAuth 回调失败"
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 提取账户信息
|
|
||||||
result.account_id = token_info.get("account_id", "")
|
|
||||||
result.access_token = token_info.get("access_token", "")
|
|
||||||
result.refresh_token = token_info.get("refresh_token", "")
|
|
||||||
result.id_token = token_info.get("id_token", "")
|
|
||||||
result.password = self.password or "" # 保存密码(已注册账号为空)
|
|
||||||
|
|
||||||
# 设置来源标记
|
|
||||||
result.source = "login" if self._is_existing_account else "register"
|
|
||||||
|
|
||||||
# 尝试获取 session_token 从 cookie
|
|
||||||
session_cookie = self.session.cookies.get("__Secure-next-auth.session-token")
|
|
||||||
if session_cookie:
|
|
||||||
self.session_token = session_cookie
|
|
||||||
result.session_token = session_cookie
|
|
||||||
self._log(f"获取到 Session Token")
|
|
||||||
|
|
||||||
# 17. 完成
|
|
||||||
self._log("=" * 60)
|
|
||||||
if self._is_existing_account:
|
|
||||||
self._log("登录成功! (已注册账号)")
|
|
||||||
else:
|
|
||||||
self._log("注册成功!")
|
|
||||||
self._log(f"邮箱: {result.email}")
|
|
||||||
self._log(f"Account ID: {result.account_id}")
|
|
||||||
self._log(f"Workspace ID: {result.workspace_id}")
|
|
||||||
self._log("=" * 60)
|
|
||||||
|
|
||||||
result.success = True
|
|
||||||
result.metadata = {
|
|
||||||
"email_service": self.email_service.service_type.value,
|
|
||||||
"proxy_used": self.proxy_url,
|
|
||||||
"registered_at": datetime.now().isoformat(),
|
|
||||||
"is_existing_account": self._is_existing_account,
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self._log(f"注册过程中发生未预期错误: {e}", "error")
|
|
||||||
result.error_message = str(e)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def save_to_database(self, result: RegistrationResult) -> bool:
|
def save_to_database(self, result: RegistrationResult) -> bool:
|
||||||
"""
|
"""
|
||||||
保存注册结果到数据库
|
保存注册结果到数据库
|
||||||
@@ -879,5 +574,5 @@ class RegistrationEngine:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log(f"保存到数据库失败: {e}", "error")
|
self._log(f"保存到数据库失败: {e}", 'error')
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import base64
|
|||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from html.parser import HTMLParser
|
||||||
from typing import Any, Dict, List, Optional, Union, Callable
|
from typing import Any, Dict, List, Optional, Union, Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -568,3 +569,49 @@ class Timer:
|
|||||||
if self.start_time is not None:
|
if self.start_time is not None:
|
||||||
return time.time() - self.start_time
|
return time.time() - self.start_time
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
|
class BootstrapExtractor(HTMLParser):
|
||||||
|
"""内部解析器,专门提取 id="client-bootstrap" 的 script 内容"""
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._in_target = False
|
||||||
|
self.json_text = None
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag == 'script':
|
||||||
|
attrs_dict = dict(attrs)
|
||||||
|
if attrs_dict.get('id') == 'client-bootstrap':
|
||||||
|
self._in_target = True
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if tag == 'script' and self._in_target:
|
||||||
|
self._in_target = False
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
if self._in_target and self.json_text is None:
|
||||||
|
self.json_text = data.strip()
|
||||||
|
|
||||||
|
def extract_client_bootstrap_json(html: str):
|
||||||
|
"""
|
||||||
|
从 HTML 字符串中提取 id="client-bootstrap" 的 script 标签内容并解析为 JSON。
|
||||||
|
返回 dict 或 None(未找到或解析失败)。
|
||||||
|
"""
|
||||||
|
parser = BootstrapExtractor()
|
||||||
|
parser.feed(html)
|
||||||
|
if parser.json_text:
|
||||||
|
try:
|
||||||
|
return json.loads(parser.json_text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def base64_payload_decode(payload_b64):
|
||||||
|
import base64
|
||||||
|
import json as json_module
|
||||||
|
padding = 4 - (len(payload_b64) % 4)
|
||||||
|
if padding != 4:
|
||||||
|
payload_b64 += '=' * padding
|
||||||
|
|
||||||
|
# 解码(Base64URL 使用 - 和 _ 替代 + 和 /)
|
||||||
|
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
||||||
|
return json_module.loads(payload_bytes)
|
||||||
|
|||||||
@@ -721,3 +721,19 @@ def delete_tm_service(db: Session, service_id: int) -> bool:
|
|||||||
db.delete(svc)
|
db.delete(svc)
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def update_outlook_refresh_token(db: Session, service_id: int, email: str, new_refresh_token: str):
|
||||||
|
"""更新 EmailService.config 中指定邮箱的 refresh_token"""
|
||||||
|
service = db.query(EmailService).filter(EmailService.id == service_id).first()
|
||||||
|
if not service or not service.config:
|
||||||
|
return
|
||||||
|
config = dict(service.config)
|
||||||
|
# 单账户格式
|
||||||
|
if config.get("email", "").lower() == email.lower():
|
||||||
|
config["refresh_token"] = new_refresh_token
|
||||||
|
# 多账户列表格式
|
||||||
|
for acc in config.get("accounts", []):
|
||||||
|
if acc.get("email", "").lower() == email.lower():
|
||||||
|
acc["refresh_token"] = new_refresh_token
|
||||||
|
service.config = config
|
||||||
|
db.commit()
|
||||||
@@ -38,9 +38,7 @@ from .outlook.base import (
|
|||||||
from .outlook.account import OutlookAccount
|
from .outlook.account import OutlookAccount
|
||||||
from .outlook.providers import (
|
from .outlook.providers import (
|
||||||
OutlookProvider,
|
OutlookProvider,
|
||||||
IMAPOldProvider,
|
|
||||||
IMAPNewProvider,
|
IMAPNewProvider,
|
||||||
GraphAPIProvider,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -67,7 +65,5 @@ __all__ = [
|
|||||||
'ProviderStatus',
|
'ProviderStatus',
|
||||||
'OutlookAccount',
|
'OutlookAccount',
|
||||||
'OutlookProvider',
|
'OutlookProvider',
|
||||||
'IMAPOldProvider',
|
|
||||||
'IMAPNewProvider',
|
'IMAPNewProvider',
|
||||||
'GraphAPIProvider',
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Outlook 账户数据类
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -16,7 +16,6 @@ class OutlookAccount:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config(cls, config: Dict[str, Any]) -> "OutlookAccount":
|
def from_config(cls, config: Dict[str, Any]) -> "OutlookAccount":
|
||||||
"""从配置创建账户"""
|
|
||||||
return cls(
|
return cls(
|
||||||
email=config.get("email", ""),
|
email=config.get("email", ""),
|
||||||
password=config.get("password", ""),
|
password=config.get("password", ""),
|
||||||
@@ -25,15 +24,12 @@ class OutlookAccount:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def has_oauth(self) -> bool:
|
def has_oauth(self) -> bool:
|
||||||
"""是否支持 OAuth2"""
|
|
||||||
return bool(self.client_id and self.refresh_token)
|
return bool(self.client_id and self.refresh_token)
|
||||||
|
|
||||||
def validate(self) -> bool:
|
def validate(self) -> bool:
|
||||||
"""验证账户信息是否有效"""
|
|
||||||
return bool(self.email and self.password) or self.has_oauth()
|
return bool(self.email and self.password) or self.has_oauth()
|
||||||
|
|
||||||
def to_dict(self, include_sensitive: bool = False) -> Dict[str, Any]:
|
def to_dict(self, include_sensitive: bool = False) -> Dict[str, Any]:
|
||||||
"""转换为字典"""
|
|
||||||
result = {
|
result = {
|
||||||
"email": self.email,
|
"email": self.email,
|
||||||
"has_oauth": self.has_oauth(),
|
"has_oauth": self.has_oauth(),
|
||||||
@@ -47,5 +43,4 @@ class OutlookAccount:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""字符串表示"""
|
|
||||||
return f"OutlookAccount({self.email})"
|
return f"OutlookAccount({self.email})"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Outlook 服务基础定义
|
Outlook 邮箱服务基础定义
|
||||||
包含枚举类型和数据类
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -10,49 +9,38 @@ from typing import Optional, Dict, Any, List
|
|||||||
|
|
||||||
|
|
||||||
class ProviderType(str, Enum):
|
class ProviderType(str, Enum):
|
||||||
"""Outlook 提供者类型"""
|
"""Outlook 提供者类型(仅 IMAP_NEW)"""
|
||||||
IMAP_OLD = "imap_old" # 旧版 IMAP (outlook.office365.com)
|
IMAP_NEW = "imap_new"
|
||||||
IMAP_NEW = "imap_new" # 新版 IMAP (outlook.live.com)
|
|
||||||
GRAPH_API = "graph_api" # Microsoft Graph API
|
|
||||||
|
|
||||||
|
|
||||||
class TokenEndpoint(str, Enum):
|
class TokenEndpoint(str, Enum):
|
||||||
"""Token 端点"""
|
"""Token 端点"""
|
||||||
LIVE = "https://login.live.com/oauth20_token.srf"
|
|
||||||
CONSUMERS = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
|
CONSUMERS = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
|
||||||
COMMON = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
|
|
||||||
|
|
||||||
|
|
||||||
class IMAPServer(str, Enum):
|
|
||||||
"""IMAP 服务器"""
|
|
||||||
OLD = "outlook.office365.com"
|
|
||||||
NEW = "outlook.live.com"
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderStatus(str, Enum):
|
class ProviderStatus(str, Enum):
|
||||||
"""提供者状态"""
|
"""提供者状态"""
|
||||||
HEALTHY = "healthy" # 健康
|
HEALTHY = "healthy"
|
||||||
DEGRADED = "degraded" # 降级
|
DEGRADED = "degraded"
|
||||||
DISABLED = "disabled" # 禁用
|
DISABLED = "disabled"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EmailMessage:
|
class EmailMessage:
|
||||||
"""邮件消息数据类"""
|
"""邮件消息数据类"""
|
||||||
id: str # 消息 ID
|
id: str
|
||||||
subject: str # 主题
|
subject: str
|
||||||
sender: str # 发件人
|
sender: str
|
||||||
recipients: List[str] = field(default_factory=list) # 收件人列表
|
recipients: List[str] = field(default_factory=list)
|
||||||
body: str = "" # 正文内容
|
body: str = ""
|
||||||
body_preview: str = "" # 正文预览
|
body_preview: str = ""
|
||||||
received_at: Optional[datetime] = None # 接收时间
|
received_at: Optional[datetime] = None
|
||||||
received_timestamp: int = 0 # 接收时间戳
|
received_timestamp: int = 0
|
||||||
is_read: bool = False # 是否已读
|
is_read: bool = False
|
||||||
has_attachments: bool = False # 是否有附件
|
has_attachments: bool = False
|
||||||
raw_data: Optional[bytes] = None # 原始数据(用于调试)
|
raw_data: Optional[bytes] = None
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""转换为字典"""
|
|
||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"subject": self.subject,
|
"subject": self.subject,
|
||||||
@@ -71,19 +59,17 @@ class EmailMessage:
|
|||||||
class TokenInfo:
|
class TokenInfo:
|
||||||
"""Token 信息数据类"""
|
"""Token 信息数据类"""
|
||||||
access_token: str
|
access_token: str
|
||||||
expires_at: float # 过期时间戳
|
expires_at: float
|
||||||
token_type: str = "Bearer"
|
token_type: str = "Bearer"
|
||||||
scope: str = ""
|
scope: str = ""
|
||||||
refresh_token: Optional[str] = None
|
refresh_token: Optional[str] = None
|
||||||
|
|
||||||
def is_expired(self, buffer_seconds: int = 120) -> bool:
|
def is_expired(self, buffer_seconds: int = 120) -> bool:
|
||||||
"""检查 Token 是否已过期"""
|
|
||||||
import time
|
import time
|
||||||
return time.time() >= (self.expires_at - buffer_seconds)
|
return time.time() >= (self.expires_at - buffer_seconds)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_response(cls, data: Dict[str, Any], scope: str = "") -> "TokenInfo":
|
def from_response(cls, data: Dict[str, Any], scope: str = "") -> "TokenInfo":
|
||||||
"""从 API 响应创建"""
|
|
||||||
import time
|
import time
|
||||||
return cls(
|
return cls(
|
||||||
access_token=data.get("access_token", ""),
|
access_token=data.get("access_token", ""),
|
||||||
@@ -99,49 +85,42 @@ class ProviderHealth:
|
|||||||
"""提供者健康状态"""
|
"""提供者健康状态"""
|
||||||
provider_type: ProviderType
|
provider_type: ProviderType
|
||||||
status: ProviderStatus = ProviderStatus.HEALTHY
|
status: ProviderStatus = ProviderStatus.HEALTHY
|
||||||
failure_count: int = 0 # 连续失败次数
|
failure_count: int = 0
|
||||||
last_success: Optional[datetime] = None # 最后成功时间
|
last_success: Optional[datetime] = None
|
||||||
last_failure: Optional[datetime] = None # 最后失败时间
|
last_failure: Optional[datetime] = None
|
||||||
last_error: str = "" # 最后错误信息
|
last_error: str = ""
|
||||||
disabled_until: Optional[datetime] = None # 禁用截止时间
|
disabled_until: Optional[datetime] = None
|
||||||
|
|
||||||
def record_success(self):
|
def record_success(self):
|
||||||
"""记录成功"""
|
|
||||||
self.status = ProviderStatus.HEALTHY
|
self.status = ProviderStatus.HEALTHY
|
||||||
self.failure_count = 0
|
self.failure_count = 0
|
||||||
self.last_success = datetime.now()
|
self.last_success = datetime.now()
|
||||||
self.disabled_until = None
|
self.disabled_until = None
|
||||||
|
|
||||||
def record_failure(self, error: str):
|
def record_failure(self, error: str):
|
||||||
"""记录失败"""
|
|
||||||
self.failure_count += 1
|
self.failure_count += 1
|
||||||
self.last_failure = datetime.now()
|
self.last_failure = datetime.now()
|
||||||
self.last_error = error
|
self.last_error = error
|
||||||
|
|
||||||
def should_disable(self, threshold: int = 3) -> bool:
|
def should_disable(self, threshold: int = 3) -> bool:
|
||||||
"""判断是否应该禁用"""
|
|
||||||
return self.failure_count >= threshold
|
return self.failure_count >= threshold
|
||||||
|
|
||||||
def is_disabled(self) -> bool:
|
def is_disabled(self) -> bool:
|
||||||
"""检查是否被禁用"""
|
|
||||||
if self.disabled_until and datetime.now() < self.disabled_until:
|
if self.disabled_until and datetime.now() < self.disabled_until:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def disable(self, duration_seconds: int = 300):
|
def disable(self, duration_seconds: int = 300):
|
||||||
"""禁用提供者"""
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
self.status = ProviderStatus.DISABLED
|
self.status = ProviderStatus.DISABLED
|
||||||
self.disabled_until = datetime.now() + timedelta(seconds=duration_seconds)
|
self.disabled_until = datetime.now() + timedelta(seconds=duration_seconds)
|
||||||
|
|
||||||
def enable(self):
|
def enable(self):
|
||||||
"""启用提供者"""
|
|
||||||
self.status = ProviderStatus.HEALTHY
|
self.status = ProviderStatus.HEALTHY
|
||||||
self.disabled_until = None
|
self.disabled_until = None
|
||||||
self.failure_count = 0
|
self.failure_count = 0
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""转换为字典"""
|
|
||||||
return {
|
return {
|
||||||
"provider_type": self.provider_type.value,
|
"provider_type": self.provider_type.value,
|
||||||
"status": self.status.value,
|
"status": self.status.value,
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
健康检查和故障切换管理
|
健康检查管理(简化版,单 Provider)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from .base import ProviderType, ProviderHealth, ProviderStatus
|
from .base import ProviderHealth, ProviderStatus, ProviderType
|
||||||
from .providers.base import OutlookProvider
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -17,296 +15,49 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
class HealthChecker:
|
class HealthChecker:
|
||||||
"""
|
"""
|
||||||
健康检查管理器
|
单 Provider 健康检查器
|
||||||
跟踪各提供者的健康状态,管理故障切换
|
跟踪 IMAP_NEW 的健康状态
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
failure_threshold: int = 3,
|
failure_threshold: int = 3,
|
||||||
disable_duration: int = 300,
|
disable_duration: int = 300,
|
||||||
recovery_check_interval: int = 60,
|
|
||||||
):
|
):
|
||||||
"""
|
|
||||||
初始化健康检查器
|
|
||||||
|
|
||||||
Args:
|
|
||||||
failure_threshold: 连续失败次数阈值,超过后禁用
|
|
||||||
disable_duration: 禁用时长(秒)
|
|
||||||
recovery_check_interval: 恢复检查间隔(秒)
|
|
||||||
"""
|
|
||||||
self.failure_threshold = failure_threshold
|
self.failure_threshold = failure_threshold
|
||||||
self.disable_duration = disable_duration
|
self.disable_duration = disable_duration
|
||||||
self.recovery_check_interval = recovery_check_interval
|
self._health = ProviderHealth(provider_type=ProviderType.IMAP_NEW)
|
||||||
|
|
||||||
# 提供者健康状态: ProviderType -> ProviderHealth
|
|
||||||
self._health_status: Dict[ProviderType, ProviderHealth] = {}
|
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
# 初始化所有提供者的健康状态
|
def record_success(self):
|
||||||
for provider_type in ProviderType:
|
|
||||||
self._health_status[provider_type] = ProviderHealth(
|
|
||||||
provider_type=provider_type
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_health(self, provider_type: ProviderType) -> ProviderHealth:
|
|
||||||
"""获取提供者的健康状态"""
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return self._health_status.get(provider_type, ProviderHealth(provider_type=provider_type))
|
self._health.record_success()
|
||||||
|
|
||||||
def record_success(self, provider_type: ProviderType):
|
def record_failure(self, error: str):
|
||||||
"""记录成功操作"""
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
health = self._health_status.get(provider_type)
|
self._health.record_failure(error)
|
||||||
if health:
|
if self._health.should_disable(self.failure_threshold):
|
||||||
health.record_success()
|
self._health.disable(self.disable_duration)
|
||||||
logger.debug(f"{provider_type.value} 记录成功")
|
|
||||||
|
|
||||||
def record_failure(self, provider_type: ProviderType, error: str):
|
|
||||||
"""记录失败操作"""
|
|
||||||
with self._lock:
|
|
||||||
health = self._health_status.get(provider_type)
|
|
||||||
if health:
|
|
||||||
health.record_failure(error)
|
|
||||||
|
|
||||||
# 检查是否需要禁用
|
|
||||||
if health.should_disable(self.failure_threshold):
|
|
||||||
health.disable(self.disable_duration)
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"{provider_type.value} 已禁用 {self.disable_duration} 秒,"
|
f"IMAP_NEW 已禁用 {self.disable_duration}s,原因: {error}"
|
||||||
f"原因: {error}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def is_available(self, provider_type: ProviderType) -> bool:
|
def is_available(self) -> bool:
|
||||||
"""
|
with self._lock:
|
||||||
检查提供者是否可用
|
if self._health.is_disabled():
|
||||||
|
remaining = (
|
||||||
Args:
|
(self._health.disabled_until - datetime.now()).total_seconds()
|
||||||
provider_type: 提供者类型
|
if self._health.disabled_until
|
||||||
|
else 0
|
||||||
Returns:
|
|
||||||
是否可用
|
|
||||||
"""
|
|
||||||
health = self.get_health(provider_type)
|
|
||||||
|
|
||||||
# 检查是否被禁用
|
|
||||||
if health.is_disabled():
|
|
||||||
remaining = (health.disabled_until - datetime.now()).total_seconds()
|
|
||||||
logger.debug(
|
|
||||||
f"{provider_type.value} 已被禁用,剩余 {int(remaining)} 秒"
|
|
||||||
)
|
)
|
||||||
|
logger.debug(f"IMAP_NEW 已被禁用,剩余 {int(remaining)}s")
|
||||||
return False
|
return False
|
||||||
|
return self._health.status != ProviderStatus.DISABLED
|
||||||
|
|
||||||
return health.status != ProviderStatus.DISABLED
|
def reset(self):
|
||||||
|
|
||||||
def get_available_providers(
|
|
||||||
self,
|
|
||||||
priority_order: Optional[List[ProviderType]] = None,
|
|
||||||
) -> List[ProviderType]:
|
|
||||||
"""
|
|
||||||
获取可用的提供者列表
|
|
||||||
|
|
||||||
Args:
|
|
||||||
priority_order: 优先级顺序,默认为 [IMAP_NEW, IMAP_OLD, GRAPH_API]
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
可用的提供者列表
|
|
||||||
"""
|
|
||||||
if priority_order is None:
|
|
||||||
priority_order = [
|
|
||||||
ProviderType.IMAP_NEW,
|
|
||||||
ProviderType.IMAP_OLD,
|
|
||||||
ProviderType.GRAPH_API,
|
|
||||||
]
|
|
||||||
|
|
||||||
available = []
|
|
||||||
for provider_type in priority_order:
|
|
||||||
if self.is_available(provider_type):
|
|
||||||
available.append(provider_type)
|
|
||||||
|
|
||||||
return available
|
|
||||||
|
|
||||||
def get_next_available_provider(
|
|
||||||
self,
|
|
||||||
priority_order: Optional[List[ProviderType]] = None,
|
|
||||||
) -> Optional[ProviderType]:
|
|
||||||
"""
|
|
||||||
获取下一个可用的提供者
|
|
||||||
|
|
||||||
Args:
|
|
||||||
priority_order: 优先级顺序
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
可用的提供者类型,如果没有返回 None
|
|
||||||
"""
|
|
||||||
available = self.get_available_providers(priority_order)
|
|
||||||
return available[0] if available else None
|
|
||||||
|
|
||||||
def force_disable(self, provider_type: ProviderType, duration: Optional[int] = None):
|
|
||||||
"""
|
|
||||||
强制禁用提供者
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider_type: 提供者类型
|
|
||||||
duration: 禁用时长(秒),默认使用配置值
|
|
||||||
"""
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
health = self._health_status.get(provider_type)
|
self._health = ProviderHealth(provider_type=ProviderType.IMAP_NEW)
|
||||||
if health:
|
|
||||||
health.disable(duration or self.disable_duration)
|
|
||||||
logger.warning(f"{provider_type.value} 已强制禁用")
|
|
||||||
|
|
||||||
def force_enable(self, provider_type: ProviderType):
|
|
||||||
"""
|
|
||||||
强制启用提供者
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider_type: 提供者类型
|
|
||||||
"""
|
|
||||||
with self._lock:
|
|
||||||
health = self._health_status.get(provider_type)
|
|
||||||
if health:
|
|
||||||
health.enable()
|
|
||||||
logger.info(f"{provider_type.value} 已启用")
|
|
||||||
|
|
||||||
def get_all_health_status(self) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
获取所有提供者的健康状态
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
健康状态字典
|
|
||||||
"""
|
|
||||||
with self._lock:
|
|
||||||
return {
|
|
||||||
provider_type.value: health.to_dict()
|
|
||||||
for provider_type, health in self._health_status.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
def check_and_recover(self):
|
|
||||||
"""
|
|
||||||
检查并恢复被禁用的提供者
|
|
||||||
|
|
||||||
如果禁用时间已过,自动恢复提供者
|
|
||||||
"""
|
|
||||||
with self._lock:
|
|
||||||
for provider_type, health in self._health_status.items():
|
|
||||||
if health.is_disabled():
|
|
||||||
# 检查是否可以恢复
|
|
||||||
if health.disabled_until and datetime.now() >= health.disabled_until:
|
|
||||||
health.enable()
|
|
||||||
logger.info(f"{provider_type.value} 已自动恢复")
|
|
||||||
|
|
||||||
def reset_all(self):
|
|
||||||
"""重置所有提供者的健康状态"""
|
|
||||||
with self._lock:
|
|
||||||
for provider_type in ProviderType:
|
|
||||||
self._health_status[provider_type] = ProviderHealth(
|
|
||||||
provider_type=provider_type
|
|
||||||
)
|
|
||||||
logger.info("已重置所有提供者的健康状态")
|
|
||||||
|
|
||||||
|
|
||||||
class FailoverManager:
|
|
||||||
"""
|
|
||||||
故障切换管理器
|
|
||||||
管理提供者之间的自动切换
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
health_checker: HealthChecker,
|
|
||||||
priority_order: Optional[List[ProviderType]] = None,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
初始化故障切换管理器
|
|
||||||
|
|
||||||
Args:
|
|
||||||
health_checker: 健康检查器
|
|
||||||
priority_order: 提供者优先级顺序
|
|
||||||
"""
|
|
||||||
self.health_checker = health_checker
|
|
||||||
self.priority_order = priority_order or [
|
|
||||||
ProviderType.IMAP_NEW,
|
|
||||||
ProviderType.IMAP_OLD,
|
|
||||||
ProviderType.GRAPH_API,
|
|
||||||
]
|
|
||||||
|
|
||||||
# 当前使用的提供者索引
|
|
||||||
self._current_index = 0
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
def get_current_provider(self) -> Optional[ProviderType]:
|
|
||||||
"""
|
|
||||||
获取当前提供者
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
当前提供者类型,如果没有可用的返回 None
|
|
||||||
"""
|
|
||||||
available = self.health_checker.get_available_providers(self.priority_order)
|
|
||||||
if not available:
|
|
||||||
return None
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
# 尝试使用当前索引
|
|
||||||
if self._current_index < len(available):
|
|
||||||
return available[self._current_index]
|
|
||||||
return available[0]
|
|
||||||
|
|
||||||
def switch_to_next(self) -> Optional[ProviderType]:
|
|
||||||
"""
|
|
||||||
切换到下一个提供者
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
下一个提供者类型,如果没有可用的返回 None
|
|
||||||
"""
|
|
||||||
available = self.health_checker.get_available_providers(self.priority_order)
|
|
||||||
if not available:
|
|
||||||
return None
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
self._current_index = (self._current_index + 1) % len(available)
|
|
||||||
next_provider = available[self._current_index]
|
|
||||||
logger.info(f"切换到提供者: {next_provider.value}")
|
|
||||||
return next_provider
|
|
||||||
|
|
||||||
def on_provider_success(self, provider_type: ProviderType):
|
|
||||||
"""
|
|
||||||
提供者成功时调用
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider_type: 提供者类型
|
|
||||||
"""
|
|
||||||
self.health_checker.record_success(provider_type)
|
|
||||||
|
|
||||||
# 重置索引到成功的提供者
|
|
||||||
with self._lock:
|
|
||||||
available = self.health_checker.get_available_providers(self.priority_order)
|
|
||||||
if provider_type in available:
|
|
||||||
self._current_index = available.index(provider_type)
|
|
||||||
|
|
||||||
def on_provider_failure(self, provider_type: ProviderType, error: str):
|
|
||||||
"""
|
|
||||||
提供者失败时调用
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider_type: 提供者类型
|
|
||||||
error: 错误信息
|
|
||||||
"""
|
|
||||||
self.health_checker.record_failure(provider_type, error)
|
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
def get_status(self) -> Dict[str, Any]:
|
||||||
"""
|
with self._lock:
|
||||||
获取故障切换状态
|
return self._health.to_dict()
|
||||||
|
|
||||||
Returns:
|
|
||||||
状态字典
|
|
||||||
"""
|
|
||||||
current = self.get_current_provider()
|
|
||||||
return {
|
|
||||||
"current_provider": current.value if current else None,
|
|
||||||
"priority_order": [p.value for p in self.priority_order],
|
|
||||||
"available_providers": [
|
|
||||||
p.value for p in self.health_checker.get_available_providers(self.priority_order)
|
|
||||||
],
|
|
||||||
"health_status": self.health_checker.get_all_health_status(),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,27 +3,18 @@ Outlook 提供者模块
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .base import OutlookProvider, ProviderConfig
|
from .base import OutlookProvider, ProviderConfig
|
||||||
from .imap_old import IMAPOldProvider
|
|
||||||
from .imap_new import IMAPNewProvider
|
from .imap_new import IMAPNewProvider
|
||||||
from .graph_api import GraphAPIProvider
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'OutlookProvider',
|
'OutlookProvider',
|
||||||
'ProviderConfig',
|
'ProviderConfig',
|
||||||
'IMAPOldProvider',
|
|
||||||
'IMAPNewProvider',
|
'IMAPNewProvider',
|
||||||
'GraphAPIProvider',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# 提供者注册表
|
|
||||||
PROVIDER_REGISTRY = {
|
PROVIDER_REGISTRY = {
|
||||||
'imap_old': IMAPOldProvider,
|
|
||||||
'imap_new': IMAPNewProvider,
|
'imap_new': IMAPNewProvider,
|
||||||
'graph_api': GraphAPIProvider,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_provider_class(provider_type: str):
|
def get_provider_class(provider_type: str):
|
||||||
"""获取提供者类"""
|
|
||||||
return PROVIDER_REGISTRY.get(provider_type)
|
return PROVIDER_REGISTRY.get(provider_type)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Outlook 提供者抽象基类
|
|||||||
import abc
|
import abc
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from ..base import ProviderType, EmailMessage, ProviderHealth, ProviderStatus
|
from ..base import ProviderType, EmailMessage, ProviderHealth, ProviderStatus
|
||||||
from ..account import OutlookAccount
|
from ..account import OutlookAccount
|
||||||
@@ -18,56 +18,36 @@ logger = logging.getLogger(__name__)
|
|||||||
class ProviderConfig:
|
class ProviderConfig:
|
||||||
"""提供者配置"""
|
"""提供者配置"""
|
||||||
timeout: int = 30
|
timeout: int = 30
|
||||||
max_retries: int = 3
|
|
||||||
proxy_url: Optional[str] = None
|
proxy_url: Optional[str] = None
|
||||||
|
service_id: Optional[int] = None
|
||||||
# 健康检查配置
|
|
||||||
health_failure_threshold: int = 3
|
health_failure_threshold: int = 3
|
||||||
health_disable_duration: int = 300 # 秒
|
health_disable_duration: int = 300
|
||||||
|
|
||||||
|
|
||||||
class OutlookProvider(abc.ABC):
|
class OutlookProvider(abc.ABC):
|
||||||
"""
|
"""Outlook 提供者抽象基类"""
|
||||||
Outlook 提供者抽象基类
|
|
||||||
定义所有提供者必须实现的接口
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
account: OutlookAccount,
|
account: OutlookAccount,
|
||||||
config: Optional[ProviderConfig] = None,
|
config: Optional[ProviderConfig] = None,
|
||||||
):
|
):
|
||||||
"""
|
|
||||||
初始化提供者
|
|
||||||
|
|
||||||
Args:
|
|
||||||
account: Outlook 账户
|
|
||||||
config: 提供者配置
|
|
||||||
"""
|
|
||||||
self.account = account
|
self.account = account
|
||||||
self.config = config or ProviderConfig()
|
self.config = config or ProviderConfig()
|
||||||
|
self._health = ProviderHealth(provider_type=ProviderType.IMAP_NEW)
|
||||||
# 健康状态
|
|
||||||
self._health = ProviderHealth(provider_type=self.provider_type)
|
|
||||||
|
|
||||||
# 连接状态
|
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._last_error: Optional[str] = None
|
self._last_error: Optional[str] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abc.abstractmethod
|
|
||||||
def provider_type(self) -> ProviderType:
|
def provider_type(self) -> ProviderType:
|
||||||
"""获取提供者类型"""
|
return ProviderType.IMAP_NEW
|
||||||
pass
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def health(self) -> ProviderHealth:
|
def health(self) -> ProviderHealth:
|
||||||
"""获取健康状态"""
|
|
||||||
return self._health
|
return self._health
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_healthy(self) -> bool:
|
def is_healthy(self) -> bool:
|
||||||
"""检查是否健康"""
|
|
||||||
return (
|
return (
|
||||||
self._health.status == ProviderStatus.HEALTHY
|
self._health.status == ProviderStatus.HEALTHY
|
||||||
and not self._health.is_disabled()
|
and not self._health.is_disabled()
|
||||||
@@ -75,22 +55,14 @@ class OutlookProvider(abc.ABC):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_connected(self) -> bool:
|
def is_connected(self) -> bool:
|
||||||
"""检查是否已连接"""
|
|
||||||
return self._connected
|
return self._connected
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def connect(self) -> bool:
|
def connect(self) -> bool:
|
||||||
"""
|
|
||||||
连接到服务
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
是否连接成功
|
|
||||||
"""
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def disconnect(self):
|
def disconnect(self):
|
||||||
"""断开连接"""
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
@@ -99,81 +71,44 @@ class OutlookProvider(abc.ABC):
|
|||||||
count: int = 20,
|
count: int = 20,
|
||||||
only_unseen: bool = True,
|
only_unseen: bool = True,
|
||||||
) -> List[EmailMessage]:
|
) -> List[EmailMessage]:
|
||||||
"""
|
|
||||||
获取最近的邮件
|
|
||||||
|
|
||||||
Args:
|
|
||||||
count: 获取数量
|
|
||||||
only_unseen: 是否只获取未读
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
邮件列表
|
|
||||||
"""
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def test_connection(self) -> bool:
|
def test_connection(self) -> bool:
|
||||||
"""
|
|
||||||
测试连接是否正常
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
连接是否正常
|
|
||||||
"""
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def wait_for_new_email_idle(self, timeout: int = 25) -> bool:
|
||||||
|
"""IMAP IDLE(默认不支持,子类可覆盖)"""
|
||||||
|
return False
|
||||||
|
|
||||||
def record_success(self):
|
def record_success(self):
|
||||||
"""记录成功操作"""
|
|
||||||
self._health.record_success()
|
self._health.record_success()
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
logger.debug(f"[{self.account.email}] {self.provider_type.value} 操作成功")
|
|
||||||
|
|
||||||
def record_failure(self, error: str):
|
def record_failure(self, error: str):
|
||||||
"""记录失败操作"""
|
|
||||||
self._health.record_failure(error)
|
self._health.record_failure(error)
|
||||||
self._last_error = error
|
self._last_error = error
|
||||||
|
|
||||||
# 检查是否需要禁用
|
|
||||||
if self._health.should_disable(self.config.health_failure_threshold):
|
if self._health.should_disable(self.config.health_failure_threshold):
|
||||||
self._health.disable(self.config.health_disable_duration)
|
self._health.disable(self.config.health_disable_duration)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[{self.account.email}] {self.provider_type.value} 已禁用 "
|
f"[{self.account.email}] IMAP_NEW 已禁用 "
|
||||||
f"{self.config.health_disable_duration} 秒,原因: {error}"
|
f"{self.config.health_disable_duration}s,原因: {error}"
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"[{self.account.email}] {self.provider_type.value} 操作失败 "
|
|
||||||
f"({self._health.failure_count}/{self.config.health_failure_threshold}): {error}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def check_health(self) -> bool:
|
def check_health(self) -> bool:
|
||||||
"""
|
|
||||||
检查健康状态
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
是否健康可用
|
|
||||||
"""
|
|
||||||
# 检查是否被禁用
|
|
||||||
if self._health.is_disabled():
|
if self._health.is_disabled():
|
||||||
logger.debug(
|
|
||||||
f"[{self.account.email}] {self.provider_type.value} 已被禁用,"
|
|
||||||
f"将在 {self._health.disabled_until} 后恢复"
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return self._health.status in (ProviderStatus.HEALTHY, ProviderStatus.DEGRADED)
|
return self._health.status in (ProviderStatus.HEALTHY, ProviderStatus.DEGRADED)
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
"""上下文管理器入口"""
|
|
||||||
self.connect()
|
self.connect()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
"""上下文管理器出口"""
|
|
||||||
self.disconnect()
|
self.disconnect()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""字符串表示"""
|
|
||||||
return f"{self.__class__.__name__}({self.account.email})"
|
return f"{self.__class__.__name__}({self.account.email})"
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
|
|||||||
@@ -1,250 +0,0 @@
|
|||||||
"""
|
|
||||||
Graph API 提供者
|
|
||||||
使用 Microsoft Graph REST API
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from typing import List, Optional
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from curl_cffi import requests as _requests
|
|
||||||
|
|
||||||
from ..base import ProviderType, EmailMessage
|
|
||||||
from ..account import OutlookAccount
|
|
||||||
from ..token_manager import TokenManager
|
|
||||||
from .base import OutlookProvider, ProviderConfig
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class GraphAPIProvider(OutlookProvider):
|
|
||||||
"""
|
|
||||||
Graph API 提供者
|
|
||||||
使用 Microsoft Graph REST API 获取邮件
|
|
||||||
需要 graph.microsoft.com/.default scope
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Graph API 端点
|
|
||||||
GRAPH_API_BASE = "https://graph.microsoft.com/v1.0"
|
|
||||||
MESSAGES_ENDPOINT = "/me/mailFolders/inbox/messages"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def provider_type(self) -> ProviderType:
|
|
||||||
return ProviderType.GRAPH_API
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
account: OutlookAccount,
|
|
||||||
config: Optional[ProviderConfig] = None,
|
|
||||||
):
|
|
||||||
super().__init__(account, config)
|
|
||||||
|
|
||||||
# Token 管理器
|
|
||||||
self._token_manager: Optional[TokenManager] = None
|
|
||||||
|
|
||||||
# 注意:Graph API 必须使用 OAuth2
|
|
||||||
if not account.has_oauth():
|
|
||||||
logger.warning(
|
|
||||||
f"[{self.account.email}] Graph API 提供者需要 OAuth2 配置 "
|
|
||||||
f"(client_id + refresh_token)"
|
|
||||||
)
|
|
||||||
|
|
||||||
def connect(self) -> bool:
|
|
||||||
"""
|
|
||||||
验证连接(获取 Token)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
是否连接成功
|
|
||||||
"""
|
|
||||||
if not self.account.has_oauth():
|
|
||||||
error = "Graph API 需要 OAuth2 配置"
|
|
||||||
self.record_failure(error)
|
|
||||||
logger.error(f"[{self.account.email}] {error}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not self._token_manager:
|
|
||||||
self._token_manager = TokenManager(
|
|
||||||
self.account,
|
|
||||||
ProviderType.GRAPH_API,
|
|
||||||
self.config.proxy_url,
|
|
||||||
self.config.timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 尝试获取 Token
|
|
||||||
token = self._token_manager.get_access_token()
|
|
||||||
if token:
|
|
||||||
self._connected = True
|
|
||||||
self.record_success()
|
|
||||||
logger.info(f"[{self.account.email}] Graph API 连接成功")
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def disconnect(self):
|
|
||||||
"""断开连接(清除状态)"""
|
|
||||||
self._connected = False
|
|
||||||
|
|
||||||
def get_recent_emails(
|
|
||||||
self,
|
|
||||||
count: int = 20,
|
|
||||||
only_unseen: bool = True,
|
|
||||||
) -> List[EmailMessage]:
|
|
||||||
"""
|
|
||||||
获取最近的邮件
|
|
||||||
|
|
||||||
Args:
|
|
||||||
count: 获取数量
|
|
||||||
only_unseen: 是否只获取未读
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
邮件列表
|
|
||||||
"""
|
|
||||||
if not self._connected:
|
|
||||||
if not self.connect():
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 获取 Access Token
|
|
||||||
token = self._token_manager.get_access_token()
|
|
||||||
if not token:
|
|
||||||
self.record_failure("无法获取 Access Token")
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 构建 API 请求
|
|
||||||
url = f"{self.GRAPH_API_BASE}{self.MESSAGES_ENDPOINT}"
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"$top": count,
|
|
||||||
"$select": "id,subject,from,toRecipients,receivedDateTime,isRead,hasAttachments,bodyPreview,body",
|
|
||||||
"$orderby": "receivedDateTime desc",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 只获取未读邮件
|
|
||||||
if only_unseen:
|
|
||||||
params["$filter"] = "isRead eq false"
|
|
||||||
|
|
||||||
# 构建代理配置
|
|
||||||
proxies = None
|
|
||||||
if self.config.proxy_url:
|
|
||||||
proxies = {"http": self.config.proxy_url, "https": self.config.proxy_url}
|
|
||||||
|
|
||||||
# 发送请求(curl_cffi 自动对 params 进行 URL 编码)
|
|
||||||
resp = _requests.get(
|
|
||||||
url,
|
|
||||||
params=params,
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {token}",
|
|
||||||
"Accept": "application/json",
|
|
||||||
"Prefer": "outlook.body-content-type='text'",
|
|
||||||
},
|
|
||||||
proxies=proxies,
|
|
||||||
timeout=self.config.timeout,
|
|
||||||
impersonate="chrome110",
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code == 401:
|
|
||||||
# Token 无 Graph 权限(client_id 未授权),清除缓存但不记录健康失败
|
|
||||||
# 避免因权限不足导致健康检查器禁用该提供者,影响其他账户
|
|
||||||
if self._token_manager:
|
|
||||||
self._token_manager.clear_cache()
|
|
||||||
self._connected = False
|
|
||||||
logger.warning(f"[{self.account.email}] Graph API 返回 401,client_id 可能无 Graph 权限,跳过")
|
|
||||||
return []
|
|
||||||
|
|
||||||
if resp.status_code != 200:
|
|
||||||
error_body = resp.text[:200]
|
|
||||||
self.record_failure(f"HTTP {resp.status_code}: {error_body}")
|
|
||||||
logger.error(f"[{self.account.email}] Graph API 请求失败: HTTP {resp.status_code}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
# 解析邮件
|
|
||||||
messages = data.get("value", [])
|
|
||||||
emails = []
|
|
||||||
|
|
||||||
for msg in messages:
|
|
||||||
try:
|
|
||||||
email_msg = self._parse_graph_message(msg)
|
|
||||||
if email_msg:
|
|
||||||
emails.append(email_msg)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[{self.account.email}] 解析 Graph API 邮件失败: {e}")
|
|
||||||
|
|
||||||
self.record_success()
|
|
||||||
return emails
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.record_failure(str(e))
|
|
||||||
logger.error(f"[{self.account.email}] Graph API 获取邮件失败: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _parse_graph_message(self, msg: dict) -> Optional[EmailMessage]:
|
|
||||||
"""
|
|
||||||
解析 Graph API 消息
|
|
||||||
|
|
||||||
Args:
|
|
||||||
msg: Graph API 消息对象
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
EmailMessage 对象
|
|
||||||
"""
|
|
||||||
# 解析发件人
|
|
||||||
from_info = msg.get("from", {})
|
|
||||||
sender_info = from_info.get("emailAddress", {})
|
|
||||||
sender = sender_info.get("address", "")
|
|
||||||
|
|
||||||
# 解析收件人
|
|
||||||
recipients = []
|
|
||||||
for recipient in msg.get("toRecipients", []):
|
|
||||||
addr_info = recipient.get("emailAddress", {})
|
|
||||||
addr = addr_info.get("address", "")
|
|
||||||
if addr:
|
|
||||||
recipients.append(addr)
|
|
||||||
|
|
||||||
# 解析日期
|
|
||||||
received_at = None
|
|
||||||
received_timestamp = 0
|
|
||||||
try:
|
|
||||||
date_str = msg.get("receivedDateTime", "")
|
|
||||||
if date_str:
|
|
||||||
# ISO 8601 格式
|
|
||||||
received_at = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
|
||||||
received_timestamp = int(received_at.timestamp())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 获取正文
|
|
||||||
body_info = msg.get("body", {})
|
|
||||||
body = body_info.get("content", "")
|
|
||||||
body_preview = msg.get("bodyPreview", "")
|
|
||||||
|
|
||||||
return EmailMessage(
|
|
||||||
id=msg.get("id", ""),
|
|
||||||
subject=msg.get("subject", ""),
|
|
||||||
sender=sender,
|
|
||||||
recipients=recipients,
|
|
||||||
body=body,
|
|
||||||
body_preview=body_preview,
|
|
||||||
received_at=received_at,
|
|
||||||
received_timestamp=received_timestamp,
|
|
||||||
is_read=msg.get("isRead", False),
|
|
||||||
has_attachments=msg.get("hasAttachments", False),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_connection(self) -> bool:
|
|
||||||
"""
|
|
||||||
测试 Graph API 连接
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
连接是否正常
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# 尝试获取一封邮件来测试连接
|
|
||||||
emails = self.get_recent_emails(count=1, only_unseen=False)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[{self.account.email}] Graph API 连接测试失败: {e}")
|
|
||||||
return False
|
|
||||||
@@ -1,39 +1,101 @@
|
|||||||
"""
|
"""
|
||||||
新版 IMAP 提供者
|
新版 IMAP 提供者
|
||||||
使用 outlook.live.com 服务器和 login.microsoftonline.com/consumers Token 端点
|
使用 outlook.live.com:993 + consumers Token 端点
|
||||||
|
引入进程级 IMAPConnectionPool 连接复用和 IMAP IDLE
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import email
|
import email
|
||||||
import imaplib
|
import imaplib
|
||||||
import logging
|
import logging
|
||||||
|
import select
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from email.header import decode_header
|
from email.header import decode_header
|
||||||
from email.utils import parsedate_to_datetime
|
from email.utils import parsedate_to_datetime
|
||||||
from typing import List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from ..base import ProviderType, EmailMessage
|
from ..base import EmailMessage
|
||||||
from ..account import OutlookAccount
|
from ..account import OutlookAccount
|
||||||
from ..token_manager import TokenManager
|
from ..token_manager import TokenManager
|
||||||
from .base import OutlookProvider, ProviderConfig
|
from .base import OutlookProvider, ProviderConfig
|
||||||
from .imap_old import IMAPOldProvider
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class IMAPNewProvider(OutlookProvider):
|
class IMAPConnectionPool:
|
||||||
"""
|
"""进程级 IMAP 连接池,按 email 复用 IMAP4_SSL 连接"""
|
||||||
新版 IMAP 提供者
|
|
||||||
使用 outlook.live.com:993 和 login.microsoftonline.com/consumers Token 端点
|
|
||||||
需要 IMAP.AccessAsUser.All scope
|
|
||||||
"""
|
|
||||||
|
|
||||||
# IMAP 服务器配置
|
|
||||||
IMAP_HOST = "outlook.live.com"
|
IMAP_HOST = "outlook.live.com"
|
||||||
IMAP_PORT = 993
|
IMAP_PORT = 993
|
||||||
|
|
||||||
@property
|
def __init__(self):
|
||||||
def provider_type(self) -> ProviderType:
|
self._connections: Dict[str, imaplib.IMAP4_SSL] = {}
|
||||||
return ProviderType.IMAP_NEW
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def get_connection(
|
||||||
|
self,
|
||||||
|
email_addr: str,
|
||||||
|
token: str,
|
||||||
|
timeout: int = 30,
|
||||||
|
) -> imaplib.IMAP4_SSL:
|
||||||
|
"""获取或新建 IMAP 连接"""
|
||||||
|
# 先在锁内检查现有连接
|
||||||
|
with self._lock:
|
||||||
|
conn = self._connections.get(email_addr)
|
||||||
|
if conn:
|
||||||
|
try:
|
||||||
|
conn.noop()
|
||||||
|
return conn
|
||||||
|
except Exception:
|
||||||
|
self._close_one(email_addr)
|
||||||
|
# 标记为「建连中」,防止重复建连
|
||||||
|
self._connections[email_addr] = None
|
||||||
|
|
||||||
|
# 锁外建立新连接(耗时操作不持锁)
|
||||||
|
try:
|
||||||
|
new_conn = imaplib.IMAP4_SSL(self.IMAP_HOST, self.IMAP_PORT, timeout=timeout)
|
||||||
|
auth_str = f"user={email_addr}\x01auth=Bearer {token}\x01\x01"
|
||||||
|
new_conn.authenticate("XOAUTH2", lambda _: auth_str.encode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
with self._lock:
|
||||||
|
# 建连失败,清除占位
|
||||||
|
if self._connections.get(email_addr) is None:
|
||||||
|
del self._connections[email_addr]
|
||||||
|
raise
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._connections[email_addr] = new_conn
|
||||||
|
logger.debug(f"[{email_addr}] IMAP 新连接已建立")
|
||||||
|
return new_conn
|
||||||
|
|
||||||
|
def invalidate(self, email_addr: str):
|
||||||
|
"""废弃连接(认证失败或连接异常时调用)"""
|
||||||
|
with self._lock:
|
||||||
|
self._close_one(email_addr)
|
||||||
|
|
||||||
|
def _close_one(self, email_addr: str):
|
||||||
|
conn = self._connections.pop(email_addr, None)
|
||||||
|
if conn:
|
||||||
|
try:
|
||||||
|
conn.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# 模块级单例连接池
|
||||||
|
_imap_pool = IMAPConnectionPool()
|
||||||
|
|
||||||
|
|
||||||
|
class IMAPNewProvider(OutlookProvider):
|
||||||
|
"""
|
||||||
|
新版 IMAP 提供者
|
||||||
|
通过连接池复用连接,支持 IMAP IDLE
|
||||||
|
"""
|
||||||
|
|
||||||
|
IMAP_HOST = "outlook.live.com"
|
||||||
|
IMAP_PORT = 993
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -41,160 +103,159 @@ class IMAPNewProvider(OutlookProvider):
|
|||||||
config: Optional[ProviderConfig] = None,
|
config: Optional[ProviderConfig] = None,
|
||||||
):
|
):
|
||||||
super().__init__(account, config)
|
super().__init__(account, config)
|
||||||
|
|
||||||
# IMAP 连接
|
|
||||||
self._conn: Optional[imaplib.IMAP4_SSL] = None
|
self._conn: Optional[imaplib.IMAP4_SSL] = None
|
||||||
|
|
||||||
# Token 管理器
|
|
||||||
self._token_manager: Optional[TokenManager] = None
|
self._token_manager: Optional[TokenManager] = None
|
||||||
|
self._idle_tag_counter = 0
|
||||||
|
self._idle_tag_lock = threading.Lock()
|
||||||
|
|
||||||
# 注意:新版 IMAP 必须使用 OAuth2
|
|
||||||
if not account.has_oauth():
|
if not account.has_oauth():
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[{self.account.email}] 新版 IMAP 提供者需要 OAuth2 配置 "
|
f"[{self.account.email}] IMAP_NEW 需要 OAuth2 配置 (client_id + refresh_token)"
|
||||||
f"(client_id + refresh_token)"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _get_token_manager(self) -> TokenManager:
|
||||||
|
if not self._token_manager:
|
||||||
|
self._token_manager = TokenManager(
|
||||||
|
self.account,
|
||||||
|
proxy_url=self.config.proxy_url,
|
||||||
|
timeout=self.config.timeout,
|
||||||
|
service_id=self.config.service_id,
|
||||||
|
)
|
||||||
|
return self._token_manager
|
||||||
|
|
||||||
|
def _next_idle_tag(self) -> str:
|
||||||
|
"""生成唯一 IDLE tag(避免使用私有 _new_tag)"""
|
||||||
|
with self._idle_tag_lock:
|
||||||
|
self._idle_tag_counter += 1
|
||||||
|
return f"IDLE{self._idle_tag_counter:04d}"
|
||||||
|
|
||||||
def connect(self) -> bool:
|
def connect(self) -> bool:
|
||||||
"""
|
"""从连接池获取连接"""
|
||||||
连接到 IMAP 服务器
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
是否连接成功
|
|
||||||
"""
|
|
||||||
if self._connected and self._conn:
|
|
||||||
try:
|
|
||||||
self._conn.noop()
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
self.disconnect()
|
|
||||||
|
|
||||||
# 新版 IMAP 必须使用 OAuth2,无 OAuth 时静默跳过,不记录健康失败
|
|
||||||
if not self.account.has_oauth():
|
if not self.account.has_oauth():
|
||||||
logger.debug(f"[{self.account.email}] 跳过 IMAP_NEW(无 OAuth)")
|
logger.debug(f"[{self.account.email}] 跳过 IMAP_NEW(无 OAuth)")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.debug(f"[{self.account.email}] 正在连接 IMAP ({self.IMAP_HOST})...")
|
tm = self._get_token_manager()
|
||||||
|
token = tm.get_access_token()
|
||||||
# 创建连接
|
|
||||||
self._conn = imaplib.IMAP4_SSL(
|
|
||||||
self.IMAP_HOST,
|
|
||||||
self.IMAP_PORT,
|
|
||||||
timeout=self.config.timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
# XOAUTH2 认证
|
|
||||||
if self._authenticate_xoauth2():
|
|
||||||
self._connected = True
|
|
||||||
self.record_success()
|
|
||||||
logger.info(f"[{self.account.email}] 新版 IMAP 连接成功 (XOAUTH2)")
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.disconnect()
|
|
||||||
self.record_failure(str(e))
|
|
||||||
logger.error(f"[{self.account.email}] 新版 IMAP 连接失败: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _authenticate_xoauth2(self) -> bool:
|
|
||||||
"""
|
|
||||||
使用 XOAUTH2 认证
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
是否认证成功
|
|
||||||
"""
|
|
||||||
if not self._token_manager:
|
|
||||||
self._token_manager = TokenManager(
|
|
||||||
self.account,
|
|
||||||
ProviderType.IMAP_NEW,
|
|
||||||
self.config.proxy_url,
|
|
||||||
self.config.timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 获取 Access Token
|
|
||||||
token = self._token_manager.get_access_token()
|
|
||||||
if not token:
|
if not token:
|
||||||
logger.error(f"[{self.account.email}] 获取 IMAP Token 失败")
|
logger.error(f"[{self.account.email}] 获取 IMAP Token 失败")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
self._conn = _imap_pool.get_connection(
|
||||||
# 构建 XOAUTH2 认证字符串
|
self.account.email, token, self.config.timeout
|
||||||
auth_string = f"user={self.account.email}\x01auth=Bearer {token}\x01\x01"
|
)
|
||||||
self._conn.authenticate("XOAUTH2", lambda _: auth_string.encode("utf-8"))
|
self._connected = True
|
||||||
|
self.record_success()
|
||||||
|
logger.debug(f"[{self.account.email}] IMAP 连接就绪(连接池)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
except imaplib.IMAP4.error as e:
|
||||||
|
err = str(e)
|
||||||
|
# Token 失效时强制刷新并重试一次
|
||||||
|
if "AUTHENTICATE" in err or "invalid" in err.lower():
|
||||||
|
logger.warning(f"[{self.account.email}] XOAUTH2 认证失败,尝试刷新 Token")
|
||||||
|
_imap_pool.invalidate(self.account.email)
|
||||||
|
try:
|
||||||
|
tm = self._get_token_manager()
|
||||||
|
token = tm.get_access_token(force_refresh=True)
|
||||||
|
if token:
|
||||||
|
self._conn = _imap_pool.get_connection(
|
||||||
|
self.account.email, token, self.config.timeout
|
||||||
|
)
|
||||||
|
self._connected = True
|
||||||
|
self.record_success()
|
||||||
|
return True
|
||||||
|
except Exception as retry_e:
|
||||||
|
self.record_failure(str(retry_e))
|
||||||
|
logger.error(f"[{self.account.email}] Token 刷新后重连失败: {retry_e}")
|
||||||
|
else:
|
||||||
|
self.record_failure(err)
|
||||||
|
logger.error(f"[{self.account.email}] IMAP 连接失败: {e}")
|
||||||
|
self._connected = False
|
||||||
|
self._conn = None
|
||||||
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{self.account.email}] XOAUTH2 认证异常: {e}")
|
self.record_failure(str(e))
|
||||||
# 清除缓存的 Token
|
logger.error(f"[{self.account.email}] IMAP 连接失败: {e}")
|
||||||
self._token_manager.clear_cache()
|
self._connected = False
|
||||||
|
self._conn = None
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def disconnect(self):
|
def disconnect(self):
|
||||||
"""断开 IMAP 连接"""
|
"""归还连接池(不 logout,保持复用)"""
|
||||||
if self._conn:
|
|
||||||
try:
|
|
||||||
self._conn.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
self._conn.logout()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._conn = None
|
|
||||||
|
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
self._conn = None
|
||||||
|
|
||||||
def get_recent_emails(
|
def get_recent_emails(
|
||||||
self,
|
self,
|
||||||
count: int = 20,
|
count: int = 20,
|
||||||
only_unseen: bool = True,
|
only_unseen: bool = True,
|
||||||
|
since_minutes: Optional[int] = None,
|
||||||
|
folders: Optional[List[str]] = None,
|
||||||
) -> List[EmailMessage]:
|
) -> List[EmailMessage]:
|
||||||
"""
|
"""
|
||||||
获取最近的邮件
|
获取最近的邮件,支持多文件夹搜索(合并去重)。
|
||||||
|
|
||||||
Args:
|
搜索策略:
|
||||||
count: 获取数量
|
- since_minutes 指定时:用 SINCE 日期 + ALL 搜索最近N分钟内的邮件(不受已读/未读限制)
|
||||||
only_unseen: 是否只获取未读
|
- only_unseen=True 且未指定 since_minutes:搜索 UNSEEN
|
||||||
|
- only_unseen=False 且未指定 since_minutes:搜索全部(取最近 count 封)
|
||||||
Returns:
|
- folders 默认为 ["INBOX"],可传入多个文件夹(如 ["INBOX", "Junk Email"])
|
||||||
邮件列表
|
|
||||||
"""
|
"""
|
||||||
if not self._connected:
|
if not self._connected:
|
||||||
if not self.connect():
|
if not self.connect():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
if folders is None:
|
||||||
# 选择收件箱
|
folders = ["INBOX"]
|
||||||
self._conn.select("INBOX", readonly=True)
|
|
||||||
|
|
||||||
# 搜索邮件
|
all_emails: List[EmailMessage] = []
|
||||||
flag = "UNSEEN" if only_unseen else "ALL"
|
seen_ids: set = set()
|
||||||
status, data = self._conn.search(None, flag)
|
|
||||||
|
for folder in folders:
|
||||||
|
try:
|
||||||
|
status, _ = self._conn.select(folder, readonly=True)
|
||||||
|
if status != "OK":
|
||||||
|
logger.debug(f"[{self.account.email}] 文件夹 {folder} 不存在或无法访问,跳过")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if since_minutes is not None:
|
||||||
|
since_dt = datetime.now(timezone.utc) - timedelta(minutes=since_minutes)
|
||||||
|
since_str = since_dt.strftime("%d-%b-%Y")
|
||||||
|
status, data = self._conn.search(None, f"SINCE {since_str}")
|
||||||
|
elif only_unseen:
|
||||||
|
status, data = self._conn.search(None, "UNSEEN")
|
||||||
|
else:
|
||||||
|
status, data = self._conn.search(None, "ALL")
|
||||||
|
|
||||||
if status != "OK" or not data or not data[0]:
|
if status != "OK" or not data or not data[0]:
|
||||||
return []
|
continue
|
||||||
|
|
||||||
# 获取最新的邮件 ID
|
|
||||||
ids = data[0].split()
|
ids = data[0].split()
|
||||||
recent_ids = ids[-count:][::-1]
|
recent_ids = ids[-count:][::-1] # 取最新的 count 封,倒序(最新在前)
|
||||||
|
|
||||||
emails = []
|
|
||||||
for msg_id in recent_ids:
|
for msg_id in recent_ids:
|
||||||
try:
|
try:
|
||||||
email_msg = self._fetch_email(msg_id)
|
msg = self._fetch_email(msg_id)
|
||||||
if email_msg:
|
if msg and msg.id not in seen_ids:
|
||||||
emails.append(email_msg)
|
seen_ids.add(msg.id)
|
||||||
|
all_emails.append(msg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[{self.account.email}] 解析邮件失败 (ID: {msg_id}): {e}")
|
logger.warning(f"[{self.account.email}] 解析邮件失败 (ID: {msg_id}, folder: {folder}): {e}")
|
||||||
|
|
||||||
return emails
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.record_failure(str(e))
|
self.record_failure(str(e))
|
||||||
logger.error(f"[{self.account.email}] 获取邮件失败: {e}")
|
logger.warning(f"[{self.account.email}] 搜索文件夹 {folder} 失败: {e}")
|
||||||
return []
|
_imap_pool.invalidate(self.account.email)
|
||||||
|
self._connected = False
|
||||||
|
self._conn = None
|
||||||
|
break
|
||||||
|
|
||||||
|
# 按收信时间降序排列,截取 count 封
|
||||||
|
all_emails.sort(key=lambda m: m.received_timestamp, reverse=True)
|
||||||
|
return all_emails[:count]
|
||||||
|
|
||||||
def _fetch_email(self, msg_id: bytes) -> Optional[EmailMessage]:
|
def _fetch_email(self, msg_id: bytes) -> Optional[EmailMessage]:
|
||||||
"""获取并解析单封邮件"""
|
"""获取并解析单封邮件"""
|
||||||
@@ -211,21 +272,193 @@ class IMAPNewProvider(OutlookProvider):
|
|||||||
if not raw:
|
if not raw:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return self._parse_email(raw)
|
return _parse_email(raw)
|
||||||
|
|
||||||
@staticmethod
|
def wait_for_new_email_idle(self, timeout: int = 25) -> bool:
|
||||||
def _parse_email(raw: bytes) -> EmailMessage:
|
"""
|
||||||
"""解析原始邮件"""
|
RFC 2177 IMAP IDLE 实现。
|
||||||
# 使用旧版提供者的解析方法
|
发送 IDLE 命令,等待服务器推送 EXISTS/RECENT,然后发送 DONE。
|
||||||
return IMAPOldProvider._parse_email(raw)
|
Returns True 表示有新邮件推送,False 表示超时或异常(调用方降级轮询)。
|
||||||
|
"""
|
||||||
|
if not self._connected:
|
||||||
|
if not self.connect():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._conn.select("INBOX", readonly=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[{self.account.email}] IDLE 前 SELECT 失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
tag = self._next_idle_tag()
|
||||||
|
sock = self._conn.socket()
|
||||||
|
logger.info(f"[{self.account.email}] 进入 IMAP IDLE 等待模式(超时 {timeout}s,tag={tag})")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 发送 IDLE 命令
|
||||||
|
self._conn.send(f"{tag} IDLE\r\n".encode())
|
||||||
|
|
||||||
|
# 等待 "+" 延续响应(服务端确认进入 IDLE)
|
||||||
|
deadline = time.time() + min(10.0, timeout)
|
||||||
|
buf = b""
|
||||||
|
got_continuation = False
|
||||||
|
while time.time() < deadline:
|
||||||
|
ready = select.select([sock], [], [], min(2.0, deadline - time.time()))
|
||||||
|
if ready[0]:
|
||||||
|
chunk = sock.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buf += chunk
|
||||||
|
if b"+ " in buf or b"+\r\n" in buf:
|
||||||
|
got_continuation = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not got_continuation:
|
||||||
|
logger.warning(f"[{self.account.email}] 未收到 IDLE 延续响应,放弃")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 等待 EXISTS / RECENT 推送
|
||||||
|
got_new = False
|
||||||
|
buf = b""
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
remaining = deadline - time.time()
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
ready = select.select([sock], [], [], min(2.0, remaining))
|
||||||
|
if ready[0]:
|
||||||
|
chunk = sock.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buf += chunk
|
||||||
|
if b"EXISTS" in buf or b"RECENT" in buf:
|
||||||
|
logger.debug(f"[{self.account.email}] IDLE 收到新邮件推送")
|
||||||
|
got_new = True
|
||||||
|
break
|
||||||
|
|
||||||
|
return got_new
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[{self.account.email}] IMAP IDLE 异常: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# 发送 DONE 结束 IDLE,并排空服务端响应
|
||||||
|
try:
|
||||||
|
self._conn.send(b"DONE\r\n")
|
||||||
|
drain_deadline = time.time() + 5
|
||||||
|
drain_buf = b""
|
||||||
|
tag_end = f"{tag} OK".encode()
|
||||||
|
tag_no = f"{tag} NO".encode()
|
||||||
|
tag_bad = f"{tag} BAD".encode()
|
||||||
|
while time.time() < drain_deadline:
|
||||||
|
ready = select.select([sock], [], [], 1.0)
|
||||||
|
if not ready[0]:
|
||||||
|
break
|
||||||
|
chunk = sock.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
drain_buf += chunk
|
||||||
|
if any(t in drain_buf for t in (tag_end, tag_no, tag_bad)):
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
_imap_pool.invalidate(self.account.email)
|
||||||
|
self._connected = False
|
||||||
|
self._conn = None
|
||||||
|
|
||||||
def test_connection(self) -> bool:
|
def test_connection(self) -> bool:
|
||||||
"""测试 IMAP 连接"""
|
"""测试 IMAP 连接"""
|
||||||
try:
|
try:
|
||||||
with self:
|
with self:
|
||||||
self._conn.select("INBOX", readonly=True)
|
self._conn.select("INBOX", readonly=True)
|
||||||
self._conn.search(None, "ALL")
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[{self.account.email}] 新版 IMAP 连接测试失败: {e}")
|
logger.warning(f"[{self.account.email}] IMAP 连接测试失败: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_email(raw: bytes) -> EmailMessage:
|
||||||
|
"""解析原始邮件为 EmailMessage(优先 text/plain,次选 text/html)"""
|
||||||
|
msg = email.message_from_bytes(raw)
|
||||||
|
|
||||||
|
def _decode(val):
|
||||||
|
if not val:
|
||||||
|
return ""
|
||||||
|
parts = decode_header(str(val))
|
||||||
|
result = ""
|
||||||
|
for part, charset in parts:
|
||||||
|
if isinstance(part, bytes):
|
||||||
|
try:
|
||||||
|
result += part.decode(charset or "utf-8", errors="replace")
|
||||||
|
except (LookupError, UnicodeDecodeError):
|
||||||
|
result += part.decode("utf-8", errors="replace")
|
||||||
|
else:
|
||||||
|
result += str(part)
|
||||||
|
return result
|
||||||
|
|
||||||
|
subject = _decode(msg.get("Subject", ""))
|
||||||
|
sender = _decode(msg.get("From", ""))
|
||||||
|
recipients = [_decode(msg.get("To", ""))]
|
||||||
|
|
||||||
|
received_at = None
|
||||||
|
received_ts = 0
|
||||||
|
date_str = msg.get("Date", "")
|
||||||
|
if date_str:
|
||||||
|
try:
|
||||||
|
received_at = parsedate_to_datetime(date_str)
|
||||||
|
received_ts = int(received_at.timestamp())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 提取正文:优先 text/plain,次选 text/html
|
||||||
|
plain_body = ""
|
||||||
|
html_body = ""
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
ct = part.get_content_type()
|
||||||
|
cd = str(part.get("Content-Disposition", ""))
|
||||||
|
if "attachment" in cd.lower():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
charset = part.get_content_charset() or "utf-8"
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if not payload:
|
||||||
|
continue
|
||||||
|
decoded = payload.decode(charset, errors="replace")
|
||||||
|
if ct == "text/plain" and not plain_body:
|
||||||
|
plain_body = decoded
|
||||||
|
elif ct == "text/html" and not html_body:
|
||||||
|
html_body = decoded
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
charset = msg.get_content_charset() or "utf-8"
|
||||||
|
payload = msg.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
ct = msg.get_content_type()
|
||||||
|
decoded = payload.decode(charset, errors="replace")
|
||||||
|
if ct == "text/plain":
|
||||||
|
plain_body = decoded
|
||||||
|
else:
|
||||||
|
html_body = decoded
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
body = plain_body or html_body
|
||||||
|
body_preview = body[:200].strip()
|
||||||
|
|
||||||
|
msg_id = msg.get("Message-ID", "").strip("<>")
|
||||||
|
if not msg_id:
|
||||||
|
msg_id = f"{sender}_{received_ts}"
|
||||||
|
|
||||||
|
return EmailMessage(
|
||||||
|
id=msg_id,
|
||||||
|
subject=subject,
|
||||||
|
sender=sender,
|
||||||
|
recipients=recipients,
|
||||||
|
body=body,
|
||||||
|
body_preview=body_preview,
|
||||||
|
received_at=received_at,
|
||||||
|
received_timestamp=received_ts,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,345 +0,0 @@
|
|||||||
"""
|
|
||||||
旧版 IMAP 提供者
|
|
||||||
使用 outlook.office365.com 服务器和 login.live.com Token 端点
|
|
||||||
"""
|
|
||||||
|
|
||||||
import email
|
|
||||||
import imaplib
|
|
||||||
import logging
|
|
||||||
from email.header import decode_header
|
|
||||||
from email.utils import parsedate_to_datetime
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from ..base import ProviderType, EmailMessage
|
|
||||||
from ..account import OutlookAccount
|
|
||||||
from ..token_manager import TokenManager
|
|
||||||
from .base import OutlookProvider, ProviderConfig
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class IMAPOldProvider(OutlookProvider):
|
|
||||||
"""
|
|
||||||
旧版 IMAP 提供者
|
|
||||||
使用 outlook.office365.com:993 和 login.live.com Token 端点
|
|
||||||
"""
|
|
||||||
|
|
||||||
# IMAP 服务器配置
|
|
||||||
IMAP_HOST = "outlook.office365.com"
|
|
||||||
IMAP_PORT = 993
|
|
||||||
|
|
||||||
@property
|
|
||||||
def provider_type(self) -> ProviderType:
|
|
||||||
return ProviderType.IMAP_OLD
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
account: OutlookAccount,
|
|
||||||
config: Optional[ProviderConfig] = None,
|
|
||||||
):
|
|
||||||
super().__init__(account, config)
|
|
||||||
|
|
||||||
# IMAP 连接
|
|
||||||
self._conn: Optional[imaplib.IMAP4_SSL] = None
|
|
||||||
|
|
||||||
# Token 管理器
|
|
||||||
self._token_manager: Optional[TokenManager] = None
|
|
||||||
|
|
||||||
def connect(self) -> bool:
|
|
||||||
"""
|
|
||||||
连接到 IMAP 服务器
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
是否连接成功
|
|
||||||
"""
|
|
||||||
if self._connected and self._conn:
|
|
||||||
# 检查现有连接
|
|
||||||
try:
|
|
||||||
self._conn.noop()
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
self.disconnect()
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.debug(f"[{self.account.email}] 正在连接 IMAP ({self.IMAP_HOST})...")
|
|
||||||
|
|
||||||
# 创建连接
|
|
||||||
self._conn = imaplib.IMAP4_SSL(
|
|
||||||
self.IMAP_HOST,
|
|
||||||
self.IMAP_PORT,
|
|
||||||
timeout=self.config.timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 尝试 XOAUTH2 认证
|
|
||||||
if self.account.has_oauth():
|
|
||||||
if self._authenticate_xoauth2():
|
|
||||||
self._connected = True
|
|
||||||
self.record_success()
|
|
||||||
logger.info(f"[{self.account.email}] IMAP 连接成功 (XOAUTH2)")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.warning(f"[{self.account.email}] XOAUTH2 认证失败,尝试密码认证")
|
|
||||||
|
|
||||||
# 密码认证
|
|
||||||
if self.account.password:
|
|
||||||
self._conn.login(self.account.email, self.account.password)
|
|
||||||
self._connected = True
|
|
||||||
self.record_success()
|
|
||||||
logger.info(f"[{self.account.email}] IMAP 连接成功 (密码认证)")
|
|
||||||
return True
|
|
||||||
|
|
||||||
raise ValueError("没有可用的认证方式")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.disconnect()
|
|
||||||
self.record_failure(str(e))
|
|
||||||
logger.error(f"[{self.account.email}] IMAP 连接失败: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _authenticate_xoauth2(self) -> bool:
|
|
||||||
"""
|
|
||||||
使用 XOAUTH2 认证
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
是否认证成功
|
|
||||||
"""
|
|
||||||
if not self._token_manager:
|
|
||||||
self._token_manager = TokenManager(
|
|
||||||
self.account,
|
|
||||||
ProviderType.IMAP_OLD,
|
|
||||||
self.config.proxy_url,
|
|
||||||
self.config.timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 获取 Access Token
|
|
||||||
token = self._token_manager.get_access_token()
|
|
||||||
if not token:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 构建 XOAUTH2 认证字符串
|
|
||||||
auth_string = f"user={self.account.email}\x01auth=Bearer {token}\x01\x01"
|
|
||||||
self._conn.authenticate("XOAUTH2", lambda _: auth_string.encode("utf-8"))
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"[{self.account.email}] XOAUTH2 认证异常: {e}")
|
|
||||||
# 清除缓存的 Token
|
|
||||||
self._token_manager.clear_cache()
|
|
||||||
return False
|
|
||||||
|
|
||||||
def disconnect(self):
|
|
||||||
"""断开 IMAP 连接"""
|
|
||||||
if self._conn:
|
|
||||||
try:
|
|
||||||
self._conn.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
self._conn.logout()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._conn = None
|
|
||||||
|
|
||||||
self._connected = False
|
|
||||||
|
|
||||||
def get_recent_emails(
|
|
||||||
self,
|
|
||||||
count: int = 20,
|
|
||||||
only_unseen: bool = True,
|
|
||||||
) -> List[EmailMessage]:
|
|
||||||
"""
|
|
||||||
获取最近的邮件
|
|
||||||
|
|
||||||
Args:
|
|
||||||
count: 获取数量
|
|
||||||
only_unseen: 是否只获取未读
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
邮件列表
|
|
||||||
"""
|
|
||||||
if not self._connected:
|
|
||||||
if not self.connect():
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 选择收件箱
|
|
||||||
self._conn.select("INBOX", readonly=True)
|
|
||||||
|
|
||||||
# 搜索邮件
|
|
||||||
flag = "UNSEEN" if only_unseen else "ALL"
|
|
||||||
status, data = self._conn.search(None, flag)
|
|
||||||
|
|
||||||
if status != "OK" or not data or not data[0]:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 获取最新的邮件 ID
|
|
||||||
ids = data[0].split()
|
|
||||||
recent_ids = ids[-count:][::-1] # 倒序,最新的在前
|
|
||||||
|
|
||||||
emails = []
|
|
||||||
for msg_id in recent_ids:
|
|
||||||
try:
|
|
||||||
email_msg = self._fetch_email(msg_id)
|
|
||||||
if email_msg:
|
|
||||||
emails.append(email_msg)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[{self.account.email}] 解析邮件失败 (ID: {msg_id}): {e}")
|
|
||||||
|
|
||||||
return emails
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.record_failure(str(e))
|
|
||||||
logger.error(f"[{self.account.email}] 获取邮件失败: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _fetch_email(self, msg_id: bytes) -> Optional[EmailMessage]:
|
|
||||||
"""
|
|
||||||
获取并解析单封邮件
|
|
||||||
|
|
||||||
Args:
|
|
||||||
msg_id: 邮件 ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
EmailMessage 对象,失败返回 None
|
|
||||||
"""
|
|
||||||
status, data = self._conn.fetch(msg_id, "(RFC822)")
|
|
||||||
if status != "OK" or not data or not data[0]:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 获取原始邮件内容
|
|
||||||
raw = b""
|
|
||||||
for part in data:
|
|
||||||
if isinstance(part, tuple) and len(part) > 1:
|
|
||||||
raw = part[1]
|
|
||||||
break
|
|
||||||
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return self._parse_email(raw)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_email(raw: bytes) -> EmailMessage:
|
|
||||||
"""
|
|
||||||
解析原始邮件
|
|
||||||
|
|
||||||
Args:
|
|
||||||
raw: 原始邮件数据
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
EmailMessage 对象
|
|
||||||
"""
|
|
||||||
# 移除 BOM
|
|
||||||
if raw.startswith(b"\xef\xbb\xbf"):
|
|
||||||
raw = raw[3:]
|
|
||||||
|
|
||||||
msg = email.message_from_bytes(raw)
|
|
||||||
|
|
||||||
# 解析邮件头
|
|
||||||
subject = IMAPOldProvider._decode_header(msg.get("Subject", ""))
|
|
||||||
sender = IMAPOldProvider._decode_header(msg.get("From", ""))
|
|
||||||
to = IMAPOldProvider._decode_header(msg.get("To", ""))
|
|
||||||
delivered_to = IMAPOldProvider._decode_header(msg.get("Delivered-To", ""))
|
|
||||||
x_original_to = IMAPOldProvider._decode_header(msg.get("X-Original-To", ""))
|
|
||||||
date_str = IMAPOldProvider._decode_header(msg.get("Date", ""))
|
|
||||||
|
|
||||||
# 提取正文
|
|
||||||
body = IMAPOldProvider._extract_body(msg)
|
|
||||||
|
|
||||||
# 解析日期
|
|
||||||
received_timestamp = 0
|
|
||||||
received_at = None
|
|
||||||
try:
|
|
||||||
if date_str:
|
|
||||||
received_at = parsedate_to_datetime(date_str)
|
|
||||||
received_timestamp = int(received_at.timestamp())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 构建收件人列表
|
|
||||||
recipients = [r for r in [to, delivered_to, x_original_to] if r]
|
|
||||||
|
|
||||||
return EmailMessage(
|
|
||||||
id=msg.get("Message-ID", ""),
|
|
||||||
subject=subject,
|
|
||||||
sender=sender,
|
|
||||||
recipients=recipients,
|
|
||||||
body=body,
|
|
||||||
received_at=received_at,
|
|
||||||
received_timestamp=received_timestamp,
|
|
||||||
is_read=False, # 搜索的是未读邮件
|
|
||||||
raw_data=raw[:500] if len(raw) > 500 else raw,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _decode_header(header: str) -> str:
|
|
||||||
"""解码邮件头"""
|
|
||||||
if not header:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
parts = []
|
|
||||||
for chunk, encoding in decode_header(header):
|
|
||||||
if isinstance(chunk, bytes):
|
|
||||||
try:
|
|
||||||
decoded = chunk.decode(encoding or "utf-8", errors="replace")
|
|
||||||
parts.append(decoded)
|
|
||||||
except Exception:
|
|
||||||
parts.append(chunk.decode("utf-8", errors="replace"))
|
|
||||||
else:
|
|
||||||
parts.append(str(chunk))
|
|
||||||
|
|
||||||
return "".join(parts).strip()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_body(msg) -> str:
|
|
||||||
"""提取邮件正文"""
|
|
||||||
import html as html_module
|
|
||||||
import re
|
|
||||||
|
|
||||||
texts = []
|
|
||||||
parts = msg.walk() if msg.is_multipart() else [msg]
|
|
||||||
|
|
||||||
for part in parts:
|
|
||||||
content_type = part.get_content_type()
|
|
||||||
if content_type not in ("text/plain", "text/html"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
payload = part.get_payload(decode=True)
|
|
||||||
if not payload:
|
|
||||||
continue
|
|
||||||
|
|
||||||
charset = part.get_content_charset() or "utf-8"
|
|
||||||
try:
|
|
||||||
text = payload.decode(charset, errors="replace")
|
|
||||||
except LookupError:
|
|
||||||
text = payload.decode("utf-8", errors="replace")
|
|
||||||
|
|
||||||
# 如果是 HTML,移除标签
|
|
||||||
if "<html" in text.lower():
|
|
||||||
text = re.sub(r"<[^>]+>", " ", text)
|
|
||||||
|
|
||||||
texts.append(text)
|
|
||||||
|
|
||||||
# 合并并清理文本
|
|
||||||
combined = " ".join(texts)
|
|
||||||
combined = html_module.unescape(combined)
|
|
||||||
combined = re.sub(r"\s+", " ", combined).strip()
|
|
||||||
|
|
||||||
return combined
|
|
||||||
|
|
||||||
def test_connection(self) -> bool:
|
|
||||||
"""
|
|
||||||
测试 IMAP 连接
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
连接是否正常
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with self:
|
|
||||||
self._conn.select("INBOX", readonly=True)
|
|
||||||
self._conn.search(None, "ALL")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[{self.account.email}] IMAP 连接测试失败: {e}")
|
|
||||||
return False
|
|
||||||
+262
-283
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Outlook 邮箱服务主类
|
Outlook 邮箱服务主类(简化版)
|
||||||
支持多种 IMAP/API 连接方式,自动故障切换
|
单一 IMAP_NEW Provider + 邮件缓存 + IMAP IDLE 支持
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -8,34 +8,24 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
from ..base import BaseEmailService, EmailServiceError, EmailServiceStatus, EmailServiceType
|
from ..base import BaseEmailService, EmailServiceError, EmailServiceType
|
||||||
from ...config.constants import EmailServiceType as ServiceType
|
from ...config.constants import EmailServiceType as ServiceType
|
||||||
from ...config.settings import get_settings
|
from ...config.settings import get_settings
|
||||||
from .account import OutlookAccount
|
from .account import OutlookAccount
|
||||||
from .base import ProviderType, EmailMessage
|
from .base import EmailMessage
|
||||||
from .email_parser import EmailParser, get_email_parser
|
from .email_parser import get_email_parser
|
||||||
from .health_checker import HealthChecker, FailoverManager
|
from .health_checker import HealthChecker
|
||||||
from .providers.base import OutlookProvider, ProviderConfig
|
from .providers.base import ProviderConfig
|
||||||
from .providers.imap_old import IMAPOldProvider
|
|
||||||
from .providers.imap_new import IMAPNewProvider
|
from .providers.imap_new import IMAPNewProvider
|
||||||
from .providers.graph_api import GraphAPIProvider
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 验证码搜索的文件夹列表(同时搜索收件箱和垃圾箱)
|
||||||
# 默认提供者优先级
|
_OUTLOOK_SEARCH_FOLDERS = ["INBOX", "Junk Email"]
|
||||||
# IMAP_OLD 最兼容(只需 login.live.com token),IMAP_NEW 次之,Graph API 最后
|
|
||||||
# 原因:部分 client_id 没有 Graph API 权限,但有 IMAP 权限
|
|
||||||
DEFAULT_PROVIDER_PRIORITY = [
|
|
||||||
ProviderType.IMAP_OLD,
|
|
||||||
ProviderType.IMAP_NEW,
|
|
||||||
ProviderType.GRAPH_API,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_email_code_settings() -> dict:
|
def _get_code_settings() -> dict:
|
||||||
"""获取验证码等待配置"""
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
return {
|
return {
|
||||||
"timeout": settings.email_code_timeout,
|
"timeout": settings.email_code_timeout,
|
||||||
@@ -43,56 +33,58 @@ def get_email_code_settings() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _EmailCache:
|
||||||
|
"""轻量级邮件内存缓存(TTL=60s,减少重复 IMAP 请求)"""
|
||||||
|
|
||||||
|
TTL = 60
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._cache: Dict[str, tuple] = {} # email -> (timestamp, List[EmailMessage])
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def get(self, email: str) -> Optional[List[EmailMessage]]:
|
||||||
|
with self._lock:
|
||||||
|
entry = self._cache.get(email)
|
||||||
|
if entry and time.time() - entry[0] < self.TTL:
|
||||||
|
return entry[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(self, email: str, messages: List[EmailMessage]):
|
||||||
|
with self._lock:
|
||||||
|
self._cache[email] = (time.time(), messages)
|
||||||
|
|
||||||
|
def invalidate(self, email: str):
|
||||||
|
with self._lock:
|
||||||
|
self._cache.pop(email, None)
|
||||||
|
|
||||||
|
|
||||||
class OutlookService(BaseEmailService):
|
class OutlookService(BaseEmailService):
|
||||||
"""
|
"""
|
||||||
Outlook 邮箱服务
|
Outlook 邮箱服务
|
||||||
支持多种 IMAP/API 连接方式,自动故障切换
|
使用单一 IMAP_NEW Provider,支持连接池复用和 IMAP IDLE
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any] = None, name: str = None):
|
def __init__(self, config: Dict[str, Any] = None, name: str = None):
|
||||||
"""
|
|
||||||
初始化 Outlook 服务
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config: 配置字典,支持以下键:
|
|
||||||
- accounts: Outlook 账户列表
|
|
||||||
- provider_priority: 提供者优先级列表
|
|
||||||
- health_failure_threshold: 连续失败次数阈值
|
|
||||||
- health_disable_duration: 禁用时长(秒)
|
|
||||||
- timeout: 请求超时时间
|
|
||||||
- proxy_url: 代理 URL
|
|
||||||
name: 服务名称
|
|
||||||
"""
|
|
||||||
super().__init__(ServiceType.OUTLOOK, name)
|
super().__init__(ServiceType.OUTLOOK, name)
|
||||||
|
|
||||||
# 默认配置
|
|
||||||
default_config = {
|
default_config = {
|
||||||
"accounts": [],
|
"accounts": [],
|
||||||
"provider_priority": [p.value for p in DEFAULT_PROVIDER_PRIORITY],
|
|
||||||
"health_failure_threshold": 5,
|
"health_failure_threshold": 5,
|
||||||
"health_disable_duration": 60,
|
"health_disable_duration": 60,
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
"proxy_url": None,
|
"proxy_url": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
self.config = {**default_config, **(config or {})}
|
self.config = {**default_config, **(config or {})}
|
||||||
|
|
||||||
# 解析提供者优先级
|
|
||||||
self.provider_priority = [
|
|
||||||
ProviderType(p) for p in self.config.get("provider_priority", [])
|
|
||||||
]
|
|
||||||
if not self.provider_priority:
|
|
||||||
self.provider_priority = DEFAULT_PROVIDER_PRIORITY
|
|
||||||
|
|
||||||
# 提供者配置
|
|
||||||
self.provider_config = ProviderConfig(
|
self.provider_config = ProviderConfig(
|
||||||
timeout=self.config.get("timeout", 30),
|
timeout=self.config.get("timeout", 30),
|
||||||
proxy_url=self.config.get("proxy_url"),
|
proxy_url=self.config.get("proxy_url"),
|
||||||
|
service_id=self.config.get("service_id"),
|
||||||
health_failure_threshold=self.config.get("health_failure_threshold", 3),
|
health_failure_threshold=self.config.get("health_failure_threshold", 3),
|
||||||
health_disable_duration=self.config.get("health_disable_duration", 300),
|
health_disable_duration=self.config.get("health_disable_duration", 300),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 获取默认 client_id(供无 client_id 的账户使用)
|
# 获取默认 client_id
|
||||||
try:
|
try:
|
||||||
_default_client_id = get_settings().outlook_default_client_id
|
_default_client_id = get_settings().outlook_default_client_id
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -103,193 +95,120 @@ class OutlookService(BaseEmailService):
|
|||||||
self._current_account_index = 0
|
self._current_account_index = 0
|
||||||
self._account_lock = threading.Lock()
|
self._account_lock = threading.Lock()
|
||||||
|
|
||||||
# 支持两种配置格式
|
|
||||||
if "email" in self.config and "password" in self.config:
|
if "email" in self.config and "password" in self.config:
|
||||||
account = OutlookAccount.from_config(self.config)
|
account = OutlookAccount.from_config(self.config)
|
||||||
if not account.client_id and _default_client_id:
|
if not account.client_id and _default_client_id:
|
||||||
account.client_id = _default_client_id
|
account.client_id = _default_client_id
|
||||||
if account.validate():
|
if account.validate():
|
||||||
|
if not account.has_oauth():
|
||||||
|
logger.warning(
|
||||||
|
f"[{account.email}] 跳过:IMAP_NEW 仅支持 OAuth2,"
|
||||||
|
f"请配置 client_id 和 refresh_token"
|
||||||
|
)
|
||||||
|
else:
|
||||||
self.accounts.append(account)
|
self.accounts.append(account)
|
||||||
else:
|
else:
|
||||||
for account_config in self.config.get("accounts", []):
|
for ac in self.config.get("accounts", []):
|
||||||
account = OutlookAccount.from_config(account_config)
|
account = OutlookAccount.from_config(ac)
|
||||||
if not account.client_id and _default_client_id:
|
if not account.client_id and _default_client_id:
|
||||||
account.client_id = _default_client_id
|
account.client_id = _default_client_id
|
||||||
if account.validate():
|
if account.validate():
|
||||||
|
if not account.has_oauth():
|
||||||
|
logger.warning(
|
||||||
|
f"[{account.email}] 跳过:IMAP_NEW 仅支持 OAuth2,"
|
||||||
|
f"请配置 client_id 和 refresh_token"
|
||||||
|
)
|
||||||
|
else:
|
||||||
self.accounts.append(account)
|
self.accounts.append(account)
|
||||||
|
|
||||||
if not self.accounts:
|
if not self.accounts:
|
||||||
logger.warning("未配置有效的 Outlook 账户")
|
logger.warning("未配置有效的 Outlook 账户(需要 client_id + refresh_token)")
|
||||||
|
|
||||||
# 健康检查器和故障切换管理器
|
# 健康检查器
|
||||||
self.health_checker = HealthChecker(
|
self.health_checker = HealthChecker(
|
||||||
failure_threshold=self.provider_config.health_failure_threshold,
|
failure_threshold=self.provider_config.health_failure_threshold,
|
||||||
disable_duration=self.provider_config.health_disable_duration,
|
disable_duration=self.provider_config.health_disable_duration,
|
||||||
)
|
)
|
||||||
self.failover_manager = FailoverManager(
|
|
||||||
health_checker=self.health_checker,
|
|
||||||
priority_order=self.provider_priority,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 邮件解析器
|
# 邮件解析器
|
||||||
self.email_parser = get_email_parser()
|
self.email_parser = get_email_parser()
|
||||||
|
|
||||||
# 提供者实例缓存: (email, provider_type) -> OutlookProvider
|
# Provider 实例缓存: email -> IMAPNewProvider
|
||||||
self._providers: Dict[tuple, OutlookProvider] = {}
|
self._providers: Dict[str, IMAPNewProvider] = {}
|
||||||
self._provider_lock = threading.Lock()
|
self._provider_lock = threading.Lock()
|
||||||
|
|
||||||
# IMAP 连接限制(防止限流)
|
# IMAP 并发限制(最多 5 个并发)
|
||||||
self._imap_semaphore = threading.Semaphore(5)
|
self._imap_semaphore = threading.Semaphore(5)
|
||||||
|
|
||||||
# 验证码去重机制
|
# 邮件缓存
|
||||||
|
self._email_cache = _EmailCache()
|
||||||
|
|
||||||
|
# 验证码去重
|
||||||
self._used_codes: Dict[str, set] = {}
|
self._used_codes: Dict[str, set] = {}
|
||||||
|
|
||||||
def _get_provider(
|
def _get_provider(self, account: OutlookAccount) -> IMAPNewProvider:
|
||||||
self,
|
key = account.email.lower()
|
||||||
account: OutlookAccount,
|
|
||||||
provider_type: ProviderType,
|
|
||||||
) -> OutlookProvider:
|
|
||||||
"""
|
|
||||||
获取或创建提供者实例
|
|
||||||
|
|
||||||
Args:
|
|
||||||
account: Outlook 账户
|
|
||||||
provider_type: 提供者类型
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
提供者实例
|
|
||||||
"""
|
|
||||||
cache_key = (account.email.lower(), provider_type)
|
|
||||||
|
|
||||||
with self._provider_lock:
|
with self._provider_lock:
|
||||||
if cache_key not in self._providers:
|
if key not in self._providers:
|
||||||
provider = self._create_provider(account, provider_type)
|
self._providers[key] = IMAPNewProvider(account, self.provider_config)
|
||||||
self._providers[cache_key] = provider
|
return self._providers[key]
|
||||||
|
|
||||||
return self._providers[cache_key]
|
def _fetch_emails(
|
||||||
|
|
||||||
def _create_provider(
|
|
||||||
self,
|
self,
|
||||||
account: OutlookAccount,
|
account: OutlookAccount,
|
||||||
provider_type: ProviderType,
|
count: int = 15,
|
||||||
) -> OutlookProvider:
|
|
||||||
"""
|
|
||||||
创建提供者实例
|
|
||||||
|
|
||||||
Args:
|
|
||||||
account: Outlook 账户
|
|
||||||
provider_type: 提供者类型
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
提供者实例
|
|
||||||
"""
|
|
||||||
if provider_type == ProviderType.IMAP_OLD:
|
|
||||||
return IMAPOldProvider(account, self.provider_config)
|
|
||||||
elif provider_type == ProviderType.IMAP_NEW:
|
|
||||||
return IMAPNewProvider(account, self.provider_config)
|
|
||||||
elif provider_type == ProviderType.GRAPH_API:
|
|
||||||
return GraphAPIProvider(account, self.provider_config)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"未知的提供者类型: {provider_type}")
|
|
||||||
|
|
||||||
def _get_provider_priority_for_account(self, account: OutlookAccount) -> List[ProviderType]:
|
|
||||||
"""根据账户是否有 OAuth,返回适合的提供者优先级列表"""
|
|
||||||
if account.has_oauth():
|
|
||||||
return self.provider_priority
|
|
||||||
else:
|
|
||||||
# 无 OAuth,直接走旧版 IMAP(密码认证),跳过需要 OAuth 的提供者
|
|
||||||
return [ProviderType.IMAP_OLD]
|
|
||||||
|
|
||||||
def _try_providers_for_emails(
|
|
||||||
self,
|
|
||||||
account: OutlookAccount,
|
|
||||||
count: int = 20,
|
|
||||||
only_unseen: bool = True,
|
only_unseen: bool = True,
|
||||||
|
since_minutes: Optional[int] = None,
|
||||||
|
use_cache: bool = False,
|
||||||
|
folders: Optional[List[str]] = None,
|
||||||
) -> List[EmailMessage]:
|
) -> List[EmailMessage]:
|
||||||
"""
|
"""通过 IMAP_NEW Provider 获取邮件,可选使用内存缓存"""
|
||||||
尝试多个提供者获取邮件
|
if use_cache:
|
||||||
|
cached = self._email_cache.get(account.email)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
Args:
|
if not self.health_checker.is_available():
|
||||||
account: Outlook 账户
|
logger.debug(f"[{account.email}] IMAP_NEW 不可用,跳过")
|
||||||
count: 获取数量
|
return []
|
||||||
only_unseen: 是否只获取未读
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
邮件列表
|
|
||||||
"""
|
|
||||||
errors = []
|
|
||||||
|
|
||||||
# 根据账户类型选择合适的提供者优先级
|
|
||||||
priority = self._get_provider_priority_for_account(account)
|
|
||||||
|
|
||||||
# 按优先级尝试各提供者
|
|
||||||
for provider_type in priority:
|
|
||||||
# 检查提供者是否可用
|
|
||||||
if not self.health_checker.is_available(provider_type):
|
|
||||||
logger.debug(
|
|
||||||
f"[{account.email}] {provider_type.value} 不可用,跳过"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
provider = self._get_provider(account, provider_type)
|
provider = self._get_provider(account)
|
||||||
|
|
||||||
with self._imap_semaphore:
|
with self._imap_semaphore:
|
||||||
with provider:
|
with provider:
|
||||||
emails = provider.get_recent_emails(count, only_unseen)
|
emails = provider.get_recent_emails(
|
||||||
|
count, only_unseen, since_minutes=since_minutes, folders=folders
|
||||||
|
)
|
||||||
|
|
||||||
if emails:
|
if emails:
|
||||||
# 成功获取邮件
|
self.health_checker.record_success()
|
||||||
self.health_checker.record_success(provider_type)
|
if use_cache:
|
||||||
logger.debug(
|
self._email_cache.set(account.email, emails)
|
||||||
f"[{account.email}] {provider_type.value} 获取到 {len(emails)} 封邮件"
|
|
||||||
)
|
|
||||||
return emails
|
return emails
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = str(e)
|
err = str(e)
|
||||||
errors.append(f"{provider_type.value}: {error_msg}")
|
self.health_checker.record_failure(err)
|
||||||
self.health_checker.record_failure(provider_type, error_msg)
|
logger.warning(f"[{account.email}] 获取邮件失败: {e}")
|
||||||
logger.warning(
|
|
||||||
f"[{account.email}] {provider_type.value} 获取邮件失败: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.error(
|
|
||||||
f"[{account.email}] 所有提供者都失败: {'; '.join(errors)}"
|
|
||||||
)
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
|
def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||||
"""
|
"""轮询选择可用的 Outlook 账户"""
|
||||||
选择可用的 Outlook 账户
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config: 配置参数(未使用)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
包含邮箱信息的字典
|
|
||||||
"""
|
|
||||||
if not self.accounts:
|
if not self.accounts:
|
||||||
self.update_status(False, EmailServiceError("没有可用的 Outlook 账户"))
|
self.update_status(False, EmailServiceError("没有可用的 Outlook 账户"))
|
||||||
raise EmailServiceError("没有可用的 Outlook 账户")
|
raise EmailServiceError("没有可用的 Outlook 账户")
|
||||||
|
|
||||||
# 轮询选择账户
|
|
||||||
with self._account_lock:
|
with self._account_lock:
|
||||||
account = self.accounts[self._current_account_index]
|
account = self.accounts[self._current_account_index]
|
||||||
self._current_account_index = (self._current_account_index + 1) % len(self.accounts)
|
self._current_account_index = (self._current_account_index + 1) % len(self.accounts)
|
||||||
|
|
||||||
email_info = {
|
|
||||||
"email": account.email,
|
|
||||||
"service_id": account.email,
|
|
||||||
"account": {
|
|
||||||
"email": account.email,
|
|
||||||
"has_oauth": account.has_oauth()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(f"选择 Outlook 账户: {account.email}")
|
logger.info(f"选择 Outlook 账户: {account.email}")
|
||||||
self.update_status(True)
|
self.update_status(True)
|
||||||
return email_info
|
return {
|
||||||
|
"email": account.email,
|
||||||
|
"service_id": account.email,
|
||||||
|
"account": {"email": account.email, "has_oauth": account.has_oauth()},
|
||||||
|
}
|
||||||
|
|
||||||
def get_verification_code(
|
def get_verification_code(
|
||||||
self,
|
self,
|
||||||
@@ -299,163 +218,234 @@ class OutlookService(BaseEmailService):
|
|||||||
pattern: str = None,
|
pattern: str = None,
|
||||||
otp_sent_at: Optional[float] = None,
|
otp_sent_at: Optional[float] = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""
|
"""从 Outlook 邮箱获取验证码"""
|
||||||
从 Outlook 邮箱获取验证码
|
account = next(
|
||||||
|
(a for a in self.accounts if a.email.lower() == email.lower()), None
|
||||||
Args:
|
)
|
||||||
email: 邮箱地址
|
|
||||||
email_id: 未使用
|
|
||||||
timeout: 超时时间(秒)
|
|
||||||
pattern: 验证码正则表达式(未使用)
|
|
||||||
otp_sent_at: OTP 发送时间戳
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
验证码字符串
|
|
||||||
"""
|
|
||||||
# 查找对应的账户
|
|
||||||
account = None
|
|
||||||
for acc in self.accounts:
|
|
||||||
if acc.email.lower() == email.lower():
|
|
||||||
account = acc
|
|
||||||
break
|
|
||||||
|
|
||||||
if not account:
|
if not account:
|
||||||
self.update_status(False, EmailServiceError(f"未找到邮箱对应的账户: {email}"))
|
self.update_status(False, EmailServiceError(f"未找到邮箱账户: {email}"))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 获取验证码等待配置
|
code_settings = _get_code_settings()
|
||||||
code_settings = get_email_code_settings()
|
|
||||||
actual_timeout = timeout or code_settings["timeout"]
|
actual_timeout = timeout or code_settings["timeout"]
|
||||||
poll_interval = code_settings["poll_interval"]
|
poll_interval = code_settings["poll_interval"]
|
||||||
|
|
||||||
logger.info(
|
logger.info(f"[{email}] 开始获取验证码,超时 {actual_timeout}s")
|
||||||
f"[{email}] 开始获取验证码,超时 {actual_timeout}s,"
|
|
||||||
f"提供者优先级: {[p.value for p in self.provider_priority]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 初始化验证码去重集合
|
|
||||||
if email not in self._used_codes:
|
if email not in self._used_codes:
|
||||||
self._used_codes[email] = set()
|
self._used_codes[email] = set()
|
||||||
used_codes = self._used_codes[email]
|
used_codes = self._used_codes[email]
|
||||||
|
|
||||||
# 计算最小时间戳(留出 60 秒时钟偏差)
|
|
||||||
min_timestamp = (otp_sent_at - 60) if otp_sent_at else 0
|
min_timestamp = (otp_sent_at - 60) if otp_sent_at else 0
|
||||||
|
|
||||||
|
use_idle = True
|
||||||
|
try:
|
||||||
|
use_idle = get_settings().outlook_use_idle
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if use_idle:
|
||||||
|
code = self._wait_with_idle(
|
||||||
|
account, email, actual_timeout, min_timestamp, used_codes, otp_sent_at
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
code = self._wait_with_poll(
|
||||||
|
account, email, actual_timeout, poll_interval, min_timestamp, used_codes, otp_sent_at
|
||||||
|
)
|
||||||
|
|
||||||
|
if code:
|
||||||
|
used_codes.add(code)
|
||||||
|
self.update_status(True)
|
||||||
|
return code
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _wait_with_poll(
|
||||||
|
self,
|
||||||
|
account: OutlookAccount,
|
||||||
|
email: str,
|
||||||
|
timeout: int,
|
||||||
|
poll_interval: int,
|
||||||
|
min_timestamp: float,
|
||||||
|
used_codes: set,
|
||||||
|
otp_sent_at: Optional[float] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""轮询方式等待验证码"""
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
poll_count = 0
|
poll_count = 0
|
||||||
|
|
||||||
while time.time() - start_time < actual_timeout:
|
while time.time() - start_time < timeout:
|
||||||
poll_count += 1
|
poll_count += 1
|
||||||
|
# 每次动态计算 since_minutes,确保时间窗口随轮询推进而更新
|
||||||
# 渐进式邮件检查:前 3 次只检查未读
|
if otp_sent_at:
|
||||||
|
elapsed_since_send = int((time.time() - otp_sent_at) / 60) + 2
|
||||||
|
since_minutes: Optional[int] = min(elapsed_since_send, 180)
|
||||||
|
only_unseen = False
|
||||||
|
else:
|
||||||
|
since_minutes = None
|
||||||
only_unseen = poll_count <= 3
|
only_unseen = poll_count <= 3
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 尝试多个提供者获取邮件
|
emails = self._fetch_emails(
|
||||||
emails = self._try_providers_for_emails(
|
account, count=15, only_unseen=only_unseen,
|
||||||
account,
|
since_minutes=since_minutes,
|
||||||
count=15,
|
folders=_OUTLOOK_SEARCH_FOLDERS,
|
||||||
only_unseen=only_unseen,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if emails:
|
if emails:
|
||||||
logger.debug(
|
|
||||||
f"[{email}] 第 {poll_count} 次轮询获取到 {len(emails)} 封邮件"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 从邮件中查找验证码
|
|
||||||
code = self.email_parser.find_verification_code_in_emails(
|
code = self.email_parser.find_verification_code_in_emails(
|
||||||
emails,
|
emails,
|
||||||
target_email=email,
|
target_email=email,
|
||||||
min_timestamp=min_timestamp,
|
min_timestamp=min_timestamp,
|
||||||
used_codes=used_codes,
|
used_codes=used_codes,
|
||||||
)
|
)
|
||||||
|
|
||||||
if code:
|
if code:
|
||||||
used_codes.add(code)
|
|
||||||
elapsed = int(time.time() - start_time)
|
elapsed = int(time.time() - start_time)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[{email}] 找到验证码: {code},"
|
f"[{email}] 找到验证码: {code},耗时 {elapsed}s,轮询 {poll_count} 次"
|
||||||
f"总耗时 {elapsed}s,轮询 {poll_count} 次"
|
)
|
||||||
|
return code
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[{email}] 轮询出错: {e}")
|
||||||
|
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
|
logger.warning(f"[{email}] 验证码超时 ({timeout}s),共轮询 {poll_count} 次")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _wait_with_idle(
|
||||||
|
self,
|
||||||
|
account: OutlookAccount,
|
||||||
|
email: str,
|
||||||
|
timeout: int,
|
||||||
|
min_timestamp: float,
|
||||||
|
used_codes: set,
|
||||||
|
otp_sent_at: Optional[float] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""IMAP IDLE 方式等待验证码,失败时自动降级为轮询"""
|
||||||
|
if not self.health_checker.is_available():
|
||||||
|
logger.warning(f"[{email}] IMAP_NEW 不可用,降级为轮询")
|
||||||
|
return self._wait_with_poll(
|
||||||
|
account, email, timeout, 3, min_timestamp, used_codes, otp_sent_at
|
||||||
|
)
|
||||||
|
|
||||||
|
# 计算 since_minutes:从发送时间前2分钟开始,最多180分钟
|
||||||
|
since_minutes: Optional[int] = None
|
||||||
|
if otp_sent_at:
|
||||||
|
elapsed_since_send = int((time.time() - otp_sent_at) / 60) + 2
|
||||||
|
since_minutes = min(elapsed_since_send, 180)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
provider = self._get_provider(account)
|
||||||
|
with self._imap_semaphore:
|
||||||
|
with provider:
|
||||||
|
# 先做一次即时检查
|
||||||
|
emails = provider.get_recent_emails(
|
||||||
|
15, only_unseen=(since_minutes is None), since_minutes=since_minutes,
|
||||||
|
folders=_OUTLOOK_SEARCH_FOLDERS,
|
||||||
|
)
|
||||||
|
code = self.email_parser.find_verification_code_in_emails(
|
||||||
|
emails,
|
||||||
|
target_email=email,
|
||||||
|
min_timestamp=min_timestamp,
|
||||||
|
used_codes=used_codes,
|
||||||
|
)
|
||||||
|
if code:
|
||||||
|
elapsed = int(time.time() - start_time)
|
||||||
|
logger.info(f"[{email}] 找到验证码: {code},耗时 {elapsed}s(即时检查)")
|
||||||
|
return code
|
||||||
|
|
||||||
|
# IDLE 等待循环
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
remaining = int(timeout - (time.time() - start_time))
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
arrived = provider.wait_for_new_email_idle(timeout=min(remaining, 25))
|
||||||
|
# 无效化缓存,强制重新拉取
|
||||||
|
self._email_cache.invalidate(email)
|
||||||
|
# IDLE 触发后用 since_minutes 搜索,覆盖已读邮件
|
||||||
|
fetch_since = since_minutes
|
||||||
|
if fetch_since is None:
|
||||||
|
# 没有 otp_sent_at 时,用距当前时间2分钟内的邮件
|
||||||
|
fetch_since = 2
|
||||||
|
emails = provider.get_recent_emails(
|
||||||
|
15, only_unseen=False, since_minutes=fetch_since,
|
||||||
|
folders=_OUTLOOK_SEARCH_FOLDERS,
|
||||||
|
)
|
||||||
|
code = self.email_parser.find_verification_code_in_emails(
|
||||||
|
emails,
|
||||||
|
target_email=email,
|
||||||
|
min_timestamp=min_timestamp,
|
||||||
|
used_codes=used_codes,
|
||||||
|
)
|
||||||
|
if code:
|
||||||
|
elapsed = int(time.time() - start_time)
|
||||||
|
logger.info(
|
||||||
|
f"[{email}] 找到验证码: {code},耗时 {elapsed}s"
|
||||||
|
f"(IDLE {'推送' if arrived else '超时检查'})"
|
||||||
)
|
)
|
||||||
self.update_status(True)
|
|
||||||
return code
|
return code
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[{email}] 检查出错: {e}")
|
logger.warning(f"[{email}] IDLE 失败,降级为轮询: {e}")
|
||||||
|
|
||||||
# 等待下次轮询
|
|
||||||
time.sleep(poll_interval)
|
|
||||||
|
|
||||||
elapsed = int(time.time() - start_time)
|
elapsed = int(time.time() - start_time)
|
||||||
logger.warning(f"[{email}] 验证码超时 ({actual_timeout}s),共轮询 {poll_count} 次")
|
remaining = max(0, timeout - elapsed)
|
||||||
|
if remaining > 0:
|
||||||
|
code_settings = _get_code_settings()
|
||||||
|
return self._wait_with_poll(
|
||||||
|
account, email, remaining,
|
||||||
|
code_settings["poll_interval"], min_timestamp, used_codes, otp_sent_at
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.warning(f"[{email}] IDLE 等待验证码超时 ({timeout}s)")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
|
|
||||||
"""列出所有可用的 Outlook 账户"""
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"email": account.email,
|
|
||||||
"id": account.email,
|
|
||||||
"has_oauth": account.has_oauth(),
|
|
||||||
"type": "outlook"
|
|
||||||
}
|
|
||||||
for account in self.accounts
|
|
||||||
]
|
|
||||||
|
|
||||||
def delete_email(self, email_id: str) -> bool:
|
|
||||||
"""删除邮箱(Outlook 不支持删除账户)"""
|
|
||||||
logger.warning(f"Outlook 服务不支持删除账户: {email_id}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def check_health(self) -> bool:
|
def check_health(self) -> bool:
|
||||||
"""检查 Outlook 服务是否可用"""
|
"""检查 Outlook 服务是否可用"""
|
||||||
if not self.accounts:
|
if not self.accounts:
|
||||||
self.update_status(False, EmailServiceError("没有配置的账户"))
|
self.update_status(False, EmailServiceError("没有配置的账户"))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 测试第一个账户的连接
|
|
||||||
test_account = self.accounts[0]
|
|
||||||
|
|
||||||
# 尝试任一提供者连接
|
|
||||||
for provider_type in self.provider_priority:
|
|
||||||
try:
|
try:
|
||||||
provider = self._get_provider(test_account, provider_type)
|
provider = self._get_provider(self.accounts[0])
|
||||||
if provider.test_connection():
|
if provider.test_connection():
|
||||||
self.update_status(True)
|
self.update_status(True)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(f"Outlook 健康检查失败: {e}")
|
||||||
f"Outlook 健康检查失败 ({test_account.email}, {provider_type.value}): {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.update_status(False, EmailServiceError("健康检查失败"))
|
self.update_status(False, EmailServiceError("健康检查失败"))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_provider_status(self) -> Dict[str, Any]:
|
def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
|
||||||
"""获取提供者状态"""
|
return [
|
||||||
return self.failover_manager.get_status()
|
{
|
||||||
|
"email": a.email,
|
||||||
|
"id": a.email,
|
||||||
|
"has_oauth": a.has_oauth(),
|
||||||
|
"type": "outlook",
|
||||||
|
}
|
||||||
|
for a in self.accounts
|
||||||
|
]
|
||||||
|
|
||||||
|
def delete_email(self, email_id: str) -> bool:
|
||||||
|
logger.warning(f"Outlook 服务不支持删除账户: {email_id}")
|
||||||
|
return False
|
||||||
|
|
||||||
def get_account_stats(self) -> Dict[str, Any]:
|
def get_account_stats(self) -> Dict[str, Any]:
|
||||||
"""获取账户统计信息"""
|
|
||||||
total = len(self.accounts)
|
total = len(self.accounts)
|
||||||
oauth_count = sum(1 for acc in self.accounts if acc.has_oauth())
|
oauth_count = sum(1 for a in self.accounts if a.has_oauth())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_accounts": total,
|
"total_accounts": total,
|
||||||
"oauth_accounts": oauth_count,
|
"oauth_accounts": oauth_count,
|
||||||
"password_accounts": total - oauth_count,
|
"password_accounts": total - oauth_count,
|
||||||
"accounts": [acc.to_dict() for acc in self.accounts],
|
"accounts": [a.to_dict() for a in self.accounts],
|
||||||
"provider_status": self.get_provider_status(),
|
"health_status": self.health_checker.get_status(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def add_account(self, account_config: Dict[str, Any]) -> bool:
|
def add_account(self, account_config: Dict[str, Any]) -> bool:
|
||||||
"""添加新的 Outlook 账户"""
|
|
||||||
try:
|
try:
|
||||||
account = OutlookAccount.from_config(account_config)
|
account = OutlookAccount.from_config(account_config)
|
||||||
if not account.validate():
|
if not account.validate():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self.accounts.append(account)
|
self.accounts.append(account)
|
||||||
logger.info(f"添加 Outlook 账户: {account.email}")
|
logger.info(f"添加 Outlook 账户: {account.email}")
|
||||||
return True
|
return True
|
||||||
@@ -464,24 +454,13 @@ class OutlookService(BaseEmailService):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def remove_account(self, email: str) -> bool:
|
def remove_account(self, email: str) -> bool:
|
||||||
"""移除 Outlook 账户"""
|
for i, a in enumerate(self.accounts):
|
||||||
for i, acc in enumerate(self.accounts):
|
if a.email.lower() == email.lower():
|
||||||
if acc.email.lower() == email.lower():
|
|
||||||
self.accounts.pop(i)
|
self.accounts.pop(i)
|
||||||
logger.info(f"移除 Outlook 账户: {email}")
|
logger.info(f"移除 Outlook 账户: {email}")
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def reset_provider_health(self):
|
def reset_health(self):
|
||||||
"""重置所有提供者的健康状态"""
|
self.health_checker.reset()
|
||||||
self.health_checker.reset_all()
|
logger.info("已重置 IMAP_NEW 健康状态")
|
||||||
logger.info("已重置所有提供者的健康状态")
|
|
||||||
|
|
||||||
def force_provider(self, provider_type: ProviderType):
|
|
||||||
"""强制使用指定的提供者"""
|
|
||||||
self.health_checker.force_enable(provider_type)
|
|
||||||
# 禁用其他提供者
|
|
||||||
for pt in ProviderType:
|
|
||||||
if pt != provider_type:
|
|
||||||
self.health_checker.force_disable(pt, 60)
|
|
||||||
logger.info(f"已强制使用提供者: {provider_type.value}")
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Token 管理器
|
Token 管理器(简化版)
|
||||||
支持多个 Microsoft Token 端点,自动选择合适的端点
|
固定使用 consumers 端点 + IMAP scope
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -11,153 +11,98 @@ from typing import Dict, Optional, Any
|
|||||||
|
|
||||||
from curl_cffi import requests as _requests
|
from curl_cffi import requests as _requests
|
||||||
|
|
||||||
from .base import ProviderType, TokenEndpoint, TokenInfo
|
from .base import TokenInfo
|
||||||
from .account import OutlookAccount
|
from .account import OutlookAccount
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
|
||||||
# 各提供者的 Scope 配置
|
IMAP_SCOPE = "https://outlook.office.com/IMAP.AccessAsUser.All offline_access"
|
||||||
PROVIDER_SCOPES = {
|
|
||||||
ProviderType.IMAP_OLD: "", # 旧版 IMAP 不需要特定 scope
|
|
||||||
ProviderType.IMAP_NEW: "https://outlook.office.com/IMAP.AccessAsUser.All offline_access",
|
|
||||||
ProviderType.GRAPH_API: "https://graph.microsoft.com/.default",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 各提供者的 Token 端点
|
|
||||||
PROVIDER_TOKEN_URLS = {
|
|
||||||
ProviderType.IMAP_OLD: TokenEndpoint.LIVE.value,
|
|
||||||
ProviderType.IMAP_NEW: TokenEndpoint.CONSUMERS.value,
|
|
||||||
ProviderType.GRAPH_API: TokenEndpoint.COMMON.value,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TokenManager:
|
class TokenManager:
|
||||||
"""
|
"""
|
||||||
Token 管理器
|
Token 管理器
|
||||||
支持多端点 Token 获取和缓存
|
固定 consumers 端点,缓存 key = email
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Token 缓存: key = (email, provider_type) -> TokenInfo
|
_token_cache: Dict[str, TokenInfo] = {}
|
||||||
_token_cache: Dict[tuple, TokenInfo] = {}
|
|
||||||
_cache_lock = threading.Lock()
|
_cache_lock = threading.Lock()
|
||||||
|
|
||||||
# 默认超时时间
|
|
||||||
DEFAULT_TIMEOUT = 30
|
DEFAULT_TIMEOUT = 30
|
||||||
# Token 刷新提前时间(秒)
|
|
||||||
REFRESH_BUFFER = 120
|
REFRESH_BUFFER = 120
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
account: OutlookAccount,
|
account: OutlookAccount,
|
||||||
provider_type: ProviderType,
|
|
||||||
proxy_url: Optional[str] = None,
|
proxy_url: Optional[str] = None,
|
||||||
timeout: int = DEFAULT_TIMEOUT,
|
timeout: int = DEFAULT_TIMEOUT,
|
||||||
|
service_id: Optional[int] = None,
|
||||||
):
|
):
|
||||||
"""
|
|
||||||
初始化 Token 管理器
|
|
||||||
|
|
||||||
Args:
|
|
||||||
account: Outlook 账户
|
|
||||||
provider_type: 提供者类型
|
|
||||||
proxy_url: 代理 URL(可选)
|
|
||||||
timeout: 请求超时时间
|
|
||||||
"""
|
|
||||||
self.account = account
|
self.account = account
|
||||||
self.provider_type = provider_type
|
|
||||||
self.proxy_url = proxy_url
|
self.proxy_url = proxy_url
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
self.service_id = service_id
|
||||||
|
|
||||||
# 获取端点和 Scope
|
def _cache_key(self) -> str:
|
||||||
self.token_url = PROVIDER_TOKEN_URLS.get(provider_type, TokenEndpoint.LIVE.value)
|
return self.account.email.lower()
|
||||||
self.scope = PROVIDER_SCOPES.get(provider_type, "")
|
|
||||||
|
|
||||||
def get_cached_token(self) -> Optional[TokenInfo]:
|
def get_cached_token(self) -> Optional[TokenInfo]:
|
||||||
"""获取缓存的 Token"""
|
|
||||||
cache_key = (self.account.email.lower(), self.provider_type)
|
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
token = self._token_cache.get(cache_key)
|
token = self._token_cache.get(self._cache_key())
|
||||||
if token and not token.is_expired(self.REFRESH_BUFFER):
|
if token and not token.is_expired(self.REFRESH_BUFFER):
|
||||||
return token
|
return token
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def set_cached_token(self, token: TokenInfo):
|
def set_cached_token(self, token: TokenInfo):
|
||||||
"""缓存 Token"""
|
|
||||||
cache_key = (self.account.email.lower(), self.provider_type)
|
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
self._token_cache[cache_key] = token
|
self._token_cache[self._cache_key()] = token
|
||||||
|
|
||||||
def clear_cache(self):
|
def clear_cache(self):
|
||||||
"""清除缓存"""
|
|
||||||
cache_key = (self.account.email.lower(), self.provider_type)
|
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
self._token_cache.pop(cache_key, None)
|
self._token_cache.pop(self._cache_key(), None)
|
||||||
|
|
||||||
def get_access_token(self, force_refresh: bool = False) -> Optional[str]:
|
def get_access_token(self, force_refresh: bool = False) -> Optional[str]:
|
||||||
"""
|
|
||||||
获取 Access Token
|
|
||||||
|
|
||||||
Args:
|
|
||||||
force_refresh: 是否强制刷新
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Access Token 字符串,失败返回 None
|
|
||||||
"""
|
|
||||||
# 检查缓存
|
|
||||||
if not force_refresh:
|
if not force_refresh:
|
||||||
cached = self.get_cached_token()
|
cached = self.get_cached_token()
|
||||||
if cached:
|
if cached:
|
||||||
logger.debug(f"[{self.account.email}] 使用缓存的 Token ({self.provider_type.value})")
|
logger.debug(f"[{self.account.email}] 使用缓存 Token")
|
||||||
return cached.access_token
|
return cached.access_token
|
||||||
|
|
||||||
# 刷新 Token
|
|
||||||
try:
|
try:
|
||||||
token = self._refresh_token()
|
token = self._refresh_token()
|
||||||
if token:
|
if token:
|
||||||
self.set_cached_token(token)
|
self.set_cached_token(token)
|
||||||
return token.access_token
|
return token.access_token
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{self.account.email}] 获取 Token 失败 ({self.provider_type.value}): {e}")
|
logger.error(f"[{self.account.email}] 获取 Token 失败: {e}")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _refresh_token(self) -> Optional[TokenInfo]:
|
def _refresh_token(self) -> Optional[TokenInfo]:
|
||||||
"""
|
|
||||||
刷新 Token
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TokenInfo 对象,失败返回 None
|
|
||||||
"""
|
|
||||||
if not self.account.client_id or not self.account.refresh_token:
|
if not self.account.client_id or not self.account.refresh_token:
|
||||||
raise ValueError("缺少 client_id 或 refresh_token")
|
raise ValueError("缺少 client_id 或 refresh_token")
|
||||||
|
|
||||||
logger.debug(f"[{self.account.email}] 正在刷新 Token ({self.provider_type.value})...")
|
logger.debug(f"[{self.account.email}] 正在刷新 Token...")
|
||||||
logger.debug(f"[{self.account.email}] Token URL: {self.token_url}")
|
|
||||||
|
|
||||||
# 构建请求体
|
|
||||||
data = {
|
data = {
|
||||||
"client_id": self.account.client_id,
|
"client_id": self.account.client_id,
|
||||||
"refresh_token": self.account.refresh_token,
|
"refresh_token": self.account.refresh_token,
|
||||||
"grant_type": "refresh_token",
|
"grant_type": "refresh_token",
|
||||||
|
"scope": IMAP_SCOPE,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 添加 Scope(如果需要)
|
|
||||||
if self.scope:
|
|
||||||
data["scope"] = self.scope
|
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
proxies = None
|
proxies = None
|
||||||
if self.proxy_url:
|
if self.proxy_url:
|
||||||
proxies = {"http": self.proxy_url, "https": self.proxy_url}
|
proxies = {"http": self.proxy_url, "https": self.proxy_url}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = _requests.post(
|
resp = _requests.post(
|
||||||
self.token_url,
|
TOKEN_URL,
|
||||||
data=data,
|
data=data,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
proxies=proxies,
|
proxies=proxies,
|
||||||
@@ -166,74 +111,56 @@ class TokenManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
error_body = resp.text
|
body = resp.text
|
||||||
logger.error(f"[{self.account.email}] Token 刷新失败: HTTP {resp.status_code}")
|
logger.error(f"[{self.account.email}] Token 刷新失败: HTTP {resp.status_code}")
|
||||||
logger.debug(f"[{self.account.email}] 错误响应: {error_body[:500]}")
|
if "service abuse" in body.lower():
|
||||||
|
|
||||||
if "service abuse" in error_body.lower():
|
|
||||||
logger.warning(f"[{self.account.email}] 账号可能被封禁")
|
logger.warning(f"[{self.account.email}] 账号可能被封禁")
|
||||||
elif "invalid_grant" in error_body.lower():
|
elif "invalid_grant" in body.lower():
|
||||||
logger.warning(f"[{self.account.email}] Refresh Token 已失效")
|
logger.warning(f"[{self.account.email}] Refresh Token 已失效")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
response_data = resp.json()
|
response_data = resp.json()
|
||||||
|
token = TokenInfo.from_response(response_data, IMAP_SCOPE)
|
||||||
# 解析响应
|
|
||||||
token = TokenInfo.from_response(response_data, self.scope)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[{self.account.email}] Token 刷新成功 ({self.provider_type.value}), "
|
f"[{self.account.email}] Token 刷新成功,"
|
||||||
f"有效期 {int(token.expires_at - time.time())} 秒"
|
f"有效期 {int(token.expires_at - time.time())} 秒"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 若响应含新 refresh_token → 写回内存 + 持久化数据库
|
||||||
|
new_rt = response_data.get("refresh_token", "")
|
||||||
|
if new_rt and new_rt != self.account.refresh_token:
|
||||||
|
self.account.refresh_token = new_rt
|
||||||
|
if self.service_id:
|
||||||
|
try:
|
||||||
|
from ...database.session import get_session_manager
|
||||||
|
from ...database.crud import update_outlook_refresh_token
|
||||||
|
with get_session_manager().session_scope() as db:
|
||||||
|
update_outlook_refresh_token(
|
||||||
|
db, self.service_id, self.account.email, new_rt
|
||||||
|
)
|
||||||
|
logger.info(f"[{self.account.email}] refresh_token 已写回数据库")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[{self.account.email}] 写回 refresh_token 失败: {e}")
|
||||||
|
|
||||||
return token
|
return token
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.error(f"[{self.account.email}] JSON 解析错误: {e}")
|
logger.error(f"[{self.account.email}] JSON 解析错误: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{self.account.email}] 未知错误: {e}")
|
logger.error(f"[{self.account.email}] 未知错误: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def clear_all_cache(cls):
|
def clear_all_cache(cls):
|
||||||
"""清除所有 Token 缓存"""
|
|
||||||
with cls._cache_lock:
|
with cls._cache_lock:
|
||||||
cls._token_cache.clear()
|
cls._token_cache.clear()
|
||||||
logger.info("已清除所有 Token 缓存")
|
logger.info("已清除所有 Token 缓存")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_cache_stats(cls) -> Dict[str, Any]:
|
def get_cache_stats(cls) -> Dict[str, Any]:
|
||||||
"""获取缓存统计"""
|
|
||||||
with cls._cache_lock:
|
with cls._cache_lock:
|
||||||
return {
|
return {
|
||||||
"cache_size": len(cls._token_cache),
|
"cache_size": len(cls._token_cache),
|
||||||
"entries": [
|
"entries": list(cls._token_cache.keys()),
|
||||||
{
|
|
||||||
"email": key[0],
|
|
||||||
"provider": key[1].value,
|
|
||||||
}
|
}
|
||||||
for key in cls._token_cache.keys()
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def create_token_manager(
|
|
||||||
account: OutlookAccount,
|
|
||||||
provider_type: ProviderType,
|
|
||||||
proxy_url: Optional[str] = None,
|
|
||||||
timeout: int = TokenManager.DEFAULT_TIMEOUT,
|
|
||||||
) -> TokenManager:
|
|
||||||
"""
|
|
||||||
创建 Token 管理器的工厂函数
|
|
||||||
|
|
||||||
Args:
|
|
||||||
account: Outlook 账户
|
|
||||||
provider_type: 提供者类型
|
|
||||||
proxy_url: 代理 URL
|
|
||||||
timeout: 超时时间
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TokenManager 实例
|
|
||||||
"""
|
|
||||||
return TokenManager(account, provider_type, proxy_url, timeout)
|
|
||||||
|
|||||||
@@ -1028,9 +1028,11 @@ def _build_inbox_config(db, service_type, email: str) -> dict:
|
|||||||
EmailServiceModel.enabled == True
|
EmailServiceModel.enabled == True
|
||||||
)
|
)
|
||||||
if service_type == EST.OUTLOOK:
|
if service_type == EST.OUTLOOK:
|
||||||
# 按 config.email 匹配账号 email
|
# 按 config.email 精确匹配,不受 enabled 限制(收件箱是账号自己的邮箱)
|
||||||
services = query.all()
|
all_outlook = db.query(EmailServiceModel).filter(
|
||||||
svc = next((s for s in services if (s.config or {}).get("email") == email), None)
|
EmailServiceModel.service_type == db_type
|
||||||
|
).all()
|
||||||
|
svc = next((s for s in all_outlook if (s.config or {}).get("email", "").lower() == email.lower()), None)
|
||||||
else:
|
else:
|
||||||
svc = query.order_by(EmailServiceModel.priority.asc()).first()
|
svc = query.order_by(EmailServiceModel.priority.asc()).first()
|
||||||
|
|
||||||
|
|||||||
+102
-18
@@ -67,7 +67,7 @@ class ServiceTestResult(BaseModel):
|
|||||||
|
|
||||||
class OutlookBatchImportRequest(BaseModel):
|
class OutlookBatchImportRequest(BaseModel):
|
||||||
"""Outlook 批量导入请求"""
|
"""Outlook 批量导入请求"""
|
||||||
data: str # 多行数据,每行格式: 邮箱----密码 或 邮箱----密码----client_id----refresh_token
|
data: str # 多行数据,每行格式: 邮箱----密码----client_id----refresh_token
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
priority: int = 0
|
priority: int = 0
|
||||||
|
|
||||||
@@ -461,11 +461,8 @@ async def batch_import_outlook(request: OutlookBatchImportRequest):
|
|||||||
"""
|
"""
|
||||||
批量导入 Outlook 邮箱账户
|
批量导入 Outlook 邮箱账户
|
||||||
|
|
||||||
支持两种格式:
|
格式(每行):邮箱----密码----client_id----refresh_token
|
||||||
- 格式一(密码认证):邮箱----密码
|
使用四个连字符(----)分隔字段
|
||||||
- 格式二(XOAUTH2 认证):邮箱----密码----client_id----refresh_token
|
|
||||||
|
|
||||||
每行一个账户,使用四个连字符(----)分隔字段
|
|
||||||
"""
|
"""
|
||||||
lines = request.data.strip().split("\n")
|
lines = request.data.strip().split("\n")
|
||||||
total = len(lines)
|
total = len(lines)
|
||||||
@@ -484,14 +481,18 @@ async def batch_import_outlook(request: OutlookBatchImportRequest):
|
|||||||
|
|
||||||
parts = line.split("----")
|
parts = line.split("----")
|
||||||
|
|
||||||
# 验证格式
|
# 必须是四字段格式
|
||||||
if len(parts) < 2:
|
if len(parts) < 4:
|
||||||
failed += 1
|
failed += 1
|
||||||
errors.append(f"行 {i+1}: 格式错误,至少需要邮箱和密码")
|
errors.append(
|
||||||
|
f"行 {i+1}: 格式错误,必须为 邮箱----密码----client_id----refresh_token"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
email = parts[0].strip()
|
email = parts[0].strip()
|
||||||
password = parts[1].strip()
|
password = parts[1].strip()
|
||||||
|
client_id = parts[2].strip()
|
||||||
|
refresh_token = parts[3].strip()
|
||||||
|
|
||||||
# 验证邮箱格式
|
# 验证邮箱格式
|
||||||
if "@" not in email:
|
if "@" not in email:
|
||||||
@@ -499,6 +500,12 @@ async def batch_import_outlook(request: OutlookBatchImportRequest):
|
|||||||
errors.append(f"行 {i+1}: 无效的邮箱地址: {email}")
|
errors.append(f"行 {i+1}: 无效的邮箱地址: {email}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 验证 OAuth 字段非空
|
||||||
|
if not client_id or not refresh_token:
|
||||||
|
failed += 1
|
||||||
|
errors.append(f"行 {i+1}: [{email}] client_id 或 refresh_token 不能为空")
|
||||||
|
continue
|
||||||
|
|
||||||
# 检查是否已存在
|
# 检查是否已存在
|
||||||
existing = db.query(EmailServiceModel).filter(
|
existing = db.query(EmailServiceModel).filter(
|
||||||
EmailServiceModel.service_type == "outlook",
|
EmailServiceModel.service_type == "outlook",
|
||||||
@@ -513,17 +520,11 @@ async def batch_import_outlook(request: OutlookBatchImportRequest):
|
|||||||
# 构建配置
|
# 构建配置
|
||||||
config = {
|
config = {
|
||||||
"email": email,
|
"email": email,
|
||||||
"password": password
|
"password": password,
|
||||||
|
"client_id": client_id,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 检查是否有 OAuth 信息(格式二)
|
|
||||||
if len(parts) >= 4:
|
|
||||||
client_id = parts[2].strip()
|
|
||||||
refresh_token = parts[3].strip()
|
|
||||||
if client_id and refresh_token:
|
|
||||||
config["client_id"] = client_id
|
|
||||||
config["refresh_token"] = refresh_token
|
|
||||||
|
|
||||||
# 创建服务记录
|
# 创建服务记录
|
||||||
try:
|
try:
|
||||||
service = EmailServiceModel(
|
service = EmailServiceModel(
|
||||||
@@ -608,3 +609,86 @@ async def test_tempmail_service(request: TempmailTestRequest):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"测试临时邮箱失败: {e}")
|
logger.error(f"测试临时邮箱失败: {e}")
|
||||||
return {"success": False, "message": f"测试失败: {str(e)}"}
|
return {"success": False, "message": f"测试失败: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ============== 收件箱 ==============
|
||||||
|
|
||||||
|
@router.get("/{service_id}/inbox")
|
||||||
|
async def get_outlook_inbox(
|
||||||
|
service_id: int,
|
||||||
|
count: int = Query(30, ge=1, le=100),
|
||||||
|
only_unseen: bool = Query(False),
|
||||||
|
):
|
||||||
|
"""获取 Outlook 收件箱邮件列表"""
|
||||||
|
with get_db() as db:
|
||||||
|
service = db.query(EmailServiceModel).filter(EmailServiceModel.id == service_id).first()
|
||||||
|
if not service:
|
||||||
|
raise HTTPException(status_code=404, detail="服务不存在")
|
||||||
|
if service.service_type != "outlook":
|
||||||
|
raise HTTPException(status_code=400, detail="仅支持 Outlook 类型服务")
|
||||||
|
|
||||||
|
config = service.config or {}
|
||||||
|
email_addr = config.get("email", "")
|
||||||
|
client_id = config.get("client_id", "")
|
||||||
|
refresh_token = config.get("refresh_token", "")
|
||||||
|
|
||||||
|
# client_id 为空时尝试使用全局默认值
|
||||||
|
if not client_id:
|
||||||
|
from ...config.settings import get_settings
|
||||||
|
client_id = get_settings().outlook_default_client_id or ""
|
||||||
|
|
||||||
|
if not client_id or not refresh_token:
|
||||||
|
raise HTTPException(status_code=400, detail="该账户缺少 OAuth 配置(client_id / refresh_token),无法读取收件箱")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ...services.outlook.account import OutlookAccount
|
||||||
|
from ...services.outlook.token_manager import TokenManager
|
||||||
|
from ...services.outlook.providers.imap_new import IMAPNewProvider
|
||||||
|
from ...services.outlook.providers.base import ProviderConfig
|
||||||
|
|
||||||
|
account = OutlookAccount(
|
||||||
|
email=email_addr,
|
||||||
|
password=config.get("password", ""),
|
||||||
|
client_id=client_id,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
)
|
||||||
|
provider_config = ProviderConfig(
|
||||||
|
proxy_url=None,
|
||||||
|
timeout=30,
|
||||||
|
service_id=service_id,
|
||||||
|
)
|
||||||
|
provider = IMAPNewProvider(account, provider_config)
|
||||||
|
|
||||||
|
connected = provider.connect()
|
||||||
|
if not connected:
|
||||||
|
raise HTTPException(status_code=502, detail="IMAP 连接失败,请检查 OAuth 配置")
|
||||||
|
|
||||||
|
try:
|
||||||
|
messages = provider.get_recent_emails(count=count, only_unseen=only_unseen)
|
||||||
|
finally:
|
||||||
|
provider.disconnect()
|
||||||
|
|
||||||
|
emails = []
|
||||||
|
for m in messages:
|
||||||
|
received_str = m.received_at.isoformat() if m.received_at else None
|
||||||
|
emails.append({
|
||||||
|
"id": m.id or "",
|
||||||
|
"subject": m.subject or "",
|
||||||
|
"sender": m.sender or "",
|
||||||
|
"received_at": received_str,
|
||||||
|
"body_preview": m.body_preview or (m.body or "")[:200],
|
||||||
|
"body": m.body or "",
|
||||||
|
"is_read": m.is_read,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"email": email_addr,
|
||||||
|
"total": len(emails),
|
||||||
|
"emails": emails,
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取收件箱失败 service_id={service_id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取收件箱失败: {str(e)}")
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ from pydantic import BaseModel, Field
|
|||||||
from ...database import crud
|
from ...database import crud
|
||||||
from ...database.session import get_db
|
from ...database.session import get_db
|
||||||
from ...database.models import RegistrationTask, Proxy
|
from ...database.models import RegistrationTask, Proxy
|
||||||
from ...core.register import RegistrationEngine, RegistrationResult
|
from ...core.login import LoginEngine
|
||||||
|
from ...core.register import RegistrationResult
|
||||||
from ...services import EmailServiceFactory, EmailServiceType
|
from ...services import EmailServiceFactory, EmailServiceType
|
||||||
from ...config.settings import get_settings
|
from ...config.settings import get_settings
|
||||||
from ..task_manager import task_manager
|
from ..task_manager import task_manager
|
||||||
@@ -340,6 +341,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy:
|
|||||||
|
|
||||||
if selected_service and selected_service.config:
|
if selected_service and selected_service.config:
|
||||||
config = selected_service.config.copy()
|
config = selected_service.config.copy()
|
||||||
|
config['service_id'] = selected_service.id
|
||||||
crud.update_registration_task(db, task_uuid, email_service_id=selected_service.id)
|
crud.update_registration_task(db, task_uuid, email_service_id=selected_service.id)
|
||||||
logger.info(f"使用数据库 Outlook 账户: {selected_service.name}")
|
logger.info(f"使用数据库 Outlook 账户: {selected_service.name}")
|
||||||
else:
|
else:
|
||||||
@@ -394,7 +396,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy:
|
|||||||
# 创建注册引擎 - 使用 TaskManager 的日志回调
|
# 创建注册引擎 - 使用 TaskManager 的日志回调
|
||||||
log_callback = task_manager.create_log_callback(task_uuid, prefix=log_prefix, batch_id=batch_id)
|
log_callback = task_manager.create_log_callback(task_uuid, prefix=log_prefix, batch_id=batch_id)
|
||||||
|
|
||||||
engine = RegistrationEngine(
|
engine = LoginEngine(
|
||||||
email_service=email_service,
|
email_service=email_service,
|
||||||
proxy_url=actual_proxy_url,
|
proxy_url=actual_proxy_url,
|
||||||
callback_logger=log_callback,
|
callback_logger=log_callback,
|
||||||
|
|||||||
@@ -720,7 +720,6 @@ async def get_outlook_settings():
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"default_client_id": settings.outlook_default_client_id,
|
"default_client_id": settings.outlook_default_client_id,
|
||||||
"provider_priority": settings.outlook_provider_priority,
|
|
||||||
"health_failure_threshold": settings.outlook_health_failure_threshold,
|
"health_failure_threshold": settings.outlook_health_failure_threshold,
|
||||||
"health_disable_duration": settings.outlook_health_disable_duration,
|
"health_disable_duration": settings.outlook_health_disable_duration,
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -191,13 +191,23 @@ class TaskManager:
|
|||||||
return _log_queues.get(task_uuid, []).copy()
|
return _log_queues.get(task_uuid, []).copy()
|
||||||
|
|
||||||
def update_status(self, task_uuid: str, status: str, **kwargs):
|
def update_status(self, task_uuid: str, status: str, **kwargs):
|
||||||
"""更新任务状态"""
|
"""更新任务状态并推送到 WebSocket"""
|
||||||
if task_uuid not in _task_status:
|
if task_uuid not in _task_status:
|
||||||
_task_status[task_uuid] = {}
|
_task_status[task_uuid] = {}
|
||||||
|
|
||||||
_task_status[task_uuid]["status"] = status
|
_task_status[task_uuid]["status"] = status
|
||||||
_task_status[task_uuid].update(kwargs)
|
_task_status[task_uuid].update(kwargs)
|
||||||
|
|
||||||
|
# 推送状态变更到 WebSocket(线程安全,兼容同步线程调用)
|
||||||
|
if self._loop and self._loop.is_running():
|
||||||
|
try:
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
self.broadcast_status(task_uuid, status, **kwargs),
|
||||||
|
self._loop
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"推送状态到 WebSocket 失败: {e}")
|
||||||
|
|
||||||
def get_status(self, task_uuid: str) -> Optional[dict]:
|
def get_status(self, task_uuid: str) -> Optional[dict]:
|
||||||
"""获取任务状态"""
|
"""获取任务状态"""
|
||||||
return _task_status.get(task_uuid)
|
return _task_status.get(task_uuid)
|
||||||
|
|||||||
+4
-4
@@ -1049,7 +1049,6 @@ function resetButtons() {
|
|||||||
elements.cancelBtn.disabled = true;
|
elements.cancelBtn.disabled = true;
|
||||||
currentTask = null;
|
currentTask = null;
|
||||||
currentBatch = null;
|
currentBatch = null;
|
||||||
isBatchMode = false;
|
|
||||||
// 重置完成标志
|
// 重置完成标志
|
||||||
taskCompleted = false;
|
taskCompleted = false;
|
||||||
batchCompleted = false;
|
batchCompleted = false;
|
||||||
@@ -1275,12 +1274,13 @@ function connectBatchWebSocket(batchId) {
|
|||||||
if (!toastShown) {
|
if (!toastShown) {
|
||||||
toastShown = true;
|
toastShown = true;
|
||||||
if (data.status === 'completed') {
|
if (data.status === 'completed') {
|
||||||
addLog('success', `[完成] Outlook 批量任务完成!成功: ${data.success}, 失败: ${data.failed}, 跳过: ${data.skipped || 0}`);
|
const batchLabel = isOutlookBatchMode ? 'Outlook 批量' : '批量';
|
||||||
|
addLog('success', `[完成] ${batchLabel}任务完成!成功: ${data.success}, 失败: ${data.failed}, 跳过: ${data.skipped || 0}`);
|
||||||
if (data.success > 0) {
|
if (data.success > 0) {
|
||||||
toast.success(`Outlook 批量注册完成,成功 ${data.success} 个`);
|
toast.success(`${batchLabel}注册完成,成功 ${data.success} 个`);
|
||||||
loadRecentAccounts();
|
loadRecentAccounts();
|
||||||
} else {
|
} else {
|
||||||
toast.warning('Outlook 批量注册完成,但没有成功注册任何账号');
|
toast.warning(`${batchLabel}注册完成,但没有成功注册任何账号`);
|
||||||
}
|
}
|
||||||
} else if (data.status === 'failed') {
|
} else if (data.status === 'failed') {
|
||||||
addLog('error', '[错误] 批量任务执行失败');
|
addLog('error', '[错误] 批量任务执行失败');
|
||||||
|
|||||||
@@ -72,6 +72,25 @@ const elements = {
|
|||||||
editOutlookForm: document.getElementById('edit-outlook-form'),
|
editOutlookForm: document.getElementById('edit-outlook-form'),
|
||||||
closeEditOutlookModal: document.getElementById('close-edit-outlook-modal'),
|
closeEditOutlookModal: document.getElementById('close-edit-outlook-modal'),
|
||||||
cancelEditOutlook: document.getElementById('cancel-edit-outlook'),
|
cancelEditOutlook: document.getElementById('cancel-edit-outlook'),
|
||||||
|
|
||||||
|
// 收件箱模态框
|
||||||
|
inboxModal: document.getElementById('inbox-modal'),
|
||||||
|
closeInboxModal: document.getElementById('close-inbox-modal'),
|
||||||
|
inboxRefreshBtn: document.getElementById('inbox-refresh-btn'),
|
||||||
|
inboxOnlyUnseen: document.getElementById('inbox-only-unseen'),
|
||||||
|
inboxLoading: document.getElementById('inbox-loading'),
|
||||||
|
inboxTable: document.getElementById('inbox-table'),
|
||||||
|
inboxTbody: document.getElementById('inbox-tbody'),
|
||||||
|
inboxEmpty: document.getElementById('inbox-empty'),
|
||||||
|
inboxModalEmail: document.getElementById('inbox-modal-email'),
|
||||||
|
|
||||||
|
// 邮件正文模态框
|
||||||
|
emailDetailModal: document.getElementById('email-detail-modal'),
|
||||||
|
closeEmailDetailModal: document.getElementById('close-email-detail-modal'),
|
||||||
|
emailDetailSubject: document.getElementById('email-detail-subject'),
|
||||||
|
emailDetailSender: document.getElementById('email-detail-sender'),
|
||||||
|
emailDetailDate: document.getElementById('email-detail-date'),
|
||||||
|
emailDetailBody: document.getElementById('email-detail-body'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const CUSTOM_SUBTYPE_LABELS = {
|
const CUSTOM_SUBTYPE_LABELS = {
|
||||||
@@ -164,6 +183,12 @@ function initEventListeners() {
|
|||||||
document.addEventListener('click', () => {
|
document.addEventListener('click', () => {
|
||||||
document.querySelectorAll('.dropdown-menu.active').forEach(m => m.classList.remove('active'));
|
document.querySelectorAll('.dropdown-menu.active').forEach(m => m.classList.remove('active'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 收件箱模态框事件
|
||||||
|
elements.closeInboxModal.addEventListener('click', () => elements.inboxModal.classList.remove('active'));
|
||||||
|
elements.closeEmailDetailModal.addEventListener('click', () => elements.emailDetailModal.classList.remove('active'));
|
||||||
|
elements.inboxRefreshBtn.addEventListener('click', () => loadInbox(currentInboxServiceId, true));
|
||||||
|
elements.inboxOnlyUnseen.addEventListener('change', () => loadInbox(currentInboxServiceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleEmailMoreMenu(btn) {
|
function toggleEmailMoreMenu(btn) {
|
||||||
@@ -247,6 +272,7 @@ async function loadOutlookServices() {
|
|||||||
<td>${format.date(service.last_used)}</td>
|
<td>${format.date(service.last_used)}</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex;gap:4px;align-items:center;white-space:nowrap;">
|
<div style="display:flex;gap:4px;align-items:center;white-space:nowrap;">
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="openInboxModal(${service.id}, '${escapeHtml(service.config?.email || service.name)}')">收件箱</button>
|
||||||
<button class="btn btn-secondary btn-sm" onclick="editOutlookService(${service.id})">编辑</button>
|
<button class="btn btn-secondary btn-sm" onclick="editOutlookService(${service.id})">编辑</button>
|
||||||
<div class="dropdown" style="position:relative;">
|
<div class="dropdown" style="position:relative;">
|
||||||
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation();toggleEmailMoreMenu(this)">更多</button>
|
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation();toggleEmailMoreMenu(this)">更多</button>
|
||||||
@@ -787,3 +813,57 @@ async function handleEditOutlook(e) {
|
|||||||
toast.error('更新失败: ' + error.message);
|
toast.error('更新失败: ' + error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============== 收件箱 ==============
|
||||||
|
|
||||||
|
let currentInboxServiceId = null;
|
||||||
|
|
||||||
|
async function openInboxModal(serviceId, email) {
|
||||||
|
currentInboxServiceId = serviceId;
|
||||||
|
elements.inboxModalEmail.textContent = email;
|
||||||
|
elements.inboxOnlyUnseen.checked = false;
|
||||||
|
elements.inboxModal.classList.add('active');
|
||||||
|
await loadInbox(serviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInbox(serviceId) {
|
||||||
|
if (!serviceId) return;
|
||||||
|
const onlyUnseen = elements.inboxOnlyUnseen.checked;
|
||||||
|
elements.inboxLoading.style.display = 'block';
|
||||||
|
elements.inboxTable.style.display = 'none';
|
||||||
|
elements.inboxEmpty.style.display = 'none';
|
||||||
|
elements.inboxEmpty.textContent = '暂无邮件';
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ count: 50, only_unseen: onlyUnseen });
|
||||||
|
const data = await api.get(`/email-services/${serviceId}/inbox?${params}`);
|
||||||
|
const emails = data.emails || [];
|
||||||
|
elements.inboxLoading.style.display = 'none';
|
||||||
|
if (emails.length === 0) {
|
||||||
|
elements.inboxEmpty.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
elements.inboxTbody.innerHTML = emails.map(m => {
|
||||||
|
const dataAttr = escapeHtml(JSON.stringify(m));
|
||||||
|
return `<tr style="cursor:pointer;" onclick="showEmailDetail(JSON.parse(this.dataset.mail))" data-mail="${dataAttr}">
|
||||||
|
<td style="text-align:center;">${m.is_read ? '' : '<span style="color:var(--primary);font-size:10px;">●</span>'}</td>
|
||||||
|
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(m.subject)}">${escapeHtml(m.subject) || '(无主题)'}</td>
|
||||||
|
<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(m.sender)}">${escapeHtml(m.sender)}</td>
|
||||||
|
<td>${format.date(m.received_at)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
elements.inboxTable.style.display = 'table';
|
||||||
|
} catch (e) {
|
||||||
|
elements.inboxLoading.style.display = 'none';
|
||||||
|
elements.inboxEmpty.style.display = 'block';
|
||||||
|
elements.inboxEmpty.textContent = '加载失败:' + (e.message || '未知错误');
|
||||||
|
console.error('加载收件箱失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEmailDetail(mail) {
|
||||||
|
elements.emailDetailSubject.textContent = mail.subject || '(无主题)';
|
||||||
|
elements.emailDetailSender.textContent = mail.sender || '';
|
||||||
|
elements.emailDetailDate.textContent = format.date(mail.received_at);
|
||||||
|
elements.emailDetailBody.textContent = mail.body || mail.body_preview || '(无正文)';
|
||||||
|
elements.emailDetailModal.classList.add('active');
|
||||||
|
}
|
||||||
|
|||||||
@@ -678,16 +678,16 @@ async function handleOutlookBatchImport() {
|
|||||||
|
|
||||||
lines.forEach((line, index) => {
|
lines.forEach((line, index) => {
|
||||||
const parts = line.split('----').map(p => p.trim());
|
const parts = line.split('----').map(p => p.trim());
|
||||||
if (parts.length < 2) {
|
if (parts.length < 4) {
|
||||||
errors.push(`第 ${index + 1} 行格式错误`);
|
errors.push(`第 ${index + 1} 行格式错误,必须为 邮箱----密码----client_id----refresh_token`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const account = {
|
const account = {
|
||||||
email: parts[0],
|
email: parts[0],
|
||||||
password: parts[1],
|
password: parts[1],
|
||||||
client_id: parts[2] || null,
|
client_id: parts[2],
|
||||||
refresh_token: parts[3] || null,
|
refresh_token: parts[3],
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
priority: priority
|
priority: priority
|
||||||
};
|
};
|
||||||
@@ -697,6 +697,11 @@ async function handleOutlookBatchImport() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!account.client_id || !account.refresh_token) {
|
||||||
|
errors.push(`第 ${index + 1} 行 client_id 或 refresh_token 不能为空`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
accounts.push(account);
|
accounts.push(account);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -62,16 +62,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body" id="outlook-import-body" style="display: none;">
|
<div class="card-body" id="outlook-import-body" style="display: none;">
|
||||||
<div class="import-info">
|
<div class="import-info">
|
||||||
<p><strong>支持格式:</strong></p>
|
<p><strong>格式(每行一个账户):</strong></p>
|
||||||
<ul>
|
<p><code>邮箱----密码----client_id----refresh_token</code></p>
|
||||||
<li><code>邮箱----密码</code> (密码认证)</li>
|
<p>使用四个连字符(----)分隔字段,以 # 开头的行将被忽略。</p>
|
||||||
<li><code>邮箱----密码----client_id----refresh_token</code> (XOAUTH2 认证,推荐)</li>
|
|
||||||
</ul>
|
|
||||||
<p>每行一个账户,使用四个连字符(----)分隔字段。以 # 开头的行将被忽略。</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="outlook-import-data">批量导入数据</label>
|
<label for="outlook-import-data">批量导入数据</label>
|
||||||
<textarea id="outlook-import-data" rows="8" placeholder="example@outlook.com----password123 test@outlook.com----password456----client_id----refresh_token"></textarea>
|
<textarea id="outlook-import-data" rows="8" placeholder="example@outlook.com----password123----client_id----refresh_token"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -516,6 +513,55 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 收件箱模态框 -->
|
||||||
|
<div class="modal" id="inbox-modal">
|
||||||
|
<div class="modal-content" style="max-width:800px;width:95%;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>📬 收件箱 — <span id="inbox-modal-email"></span></h3>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;">
|
||||||
|
<label style="display:flex;align-items:center;gap:4px;font-size:13px;">
|
||||||
|
<input type="checkbox" id="inbox-only-unseen"> 仅未读
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-secondary btn-sm" id="inbox-refresh-btn">刷新</button>
|
||||||
|
<button class="modal-close" id="close-inbox-modal">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="padding:0;max-height:70vh;overflow-y:auto;">
|
||||||
|
<div id="inbox-loading" style="padding:32px;text-align:center;">加载中...</div>
|
||||||
|
<table class="data-table" id="inbox-table" style="display:none;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:36px;"></th>
|
||||||
|
<th>主题</th>
|
||||||
|
<th style="width:200px;">发件人</th>
|
||||||
|
<th style="width:150px;">时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="inbox-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="inbox-empty" style="display:none;padding:32px;text-align:center;color:var(--text-muted);">暂无邮件</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 邮件正文模态框 -->
|
||||||
|
<div class="modal" id="email-detail-modal">
|
||||||
|
<div class="modal-content" style="max-width:700px;width:95%;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<h3 id="email-detail-subject" style="margin:0;"></h3>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted);margin-top:4px;">
|
||||||
|
<span id="email-detail-sender"></span> · <span id="email-detail-date"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" id="close-email-detail-modal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="max-height:65vh;overflow-y:auto;">
|
||||||
|
<div id="email-detail-body" style="white-space:pre-wrap;word-break:break-word;font-size:13px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/utils.js?v={{ static_version }}"></script>
|
<script src="/static/js/utils.js?v={{ static_version }}"></script>
|
||||||
<script src="/static/js/email_services.js?v={{ static_version }}"></script>
|
<script src="/static/js/email_services.js?v={{ static_version }}"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# @Time : 2026/3/21 14:48
|
||||||
|
from src.core.utils import base64_payload_decode, base64_decode
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print(base64_payload_decode("eyJzZXNzaW9uX2lkIjoiYXV0aHNlc3NfcUE5eFByY3RaZmtHWXJnSlJGdUpxRXBPIiwiY291bnRyeV9jb2RlX2hpbnQiOiJVUyIsImF1dGhfc2Vzc2lvbl9sb2dnaW5nX2lkIjoiMTk0ZDg5OGQtM2Q0ZC00MzU5LWI1NTQtYmJjMjc1YTJlYjU1IiwicHJvbW8iOiIiLCJzaWdudXBfc291cmNlIjoiIiwib3BlbmFpX2NsaWVudF9pZCI6ImFwcF9FTW9hbUVFWjczZjBDa1hhWHA3aHJhbm4iLCJhcHBfbmFtZV9lbnVtIjoib2FpY2xpIiwiYWFzX2VuYWJsZWQiOmZhbHNlLCJvcmlnaW5hbF9zY3JlZW5faGludCI6ImxvZ2luIiwicGFzc3dvcmRsZXNzX2Rpc2FibGVkIjpmYWxzZSwicGFzc3dvcmRsZXNzX290cF9mcm9tX3Bhc3N3b3JkX3JlZGlyZWN0IjpmYWxzZSwiZW1haWxfdmVyaWZpY2F0aW9uX21vZGUiOiJwYXNzd29yZGxlc3NfbG9naW4iLCJlbWFpbCI6Imxob2xsYW5kNTcwQGdzb2xleWZveWxlLm9yZy51ayIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYW1lIjoiZXJyIiwid29ya3NwYWNlcyI6W3siaWQiOiI4NjhmZGNmYi1kNjI3LTRhZTItYTQ4Mi1jMTQxMjA1MGZhYTYiLCJuYW1lIjpudWxsLCJraW5kIjoicGVyc29uYWwiLCJwcm9maWxlX3BpY3R1cmVfYWx0X3RleHQiOiJlcnIifV19"))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user