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邮件系统 - 账号管理 + + + + + + +
+
+
+

Outlook邮件系统 - 账号管理

+
+ +
+ + + + +
+
+
+ + +
+
+
+ + +
+ +
+
+ +
+
+ + + + + + + + + + + + + +
邮箱标签操作
+
+
正在加载账号列表...
+
+
+
+ +
+
第 1 / 1 页,共 0 条
+
+ + +
+
+ + + + +
+ + + 返回邮件管理 + +
+
+
+
+
+ + + + + + + + + + + + + + + + + \ 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 邮件管理器 + + + + + + + +
+ + + + +
+ +
+
+

+ + 邮箱账号列表 +

+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
#邮箱密码客户ID令牌支付状态退款状态支付时间退款时间封号时间备注/卡号代理操作
+
+ +
暂无邮箱数据,点击"粘贴导入"添加
+
+
+
+ + +
+
+ 共 0 条 + | + 1/1 页 +
+
+ + + +
+
+
+
+
+ + + + + +
+
+
+
+
+

粘贴导入邮箱

+

将邮箱数据粘贴到下方输入框中

+
+
+ +
+ + 使用 ---- 分隔各字段,每行一个邮箱 +
+
+ +
+ + +
+
+
+ + +
+
+
+ + +
+
+
+
+
+

编辑备注

+

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+
+
+

编辑代理

+

