diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..3c31ae0
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,7 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(python:*)"
+ ]
+ }
+}
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..e8675f0
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,63 @@
+# Python缓存文件
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# 虚拟环境
+venv/
+env/
+ENV/
+
+# IDE文件
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# 操作系统文件
+.DS_Store
+Thumbs.db
+
+# 日志文件
+*.log
+
+# 临时文件
+*.tmp
+*.temp
+
+# Git文件
+.git/
+.gitignore
+
+# Docker文件
+Dockerfile
+.dockerignore
+
+# 文档文件
+README.md
+*.md
+
+# 测试文件
+tests/
+test_*.py
+
+# 其他不需要的文件
+outlook.txt
\ No newline at end of file
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..df408d7
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,20 @@
+# Outlook邮件管理系统环境配置
+# 复制此文件为 .env 并修改相应配置
+
+# 管理员访问令牌(请修改为安全的密码)
+ADMIN_TOKEN=admin123
+
+# 服务器端口(默认5000)
+SERVER_PORT=5000
+
+# 服务器主机(默认0.0.0.0,接受所有连接)
+SERVER_HOST=0.0.0.0
+
+# 时区设置
+TZ=Asia/Shanghai
+
+# 日志级别(DEBUG, INFO, WARNING, ERROR, CRITICAL)
+LOG_LEVEL=INFO
+
+# 是否启用详细日志
+VERBOSE_LOGGING=false
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4bf1290
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+config.txt
+outlook_manager.db
+.codebuddy
+_pychache_
+*.pyc
+config.txt
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..c37ecfc
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,38 @@
+# 使用Python 3.12官方镜像作为基础镜像
+FROM python:3.12-slim
+
+# 设置工作目录
+WORKDIR /app
+
+# 设置环境变量
+ENV PYTHONUNBUFFERED=1
+ENV PYTHONDONTWRITEBYTECODE=1
+
+# 安装系统依赖
+RUN apt-get update && apt-get install -y \
+ gcc \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+# 复制requirements.txt并安装Python依赖
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+# 复制应用程序代码
+COPY . .
+
+# 创建配置文件目录(如果不存在)
+RUN touch config.txt
+
+# 设置权限
+RUN chmod +x mail_api.py
+
+# 暴露端口
+EXPOSE 5000
+
+# 设置健康检查
+HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
+ CMD curl -f http://localhost:5000/ || exit 1
+
+# 启动命令
+CMD ["python", "mail_api.py", "web"]
\ No newline at end of file
diff --git a/TASK_CLAUDE_PAYMENT.md b/TASK_CLAUDE_PAYMENT.md
new file mode 100644
index 0000000..f56eebf
--- /dev/null
+++ b/TASK_CLAUDE_PAYMENT.md
@@ -0,0 +1,240 @@
+# 任务:实现 Claude 支付检测工具
+
+## 需求
+
+自动扫描邮箱收件箱,检测 Anthropic 的支付/退款邮件,自动给账号打标签并记录时间。
+
+**匹配规则:**
+- 发件人:`invoice+statements@mail.anthropic.com`
+- 标题含 `Your receipt from Anthropic` → 标记 **已支付Claude**(status=`paid`),记录支付时间
+- 标题含 `Your refund from Anthropic` → 标记 **已退款**(status=`refunded`),记录退款时间
+- 时间取邮件的 `receivedDateTime`
+- 如果同时存在支付和退款邮件,比较时间取最新的作为当前状态
+
+---
+
+## 实现方案(共 5 个改动点)
+
+### 1. `database.py` — 新增表 + 4 个方法
+
+在 `init_database()` 中创建新表:
+
+```sql
+CREATE TABLE IF NOT EXISTS claude_payment_status (
+ email TEXT PRIMARY KEY,
+ status TEXT DEFAULT 'unknown', -- paid / refunded / unknown / error
+ payment_time TEXT,
+ refund_time TEXT,
+ checked_at TEXT
+)
+```
+
+新增 3 个 async 方法(参考现有 `get_account_tags` / `set_account_tags` 的写法):
+- `set_claude_payment_status(email, status, payment_time=None, refund_time=None)` — INSERT OR REPLACE,checked_at 取当前时间
+- `get_claude_payment_status(email)` → Optional[Dict]
+- `get_all_claude_payment_statuses()` → Dict[str, Dict]
+
+修改 `delete_account()`:在删除账户时联动删除 `claude_payment_status` 表中对应记录。
+
+### 2. `mail_api.py` — 检测逻辑 + 2 个新端点
+
+添加 `StreamingResponse` 到 import:
+```python
+from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse, StreamingResponse
+```
+
+在"命令行入口"注释之前,添加以下内容:
+
+**常量:**
+```python
+ANTHROPIC_SENDER = "invoice+statements@mail.anthropic.com"
+RECEIPT_KEYWORD = "Your receipt from Anthropic"
+REFUND_KEYWORD = "Your refund from Anthropic"
+```
+
+**核心检测函数** `_check_claude_payment_for_account(email_addr)`:
+1. 调用 `email_manager.get_client(email_addr)` 获取客户端
+2. 调用 `client.get_messages_with_content(top=30)` 获取最近 30 封 INBOX 邮件
+3. 遍历邮件,匹配 `sender.emailAddress.address` == ANTHROPIC_SENDER(不区分大小写)
+4. 匹配标题关键词,记录最新的 payment_time 和 refund_time
+5. 确定状态:两者都有则比较时间取最新;只有支付=paid;只有退款=refunded;都没有=unknown
+6. 调用 `db_manager.set_claude_payment_status()` 写入数据库
+7. 同时双写标签:获取现有标签,移除旧的"已支付Claude"/"已退款"标签,根据状态添加新标签
+8. 返回 `{email, status, payment_time, refund_time}`
+9. 出错时记录 error 状态并返回 `{email, status: "error", message: str(e)}`
+
+**端点 1:** `POST /api/tools/check-claude-payment` — SSE 流式扫描
+- 获取所有账户 `load_accounts_config()`
+- 逐个调用 `_check_claude_payment_for_account()`
+- SSE 事件格式(每行 `data: {JSON}\n\n`):
+ - `{type: "start", total: N}`
+ - `{type: "progress", current: i, total: N, email: "..."}`(扫描前发送)
+ - `{type: "result", current: i, total: N, email, status, payment_time, refund_time}`(扫描后发送)
+ - `{type: "done", total: N}`
+- 账户之间 `await asyncio.sleep(0.5)` 避免限速
+- 返回 `StreamingResponse(event_generator(), media_type="text/event-stream")`
+
+**端点 2:** `GET /api/tools/claude-payment-status` — 获取缓存状态
+- 调用 `db_manager.get_all_claude_payment_statuses()`
+- 返回 `ApiResponse(success=True, data=statuses)`
+
+### 3. `static/index.html` — 按钮 + 表格列
+
+**导航栏**:在 `nav-actions` 的"导出"按钮之后、"刷新"按钮之前,添加:
+```html
+
+```
+
+**表格表头**:在"令牌"和"操作"之间,添加新列:
+```html
+
Claude状态 |
+```
+同时把所有 `colspan="6"` 改为 `colspan="7"`(空状态行)。
+
+### 4. `static/script.js` — SSE 扫描 + 状态渲染
+
+**constructor** 中新增:
+```javascript
+this.claudePaymentStatuses = {};
+```
+
+**bindAccountEvents()** 中新增按钮绑定:
+```javascript
+document.getElementById('claudePaymentBtn').addEventListener('click', () => this.startClaudePaymentCheck());
+```
+
+**loadAccounts()** 中:在 `renderAccounts()` 调用前,先 `await this.loadClaudePaymentStatuses()`。同时把所有 `colspan="6"` 改为 `colspan="7"`。
+
+**renderAccounts()** 中:在令牌列 `` 和操作列 `` 之间,插入新列:
+```javascript
+ | ${this.renderClaudeStatusBadge(email)} |
+```
+
+**新增 4 个方法:**
+
+```javascript
+async loadClaudePaymentStatuses() {
+ try {
+ const resp = await fetch('/api/tools/claude-payment-status');
+ if (resp.ok) {
+ const result = await resp.json();
+ if (result.success) this.claudePaymentStatuses = result.data || {};
+ }
+ } catch (e) { console.error('加载Claude支付状态失败:', e); }
+}
+
+renderClaudeStatusBadge(email) {
+ const info = this.claudePaymentStatuses[email];
+ if (!info) return '未检测';
+ const map = {
+ paid: { cls: 'claude-paid', label: '已支付' },
+ refunded: { cls: 'claude-refunded', label: '已退款' },
+ unknown: { cls: 'claude-unknown', label: '未知' },
+ error: { cls: 'claude-error', label: '错误' }
+ };
+ const s = map[info.status] || map.unknown;
+ let tips = [];
+ if (info.payment_time) tips.push('支付: ' + info.payment_time);
+ if (info.refund_time) tips.push('退款: ' + info.refund_time);
+ if (info.checked_at) tips.push('检测: ' + info.checked_at);
+ const title = tips.length ? ` title="${this.escapeHtml(tips.join('\n'))}"` : '';
+ return `${s.label}`;
+}
+
+async startClaudePaymentCheck() {
+ const btn = document.getElementById('claudePaymentBtn');
+ if (!btn) return;
+ btn.disabled = true;
+ const origHtml = btn.innerHTML;
+ btn.innerHTML = '检测中...';
+
+ try {
+ const response = await fetch('/api/tools/check-claude-payment', { method: 'POST' });
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop();
+ for (const line of lines) {
+ if (!line.startsWith('data: ')) continue;
+ try {
+ const data = JSON.parse(line.slice(6));
+ if (data.type === 'progress') {
+ btn.innerHTML = `${data.current}/${data.total}`;
+ } else if (data.type === 'result') {
+ this.claudePaymentStatuses[data.email] = {
+ status: data.status,
+ payment_time: data.payment_time || null,
+ refund_time: data.refund_time || null,
+ checked_at: new Date().toLocaleString('zh-CN')
+ };
+ this.updateClaudeBadgeInTable(data.email);
+ } else if (data.type === 'done') {
+ btn.innerHTML = '完成';
+ setTimeout(() => { btn.innerHTML = origHtml; btn.disabled = false; }, 2000);
+ }
+ } catch (e) {}
+ }
+ }
+ } catch (err) {
+ console.error('Claude支付检测失败:', err);
+ btn.innerHTML = '失败';
+ setTimeout(() => { btn.innerHTML = origHtml; btn.disabled = false; }, 2000);
+ }
+}
+
+updateClaudeBadgeInTable(email) {
+ const tbody = document.getElementById('accountTableBody');
+ if (!tbody) return;
+ for (const row of tbody.querySelectorAll('tr')) {
+ const firstTd = row.querySelector('td');
+ if (firstTd && firstTd.textContent.includes(email)) {
+ // Claude状态列 = 第6个td(index 5,在令牌后、操作前)
+ const cells = row.querySelectorAll('td');
+ if (cells.length >= 6) {
+ cells[5].innerHTML = this.renderClaudeStatusBadge(email);
+ }
+ break;
+ }
+ }
+}
+```
+
+### 5. `static/style.css` — 徽章样式
+
+在文件末尾(`@media` 之前或之后均可)添加:
+
+```css
+/* Claude支付状态徽章 */
+.claude-badge {
+ display: inline-block;
+ padding: 3px 10px;
+ border-radius: 12px;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: default;
+ white-space: nowrap;
+}
+.claude-paid { background: rgba(16, 185, 129, 0.12); color: #059669; }
+.claude-refunded { background: rgba(245, 87, 108, 0.12); color: #e11d48; }
+.claude-unknown { background: rgba(148, 163, 184, 0.15); color: #64748b; }
+.claude-error { background: rgba(245, 158, 11, 0.15); color: #d97706; }
+```
+
+---
+
+## 验证步骤
+
+1. `python mail_api.py web` → 访问 `http://localhost:5001/`
+2. 表格应多出一列"Claude状态",初始都显示灰色"未检测"
+3. 点击导航栏"Claude检测"按钮 → 按钮显示进度 `1/N`、`2/N`...
+4. 扫描完成后,有 Anthropic 收据的账号显示绿色"已支付",有退款的显示红色"已退款"
+5. 鼠标悬停徽章可看到支付/退款时间
+6. 刷新页面后状态仍然保留(数据库持久化)
diff --git a/auth.py b/auth.py
new file mode 100644
index 0000000..57bd084
--- /dev/null
+++ b/auth.py
@@ -0,0 +1,79 @@
+#!/usr/bin/env python3
+"""
+OAuth2认证模块
+处理Microsoft OAuth2令牌获取和刷新
+"""
+
+import requests
+import logging
+from typing import Optional
+from fastapi import HTTPException
+
+from config import CLIENT_ID, TOKEN_URL
+
+logger = logging.getLogger(__name__)
+
+# ============================================================================
+# OAuth2令牌获取函数
+# ============================================================================
+
+async def get_access_token(refresh_token: str, check_only: bool = False, client_id: str = None) -> Optional[str]:
+ """使用refresh_token获取access_token
+
+ Args:
+ refresh_token: 刷新令牌
+ check_only: 如果为True,验证失败时返回None而不是抛出异常
+ client_id: 可选,指定client_id,默认使用全局CLIENT_ID
+
+ Returns:
+ 成功返回access_token,如果check_only=True且验证失败则返回None
+ """
+ data = {
+ 'client_id': client_id or CLIENT_ID,
+ 'grant_type': 'refresh_token',
+ 'refresh_token': refresh_token,
+ 'scope': 'https://outlook.office.com/IMAP.AccessAsUser.All offline_access'
+ }
+
+ try:
+ # 使用requests而不是httpx,因为exp.py验证有效
+ response = requests.post(TOKEN_URL, data=data)
+ response.raise_for_status()
+
+ token_data = response.json()
+ access_token = token_data.get('access_token')
+
+ if not access_token:
+ error_msg = f"获取 access_token 失败: {token_data.get('error_description', '响应中未找到 access_token')}"
+ logger.error(error_msg)
+ if check_only:
+ return None
+ raise HTTPException(status_code=401, detail=error_msg)
+
+ new_refresh_token = token_data.get('refresh_token')
+ if new_refresh_token and new_refresh_token != refresh_token:
+ logger.debug("提示: refresh_token 已被服务器更新")
+
+ return access_token
+
+ except requests.exceptions.HTTPError as http_err:
+ logger.error(f"请求 access_token 时发生HTTP错误: {http_err}")
+ if http_err.response is not None:
+ logger.error(f"服务器响应: {http_err.response.status_code} - {http_err.response.text}")
+
+ if check_only:
+ return None
+ raise HTTPException(status_code=401, detail="Refresh token已过期或无效,需要重新获取授权")
+
+ except requests.exceptions.RequestException as e:
+ logger.error(f"请求 access_token 时发生网络错误: {e}")
+ if check_only:
+ return None
+ raise HTTPException(status_code=500, detail="Token acquisition failed")
+
+ except Exception as e:
+ logger.error(f"解析 access_token 响应时出错: {e}")
+ if check_only:
+ return None
+ raise HTTPException(status_code=500, detail="Token acquisition failed")
+
diff --git a/config.py b/config.py
new file mode 100644
index 0000000..cb7f2fc
--- /dev/null
+++ b/config.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+"""
+配置常量和设置
+"""
+
+import os
+import logging
+
+# ============================================================================
+# Microsoft OAuth2配置
+# ============================================================================
+
+# 使用exp.py中验证有效的CLIENT_ID
+CLIENT_ID = 'dbc8e03a-b00c-46bd-ae65-b683e7707cb0'
+TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
+
+# ============================================================================
+# IMAP配置
+# ============================================================================
+
+IMAP_SERVER = 'outlook.live.com'
+IMAP_PORT = 993
+INBOX_FOLDER_NAME = "INBOX"
+JUNK_FOLDER_NAME = "Junk"
+
+# ============================================================================
+# 系统配置
+# ============================================================================
+
+# 管理认证配置
+ADMIN_TOKEN = os.getenv('ADMIN_TOKEN', 'admin123') # 从环境变量获取,默认为admin123
+
+# 邮件获取配置
+DEFAULT_EMAIL_LIMIT = 10 # 默认邮件获取数量限制(V2默认获取10封)
+
+# ============================================================================
+# 日志配置
+# ============================================================================
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
diff --git a/database.py b/database.py
new file mode 100644
index 0000000..f7c71ed
--- /dev/null
+++ b/database.py
@@ -0,0 +1,631 @@
+#!/usr/bin/env python3
+"""
+SQLite数据库管理模块
+用于管理邮件和标签数据
+"""
+
+import sqlite3
+import json
+import logging
+from datetime import datetime
+from typing import Dict, List, Optional, Tuple
+from pathlib import Path
+import asyncio
+import threading
+
+logger = logging.getLogger(__name__)
+
+class DatabaseManager:
+ """数据库管理器"""
+
+ def __init__(self, db_path: str = "outlook_manager.db"):
+ self.db_path = db_path
+ self._local = threading.local()
+ self.init_database()
+
+ def get_connection(self) -> sqlite3.Connection:
+ """获取线程本地的数据库连接"""
+ if not hasattr(self._local, 'connection'):
+ self._local.connection = sqlite3.connect(self.db_path)
+ self._local.connection.row_factory = sqlite3.Row
+ return self._local.connection
+
+ def init_database(self):
+ """初始化数据库表"""
+ conn = self.get_connection()
+ cursor = conn.cursor()
+
+ # 创建账户表
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS accounts (
+ email TEXT PRIMARY KEY,
+ password TEXT DEFAULT '',
+ client_id TEXT DEFAULT '',
+ refresh_token TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ ''')
+
+ # 创建账户标签表
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS account_tags (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT NOT NULL,
+ tags TEXT NOT NULL, -- JSON格式存储标签数组
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ ''')
+
+ # 创建邮件缓存表
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS email_cache (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT NOT NULL,
+ message_id TEXT NOT NULL,
+ subject TEXT,
+ sender TEXT,
+ received_date TEXT,
+ body_preview TEXT,
+ body_content TEXT,
+ body_type TEXT DEFAULT 'text',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(email, message_id)
+ )
+ ''')
+
+ # 创建系统配置表
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS system_config (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ ''')
+
+ # 创建Claude支付状态表
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS claude_payment_status (
+ email TEXT PRIMARY KEY,
+ status TEXT DEFAULT 'unknown',
+ payment_time TEXT,
+ refund_time TEXT,
+ suspended_time TEXT,
+ title TEXT DEFAULT '',
+ remark TEXT DEFAULT '',
+ card_number TEXT DEFAULT '',
+ proxy TEXT DEFAULT '',
+ proxy_expire_days INTEGER DEFAULT 30,
+ proxy_share TEXT DEFAULT 'exclusive',
+ proxy_purchase_date TEXT DEFAULT '',
+ checked_at TEXT
+ )
+ ''')
+
+ # 兼容旧表:动态添加缺少的列
+ for col in ['suspended_time', 'title', 'remark', 'card_number', 'proxy', 'proxy_expire_days', 'proxy_share', 'proxy_purchase_date']:
+ try:
+ cursor.execute(f"SELECT {col} FROM claude_payment_status LIMIT 1")
+ except sqlite3.OperationalError:
+ defaults = {'title': "DEFAULT ''", 'remark': "DEFAULT ''", 'card_number': "DEFAULT ''", 'proxy': "DEFAULT ''",
+ 'proxy_expire_days': "DEFAULT 30", 'proxy_share': "DEFAULT 'exclusive'", 'proxy_purchase_date': "DEFAULT ''"}
+ default = defaults.get(col, '')
+ cursor.execute(f"ALTER TABLE claude_payment_status ADD COLUMN {col} TEXT {default}")
+ logger.info(f"已为 claude_payment_status 添加 {col} 列")
+
+ # 创建索引
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_accounts_email ON accounts(email)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_account_tags_email ON account_tags(email)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_email_cache_email ON email_cache(email)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_email_cache_message_id ON email_cache(message_id)')
+
+ conn.commit()
+ logger.info("数据库初始化完成")
+
+ async def get_account_tags(self, email: str) -> List[str]:
+ """获取账户标签"""
+ def _sync_get():
+ conn = self.get_connection()
+ cursor = conn.cursor()
+ cursor.execute('SELECT tags FROM account_tags WHERE email = ?', (email,))
+ row = cursor.fetchone()
+ if row:
+ try:
+ return json.loads(row['tags'])
+ except json.JSONDecodeError:
+ return []
+ return []
+
+ return await asyncio.to_thread(_sync_get)
+
+ async def set_account_tags(self, email: str, tags: List[str]) -> bool:
+ """设置账户标签"""
+ def _sync_set():
+ try:
+ conn = self.get_connection()
+ cursor = conn.cursor()
+ tags_json = json.dumps(tags, ensure_ascii=False)
+
+ # 先检查记录是否存在
+ cursor.execute('SELECT id FROM account_tags WHERE email = ?', (email,))
+ existing_record = cursor.fetchone()
+
+ if existing_record:
+ # 更新现有记录
+ cursor.execute('''
+ UPDATE account_tags
+ SET tags = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE email = ?
+ ''', (tags_json, email))
+ logger.info(f"更新账户 {email} 的标签: {tags}")
+ else:
+ # 插入新记录
+ cursor.execute('''
+ INSERT INTO account_tags (email, tags, created_at, updated_at)
+ VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ ''', (email, tags_json))
+ logger.info(f"为账户 {email} 创建新标签: {tags}")
+
+ conn.commit()
+ logger.info(f"成功保存账户 {email} 的标签")
+ return True
+ except Exception as e:
+ logger.error(f"设置账户标签失败: {e}")
+ conn.rollback()
+ return False
+
+ return await asyncio.to_thread(_sync_set)
+
+ async def get_all_tags(self) -> List[str]:
+ """获取所有标签"""
+ def _sync_get():
+ conn = self.get_connection()
+ cursor = conn.cursor()
+ cursor.execute('SELECT tags FROM account_tags')
+ rows = cursor.fetchall()
+
+ all_tags = set()
+ for row in rows:
+ try:
+ tags = json.loads(row['tags'])
+ all_tags.update(tags)
+ except json.JSONDecodeError:
+ continue
+
+ return sorted(list(all_tags))
+
+ return await asyncio.to_thread(_sync_get)
+
+ async def get_accounts_with_tags(self) -> Dict[str, List[str]]:
+ """获取所有账户及其标签"""
+ def _sync_get():
+ conn = self.get_connection()
+ cursor = conn.cursor()
+ cursor.execute('SELECT email, tags FROM account_tags')
+ rows = cursor.fetchall()
+
+ result = {}
+ for row in rows:
+ try:
+ tags = json.loads(row['tags'])
+ result[row['email']] = tags
+ except json.JSONDecodeError:
+ result[row['email']] = []
+
+ return result
+
+ return await asyncio.to_thread(_sync_get)
+
+ async def cache_email(self, email: str, message_id: str, email_data: Dict) -> bool:
+ """缓存邮件数据"""
+ def _sync_cache():
+ try:
+ conn = self.get_connection()
+ cursor = conn.cursor()
+
+ # 提取邮件信息
+ subject = email_data.get('subject', '')
+ sender_info = email_data.get('sender', {}).get('emailAddress', {})
+ sender = f"{sender_info.get('name', '')} <{sender_info.get('address', '')}>"
+ received_date = email_data.get('receivedDateTime', '')
+ body_preview = email_data.get('bodyPreview', '')
+ body_info = email_data.get('body', {})
+ body_content = body_info.get('content', '')
+ body_type = body_info.get('contentType', 'text')
+
+ cursor.execute('''
+ INSERT OR REPLACE INTO email_cache
+ (email, message_id, subject, sender, received_date, body_preview, body_content, body_type)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ''', (email, message_id, subject, sender, received_date, body_preview, body_content, body_type))
+
+ conn.commit()
+ return True
+ except Exception as e:
+ logger.error(f"缓存邮件失败: {e}")
+ return False
+
+ return await asyncio.to_thread(_sync_cache)
+
+ async def get_cached_email(self, email: str, message_id: str) -> Optional[Dict]:
+ """获取缓存的邮件数据"""
+ def _sync_get():
+ conn = self.get_connection()
+ cursor = conn.cursor()
+ cursor.execute('''
+ SELECT * FROM email_cache
+ WHERE email = ? AND message_id = ?
+ ''', (email, message_id))
+ row = cursor.fetchone()
+
+ if row:
+ return {
+ 'id': row['message_id'],
+ 'subject': row['subject'],
+ 'sender': {'emailAddress': {'name': row['sender'].split(' <')[0] if ' <' in row['sender'] else row['sender'],
+ 'address': row['sender'].split('<')[1].rstrip('>') if '<' in row['sender'] else row['sender']}},
+ 'receivedDateTime': row['received_date'],
+ 'bodyPreview': row['body_preview'],
+ 'body': {'content': row['body_content'], 'contentType': row['body_type']}
+ }
+ return None
+
+ return await asyncio.to_thread(_sync_get)
+
+ async def get_system_config(self, key: str, default_value: str = None) -> Optional[str]:
+ """获取系统配置"""
+ def _sync_get():
+ conn = self.get_connection()
+ cursor = conn.cursor()
+ cursor.execute('SELECT value FROM system_config WHERE key = ?', (key,))
+ row = cursor.fetchone()
+ return row['value'] if row else default_value
+
+ return await asyncio.to_thread(_sync_get)
+
+ async def set_system_config(self, key: str, value: str) -> bool:
+ """设置系统配置"""
+ def _sync_set():
+ try:
+ conn = self.get_connection()
+ cursor = conn.cursor()
+ cursor.execute('''
+ INSERT OR REPLACE INTO system_config (key, value, updated_at)
+ VALUES (?, ?, CURRENT_TIMESTAMP)
+ ''', (key, value))
+ conn.commit()
+ return True
+ except Exception as e:
+ logger.error(f"设置系统配置失败: {e}")
+ return False
+
+ return await asyncio.to_thread(_sync_set)
+
+ async def cleanup_old_emails(self, days: int = 30) -> int:
+ """清理旧的邮件缓存"""
+ def _sync_cleanup():
+ try:
+ conn = self.get_connection()
+ cursor = conn.cursor()
+ cursor.execute('''
+ DELETE FROM email_cache
+ WHERE created_at < datetime('now', '-{} days')
+ '''.format(days))
+ deleted_count = cursor.rowcount
+ conn.commit()
+ return deleted_count
+ except Exception as e:
+ logger.error(f"清理旧邮件失败: {e}")
+ return 0
+
+ return await asyncio.to_thread(_sync_cleanup)
+
+ async def get_all_accounts(self) -> Dict[str, Dict[str, str]]:
+ """获取所有账户"""
+ def _sync_get():
+ conn = self.get_connection()
+ cursor = conn.cursor()
+ cursor.execute('SELECT email, password, client_id, refresh_token FROM accounts')
+ rows = cursor.fetchall()
+
+ result = {}
+ for row in rows:
+ result[row['email']] = {
+ 'password': row['password'] or '',
+ 'client_id': row['client_id'] or '',
+ 'refresh_token': row['refresh_token']
+ }
+ return result
+
+ return await asyncio.to_thread(_sync_get)
+
+ async def add_account(self, email: str, password: str = '', client_id: str = '', refresh_token: str = '') -> bool:
+ """添加账户"""
+ def _sync_add():
+ try:
+ conn = self.get_connection()
+ cursor = conn.cursor()
+ cursor.execute('''
+ INSERT INTO accounts (email, password, client_id, refresh_token)
+ VALUES (?, ?, ?, ?)
+ ''', (email, password, client_id, refresh_token))
+ conn.commit()
+ return True
+ except sqlite3.IntegrityError:
+ # 账户已存在
+ return False
+ except Exception as e:
+ logger.error(f"添加账户失败: {e}")
+ return False
+
+ return await asyncio.to_thread(_sync_add)
+
+ async def update_account(self, email: str, password: str = None, client_id: str = None, refresh_token: str = None) -> bool:
+ """更新账户"""
+ def _sync_update():
+ try:
+ conn = self.get_connection()
+ cursor = conn.cursor()
+
+ # 构建更新语句
+ updates = []
+ params = []
+
+ if password is not None:
+ updates.append('password = ?')
+ params.append(password)
+
+ if client_id is not None:
+ updates.append('client_id = ?')
+ params.append(client_id)
+
+ if refresh_token is not None:
+ updates.append('refresh_token = ?')
+ params.append(refresh_token)
+
+ if not updates:
+ return True # 没有更新内容
+
+ updates.append('updated_at = CURRENT_TIMESTAMP')
+ params.append(email)
+
+ sql = f"UPDATE accounts SET {', '.join(updates)} WHERE email = ?"
+ cursor.execute(sql, params)
+ conn.commit()
+ return cursor.rowcount > 0
+ except Exception as e:
+ logger.error(f"更新账户失败: {e}")
+ return False
+
+ return await asyncio.to_thread(_sync_update)
+
+ async def delete_account(self, email: str) -> bool:
+ """删除账户"""
+ def _sync_delete():
+ try:
+ conn = self.get_connection()
+ cursor = conn.cursor()
+
+ # 删除账户
+ cursor.execute('DELETE FROM accounts WHERE email = ?', (email,))
+
+ # 同时删除相关的标签
+ cursor.execute('DELETE FROM account_tags WHERE email = ?', (email,))
+
+ # 删除相关的邮件缓存
+ cursor.execute('DELETE FROM email_cache WHERE email = ?', (email,))
+
+ # 删除Claude支付状态
+ cursor.execute('DELETE FROM claude_payment_status WHERE email = ?', (email,))
+
+ conn.commit()
+ return cursor.rowcount > 0
+ except Exception as e:
+ logger.error(f"删除账户失败: {e}")
+ return False
+
+ return await asyncio.to_thread(_sync_delete)
+
+ async def account_exists(self, email: str) -> bool:
+ """检查账户是否存在"""
+ def _sync_check():
+ conn = self.get_connection()
+ cursor = conn.cursor()
+ cursor.execute('SELECT 1 FROM accounts WHERE email = ?', (email,))
+ return cursor.fetchone() is not None
+
+ return await asyncio.to_thread(_sync_check)
+
+ async def get_account(self, email: str) -> Optional[Dict[str, str]]:
+ """获取单个账户信息"""
+ def _sync_get():
+ conn = self.get_connection()
+ cursor = conn.cursor()
+ cursor.execute('SELECT password, client_id, refresh_token FROM accounts WHERE email = ?', (email,))
+ row = cursor.fetchone()
+
+ if row:
+ return {
+ 'password': row['password'] or '',
+ 'client_id': row['client_id'] or '',
+ 'refresh_token': row['refresh_token']
+ }
+ return None
+
+ return await asyncio.to_thread(_sync_get)
+
+ async def migrate_from_config_file(self, config_file_path: str = 'config.txt') -> Tuple[int, int]:
+ """从config.txt迁移数据到数据库"""
+ def _sync_migrate():
+ try:
+ import os
+ if not os.path.exists(config_file_path):
+ return 0, 0
+
+ conn = self.get_connection()
+ cursor = conn.cursor()
+
+ added_count = 0
+ error_count = 0
+
+ with open(config_file_path, 'r', encoding='utf-8') as f:
+ lines = f.readlines()
+
+ for line in lines:
+ line = line.strip()
+ if line.startswith('#') or not line:
+ continue
+
+ try:
+ parts = line.split('----')
+ if len(parts) >= 4:
+ email, password, client_id, refresh_token = parts[0], parts[1], parts[2], parts[3]
+
+ # 检查账户是否已存在
+ cursor.execute('SELECT 1 FROM accounts WHERE email = ?', (email.strip(),))
+ if not cursor.fetchone():
+ cursor.execute('''
+ INSERT INTO accounts (email, password, client_id, refresh_token)
+ VALUES (?, ?, ?, ?)
+ ''', (email.strip(), password.strip(), client_id.strip(), refresh_token.strip()))
+ added_count += 1
+ elif len(parts) == 2: # 兼容旧格式
+ email, refresh_token = parts
+ cursor.execute('SELECT 1 FROM accounts WHERE email = ?', (email.strip(),))
+ if not cursor.fetchone():
+ cursor.execute('''
+ INSERT INTO accounts (email, password, client_id, refresh_token)
+ VALUES (?, ?, ?, ?)
+ ''', (email.strip(), '', '', refresh_token.strip()))
+ added_count += 1
+ except Exception as e:
+ logger.error(f"迁移行失败: {line}, 错误: {e}")
+ error_count += 1
+
+ conn.commit()
+ return added_count, error_count
+
+ except Exception as e:
+ logger.error(f"迁移配置文件失败: {e}")
+ return 0, 1
+
+ return await asyncio.to_thread(_sync_migrate)
+
+ async def set_claude_payment_status(self, email: str, status: str, payment_time: str = None, refund_time: str = None, suspended_time: str = None) -> bool:
+ """设置Claude支付状态"""
+ def _sync_set():
+ try:
+ conn = self.get_connection()
+ cursor = conn.cursor()
+ checked_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+ # 先查已有的 remark/card_number,避免被覆盖
+ cursor.execute('SELECT title, remark, card_number, proxy, proxy_expire_days, proxy_share, proxy_purchase_date FROM claude_payment_status WHERE email = ?', (email,))
+ existing = cursor.fetchone()
+ old_title = existing['title'] if existing else ''
+ old_remark = existing['remark'] if existing else ''
+ old_card = existing['card_number'] if existing else ''
+ old_proxy = existing['proxy'] if existing else ''
+ old_expire = existing['proxy_expire_days'] if existing else 30
+ old_share = existing['proxy_share'] if existing else 'exclusive'
+ old_purchase = existing['proxy_purchase_date'] if existing else ''
+ cursor.execute('''
+ INSERT OR REPLACE INTO claude_payment_status (email, status, payment_time, refund_time, suspended_time, title, remark, card_number, proxy, proxy_expire_days, proxy_share, proxy_purchase_date, checked_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ''', (email, status, payment_time, refund_time, suspended_time, old_title or '', old_remark or '', old_card or '', old_proxy or '', old_expire or 30, old_share or 'exclusive', old_purchase or '', checked_at))
+ conn.commit()
+ return True
+ except Exception as e:
+ logger.error(f"设置Claude支付状态失败: {e}")
+ return False
+
+ return await asyncio.to_thread(_sync_set)
+
+ async def get_claude_payment_status(self, email: str) -> Optional[Dict]:
+ """获取Claude支付状态"""
+ def _sync_get():
+ conn = self.get_connection()
+ cursor = conn.cursor()
+ cursor.execute('SELECT status, payment_time, refund_time, suspended_time, title, remark, card_number, proxy, proxy_expire_days, proxy_share, proxy_purchase_date, checked_at FROM claude_payment_status WHERE email = ?', (email,))
+ row = cursor.fetchone()
+ if row:
+ return {
+ 'status': row['status'],
+ 'payment_time': row['payment_time'],
+ 'refund_time': row['refund_time'],
+ 'suspended_time': row['suspended_time'],
+ 'title': row['title'] or '',
+ 'remark': row['remark'] or '',
+ 'card_number': row['card_number'] or '',
+ 'proxy': row['proxy'] or '',
+ 'proxy_expire_days': row['proxy_expire_days'] or 30,
+ 'proxy_share': row['proxy_share'] or 'exclusive',
+ 'proxy_purchase_date': row['proxy_purchase_date'] or '',
+ 'checked_at': row['checked_at']
+ }
+ return None
+
+ return await asyncio.to_thread(_sync_get)
+
+ async def get_all_claude_payment_statuses(self) -> Dict[str, Dict]:
+ """获取所有Claude支付状态"""
+ def _sync_get():
+ conn = self.get_connection()
+ cursor = conn.cursor()
+ cursor.execute('SELECT email, status, payment_time, refund_time, suspended_time, title, remark, card_number, proxy, proxy_expire_days, proxy_share, proxy_purchase_date, checked_at FROM claude_payment_status')
+ rows = cursor.fetchall()
+ result = {}
+ for row in rows:
+ result[row['email']] = {
+ 'status': row['status'],
+ 'payment_time': row['payment_time'],
+ 'refund_time': row['refund_time'],
+ 'suspended_time': row['suspended_time'],
+ 'title': row['title'] or '',
+ 'remark': row['remark'] or '',
+ 'card_number': row['card_number'] or '',
+ 'proxy': row['proxy'] or '',
+ 'proxy_expire_days': row['proxy_expire_days'] or 30,
+ 'proxy_share': row['proxy_share'] or 'exclusive',
+ 'proxy_purchase_date': row['proxy_purchase_date'] or '',
+ 'checked_at': row['checked_at']
+ }
+ return result
+
+ return await asyncio.to_thread(_sync_get)
+
+ async def update_claude_payment_note(self, email: str, **kwargs) -> bool:
+ """更新备注、卡号、代理等字段"""
+ allowed = {'title', 'remark', 'card_number', 'proxy', 'proxy_expire_days', 'proxy_share', 'proxy_purchase_date'}
+ def _sync_update():
+ try:
+ conn = self.get_connection()
+ cursor = conn.cursor()
+ cursor.execute('SELECT 1 FROM claude_payment_status WHERE email = ?', (email,))
+ if not cursor.fetchone():
+ cursor.execute("INSERT INTO claude_payment_status (email, status) VALUES (?, 'unknown')", (email,))
+ updates = []
+ params = []
+ for k, v in kwargs.items():
+ if k in allowed and v is not None:
+ updates.append(f'{k} = ?')
+ params.append(v)
+ if updates:
+ params.append(email)
+ cursor.execute(f"UPDATE claude_payment_status SET {', '.join(updates)} WHERE email = ?", params)
+ conn.commit()
+ return True
+ except Exception as e:
+ logger.error(f"更新备注/卡号失败: {e}")
+ return False
+
+ return await asyncio.to_thread(_sync_update)
+
+ def close(self):
+ """关闭数据库连接"""
+ if hasattr(self._local, 'connection'):
+ self._local.connection.close()
+ delattr(self._local, 'connection')
+
+# 全局数据库管理器实例
+db_manager = DatabaseManager()
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..0fd28e4
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,31 @@
+version: '3.8'
+
+services:
+ outlook-mail-system:
+ build: .
+ container_name: outlook-mail-automation
+ ports:
+ - "5000:5000"
+ volumes:
+ # 挂载配置文件,便于修改邮箱配置
+ - ./config.txt:/app/config.txt
+ # 可选:挂载日志目录
+ - ./logs:/app/logs
+ environment:
+ # 管理员令牌,可以通过环境变量设置
+ - ADMIN_TOKEN=${ADMIN_TOKEN:-admin123}
+ # 可选:设置时区
+ - TZ=Asia/Shanghai
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:5000/"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
+ networks:
+ - outlook-mail-network
+
+networks:
+ outlook-mail-network:
+ driver: bridge
\ No newline at end of file
diff --git a/imap_client.py b/imap_client.py
new file mode 100644
index 0000000..d6ce615
--- /dev/null
+++ b/imap_client.py
@@ -0,0 +1,370 @@
+#!/usr/bin/env python3
+"""
+IMAP邮件客户端模块
+处理IMAP连接和邮件获取操作
+"""
+
+import asyncio
+import imaplib
+import email
+import logging
+import time
+from datetime import datetime
+from typing import Dict, List, Optional
+from email.header import decode_header
+from email import utils as email_utils
+from fastapi import HTTPException
+
+from config import IMAP_SERVER, IMAP_PORT, INBOX_FOLDER_NAME
+from auth import get_access_token
+
+logger = logging.getLogger(__name__)
+
+# ============================================================================
+# 辅助函数
+# ============================================================================
+
+def decode_header_value(header_value):
+ """解码邮件头部信息"""
+ if header_value is None:
+ return ""
+ decoded_string = ""
+ try:
+ parts = decode_header(str(header_value))
+ for part, charset in parts:
+ if isinstance(part, bytes):
+ try:
+ decoded_string += part.decode(charset if charset else 'utf-8', 'replace')
+ except LookupError:
+ decoded_string += part.decode('utf-8', 'replace')
+ else:
+ decoded_string += str(part)
+ except Exception:
+ if isinstance(header_value, str):
+ return header_value
+ try:
+ return str(header_value, 'utf-8', 'replace') if isinstance(header_value, bytes) else str(header_value)
+ except:
+ return "[Header Decode Error]"
+ return decoded_string
+
+# ============================================================================
+# IMAP客户端类
+# ============================================================================
+
+class IMAPEmailClient:
+ """IMAP邮件客户端(按需连接模式)"""
+
+ def __init__(self, email: str, account_info: Dict):
+ """初始化IMAP邮件客户端
+
+ Args:
+ email: 邮箱地址
+ account_info: 包含refresh_token的账户信息
+ """
+ self.email = email
+ self.refresh_token = account_info['refresh_token']
+ self.client_id = account_info.get('client_id', '')
+ self.access_token = ''
+ self.expires_at = 0
+
+ # Token管理锁
+ self._token_lock = asyncio.Lock()
+
+ logger.debug(f"IMAPEmailClient初始化 ({email}),采用按需连接策略")
+
+ def is_token_expired(self) -> bool:
+ """检查access token是否过期或即将过期"""
+ buffer_time = 300 # 5分钟缓冲时间
+ return datetime.now().timestamp() + buffer_time >= self.expires_at
+
+ async def ensure_token_valid(self):
+ """确保token有效(异步版本,带并发控制)"""
+ async with self._token_lock:
+ if not self.access_token or self.is_token_expired():
+ logger.info(f"{self.email} access token已过期或不存在,需要刷新")
+ await self.refresh_access_token()
+
+ async def refresh_access_token(self) -> None:
+ """刷新访问令牌"""
+ try:
+ logger.info(f"🔑 正在刷新 {self.email} 的访问令牌...")
+ access_token = await get_access_token(self.refresh_token, client_id=self.client_id)
+
+ if access_token:
+ self.access_token = access_token
+ self.expires_at = time.time() + 3600 # 默认1小时过期
+ expires_at_str = datetime.fromtimestamp(self.expires_at).strftime('%Y-%m-%d %H:%M:%S')
+ logger.info(f"✓ Token刷新成功(有效期至: {expires_at_str})")
+ else:
+ raise HTTPException(status_code=401, detail="Failed to refresh access token")
+
+ except Exception as e:
+ logger.error(f"✗ Token刷新失败 {self.email}: {e}")
+ raise
+
+ async def create_imap_connection(self, mailbox_to_select=INBOX_FOLDER_NAME):
+ """创建IMAP连接(按需创建,带超时和重试)"""
+ await self.ensure_token_valid()
+
+ max_retries = 3
+ for attempt in range(max_retries):
+ try:
+ if attempt > 0:
+ logger.info(f"🔄 重试连接 IMAP (第{attempt+1}次)")
+
+ def _sync_connect():
+ imap_conn = imaplib.IMAP4_SSL(IMAP_SERVER, IMAP_PORT)
+ auth_string = f"user={self.email}\1auth=Bearer {self.access_token}\1\1"
+ typ, data = imap_conn.authenticate('XOAUTH2', lambda x: auth_string.encode('utf-8'))
+
+ if typ == 'OK':
+ stat_select, data_select = imap_conn.select(mailbox_to_select, readonly=True)
+ if stat_select == 'OK':
+ return imap_conn
+ else:
+ error_msg = data_select[0].decode('utf-8', 'replace') if data_select and data_select[0] else "未知错误"
+ raise Exception(f"选择邮箱 '{mailbox_to_select}' 失败: {error_msg}")
+ else:
+ error_message = data[0].decode('utf-8', 'replace') if data and data[0] else "未知认证错误"
+ raise Exception(f"IMAP XOAUTH2 认证失败: {error_message} (Type: {typ})")
+
+ # 在线程池中执行,带10秒超时
+ imap_conn = await asyncio.wait_for(
+ asyncio.to_thread(_sync_connect), timeout=10.0
+ )
+ logger.info(f"🔌 IMAP连接已建立 → {mailbox_to_select}")
+ return imap_conn
+
+ except asyncio.TimeoutError:
+ logger.error(f"创建IMAP连接超时 ({self.email}), 第{attempt+1}次尝试")
+ if attempt < max_retries - 1:
+ await asyncio.sleep(1)
+ continue
+ except Exception as e:
+ logger.error(f"创建IMAP连接失败 ({self.email}), 第{attempt+1}次尝试: {e}")
+ if attempt < max_retries - 1:
+ await asyncio.sleep(1)
+ continue
+
+ logger.error(f"经过{max_retries}次尝试,仍无法创建IMAP连接 ({self.email})")
+ raise HTTPException(status_code=500, detail=f"Failed to connect to IMAP server for {self.email}")
+
+ def close_imap_connection(self, imap_conn):
+ """安全关闭IMAP连接"""
+ if imap_conn:
+ try:
+ current_state = getattr(imap_conn, 'state', None)
+
+ try:
+ if current_state == 'SELECTED':
+ imap_conn.close()
+ except Exception as e:
+ logger.debug(f"关闭邮箱时出现预期错误: {e}")
+
+ try:
+ if current_state != 'LOGOUT':
+ imap_conn.logout()
+ except Exception as e:
+ logger.debug(f"登出时出现预期错误: {e}")
+
+ logger.info(f"🔌 IMAP连接已关闭")
+ except Exception as e:
+ logger.debug(f"关闭IMAP连接时发生预期错误: {e}")
+
+ async def get_messages_with_content(self, folder_id: str = INBOX_FOLDER_NAME, top: int = 5) -> List[Dict]:
+ """获取指定文件夹的邮件(一次性获取完整内容,包括正文)
+
+ 优化点:
+ - 一次性获取邮件的完整内容(头部+正文)
+ - 前端可以缓存这些数据,查看详情时无需再次请求
+
+ Args:
+ folder_id: 文件夹ID, 默认为'INBOX'
+ top: 获取的邮件数量
+ """
+ import time
+ start_time = time.time()
+ logger.info(f"📧 开始获取 {self.email} 的邮件(文件夹: {folder_id}, 请求数量: {top})")
+
+ imap_conn = None
+ try:
+ imap_conn = await self.create_imap_connection(folder_id)
+
+ def _sync_get_messages_full():
+ # 快速扫描邮件UID列表(毫秒级操作)
+ scan_start = time.time()
+ typ, uid_data = imap_conn.uid('search', None, "ALL")
+ if typ != 'OK':
+ raise Exception(f"在 '{folder_id}' 中搜索邮件失败 (status: {typ})。")
+
+ if not uid_data[0]:
+ return []
+
+ uids = uid_data[0].split()
+ scan_time = (time.time() - scan_start) * 1000
+ logger.info(f"📋 扫描完成: 共 {len(uids)} 封邮件 (耗时: {scan_time:.0f}ms)")
+
+ # 只获取最新的top条邮件
+ uids = uids[-top:] if len(uids) > top else uids
+ uids.reverse() # 最新的在前
+
+ fetch_start = time.time()
+ logger.info(f"📥 开始获取 {len(uids)} 封邮件的完整内容(包含正文和附件)...")
+
+ messages = []
+ for idx, uid_bytes in enumerate(uids, 1):
+ try:
+ # 一次性获取完整邮件内容(RFC822)
+ typ, msg_data = imap_conn.uid('fetch', uid_bytes, '(RFC822)')
+
+ if typ == 'OK' and msg_data and msg_data[0] is not None:
+ raw_email_bytes = None
+ if isinstance(msg_data[0], tuple) and len(msg_data[0]) == 2:
+ raw_email_bytes = msg_data[0][1]
+
+ if raw_email_bytes:
+ email_message = email.message_from_bytes(raw_email_bytes)
+
+ # 解析头部信息
+ subject = decode_header_value(email_message['Subject']) or "(No Subject)"
+ from_str = decode_header_value(email_message['From']) or "(Unknown Sender)"
+ to_str = decode_header_value(email_message['To']) or ""
+ date_str = email_message['Date'] or "(Unknown Date)"
+
+ # 解析From字段
+ from_name = "(Unknown)"
+ from_email = ""
+ if '<' in from_str and '>' in from_str:
+ from_name = from_str.split('<')[0].strip().strip('"')
+ from_email = from_str.split('<')[1].split('>')[0].strip()
+ else:
+ from_email = from_str.strip()
+ if '@' in from_email:
+ from_name = from_email.split('@')[0]
+
+ # 解析日期
+ try:
+ dt_obj = email_utils.parsedate_to_datetime(date_str)
+ if dt_obj:
+ date_str = dt_obj.strftime('%Y-%m-%d %H:%M:%S')
+ except Exception:
+ date_str = date_str[:25] if len(date_str) > 25 else date_str
+
+ # 解析邮件正文(优先HTML)
+ body_content = ""
+ body_type = "text"
+ body_preview = ""
+
+ if email_message.is_multipart():
+ html_content = None
+ text_content = None
+
+ for part in email_message.walk():
+ content_type = part.get_content_type()
+ content_disposition = str(part.get("Content-Disposition"))
+
+ if 'attachment' not in content_disposition.lower():
+ try:
+ charset = part.get_content_charset() or 'utf-8'
+ payload = part.get_payload(decode=True)
+
+ if content_type == 'text/html' and not html_content:
+ html_content = payload.decode(charset, errors='replace')
+ elif content_type == 'text/plain' and not text_content:
+ text_content = payload.decode(charset, errors='replace')
+ except Exception:
+ continue
+
+ # 优先使用HTML内容
+ if html_content:
+ body_content = html_content
+ body_type = "html"
+ # 生成预览文本(移除HTML标签)
+ import re
+ body_preview = re.sub('<[^<]+?>', '', html_content)[:150]
+ elif text_content:
+ body_content = text_content
+ body_type = "text"
+ body_preview = text_content[:150]
+ else:
+ body_content = "[未找到可读的邮件内容]"
+ body_preview = "[未找到可读的邮件内容]"
+ else:
+ # 非多部分邮件
+ try:
+ charset = email_message.get_content_charset() or 'utf-8'
+ payload = email_message.get_payload(decode=True)
+ body_content = payload.decode(charset, errors='replace')
+
+ # 检查是否为HTML内容
+ if '', '', body_content)[:150]
+ else:
+ body_preview = body_content[:150]
+ except Exception:
+ body_content = "[Failed to decode email body]"
+ body_preview = "[Failed to decode email body]"
+
+ if not body_content:
+ body_content = "[未找到可读的文本内容]"
+ body_preview = "[未找到可读的文本内容]"
+
+ # 构建完整邮件信息(包含正文)
+ message = {
+ 'id': uid_bytes.decode('utf-8'),
+ 'subject': subject,
+ 'receivedDateTime': date_str,
+ 'sender': {
+ 'emailAddress': {
+ 'address': from_email,
+ 'name': from_name
+ }
+ },
+ 'from': {
+ 'emailAddress': {
+ 'address': from_email,
+ 'name': from_name
+ }
+ },
+ 'toRecipients': [{'emailAddress': {'address': to_str, 'name': to_str}}] if to_str else [],
+ 'body': {
+ 'content': body_content,
+ 'contentType': body_type
+ },
+ 'bodyPreview': body_preview
+ }
+ messages.append(message)
+
+ except Exception as e:
+ logger.error(f" ✗ 处理邮件UID {uid_bytes}时出错: {e}")
+ continue
+
+ fetch_time = (time.time() - fetch_start) * 1000
+ logger.info(f"📬 内容获取完成: {len(messages)} 封邮件 (耗时: {fetch_time:.0f}ms, 平均: {fetch_time/len(messages) if messages else 0:.0f}ms/封)")
+
+ return messages
+
+ # 在线程池中执行同步IMAP操作
+ messages = await asyncio.to_thread(_sync_get_messages_full)
+
+ total_time = (time.time() - start_time) * 1000
+ logger.info(f"✅ 完成!总耗时: {total_time:.0f}ms | 获取 {len(messages)} 封完整邮件(已包含正文,前端可缓存)")
+ return messages
+
+ except asyncio.CancelledError:
+ logger.warning(f"获取邮件操作被取消 ({self.email})")
+ raise
+ except Exception as e:
+ logger.error(f"获取邮件失败 {self.email}: {e}")
+ raise HTTPException(status_code=500, detail="Failed to retrieve emails")
+ finally:
+ if imap_conn:
+ self.close_imap_connection(imap_conn)
+
+ async def cleanup(self):
+ """清理资源"""
+ logger.debug(f"IMAPEmailClient清理完成 ({self.email})")
+
diff --git a/mail_api.py b/mail_api.py
new file mode 100644
index 0000000..9fd97ee
--- /dev/null
+++ b/mail_api.py
@@ -0,0 +1,1298 @@
+#!/usr/bin/env python3
+"""
+Microsoft邮件管理API
+基于FastAPI的现代化异步实现
+重构版本:使用模块化架构
+"""
+
+import asyncio
+import json
+import logging
+import os
+from datetime import datetime
+from typing import Dict, List, Optional
+from pathlib import Path
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI, HTTPException, Request, Depends, Header
+from fastapi.staticfiles import StaticFiles
+from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse, StreamingResponse
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.exceptions import RequestValidationError
+
+# 导入自定义模块
+from database import db_manager
+from models import (
+ ApiResponse, ImportAccountData, ImportResult, AdminTokenRequest,
+ DeleteAccountRequest, TempAccountRequest, SystemConfigRequest,
+ AccountTagRequest, TestEmailRequest
+)
+from config import CLIENT_ID, ADMIN_TOKEN, DEFAULT_EMAIL_LIMIT, logger
+from imap_client import IMAPEmailClient
+
+# ============================================================================
+# 辅助函数
+# ============================================================================
+
+def verify_admin_token(token: str) -> bool:
+ """验证管理令牌"""
+ return token == ADMIN_TOKEN
+
+def get_admin_token(authorization: Optional[str] = Header(None)) -> str:
+ """获取并验证管理令牌"""
+ if not authorization:
+ raise HTTPException(status_code=401, detail="未提供认证令牌")
+
+ if not authorization.startswith("Bearer "):
+ raise HTTPException(status_code=401, detail="无效的认证格式")
+
+ token = authorization[7:] # 移除 "Bearer " 前缀
+
+ if not verify_admin_token(token):
+ raise HTTPException(status_code=401, detail="无效的管理令牌")
+
+ return token
+
+async def load_accounts_config() -> Dict[str, Dict[str, str]]:
+ """从数据库加载批量账户信息"""
+ try:
+ accounts = await db_manager.get_all_accounts()
+
+ # 如果数据库为空,尝试从config.txt迁移
+ if not accounts:
+ logger.info("数据库中没有账户,尝试从config.txt迁移...")
+ added_count, error_count = await db_manager.migrate_from_config_file()
+ if added_count > 0:
+ logger.info(f"成功从config.txt迁移了 {added_count} 个账户")
+ accounts = await db_manager.get_all_accounts()
+ else:
+ logger.info("没有找到config.txt或迁移失败")
+
+ return accounts
+
+ except Exception as e:
+ logger.error(f"加载账户配置失败: {e}")
+ return {}
+
+async def save_accounts_config(accounts: Dict[str, Dict[str, str]]) -> bool:
+ """保存账户信息到配置文件(异步版本)"""
+ def _sync_save():
+ try:
+ header_lines = []
+ if Path('config.txt').exists():
+ with open('config.txt', 'r', encoding='utf-8') as f:
+ for line in f:
+ stripped = line.strip()
+ if stripped.startswith('#') or not stripped:
+ header_lines.append(line.rstrip())
+ else:
+ break
+
+ if not header_lines:
+ header_lines = [
+ '# 批量邮箱账户配置文件',
+ '# 格式:用户名----密码----client_id----refresh_token',
+ '# 每行一个账户,用----分隔各字段',
+ ''
+ ]
+
+ with open('config.txt', 'w', encoding='utf-8') as f:
+ for line in header_lines:
+ f.write(line + '\n')
+
+ for email, info in accounts.items():
+ password = info.get('password', '')
+ refresh_token = info.get('refresh_token', '')
+ line = f"{email}----{password}----{CLIENT_ID}----{refresh_token}"
+ f.write(line + '\n')
+
+ return True
+ except Exception as e:
+ logger.error(f"保存配置文件失败: {e}")
+ return False
+
+ return await asyncio.to_thread(_sync_save)
+
+# ============================================================================
+# 系统配置管理
+# ============================================================================
+
+async def load_system_config() -> Dict[str, any]:
+ """加载系统配置(使用数据库)"""
+ try:
+ email_limit = await db_manager.get_system_config('email_limit', str(DEFAULT_EMAIL_LIMIT))
+ return {
+ 'email_limit': int(email_limit) if email_limit else DEFAULT_EMAIL_LIMIT
+ }
+ except Exception as e:
+ logger.error(f"加载系统配置失败: {e}")
+ return {'email_limit': DEFAULT_EMAIL_LIMIT}
+
+async def save_system_config(config: Dict[str, any]) -> bool:
+ """保存系统配置(使用数据库)"""
+ try:
+ for key, value in config.items():
+ await db_manager.set_system_config(key, str(value))
+ return True
+ except Exception as e:
+ logger.error(f"保存系统配置失败: {e}")
+ return False
+
+async def get_system_config_value(key: str, default_value: any = None) -> any:
+ """获取系统配置值(使用数据库)"""
+ try:
+ value = await db_manager.get_system_config(key, str(default_value) if default_value is not None else None)
+ if key == 'email_limit' and value:
+ return int(value)
+ return value
+ except Exception as e:
+ logger.error(f"获取系统配置失败: {e}")
+ return default_value
+
+async def set_system_config_value(key: str, value: any) -> bool:
+ """设置系统配置值(使用数据库)"""
+ try:
+ return await db_manager.set_system_config(key, str(value))
+ except Exception as e:
+ logger.error(f"设置系统配置失败: {e}")
+ return False
+
+async def merge_accounts_data(existing_accounts: Dict[str, Dict[str, str]],
+ new_accounts: List[ImportAccountData],
+ merge_mode: str = "update") -> ImportResult:
+ """合并账户数据"""
+ result = ImportResult(
+ success=True,
+ total_count=len(new_accounts),
+ added_count=0,
+ updated_count=0,
+ skipped_count=0,
+ error_count=0,
+ details=[],
+ message=""
+ )
+
+ if merge_mode == "replace":
+ existing_accounts.clear()
+ result.details.append({"action": "clear", "message": "清空现有账户数据"})
+
+ for account_data in new_accounts:
+ try:
+ email = account_data.email
+ new_info = {
+ 'password': account_data.password or '',
+ 'refresh_token': account_data.refresh_token
+ }
+
+ if email in existing_accounts:
+ if merge_mode == "skip":
+ result.skipped_count += 1
+ result.details.append({
+ "email": email,
+ "action": "skipped",
+ "message": "账户已存在,跳过更新"
+ })
+ else:
+ existing_accounts[email] = new_info
+ result.updated_count += 1
+ result.details.append({
+ "email": email,
+ "action": "updated",
+ "message": "更新账户信息"
+ })
+ else:
+ existing_accounts[email] = new_info
+ result.added_count += 1
+ result.details.append({
+ "email": email,
+ "action": "added",
+ "message": "新增账户"
+ })
+
+ except Exception as e:
+ result.error_count += 1
+ result.details.append({
+ "email": getattr(account_data, 'email', 'unknown'),
+ "action": "error",
+ "message": f"处理失败: {str(e)}"
+ })
+ logger.error(f"处理账户数据失败: {e}")
+
+ # 生成结果消息
+ if result.error_count > 0:
+ result.success = False
+ result.message = f"导入完成,但有 {result.error_count} 个错误"
+ else:
+ result.message = f"导入成功:新增 {result.added_count} 个,更新 {result.updated_count} 个,跳过 {result.skipped_count} 个"
+
+ return result
+
+# ============================================================================
+# 邮件管理器
+# ============================================================================
+
+class EmailManager:
+ """邮件管理器,负责管理多个邮箱账户"""
+
+ def __init__(self):
+ self.clients = {}
+ self._accounts = None
+ self._clients_lock = None
+
+ async def _load_accounts(self):
+ """加载账户配置(懒加载)"""
+ if self._accounts is None:
+ self._accounts = await load_accounts_config()
+ return self._accounts
+
+ async def get_client(self, email: str) -> Optional[IMAPEmailClient]:
+ """获取指定邮箱的客户端(带并发控制)"""
+ if self._clients_lock is None:
+ self._clients_lock = asyncio.Lock()
+
+ async with self._clients_lock:
+ accounts = await load_accounts_config()
+ if email not in accounts:
+ return None
+
+ if email not in self.clients:
+ self.clients[email] = IMAPEmailClient(email, accounts[email])
+
+ return self.clients[email]
+
+ async def verify_email(self, email: str) -> bool:
+ """验证邮箱是否存在于配置中"""
+ accounts = await load_accounts_config()
+ return email in accounts
+
+ async def get_messages(self, email: str, top: int = 5, folder: str = "INBOX") -> List[Dict]:
+ """获取指定邮箱的邮件列表(包含完整内容)"""
+ client = await self.get_client(email)
+ if not client:
+ raise HTTPException(status_code=404, detail=f"邮箱 {email} 未在配置中找到")
+
+ try:
+ # 使用优化后的方法:一次性获取完整邮件内容
+ return await client.get_messages_with_content(folder_id=folder, top=top)
+ except HTTPException as e:
+ if "refresh token" in e.detail.lower() or "token" in e.detail.lower():
+ raise HTTPException(
+ status_code=401,
+ detail=f"邮箱 {email} 的 Refresh Token 已过期。请使用 get_refresh_token.py 重新获取授权,然后更新 config.txt 中的 refresh_token。"
+ )
+ raise
+
+ async def cleanup_all(self):
+ """清理所有资源"""
+ try:
+ if self._clients_lock:
+ async with self._clients_lock:
+ for email, client in self.clients.items():
+ try:
+ logger.info(f"清理客户端: {email}")
+ await client.cleanup()
+ except Exception as e:
+ logger.error(f"清理客户端失败 ({email}): {e}")
+
+ self.clients.clear()
+ logger.info("所有客户端已清理完毕")
+
+ except Exception as e:
+ logger.error(f"清理资源时出错: {e}")
+
+# ============================================================================
+# FastAPI应用和API端点
+# ============================================================================
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ """应用程序生命周期管理"""
+ logger.info("启动邮件管理系统...")
+ logger.info("初始化数据库...")
+ yield
+ logger.info("正在关闭邮件管理系统...")
+ try:
+ await email_manager.cleanup_all()
+ db_manager.close()
+ except Exception as e:
+ logger.error(f"清理系统资源时出错: {e}")
+ logger.info("邮件管理系统已关闭")
+
+app = FastAPI(
+ title="Outlook邮件管理系统",
+ description="基于FastAPI的现代化异步邮件管理服务(重构版)",
+ version="2.1.0",
+ lifespan=lifespan
+)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# 添加验证错误处理器
+@app.exception_handler(RequestValidationError)
+async def validation_exception_handler(request: Request, exc: RequestValidationError):
+ logger.error(f"Pydantic验证错误: {exc}")
+ logger.error(f"请求路径: {request.url}")
+ logger.error(f"请求方法: {request.method}")
+ try:
+ body = await request.body()
+ logger.error(f"请求数据: {body.decode('utf-8')}")
+ except:
+ logger.error("无法读取请求数据")
+
+ return JSONResponse(
+ status_code=422,
+ content={"detail": exc.errors(), "message": "数据验证失败"}
+ )
+
+# 挂载静态文件服务
+app.mount("/static", StaticFiles(directory="static"), name="static")
+
+# 创建邮件管理器实例
+email_manager = EmailManager()
+
+# ============================================================================
+# 前端页面路由
+# ============================================================================
+
+@app.get("/")
+async def root():
+ """根路径 - 返回V2双栏邮件查看器"""
+ return FileResponse("static/index.html")
+
+
+@app.get("/style.css")
+async def style_css():
+ """CSS文件 - 带缓存控制"""
+ response = FileResponse("static/style.css")
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
+ response.headers["Pragma"] = "no-cache"
+ response.headers["Expires"] = "0"
+ return response
+
+
+@app.get("/script.js")
+async def script_js():
+ """V2 JavaScript文件 - 带缓存控制"""
+ response = FileResponse("static/script.js")
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
+ response.headers["Pragma"] = "no-cache"
+ response.headers["Expires"] = "0"
+ return response
+
+@app.get("/admin")
+async def admin_page():
+ """管理页面"""
+ return FileResponse("static/admin.html")
+
+@app.get("/admin.js")
+async def admin_js():
+ """管理页面JavaScript文件 - 带缓存控制"""
+ response = FileResponse("static/admin.js")
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
+ response.headers["Pragma"] = "no-cache"
+ response.headers["Expires"] = "0"
+ return response
+
+# ============================================================================
+# 邮件API端点
+# ============================================================================
+
+@app.get("/api/messages")
+async def get_messages(email: str, top: int = None, folder: str = "INBOX") -> ApiResponse:
+ """获取邮件列表(包含完整内容)
+
+ 优化:一次性返回邮件的完整信息,前端可以缓存
+ """
+ email = email.strip()
+
+ if not email:
+ return ApiResponse(success=False, message="请提供邮箱地址")
+
+ # 如果没有指定top参数,使用系统配置的默认值
+ if top is None:
+ top = await get_system_config_value('email_limit', DEFAULT_EMAIL_LIMIT)
+
+ try:
+ # 使用优化后的get_messages,返回完整邮件内容
+ messages = await email_manager.get_messages(email, top, folder)
+ return ApiResponse(success=True, data=messages)
+ except HTTPException as e:
+ return ApiResponse(success=False, message=e.detail)
+ except Exception as e:
+ logger.error(f"获取邮件列表失败: {e}")
+ return ApiResponse(success=False, message="获取邮件列表失败")
+
+# 注意:现在前端应该使用缓存的数据,不再需要单独的message detail端点
+# 但为了兼容性保留(如果前端直接访问)
+@app.get("/api/message/{message_id}")
+async def get_message_detail(message_id: str, email: str) -> ApiResponse:
+ """获取邮件详情(兼容性保留,建议前端使用缓存)"""
+ return ApiResponse(
+ success=False,
+ message="请使用 /api/messages 接口获取邮件列表,包含完整内容"
+ )
+
+@app.post("/api/temp-messages")
+async def get_temp_messages(request: TempAccountRequest) -> ApiResponse:
+ """使用临时账户获取邮件列表(包含完整内容)"""
+ try:
+ account_info = {
+ 'password': request.password,
+ 'refresh_token': request.refresh_token
+ }
+
+ temp_client = IMAPEmailClient(request.email, account_info)
+
+ try:
+ # 使用优化后的方法获取完整邮件
+ messages = await temp_client.get_messages_with_content(folder_id=request.folder, top=request.top)
+ return ApiResponse(success=True, data=messages)
+ finally:
+ await temp_client.cleanup()
+
+ except HTTPException as e:
+ return ApiResponse(success=False, message=e.detail)
+ except Exception as e:
+ logger.error(f"临时账户获取邮件失败: {e}")
+ return ApiResponse(success=False, message=f"获取邮件失败: {str(e)}")
+
+@app.get("/api/accounts")
+async def get_accounts(authorization: Optional[str] = Header(None)) -> ApiResponse:
+ """获取所有账户列表(可选管理认证)"""
+ try:
+ is_admin = False
+ if authorization and authorization.startswith("Bearer "):
+ token = authorization[7:]
+ is_admin = verify_admin_token(token)
+
+ accounts = await load_accounts_config()
+ account_list = [{"email": email} for email in accounts.keys()]
+ return ApiResponse(success=True, data=account_list, message=f"共 {len(account_list)} 个账户")
+ except Exception as e:
+ logger.error(f"获取账户列表失败: {e}")
+ return ApiResponse(success=False, message="获取账户列表失败")
+
+# ============================================================================
+# 管理端相关与分页/搜索/标签API
+# ============================================================================
+
+@app.post("/api/admin/verify")
+async def admin_verify(request: AdminTokenRequest) -> ApiResponse:
+ """验证管理令牌"""
+ try:
+ if verify_admin_token(request.token):
+ return ApiResponse(success=True, message="验证成功")
+ return ApiResponse(success=False, message="令牌无效")
+ except Exception as e:
+ logger.error(f"验证管理令牌失败: {e}")
+ return ApiResponse(success=False, message="验证失败")
+
+
+@app.get("/api/accounts/paged")
+async def get_accounts_paged(q: Optional[str] = None,
+ page: int = 1,
+ page_size: int = 10,
+ authorization: Optional[str] = Header(None)) -> ApiResponse:
+ """分页与搜索账户列表
+ - q: 按邮箱子串搜索(不区分大小写)
+ - page/page_size: 分页参数
+ """
+ try:
+ # 可选的管理鉴权(目前不强制)
+ if authorization and authorization.startswith("Bearer "):
+ token = authorization[7:]
+ _ = verify_admin_token(token)
+
+ accounts_dict = await load_accounts_config()
+ emails = sorted(accounts_dict.keys())
+
+ if q:
+ q_lower = q.strip().lower()
+ emails = [e for e in emails if q_lower in e.lower()]
+
+ total = len(emails)
+ # 规范分页参数
+ page = max(1, page)
+ page_size = max(1, min(100, page_size))
+ start = (page - 1) * page_size
+ end = start + page_size
+ items = [{"email": e} for e in emails[start:end]]
+
+ return ApiResponse(
+ success=True,
+ data={
+ "items": items,
+ "total": total,
+ "page": page,
+ "page_size": page_size
+ },
+ message=f"共 {total} 个账户"
+ )
+ except Exception as e:
+ logger.error(f"分页获取账户列表失败: {e}")
+ return ApiResponse(success=False, message="获取账户列表失败")
+
+
+@app.get("/api/accounts/tags")
+async def get_accounts_tags(authorization: Optional[str] = Header(None)) -> ApiResponse:
+ """获取所有标签和账户-标签映射"""
+ try:
+ if authorization and authorization.startswith("Bearer "):
+ token = authorization[7:]
+ _ = verify_admin_token(token)
+
+ tags = await db_manager.get_all_tags()
+ accounts_map = await db_manager.get_accounts_with_tags()
+ return ApiResponse(success=True, data={"tags": tags, "accounts": accounts_map})
+ except Exception as e:
+ logger.error(f"获取账户标签失败: {e}")
+ return ApiResponse(success=False, message="获取账户标签失败")
+
+
+@app.get("/api/account/{email}/tags")
+async def get_account_tags(email: str, authorization: Optional[str] = Header(None)) -> ApiResponse:
+ """获取指定账户的标签"""
+ try:
+ if authorization and authorization.startswith("Bearer "):
+ token = authorization[7:]
+ _ = verify_admin_token(token)
+
+ tags = await db_manager.get_account_tags(email)
+ return ApiResponse(success=True, data={"email": email, "tags": tags})
+ except Exception as e:
+ logger.error(f"获取账户标签失败({email}): {e}")
+ return ApiResponse(success=False, message="获取账户标签失败")
+
+
+@app.post("/api/account/{email}/tags")
+async def set_account_tags(email: str, request: AccountTagRequest, authorization: Optional[str] = Header(None)) -> ApiResponse:
+ """设置指定账户的标签"""
+ try:
+ # 需要管理认证
+ _ = get_admin_token(authorization)
+
+ # 保护:路径中的邮箱与请求体邮箱需一致(若请求体提供)
+ if request.email and request.email != email:
+ return ApiResponse(success=False, message="邮箱不一致")
+
+ # 去重并清理空白
+ cleaned_tags = []
+ seen = set()
+ for t in (request.tags or []):
+ tag = (t or "").strip()
+ if not tag:
+ continue
+ if tag not in seen:
+ seen.add(tag)
+ cleaned_tags.append(tag)
+
+ ok = await db_manager.set_account_tags(email, cleaned_tags)
+ if ok:
+ return ApiResponse(success=True, message="标签已保存", data={"email": email, "tags": cleaned_tags})
+ return ApiResponse(success=False, message="保存标签失败")
+ except HTTPException as e:
+ return ApiResponse(success=False, message=e.detail)
+ except Exception as e:
+ logger.error(f"保存账户标签失败({email}): {e}")
+ return ApiResponse(success=False, message="保存标签失败")
+
+# ============================================================================
+# 账户详细信息 / 删除 / 批量删除 / 简化导入
+# ============================================================================
+
+@app.get("/api/accounts/detailed")
+async def get_accounts_detailed(q: Optional[str] = None,
+ page: int = 1,
+ page_size: int = 10) -> ApiResponse:
+ """分页返回完整账号信息(email, password, client_id, refresh_token)"""
+ try:
+ accounts_dict = await load_accounts_config()
+ emails = sorted(accounts_dict.keys())
+
+ if q:
+ q_lower = q.strip().lower()
+ emails = [e for e in emails if q_lower in e.lower()]
+
+ total = len(emails)
+ page = max(1, page)
+ page_size = max(1, min(100, page_size))
+ start = (page - 1) * page_size
+ end = start + page_size
+
+ items = []
+ for e in emails[start:end]:
+ info = accounts_dict[e]
+ items.append({
+ "email": e,
+ "password": info.get("password", ""),
+ "client_id": info.get("client_id", ""),
+ "refresh_token": info.get("refresh_token", "")
+ })
+
+ return ApiResponse(
+ success=True,
+ data={
+ "items": items,
+ "total": total,
+ "page": page,
+ "page_size": page_size
+ },
+ message=f"共 {total} 个账户"
+ )
+ except Exception as e:
+ logger.error(f"获取账户详细列表失败: {e}")
+ return ApiResponse(success=False, message="获取账户列表失败")
+
+
+@app.delete("/api/account/{email}")
+async def delete_single_account(email: str) -> ApiResponse:
+ """删除单个账号"""
+ try:
+ email = email.strip()
+ exists = await db_manager.account_exists(email)
+ if not exists:
+ return ApiResponse(success=False, message=f"账户 {email} 不存在")
+
+ ok = await db_manager.delete_account(email)
+ if ok:
+ # 清除 EmailManager 中的缓存客户端
+ if email in email_manager.clients:
+ try:
+ await email_manager.clients[email].cleanup()
+ except Exception:
+ pass
+ del email_manager.clients[email]
+ email_manager._accounts = None # 强制重新加载
+ return ApiResponse(success=True, message=f"已删除账户 {email}")
+ return ApiResponse(success=False, message="删除失败")
+ except Exception as e:
+ logger.error(f"删除账户失败: {e}")
+ return ApiResponse(success=False, message=f"删除失败: {str(e)}")
+
+
+@app.post("/api/accounts/delete-batch")
+async def delete_accounts_batch(request: dict) -> ApiResponse:
+ """批量删除账号"""
+ try:
+ emails = request.get("emails", [])
+ if not emails:
+ return ApiResponse(success=False, message="未提供要删除的邮箱列表")
+
+ deleted = 0
+ failed = 0
+ for em in emails:
+ em = em.strip()
+ try:
+ ok = await db_manager.delete_account(em)
+ if ok:
+ if em in email_manager.clients:
+ try:
+ await email_manager.clients[em].cleanup()
+ except Exception:
+ pass
+ del email_manager.clients[em]
+ deleted += 1
+ else:
+ failed += 1
+ except Exception:
+ failed += 1
+
+ email_manager._accounts = None
+ return ApiResponse(
+ success=True,
+ message=f"批量删除完成:成功 {deleted} 个,失败 {failed} 个",
+ data={"deleted": deleted, "failed": failed}
+ )
+ except Exception as e:
+ logger.error(f"批量删除失败: {e}")
+ return ApiResponse(success=False, message=f"批量删除失败: {str(e)}")
+
+
+@app.post("/api/accounts/import")
+async def import_accounts_simple(request: dict) -> ApiResponse:
+ """简化导入 — 直接接收文本,解析并写入数据库"""
+ try:
+ import_text = request.get("text", "").strip()
+ merge_mode = request.get("merge_mode", "update")
+
+ if not import_text:
+ return ApiResponse(success=False, message="请提供要导入的文本数据")
+
+ added = 0
+ updated = 0
+ skipped = 0
+ errors = []
+
+ lines = import_text.split("\n")
+ for line_num, line in enumerate(lines, 1):
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+
+ try:
+ parts = line.split("----")
+ if len(parts) >= 4:
+ em, pw, cid, rt = parts[0].strip(), parts[1].strip(), parts[2].strip(), parts[3].strip()
+ elif len(parts) == 2:
+ em, rt = parts[0].strip(), parts[1].strip()
+ pw, cid = "", CLIENT_ID
+ else:
+ errors.append(f"第{line_num}行格式错误")
+ continue
+
+ if not em or not rt:
+ errors.append(f"第{line_num}行缺少邮箱或令牌")
+ continue
+
+ exists = await db_manager.account_exists(em)
+ if exists:
+ if merge_mode == "skip":
+ skipped += 1
+ continue
+ await db_manager.update_account(em, password=pw, client_id=cid, refresh_token=rt)
+ updated += 1
+ else:
+ await db_manager.add_account(em, pw, cid, rt)
+ added += 1
+
+ except Exception as ex:
+ errors.append(f"第{line_num}行处理失败: {str(ex)}")
+
+ email_manager._accounts = None
+ msg = f"导入完成:新增 {added},更新 {updated},跳过 {skipped}"
+ if errors:
+ msg += f",错误 {len(errors)}"
+
+ return ApiResponse(
+ success=True,
+ data={"added": added, "updated": updated, "skipped": skipped, "errors": errors},
+ message=msg
+ )
+ except Exception as e:
+ logger.error(f"简化导入失败: {e}")
+ return ApiResponse(success=False, message=f"导入失败: {str(e)}")
+
+
+# ============================================================================
+# 账户导入/导出API端点
+# ============================================================================
+
+@app.post("/api/import")
+async def import_accounts_dict(request_data: dict) -> dict:
+ """批量导入邮箱账户"""
+ try:
+ logger.info(f"完整请求数据: {request_data}")
+
+ accounts_data = request_data.get('accounts', [])
+ merge_mode = request_data.get('merge_mode', 'update')
+
+ logger.info(f"收到导入请求,账户数量: {len(accounts_data) if isinstance(accounts_data, (list, dict)) else 'N/A'}, 合并模式: {merge_mode}")
+
+ # 检查并处理嵌套的accounts字段
+ if isinstance(accounts_data, dict):
+ if 'accounts' in accounts_data:
+ logger.info("发现嵌套的accounts字段,正在提取...")
+ accounts_data = accounts_data['accounts']
+ else:
+ return {
+ "success": False,
+ "total_count": 0,
+ "added_count": 0,
+ "updated_count": 0,
+ "skipped_count": 0,
+ "error_count": 0,
+ "details": [{"action": "error", "message": "账户数据格式错误:应该是数组"}],
+ "message": "账户数据格式错误:应该是数组"
+ }
+
+ if not isinstance(accounts_data, list):
+ return {
+ "success": False,
+ "total_count": 0,
+ "added_count": 0,
+ "updated_count": 0,
+ "skipped_count": 0,
+ "error_count": 1,
+ "details": [{"action": "error", "message": f"账户数据类型错误: {type(accounts_data)}, 应该是数组"}],
+ "message": f"账户数据类型错误: {type(accounts_data)}, 应该是数组"
+ }
+
+ # 转换为 ImportAccountData 对象
+ accounts = []
+ for i, acc_data in enumerate(accounts_data):
+ try:
+ if isinstance(acc_data, str):
+ logger.error(f"账户数据是字符串而不是字典: {acc_data}")
+ continue
+
+ account = ImportAccountData(
+ email=acc_data.get('email', ''),
+ password=acc_data.get('password', ''),
+ client_id=acc_data.get('client_id', ''),
+ refresh_token=acc_data.get('refresh_token', '')
+ )
+ accounts.append(account)
+ except Exception as e:
+ logger.error(f"转换账户数据失败: {acc_data}, 错误: {e}")
+ continue
+
+ # 加载现有账户并合并
+ existing_accounts = await load_accounts_config()
+ result = await merge_accounts_data(existing_accounts, accounts, merge_mode)
+
+ # 保存更新后的数据
+ if result.success and (result.added_count > 0 or result.updated_count > 0):
+ save_success = await save_accounts_config(existing_accounts)
+ if not save_success:
+ result.success = False
+ result.message += ",但保存文件失败"
+
+ return {
+ "success": result.success,
+ "total_count": result.total_count,
+ "added_count": result.added_count,
+ "updated_count": result.updated_count,
+ "skipped_count": result.skipped_count,
+ "error_count": result.error_count,
+ "details": result.details,
+ "message": result.message
+ }
+
+ except Exception as e:
+ logger.error(f"导入账户失败: {e}")
+ return {
+ "success": False,
+ "total_count": len(request_data.get('accounts', [])),
+ "added_count": 0,
+ "updated_count": 0,
+ "skipped_count": 0,
+ "error_count": len(request_data.get('accounts', [])),
+ "details": [{"action": "error", "message": f"系统错误: {str(e)}"}],
+ "message": f"导入失败: {str(e)}"
+ }
+
+@app.post("/api/parse-import-text")
+async def parse_import_text(request: dict) -> ApiResponse:
+ """解析导入文本格式数据"""
+ try:
+ import_text = request.get('text', '').strip()
+ if not import_text:
+ return ApiResponse(success=False, message="请提供要导入的文本数据")
+
+ accounts = []
+ errors = []
+
+ lines = import_text.split('\n')
+ for line_num, line in enumerate(lines, 1):
+ line = line.strip()
+ if not line or line.startswith('#'):
+ continue
+
+ try:
+ parts = line.split('----')
+ if len(parts) >= 4:
+ email, password, client_id, refresh_token = parts[0], parts[1], parts[2], parts[3]
+ accounts.append({
+ "email": email.strip(),
+ "password": password.strip(),
+ "client_id": client_id.strip(),
+ "refresh_token": refresh_token.strip()
+ })
+ elif len(parts) == 2:
+ email, refresh_token = parts
+ accounts.append({
+ "email": email.strip(),
+ "password": "",
+ "client_id": CLIENT_ID,
+ "refresh_token": refresh_token.strip()
+ })
+ else:
+ errors.append(f"第{line_num}行格式错误:{line}")
+ except Exception as e:
+ errors.append(f"第{line_num}行解析失败:{str(e)}")
+
+ result_data = {
+ "accounts": accounts,
+ "parsed_count": len(accounts),
+ "error_count": len(errors),
+ "errors": errors
+ }
+
+ if errors:
+ return ApiResponse(
+ success=True,
+ data=result_data,
+ message=f"解析完成:成功 {len(accounts)} 条,错误 {len(errors)} 条"
+ )
+ else:
+ return ApiResponse(
+ success=True,
+ data=result_data,
+ message=f"解析成功:共 {len(accounts)} 条账户数据"
+ )
+
+ except Exception as e:
+ logger.error(f"解析导入文本失败: {e}")
+ return ApiResponse(success=False, message=f"解析失败: {str(e)}")
+
+@app.get("/api/export")
+async def export_accounts_public(format: str = "txt"):
+ """公开导出账户配置"""
+ try:
+ accounts = await load_accounts_config()
+
+ if not accounts:
+ raise HTTPException(status_code=404, detail="暂无账户数据")
+
+ export_lines = []
+ export_lines.append("# Outlook邮件系统账号配置文件")
+ export_lines.append(f"# 导出时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
+ export_lines.append("# 格式: 邮箱----密码----client_id----refresh_token")
+ export_lines.append("# 注意:请妥善保管此文件,包含敏感信息")
+ export_lines.append("")
+
+ for email, account_info in accounts.items():
+ password = account_info.get('password', '')
+ refresh_token = account_info.get('refresh_token', '')
+ client_id = account_info.get('client_id', CLIENT_ID)
+ line = f"{email}----{password}----{client_id}----{refresh_token}"
+ export_lines.append(line)
+
+ export_content = "\n".join(export_lines)
+ filename = f"outlook_accounts_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
+
+ return PlainTextResponse(
+ content=export_content,
+ headers={
+ "Content-Disposition": f"attachment; filename={filename}",
+ "Content-Type": "text/plain; charset=utf-8"
+ }
+ )
+
+ except Exception as e:
+ logger.error(f"导出账户配置失败: {e}")
+ raise HTTPException(status_code=500, detail=f"导出失败: {str(e)}")
+
+# ============================================================================
+# 系统配置API端点
+# ============================================================================
+
+@app.get("/api/system/config")
+async def get_system_config() -> ApiResponse:
+ """获取系统配置"""
+ try:
+ config = await load_system_config()
+ return ApiResponse(success=True, data=config)
+ except Exception as e:
+ logger.error(f"获取系统配置失败: {e}")
+ return ApiResponse(success=False, message="获取系统配置失败")
+
+@app.post("/api/system/config")
+async def update_system_config(request: SystemConfigRequest) -> ApiResponse:
+ """更新系统配置"""
+ try:
+ if request.email_limit < 1 or request.email_limit > 50:
+ return ApiResponse(success=False, message="邮件限制必须在1-50之间")
+
+ success = await set_system_config_value('email_limit', request.email_limit)
+ if success:
+ return ApiResponse(success=True, message=f"系统配置更新成功,邮件限制设置为 {request.email_limit}")
+ else:
+ return ApiResponse(success=False, message="保存系统配置失败")
+ except Exception as e:
+ logger.error(f"更新系统配置失败: {e}")
+ return ApiResponse(success=False, message="更新系统配置失败")
+
+# ============================================================================
+# 测试邮件API端点
+# ============================================================================
+
+@app.post("/api/test-email")
+async def test_email_connection(request: dict) -> ApiResponse:
+ """测试邮件连接,获取最新的1条邮件"""
+ try:
+ email = request.get('email', '').strip()
+ if not email:
+ return ApiResponse(success=False, message="请提供邮箱地址")
+
+ if 'refresh_token' in request:
+ # 临时账户测试
+ account_info = {
+ 'password': request.get('password', ''),
+ 'refresh_token': request.get('refresh_token', '')
+ }
+
+ temp_client = IMAPEmailClient(email, account_info)
+ try:
+ messages = await temp_client.get_messages_with_content(top=1)
+ if messages:
+ latest_message = messages[0]
+ return ApiResponse(
+ success=True,
+ data=latest_message,
+ message="测试成功,获取到最新邮件"
+ )
+ else:
+ return ApiResponse(
+ success=True,
+ data=None,
+ message="测试成功,但该邮箱暂无邮件"
+ )
+ finally:
+ await temp_client.cleanup()
+ else:
+ # 配置文件中的账户测试
+ messages = await email_manager.get_messages(email, 1)
+ if messages:
+ latest_message = messages[0]
+ return ApiResponse(
+ success=True,
+ data=latest_message,
+ message="测试成功,获取到最新邮件"
+ )
+ else:
+ return ApiResponse(
+ success=True,
+ data=None,
+ message="测试成功,但该邮箱暂无邮件"
+ )
+
+ except HTTPException as e:
+ return ApiResponse(success=False, message=e.detail)
+ except Exception as e:
+ logger.error(f"测试邮件连接失败: {e}")
+ return ApiResponse(success=False, message=f"测试失败: {str(e)}")
+
+# ============================================================================
+# Claude 支付检测工具
+# ============================================================================
+
+ANTHROPIC_SENDER = "invoice+statements@mail.anthropic.com"
+ANTHROPIC_NOREPLY_SENDER = "no-reply-m3nO2k7JiUAli7R4q4l24A@mail.anthropic.com"
+RECEIPT_KEYWORD = "Your receipt from Anthropic"
+REFUND_KEYWORD = "Your refund from Anthropic"
+SUSPENDED_KEYWORD = "Your account has been suspended"
+
+async def _check_claude_payment_for_account(email_addr: str) -> dict:
+ """检测单个账户的Claude支付状态"""
+ try:
+ client = await email_manager.get_client(email_addr)
+ if not client:
+ raise Exception(f"邮箱 {email_addr} 未找到")
+
+ messages = await client.get_messages_with_content(top=30)
+
+ payment_time = None
+ refund_time = None
+ suspended_time = None
+ payment_msg = None
+
+ for msg in messages:
+ sender = msg.get('sender', {}).get('emailAddress', {}) or msg.get('from', {}).get('emailAddress', {})
+ sender_addr = (sender.get('address', '') or '').lower()
+ subject = msg.get('subject', '') or ''
+ received = msg.get('receivedDateTime', '')
+
+ if not sender_addr.endswith('@mail.anthropic.com'):
+ continue
+
+ logger.info(f"[Claude检测] sender={sender_addr}, subject={subject}")
+
+ if RECEIPT_KEYWORD in subject:
+ if not payment_time or received > payment_time:
+ payment_time = received
+ payment_msg = msg
+ if REFUND_KEYWORD in subject:
+ if not refund_time or received > refund_time:
+ refund_time = received
+ if SUSPENDED_KEYWORD in subject:
+ if not suspended_time or received > suspended_time:
+ suspended_time = received
+
+ # UTC+0 转 UTC+8
+ def convert_to_utc8(time_str):
+ if not time_str:
+ return None
+ try:
+ from datetime import datetime, timedelta
+ dt = datetime.fromisoformat(time_str.replace('Z', '+00:00'))
+ dt_utc8 = dt + timedelta(hours=8)
+ return dt_utc8.strftime('%Y-%m-%d %H:%M:%S')
+ except:
+ return time_str
+
+ payment_time = convert_to_utc8(payment_time)
+ refund_time = convert_to_utc8(refund_time)
+ suspended_time = convert_to_utc8(suspended_time)
+
+ # 确定状态:优先级 suspended > 比较支付/退款时间
+ if suspended_time:
+ status = 'suspended'
+ elif payment_time and refund_time:
+ status = 'paid' if payment_time > refund_time else 'refunded'
+ elif payment_time:
+ status = 'paid'
+ elif refund_time:
+ status = 'refunded'
+ else:
+ status = 'unknown'
+
+ # 写入数据库
+ await db_manager.set_claude_payment_status(email_addr, status, payment_time, refund_time, suspended_time)
+
+ # 如果是支付状态且标题和备注为空,提取收据信息
+ if status == 'paid' and payment_msg:
+ current_info = await db_manager.get_claude_payment_status(email_addr)
+ if current_info and not current_info.get('title') and not current_info.get('remark'):
+ import re
+ body = payment_msg.get('body', {}).get('content', '') or payment_msg.get('bodyPreview', '')
+
+ # 提取 Receipt number
+ receipt_match = re.search(r'Receipt number[:\s]+([A-Z0-9-]+)', body, re.IGNORECASE)
+ title = receipt_match.group(1) if receipt_match else None
+
+ # 提取 Visa 后4位
+ visa_match = re.search(r'Visa[^\d]*(\d{4})', body, re.IGNORECASE)
+ card_number = visa_match.group(1) if visa_match else None
+
+ if title or card_number:
+ await db_manager.update_claude_payment_note(
+ email=email_addr,
+ title=title,
+ card_number=card_number
+ )
+
+ # 双写标签
+ tags = await db_manager.get_account_tags(email_addr)
+ tags = [t for t in tags if t not in ('已支付Claude', '已退款', '已封号')]
+ if status == 'paid':
+ tags.append('已支付Claude')
+ elif status == 'refunded':
+ tags.append('已退款')
+ elif status == 'suspended':
+ tags.append('已封号')
+ await db_manager.set_account_tags(email_addr, tags)
+
+ return {
+ 'email': email_addr,
+ 'status': status,
+ 'payment_time': payment_time,
+ 'refund_time': refund_time,
+ 'suspended_time': suspended_time
+ }
+ except Exception as e:
+ logger.error(f"检测Claude支付状态失败({email_addr}): {e}")
+ await db_manager.set_claude_payment_status(email_addr, 'error')
+ return {
+ 'email': email_addr,
+ 'status': 'error',
+ 'payment_time': None,
+ 'refund_time': None,
+ 'suspended_time': None,
+ 'message': str(e)
+ }
+
+@app.post("/api/tools/check-claude-payment")
+async def check_claude_payment():
+ """SSE流式扫描所有账户的Claude支付状态"""
+ accounts = await load_accounts_config()
+ emails = list(accounts.keys())
+
+ async def event_generator():
+ total = len(emails)
+ yield f"data: {json.dumps({'type': 'start', 'total': total})}\n\n"
+
+ for i, email_addr in enumerate(emails, 1):
+ yield f"data: {json.dumps({'type': 'progress', 'current': i, 'total': total, 'email': email_addr})}\n\n"
+
+ result = await _check_claude_payment_for_account(email_addr)
+
+ yield f"data: {json.dumps({'type': 'result', 'current': i, 'total': total, **result})}\n\n"
+
+ if i < total:
+ await asyncio.sleep(0.5)
+
+ yield f"data: {json.dumps({'type': 'done', 'total': total})}\n\n"
+
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
+
+@app.post("/api/tools/check-claude-payment/{email}")
+async def check_claude_payment_single(email: str) -> ApiResponse:
+ """检测单个账户的Claude支付状态"""
+ email = email.strip()
+ result = await _check_claude_payment_for_account(email)
+ if result.get('status') == 'error':
+ return ApiResponse(success=False, message=result.get('message', '检测失败'), data=result)
+ return ApiResponse(success=True, data=result)
+
+@app.post("/api/tools/claude-payment-note/{email}")
+async def update_claude_payment_note(email: str, request: dict) -> ApiResponse:
+ """更新账户备注和卡号"""
+ email = email.strip()
+ fields = {}
+ for key in ['title', 'remark', 'card_number', 'proxy', 'proxy_expire_days', 'proxy_share', 'proxy_purchase_date']:
+ if key in request:
+ fields[key] = request[key]
+ ok = await db_manager.update_claude_payment_note(email, **fields)
+ if ok:
+ return ApiResponse(success=True, message="保存成功")
+ return ApiResponse(success=False, message="保存失败")
+
+@app.get("/api/tools/claude-payment-status")
+async def get_claude_payment_status() -> ApiResponse:
+ """获取所有账户的Claude支付缓存状态"""
+ statuses = await db_manager.get_all_claude_payment_statuses()
+ return ApiResponse(success=True, data=statuses)
+
+# ============================================================================
+# 命令行入口
+# ============================================================================
+
+async def main():
+ """命令行模式入口"""
+ try:
+ accounts = await load_accounts_config()
+ if not accounts:
+ print("没有找到有效的邮箱配置,请检查config.txt文件")
+ return
+
+ print(f"已加载 {len(accounts)} 个邮箱账户")
+ for email in accounts.keys():
+ print(f"- {email}")
+
+ # 测试第一个账户
+ first_email = list(accounts.keys())[0]
+ manager = EmailManager()
+
+ print(f"\n测试获取 {first_email} 的邮件...")
+ messages = await manager.get_messages(first_email, 5)
+
+ print(f"\n找到 {len(messages)} 封邮件:")
+ for i, msg in enumerate(messages, 1):
+ subject = msg.get('subject', '无主题')
+ from_addr = msg.get('from', {}).get('emailAddress', {}).get('address', '未知发件人')
+ print(f"{i}. {subject} - {from_addr}")
+
+ except Exception as e:
+ logger.error(f"程序执行出错: {e}")
+ raise
+
+if __name__ == '__main__':
+ import sys
+ import uvicorn
+
+ if len(sys.argv) > 1 and sys.argv[1] == 'web':
+ # Web模式
+ print("启动Web服务器...")
+ print("访问 http://localhost:5001 查看前端界面")
+ uvicorn.run(app, host="0.0.0.0", port=5001, log_level="info")
+ else:
+ # 命令行模式
+ asyncio.run(main())
+
diff --git a/models.py b/models.py
new file mode 100644
index 0000000..37e4ff3
--- /dev/null
+++ b/models.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+"""
+数据模型定义
+Pydantic模型用于API请求和响应
+"""
+
+from typing import Dict, List, Optional, Union
+from pydantic import BaseModel, EmailStr
+
+# ============================================================================
+# 请求模型
+# ============================================================================
+
+class EmailVerifyRequest(BaseModel):
+ email: EmailStr
+
+class EmailListRequest(BaseModel):
+ email: EmailStr
+ top: int = 5
+
+class EmailDetailRequest(BaseModel):
+ email: EmailStr
+ message_id: str
+
+class AccountCredentials(BaseModel):
+ email: EmailStr
+ password: str
+ client_id: str
+ refresh_token: str
+
+class ImportAccountData(BaseModel):
+ """单个导入账户数据模型"""
+ email: str # 暂时使用str而不EmailStr避免验证问题
+ password: str = ""
+ client_id: str = ""
+ refresh_token: str
+
+class ImportRequest(BaseModel):
+ """批量导入请求模型"""
+ accounts: List[ImportAccountData]
+ merge_mode: str = "update" # "update": 更新现有账户, "skip": 跳过重复账户, "replace": 替换所有数据
+
+class ParsedImportRequest(BaseModel):
+ """解析后的导入请求模型(包含解析统计信息)"""
+ accounts: List[ImportAccountData]
+ parsed_count: int
+ error_count: int
+ errors: List[str]
+ merge_mode: str = "update"
+
+class ImportResult(BaseModel):
+ """导入结果模型"""
+ success: bool
+ total_count: int
+ added_count: int
+ updated_count: int
+ skipped_count: int
+ error_count: int
+ details: List[Dict[str, str]] # 详细信息
+ message: str
+
+class AdminTokenRequest(BaseModel):
+ """管理令牌验证请求"""
+ token: str
+
+class DeleteAccountRequest(BaseModel):
+ """删除账户请求"""
+ email: EmailStr
+
+class TempAccountRequest(BaseModel):
+ """临时账户请求"""
+ email: EmailStr
+ password: str = ""
+ client_id: str = ""
+ refresh_token: str
+ top: int = 5
+ folder: str = "INBOX"
+
+class TempMessageDetailRequest(BaseModel):
+ """临时账户邮件详情请求"""
+ email: EmailStr
+ password: str = ""
+ client_id: str = ""
+ refresh_token: str
+ message_id: str
+
+class SystemConfigRequest(BaseModel):
+ """系统配置请求"""
+ email_limit: int = 5
+
+class AccountTagRequest(BaseModel):
+ """账户标签请求"""
+ email: EmailStr
+ tags: List[str] = []
+
+class TestEmailRequest(BaseModel):
+ """测试邮件请求模型"""
+ email: EmailStr
+ password: str = ""
+ client_id: str = ""
+ refresh_token: str = ""
+
+# ============================================================================
+# 响应模型
+# ============================================================================
+
+class ApiResponse(BaseModel):
+ success: bool
+ message: str = ""
+ data: Optional[Union[Dict, List, str]] = None
+
+class EmailMessage(BaseModel):
+ """邮件消息模型"""
+ id: str
+ subject: str
+ receivedDateTime: str
+ sender: Dict
+ from_: Optional[Dict] = None
+ body: Dict
+ bodyPreview: str = ""
+ toRecipients: Optional[List[Dict]] = None
+
+ class Config:
+ fields = {'from_': 'from'}
+
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..80398d7
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,8 @@
+fastapi==0.104.1
+uvicorn[standard]==0.24.0
+pydantic==2.5.0
+httpx==0.25.2
+requests==2.31.0
+python-multipart==0.0.6
+jinja2==3.1.2
+aiofiles==23.2.1
\ No newline at end of file
diff --git a/static/admin.html b/static/admin.html
new file mode 100644
index 0000000..48cfa77
--- /dev/null
+++ b/static/admin.html
@@ -0,0 +1,337 @@
+
+
+
+
+
+ Outlook邮件系统 - 账号管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
身份验证
+
请输入管理令牌以访问账号管理功能
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 邮箱 |
+ 标签 |
+ 操作 |
+
+
+
+
+ |
+
+ 正在加载账号列表...
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 返回邮件管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 多个标签用逗号分隔,如:微软,谷歌,苹果
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 支持:完整格式「邮箱----密码----client_id----refresh_token」或简化格式「邮箱----refresh_token」。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/static/admin.js b/static/admin.js
new file mode 100644
index 0000000..5901a2d
--- /dev/null
+++ b/static/admin.js
@@ -0,0 +1,743 @@
+// 账号管理页面JavaScript
+
+class AdminManager {
+ constructor() {
+ this.isAuthenticated = false;
+ this.token = '';
+ // 分页与搜索状态
+ this.page = 1;
+ this.pageSize = 10;
+ this.total = 0;
+ this.query = '';
+ this.init();
+ }
+
+ init() {
+ this.bindEvents();
+ this.checkStoredAuth();
+ }
+
+ bindEvents() {
+ // 登录表单
+ document.getElementById('loginForm').addEventListener('submit', this.handleLogin.bind(this));
+
+ // 刷新账号列表
+ document.getElementById('refreshAccountsBtn').addEventListener('click', () => {
+ this.page = 1;
+ this.loadAccounts();
+ });
+
+ // 搜索
+ const searchInput = document.getElementById('accountSearchInput');
+ const searchBtn = document.getElementById('searchAccountsBtn');
+ if (searchBtn) {
+ searchBtn.addEventListener('click', () => {
+ this.query = searchInput.value.trim();
+ this.page = 1;
+ this.loadAccounts();
+ });
+ }
+ if (searchInput) {
+ searchInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ this.query = searchInput.value.trim();
+ this.page = 1;
+ this.loadAccounts();
+ }
+ });
+ }
+
+ // 分页
+ const prevBtn = document.getElementById('prevPageBtn');
+ const nextBtn = document.getElementById('nextPageBtn');
+ if (prevBtn) {
+ prevBtn.addEventListener('click', () => {
+ if (this.page > 1) {
+ this.page -= 1;
+ this.loadAccounts();
+ }
+ });
+ }
+ if (nextBtn) {
+ nextBtn.addEventListener('click', () => {
+ const maxPage = Math.max(1, Math.ceil(this.total / this.pageSize));
+ if (this.page < maxPage) {
+ this.page += 1;
+ this.loadAccounts();
+ }
+ });
+ }
+
+ // 导入相关(存在才绑定)
+ const executeImportBtn = document.getElementById('executeImportBtn');
+ if (executeImportBtn) {
+ executeImportBtn.addEventListener('click', this.executeImport.bind(this));
+ }
+ const clearImportBtn = document.getElementById('clearImportBtn');
+ if (clearImportBtn) {
+ clearImportBtn.addEventListener('click', this.clearImport.bind(this));
+ }
+
+ // 导出
+ document.getElementById('exportDataBtn').addEventListener('click', this.exportData.bind(this));
+
+ // 退出登录
+ document.getElementById('logoutBtn').addEventListener('click', this.logout.bind(this));
+
+ // 单页:不使用tabs,直接加载列表
+ setTimeout(() => this.loadAccounts(), 100);
+
+ // 打开导入对话框
+ const openImportBtn = document.getElementById('openImportModalBtn');
+ if (openImportBtn) {
+ openImportBtn.addEventListener('click', () => {
+ const modal = new bootstrap.Modal(document.getElementById('importModal'));
+ modal.show();
+ });
+ }
+ }
+
+ checkStoredAuth() {
+ // 检查是否有保存的认证信息(仅在当前会话有效)
+ const storedToken = sessionStorage.getItem('admin_token');
+ if (storedToken) {
+ this.token = storedToken;
+ this.showManagement();
+ }
+ }
+
+ async handleLogin(event) {
+ event.preventDefault();
+
+ const tokenInput = document.getElementById('tokenInput');
+ const enteredToken = tokenInput.value.trim();
+
+ if (!enteredToken) {
+ this.showError('请输入管理令牌');
+ return;
+ }
+
+ try {
+ // 验证令牌
+ const isValid = await this.verifyToken(enteredToken);
+
+ if (isValid) {
+ this.token = enteredToken;
+ this.isAuthenticated = true;
+
+ // 保存到会话存储
+ sessionStorage.setItem('admin_token', enteredToken);
+
+ this.showManagement();
+ this.showSuccess('登录成功');
+ } else {
+ this.showError('令牌验证失败,请检查输入的令牌是否正确');
+ }
+ } catch (error) {
+ console.error('登录失败:', error);
+ this.showError('登录失败: ' + error.message);
+ }
+ }
+
+ async verifyToken(token) {
+ try {
+ // 发送验证请求到后端
+ const response = await fetch('/api/admin/verify', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ token: token })
+ });
+
+ if (response.ok) {
+ const result = await response.json();
+ return result.success;
+ }
+ return false;
+ } catch (error) {
+ console.error('令牌验证错误:', error);
+ return false;
+ }
+ }
+
+ showManagement() {
+ document.getElementById('loginSection').style.display = 'none';
+ document.getElementById('managementSection').style.display = 'block';
+
+ // 自动加载账号列表
+ this.loadAccounts();
+ }
+
+ async loadAccounts() {
+ const accountsList = document.getElementById('accountsList');
+
+ // 显示加载状态(表格内)
+ const tbodyLoading = document.getElementById('accountsTbody');
+ if (tbodyLoading) {
+ tbodyLoading.innerHTML = `
+
+ |
+
+ 正在加载账号列表...
+ |
+
+ `;
+ }
+
+ try {
+ const params = new URLSearchParams();
+ params.set('page', String(this.page));
+ params.set('page_size', String(this.pageSize));
+ if (this.query) params.set('q', this.query);
+ const response = await fetch(`/api/accounts/paged?${params.toString()}`, {
+ headers: {
+ 'Authorization': `Bearer ${this.token}`
+ }
+ });
+
+ if (response.ok) {
+ const result = await response.json();
+ if (result.success) {
+ const items = (result.data && result.data.items) ? result.data.items : [];
+ this.total = (result.data && typeof result.data.total === 'number') ? result.data.total : items.length;
+ this.page = (result.data && typeof result.data.page === 'number') ? result.data.page : this.page;
+ this.pageSize = (result.data && typeof result.data.page_size === 'number') ? result.data.page_size : this.pageSize;
+ this.renderAccounts(items);
+ this.renderPager();
+ } else {
+ throw new Error(result.message);
+ }
+ } else {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+ } catch (error) {
+ console.error('加载账号列表失败:', error);
+ accountsList.innerHTML = `
+
+
+
加载失败: ${error.message}
+
+
+ `;
+ }
+ }
+
+ async renderAccounts(accounts) {
+ const tbody = document.getElementById('accountsTbody');
+ if (!tbody) return;
+
+ if (accounts.length === 0) {
+ tbody.innerHTML = `
+
+ |
+
+ 暂无账号数据
+ |
+
+ `;
+ return;
+ }
+
+ // 获取所有账户的标签信息
+ const accountsWithTags = await this.loadAccountsWithTags();
+
+ const accountsHtml = accounts.map((account, index) => {
+ const tags = accountsWithTags[account.email] || [];
+ const tagsHtml = tags.length > 0 ?
+ tags.map(tag => `${this.escapeHtml(tag)}`).join('') :
+ '无标签';
+
+ return `
+
+ | ${account.email} |
+ ${tagsHtml} |
+
+
+
+
+ |
+
+ `;
+ }).join('');
+ tbody.innerHTML = accountsHtml;
+ }
+
+ // 已移除测试与系统配置相关功能
+
+ // 删除账户功能不在精简范围内,已移除调用
+
+ clearImport() {
+ document.getElementById('importTextarea').value = '';
+ document.getElementById('mergeMode').value = 'update';
+ this.showSuccess('已清空导入内容');
+ }
+
+ async executeImport() {
+ const textarea = document.getElementById('importTextarea');
+ const mergeMode = document.getElementById('mergeMode');
+
+ const importText = textarea.value.trim();
+ if (!importText) {
+ this.showError('请输入要导入的账户数据');
+ return;
+ }
+
+ try {
+ // 解析文本
+ const parseResponse = await fetch('/api/parse-import-text', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${this.token}`
+ },
+ body: JSON.stringify({ text: importText })
+ });
+
+ if (!parseResponse.ok) {
+ throw new Error(`解析失败: HTTP ${parseResponse.status}`);
+ }
+
+ const parseResult = await parseResponse.json();
+ if (!parseResult.success) {
+ throw new Error(parseResult.message);
+ }
+
+ // 执行导入
+ const importResponse = await fetch('/api/import', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${this.token}`
+ },
+ body: JSON.stringify({
+ accounts: parseResult.data.accounts, // 只提取accounts数组
+ merge_mode: mergeMode.value
+ })
+ });
+
+ if (!importResponse.ok) {
+ throw new Error(`导入失败: HTTP ${importResponse.status}`);
+ }
+
+ const importResult = await importResponse.json();
+
+ if (importResult.success) {
+ this.showSuccess(`导入完成! 新增: ${importResult.added_count}, 更新: ${importResult.updated_count}, 跳过: ${importResult.skipped_count}`);
+ // 清空导入内容
+ document.getElementById('importTextarea').value = '';
+ this.loadAccounts(); // 刷新账号列表
+ } else {
+ this.showError(`导入失败: ${importResult.message}`);
+ }
+
+ } catch (error) {
+ console.error('导入失败:', error);
+ this.showError(`导入失败: ${error.message}`);
+ }
+ }
+
+ async exportData() {
+ try {
+ const response = await fetch('/api/export');
+
+ if (response.ok) {
+ // 直接获取文本内容
+ const content = await response.text();
+
+ // 从响应头获取文件名
+ const contentDisposition = response.headers.get('Content-Disposition');
+ let filename = 'outlook_accounts_config.txt';
+ if (contentDisposition) {
+ const match = contentDisposition.match(/filename=(.+)/);
+ if (match) {
+ filename = match[1];
+ }
+ }
+
+ // 下载文件
+ this.downloadTextFile(content, filename);
+ this.showSuccess('数据导出成功');
+ } else {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+ } catch (error) {
+ console.error('导出失败:', error);
+ this.showError(`导出失败: ${error.message}`);
+ }
+ }
+
+ downloadTextFile(content, filename) {
+ const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ window.URL.revokeObjectURL(url);
+ }
+
+ logout() {
+ this.isAuthenticated = false;
+ this.token = '';
+ sessionStorage.removeItem('admin_token');
+
+ document.getElementById('managementSection').style.display = 'none';
+ document.getElementById('loginSection').style.display = 'block';
+
+ // 清空表单
+ document.getElementById('tokenInput').value = '';
+
+ this.showSuccess('已安全退出管理');
+ }
+
+ showSuccess(message) {
+ document.getElementById('successMessage').textContent = message;
+ const modal = new bootstrap.Modal(document.getElementById('successModal'));
+ modal.show();
+ }
+
+ showError(message) {
+ document.getElementById('errorMessage').textContent = message;
+ const modal = new bootstrap.Modal(document.getElementById('errorModal'));
+ modal.show();
+ }
+
+ // ==================== 标签管理功能 ====================
+
+ async loadAccountsWithTags() {
+ try {
+ const response = await fetch('/api/accounts/tags', {
+ headers: {
+ 'Authorization': `Bearer ${this.token}`
+ }
+ });
+ if (response.ok) {
+ const result = await response.json();
+ if (result.success) {
+ return result.data.accounts || {};
+ }
+ }
+ } catch (error) {
+ console.error('加载账户标签失败:', error);
+ }
+ return {};
+ }
+
+ async showTagManagementDialog(email) {
+ // 显示标签管理对话框
+ const modal = new bootstrap.Modal(document.getElementById('tagManagementModal'));
+ const modalTitle = document.getElementById('tagManagementModalTitle');
+ const tagInput = document.getElementById('tagManagementInput');
+ const currentTagsDisplay = document.getElementById('tagManagementCurrentTags');
+
+ modalTitle.textContent = `管理标签 - ${email}`;
+
+ // 加载当前标签
+ try {
+ const response = await fetch(`/api/account/${encodeURIComponent(email)}/tags`, {
+ headers: {
+ 'Authorization': `Bearer ${this.token}`
+ }
+ });
+
+ if (response.ok) {
+ const result = await response.json();
+ if (result.success) {
+ const tags = result.data.tags || [];
+ tagInput.value = tags.join(',');
+
+ if (tags.length > 0) {
+ const tagsHtml = tags.map(tag =>
+ `${this.escapeHtml(tag)}`
+ ).join('');
+ currentTagsDisplay.innerHTML = tagsHtml;
+ } else {
+ currentTagsDisplay.innerHTML = '暂无标签';
+ }
+ }
+ }
+ } catch (error) {
+ console.error('加载账户标签失败:', error);
+ }
+
+ // 设置保存按钮事件
+ const saveBtn = document.getElementById('tagManagementSaveBtn');
+ saveBtn.onclick = () => this.saveAccountTagsFromDialog(email);
+
+ modal.show();
+ }
+
+ async saveAccountTagsFromDialog(email) {
+ const tagInput = document.getElementById('tagManagementInput');
+ const tagsText = tagInput.value.trim();
+ const tags = tagsText ? tagsText.split(',').map(tag => tag.trim()).filter(tag => tag) : [];
+
+ try {
+ const response = await fetch(`/api/account/${encodeURIComponent(email)}/tags`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${this.token}`
+ },
+ body: JSON.stringify({ email: email, tags: tags })
+ });
+
+ if (response.ok) {
+ const result = await response.json();
+ if (result.success) {
+ this.showSuccess('标签保存成功');
+ // 关闭对话框
+ const modal = bootstrap.Modal.getInstance(document.getElementById('tagManagementModal'));
+ modal.hide();
+ // 刷新账户列表
+ this.loadAccounts();
+ } else {
+ this.showError('保存失败: ' + result.message);
+ }
+ } else {
+ this.showError(`保存失败: HTTP ${response.status}`);
+ }
+ } catch (error) {
+ console.error('保存标签失败:', error);
+ this.showError('保存标签失败: ' + error.message);
+ }
+ }
+
+ async loadAllTags() {
+ const allTagsList = document.getElementById('allTagsList');
+
+ allTagsList.innerHTML = `
+
+ `;
+
+ try {
+ const response = await fetch('/api/accounts/tags', {
+ headers: {
+ 'Authorization': `Bearer ${this.token}`
+ }
+ });
+ if (response.ok) {
+ const result = await response.json();
+ if (result.success) {
+ this.renderAllTags(result.data.tags || []);
+ } else {
+ throw new Error(result.message);
+ }
+ } else {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ } catch (error) {
+ console.error('加载标签失败:', error);
+ allTagsList.innerHTML = `
+
+
+
加载失败: ${error.message}
+
+ `;
+ }
+ }
+
+ renderAllTags(tags) {
+ const allTagsList = document.getElementById('allTagsList');
+
+ if (tags.length === 0) {
+ allTagsList.innerHTML = `
+
+ `;
+ return;
+ }
+
+ const tagsHtml = tags.map(tag => `
+ ${this.escapeHtml(tag)}
+ `).join('');
+
+ allTagsList.innerHTML = `
+
+ 共 ${tags.length} 个标签:
+
+ ${tagsHtml}
+ `;
+ }
+
+ async loadAccountsForTags() {
+ const tagAccountSelect = document.getElementById('tagAccountSelect');
+
+ try {
+ const response = await fetch('/api/accounts');
+ if (response.ok) {
+ const result = await response.json();
+ if (result.success) {
+ tagAccountSelect.innerHTML = '';
+ result.data.forEach(account => {
+ const option = document.createElement('option');
+ option.value = account.email;
+ option.textContent = account.email;
+ tagAccountSelect.appendChild(option);
+ });
+ }
+ }
+ } catch (error) {
+ console.error('加载账户列表失败:', error);
+ }
+ }
+
+ async loadAccountTags() {
+ const tagAccountSelect = document.getElementById('tagAccountSelect');
+ const accountTagsInput = document.getElementById('accountTagsInput');
+ const currentAccountTags = document.getElementById('currentAccountTags');
+ const currentTagsDisplay = document.getElementById('currentTagsDisplay');
+
+ const selectedEmail = tagAccountSelect.value;
+ if (!selectedEmail) {
+ accountTagsInput.value = '';
+ currentAccountTags.style.display = 'none';
+ return;
+ }
+
+ try {
+ const response = await fetch(`/api/account/${encodeURIComponent(selectedEmail)}/tags`, {
+ headers: {
+ 'Authorization': `Bearer ${this.token}`
+ }
+ });
+ if (response.ok) {
+ const result = await response.json();
+ if (result.success) {
+ const tags = result.data.tags || [];
+ accountTagsInput.value = tags.join(',');
+
+ if (tags.length > 0) {
+ const tagsHtml = tags.map(tag =>
+ `${this.escapeHtml(tag)}`
+ ).join('');
+ currentTagsDisplay.innerHTML = tagsHtml;
+ currentAccountTags.style.display = 'block';
+ } else {
+ currentAccountTags.style.display = 'none';
+ }
+ }
+ }
+ } catch (error) {
+ console.error('加载账户标签失败:', error);
+ this.showError('加载账户标签失败: ' + error.message);
+ }
+ }
+
+ async saveAccountTags() {
+ const tagAccountSelect = document.getElementById('tagAccountSelect');
+ const accountTagsInput = document.getElementById('accountTagsInput');
+
+ const selectedEmail = tagAccountSelect.value;
+ if (!selectedEmail) {
+ this.showError('请先选择账户');
+ return;
+ }
+
+ const tagsText = accountTagsInput.value.trim();
+ const tags = tagsText ? tagsText.split(',').map(tag => tag.trim()).filter(tag => tag) : [];
+
+ try {
+ const response = await fetch(`/api/account/${encodeURIComponent(selectedEmail)}/tags`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${this.token}`
+ },
+ body: JSON.stringify({ email: selectedEmail, tags: tags })
+ });
+
+ if (response.ok) {
+ const result = await response.json();
+ if (result.success) {
+ this.showSuccess('标签保存成功');
+ this.loadAccountTags(); // 刷新显示
+ this.loadAllTags(); // 刷新所有标签列表
+ } else {
+ this.showError('保存失败: ' + result.message);
+ }
+ } else {
+ this.showError(`保存失败: HTTP ${response.status}`);
+ }
+ } catch (error) {
+ console.error('保存标签失败:', error);
+ this.showError('保存标签失败: ' + error.message);
+ }
+ }
+
+ clearAccountTags() {
+ document.getElementById('accountTagsInput').value = '';
+ document.getElementById('currentAccountTags').style.display = 'none';
+ }
+
+ // ==================== 分页信息渲染 ====================
+ renderPager() {
+ const info = document.getElementById('accountsPagerInfo');
+ if (!info) return;
+ const maxPage = Math.max(1, Math.ceil(this.total / this.pageSize));
+ info.textContent = `第 ${this.page} / ${maxPage} 页,共 ${this.total} 条`;
+ const prevBtn = document.getElementById('prevPageBtn');
+ const nextBtn = document.getElementById('nextPageBtn');
+ if (prevBtn) prevBtn.disabled = this.page <= 1;
+ if (nextBtn) nextBtn.disabled = this.page >= maxPage;
+ }
+
+ // ==================== 辅助方法 ====================
+
+ escapeHtml(text) {
+ if (!text) return '';
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ formatDate(dateString) {
+ if (!dateString) return '未知时间';
+
+ try {
+ const date = new Date(dateString);
+ if (isNaN(date.getTime())) {
+ return dateString;
+ }
+
+ const now = new Date();
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const messageDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
+
+ const timeDiff = today.getTime() - messageDate.getTime();
+ const daysDiff = Math.floor(timeDiff / (1000 * 3600 * 24));
+
+ if (daysDiff === 0) {
+ return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
+ } else if (daysDiff === 1) {
+ return '昨天';
+ } else if (daysDiff < 7) {
+ return `${daysDiff}天前`;
+ } else {
+ return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
+ }
+ } catch (error) {
+ console.error('Date formatting error:', error);
+ return dateString;
+ }
+ }
+}
+
+// 页面加载完成后初始化
+document.addEventListener('DOMContentLoaded', function() {
+ window.adminManager = new AdminManager();
+});
\ No newline at end of file
diff --git a/static/index.html b/static/index.html
new file mode 100644
index 0000000..c2f1a8c
--- /dev/null
+++ b/static/index.html
@@ -0,0 +1,350 @@
+
+
+
+
+
+ Outlook 邮件管理器
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
选择一封邮件查看详情
+
从左侧列表中选择邮件
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 使用 ---- 分隔各字段,每行一个邮箱
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 也可直接粘贴:ip:端口:账号:密码 或 socks5://user:pass@host:port
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/script.js b/static/script.js
new file mode 100644
index 0000000..386e52f
--- /dev/null
+++ b/static/script.js
@@ -0,0 +1,1165 @@
+// Outlook 邮件管理器 — 账号管理 + 双栏邮件查看器
+
+class MailManager {
+ constructor() {
+ // 账号管理状态
+ this.accounts = [];
+ this.page = 1;
+ this.pageSize = 15;
+ this.total = 0;
+ this.query = '';
+
+ // 邮件查看状态
+ this.currentEmail = '';
+ this.currentFolder = '';
+ this.messages = [];
+ this.selectedId = null;
+ this.claudePaymentStatuses = {};
+
+ this.init();
+ }
+
+ init() {
+ this.bindAccountEvents();
+ this.bindEmailEvents();
+ this.loadAccounts();
+ }
+
+ // ====================================================================
+ // 事件绑定 — 账号视图
+ // ====================================================================
+
+ bindAccountEvents() {
+ // 搜索 — 防抖
+ const searchInput = document.getElementById('accountSearch');
+ let searchTimer = null;
+ searchInput.addEventListener('input', () => {
+ clearTimeout(searchTimer);
+ searchTimer = setTimeout(() => {
+ this.query = searchInput.value.trim();
+ this.page = 1;
+ this.loadAccounts();
+ }, 300);
+ });
+
+ // 导入按钮
+ document.getElementById('importBtn').addEventListener('click', () => {
+ document.getElementById('importText').value = '';
+ document.getElementById('importModal').classList.add('show');
+ });
+
+ // 取消导入
+ document.getElementById('cancelImportBtn').addEventListener('click', () => {
+ document.getElementById('importModal').classList.remove('show');
+ });
+
+ // 点击模态框背景关闭
+ document.getElementById('importModal').addEventListener('click', (e) => {
+ if (e.target === e.currentTarget) {
+ e.currentTarget.classList.remove('show');
+ }
+ });
+
+ // 确认导入
+ document.getElementById('doImportBtn').addEventListener('click', () => this.importAccounts());
+
+ // 导出
+ document.getElementById('exportBtn').addEventListener('click', () => this.exportAccounts());
+
+ // Claude支付检测
+ document.getElementById('claudePaymentBtn').addEventListener('click', () => this.startClaudePaymentCheck());
+
+ // 刷新
+ document.getElementById('refreshAccountsBtn').addEventListener('click', () => this.loadAccounts());
+
+ // 复制按钮 — 事件委托
+ document.getElementById('accountTableBody').addEventListener('click', (e) => {
+ const btn = e.target.closest('button[data-copy-field]');
+ if (!btn) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const field = btn.dataset.copyField;
+ const key = btn.dataset.copyKey;
+ if (field === 'email') {
+ this.copyToClipboard(key);
+ } else if (this._accountDataMap && this._accountDataMap[key]) {
+ const map = { pwd: 'pwd', cid: 'cid', token: 'token' };
+ this.copyToClipboard(this._accountDataMap[key][map[field]] || '');
+ }
+ });
+
+ // 代理按钮 — 事件委托打开弹窗 + 复制代理
+ document.getElementById('accountTableBody').addEventListener('click', (e) => {
+ // 复制代理
+ const copyBtn = e.target.closest('[data-copy-proxy]');
+ if (copyBtn) {
+ e.preventDefault();
+ e.stopPropagation();
+ const em = copyBtn.dataset.copyProxy;
+ const info = this.claudePaymentStatuses[em];
+ if (info && info.proxy) this.copyToClipboard(info.proxy);
+ return;
+ }
+ // 打开弹窗
+ const btn = e.target.closest('.proxy-btn');
+ if (!btn) return;
+ this.openProxyModal(btn.dataset.email);
+ });
+
+ // 代理弹窗事件
+ document.getElementById('cancelProxyBtn').addEventListener('click', () => {
+ document.getElementById('proxyModal').classList.remove('show');
+ });
+ document.getElementById('proxyModal').addEventListener('click', (e) => {
+ if (e.target === e.currentTarget) e.currentTarget.classList.remove('show');
+ });
+ document.getElementById('saveProxyBtn').addEventListener('click', () => this.saveProxy());
+ document.getElementById('clearProxyBtn').addEventListener('click', () => this.clearProxy());
+ // 快速粘贴解析
+ document.getElementById('proxyRaw').addEventListener('input', (e) => this.parseProxyRaw(e.target.value));
+ // 字段变化时更新预览
+ for (const id of ['proxyHost', 'proxyPort', 'proxyUser', 'proxyPass']) {
+ document.getElementById(id).addEventListener('input', () => this.updateProxyPreview());
+ }
+ document.querySelectorAll('input[name="proxyProtocol"]').forEach(r => {
+ r.addEventListener('change', () => this.updateProxyPreview());
+ });
+ // 有效期选项切换
+ document.querySelectorAll('input[name="proxyExpire"]').forEach(r => {
+ r.addEventListener('change', () => {
+ const customInput = document.getElementById('proxyExpireCustom');
+ customInput.style.display = r.value === 'custom' && r.checked ? '' : 'none';
+ this.calcProxyExpireDate();
+ });
+ });
+ document.getElementById('proxyExpireCustom').addEventListener('input', () => this.calcProxyExpireDate());
+ document.getElementById('proxyPurchaseDate').addEventListener('change', () => this.calcProxyExpireDate());
+
+ // 备注/卡号 — 点击打开弹窗
+ document.getElementById('accountTableBody').addEventListener('click', (e) => {
+ const btn = e.target.closest('[data-note-email]');
+ if (!btn) return;
+ this.openNoteModal(btn.dataset.noteEmail);
+ });
+ // 备注弹窗
+ document.getElementById('cancelNoteBtn').addEventListener('click', () => {
+ document.getElementById('noteModal').classList.remove('show');
+ });
+ document.getElementById('noteModal').addEventListener('click', (e) => {
+ if (e.target === e.currentTarget) e.currentTarget.classList.remove('show');
+ });
+ document.getElementById('saveNoteBtn').addEventListener('click', () => this.saveNote());
+
+ // 分页
+ document.getElementById('prevPageBtn').addEventListener('click', () => {
+ if (this.page > 1) { this.page--; this.loadAccounts(); }
+ });
+ document.getElementById('nextPageBtn').addEventListener('click', () => {
+ const maxPage = Math.ceil(this.total / this.pageSize) || 1;
+ if (this.page < maxPage) { this.page++; this.loadAccounts(); }
+ });
+ }
+
+ // ====================================================================
+ // 事件绑定 — 邮件视图
+ // ====================================================================
+
+ bindEmailEvents() {
+ document.getElementById('backToAccounts').addEventListener('click', () => this.showAccountView());
+ document.getElementById('mobileMailToggle').addEventListener('click', () => this.toggleMobileMailList());
+ document.getElementById('mobileOverlay').addEventListener('click', () => this.closeMobileMailList());
+ }
+
+ // ====================================================================
+ // 视图切换
+ // ====================================================================
+
+ showAccountView() {
+ document.getElementById('accountView').style.display = '';
+ document.getElementById('emailView').style.display = 'none';
+ this.loadAccounts();
+ }
+
+ showEmailView(email, folder) {
+ document.getElementById('accountView').style.display = 'none';
+ document.getElementById('emailView').style.display = '';
+
+ document.getElementById('topbarEmail').textContent = email;
+ const folderLabel = folder === 'Junk' ? '垃圾箱' : '收件箱';
+ document.getElementById('topbarFolder').textContent = folderLabel;
+ document.getElementById('emailListTitle').textContent = folderLabel;
+
+ this.currentEmail = email;
+ this.currentFolder = folder;
+ this.messages = [];
+ this.selectedId = null;
+
+ this.showDetailEmpty();
+ this.loadEmails();
+ }
+
+ // ====================================================================
+ // 账号管理 — 数据加载
+ // ====================================================================
+
+ async loadAccounts() {
+ const tbody = document.getElementById('accountTableBody');
+ tbody.innerHTML = ` |
`;
+
+ try {
+ const params = new URLSearchParams({
+ page: this.page,
+ page_size: this.pageSize
+ });
+ if (this.query) params.set('q', this.query);
+
+ const resp = await fetch(`/api/accounts/detailed?${params}`);
+ const result = await resp.json();
+
+ if (result.success && result.data) {
+ this.accounts = result.data.items || [];
+ this.total = result.data.total || 0;
+ this.page = result.data.page || 1;
+ await this.loadClaudePaymentStatuses();
+ this.renderAccounts();
+ this.renderPager();
+ } else {
+ tbody.innerHTML = `${this.escapeHtml(result.message || '加载失败')} |
`;
+ }
+ } catch (err) {
+ console.error('加载账号失败:', err);
+ tbody.innerHTML = ` |
`;
+ }
+ }
+
+ renderAccounts() {
+ const tbody = document.getElementById('accountTableBody');
+ if (!this.accounts.length) {
+ tbody.innerHTML = ` |
`;
+ return;
+ }
+
+ // 将账户数据存到map中,复制时通过索引取值
+ this._accountDataMap = {};
+ const startIdx = (this.page - 1) * this.pageSize;
+ tbody.innerHTML = this.accounts.map((acc, i) => {
+ const num = startIdx + i + 1;
+ const email = acc.email || '';
+ const pwd = acc.password || '';
+ const cid = acc.client_id || '';
+ const token = acc.refresh_token || '';
+ this._accountDataMap[email] = { pwd, cid, token };
+ return `
+
+ | ${num} |
+
+
+ ${this.escapeHtml(email)}
+
+
+ |
+
+
+ ${pwd ? '••••••' : '-'}
+ ${pwd ? `` : ''}
+
+ |
+
+
+ ${this.truncate(cid, 16)}
+ ${cid ? `` : ''}
+
+ |
+
+
+ ${token ? this.truncate(token, 20) : '-'}
+ ${token ? `` : ''}
+
+ |
+ ${this.renderClaudeColumns(email)}
+
+
+
+
+
+
+
+ |
+
+ `;
+ }).join('');
+ }
+
+ renderPager() {
+ const maxPage = Math.ceil(this.total / this.pageSize) || 1;
+ document.getElementById('pagerInfo').textContent = `共 ${this.total} 条`;
+ document.getElementById('pagerCurrent').textContent = `${this.page}/${maxPage} 页`;
+
+ // 渲染页码按钮
+ const controlsContainer = document.getElementById('pagerControls');
+ let btns = '';
+
+ // 上一页
+ btns += ``;
+
+ // 页码
+ const range = this.getPageRange(this.page, maxPage, 5);
+ for (const p of range) {
+ if (p === '...') {
+ btns += `...`;
+ } else {
+ btns += ``;
+ }
+ }
+
+ // 下一页
+ btns += ``;
+
+ controlsContainer.innerHTML = btns;
+ }
+
+ getPageRange(current, total, maxButtons) {
+ if (total <= maxButtons) {
+ return Array.from({ length: total }, (_, i) => i + 1);
+ }
+ const pages = [];
+ const half = Math.floor(maxButtons / 2);
+ let start = Math.max(1, current - half);
+ let end = Math.min(total, start + maxButtons - 1);
+ if (end - start < maxButtons - 1) {
+ start = Math.max(1, end - maxButtons + 1);
+ }
+ if (start > 1) { pages.push(1); if (start > 2) pages.push('...'); }
+ for (let i = start; i <= end; i++) pages.push(i);
+ if (end < total) { if (end < total - 1) pages.push('...'); pages.push(total); }
+ return pages;
+ }
+
+ goPage(p) {
+ const maxPage = Math.ceil(this.total / this.pageSize) || 1;
+ if (p < 1 || p > maxPage) return;
+ this.page = p;
+ this.loadAccounts();
+ }
+
+ // ====================================================================
+ // 账号管理 — 操作
+ // ====================================================================
+
+ async importAccounts() {
+ const text = document.getElementById('importText').value.trim();
+ if (!text) {
+ this.showToast('请粘贴账号数据', 'warning');
+ return;
+ }
+
+ const mergeMode = document.querySelector('input[name="mergeMode"]:checked').value;
+ const btn = document.getElementById('doImportBtn');
+ btn.disabled = true;
+ btn.innerHTML = ' 导入中...';
+
+ try {
+ const resp = await fetch('/api/accounts/import', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text, merge_mode: mergeMode })
+ });
+ const result = await resp.json();
+
+ if (result.success) {
+ this.showToast(result.message || '导入成功', 'success');
+ document.getElementById('importModal').classList.remove('show');
+ this.page = 1;
+ this.loadAccounts();
+ } else {
+ this.showToast(result.message || '导入失败', 'danger');
+ }
+ } catch (err) {
+ this.showToast('网络错误', 'danger');
+ } finally {
+ btn.disabled = false;
+ btn.innerHTML = '确定导入';
+ }
+ }
+
+ async exportAccounts() {
+ try {
+ const resp = await fetch('/api/export');
+ if (!resp.ok) throw new Error('导出失败');
+ const blob = await resp.blob();
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `outlook_accounts_${new Date().toISOString().slice(0, 10)}.txt`;
+ a.click();
+ URL.revokeObjectURL(url);
+ this.showToast('导出成功', 'success');
+ } catch (err) {
+ this.showToast('导出失败', 'danger');
+ }
+ }
+
+ async deleteAccount(email) {
+ if (!confirm(`确定要删除账号 ${email} 吗?`)) return;
+
+ try {
+ const resp = await fetch(`/api/account/${encodeURIComponent(email)}`, { method: 'DELETE' });
+ const result = await resp.json();
+ if (result.success) {
+ this.showToast(`已删除 ${email}`, 'success');
+ this.loadAccounts();
+ } else {
+ this.showToast(result.message || '删除失败', 'danger');
+ }
+ } catch (err) {
+ this.showToast('网络错误', 'danger');
+ }
+ }
+
+ openMailbox(email, folder) {
+ this.showEmailView(email, folder);
+ }
+
+ // ====================================================================
+ // 邮件查看 — 数据
+ // ====================================================================
+
+ async loadEmails() {
+ const container = document.getElementById('emailListContent');
+ container.innerHTML = '';
+
+ try {
+ const params = new URLSearchParams({
+ email: this.currentEmail,
+ folder: this.currentFolder,
+ top: 20
+ });
+ const resp = await fetch(`/api/messages?${params}`);
+ const result = await resp.json();
+
+ if (result.success && result.data && result.data.length > 0) {
+ this.messages = result.data;
+ this.renderMailList();
+ this.updateEmailCount(this.messages.length);
+ this.selectEmail(this.messages[0].id);
+ } else {
+ this.messages = [];
+ this.updateEmailCount(0);
+ container.innerHTML = `${this.escapeHtml(result.message || '暂无邮件')}
`;
+ }
+ } catch (err) {
+ console.error('加载邮件失败:', err);
+ container.innerHTML = '';
+ }
+ }
+
+ renderMailList() {
+ const container = document.getElementById('emailListContent');
+ container.innerHTML = this.messages.map(msg => {
+ const sender = msg.sender?.emailAddress || msg.from?.emailAddress || {};
+ const senderName = sender.name || sender.address || '未知';
+ const subject = msg.subject || '(无主题)';
+ const preview = (msg.bodyPreview || '').substring(0, 80);
+ const time = this.formatDate(msg.receivedDateTime);
+
+ return `
+
+
+
${this.escapeHtml(subject)}
+
${this.escapeHtml(preview)}
+
+ `;
+ }).join('');
+ }
+
+ selectEmail(id) {
+ this.selectedId = id;
+
+ document.querySelectorAll('.mail-item').forEach(el => {
+ el.classList.toggle('selected', el.dataset.id === id);
+ });
+
+ const msg = this.messages.find(m => m.id === id);
+ if (msg) this.renderDetail(msg);
+
+ this.closeMobileMailList();
+ }
+
+ updateEmailCount(count) {
+ const badge = document.getElementById('emailCountBadge');
+ badge.textContent = count;
+ badge.style.display = count > 0 ? 'inline-flex' : 'none';
+ }
+
+ // ====================================================================
+ // 邮件详情
+ // ====================================================================
+
+ renderDetail(msg) {
+ const container = document.getElementById('detailContent');
+ const sender = msg.sender?.emailAddress || msg.from?.emailAddress || {};
+ const senderName = sender.name || sender.address || '未知发件人';
+ const senderAddr = sender.address || '';
+
+ const toRecipients = msg.toRecipients || [];
+ const recipients = toRecipients
+ .map(r => r.emailAddress?.name || r.emailAddress?.address)
+ .filter(Boolean)
+ .join(', ') || '未知收件人';
+
+ const subject = msg.subject || '(无主题)';
+ const date = this.formatDateFull(msg.receivedDateTime);
+ const body = msg.body?.content || '(无内容)';
+ const contentType = msg.body?.contentType || 'text';
+
+ const isHtml = contentType === 'html' ||
+ body.includes('${this.escapeHtml(body)}`;
+ }
+
+ container.innerHTML = `
+
+
+ ${bodySection}
+
+ `;
+
+ if (isHtml) this.writeIframe(body);
+ }
+
+ writeIframe(htmlContent) {
+ const iframe = document.getElementById('emailIframe');
+ if (!iframe) return;
+
+ const sanitized = this.sanitizeHtml(htmlContent);
+
+ iframe.onload = () => {
+ try {
+ const doc = iframe.contentDocument || iframe.contentWindow.document;
+ const height = doc.documentElement.scrollHeight || doc.body.scrollHeight;
+ iframe.style.height = Math.max(300, Math.min(height + 30, 2000)) + 'px';
+ } catch (e) {
+ iframe.style.height = '500px';
+ }
+ };
+
+ try {
+ const doc = iframe.contentDocument || iframe.contentWindow.document;
+ doc.open();
+ doc.write(`${sanitized}`);
+ doc.close();
+ } catch (e) {
+ console.error('写入iframe失败:', e);
+ iframe.style.height = '500px';
+ }
+ }
+
+ showDetailEmpty() {
+ const container = document.getElementById('detailContent');
+ container.innerHTML = '';
+ }
+
+ // ====================================================================
+ // 移动端邮件列表
+ // ====================================================================
+
+ toggleMobileMailList() {
+ const panel = document.getElementById('emailListPanel');
+ const overlay = document.getElementById('mobileOverlay');
+ panel.classList.toggle('mobile-open');
+ overlay.classList.toggle('show');
+ }
+
+ closeMobileMailList() {
+ const panel = document.getElementById('emailListPanel');
+ const overlay = document.getElementById('mobileOverlay');
+ panel.classList.remove('mobile-open');
+ overlay.classList.remove('show');
+ }
+
+ // ====================================================================
+ // Claude 支付检测
+ // ====================================================================
+
+ async loadClaudePaymentStatuses() {
+ try {
+ const resp = await fetch('/api/tools/claude-payment-status');
+ if (resp.ok) {
+ const result = await resp.json();
+ if (result.success) this.claudePaymentStatuses = result.data || {};
+ }
+ } catch (e) { console.error('加载Claude支付状态失败:', e); }
+ }
+
+ renderClaudeColumns(email) {
+ const info = this.claudePaymentStatuses[email];
+ if (!info) {
+ return `未检测 |
+ 未检测 |
+ - |
+ - |
+ - |
+ |
+ | `;
+
+ }
+
+ const hasPaid = !!info.payment_time;
+ const hasRefund = !!info.refund_time;
+ const hasSuspended = !!info.suspended_time;
+
+ const paidBadge = info.status === 'error'
+ ? '错误'
+ : hasPaid
+ ? '已支付'
+ : '未支付';
+
+ const refundBadge = info.status === 'error'
+ ? '错误'
+ : hasRefund
+ ? '已退款'
+ : '未退款';
+
+ const fmtTime = (t) => {
+ if (!t) return '-';
+ try {
+ const d = new Date(t);
+ if (isNaN(d.getTime())) return t;
+ return d.toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
+ } catch(e) { return t; }
+ };
+
+ const suspendedHtml = hasSuspended
+ ? `${fmtTime(info.suspended_time)}`
+ : '-';
+
+ return `${paidBadge} |
+ ${refundBadge} |
+ ${fmtTime(info.payment_time)} |
+ ${fmtTime(info.refund_time)} |
+ ${suspendedHtml} |
+ ${this.renderNoteCell(email, info)} |
+
+
+
+ ${info.proxy ? `` : ''}
+
+ ${info.proxy ? this.renderProxyExpireBadge(info) : ''}
+ | `;
+ }
+
+ openProxyModal(email) {
+ this._proxyEditEmail = email;
+ const info = this.claudePaymentStatuses[email];
+ document.getElementById('proxyModalEmail').textContent = email;
+
+ // 清空字段
+ document.getElementById('proxyHost').value = '';
+ document.getElementById('proxyPort').value = '';
+ document.getElementById('proxyUser').value = '';
+ document.getElementById('proxyPass').value = '';
+ document.getElementById('proxyRaw').value = '';
+ document.querySelector('input[name="proxyProtocol"][value="http"]').checked = true;
+
+ // 有效期/类型默认值
+ const expireDays = info ? (info.proxy_expire_days || 30) : 30;
+ const share = info ? (info.proxy_share || 'exclusive') : 'exclusive';
+ const purchaseDate = info ? (info.proxy_purchase_date || '') : '';
+
+ // 设置有效期
+ const customInput = document.getElementById('proxyExpireCustom');
+ if (expireDays === 10 || expireDays === 30) {
+ document.querySelector(`input[name="proxyExpire"][value="${expireDays}"]`).checked = true;
+ customInput.style.display = 'none';
+ } else {
+ document.querySelector('input[name="proxyExpire"][value="custom"]').checked = true;
+ customInput.style.display = '';
+ customInput.value = expireDays;
+ }
+
+ // 设置类型
+ const shareRadio = document.querySelector(`input[name="proxyShare"][value="${share}"]`);
+ if (shareRadio) shareRadio.checked = true;
+
+ // 设置购买日期
+ document.getElementById('proxyPurchaseDate').value = purchaseDate || new Date().toISOString().slice(0, 10);
+ this.calcProxyExpireDate();
+
+ // 如果已有代理,解析填充
+ const existing = info ? (info.proxy || '') : '';
+ if (existing) {
+ this.parseProxyRaw(existing);
+ document.getElementById('proxyRaw').value = existing;
+ }
+ this.updateProxyPreview();
+ document.getElementById('proxyModal').classList.add('show');
+ setTimeout(() => document.getElementById('proxyHost').focus(), 100);
+ }
+
+ parseProxyRaw(raw) {
+ raw = raw.trim();
+ if (!raw) return;
+
+ let protocol = 'http', host = '', port = '', user = '', pass = '';
+
+ // 格式1: protocol://user:pass@host:port
+ const urlMatch = raw.match(/^(https?|socks5):\/\/(?:([^:@]+):([^@]+)@)?([^:\/]+):(\d+)/i);
+ if (urlMatch) {
+ protocol = urlMatch[1].toLowerCase();
+ user = urlMatch[2] || '';
+ pass = urlMatch[3] || '';
+ host = urlMatch[4];
+ port = urlMatch[5];
+ } else {
+ // 格式2: ip:port:user:pass 或 ip:port
+ const parts = raw.split(':');
+ if (parts.length >= 2) {
+ host = parts[0];
+ port = parts[1];
+ if (parts.length >= 4) {
+ user = parts[2];
+ pass = parts[3];
+ } else if (parts.length === 3) {
+ user = parts[2];
+ }
+ }
+ }
+
+ document.getElementById('proxyHost').value = host;
+ document.getElementById('proxyPort').value = port;
+ document.getElementById('proxyUser').value = user;
+ document.getElementById('proxyPass').value = pass;
+ const radio = document.querySelector(`input[name="proxyProtocol"][value="${protocol}"]`);
+ if (radio) radio.checked = true;
+ this.updateProxyPreview();
+ }
+
+ buildProxyString() {
+ const host = document.getElementById('proxyHost').value.trim();
+ const port = document.getElementById('proxyPort').value.trim();
+ if (!host || !port) return '';
+
+ const protocol = document.querySelector('input[name="proxyProtocol"]:checked').value;
+ const user = document.getElementById('proxyUser').value.trim();
+ const pass = document.getElementById('proxyPass').value.trim();
+
+ if (user && pass) {
+ return `${protocol}://${user}:${pass}@${host}:${port}`;
+ } else if (user) {
+ return `${protocol}://${user}@${host}:${port}`;
+ }
+ return `${protocol}://${host}:${port}`;
+ }
+
+ updateProxyPreview() {
+ const str = this.buildProxyString();
+ const el = document.getElementById('proxyPreview');
+ if (str) {
+ el.innerHTML = `完整代理:${this.escapeHtml(str)}`;
+ } else {
+ el.innerHTML = '';
+ }
+ }
+
+ renderNoteCell(email, info) {
+ const title = info.title || '';
+ const card = info.card_number || '';
+ const hasContent = title || card || info.remark;
+ if (!hasContent) {
+ return ``;
+ }
+ const maskedCard = card ? '****' + card.slice(-4) : '';
+ return `
+ ${title ? `
${this.escapeHtml(title)}
` : ''}
+ ${maskedCard ? `
${maskedCard}
` : ''}
+
`;
+ }
+
+ renderProxyExpireBadge(info) {
+ if (!info.proxy_purchase_date) return '';
+ const days = info.proxy_expire_days || 30;
+ const d = new Date(info.proxy_purchase_date);
+ d.setDate(d.getDate() + days);
+ const now = new Date(); now.setHours(0, 0, 0, 0);
+ const remaining = Math.ceil((d - now) / 86400000);
+ const shareLabel = info.proxy_share === 'shared' ? '共享' : '独享';
+ let cls = 'proxy-expire-ok';
+ let label = `${remaining}天`;
+ if (remaining <= 0) { cls = 'proxy-expire-dead'; label = '已过期'; }
+ else if (remaining <= 3) { cls = 'proxy-expire-warn'; }
+ return `${label}${shareLabel}
`;
+ }
+
+ getProxyExpireDays() {
+ const checked = document.querySelector('input[name="proxyExpire"]:checked').value;
+ if (checked === 'custom') {
+ return parseInt(document.getElementById('proxyExpireCustom').value) || 30;
+ }
+ return parseInt(checked);
+ }
+
+ calcProxyExpireDate() {
+ const purchase = document.getElementById('proxyPurchaseDate').value;
+ const days = this.getProxyExpireDays();
+ const expireEl = document.getElementById('proxyExpireDate');
+ if (purchase && days > 0) {
+ const d = new Date(purchase);
+ d.setDate(d.getDate() + days);
+ const now = new Date();
+ now.setHours(0, 0, 0, 0);
+ const remaining = Math.ceil((d - now) / 86400000);
+ const dateStr = d.toISOString().slice(0, 10);
+ if (remaining <= 0) {
+ expireEl.value = `${dateStr} (已过期)`;
+ expireEl.style.color = '#e11d48';
+ } else if (remaining <= 3) {
+ expireEl.value = `${dateStr} (剩${remaining}天)`;
+ expireEl.style.color = '#d97706';
+ } else {
+ expireEl.value = `${dateStr} (剩${remaining}天)`;
+ expireEl.style.color = '#059669';
+ }
+ } else {
+ expireEl.value = '';
+ expireEl.style.color = '#64748b';
+ }
+ }
+
+ async saveProxy() {
+ const email = this._proxyEditEmail;
+ if (!email) return;
+ const value = this.buildProxyString();
+ const expireDays = this.getProxyExpireDays();
+ const share = document.querySelector('input[name="proxyShare"]:checked').value;
+ const purchaseDate = document.getElementById('proxyPurchaseDate').value;
+ try {
+ const resp = await fetch(`/api/tools/claude-payment-note/${encodeURIComponent(email)}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ proxy: value,
+ proxy_expire_days: expireDays,
+ proxy_share: share,
+ proxy_purchase_date: purchaseDate
+ })
+ });
+ const result = await resp.json();
+ if (result.success) {
+ if (!this.claudePaymentStatuses[email]) {
+ this.claudePaymentStatuses[email] = { status: 'unknown' };
+ }
+ this.claudePaymentStatuses[email].proxy = value;
+ this.claudePaymentStatuses[email].proxy_expire_days = expireDays;
+ this.claudePaymentStatuses[email].proxy_share = share;
+ this.claudePaymentStatuses[email].proxy_purchase_date = purchaseDate;
+ this.updateClaudeBadgeInTable(email);
+ document.getElementById('proxyModal').classList.remove('show');
+ this.showToast('代理已保存', 'success');
+ } else {
+ this.showToast(result.message || '保存失败', 'danger');
+ }
+ } catch (e) {
+ this.showToast('保存失败', 'danger');
+ }
+ }
+
+ async clearProxy() {
+ const email = this._proxyEditEmail;
+ if (!email) return;
+ try {
+ const resp = await fetch(`/api/tools/claude-payment-note/${encodeURIComponent(email)}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ proxy: '' })
+ });
+ const result = await resp.json();
+ if (result.success) {
+ if (this.claudePaymentStatuses[email]) {
+ this.claudePaymentStatuses[email].proxy = '';
+ }
+ this.updateClaudeBadgeInTable(email);
+ document.getElementById('proxyModal').classList.remove('show');
+ this.showToast('代理已清除', 'success');
+ }
+ } catch (e) {
+ this.showToast('清除失败', 'danger');
+ }
+ }
+
+ openNoteModal(email) {
+ this._noteEditEmail = email;
+ const info = this.claudePaymentStatuses[email] || {};
+ document.getElementById('noteModalEmail').textContent = email;
+ document.getElementById('noteTitle').value = info.title || '';
+ document.getElementById('noteCardNumber').value = info.card_number || '';
+ document.getElementById('noteRemark').value = info.remark || '';
+ document.getElementById('noteModal').classList.add('show');
+ setTimeout(() => document.getElementById('noteTitle').focus(), 100);
+ }
+
+ async saveNote() {
+ const email = this._noteEditEmail;
+ if (!email) return;
+ const title = document.getElementById('noteTitle').value.trim();
+ const card_number = document.getElementById('noteCardNumber').value.trim();
+ const remark = document.getElementById('noteRemark').value.trim();
+ try {
+ const resp = await fetch(`/api/tools/claude-payment-note/${encodeURIComponent(email)}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ title, card_number, remark })
+ });
+ const result = await resp.json();
+ if (result.success) {
+ if (!this.claudePaymentStatuses[email]) {
+ this.claudePaymentStatuses[email] = { status: 'unknown' };
+ }
+ this.claudePaymentStatuses[email].title = title;
+ this.claudePaymentStatuses[email].card_number = card_number;
+ this.claudePaymentStatuses[email].remark = remark;
+ this.updateClaudeBadgeInTable(email);
+ document.getElementById('noteModal').classList.remove('show');
+ this.showToast('备注已保存', 'success');
+ } else {
+ this.showToast(result.message || '保存失败', 'danger');
+ }
+ } catch (e) {
+ this.showToast('保存失败', 'danger');
+ }
+ }
+
+ async startClaudePaymentCheck() {
+ const btn = document.getElementById('claudePaymentBtn');
+ if (!btn) return;
+ btn.disabled = true;
+ const origHtml = btn.innerHTML;
+ btn.innerHTML = '检测中...';
+
+ try {
+ const response = await fetch('/api/tools/check-claude-payment', { method: 'POST' });
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop();
+ for (const line of lines) {
+ if (!line.startsWith('data: ')) continue;
+ try {
+ const data = JSON.parse(line.slice(6));
+ if (data.type === 'progress') {
+ btn.innerHTML = `${data.current}/${data.total}`;
+ } else if (data.type === 'result') {
+ this.claudePaymentStatuses[data.email] = {
+ status: data.status,
+ payment_time: data.payment_time || null,
+ refund_time: data.refund_time || null,
+ suspended_time: data.suspended_time || null,
+ checked_at: new Date().toLocaleString('zh-CN')
+ };
+ this.updateClaudeBadgeInTable(data.email);
+ } else if (data.type === 'done') {
+ btn.innerHTML = '完成';
+ setTimeout(() => { btn.innerHTML = origHtml; btn.disabled = false; }, 2000);
+ }
+ } catch (e) {}
+ }
+ }
+ } catch (err) {
+ console.error('Claude支付检测失败:', err);
+ btn.innerHTML = '失败';
+ setTimeout(() => { btn.innerHTML = origHtml; btn.disabled = false; }, 2000);
+ }
+ }
+
+ async checkSingleClaudePayment(email, btnEl) {
+ if (!btnEl) return;
+ btnEl.disabled = true;
+ const origText = btnEl.textContent;
+ btnEl.textContent = '检测中...';
+
+ try {
+ const resp = await fetch(`/api/tools/check-claude-payment/${encodeURIComponent(email)}`, { method: 'POST' });
+ const result = await resp.json();
+ if (result.success && result.data) {
+ this.claudePaymentStatuses[email] = {
+ status: result.data.status,
+ payment_time: result.data.payment_time || null,
+ refund_time: result.data.refund_time || null,
+ suspended_time: result.data.suspended_time || null,
+ checked_at: new Date().toLocaleString('zh-CN')
+ };
+ this.updateClaudeBadgeInTable(email);
+ this.showToast(`${email} 检测完成: ${result.data.status}`, 'success');
+ } else {
+ this.showToast(result.message || '检测失败', 'danger');
+ }
+ } catch (err) {
+ console.error('单账户Claude检测失败:', err);
+ this.showToast('检测失败', 'danger');
+ } finally {
+ btnEl.disabled = false;
+ btnEl.textContent = origText;
+ }
+ }
+
+ updateClaudeBadgeInTable(email) {
+ const tbody = document.getElementById('accountTableBody');
+ if (!tbody) return;
+ for (const row of tbody.querySelectorAll('tr')) {
+ const firstTd = row.querySelector('td');
+ if (firstTd && firstTd.textContent.includes(email)) {
+ const cells = row.querySelectorAll('td');
+ if (cells.length >= 13) {
+ const tmp = document.createElement('tr');
+ tmp.innerHTML = this.renderClaudeColumns(email);
+ const newCells = tmp.querySelectorAll('td');
+ for (let j = 0; j < 7 && j < newCells.length; j++) {
+ cells[5 + j].innerHTML = newCells[j].innerHTML;
+ cells[5 + j].className = newCells[j].className;
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ // ====================================================================
+ // 工具方法
+ // ====================================================================
+
+ copyToClipboard(text) {
+ if (!text) return;
+ const fallback = () => {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0.01';
+ document.body.appendChild(ta);
+ ta.focus();
+ ta.select();
+ try { document.execCommand('copy'); } catch (e) {}
+ document.body.removeChild(ta);
+ this.showToast('已复制到剪贴板', 'success');
+ };
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard.writeText(text).then(() => {
+ this.showToast('已复制到剪贴板', 'success');
+ }).catch(fallback);
+ } else {
+ fallback();
+ }
+ }
+
+ showToast(message, type = 'info') {
+ const container = document.getElementById('toastContainer');
+ const iconMap = {
+ success: 'bi-check-circle-fill',
+ danger: 'bi-exclamation-triangle-fill',
+ warning: 'bi-exclamation-circle-fill',
+ info: 'bi-info-circle-fill'
+ };
+ const toast = document.createElement('div');
+ toast.className = `app-toast toast-${type}`;
+ toast.innerHTML = `${this.escapeHtml(message)}`;
+ container.appendChild(toast);
+
+ requestAnimationFrame(() => toast.classList.add('show'));
+ setTimeout(() => {
+ toast.classList.remove('show');
+ setTimeout(() => toast.remove(), 300);
+ }, 2500);
+ }
+
+ truncate(text, maxLen) {
+ if (!text) return '-';
+ return text.length > maxLen ? text.substring(0, maxLen) + '...' : text;
+ }
+
+ formatDate(dateString) {
+ if (!dateString) return '';
+ try {
+ const date = new Date(dateString);
+ if (isNaN(date.getTime())) return dateString;
+ const now = new Date();
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const msgDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
+ const diff = Math.floor((today - msgDay) / 86400000);
+ if (diff === 0) return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
+ if (diff === 1) return '昨天';
+ if (diff < 7) return `${diff}天前`;
+ return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
+ } catch (e) {
+ return dateString;
+ }
+ }
+
+ formatDateFull(dateString) {
+ if (!dateString) return '未知时间';
+ try {
+ const date = new Date(dateString);
+ if (isNaN(date.getTime())) return dateString;
+ return date.toLocaleString('zh-CN', {
+ year: 'numeric', month: '2-digit', day: '2-digit',
+ hour: '2-digit', minute: '2-digit'
+ });
+ } catch (e) {
+ return dateString;
+ }
+ }
+
+ escapeHtml(text) {
+ if (text == null) return '';
+ const str = String(text);
+ const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+ return str.replace(/[&<>"']/g, m => map[m]);
+ }
+
+ escapeJs(text) {
+ if (text == null) return '';
+ return String(text).replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
+ }
+
+ sanitizeHtml(html) {
+ let cleaned = html;
+ cleaned = cleaned.replace(/