refactor(outlook): 简化为单 IMAP_NEW Provider

This commit is contained in:
cnlimiter
2026-03-21 01:34:49 +08:00
parent fd651d95f4
commit 14ea1fd983
18 changed files with 732 additions and 1607 deletions
+7 -17
View File
@@ -68,12 +68,14 @@ OPENAI_API_ENDPOINTS = {
"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",
"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 +380,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 ScopeIMAP_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"]
+8 -8
View File
@@ -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
# 全局配置实例 # 全局配置实例
+77 -37
View File
@@ -286,6 +286,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 +319,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"检测到已注册账号,将自动切换到登录流程")
@@ -367,8 +368,7 @@ 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:
@@ -377,7 +377,7 @@ class RegistrationEngine:
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()
@@ -392,24 +392,74 @@ class RegistrationEngine:
self._log(f"密码注册失败: {e}", "error") self._log(f"密码注册失败: {e}", "error")
return False, None return False, None
def _mark_email_as_registered(self): def _login_password(self, did: str, sen_token: Optional[str]) -> bool:
"""标记邮箱为已注册状态(用于防止重复尝试)""" """注册密码"""
try: try:
password = ""
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: # 生成密码
# 创建一个失败记录,标记该邮箱已注册过 password = existing.password
# 提交密码登录
register_body = json.dumps({
"password": password
})
if sen_token:
sentinel = f'{{"p": "", "id": "{did}", "flow": "password_verify"}}'
response = self.session.post(
OPENAI_API_ENDPOINTS["password_verify"],
headers={
"referer": "https://auth.openai.com/log-in/password",
"accept": "application/json",
"content-type": "application/json",
"openai-sentinel-token": sentinel
},
data=register_body,
)
self._log(f"提交密码状态: {response.status_code}")
if response.status_code != 200:
error_text = response.text[:500]
self._log(f"密码登录失败: {error_text}", "warning")
return False
return True
except Exception as e:
self._log(f"密码登录失败: {e}", "error")
return False, None
def _mark_email_as_registered(self):
"""标记邮箱为已注册状态(OpenAI 侧已存在该账号)"""
try:
with get_db() as db:
existing = crud.get_account_by_email(db, self.email)
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}")
@@ -722,9 +772,11 @@ class RegistrationEngine:
result.error_message = f"提交注册表单失败: {signup_result.error_message}" result.error_message = f"提交注册表单失败: {signup_result.error_message}"
return result return result
# 8. [已注册账号跳过] 注册密码 # 8. 检测到已注册账号 → 直接终止任务
if self._is_existing_account: if self._is_existing_account:
self._log("8. [已注册账号] 跳过密码设置,OTP 已自动发送") self._log(f"8. 邮箱 {self.email} 在 OpenAI 已注册,跳过注册流程", "warning")
result.error_message = f"邮箱 {self.email} 已在 OpenAI 注册"
return result
else: else:
self._log("8. 注册密码...") self._log("8. 注册密码...")
password_ok, password = self._register_password() password_ok, password = self._register_password()
@@ -732,16 +784,11 @@ class RegistrationEngine:
result.error_message = "注册密码失败" result.error_message = "注册密码失败"
return result return result
# 9. [已注册账号跳过] 发送验证码 # 9. 发送验证码
if self._is_existing_account: self._log("9. 发送验证码...")
self._log("9. [已注册账号] 跳过发送验证码,使用自动发送的 OTP") if not self._send_verification_code():
# 已注册账号的 OTP 在提交表单时已自动发送,记录时间戳 result.error_message = "发送验证码失败"
self._otp_sent_at = time.time() return result
else:
self._log("9. 发送验证码...")
if not self._send_verification_code():
result.error_message = "发送验证码失败"
return result
# 10. 获取验证码 # 10. 获取验证码
self._log("10. 等待验证码...") self._log("10. 等待验证码...")
@@ -756,14 +803,11 @@ class RegistrationEngine:
result.error_message = "验证验证码失败" result.error_message = "验证验证码失败"
return result return result
# 12. [已注册账号跳过] 创建用户账户 # 12. 创建用户账户
if self._is_existing_account: self._log("12. 创建用户账户...")
self._log("12. [已注册账号] 跳过创建用户账户") if not self._create_user_account():
else: result.error_message = "创建用户账户失败"
self._log("12. 创建用户账户...") return result
if not self._create_user_account():
result.error_message = "创建用户账户失败"
return result
# 13. 获取 Workspace ID # 13. 获取 Workspace ID
self._log("13. 获取 Workspace ID...") self._log("13. 获取 Workspace ID...")
@@ -803,7 +847,7 @@ class RegistrationEngine:
result.password = self.password or "" # 保存密码(已注册账号为空) result.password = self.password or "" # 保存密码(已注册账号为空)
# 设置来源标记 # 设置来源标记
result.source = "login" if self._is_existing_account else "register" result.source = "register"
# 尝试获取 session_token 从 cookie # 尝试获取 session_token 从 cookie
session_cookie = self.session.cookies.get("__Secure-next-auth.session-token") session_cookie = self.session.cookies.get("__Secure-next-auth.session-token")
@@ -814,10 +858,7 @@ class RegistrationEngine:
# 17. 完成 # 17. 完成
self._log("=" * 60) self._log("=" * 60)
if self._is_existing_account: self._log("注册成功!")
self._log("登录成功! (已注册账号)")
else:
self._log("注册成功!")
self._log(f"邮箱: {result.email}") self._log(f"邮箱: {result.email}")
self._log(f"Account ID: {result.account_id}") self._log(f"Account ID: {result.account_id}")
self._log(f"Workspace ID: {result.workspace_id}") self._log(f"Workspace ID: {result.workspace_id}")
@@ -828,7 +869,6 @@ class RegistrationEngine:
"email_service": self.email_service.service_type.value, "email_service": self.email_service.service_type.value,
"proxy_used": self.proxy_url, "proxy_used": self.proxy_url,
"registered_at": datetime.now().isoformat(), "registered_at": datetime.now().isoformat(),
"is_existing_account": self._is_existing_account,
} }
return result return result
+18 -1
View File
@@ -713,4 +713,21 @@ def delete_tm_service(db: Session, service_id: int) -> bool:
return False return False
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()
-4
View File
@@ -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',
] ]
+1 -6
View File
@@ -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})"
+23 -44
View File
@@ -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,
+28 -277
View File
@@ -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} 记录成功") logger.warning(
f"IMAP_NEW 已禁用 {self.disable_duration}s,原因: {error}"
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(
f"{provider_type.value} 已禁用 {self.disable_duration} 秒,"
f"原因: {error}"
)
def is_available(self, provider_type: ProviderType) -> bool:
"""
检查提供者是否可用
Args:
provider_type: 提供者类型
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)}"
)
return False
return health.status != ProviderStatus.DISABLED
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:
health = self._health_status.get(provider_type)
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
def is_available(self) -> bool:
with self._lock: with self._lock:
# 尝试使用当前索引 if self._health.is_disabled():
if self._current_index < len(available): remaining = (
return available[self._current_index] (self._health.disabled_until - datetime.now()).total_seconds()
return available[0] if self._health.disabled_until
else 0
def switch_to_next(self) -> Optional[ProviderType]: )
""" logger.debug(f"IMAP_NEW 已被禁用,剩余 {int(remaining)}s")
切换到下一个提供者 return False
return self._health.status != ProviderStatus.DISABLED
Returns:
下一个提供者类型,如果没有可用的返回 None
"""
available = self.health_checker.get_available_providers(self.priority_order)
if not available:
return None
def reset(self):
with self._lock: with self._lock:
self._current_index = (self._current_index + 1) % len(available) self._health = ProviderHealth(provider_type=ProviderType.IMAP_NEW)
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)
+12 -77
View File
@@ -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:
-250
View File
@@ -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 返回 401client_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
+281 -122
View File
@@ -1,39 +1,87 @@
""" """
新版 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 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)
conn = imaplib.IMAP4_SSL(self.IMAP_HOST, self.IMAP_PORT, timeout=timeout)
auth_str = f"user={email_addr}\x01auth=Bearer {token}\x01\x01"
conn.authenticate("XOAUTH2", lambda _: auth_str.encode("utf-8"))
self._connections[email_addr] = conn
logger.debug(f"[{email_addr}] IMAP 新连接已建立")
return 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,151 +89,110 @@ 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
# 注意:新版 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 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()
if not token:
logger.error(f"[{self.account.email}] 获取 IMAP Token 失败")
return False
# 创建连接 self._conn = _imap_pool.get_connection(
self._conn = imaplib.IMAP4_SSL( self.account.email, token, self.config.timeout
self.IMAP_HOST,
self.IMAP_PORT,
timeout=self.config.timeout,
) )
self._connected = True
# XOAUTH2 认证 self.record_success()
if self._authenticate_xoauth2(): logger.debug(f"[{self.account.email}] IMAP 连接就绪(连接池)")
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:
logger.error(f"[{self.account.email}] 获取 IMAP 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 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,
) -> List[EmailMessage]: ) -> List[EmailMessage]:
""" """获取最近的邮件"""
获取最近的邮件
Args:
count: 获取数量
only_unseen: 是否只获取未读
Returns:
邮件列表
"""
if not self._connected: if not self._connected:
if not self.connect(): if not self.connect():
return [] return []
try: try:
# 选择收件箱
self._conn.select("INBOX", readonly=True) self._conn.select("INBOX", readonly=True)
# 搜索邮件
flag = "UNSEEN" if only_unseen else "ALL" flag = "UNSEEN" if only_unseen else "ALL"
status, data = self._conn.search(None, flag) status, data = self._conn.search(None, flag)
if status != "OK" or not data or not data[0]: if status != "OK" or not data or not data[0]:
return [] return []
# 获取最新的邮件 ID
ids = data[0].split() ids = data[0].split()
recent_ids = ids[-count:][::-1] recent_ids = ids[-count:][::-1]
emails = [] 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:
emails.append(email_msg) 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}): {e}")
@@ -194,6 +201,9 @@ class IMAPNewProvider(OutlookProvider):
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.error(f"[{self.account.email}] 获取邮件失败: {e}")
_imap_pool.invalidate(self.account.email)
self._connected = False
self._conn = None
return [] return []
def _fetch_email(self, msg_id: bytes) -> Optional[EmailMessage]: def _fetch_email(self, msg_id: bytes) -> Optional[EmailMessage]:
@@ -211,21 +221,170 @@ 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
logger.info(f"[{self.account.email}] 进入 IMAP IDLE 等待模式(超时 {timeout}s")
sock = self._conn.socket()
tag = self._conn._new_tag().decode() if isinstance(self._conn._new_tag(), bytes) else self._conn._new_tag()
try:
# 发送 IDLE 命令
self._conn.send(f"{tag} IDLE\r\n".encode())
# 等待 "+" 延续响应
deadline = time.time() + timeout
buf = b""
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:
break
# 等待 EXISTS / RECENT 推送
got_new = False
buf = b""
while time.time() < deadline:
remaining = deadline - time.time()
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:
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")
# 读取 IDLE 结束响应(避免缓冲区污染后续命令)
deadline2 = time.time() + 5
resp_buf = b""
while time.time() < deadline2:
ready = select.select([sock], [], [], 1.0)
if ready[0]:
chunk = sock.recv(4096)
if not chunk:
break
resp_buf += chunk
if tag.encode() in resp_buf:
break
except Exception:
# DONE 发送失败则废弃连接
_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"""
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
body = ""
body_preview = ""
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
cd = str(part.get("Content-Disposition", ""))
if "attachment" not in cd.lower() and ct in ("text/plain", "text/html"):
try:
charset = part.get_content_charset() or "utf-8"
payload = part.get_payload(decode=True)
if payload:
body = payload.decode(charset, errors="replace")
break
except Exception:
pass
else:
try:
charset = msg.get_content_charset() or "utf-8"
payload = msg.get_payload(decode=True)
if payload:
body = payload.decode(charset, errors="replace")
except Exception:
pass
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,
)
-345
View File
@@ -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
+223 -293
View File
@@ -1,6 +1,6 @@
""" """
Outlook 邮箱服务主类 Outlook 邮箱服务主类(简化版)
支持多种 IMAP/API 连接方式,自动故障切换 单一 IMAP_NEW Provider + 邮件缓存 + IMAP IDLE 支持
""" """
import logging import logging
@@ -8,34 +8,21 @@ 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__)
# 默认提供者优先级 def _get_code_settings() -> dict:
# 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:
"""获取验证码等待配置"""
settings = get_settings() settings = get_settings()
return { return {
"timeout": settings.email_code_timeout, "timeout": settings.email_code_timeout,
@@ -43,56 +30,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,7 +92,6 @@ 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:
@@ -111,8 +99,8 @@ class OutlookService(BaseEmailService):
if account.validate(): if account.validate():
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():
@@ -121,175 +109,87 @@ class OutlookService(BaseEmailService):
if not self.accounts: if not self.accounts:
logger.warning("未配置有效的 Outlook 账户") logger.warning("未配置有效的 Outlook 账户")
# 健康检查器和故障切换管理器 # 健康检查器
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,
use_cache: bool = False,
) -> 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: try:
邮件列表 provider = self._get_provider(account)
""" with self._imap_semaphore:
errors = [] with provider:
emails = provider.get_recent_emails(count, only_unseen)
# 根据账户类型选择合适的提供者优先级 if emails:
priority = self._get_provider_priority_for_account(account) self.health_checker.record_success()
if use_cache:
self._email_cache.set(account.email, emails)
return emails
# 按优先级尝试各提供者 except Exception as e:
for provider_type in priority: err = str(e)
# 检查提供者是否可用 self.health_checker.record_failure(err)
if not self.health_checker.is_available(provider_type): logger.warning(f"[{account.email}] 获取邮件失败: {e}")
logger.debug( return []
f"[{account.email}] {provider_type.value} 不可用,跳过"
)
continue
try:
provider = self._get_provider(account, provider_type)
with self._imap_semaphore:
with provider:
emails = provider.get_recent_emails(count, only_unseen)
if emails:
# 成功获取邮件
self.health_checker.record_success(provider_type)
logger.debug(
f"[{account.email}] {provider_type.value} 获取到 {len(emails)} 封邮件"
)
return emails
except Exception as e:
error_msg = str(e)
errors.append(f"{provider_type.value}: {error_msg}")
self.health_checker.record_failure(provider_type, error_msg)
logger.warning(
f"[{account.email}] {provider_type.value} 获取邮件失败: {e}"
)
logger.error(
f"[{account.email}] 所有提供者都失败: {'; '.join(errors)}"
)
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,114 +199,155 @@ 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
)
else:
code = self._wait_with_poll(
account, email, actual_timeout, poll_interval, min_timestamp, used_codes
)
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,
) -> 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
# 渐进式邮件检查:前 3 次只检查未读
only_unseen = poll_count <= 3 only_unseen = poll_count <= 3
try: try:
# 尝试多个提供者获取邮件 emails = self._fetch_emails(account, count=15, only_unseen=only_unseen)
emails = self._try_providers_for_emails(
account,
count=15,
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}"
) )
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}] 轮询出错: {e}")
# 等待下次轮询
time.sleep(poll_interval) time.sleep(poll_interval)
elapsed = int(time.time() - start_time) logger.warning(f"[{email}] 验证码超时 ({timeout}s),共轮询 {poll_count}")
logger.warning(f"[{email}] 验证码超时 ({actual_timeout}s),共轮询 {poll_count}")
return None return None
def list_emails(self, **kwargs) -> List[Dict[str, Any]]: def _wait_with_idle(
"""列出所有可用的 Outlook 账户""" self,
return [ account: OutlookAccount,
{ email: str,
"email": account.email, timeout: int,
"id": account.email, min_timestamp: float,
"has_oauth": account.has_oauth(), used_codes: set,
"type": "outlook" ) -> Optional[str]:
} """IMAP IDLE 方式等待验证码,失败时自动降级为轮询"""
for account in self.accounts 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
)
def delete_email(self, email_id: str) -> bool: start_time = time.time()
"""删除邮箱(Outlook 不支持删除账户)""" try:
logger.warning(f"Outlook 服务不支持删除账户: {email_id}") provider = self._get_provider(account)
return False with self._imap_semaphore:
with provider:
# 先做一次即时检查
emails = provider.get_recent_emails(15, only_unseen=True)
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)
emails = provider.get_recent_emails(15, only_unseen=True)
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 '超时检查'}"
)
return code
except Exception as e:
logger.warning(f"[{email}] IDLE 失败,降级为轮询: {e}")
elapsed = int(time.time() - start_time)
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
)
logger.warning(f"[{email}] IDLE 等待验证码超时 ({timeout}s)")
return None
def check_health(self) -> bool: def check_health(self) -> bool:
"""检查 Outlook 服务是否可用""" """检查 Outlook 服务是否可用"""
@@ -414,48 +355,48 @@ class OutlookService(BaseEmailService):
self.update_status(False, EmailServiceError("没有配置的账户")) self.update_status(False, EmailServiceError("没有配置的账户"))
return False return False
# 测试第一个账户的连接 try:
test_account = self.accounts[0] provider = self._get_provider(self.accounts[0])
if provider.test_connection():
# 尝试任一提供者连接 self.update_status(True)
for provider_type in self.provider_priority: return True
try: except Exception as e:
provider = self._get_provider(test_account, provider_type) logger.warning(f"Outlook 健康检查失败: {e}")
if provider.test_connection():
self.update_status(True)
return True
except Exception as e:
logger.warning(
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 +405,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}")
+42 -115
View File
@@ -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)
+1
View File
@@ -340,6 +340,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:
-1
View File
@@ -705,7 +705,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
View File
@@ -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)