Add project files: Outlook mail manager with Docker support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-06 00:45:44 +08:00
parent d964a8d758
commit 5b01caf8e3
19 changed files with 6984 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(python:*)"
]
}
}

63
.dockerignore Normal file
View File

@@ -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

20
.env.example Normal file
View File

@@ -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

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
config.txt
outlook_manager.db
.codebuddy
_pychache_
*.pyc
config.txt

38
Dockerfile Normal file
View File

@@ -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"]

240
TASK_CLAUDE_PAYMENT.md Normal file
View File

@@ -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 REPLACEchecked_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
<button class="btn btn-primary" id="claudePaymentBtn">
<i class="bi bi-credit-card"></i>
<span>Claude检测</span>
</button>
```
**表格表头**:在"令牌"和"操作"之间,添加新列:
```html
<th>Claude状态</th>
```
同时把所有 `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()** 中:在令牌列 `</td>` 和操作列 `<td>` 之间,插入新列:
```javascript
<td>${this.renderClaudeStatusBadge(email)}</td>
```
**新增 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 '<span class="claude-badge claude-unknown">未检测</span>';
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 `<span class="claude-badge ${s.cls}"${title}>${s.label}</span>`;
}
async startClaudePaymentCheck() {
const btn = document.getElementById('claudePaymentBtn');
if (!btn) return;
btn.disabled = true;
const origHtml = btn.innerHTML;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i><span>检测中...</span>';
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 = `<i class="bi bi-hourglass-split"></i><span>${data.current}/${data.total}</span>`;
} 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 = '<i class="bi bi-check-circle"></i><span>完成</span>';
setTimeout(() => { btn.innerHTML = origHtml; btn.disabled = false; }, 2000);
}
} catch (e) {}
}
}
} catch (err) {
console.error('Claude支付检测失败:', err);
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i><span>失败</span>';
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个tdindex 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. 刷新页面后状态仍然保留(数据库持久化)

79
auth.py Normal file
View File

@@ -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")

41
config.py Normal file
View File

@@ -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__)

631
database.py Normal file
View File

@@ -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()

31
docker-compose.yml Normal file
View File

@@ -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

370
imap_client.py Normal file
View File

@@ -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 '<html' in body_content.lower() or '<body' in body_content.lower():
body_type = "html"
import re
body_preview = re.sub('<[^<]+?>', '', 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})")

1298
mail_api.py Normal file

File diff suppressed because it is too large Load Diff

125
models.py Normal file
View File

@@ -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'}

8
requirements.txt Normal file
View File

@@ -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

337
static/admin.html Normal file
View File