+
+
+
+ +
+ + + +
+
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+
+
+ +
+ + + + +
+
+
+ +
+ + +
+
+
+
+
+ + +
+
+ + +
+
+
+
+ + 也可直接粘贴: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(senderName)} + ${time} +
+
${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 = ` + + `; + + 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(/)<[^<]*)*<\/script>/gi, ''); + cleaned = cleaned.replace(/\s*on\w+\s*=\s*["'][^"']*["']/gi, ''); + cleaned = cleaned.replace(/javascript\s*:/gi, ''); + return cleaned; + } +} + +// 初始化 +const app = new MailManager(); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..f71f34e --- /dev/null +++ b/static/style.css @@ -0,0 +1,1432 @@ +/* Outlook 邮件管理器 — 参考小苹果风格 */ + +/* ============================================================================ + 基础重置 + ============================================================================ */ +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: #fefefe; + min-height: 100vh; + line-height: 1.6; + color: #1e293b; + overflow-x: hidden; +} + +.view-container { + min-height: 100vh; +} + +/* ============================================================================ + 顶部导航栏 — 白色毛玻璃 + ============================================================================ */ +.navbar { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + position: sticky; + top: 0; + z-index: 1000; + box-shadow: 0 1px 12px rgba(0, 0, 0, 0.06); +} + +.navbar-container { + max-width: 1400px; + margin: 0 auto; + padding: 0 2rem; + display: flex; + align-items: center; + justify-content: space-between; + height: 60px; + gap: 16px; +} + +/* Logo */ +.logo { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + flex-shrink: 0; +} + +.logo-icon { + width: 36px; + height: 36px; + background: linear-gradient(145deg, rgba(102, 126, 234, 0.08), rgba(118, 75, 162, 0.06)); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + color: #7c8cf5; + font-size: 16px; + border: 1px solid rgba(102, 126, 234, 0.08); + transition: all 0.3s ease; +} + +.logo:hover .logo-icon { + background: linear-gradient(145deg, rgba(102, 126, 234, 0.14), rgba(118, 75, 162, 0.10)); + transform: translateY(-1px) scale(1.03); + box-shadow: 0 4px 14px rgba(102, 126, 234, 0.12); +} + +.logo-text { + font-size: 18px; + font-weight: 700; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +/* 导航操作区 */ +.nav-actions { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + justify-content: flex-end; +} + +/* 搜索框 */ +.search-box { + position: relative; + width: 240px; + flex-shrink: 0; +} + +.search-box i { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: #9ca3af; + font-size: 13px; +} + +.search-box input { + width: 100%; + padding: 8px 12px 8px 34px; + border: 2px solid #e5e7eb; + border-radius: 8px; + font-size: 13px; + background: rgba(255, 255, 255, 0.8); + color: #1f2937; + outline: none; + transition: all 0.3s ease; + height: 38px; +} + +.search-box input::placeholder { color: #9ca3af; } +.search-box input:focus { + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); + background: white; +} + +/* ============================================================================ + 按钮系统 — 紫色主题 + ============================================================================ */ +.btn { + padding: 8px 16px; + border: none; + border-radius: 8px; + font-weight: 600; + font-size: 13px; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: inline-flex; + align-items: center; + gap: 6px; + text-decoration: none; + height: 38px; + white-space: nowrap; + position: relative; + overflow: hidden; +} + +.btn i { font-size: 14px; } + +.btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + background: rgba(255, 255, 255, 0.3); + border-radius: 50%; + transform: translate(-50%, -50%); + transition: width 0.4s ease, height 0.4s ease; +} + +.btn:active::before { + width: 200px; + height: 200px; +} + +.btn-primary { + background: rgba(102, 126, 234, 0.10); + color: #667eea; + border: 1px solid rgba(102, 126, 234, 0.08); +} + +.btn-primary:hover { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-color: transparent; + transform: translateY(-2px); + box-shadow: 0 5px 14px rgba(102, 126, 234, 0.35); +} + +.btn-cancel { + background: rgba(100, 116, 139, 0.08); + color: #64748b; + border: 1px solid rgba(100, 116, 139, 0.12); +} + +.btn-cancel:hover { + background: #f1f5f9; + color: #475569; + transform: translateY(-1px); +} + +.btn-icon { + width: 38px; + height: 38px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + background: rgba(100, 116, 139, 0.06); + color: #64748b; + border: 1px solid rgba(100, 116, 139, 0.08); + cursor: pointer; + transition: all 0.3s ease; + text-decoration: none; + font-size: 16px; +} + +.btn-icon:hover { + background: rgba(102, 126, 234, 0.1); + color: #667eea; + border-color: rgba(102, 126, 234, 0.15); + transform: translateY(-1px); +} + +/* ============================================================================ + 主内容区 + ============================================================================ */ +.main-container { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; +} + +/* ============================================================================ + 表格容器 — 毛玻璃卡片 + ============================================================================ */ +.table-container { + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(20px); + border-radius: 12px; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.06); + border: 1px solid rgba(255, 255, 255, 0.2); + overflow: hidden; +} + +.table-header { + padding: 1.25rem 1.75rem 1rem; + background: rgba(255, 255, 255, 0.3); + border-bottom: 1px solid rgba(0, 0, 0, 0.04); +} + +.table-title { + font-size: 16px; + font-weight: 700; + color: #1e293b; + display: flex; + align-items: center; + gap: 8px; + margin: 0; +} + +.table-title i { + font-size: 16px; + color: #667eea; + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(102, 126, 234, 0.08); + border-radius: 8px; +} + +.table-scroll { + overflow-x: auto; +} + +/* ============================================================================ + 数据表格 + ============================================================================ */ +.data-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.data-table th, +.data-table td { + padding: 0.85rem 1.2rem; + text-align: left; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + font-size: 13px; +} + +.data-table th { + background: rgba(248, 250, 252, 0.85); + color: #475569; + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.3px; + white-space: nowrap; + position: sticky; + top: 0; + z-index: 5; +} + +.data-table tbody tr { + transition: background 0.15s ease; +} + +.data-table tbody tr:hover { + background: rgba(102, 126, 234, 0.035); +} + +/* 列宽 */ +.col-num { + width: 50px; + text-align: center; + color: #64748b; + font-weight: 600; +} + +.data-table th:nth-child(2) { width: 14%; min-width: 150px; } /* 邮箱 */ +.data-table th:nth-child(3) { width: 6%; min-width: 70px; } /* 密码 */ +.data-table th:nth-child(4) { width: 8%; min-width: 90px; } /* 客户ID */ +.data-table th:nth-child(5) { width: 10%; min-width: 110px; } /* 令牌 */ +.data-table th:nth-child(6) { width: 65px; } /* 支付状态 */ +.data-table th:nth-child(7) { width: 65px; } /* 退款状态 */ +.data-table th:nth-child(8) { width: 85px; } /* 支付时间 */ +.data-table th:nth-child(9) { width: 85px; } /* 退款时间 */ +.data-table th:nth-child(10) { width: 85px; } /* 封号时间 */ +.data-table th:nth-child(11) { width: 120px; } /* 备注/卡号 */ +.data-table th:nth-child(12) { width: 150px; } /* 代理 */ +.data-table th:nth-child(13) { width: 190px; } /* 操作 */ + +/* 单元格内容 */ +.value-container { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.value-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #334155; + flex: 1; + min-width: 0; +} + +.value-secret { + color: #94a3b8; + font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + font-size: 12px; +} + +/* 复制按钮 */ +.copy-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: transparent; + color: #94a3b8; + cursor: pointer; + border-radius: 6px; + font-size: 12px; + transition: all 0.2s ease; + flex-shrink: 0; + padding: 0; +} + +.copy-icon-btn:hover { + background: rgba(102, 126, 234, 0.1); + color: #667eea; + transform: scale(1.1); +} + +/* 操作按钮 */ +.actions { + display: flex; + gap: 6px; + flex-wrap: nowrap; +} + +.actions button { + padding: 5px 10px; + border: none; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + white-space: nowrap; +} + +.actions button.view { + background: rgba(102, 126, 234, 0.1); + color: #667eea; + border: 1px solid rgba(102, 126, 234, 0.08); +} + +.actions button.view:hover { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-color: transparent; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25); +} + +.actions button.delete { + background: rgba(245, 87, 108, 0.08); + color: #f5576c; + border: 1px solid rgba(245, 87, 108, 0.08); +} + +.actions button.delete:hover { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + color: white; + border-color: transparent; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(245, 87, 108, 0.25); +} + +/* 空数据状态 */ +.no-data { + text-align: center; + padding: 4rem 2rem; + color: #9ca3af; + font-size: 15px; + font-weight: 500; +} + +.no-data i { + font-size: 56px; + color: #e5e7eb; + margin-bottom: 1rem; + display: block; + opacity: 0.7; +} + +/* 表格加载状态 */ +.table-loading { + text-align: center; + padding: 3rem 2rem; + color: #667eea; + font-size: 14px; + font-weight: 500; +} + +.table-loading .spinner-border { + color: #667eea; + margin-right: 8px; +} + +/* ============================================================================ + 分页控件 + ============================================================================ */ +.pagination-container { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 15px; + padding: 15px 20px; + background: rgba(255, 255, 255, 0.6); + border-top: 1px solid rgba(0, 0, 0, 0.04); +} + +.pagination-info { + font-size: 13px; + color: #64748b; + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; + white-space: nowrap; +} + +.pagination-info .divider { + color: #cbd5e1; +} + +.pagination-controls { + display: flex; + align-items: center; + gap: 4px; +} + +.pagination-btn { + min-width: 32px; + height: 32px; + padding: 0 8px; + border: 1px solid rgba(226, 232, 240, 0.8); + background: white; + color: #64748b; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + align-items: center; + justify-content: center; + outline: none; +} + +.pagination-btn:hover:not(:disabled):not(.active) { + border-color: #667eea; + color: #667eea; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15); +} + +.pagination-btn.active { + background: rgba(102, 126, 234, 0.12); + color: #667eea; + border: 1px solid rgba(102, 126, 234, 0.15); + font-weight: 700; +} + +.pagination-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + background: #f8fafc; +} + +/* ============================================================================ + 视图2:邮件查看器 — 当前邮箱信息 + ============================================================================ */ +.current-email-info { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; + justify-content: center; +} + +.current-email-info .info-icon { + width: 36px; + height: 36px; + background: rgba(102, 126, 234, 0.08); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + color: #667eea; + font-size: 16px; + flex-shrink: 0; +} + +.current-email-info .info-text { + min-width: 0; +} + +.current-email-info .info-label { + font-size: 11px; + color: #94a3b8; + font-weight: 500; +} + +.current-email-info .info-value { + font-size: 14px; + font-weight: 600; + color: #334155; + display: flex; + align-items: center; + gap: 8px; +} + +.folder-badge { + display: inline-flex; + padding: 2px 10px; + background: rgba(102, 126, 234, 0.1); + color: #667eea; + border-radius: 12px; + font-size: 12px; + font-weight: 600; +} + +/* ============================================================================ + 视图2:双栏邮件布局 + ============================================================================ */ +.email-main { + display: flex; + height: calc(100vh - 60px); + overflow: hidden; +} + +.email-list-panel { + width: 360px; + min-width: 280px; + background: rgba(255, 255, 255, 0.95); + border-right: 1px solid rgba(0, 0, 0, 0.06); + display: flex; + flex-direction: column; +} + +.email-list-header { + padding: 14px 18px; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + background: rgba(248, 250, 252, 0.8); + font-size: 14px; + font-weight: 600; + color: #334155; + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.email-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 7px; + border-radius: 11px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + font-size: 11px; + font-weight: 700; +} + +.email-list-content { + flex: 1; + overflow-y: auto; +} + +/* 邮件列表项 */ +.mail-item { + padding: 14px 18px; + border-bottom: 1px solid rgba(0, 0, 0, 0.04); + cursor: pointer; + border-left: 3px solid transparent; + transition: all 0.15s ease; + animation: fadeIn 0.2s ease; +} + +.mail-item:hover { + background: rgba(102, 126, 234, 0.04); + border-left-color: #667eea; +} + +.mail-item.selected { + background: rgba(102, 126, 234, 0.08); + border-left-color: #667eea; +} + +.mail-item-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 4px; +} + +.mail-sender { + font-weight: 600; + color: #334155; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.mail-time { + color: #94a3b8; + font-size: 11px; + flex-shrink: 0; + margin-left: 8px; +} + +.mail-subject { + color: #475569; + font-size: 13px; + margin-bottom: 3px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +.mail-preview { + color: #94a3b8; + font-size: 12px; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* 右栏:邮件详情 */ +.email-detail-panel { + flex: 1; + background: white; + display: flex; + flex-direction: column; + min-width: 0; +} + +.detail-content { + flex: 1; + overflow-y: auto; +} + +.email-detail-wrapper { padding: 28px; } + +.email-detail-header { + margin-bottom: 24px; + padding-bottom: 18px; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); +} + +.email-detail-subject { + font-size: 22px; + font-weight: 700; + color: #1e293b; + margin-bottom: 16px; + line-height: 1.3; +} + +.email-detail-meta { + display: flex; + flex-direction: column; + gap: 8px; +} + +.meta-row { + display: flex; + align-items: center; + font-size: 13px; +} + +.meta-label { + color: #94a3b8; + font-weight: 600; + min-width: 55px; + margin-right: 12px; +} + +.meta-value { color: #475569; } + +.meta-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 10px; + background: rgba(102, 126, 234, 0.08); + color: #667eea; + border-radius: 12px; + font-size: 12px; + margin-left: 8px; + font-weight: 500; +} + +.email-body-container { margin-top: 20px; } + +.email-iframe { + width: 100%; + min-height: 300px; + border: 1px solid rgba(0, 0, 0, 0.06); + border-radius: 8px; + background: white; + display: block; +} + +.email-body-text { + font-size: 14px; + line-height: 1.7; + color: #334155; + white-space: pre-wrap; + word-wrap: break-word; + padding: 20px; + background: rgba(248, 250, 252, 0.6); + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.06); +} + +/* ============================================================================ + 空状态 + ============================================================================ */ +.empty-state, .empty-detail { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + padding: 40px 20px; + color: #94a3b8; +} + +.empty-state i, .empty-detail i { + font-size: 56px; + margin-bottom: 16px; + opacity: 0.3; + color: #cbd5e1; +} + +.empty-state p { font-size: 14px; margin: 0; } +.empty-detail h6 { color: #64748b; margin-bottom: 6px; font-size: 15px; font-weight: 600; } +.empty-detail p { font-size: 13px; margin: 0; } + +.loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 200px; + gap: 12px; +} + +.loading-state .spinner-border { color: #667eea; } +.loading-state p { color: #667eea; font-weight: 500; font-size: 14px; margin: 0; } + +/* ============================================================================ + 导入模态框 — 毛玻璃 + ============================================================================ */ +.paste-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + justify-content: center; + align-items: center; + z-index: 9999; +} + +.paste-modal.show { + display: flex; +} + +.paste-modal-content { + background: rgba(255, 255, 255, 0.97); + backdrop-filter: blur(20px); + padding: 2rem; + border-radius: 16px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.15); + max-width: 600px; + width: 90%; + border: 1px solid rgba(255, 255, 255, 0.3); + animation: fadeInUp 0.3s ease; +} + +.paste-modal-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 1.5rem; +} + +.paste-modal-icon { + width: 44px; + height: 44px; + background: linear-gradient(135deg, rgba(102, 126, 234, 0.1), rgba(118, 75, 162, 0.08)); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + color: #667eea; + font-size: 18px; + flex-shrink: 0; +} + +.paste-modal-title { + font-size: 18px; + font-weight: 700; + color: #1e293b; + margin: 0; +} + +.paste-modal-subtitle { + font-size: 13px; + color: #94a3b8; + margin: 2px 0 0; +} + +.paste-textarea { + width: 100%; + min-height: 200px; + padding: 14px; + border: 2px solid #e5e7eb; + border-radius: 10px; + font-size: 13px; + font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + color: #334155; + background: rgba(255, 255, 255, 0.8); + outline: none; + transition: all 0.3s ease; + resize: vertical; + line-height: 1.6; +} + +.paste-textarea:focus { + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); + background: white; +} + +.paste-textarea::placeholder { + color: #b0b8c4; + font-size: 12px; +} + +.paste-modal-hint { + display: flex; + align-items: center; + gap: 8px; + margin-top: 0.75rem; + color: #94a3b8; + font-size: 12px; +} + +.paste-modal-hint code { + background: rgba(102, 126, 234, 0.08); + color: #667eea; + padding: 1px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; +} + +.merge-mode-section { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(0, 0, 0, 0.06); +} + +.input-label { + font-size: 12px; + font-weight: 600; + color: #475569; + margin-bottom: 6px; + display: block; +} + +.merge-options { + display: flex; + gap: 16px; +} + +.merge-option { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: #475569; + cursor: pointer; +} + +.merge-option input[type="radio"] { + accent-color: #667eea; +} + +.paste-modal-buttons { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 1.5rem; +} + +/* ============================================================================ + Toast 提示 + ============================================================================ */ +.toast-container { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 99999; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.app-toast { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + color: white; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + transform: translateY(-20px); + opacity: 0; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + white-space: nowrap; +} + +.app-toast.show { + transform: translateY(0); + opacity: 1; +} + +.app-toast i { font-size: 16px; flex-shrink: 0; } + +.toast-success { background: linear-gradient(135deg, #10b981 0%, #059669 100%); } +.toast-danger { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); } +.toast-warning { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); } +.toast-info { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); } + +/* ============================================================================ + Loading 遮罩 + ============================================================================ */ +.loading-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(8px); + justify-content: center; + align-items: center; + z-index: 99999; +} + +.loading-overlay.show { + display: flex; +} + +.loading-spinner { + width: 44px; + height: 44px; + border: 3px solid rgba(102, 126, 234, 0.2); + border-top: 3px solid #667eea; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +/* ============================================================================ + 移动端遮罩 + ============================================================================ */ +.mobile-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + z-index: 999; + display: none; + opacity: 0; + transition: opacity 0.3s; +} + +.mobile-overlay.show { + display: block; + opacity: 1; +} + +/* ============================================================================ + 滚动条 + ============================================================================ */ +.email-list-content::-webkit-scrollbar, +.detail-content::-webkit-scrollbar, +.table-scroll::-webkit-scrollbar { + width: 5px; +} + +.email-list-content::-webkit-scrollbar-track, +.detail-content::-webkit-scrollbar-track, +.table-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.email-list-content::-webkit-scrollbar-thumb, +.detail-content::-webkit-scrollbar-thumb, +.table-scroll::-webkit-scrollbar-thumb { + background: #ddd; + border-radius: 3px; +} + +.email-list-content::-webkit-scrollbar-thumb:hover, +.detail-content::-webkit-scrollbar-thumb:hover, +.table-scroll::-webkit-scrollbar-thumb:hover { + background: #bbb; +} + +/* ============================================================================ + 动画 + ============================================================================ */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* ============================================================================ + 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; } +.claude-suspended { background: rgba(220, 38, 38, 0.12); color: #dc2626; } + +.claude-time { + font-size: 12px; + color: #64748b; + white-space: nowrap; +} + +/* 备注/卡号单元格 */ +.note-cell-btn { + padding: 3px 8px; + border: 1px solid #e5e7eb; + border-radius: 6px; + font-size: 11px; + color: #94a3b8; + background: transparent; + cursor: pointer; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 4px; +} +.note-cell-btn:hover { + border-color: #667eea; + color: #667eea; + background: rgba(102, 126, 234, 0.05); +} +.note-cell-display { + cursor: pointer; + padding: 2px 0; + border-radius: 4px; + transition: background 0.15s; +} +.note-cell-display:hover { + background: rgba(102, 126, 234, 0.05); +} +.note-title { + font-size: 12px; + font-weight: 600; + color: #334155; + line-height: 1.3; +} +.note-card { + font-size: 11px; + color: #94a3b8; + display: flex; + align-items: center; + gap: 3px; + margin-top: 1px; +} +.note-card i { font-size: 10px; } + +.note-fields { + display: flex; + flex-direction: column; + gap: 12px; +} +.note-field-group { + display: flex; + flex-direction: column; +} +.note-field-group .input-label { + margin-bottom: 4px; +} + +.proxy-btn { + padding: 3px 8px; + border: 1px solid #e5e7eb; + border-radius: 6px; + font-size: 11px; + color: #64748b; + background: transparent; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 4px; +} +.proxy-btn:hover { + border-color: #667eea; + color: #667eea; + background: rgba(102, 126, 234, 0.05); +} +.proxy-btn.has-proxy { + border-color: rgba(16, 185, 129, 0.3); + color: #059669; + background: rgba(16, 185, 129, 0.05); +} +.proxy-btn.has-proxy:hover { + background: rgba(16, 185, 129, 0.1); +} + +.proxy-input { + width: 100%; + padding: 10px 14px; + border: 2px solid #e5e7eb; + border-radius: 8px; + font-size: 13px; + font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + color: #334155; + outline: none; + transition: all 0.3s ease; +} +.proxy-input:focus { + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.proxy-format-section { + margin-bottom: 12px; +} +.proxy-format-options { + display: flex; + gap: 16px; +} +.proxy-fields { + display: flex; + flex-direction: column; + gap: 10px; +} +.proxy-row { + display: flex; + gap: 10px; +} +.proxy-field { + flex: 1; +} +.proxy-field-sm { + flex: 0 0 100px; +} +.proxy-field .input-label { + margin-bottom: 4px; +} +.proxy-raw-section { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(0, 0, 0, 0.06); +} +.proxy-raw-section .input-label { + margin-bottom: 4px; +} +.proxy-preview { + margin-top: 10px; + min-height: 20px; +} +.proxy-preview-label { + font-size: 12px; + color: #64748b; + font-weight: 600; +} +.proxy-preview-value { + display: inline-block; + margin-left: 6px; + padding: 2px 8px; + background: rgba(102, 126, 234, 0.08); + color: #667eea; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + word-break: break-all; +} +.proxy-cell { + vertical-align: middle; +} +.proxy-cell-inner { + display: flex; + align-items: center; + gap: 4px; +} +.proxy-expire-info { + display: flex; + align-items: center; + gap: 4px; + margin-top: 3px; +} +.proxy-expire-badge { + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 8px; +} +.proxy-expire-ok { background: rgba(16, 185, 129, 0.1); color: #059669; } +.proxy-expire-warn { background: rgba(245, 158, 11, 0.12); color: #d97706; } +.proxy-expire-dead { background: rgba(220, 38, 38, 0.1); color: #dc2626; } +.proxy-share-tag { + font-size: 10px; + color: #94a3b8; + font-weight: 500; +} +.proxy-extra-section { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(0, 0, 0, 0.06); +} +.proxy-expire-options, +.proxy-share-options { + display: flex; + align-items: center; + gap: 12px; +} +.proxy-input-mini { + width: 60px; + padding: 4px 8px; + font-size: 12px; + height: 30px; +} + +/* ============================================================================ + 桌面端 + ============================================================================ */ +@media (min-width: 769px) { + .email-list-panel { + display: flex !important; + } + + #mobileMailToggle { + display: none !important; + } +} + +/* ============================================================================ + 移动端响应式 + ============================================================================ */ +@media (max-width: 768px) { + .navbar-container { + padding: 0 1rem; + height: 56px; + gap: 8px; + } + + .logo-text { display: none; } + + .nav-actions { + gap: 4px; + } + + .nav-actions .btn span { display: none; } + .nav-actions .btn { padding: 8px 10px; min-width: auto; } + + .search-box { width: 140px; } + .search-box input { font-size: 12px; padding-left: 30px; } + + .main-container { padding: 1rem; } + + /* 隐藏部分列 */ + .col-hide-mobile { display: none; } + + .data-table th, .data-table td { + padding: 0.65rem 0.6rem; + font-size: 12px; + } + + .col-num { width: 36px; } + + .actions button { padding: 4px 8px; font-size: 11px; } + + /* 邮件列表面板 — 抽屉 */ + .email-list-panel { + position: fixed; + top: 0; + left: -320px; + width: 320px; + height: 100vh; + z-index: 1000; + transition: left 0.3s ease; + box-shadow: 2px 0 16px rgba(0, 0, 0, 0.15); + } + + .email-list-panel.mobile-open { left: 0; } + + .email-detail-panel { width: 100%; } + + .email-detail-wrapper { padding: 16px; } + .email-detail-subject { font-size: 18px; } + + .email-main { height: calc(100vh - 56px); } + + /* 当前邮箱信息 */ + .current-email-info .info-label { display: none; } + .current-email-info .info-value { font-size: 12px; } + .current-email-info .info-icon { width: 30px; height: 30px; font-size: 14px; } + + /* 分页 */ + .pagination-container { + flex-direction: column; + gap: 10px; + padding: 12px; + } + + .pagination-info { width: 100%; justify-content: center; } + .pagination-controls { width: 100%; justify-content: center; } +} + +@media (max-width: 480px) { + .email-list-panel { width: 280px; left: -280px; } + .search-box { width: 100px; } + + .actions button { padding: 3px 6px; font-size: 10px; } +}