ctime commited on
Commit
d14f4f3
·
verified ·
1 Parent(s): 198ff7a

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +43 -0
  2. main.py +1130 -0
  3. static/index.html +0 -0
Dockerfile ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 使用Python 3.11 Alpine作为基础镜像,体积更小
2
+ FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/python:3.11-alpine3.21
3
+
4
+ # 设置工作目录
5
+ WORKDIR /app
6
+
7
+ # 设置环境变量
8
+ ENV PYTHONDONTWRITEBYTECODE=1 \
9
+ PYTHONUNBUFFERED=1 \
10
+ PIP_NO_CACHE_DIR=1 \
11
+ PIP_DISABLE_PIP_VERSION_CHECK=1
12
+
13
+ # 安装系统依赖(如果需要)
14
+ # RUN apk add --no-cache \
15
+ # gcc \
16
+ # musl-dev \
17
+ # libffi-dev \
18
+ # openssl-dev
19
+
20
+ # 复制requirements文件并安装Python依赖
21
+ COPY requirements.txt .
22
+ RUN pip install --no-cache-dir -r requirements.txt
23
+
24
+ # 复制应用代码
25
+ COPY main.py .
26
+ COPY static/ ./static/
27
+ COPY docker-entrypoint.sh .
28
+
29
+ # 设置启动脚本权限
30
+ RUN chmod +x docker-entrypoint.sh
31
+
32
+ # 创建数据目录用于持久化存储
33
+ RUN mkdir -p /app/data && chown 777 /app/data
34
+
35
+ # 暴露端口
36
+ EXPOSE 8000
37
+
38
+ # 健康检查
39
+ HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
40
+ CMD python -c "import requests; requests.get('http://localhost:8000/api')" || exit 1
41
+
42
+ # 启动命令
43
+ CMD ["./docker-entrypoint.sh"]
main.py ADDED
@@ -0,0 +1,1130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Outlook邮件管理系统 - 主应用模块
3
+
4
+ 基于FastAPI和IMAP协议的高性能邮件管理系统
5
+ 支持多账户管理、邮件查看、搜索过滤等功能
6
+
7
+ Author: Outlook Manager Team
8
+ Version: 1.0.0
9
+ """
10
+
11
+ import asyncio
12
+ import email
13
+ import imaplib
14
+ import json
15
+ import logging
16
+ import re
17
+ import socket
18
+ import threading
19
+ import time
20
+ from contextlib import asynccontextmanager
21
+ from datetime import datetime
22
+ from itertools import groupby
23
+ from pathlib import Path
24
+ from queue import Empty, Queue
25
+ from typing import AsyncGenerator, List, Optional
26
+
27
+ import httpx
28
+ from email.header import decode_header
29
+ from email.utils import parsedate_to_datetime
30
+ from fastapi import FastAPI, HTTPException, Query
31
+ from fastapi.middleware.cors import CORSMiddleware
32
+ from fastapi.responses import FileResponse
33
+ from fastapi.staticfiles import StaticFiles
34
+ from pydantic import BaseModel, EmailStr
35
+
36
+
37
+
38
+ # ============================================================================
39
+ # 配置常量
40
+ # ============================================================================
41
+
42
+ # 文件路径配置
43
+ ACCOUNTS_FILE = "accounts.json"
44
+
45
+ # OAuth2配置
46
+ TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
47
+ OAUTH_SCOPE = "https://outlook.office.com/IMAP.AccessAsUser.All offline_access"
48
+
49
+ # IMAP服务器配置
50
+ IMAP_SERVER = "outlook.live.com"
51
+ IMAP_PORT = 993
52
+
53
+ # 连接池配置
54
+ MAX_CONNECTIONS = 5
55
+ CONNECTION_TIMEOUT = 30
56
+ SOCKET_TIMEOUT = 15
57
+
58
+ # 缓存配置
59
+ CACHE_EXPIRE_TIME = 60 # 缓存过期时间(秒)
60
+
61
+ # 日志配置
62
+ logging.basicConfig(
63
+ level=logging.INFO,
64
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
65
+ )
66
+ logger = logging.getLogger(__name__)
67
+
68
+
69
+ # ============================================================================
70
+ # 数据模型 (Pydantic Models)
71
+ # ============================================================================
72
+
73
+ class AccountCredentials(BaseModel):
74
+ """账户凭证模型"""
75
+ email: EmailStr
76
+ refresh_token: str
77
+ client_id: str
78
+
79
+ class Config:
80
+ schema_extra = {
81
+ "example": {
82
+ "email": "[email protected]",
83
+ "refresh_token": "0.AXoA...",
84
+ "client_id": "your-client-id"
85
+ }
86
+ }
87
+
88
+
89
+ class EmailItem(BaseModel):
90
+ """邮件项目模型"""
91
+ message_id: str
92
+ folder: str
93
+ subject: str
94
+ from_email: str
95
+ date: str
96
+ is_read: bool = False
97
+ has_attachments: bool = False
98
+ sender_initial: str = "?"
99
+
100
+ class Config:
101
+ schema_extra = {
102
+ "example": {
103
+ "message_id": "INBOX-123",
104
+ "folder": "INBOX",
105
+ "subject": "Welcome to Augment Code",
106
+ "from_email": "[email protected]",
107
+ "date": "2024-01-01T12:00:00",
108
+ "is_read": False,
109
+ "has_attachments": False,
110
+ "sender_initial": "A"
111
+ }
112
+ }
113
+
114
+
115
+ class EmailListResponse(BaseModel):
116
+ """邮件列表响应模型"""
117
+ email_id: str
118
+ folder_view: str
119
+ page: int
120
+ page_size: int
121
+ total_emails: int
122
+ emails: List[EmailItem]
123
+
124
+
125
+ class DualViewEmailResponse(BaseModel):
126
+ """双栏视图邮件响应模型"""
127
+ email_id: str
128
+ inbox_emails: List[EmailItem]
129
+ junk_emails: List[EmailItem]
130
+ inbox_total: int
131
+ junk_total: int
132
+
133
+
134
+ class EmailDetailsResponse(BaseModel):
135
+ """邮件详情响应模型"""
136
+ message_id: str
137
+ subject: str
138
+ from_email: str
139
+ to_email: str
140
+ date: str
141
+ body_plain: Optional[str] = None
142
+ body_html: Optional[str] = None
143
+
144
+
145
+ class AccountResponse(BaseModel):
146
+ """账户操作响应模型"""
147
+ email_id: str
148
+ message: str
149
+
150
+
151
+ class AccountInfo(BaseModel):
152
+ """账户信息模型"""
153
+ email_id: str
154
+ client_id: str
155
+ status: str = "active"
156
+
157
+
158
+ class AccountListResponse(BaseModel):
159
+ """账户列表响应模型"""
160
+ total_accounts: int
161
+ accounts: List[AccountInfo]
162
+
163
+
164
+ # ============================================================================
165
+ # IMAP连接池管理
166
+ # ============================================================================
167
+
168
+ class IMAPConnectionPool:
169
+ """
170
+ IMAP连接池管理器
171
+
172
+ 提供连接复用、自动重连、连接状态监控等功能
173
+ 优化IMAP连接性能,减少连接建立开销
174
+ """
175
+
176
+ def __init__(self, max_connections: int = MAX_CONNECTIONS):
177
+ """
178
+ 初始化连接池
179
+
180
+ Args:
181
+ max_connections: 每个邮箱的最大连接数
182
+ """
183
+ self.max_connections = max_connections
184
+ self.connections = {} # {email: Queue of connections}
185
+ self.connection_count = {} # {email: active connection count}
186
+ self.lock = threading.Lock()
187
+ logger.info(f"Initialized IMAP connection pool with max_connections={max_connections}")
188
+
189
+ def _create_connection(self, email: str, access_token: str) -> imaplib.IMAP4_SSL:
190
+ """
191
+ 创建新的IMAP��接
192
+
193
+ Args:
194
+ email: 邮箱地址
195
+ access_token: OAuth2访问令牌
196
+
197
+ Returns:
198
+ IMAP4_SSL: 已认证的IMAP连接
199
+
200
+ Raises:
201
+ Exception: 连接创建失败
202
+ """
203
+ try:
204
+ # 设置全局socket超时
205
+ socket.setdefaulttimeout(SOCKET_TIMEOUT)
206
+
207
+ # 创建SSL IMAP连接
208
+ imap_client = imaplib.IMAP4_SSL(IMAP_SERVER, IMAP_PORT)
209
+
210
+ # 设置连接超时
211
+ imap_client.sock.settimeout(CONNECTION_TIMEOUT)
212
+
213
+ # XOAUTH2认证
214
+ auth_string = f"user={email}\1auth=Bearer {access_token}\1\1".encode('utf-8')
215
+ imap_client.authenticate('XOAUTH2', lambda _: auth_string)
216
+
217
+ logger.info(f"Successfully created IMAP connection for {email}")
218
+ return imap_client
219
+
220
+ except Exception as e:
221
+ logger.error(f"Failed to create IMAP connection for {email}: {e}")
222
+ raise
223
+
224
+ def get_connection(self, email: str, access_token: str) -> imaplib.IMAP4_SSL:
225
+ """
226
+ 获取IMAP连接(从池中复用或创建新连接)
227
+
228
+ Args:
229
+ email: 邮箱地址
230
+ access_token: OAuth2访问令牌
231
+
232
+ Returns:
233
+ IMAP4_SSL: 可用的IMAP连接
234
+
235
+ Raises:
236
+ Exception: 无法获取连接
237
+ """
238
+ with self.lock:
239
+ # 初始化邮箱的连接池
240
+ if email not in self.connections:
241
+ self.connections[email] = Queue(maxsize=self.max_connections)
242
+ self.connection_count[email] = 0
243
+
244
+ connection_queue = self.connections[email]
245
+
246
+ # 尝试从池中获取现有连接
247
+ try:
248
+ connection = connection_queue.get_nowait()
249
+ # 测试连接有效性
250
+ try:
251
+ connection.noop()
252
+ logger.debug(f"Reused existing IMAP connection for {email}")
253
+ return connection
254
+ except Exception:
255
+ # 连接已失效,需要创建新连接
256
+ logger.debug(f"Existing connection invalid for {email}, creating new one")
257
+ self.connection_count[email] -= 1
258
+ except Empty:
259
+ # 池中没有可用连接
260
+ pass
261
+
262
+ # 检查是否可以创建新连接
263
+ if self.connection_count[email] < self.max_connections:
264
+ connection = self._create_connection(email, access_token)
265
+ self.connection_count[email] += 1
266
+ return connection
267
+ else:
268
+ # 达到最大连接数,等待可用连接
269
+ logger.warning(f"Max connections ({self.max_connections}) reached for {email}, waiting...")
270
+ try:
271
+ return connection_queue.get(timeout=30)
272
+ except Exception as e:
273
+ logger.error(f"Timeout waiting for connection for {email}: {e}")
274
+ raise
275
+
276
+ def return_connection(self, email: str, connection: imaplib.IMAP4_SSL) -> None:
277
+ """
278
+ 归还连接到池中
279
+
280
+ Args:
281
+ email: 邮箱地址
282
+ connection: 要归还的IMAP连接
283
+ """
284
+ if email not in self.connections:
285
+ logger.warning(f"Attempting to return connection for unknown email: {email}")
286
+ return
287
+
288
+ try:
289
+ # 测试连接状态
290
+ connection.noop()
291
+ # 连接有效,归还到池中
292
+ self.connections[email].put_nowait(connection)
293
+ logger.debug(f"Successfully returned IMAP connection for {email}")
294
+ except Exception as e:
295
+ # 连接已失效,减少计数并丢弃
296
+ with self.lock:
297
+ if email in self.connection_count:
298
+ self.connection_count[email] = max(0, self.connection_count[email] - 1)
299
+ logger.debug(f"Discarded invalid connection for {email}: {e}")
300
+
301
+ def close_all_connections(self, email: str = None) -> None:
302
+ """
303
+ 关闭所有连接
304
+
305
+ Args:
306
+ email: 指定邮箱地址,如果为None则关闭所有邮箱的连接
307
+ """
308
+ with self.lock:
309
+ if email:
310
+ # 关闭指定邮箱的所有连接
311
+ if email in self.connections:
312
+ closed_count = 0
313
+ while not self.connections[email].empty():
314
+ try:
315
+ conn = self.connections[email].get_nowait()
316
+ conn.logout()
317
+ closed_count += 1
318
+ except Exception as e:
319
+ logger.debug(f"Error closing connection: {e}")
320
+
321
+ self.connection_count[email] = 0
322
+ logger.info(f"Closed {closed_count} connections for {email}")
323
+ else:
324
+ # 关闭所有邮箱的连接
325
+ total_closed = 0
326
+ for email_key in list(self.connections.keys()):
327
+ count_before = self.connection_count.get(email_key, 0)
328
+ self.close_all_connections(email_key)
329
+ total_closed += count_before
330
+ logger.info(f"Closed total {total_closed} connections for all accounts")
331
+
332
+ # ============================================================================
333
+ # 全局实例和缓存管理
334
+ # ============================================================================
335
+
336
+ # 全局连接池实例
337
+ imap_pool = IMAPConnectionPool()
338
+
339
+ # 内存缓存存储
340
+ email_cache = {} # 邮件列表缓存
341
+ email_count_cache = {} # 邮件总数缓存,用于检测新邮件
342
+
343
+
344
+ def get_cache_key(email: str, folder: str, page: int, page_size: int) -> str:
345
+ """
346
+ 生成缓存键
347
+
348
+ Args:
349
+ email: 邮箱地址
350
+ folder: 文件夹名称
351
+ page: 页码
352
+ page_size: 每页大小
353
+
354
+ Returns:
355
+ str: 缓存键
356
+ """
357
+ return f"{email}:{folder}:{page}:{page_size}"
358
+
359
+
360
+ def get_cached_emails(cache_key: str, force_refresh: bool = False):
361
+ """
362
+ 获取缓存的邮件列表
363
+
364
+ Args:
365
+ cache_key: 缓存键
366
+ force_refresh: 是否强制刷新缓存
367
+
368
+ Returns:
369
+ 缓存的数据或None
370
+ """
371
+ if force_refresh:
372
+ # 强制刷新,删除现有缓存
373
+ if cache_key in email_cache:
374
+ del email_cache[cache_key]
375
+ logger.debug(f"Force refresh: removed cache for {cache_key}")
376
+ return None
377
+
378
+ if cache_key in email_cache:
379
+ cached_data, timestamp = email_cache[cache_key]
380
+ if time.time() - timestamp < CACHE_EXPIRE_TIME:
381
+ logger.debug(f"Cache hit for {cache_key}")
382
+ return cached_data
383
+ else:
384
+ # 缓存已过期,删除
385
+ del email_cache[cache_key]
386
+ logger.debug(f"Cache expired for {cache_key}")
387
+
388
+ return None
389
+
390
+
391
+ def set_cached_emails(cache_key: str, data) -> None:
392
+ """
393
+ 设置邮件列表缓存
394
+
395
+ Args:
396
+ cache_key: 缓存键
397
+ data: 要缓存的数据
398
+ """
399
+ email_cache[cache_key] = (data, time.time())
400
+ logger.debug(f"Cache set for {cache_key}")
401
+
402
+
403
+ def clear_email_cache(email: str = None) -> None:
404
+ """
405
+ 清除邮件缓存
406
+
407
+ Args:
408
+ email: 指定邮箱地址,如果为None则清除所有缓存
409
+ """
410
+ if email:
411
+ # 清除特定邮箱的缓存
412
+ keys_to_delete = [key for key in email_cache.keys() if key.startswith(f"{email}:")]
413
+ for key in keys_to_delete:
414
+ del email_cache[key]
415
+ logger.info(f"Cleared cache for {email} ({len(keys_to_delete)} entries)")
416
+ else:
417
+ # 清除所有缓存
418
+ cache_count = len(email_cache)
419
+ email_cache.clear()
420
+ email_count_cache.clear()
421
+ logger.info(f"Cleared all email cache ({cache_count} entries)")
422
+
423
+ # ============================================================================
424
+ # 邮件处理辅助函数
425
+ # ============================================================================
426
+
427
+ def decode_header_value(header_value: str) -> str:
428
+ """
429
+ 解码邮件头字段
430
+
431
+ 处理各种编码格式的邮件头部信息,如Subject、From等
432
+
433
+ Args:
434
+ header_value: 原始头部值
435
+
436
+ Returns:
437
+ str: 解码后的字符串
438
+ """
439
+ if not header_value:
440
+ return ""
441
+
442
+ try:
443
+ decoded_parts = decode_header(str(header_value))
444
+ decoded_string = ""
445
+
446
+ for part, charset in decoded_parts:
447
+ if isinstance(part, bytes):
448
+ try:
449
+ # 使用指定编码或默认UTF-8解码
450
+ encoding = charset if charset else 'utf-8'
451
+ decoded_string += part.decode(encoding, errors='replace')
452
+ except (LookupError, UnicodeDecodeError):
453
+ # 编码失败时使用UTF-8强制解码
454
+ decoded_string += part.decode('utf-8', errors='replace')
455
+ else:
456
+ decoded_string += str(part)
457
+
458
+ return decoded_string.strip()
459
+ except Exception as e:
460
+ logger.warning(f"Failed to decode header value '{header_value}': {e}")
461
+ return str(header_value) if header_value else ""
462
+
463
+
464
+ def extract_email_content(email_message: email.message.EmailMessage) -> tuple[str, str]:
465
+ """
466
+ 提取邮件的纯文本和HTML内容
467
+
468
+ Args:
469
+ email_message: 邮件消息对象
470
+
471
+ Returns:
472
+ tuple[str, str]: (纯文本内容, HTML内容)
473
+ """
474
+ body_plain = ""
475
+ body_html = ""
476
+
477
+ try:
478
+ if email_message.is_multipart():
479
+ # 处理多部分邮件
480
+ for part in email_message.walk():
481
+ content_type = part.get_content_type()
482
+ content_disposition = str(part.get("Content-Disposition", ""))
483
+
484
+ # 跳过附件
485
+ if 'attachment' not in content_disposition.lower():
486
+ try:
487
+ charset = part.get_content_charset() or 'utf-8'
488
+ payload = part.get_payload(decode=True)
489
+
490
+ if payload:
491
+ decoded_content = payload.decode(charset, errors='replace')
492
+
493
+ if content_type == 'text/plain' and not body_plain:
494
+ body_plain = decoded_content
495
+ elif content_type == 'text/html' and not body_html:
496
+ body_html = decoded_content
497
+
498
+ except Exception as e:
499
+ logger.warning(f"Failed to decode email part ({content_type}): {e}")
500
+ else:
501
+ # 处理单部分邮件
502
+ try:
503
+ charset = email_message.get_content_charset() or 'utf-8'
504
+ payload = email_message.get_payload(decode=True)
505
+
506
+ if payload:
507
+ content = payload.decode(charset, errors='replace')
508
+ content_type = email_message.get_content_type()
509
+
510
+ if content_type == 'text/plain':
511
+ body_plain = content
512
+ elif content_type == 'text/html':
513
+ body_html = content
514
+ else:
515
+ # 默认当作纯文本处理
516
+ body_plain = content
517
+
518
+ except Exception as e:
519
+ logger.warning(f"Failed to decode single-part email body: {e}")
520
+
521
+ except Exception as e:
522
+ logger.error(f"Error extracting email content: {e}")
523
+
524
+ return body_plain.strip(), body_html.strip()
525
+
526
+
527
+ # ============================================================================
528
+ # 账户凭证管理模块
529
+ # ============================================================================
530
+
531
+ async def get_account_credentials(email_id: str) -> AccountCredentials:
532
+ """
533
+ 从accounts.json文件获取指定邮箱的账户凭证
534
+
535
+ Args:
536
+ email_id: 邮箱地址
537
+
538
+ Returns:
539
+ AccountCredentials: 账户凭证对象
540
+
541
+ Raises:
542
+ HTTPException: 账户不存在或文件读取失败
543
+ """
544
+ try:
545
+ # 检查账户文件是否存在
546
+ accounts_path = Path(ACCOUNTS_FILE)
547
+ if not accounts_path.exists():
548
+ logger.warning(f"Accounts file {ACCOUNTS_FILE} not found")
549
+ raise HTTPException(status_code=404, detail="No accounts configured")
550
+
551
+ # 读取账户数据
552
+ with open(accounts_path, 'r', encoding='utf-8') as f:
553
+ accounts = json.load(f)
554
+
555
+ # 检查指定邮箱是否存在
556
+ if email_id not in accounts:
557
+ logger.warning(f"Account {email_id} not found in accounts file")
558
+ raise HTTPException(status_code=404, detail=f"Account {email_id} not found")
559
+
560
+ # 验证账户数据完整性
561
+ account_data = accounts[email_id]
562
+ required_fields = ['refresh_token', 'client_id']
563
+ missing_fields = [field for field in required_fields if not account_data.get(field)]
564
+
565
+ if missing_fields:
566
+ logger.error(f"Account {email_id} missing required fields: {missing_fields}")
567
+ raise HTTPException(status_code=500, detail="Account configuration incomplete")
568
+
569
+ return AccountCredentials(
570
+ email=email_id,
571
+ refresh_token=account_data['refresh_token'],
572
+ client_id=account_data['client_id']
573
+ )
574
+
575
+ except HTTPException:
576
+ # 重新抛出HTTP异常
577
+ raise
578
+ except json.JSONDecodeError as e:
579
+ logger.error(f"Invalid JSON in accounts file: {e}")
580
+ raise HTTPException(status_code=500, detail="Accounts file format error")
581
+ except Exception as e:
582
+ logger.error(f"Unexpected error getting account credentials for {email_id}: {e}")
583
+ raise HTTPException(status_code=500, detail="Internal server error")
584
+
585
+
586
+ async def save_account_credentials(email_id: str, credentials: AccountCredentials) -> None:
587
+ """保存账户凭证到accounts.json"""
588
+ try:
589
+ accounts = {}
590
+ if Path(ACCOUNTS_FILE).exists():
591
+ with open(ACCOUNTS_FILE, 'r', encoding='utf-8') as f:
592
+ accounts = json.load(f)
593
+
594
+ accounts[email_id] = {
595
+ 'refresh_token': credentials.refresh_token,
596
+ 'client_id': credentials.client_id
597
+ }
598
+
599
+ with open(ACCOUNTS_FILE, 'w', encoding='utf-8') as f:
600
+ json.dump(accounts, f, indent=2, ensure_ascii=False)
601
+
602
+ logger.info(f"Account credentials saved for {email_id}")
603
+ except Exception as e:
604
+ logger.error(f"Error saving account credentials: {e}")
605
+ raise HTTPException(status_code=500, detail="Failed to save account")
606
+
607
+
608
+ async def get_all_accounts() -> AccountListResponse:
609
+ """获取所有已加载的邮箱账户列表"""
610
+ try:
611
+ if not Path(ACCOUNTS_FILE).exists():
612
+ return AccountListResponse(total_accounts=0, accounts=[])
613
+
614
+ with open(ACCOUNTS_FILE, 'r', encoding='utf-8') as f:
615
+ accounts_data = json.load(f)
616
+
617
+ accounts = []
618
+ for email_id, account_info in accounts_data.items():
619
+ # 验证账户状态(可选:检查token是否有效)
620
+ status = "active"
621
+ try:
622
+ # 简单验证:检查必要字段是否存在
623
+ if not account_info.get('refresh_token') or not account_info.get('client_id'):
624
+ status = "invalid"
625
+ except Exception:
626
+ status = "error"
627
+
628
+ accounts.append(AccountInfo(
629
+ email_id=email_id,
630
+ client_id=account_info.get('client_id', ''),
631
+ status=status
632
+ ))
633
+
634
+ return AccountListResponse(
635
+ total_accounts=len(accounts),
636
+ accounts=accounts
637
+ )
638
+
639
+ except json.JSONDecodeError:
640
+ logger.error("Failed to parse accounts.json")
641
+ raise HTTPException(status_code=500, detail="Failed to read accounts file")
642
+ except Exception as e:
643
+ logger.error(f"Error getting accounts list: {e}")
644
+ raise HTTPException(status_code=500, detail="Internal server error")
645
+
646
+
647
+ # ============================================================================
648
+ # OAuth2令牌管理模块
649
+ # ============================================================================
650
+
651
+ async def get_access_token(credentials: AccountCredentials) -> str:
652
+ """
653
+ 使用refresh_token获取access_token
654
+
655
+ Args:
656
+ credentials: 账户凭证信息
657
+
658
+ Returns:
659
+ str: OAuth2访问令牌
660
+
661
+ Raises:
662
+ HTTPException: 令牌获取失败
663
+ """
664
+ # 构建OAuth2请求数据
665
+ token_request_data = {
666
+ 'client_id': credentials.client_id,
667
+ 'grant_type': 'refresh_token',
668
+ 'refresh_token': credentials.refresh_token,
669
+ 'scope': OAUTH_SCOPE
670
+ }
671
+
672
+ try:
673
+ # 发送令牌请求
674
+ async with httpx.AsyncClient(timeout=30.0) as client:
675
+ response = await client.post(TOKEN_URL, data=token_request_data)
676
+ response.raise_for_status()
677
+
678
+ # 解析响应
679
+ token_data = response.json()
680
+ access_token = token_data.get('access_token')
681
+
682
+ if not access_token:
683
+ logger.error(f"No access token in response for {credentials.email}")
684
+ raise HTTPException(
685
+ status_code=401,
686
+ detail="Failed to obtain access token from response"
687
+ )
688
+
689
+ logger.info(f"Successfully obtained access token for {credentials.email}")
690
+ return access_token
691
+
692
+ except httpx.HTTPStatusError as e:
693
+ logger.error(f"HTTP {e.response.status_code} error getting access token for {credentials.email}: {e}")
694
+ if e.response.status_code == 400:
695
+ raise HTTPException(status_code=401, detail="Invalid refresh token or client credentials")
696
+ else:
697
+ raise HTTPException(status_code=401, detail="Authentication failed")
698
+ except httpx.RequestError as e:
699
+ logger.error(f"Request error getting access token for {credentials.email}: {e}")
700
+ raise HTTPException(status_code=500, detail="Network error during token acquisition")
701
+ except Exception as e:
702
+ logger.error(f"Unexpected error getting access token for {credentials.email}: {e}")
703
+ raise HTTPException(status_code=500, detail="Token acquisition failed")
704
+
705
+
706
+ # ============================================================================
707
+ # IMAP核心服务 - 邮件列表
708
+ # ============================================================================
709
+
710
+ async def list_emails(credentials: AccountCredentials, folder: str, page: int, page_size: int, force_refresh: bool = False) -> EmailListResponse:
711
+ """获取邮件列表 - 优化版本"""
712
+
713
+ # 检查缓存
714
+ cache_key = get_cache_key(credentials.email, folder, page, page_size)
715
+ cached_result = get_cached_emails(cache_key, force_refresh)
716
+ if cached_result:
717
+ return cached_result
718
+
719
+ access_token = await get_access_token(credentials)
720
+
721
+ def _sync_list_emails():
722
+ imap_client = None
723
+ try:
724
+ # 从连接池获取连接
725
+ imap_client = imap_pool.get_connection(credentials.email, access_token)
726
+
727
+ all_emails_data = []
728
+
729
+ # 根据folder参数决定要获取的文件夹
730
+ folders_to_check = []
731
+ if folder == "inbox":
732
+ folders_to_check = ["INBOX"]
733
+ elif folder == "junk":
734
+ folders_to_check = ["Junk"]
735
+ else: # folder == "all"
736
+ folders_to_check = ["INBOX", "Junk"]
737
+
738
+ for folder_name in folders_to_check:
739
+ try:
740
+ # 选择文件夹
741
+ imap_client.select(f'"{folder_name}"', readonly=True)
742
+
743
+ # 搜索所有邮件
744
+ status, messages = imap_client.search(None, "ALL")
745
+ if status != 'OK' or not messages or not messages[0]:
746
+ continue
747
+
748
+ message_ids = messages[0].split()
749
+
750
+ # 按日期排序所需的数据(邮件ID和日期)
751
+ # 为了避免获取所有邮件的日期,我们假设ID顺序与日期大致相关
752
+ message_ids.reverse() # 通常ID越大越新
753
+
754
+ for msg_id in message_ids:
755
+ all_emails_data.append({
756
+ "message_id_raw": msg_id,
757
+ "folder": folder_name
758
+ })
759
+
760
+ except Exception as e:
761
+ logger.warning(f"Failed to access folder {folder_name}: {e}")
762
+ continue
763
+
764
+ # 对所有文件夹的邮件进行统一分页
765
+ total_emails = len(all_emails_data)
766
+ start_index = (page - 1) * page_size
767
+ end_index = start_index + page_size
768
+ paginated_email_meta = all_emails_data[start_index:end_index]
769
+
770
+ email_items = []
771
+ # 按文件夹分组批量获取
772
+ paginated_email_meta.sort(key=lambda x: x['folder'])
773
+
774
+ for folder_name, group in groupby(paginated_email_meta, key=lambda x: x['folder']):
775
+ try:
776
+ imap_client.select(f'"{folder_name}"', readonly=True)
777
+
778
+ msg_ids_to_fetch = [item['message_id_raw'] for item in group]
779
+ if not msg_ids_to_fetch:
780
+ continue
781
+
782
+ # 批量获取邮件头 - 优化获取字段
783
+ msg_id_sequence = b','.join(msg_ids_to_fetch)
784
+ # 只获取必要的头部信息,减少数据传输
785
+ status, msg_data = imap_client.fetch(msg_id_sequence, '(FLAGS BODY.PEEK[HEADER.FIELDS (SUBJECT DATE FROM MESSAGE-ID)])')
786
+
787
+ if status != 'OK':
788
+ continue
789
+
790
+ # 解析批量获取的数据
791
+ for i in range(0, len(msg_data), 2):
792
+ header_data = msg_data[i][1]
793
+
794
+ # 从返回的原始数据中解析出msg_id
795
+ # e.g., b'1 (BODY[HEADER.FIELDS (SUBJECT DATE FROM)] {..}'
796
+ match = re.match(rb'(\d+)\s+\(', msg_data[i][0])
797
+ if not match:
798
+ continue
799
+ fetched_msg_id = match.group(1)
800
+
801
+ msg = email.message_from_bytes(header_data)
802
+
803
+ subject = decode_header_value(msg.get('Subject', '(No Subject)'))
804
+ from_email = decode_header_value(msg.get('From', '(Unknown Sender)'))
805
+ date_str = msg.get('Date', '')
806
+
807
+ try:
808
+ date_obj = parsedate_to_datetime(date_str) if date_str else datetime.now()
809
+ formatted_date = date_obj.isoformat()
810
+ except:
811
+ date_obj = datetime.now()
812
+ formatted_date = date_obj.isoformat()
813
+
814
+ message_id = f"{folder_name}-{fetched_msg_id.decode()}"
815
+
816
+ # 提取发件人首字母
817
+ sender_initial = "?"
818
+ if from_email:
819
+ # 尝试提取邮箱用户名的首字母
820
+ email_match = re.search(r'([a-zA-Z])', from_email)
821
+ if email_match:
822
+ sender_initial = email_match.group(1).upper()
823
+
824
+ email_item = EmailItem(
825
+ message_id=message_id,
826
+ folder=folder_name,
827
+ subject=subject,
828
+ from_email=from_email,
829
+ date=formatted_date,
830
+ is_read=False, # 简化处理,实际可通过IMAP flags判断
831
+ has_attachments=False, # 简化处理,实际需要检查邮件结构
832
+ sender_initial=sender_initial
833
+ )
834
+ email_items.append(email_item)
835
+
836
+ except Exception as e:
837
+ logger.warning(f"Failed to fetch bulk emails from {folder_name}: {e}")
838
+ continue
839
+
840
+ # 按日期重新排序最终结果
841
+ email_items.sort(key=lambda x: x.date, reverse=True)
842
+
843
+ # 归还连接到池中
844
+ imap_pool.return_connection(credentials.email, imap_client)
845
+
846
+ result = EmailListResponse(
847
+ email_id=credentials.email,
848
+ folder_view=folder,
849
+ page=page,
850
+ page_size=page_size,
851
+ total_emails=total_emails,
852
+ emails=email_items
853
+ )
854
+
855
+ # 设置缓存
856
+ set_cached_emails(cache_key, result)
857
+
858
+ return result
859
+
860
+ except Exception as e:
861
+ logger.error(f"Error listing emails: {e}")
862
+ if imap_client:
863
+ try:
864
+ # 如果出错,尝试归还连接或关闭
865
+ if hasattr(imap_client, 'state') and imap_client.state != 'LOGOUT':
866
+ imap_pool.return_connection(credentials.email, imap_client)
867
+ else:
868
+ # 连接已断开,从池中移除
869
+ pass
870
+ except:
871
+ pass
872
+ raise HTTPException(status_code=500, detail="Failed to retrieve emails")
873
+
874
+ # 在线程池中运行同步代码
875
+ return await asyncio.to_thread(_sync_list_emails)
876
+
877
+
878
+ # ============================================================================
879
+ # IMAP核心服务 - 邮件详情
880
+ # ============================================================================
881
+
882
+ async def get_email_details(credentials: AccountCredentials, message_id: str) -> EmailDetailsResponse:
883
+ """获取邮件详细内容 - 优化版本"""
884
+ # 解析复合message_id
885
+ try:
886
+ folder_name, msg_id = message_id.split('-', 1)
887
+ except ValueError:
888
+ raise HTTPException(status_code=400, detail="Invalid message_id format")
889
+
890
+ access_token = await get_access_token(credentials)
891
+
892
+ def _sync_get_email_details():
893
+ imap_client = None
894
+ try:
895
+ # 从连接池获取连接
896
+ imap_client = imap_pool.get_connection(credentials.email, access_token)
897
+
898
+ # 选择正确的文件夹
899
+ imap_client.select(folder_name)
900
+
901
+ # 获取完整邮件内容
902
+ status, msg_data = imap_client.fetch(msg_id, '(RFC822)')
903
+
904
+ if status != 'OK' or not msg_data:
905
+ raise HTTPException(status_code=404, detail="Email not found")
906
+
907
+ # 解析邮件
908
+ raw_email = msg_data[0][1]
909
+ msg = email.message_from_bytes(raw_email)
910
+
911
+ # 提取基本信息
912
+ subject = decode_header_value(msg.get('Subject', '(No Subject)'))
913
+ from_email = decode_header_value(msg.get('From', '(Unknown Sender)'))
914
+ to_email = decode_header_value(msg.get('To', '(Unknown Recipient)'))
915
+ date_str = msg.get('Date', '')
916
+
917
+ # 格式化日期
918
+ try:
919
+ if date_str:
920
+ date_obj = parsedate_to_datetime(date_str)
921
+ formatted_date = date_obj.isoformat()
922
+ else:
923
+ formatted_date = datetime.now().isoformat()
924
+ except:
925
+ formatted_date = datetime.now().isoformat()
926
+
927
+ # 提取邮件内容
928
+ body_plain, body_html = extract_email_content(msg)
929
+
930
+ # 归还连接到池中
931
+ imap_pool.return_connection(credentials.email, imap_client)
932
+
933
+ return EmailDetailsResponse(
934
+ message_id=message_id,
935
+ subject=subject,
936
+ from_email=from_email,
937
+ to_email=to_email,
938
+ date=formatted_date,
939
+ body_plain=body_plain if body_plain else None,
940
+ body_html=body_html if body_html else None
941
+ )
942
+
943
+ except HTTPException:
944
+ raise
945
+ except Exception as e:
946
+ logger.error(f"Error getting email details: {e}")
947
+ if imap_client:
948
+ try:
949
+ # 如果出错,尝试归还连接
950
+ if hasattr(imap_client, 'state') and imap_client.state != 'LOGOUT':
951
+ imap_pool.return_connection(credentials.email, imap_client)
952
+ except:
953
+ pass
954
+ raise HTTPException(status_code=500, detail="Failed to retrieve email details")
955
+
956
+ # 在线程池中运行同步代码
957
+ return await asyncio.to_thread(_sync_get_email_details)
958
+
959
+
960
+ # ============================================================================
961
+ # FastAPI应用和API端点
962
+ # ============================================================================
963
+
964
+ @asynccontextmanager
965
+ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
966
+ """
967
+ FastAPI应用生命周期管理
968
+
969
+ 处理应用启动和关闭时的资源管理
970
+ """
971
+ # 应用启动
972
+ logger.info("Starting Outlook Email Management System...")
973
+ logger.info(f"IMAP connection pool initialized with max_connections={MAX_CONNECTIONS}")
974
+
975
+ yield
976
+
977
+ # 应用关闭
978
+ logger.info("Shutting down Outlook Email Management System...")
979
+ logger.info("Closing IMAP connection pool...")
980
+ imap_pool.close_all_connections()
981
+ logger.info("Application shutdown complete.")
982
+
983
+
984
+ app = FastAPI(
985
+ title="Outlook邮件API服务",
986
+ description="基于FastAPI和IMAP协议的高性能邮件管理系统",
987
+ version="1.0.0",
988
+ lifespan=lifespan
989
+ )
990
+
991
+ app.add_middleware(
992
+ CORSMiddleware,
993
+ allow_origins=["*"],
994
+ allow_credentials=True,
995
+ allow_methods=["*"],
996
+ allow_headers=["*"],
997
+ )
998
+
999
+ # 挂载静态文件服务
1000
+ app.mount("/static", StaticFiles(directory="static"), name="static")
1001
+
1002
+ @app.get("/accounts", response_model=AccountListResponse)
1003
+ async def get_accounts():
1004
+ """获取所有已加载的邮箱账户列表"""
1005
+ return await get_all_accounts()
1006
+
1007
+
1008
+ @app.post("/accounts", response_model=AccountResponse)
1009
+ async def register_account(credentials: AccountCredentials):
1010
+ """注册或更新邮箱账户"""
1011
+ try:
1012
+ # 验证凭证有效性
1013
+ await get_access_token(credentials)
1014
+
1015
+ # 保存凭证
1016
+ await save_account_credentials(credentials.email, credentials)
1017
+
1018
+ return AccountResponse(
1019
+ email_id=credentials.email,
1020
+ message="Account verified and saved successfully."
1021
+ )
1022
+
1023
+ except HTTPException:
1024
+ raise
1025
+ except Exception as e:
1026
+ logger.error(f"Error registering account: {e}")
1027
+ raise HTTPException(status_code=500, detail="Account registration failed")
1028
+
1029
+
1030
+ @app.get("/emails/{email_id}", response_model=EmailListResponse)
1031
+ async def get_emails(
1032
+ email_id: str,
1033
+ folder: str = Query("all", regex="^(inbox|junk|all)$"),
1034
+ page: int = Query(1, ge=1),
1035
+ page_size: int = Query(100, ge=1, le=500),
1036
+ refresh: bool = Query(False, description="强制刷新缓存")
1037
+ ):
1038
+ """获取邮件列表"""
1039
+ credentials = await get_account_credentials(email_id)
1040
+ print('credentials:' + str(credentials))
1041
+ return await list_emails(credentials, folder, page, page_size, refresh)
1042
+
1043
+
1044
+ @app.get("/emails/{email_id}/dual-view")
1045
+ async def get_dual_view_emails(
1046
+ email_id: str,
1047
+ inbox_page: int = Query(1, ge=1),
1048
+ junk_page: int = Query(1, ge=1),
1049
+ page_size: int = Query(20, ge=1, le=100)
1050
+ ):
1051
+ """获取双栏视图邮件(收件箱和垃圾箱)"""
1052
+ credentials = await get_account_credentials(email_id)
1053
+
1054
+ # 并行获取收件箱和垃圾箱邮件
1055
+ inbox_response = await list_emails(credentials, "inbox", inbox_page, page_size)
1056
+ junk_response = await list_emails(credentials, "junk", junk_page, page_size)
1057
+
1058
+ return DualViewEmailResponse(
1059
+ email_id=email_id,
1060
+ inbox_emails=inbox_response.emails,
1061
+ junk_emails=junk_response.emails,
1062
+ inbox_total=inbox_response.total_emails,
1063
+ junk_total=junk_response.total_emails
1064
+ )
1065
+
1066
+
1067
+ @app.get("/emails/{email_id}/{message_id}", response_model=EmailDetailsResponse)
1068
+ async def get_email_detail(email_id: str, message_id: str):
1069
+ """获取邮件详细内容"""
1070
+ credentials = await get_account_credentials(email_id)
1071
+ return await get_email_details(credentials, message_id)
1072
+
1073
+
1074
+ @app.get("/")
1075
+ async def root():
1076
+ """根路径 - 返回前端页面"""
1077
+ return FileResponse("static/index.html")
1078
+
1079
+ @app.delete("/cache/{email_id}")
1080
+ async def clear_cache(email_id: str):
1081
+ """清除指定邮箱的缓存"""
1082
+ clear_email_cache(email_id)
1083
+ return {"message": f"Cache cleared for {email_id}"}
1084
+
1085
+ @app.delete("/cache")
1086
+ async def clear_all_cache():
1087
+ """清除所有缓存"""
1088
+ clear_email_cache()
1089
+ return {"message": "All cache cleared"}
1090
+
1091
+ @app.get("/api")
1092
+ async def api_status():
1093
+ """API状态检查"""
1094
+ return {
1095
+ "message": "Outlook邮件API服务正在运行",
1096
+ "version": "1.0.0",
1097
+ "endpoints": {
1098
+ "get_accounts": "GET /accounts",
1099
+ "register_account": "POST /accounts",
1100
+ "get_emails": "GET /emails/{email_id}?refresh=true",
1101
+ "get_dual_view_emails": "GET /emails/{email_id}/dual-view",
1102
+ "get_email_detail": "GET /emails/{email_id}/{message_id}",
1103
+ "clear_cache": "DELETE /cache/{email_id}",
1104
+ "clear_all_cache": "DELETE /cache"
1105
+ }
1106
+ }
1107
+
1108
+
1109
+ # ============================================================================
1110
+ # 启动配置
1111
+ # ============================================================================
1112
+
1113
+ if __name__ == "__main__":
1114
+ import uvicorn
1115
+
1116
+ # 启动配置
1117
+ HOST = "0.0.0.0"
1118
+ PORT = 8000
1119
+
1120
+ logger.info(f"Starting Outlook Email Management System on {HOST}:{PORT}")
1121
+ logger.info("Access the web interface at: http://localhost:8000")
1122
+ logger.info("Access the API documentation at: http://localhost:8000/docs")
1123
+
1124
+ uvicorn.run(
1125
+ app,
1126
+ host=HOST,
1127
+ port=PORT,
1128
+ log_level="info",
1129
+ access_log=True
1130
+ )
static/index.html ADDED
The diff for this file is too large to render. See raw diff