@@ -0,0 +1,337 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Outlook邮件系统 - 账号管理</title>
<link rel="stylesheet" href="style.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
<style>
/* 背景与容器风格对齐用户页 */
body {
background: #f5f7fa;
}
.admin-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.admin-card {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
width: 100%;
max-width: 1100px;
animation: slideIn 0.5s ease-out;
display: flex;
flex-direction: column;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.admin-header {
background: #0078d4;
color: white;
padding: 16px 18px;
text-align: center;
}
.admin-header h2 { font-size: 18px; font-weight: 600; margin: 0 0 4px 0; }
.admin-header p { margin: 0; opacity: .9; }
.admin-content { padding: 16px; }
.login-section { max-width: 420px; margin: 0 auto; }
.management-section { display: none; }
/* 账号卡片风格 */
.account-item {
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 14px;
margin-bottom: 12px;
background: #ffffff;
transition: box-shadow .2s ease, border-color .2s ease;
}
.account-item:hover {
border-color: #cbd5e0;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
background: #fff;
}
/* 按钮与输入对齐用户页 */
.btn-admin {
background: #0078d4;
border: none;
color: white;
border-radius: 6px;
padding: 6px 12px;
transition: all 0.2s ease;
font-weight: 500;
font-size: 13px;
}
.btn-admin:hover { background: #005a9e; color: #fff; transform: translateY(-1px); }
.input-group-custom { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.input-group-custom input {
flex: 1; min-width: 200px; padding: 6px 10px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 13px; outline: none; transition: all .3s;
}
.input-group-custom input:focus { border-color: #0078d4; box-shadow: 0 0 0 3px rgba(0, 120, 212, 0.1); }
.btn-custom { padding: 6px 10px; border: none; border-radius: 6px; font-weight: 500; cursor: pointer; transition: all .3s; display: inline-flex; align-items: center; gap: 6px; font-size: 13px; }
.btn-primary-custom { background: #0078d4; color: #fff; }
.btn-primary-custom:hover { background: #005a9e; }
.btn-outline-secondary { border: 1px solid #e2e8f0; color: #718096; padding: 6px 8px; border-radius: 6px; font-size: 12px; }
/* 选项卡与内容区域 */
.nav-tabs .nav-link { border: 1px solid transparent; border-radius: 8px 8px 0 0; color: #0078d4; padding: 6px 10px; font-size: 13px; }
.nav-tabs .nav-link.active { background: #0078d4; color: white; border-color: #0078d4; }
.tab-content { border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px; padding: 12px; background: white; max-height: 55vh; overflow-y: auto; }
.admin-content { padding: 16px; flex: 1; overflow-y: auto; max-height: calc(100vh - 160px); }
/* 模态风格对齐用户页 */
.modal-content { border: none; border-radius: 12px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); }
.modal-header { background: #0078d4; color: white; border-radius: 12px 12px 0 0; border-bottom: none; padding: 15px 20px; }
.modal-title { font-size: 16px; font-weight: 600; }
.modal-header .btn-close { filter: brightness(0) invert(1); opacity: 0.8; }
.modal-header .btn-close:hover { opacity: 1; }
.modal-body .form-label { font-weight: 600; color: #2d3748; margin-bottom: 6px; font-size: 14px; }
.modal-body .form-control { border: 1px solid #e2e8f0; border-radius: 6px; padding: 8px 12px; transition: all 0.3s; font-size: 14px; }
.modal-body .form-control:focus { border-color: #0078d4; box-shadow: 0 0 0 3px rgba(0, 120, 212, 0.1); }
</style>
</head>
<body>
<div class="admin-container">
<div class="admin-card">
<div class="admin-header P-3">
<h2><i class="bi bi-gear me-2"></i>Outlook邮件系统 - 账号管理</h2>
</div>
<div class="admin-content">
<!-- 登录验证区域 -->
<div id="loginSection" class="login-section">
<div class="text-center mb-4">
<i class="bi bi-shield-lock display-4 text-primary"></i>
<h4 class="mt-3">身份验证</h4>
<p class="text-muted">请输入管理令牌以访问账号管理功能</p>
</div>
<form id="loginForm">
<div class="mb-3">
<label for="tokenInput" class="form-label">管理令牌</label>
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-key"></i>
</span>
<input type="password" class="form-control" id="tokenInput"
placeholder="请输入管理令牌" required>
</div>
<div class="form-text">
<i class="bi bi-info-circle me-1"></i>
默认令牌admin123可通过环境变量 ADMIN_TOKEN 修改)
</div>
</div>
<button type="submit" class="btn btn-admin w-100">
<i class="bi bi-unlock me-2"></i>验证并登录
</button>
</form>
<div class="text-center mt-3">
<a href="/" class="text-decoration-none">
<i class="bi bi-arrow-left me-1"></i>返回邮件管理
</a>
</div>
</div>
<!-- 管理功能区域(单页紧凑版) -->
<div id="managementSection" class="management-section">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-outline-secondary btn-sm" id="openImportModalBtn" title="导入">
<i class="bi bi-upload"></i> 导入
</button>
<button class="btn btn-outline-secondary btn-sm" id="exportDataBtn" title="导出">
<i class="bi bi-download"></i> 导出
</button>
</div>
<div class="d-flex gap-2 align-items-center">
<div class="input-group-custom" style="width: 280px;">
<input type="text" id="accountSearchInput" placeholder="搜索邮箱...">
<button class="btn btn-outline-secondary btn-sm" id="searchAccountsBtn" title="搜索">
<i class="bi bi-search"></i>
</button>
</div>
<button class="btn btn-outline-secondary btn-sm" id="refreshAccountsBtn" title="刷新">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
<div id="accountsList" class="accounts-list">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th class="small text-muted" style="width:50%">邮箱</th>
<th class="small text-muted">标签</th>
<th class="text-end small text-muted" style="width:120px">操作</th>
</tr>
</thead>
<tbody id="accountsTbody">
<tr>
<td colspan="3" class="text-center py-4">
<div class="spinner-border text-primary" role="status"></div>
<div class="mt-2 small text-muted">正在加载账号列表...</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<div class="small text-muted" id="accountsPagerInfo">第 1 / 1 页,共 0 条</div>
<div class="btn-group" role="group">
<button class="btn btn-outline-secondary btn-sm" id="prevPageBtn" title="上一页"><i class="bi bi-chevron-left"></i></button>
<button class="btn btn-outline-secondary btn-sm" id="nextPageBtn" title="下一页"><i class="bi bi-chevron-right"></i></button>
</div>
</div>
<!-- 退出登录 -->
<div class="text-center mt-4">
<button class="btn btn-sm btn-outline-danger" id="logoutBtn">
<i class="bi bi-box-arrow-right me-2"></i>退出管理
</button>
<a href="/" class="btn btn-outline-secondary ms-2">
<i class="bi bi-arrow-left me-2"></i>返回邮件管理
</a>
</div>
</div>
</div>
</div>
</div>
<!-- 成功提示模态框 -->
<div class="modal fade" id="successModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header border-0">
<h5 class="modal-title">
<i class="bi bi-check-circle me-2"></i>操作成功
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p id="successMessage" class="mb-0"></p>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">确定</button>
</div>
</div>
</div>
</div>
<!-- 错误提示模态框 -->
<div class="modal fade" id="errorModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header border-0">
<h5 class="modal-title text-danger">
<i class="bi bi-exclamation-triangle me-2"></i>错误提示
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p id="errorMessage" class="mb-0"></p>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">确定</button>
</div>
</div>
</div>
</div>
<!-- 标签管理模态框(保留用于行内编辑) -->
<div class="modal fade" id="tagManagementModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="tagManagementModalTitle">
<i class="bi bi-tags me-2"></i>管理标签
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="tagManagementInput" class="form-label">标签</label>
<input type="text" class="form-control" id="tagManagementInput"
placeholder="输入标签,用逗号分隔,如:微软,谷歌,苹果">
<div class="form-text">
<i class="bi bi-lightbulb me-1"></i>
多个标签用逗号分隔,如:微软,谷歌,苹果
</div>
</div>
<div class="mb-3">
<small class="text-muted">当前标签:</small>
<div id="tagManagementCurrentTags" class="mt-1">
<span class="text-muted">暂无标签</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" id="tagManagementSaveBtn">
<i class="bi bi-save me-1"></i>保存标签
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
</div>
</div>
</div>
</div>
<!-- 导入对话框 -->
<div class="modal fade" id="importModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-upload me-2"></i>批量导入账号
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info" role="alert" style="font-size: 12px;">
<i class="bi bi-info-circle"></i>
支持:完整格式「邮箱----密码----client_id----refresh_token」或简化格式「邮箱----refresh_token」。
</div>
<div class="mb-2">
<label for="importTextarea" class="form-label small mb-1">账户数据</label>
<textarea class="form-control" id="importTextarea" rows="8" placeholder="每行一个账户,使用四个短横线(----)分隔字段"></textarea>
</div>
<div>
<label for="mergeMode" class="form-label small mb-1">合并模式</label>
<select class="form-select form-select-sm" id="mergeMode">
<option value="update">更新现有账户</option>
<option value="skip">跳过重复账户</option>
<option value="replace">替换所有数据</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-admin btn-sm" id="executeImportBtn">
<i class="bi bi-upload me-1"></i>导入
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="admin.js"></script>
</body>
</html>

743
static/admin.js Normal file
View File

@@ -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 = `
<tr>
<td colspan="3" class="text-center py-4">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<div class="mt-2 small text-muted">正在加载账号列表...</div>
</td>
</tr>
`;
}
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 = `
<div class="text-center py-4 text-danger">
<i class="bi bi-exclamation-triangle display-4"></i>
<div class="mt-2">加载失败: ${error.message}</div>
<button class="btn btn-outline-primary btn-sm mt-2" onclick="adminManager.loadAccounts()">
<i class="bi bi-arrow-clockwise me-1"></i>重试
</button>
</div>
`;
}
}
async renderAccounts(accounts) {
const tbody = document.getElementById('accountsTbody');
if (!tbody) return;
if (accounts.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="3" class="text-center py-4 text-muted">
<i class="bi bi-inbox"></i>
<div class="mt-2 small">暂无账号数据</div>
</td>
</tr>
`;
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 => `<span class="badge bg-secondary me-1 mb-1">${this.escapeHtml(tag)}</span>`).join('') :
'<span class="text-muted small">无标签</span>';
return `
<tr>
<td class="small"><i class="bi bi-envelope me-1"></i>${account.email}</td>
<td>${tagsHtml}</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary btn-sm"
onclick="adminManager.showTagManagementDialog('${account.email}')"
title="管理标签">
<i class="bi bi-tags"></i>
</button>
</div>
</td>
</tr>
`;
}).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 =>
`<span class="badge bg-secondary me-1">${this.escapeHtml(tag)}</span>`
).join('');
currentTagsDisplay.innerHTML = tagsHtml;
} else {
currentTagsDisplay.innerHTML = '<span class="text-muted">暂无标签</span>';
}
}
}
} 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 = `
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<div class="mt-2 small">加载中...</div>
</div>
`;
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 = `
<div class="text-center py-3 text-danger">
<i class="bi bi-exclamation-triangle"></i>
<div class="mt-2 small">加载失败: ${error.message}</div>
</div>
`;
}
}
renderAllTags(tags) {
const allTagsList = document.getElementById('allTagsList');
if (tags.length === 0) {
allTagsList.innerHTML = `
<div class="text-center py-3 text-muted">
<i class="bi bi-tags"></i>
<div class="mt-2 small">暂无标签</div>
</div>
`;
return;
}
const tagsHtml = tags.map(tag => `
<span class="badge bg-primary me-1 mb-1">${this.escapeHtml(tag)}</span>
`).join('');
allTagsList.innerHTML = `
<div class="mb-2">
<small class="text-muted">共 ${tags.length} 个标签:</small>
</div>
<div>${tagsHtml}</div>
`;
}
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 = '<option value="">请选择账户...</option>';
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 =>
`<span class="badge bg-secondary me-1">${this.escapeHtml(tag)}</span>`
).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();
});

350
static/index.html Normal file
View File

@@ -0,0 +1,350 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Outlook 邮件管理器</title>
<link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.1.3/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.bootcdn.net/ajax/libs/bootstrap-icons/1.13.1/font/bootstrap-icons.css" rel="stylesheet">
<link href="style.css" rel="stylesheet">
</head>
<body>
<!-- ====================================================================
视图1账号管理默认主页
==================================================================== -->
<div id="accountView" class="view-container">
<!-- 顶部导航栏 -->
<nav class="navbar">
<div class="navbar-container">
<a href="#" class="logo">
<div class="logo-icon"><i class="bi bi-envelope-fill"></i></div>
<span class="logo-text">邮箱助手</span>
</a>
<div class="nav-actions">
<div class="search-box">
<i class="bi bi-search"></i>
<input type="text" id="accountSearch" placeholder="搜索邮箱..." autocomplete="off">
</div>
<button class="btn btn-primary" id="importBtn">
<i class="bi bi-clipboard-plus"></i>
<span>粘贴导入</span>
</button>
<button class="btn btn-primary" id="exportBtn">
<i class="bi bi-download"></i>
<span>导出</span>
</button>
<button class="btn btn-primary" id="claudePaymentBtn">
<i class="bi bi-credit-card"></i>
<span>Claude检测</span>
</button>
<button class="btn btn-icon" id="refreshAccountsBtn" title="刷新">
<i class="bi bi-arrow-clockwise"></i>
</button>
<a href="/admin" class="btn btn-icon" title="管理后台">
<i class="bi bi-gear"></i>
</a>
</div>
</div>
</nav>
<!-- 主内容区 -->
<div class="main-container">
<!-- 表格容器 -->
<div class="table-container">
<div class="table-header">
<h2 class="table-title">
<i class="bi bi-people"></i>
邮箱账号列表
</h2>
</div>
<div class="table-scroll">
<table class="data-table" id="accountTable">
<thead>
<tr>
<th class="col-num">#</th>
<th>邮箱</th>
<th>密码</th>
<th class="col-hide-mobile">客户ID</th>
<th>令牌</th>
<th>支付状态</th>
<th>退款状态</th>
<th>支付时间</th>
<th>退款时间</th>
<th>封号时间</th>
<th>备注/卡号</th>
<th>代理</th>
<th>操作</th>
</tr>
</thead>
<tbody id="accountTableBody">
<tr>
<td colspan="13">
<div class="no-data">
<i class="bi bi-inbox"></i>
<div>暂无邮箱数据,点击"粘贴导入"添加</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="pagination-container" id="paginationContainer">
<div class="pagination-info">
<span id="pagerInfo">共 0 条</span>
<span class="divider">|</span>
<span id="pagerCurrent">1/1 页</span>
</div>
<div class="pagination-controls" id="pagerControls">
<button class="pagination-btn" id="prevPageBtn" disabled title="上一页">
<i class="bi bi-chevron-left"></i>
</button>
<button class="pagination-btn active">1</button>
<button class="pagination-btn" id="nextPageBtn" disabled title="下一页">
<i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- ====================================================================
视图2邮件查看器
==================================================================== -->
<div id="emailView" class="view-container" style="display:none;">
<!-- 顶部导航 -->
<nav class="navbar">
<div class="navbar-container">
<button class="btn btn-primary" id="backToAccounts">
<i class="bi bi-arrow-left"></i>
<span>返回邮箱管理</span>
</button>
<div class="current-email-info">
<div class="info-icon"><i class="bi bi-envelope"></i></div>
<div class="info-text">
<div class="info-label">当前查看</div>
<div class="info-value">
<span id="topbarEmail"></span>
<span class="folder-badge" id="topbarFolder"></span>
</div>
</div>
</div>
<!-- 移动端邮件列表按钮 -->
<button class="btn btn-icon d-md-none" id="mobileMailToggle" title="邮件列表">
<i class="bi bi-list-ul"></i>
</button>
</div>
</nav>
<!-- 双栏主体 -->
<div class="email-main">
<!-- 左侧邮件列表 -->
<div class="email-list-panel" id="emailListPanel">
<div class="email-list-header">
<span id="emailListTitle">邮件列表</span>
<span class="email-count-badge" id="emailCountBadge" style="display:none;">0</span>
</div>
<div class="email-list-content" id="emailListContent">
<div class="empty-state">
<i class="bi bi-envelope"></i>
<p>加载中...</p>
</div>
</div>
</div>
<!-- 右侧邮件详情 -->
<div class="email-detail-panel" id="emailDetailPanel">
<div class="detail-content" id="detailContent">
<div class="empty-detail">
<i class="bi bi-envelope-open"></i>
<h6>选择一封邮件查看详情</h6>
<p>从左侧列表中选择邮件</p>
</div>
</div>
</div>
</div>
<!-- 移动端遮罩 -->
<div class="mobile-overlay" id="mobileOverlay"></div>
</div>
<!-- ====================================================================
导入模态框
==================================================================== -->
<div class="paste-modal" id="importModal">
<div class="paste-modal-content">
<div class="paste-modal-header">
<div class="paste-modal-icon"><i class="bi bi-clipboard-plus"></i></div>
<div>
<h3 class="paste-modal-title">粘贴导入邮箱</h3>
<p class="paste-modal-subtitle">将邮箱数据粘贴到下方输入框中</p>
</div>
</div>
<textarea class="paste-textarea" id="importText"
placeholder="每行一个账户&#10;格式: 邮箱----密码----客户ID----令牌&#10;简化: 邮箱----令牌"></textarea>
<div class="paste-modal-hint">
<i class="bi bi-info-circle"></i>
<span>使用 <code>----</code> 分隔各字段,每行一个邮箱</span>
</div>
<div class="merge-mode-section">
<label class="input-label">合并模式</label>
<div class="merge-options">
<label class="merge-option">
<input type="radio" name="mergeMode" value="update" checked>
<span>更新已有</span>
</label>
<label class="merge-option">
<input type="radio" name="mergeMode" value="skip">
<span>跳过重复</span>
</label>
</div>
</div>
<div class="paste-modal-buttons">
<button class="btn btn-cancel" id="cancelImportBtn">取消</button>
<button class="btn btn-primary" id="doImportBtn">
<i class="bi bi-check2"></i>
<span>确定导入</span>
</button>
</div>
</div>
</div>
<!-- 备注/卡号编辑弹窗 -->
<div class="paste-modal" id="noteModal">
<div class="paste-modal-content" style="max-width:420px;">
<div class="paste-modal-header">
<div class="paste-modal-icon"><i class="bi bi-pencil-square"></i></div>
<div>
<h3 class="paste-modal-title">编辑备注</h3>
<p class="paste-modal-subtitle" id="noteModalEmail"></p>
</div>
</div>
<div class="note-fields">
<div class="note-field-group">
<label class="input-label">标题</label>
<input type="text" class="proxy-input" id="noteTitle" placeholder="如:淘宝、开台" autocomplete="off">
</div>
<div class="note-field-group">
<label class="input-label">卡号</label>
<input type="text" class="proxy-input" id="noteCardNumber" placeholder="银行卡号" autocomplete="off">
</div>
<div class="note-field-group">
<label class="input-label">备注</label>
<textarea class="proxy-input" id="noteRemark" rows="3" placeholder="其他备注信息" style="resize:vertical;min-height:60px;"></textarea>
</div>
</div>
<div class="paste-modal-buttons">
<button class="btn btn-cancel" id="cancelNoteBtn">取消</button>
<button class="btn btn-primary" id="saveNoteBtn">
<i class="bi bi-check2"></i>
<span>保存</span>
</button>
</div>
</div>
</div>
<!-- 代理编辑弹窗 -->
<div class="paste-modal" id="proxyModal">
<div class="paste-modal-content" style="max-width:480px;">
<div class="paste-modal-header">
<div class="paste-modal-icon"><i class="bi bi-globe"></i></div>
<div>
<h3 class="paste-modal-title">编辑代理</h3>
<p class="paste-modal-subtitle" id="proxyModalEmail"></p>
</div>
</div>
<div class="proxy-format-section">
<label class="input-label">协议类型</label>
<div class="proxy-format-options">
<label class="merge-option"><input type="radio" name="proxyProtocol" value="http" checked><span>HTTP</span></label>
<label class="merge-option"><input type="radio" name="proxyProtocol" value="https"><span>HTTPS</span></label>
<label class="merge-option"><input type="radio" name="proxyProtocol" value="socks5"><span>SOCKS5</span></label>
</div>
</div>
<div class="proxy-fields">
<div class="proxy-row">
<div class="proxy-field">
<label class="input-label">IP地址</label>
<input type="text" class="proxy-input" id="proxyHost" placeholder="127.0.0.1" autocomplete="off">
</div>
<div class="proxy-field proxy-field-sm">
<label class="input-label">端口</label>
<input type="text" class="proxy-input" id="proxyPort" placeholder="1080" autocomplete="off">
</div>
</div>
<div class="proxy-row">
<div class="proxy-field">
<label class="input-label">账号 <span style="color:#94a3b8;font-weight:400">(可选)</span></label>
<input type="text" class="proxy-input" id="proxyUser" placeholder="username" autocomplete="off">
</div>
<div class="proxy-field">
<label class="input-label">密码 <span style="color:#94a3b8;font-weight:400">(可选)</span></label>
<input type="text" class="proxy-input" id="proxyPass" placeholder="password" autocomplete="off">
</div>
</div>
</div>
<div class="proxy-extra-section">
<div class="proxy-row">
<div class="proxy-field">
<label class="input-label">IP有效期</label>
<div class="proxy-expire-options">
<label class="merge-option"><input type="radio" name="proxyExpire" value="10"><span>10天</span></label>
<label class="merge-option"><input type="radio" name="proxyExpire" value="30" checked><span>30天</span></label>
<label class="merge-option"><input type="radio" name="proxyExpire" value="custom"><span>自定义</span></label>
<input type="number" class="proxy-input proxy-input-mini" id="proxyExpireCustom" placeholder="天" min="1" style="display:none;">
</div>
</div>
<div class="proxy-field">
<label class="input-label">类型</label>
<div class="proxy-share-options">
<label class="merge-option"><input type="radio" name="proxyShare" value="exclusive" checked><span>独享</span></label>
<label class="merge-option"><input type="radio" name="proxyShare" value="shared"><span>共享</span></label>
</div>
</div>
</div>
<div class="proxy-row">
<div class="proxy-field">
<label class="input-label">购买日期</label>
<input type="date" class="proxy-input" id="proxyPurchaseDate">
</div>
<div class="proxy-field">
<label class="input-label">到期日期 <span style="color:#94a3b8;font-weight:400">(自动计算)</span></label>
<input type="text" class="proxy-input" id="proxyExpireDate" readonly style="background:#f8fafc;color:#64748b;">
</div>
</div>
</div>
<div class="paste-modal-hint" style="margin-top:10px;">
<i class="bi bi-info-circle"></i>
<span>也可直接粘贴:<code>ip:端口:账号:密码</code><code>socks5://user:pass@host:port</code></span>
</div>
<div class="proxy-raw-section">
<label class="input-label">快速粘贴</label>
<input type="text" class="proxy-input" id="proxyRaw" placeholder="粘贴完整代理字符串,自动解析" autocomplete="off">
</div>
<div class="proxy-preview" id="proxyPreview"></div>
<div class="paste-modal-buttons">
<button class="btn btn-cancel" id="clearProxyBtn" style="margin-right:auto;">清除代理</button>
<button class="btn btn-cancel" id="cancelProxyBtn">取消</button>
<button class="btn btn-primary" id="saveProxyBtn">
<i class="bi bi-check2"></i>
<span>保存</span>
</button>
</div>
</div>
</div>
<!-- Toast 提示 -->
<div class="toast-container" id="toastContainer"></div>
<!-- Loading 遮罩 -->
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-spinner"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="script.js"></script>
</body>
</html>

1165
static/script.js Normal file

File diff suppressed because it is too large Load Diff

1432
static/style.css Normal file

File diff suppressed because it is too large Load Diff