File size: 7,791 Bytes
7482820
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
"""
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