diff --git a/.env.example b/.env.example index 19f9d08..c0073a8 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,9 @@ PORT=5000 HOST=0.0.0.0 FLASK_ENV=production +# 是否自动启动后台调度器(定时刷新/心跳)。生产建议 true。 +SCHEDULER_AUTOSTART=true + # 数据库路径 DATABASE_PATH=data/outlook_accounts.db diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..9e350e8 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,48 @@ +name: Python Tests + +on: + push: + branches: [ "main", "master" ] + paths: + - "**.py" + - "requirements.txt" + - "tests/**" + - ".github/workflows/python-tests.yml" + pull_request: + paths: + - "**.py" + - "requirements.txt" + - "tests/**" + - ".github/workflows/python-tests.yml" + workflow_dispatch: + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [ "3.11", "3.12", "3.13" ] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Syntax check + run: | + python -m py_compile web_outlook_app.py + python -m py_compile outlook_mail_reader.py + + - name: Run unit tests + run: python -m unittest discover -s tests -v + diff --git a/.gitignore b/.gitignore index e7485ae..a184b8d 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,8 @@ data/ Thumbs.db /get_oauth_token.py /test* +!/tests/ +!/tests/** .ace-tool/ /CLAUDE.md /.claude/ diff --git a/.kiro/steering/architecture.md b/.kiro/steering/architecture.md new file mode 100644 index 0000000..a6dee34 --- /dev/null +++ b/.kiro/steering/architecture.md @@ -0,0 +1,299 @@ +# 架构设计 + +## 整体架构 + +### 架构模式 +采用经典的三层架构模式: + +``` +┌─────────────────────────────────────────┐ +│ Presentation Layer │ +│ (Routes / Templates / Static) │ +└─────────────────┬───────────────────────┘ + │ +┌─────────────────▼───────────────────────┐ +│ Business Layer │ +│ (Services) │ +└─────────────────┬───────────────────────┘ + │ +┌─────────────────▼───────────────────────┐ +│ Data Access Layer │ +│ (Repositories) │ +└─────────────────┬───────────────────────┘ + │ +┌─────────────────▼───────────────────────┐ +│ Database Layer │ +│ (SQLite) │ +└─────────────────────────────────────────┘ +``` + +## 目录结构详解 + +### outlook_web/ 主模块 + +#### 核心文件 +- `app.py` - Flask 应用工厂,负责应用初始化和 Blueprint 注册 +- `config.py` - 配置管理,环境变量读取 +- `db.py` - 数据库连接管理,Schema 初始化和升级 +- `legacy.py` - 旧版实现,正在迁移中 (约 5000 行) +- `audit.py` - 审计日志记录 +- `errors.py` - 错误处理和 trace_id 生成 + +#### routes/ 路由层 +Blueprint 模块化路由,每个文件对应一个功能模块: + +- `pages.py` - 页面路由 (首页/登录页) +- `accounts.py` - 邮箱账号 CRUD +- `groups.py` - 分组 CRUD +- `tags.py` - 标签 CRUD +- `emails.py` - 邮件查看/删除 +- `temp_emails.py` - 临时邮箱管理 +- `oauth.py` - OAuth2 授权流程 +- `settings.py` - 系统设置 +- `scheduler.py` - 定时任务管理 +- `system.py` - 系统操作 (导出/密码修改) +- `audit.py` - 审计日志查询 + +#### services/ 服务层 +业务逻辑实现,封装外部 API 调用: + +- `graph.py` - Microsoft Graph API 封装 + - 获取邮件列表 + - 获取邮件详情 + - 刷新 Token + - 删除邮件 +- `imap.py` - IMAP 协议封装 + - 新版服务器 (outlook.live.com) + - 旧版服务器 (outlook.office365.com) + - 邮件列表/详情获取 +- `refresh.py` - Token 刷新服务 + - 单账号刷新 + - 批量刷新 + - 定时刷新调度 +- `email_delete.py` - 邮件删除服务 + - 多 API 回退策略 +- `gptmail.py` - GPTMail API 封装 +- `http.py` - HTTP 客户端封装 (支持代理) + +#### repositories/ 数据访问层 +数据库 CRUD 操作封装: + +- `accounts.py` - 邮箱账号数据访问 + - 增删改查 + - 批量操作 + - 状态更新 +- `groups.py` - 分组数据访问 +- `tags.py` - 标签数据访问 +- `settings.py` - 系统设置数据访问 +- `temp_emails.py` - 临时邮箱数据访问 +- `refresh_logs.py` - 刷新日志数据访问 +- `refresh_runs.py` - 刷新运行记录数据访问 +- `distributed_locks.py` - 分布式锁实现 + +#### security/ 安全模块 +安全相关功能封装: + +- `auth.py` - 认证授权 + - 登录验证 + - 密码哈希 + - 会话管理 + - 登录限流 +- `crypto.py` - 加密解密 + - Fernet 对称加密 + - Token 加密存储 + - 密钥派生 +- `csrf.py` - CSRF 防护 + - Token 生成和验证 + - 装饰器封装 + +## 数据流 + +### 典型请求流程 + +``` +1. 用户请求 + ↓ +2. Route (Blueprint) + - 参数验证 + - CSRF 验证 + - 认证检查 + ↓ +3. Service + - 业务逻辑 + - 外部 API 调用 + - 数据处理 + ↓ +4. Repository + - 数据库操作 + - 数据转换 + ↓ +5. Database + - SQL 执行 + ↓ +6. 响应返回 + - JSON / HTML + - 错误处理 +``` + +### 示例: 获取邮件列表 + +```python +# 1. Route (routes/emails.py) +@bp.route("/api/emails") +def get_emails(): + account_id = request.args.get("account_id") + folder = request.args.get("folder", "inbox") + + # 2. Service (services/graph.py or imap.py) + emails = email_service.get_emails(account_id, folder) + + return jsonify(emails) + +# 3. Service 内部 +def get_emails(account_id, folder): + # 从 Repository 获取账号信息 + account = account_repo.get_by_id(account_id) + + # 尝试 Graph API + try: + return graph_api.get_emails(account, folder) + except: + # 回退到 IMAP + return imap_api.get_emails(account, folder) +``` + +## 设计模式 + +### 1. 工厂模式 +`create_app()` 应用工厂函数,便于测试和多实例创建。 + +### 2. 仓储模式 +Repository 层封装数据访问,业务逻辑与数据库解耦。 + +### 3. 策略模式 +邮件访问采用多策略回退 (Graph API → IMAP 新 → IMAP 旧)。 + +### 4. 装饰器模式 +- `@login_required` - 登录验证 +- `@csrf_exempt` - CSRF 豁免 +- `@rate_limit` - 速率限制 + +### 5. 单例模式 +数据库连接绑定到 Flask `g` 对象,请求内单例。 + +## 安全架构 + +### 多层防护 + +``` +┌─────────────────────────────────────────┐ +│ Input Validation │ 参数验证 +└─────────────────┬───────────────────────┘ + │ +┌─────────────────▼───────────────────────┐ +│ CSRF Protection │ CSRF Token +└─────────────────┬───────────────────────┘ + │ +┌─────────────────▼───────────────────────┐ +│ Authentication & Rate Limit │ 登录验证/限流 +└─────────────────┬───────────────────────┘ + │ +┌─────────────────▼───────────────────────┐ +│ Business Logic │ 业务逻辑 +└─────────────────┬───────────────────────┘ + │ +┌─────────────────▼───────────────────────┐ +│ Data Encryption │ 数据加密 +└─────────────────┬───────────────────────┘ + │ +┌─────────────────▼───────────────────────┐ +│ Audit Logging │ 审计日志 +└─────────────────────────────────────────┘ +``` + +### XSS 防护 +1. **前端**: DOMPurify 净化 HTML +2. **iframe 沙箱**: 禁止脚本执行 +3. **输出转义**: Jinja2 自动转义 + +### CSRF 防护 +1. **Token 验证**: 所有状态变更操作 +2. **SameSite Cookie**: Lax 模式 +3. **Referer 检查**: 可选 + +### 数据加密 +1. **Refresh Token**: Fernet 对称加密 +2. **密码**: bcrypt 哈希 +3. **密钥派生**: 基于 SECRET_KEY + +## 并发处理 + +### 数据库并发 +- SQLite WAL 模式 +- 连接池管理 (Flask g 对象) +- 分布式锁 (distributed_locks 表) + +### 定时任务 +- APScheduler 后台调度 +- 单实例运行 (避免重复执行) +- 任务状态持久化 + +## 缓存策略 + +### 邮件列表缓存 +- 前端内存缓存 +- 切换邮箱/文件夹即时展示 +- 刷新页面清空缓存 + +### Token 缓存 +- 数据库持久化 +- 90 天有效期 +- 自动刷新机制 + +## 错误处理 + +### 统一错误响应 +```json +{ + "error": "错误信息", + "trace_id": "唯一追踪 ID", + "details": "详细错误信息" +} +``` + +### 错误级别 +1. **HTTP 异常**: 4xx/5xx 状态码 +2. **业务异常**: 自定义错误类 +3. **系统异常**: 未捕获异常 + +### 错误追踪 +- 每个请求生成唯一 trace_id +- 记录到审计日志 +- 便于问题排查 + +## 扩展性设计 + +### 水平扩展 +- 无状态设计 (会话存储在 Cookie) +- 数据库可迁移到 PostgreSQL/MySQL +- 支持负载均衡 + +### 垂直扩展 +- 模块化设计,易于拆分微服务 +- Blueprint 可独立部署 +- 服务层可抽取为独立服务 + +## 迁移策略 + +### 当前状态 +- `legacy.py` 包含旧版实现 +- 新架构逐步替换旧实现 +- 保持 API 兼容性 + +### 迁移步骤 +1. ✅ 创建新的分层结构 +2. ✅ Blueprint 模块化路由 +3. 🔄 逐步迁移业务逻辑到 Services +4. 🔄 数据访问迁移到 Repositories +5. ⏳ 移除 legacy.py +6. ⏳ 前后端完全分离 diff --git a/.kiro/steering/project-overview.md b/.kiro/steering/project-overview.md new file mode 100644 index 0000000..ab3f5df --- /dev/null +++ b/.kiro/steering/project-overview.md @@ -0,0 +1,184 @@ +# 项目概览 + +## 项目名称 +Outlook 邮件管理工具 (Outlook Email Management Tool) + +## 项目类型 +Web 应用 - Flask 后端 + 传统前端 + +## 技术栈 + +### 后端框架 +- **Flask 3.0+** - Web 框架 +- **Flask-WTF 1.2+** - CSRF 保护 +- **Werkzeug 3.0+** - WSGI 工具库 + +### 数据存储 +- **SQLite** - 轻量级关系数据库 +- 数据库路径: `data/outlook_accounts.db` +- Schema 版本: v2 + +### 核心依赖 +- **requests[socks] 2.25+** - HTTP 客户端,支持 SOCKS 代理 +- **APScheduler 3.10+** - 定时任务调度 +- **croniter 1.3+** - Cron 表达式解析 +- **bcrypt 4.0+** - 密码哈希 +- **cryptography 41.0+** - 数据加密 (Fernet 对称加密) + +### 外部 API +- **Microsoft Graph API** - 主要邮件访问方式 +- **IMAP 协议** - 备用邮件访问方式 (新版/旧版服务器) +- **GPTMail API** - 临时邮箱服务 + +## 项目结构 + +``` +outlookEmail/ +├── web_outlook_app.py # 应用入口 (兼容层) +├── outlook_mail_reader.py # 命令行邮件读取工具 +├── outlook_web/ # 主应用模块 +│ ├── app.py # Flask 应用工厂 +│ ├── config.py # 配置管理 +│ ├── db.py # 数据库操作 +│ ├── legacy.py # 旧版实现 (迁移中) +│ ├── audit.py # 审计日志 +│ ├── errors.py # 错误处理 +│ ├── routes/ # 路由层 (Blueprint) +│ ├── services/ # 服务层 +│ ├── repositories/ # 数据访问层 +│ └── security/ # 安全模块 +├── templates/ # HTML 模板 +├── static/ # 静态资源 +├── tests/ # 测试 +├── docs/ # 文档 +└── data/ # 数据目录 +``` + +## 核心功能模块 + +### 1. 邮箱账号管理 +- 多邮箱账号管理 +- 分组管理 (支持自定义颜色) +- 标签管理 (批量打标签/筛选) +- 批量导入/导出 +- 分组级别代理配置 + +### 2. 邮件操作 +- 查看邮件 (收件箱/垃圾邮件/已删除) +- 删除邮件 (单封/批量) +- 全屏查看模式 +- 分页加载 (每页 20 封) +- 邮件缓存机制 + +### 3. Token 管理 +- OAuth2 授权流程 +- 自动刷新 Token +- 定时刷新 (按天数/Cron 表达式) +- 刷新历史记录 +- 失败状态标识 + +### 4. 临时邮箱 +- 集成 GPTMail API +- 一键生成临时邮箱 + +### 5. 安全特性 +- XSS 防护 (DOMPurify + iframe 沙箱) +- CSRF 防护 (Flask-WTF) +- 数据加密 (Fernet 对称加密) +- 登录限流 (5 次失败锁定 15 分钟) +- 审计日志 +- 二次验证 (导出功能) + +## 架构特点 + +### 分层架构 +``` +Routes (路由层) + ↓ +Services (服务层) + ↓ +Repositories (数据访问层) + ↓ +Database (SQLite) +``` + +### Blueprint 模块化 +- `pages` - 页面路由 +- `accounts` - 邮箱账号管理 +- `groups` - 分组管理 +- `tags` - 标签管理 +- `emails` - 邮件操作 +- `temp_emails` - 临时邮箱 +- `oauth` - OAuth2 授权 +- `settings` - 系统设置 +- `scheduler` - 定时任务 +- `system` - 系统操作 +- `audit` - 审计日志 + +### 应用工厂模式 +使用 `create_app()` 工厂函数创建 Flask 应用实例,便于测试和配置管理。 + +### 迁移中架构 +- `legacy.py` 包含旧版实现 (约 20 万字节) +- 正在逐步迁移到新的分层架构 +- 保持向后兼容性 + +## 数据模型 + +### 核心表 +- `accounts` - 邮箱账号 +- `groups` - 分组 +- `tags` - 标签 +- `account_tags` - 账号标签关联 +- `settings` - 系统设置 +- `temp_emails` - 临时邮箱 +- `refresh_logs` - Token 刷新日志 +- `refresh_runs` - 刷新运行记录 +- `distributed_locks` - 分布式锁 + +## 部署方式 + +### 开发环境 +```bash +python web_outlook_app.py +``` + +### 生产环境 +- Docker 部署 (提供 Dockerfile) +- WSGI 服务器 (Gunicorn/uWSGI) +- 环境变量配置 + +### 环境变量 +- `PORT` - 端口号 (默认 5000) +- `HOST` - 监听地址 (默认 0.0.0.0) +- `FLASK_ENV` - 运行模式 (production/development) +- `SECRET_KEY` - Flask 密钥 +- `DATABASE_PATH` - 数据库路径 + +## API 优先级策略 + +邮件操作采用三级回退机制: +1. **Graph API** (主要方式) +2. **IMAP 新版** (outlook.live.com) +3. **IMAP 旧版** (outlook.office365.com) + +失败时自动回退到下一级,并提供详细错误信息。 + +## 当前状态 + +### 已完成 +- ✅ 核心功能实现 +- ✅ 安全特性完善 +- ✅ 分组代理支持 +- ✅ API 优先级回退 +- ✅ 详细错误提示 + +### 进行中 +- 🔄 架构迁移 (legacy.py → 分层架构) +- 🔄 前后端分离准备 + +### 待优化 +- ⏳ 完全移除 legacy.py +- ⏳ 前后端完全分离 +- ⏳ API 文档完善 +- ⏳ 单元测试覆盖率提升 diff --git a/.kiro/steering/tech-stack.md b/.kiro/steering/tech-stack.md new file mode 100644 index 0000000..0d7f31c --- /dev/null +++ b/.kiro/steering/tech-stack.md @@ -0,0 +1,345 @@ +# 技术栈详解 + +## 后端技术 + +### Web 框架 +- **Flask 3.0+** + - 轻量级 WSGI Web 框架 + - 灵活的扩展系统 + - 应用工厂模式支持 + - Blueprint 模块化 + +### 数据库 +- **SQLite** + - 轻量级嵌入式数据库 + - 无需独立服务器 + - 支持 WAL 模式 (并发优化) + - 适合中小规模应用 + +### 安全库 +- **Flask-WTF 1.2+** + - CSRF 保护 + - 表单验证 + - 文件上传处理 + +- **bcrypt 4.0+** + - 密码哈希算法 + - 自适应成本因子 + - 防止彩虹表攻击 + +- **cryptography 41.0+** + - Fernet 对称加密 + - 密钥派生 (PBKDF2) + - Token 加密存储 + +### HTTP 客户端 +- **requests[socks] 2.25+** + - HTTP/HTTPS 请求 + - SOCKS5 代理支持 + - 会话管理 + - 连接池 + +### 定时任务 +- **APScheduler 3.10+** + - 后台任务调度 + - Cron 表达式支持 + - 持久化任务状态 + - 多种触发器 (interval/cron/date) + +- **croniter 1.3+** + - Cron 表达式解析 + - 下次执行时间计算 + +### WSGI 服务器 +- **Werkzeug 3.0+** + - Flask 底层 WSGI 工具库 + - 开发服务器 + - 调试工具 + - 中间件支持 + +## 前端技术 + +### 基础技术 +- **HTML5** + - 语义化标签 + - 表单验证 + +- **CSS3** + - Flexbox 布局 + - 响应式设计 + - 动画效果 + +- **JavaScript (ES6+)** + - 异步请求 (Fetch API) + - 事件处理 + - DOM 操作 + +### 第三方库 +- **jQuery 3.x** + - DOM 操作简化 + - AJAX 请求 + - 事件处理 + +- **DOMPurify** + - HTML 净化 + - XSS 防护 + - 白名单过滤 + +### UI 组件 +- 自定义 CSS 样式 +- 四栏式布局 +- 响应式设计 + +## 外部 API + +### Microsoft Graph API +- **用途**: 主要邮件访问方式 +- **认证**: OAuth2 +- **功能**: + - 获取邮件列表 + - 获取邮件详情 + - 删除邮件 + - 刷新 Token + +### IMAP 协议 +- **用途**: 备用邮件访问方式 +- **服务器**: + - 新版: `outlook.live.com:993` + - 旧版: `outlook.office365.com:993` +- **认证**: 邮箱密码 +- **功能**: + - 获取邮件列表 + - 获取邮件详情 + +### GPTMail API +- **用途**: 临时邮箱服务 +- **功能**: + - 生成临时邮箱 + - 获取临时邮件 + +## 开发工具 + +### 包管理 +- **pip** + - Python 包管理器 + - requirements.txt 依赖管理 + +### 测试框架 +- **pytest** + - 单元测试 + - 集成测试 + - 覆盖率报告 + +### 代码质量 +- **类型注解** + - `from __future__ import annotations` + - 类型提示 + +### 版本控制 +- **Git** + - 分支管理 + - 提交规范 + +## 部署技术 + +### 容器化 +- **Docker** + - 容器化部署 + - 环境隔离 + - 快速部署 + +### Web 服务器 +- **Nginx** (推荐) + - 反向代理 + - 静态文件服务 + - HTTPS 配置 + +### WSGI 服务器 +- **Gunicorn** (推荐) + - 多进程模式 + - 性能优化 + - 平滑重启 + +- **uWSGI** (备选) + - 高性能 + - 多协议支持 + +## 数据库设计 + +### 表结构 + +#### accounts (邮箱账号) +```sql +CREATE TABLE accounts ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL, + password TEXT, + refresh_token TEXT, + group_id INTEGER, + status TEXT, + remark TEXT, + created_at TIMESTAMP, + updated_at TIMESTAMP +) +``` + +#### groups (分组) +```sql +CREATE TABLE groups ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + color TEXT, + proxy_type TEXT, + proxy_host TEXT, + proxy_port INTEGER, + created_at TIMESTAMP +) +``` + +#### tags (标签) +```sql +CREATE TABLE tags ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at TIMESTAMP +) +``` + +#### account_tags (账号标签关联) +```sql +CREATE TABLE account_tags ( + account_id INTEGER, + tag_id INTEGER, + PRIMARY KEY (account_id, tag_id) +) +``` + +#### settings (系统设置) +```sql +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TIMESTAMP +) +``` + +#### refresh_logs (刷新日志) +```sql +CREATE TABLE refresh_logs ( + id INTEGER PRIMARY KEY, + run_id TEXT, + account_id INTEGER, + status TEXT, + error_message TEXT, + created_at TIMESTAMP +) +``` + +#### refresh_runs (刷新运行记录) +```sql +CREATE TABLE refresh_runs ( + id TEXT PRIMARY KEY, + total_count INTEGER, + success_count INTEGER, + failed_count INTEGER, + started_at TIMESTAMP, + completed_at TIMESTAMP +) +``` + +#### distributed_locks (分布式锁) +```sql +CREATE TABLE distributed_locks ( + lock_name TEXT PRIMARY KEY, + owner TEXT, + acquired_at TIMESTAMP, + expires_at TIMESTAMP +) +``` + +#### temp_emails (临时邮箱) +```sql +CREATE TABLE temp_emails ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL, + created_at TIMESTAMP +) +``` + +## 性能优化 + +### 数据库优化 +- 索引优化 (email, group_id, status) +- 连接池管理 +- WAL 模式 (并发优化) +- 分布式锁 (避免竞态条件) + +### 缓存策略 +- 前端邮件列表缓存 +- Token 数据库持久化 +- 静态资源缓存 + +### 并发处理 +- Flask g 对象 (请求级单例) +- APScheduler 后台调度 +- 分布式锁机制 + +## 安全技术 + +### 认证授权 +- 会话管理 (Flask Session) +- 密码哈希 (bcrypt) +- OAuth2 授权流程 + +### 数据保护 +- Fernet 对称加密 +- 密钥派生 (PBKDF2) +- HTTPS 传输加密 + +### 攻击防护 +- XSS 防护 (DOMPurify + 输出转义) +- CSRF 防护 (Flask-WTF) +- SQL 注入防护 (参数化查询) +- 登录限流 (速率限制) + +### 审计追踪 +- 操作日志记录 +- trace_id 追踪 +- 敏感操作审计 + +## 监控与日志 + +### 日志系统 +- Python logging 模块 +- 分级日志 (DEBUG/INFO/WARNING/ERROR) +- 文件日志轮转 + +### 错误追踪 +- 统一 trace_id +- 错误详情记录 +- 审计日志 + +### 性能监控 +- 请求响应时间 +- 数据库查询性能 +- 定时任务执行状态 + +## 扩展性 + +### 水平扩展 +- 无状态设计 +- 数据库可迁移 (PostgreSQL/MySQL) +- 负载均衡支持 + +### 垂直扩展 +- 模块化设计 +- Blueprint 独立部署 +- 服务层可抽取为微服务 + +### 未来技术栈 +- 前后端分离 (Vue.js/React) +- RESTful API +- PostgreSQL/MySQL +- Redis 缓存 +- Celery 异步任务 +- Docker Compose 编排 diff --git a/Dockerfile b/Dockerfile index 8ec404c..c17a01c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,5 +31,9 @@ RUN mkdir -p /app/data # 暴露端口 EXPOSE 5000 +# 健康检查 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD curl -fsS http://localhost:5000/healthz || exit 1 + # 启动应用(使用 Gunicorn,单 worker 避免 session 共享问题) -CMD ["gunicorn", "-w", "1", "-b", "0.0.0.0:5000", "--timeout", "120", "--access-logfile", "-", "--preload", "web_outlook_app:app"] +# 注意:禁用 --preload,避免在 master 进程中启动后台调度线程 +CMD ["gunicorn", "-w", "1", "-b", "0.0.0.0:5000", "--timeout", "120", "--access-logfile", "-", "web_outlook_app:app"] diff --git a/README.md b/README.md index 0ac7fcf..467fedd 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,9 @@ services: - ./data:/app/data environment: - LOGIN_PASSWORD=admin123 + - SECRET_KEY=your-secret-key-here - FLASK_ENV=production + - SCHEDULER_AUTOSTART=true restart: unless-stopped ``` @@ -218,6 +220,7 @@ docker-compose down | `SECRET_KEY` | Session 密钥(**必须设置**) | 无默认值,必须提供,请勿随意修改,数据库会基于这个加密,如果要改请先导出邮箱账号,改之后再重新导入账号 | | `LOGIN_PASSWORD` | 登录密码 | `admin123` | | `FLASK_ENV` | 运行环境 | `production` | +| `SCHEDULER_AUTOSTART` | 是否自动启动后台调度器(定时刷新/心跳) | `true` | | `PORT` | 应用端口 | `5000` | | `HOST` | 监听地址 | `0.0.0.0` | | `DATABASE_PATH` | 数据库路径 | `data/outlook_accounts.db` | @@ -232,6 +235,46 @@ docker-compose down python -c 'import secrets; print(secrets.token_hex(32))' ``` +## ✅ 运行验证(定时刷新/健康状态) + +- 定时刷新是否真实运行、未来触发时间预览、锁/运行中状态、最近一次定时刷新统计等:见 `docs/RUN/运行与定时刷新验证指南.md` +- 发布前回归清单与 P0 验收对照:见 `docs/QA/回归验证清单.md`、`docs/QA/P0验收对照表.md` +- 性能与容量基线(1,000 账号):见 `docs/PERF/性能与容量基线.md` + +## 🧪 自动化测试 + +```bash +pip install -r requirements.txt +python -m unittest discover -s tests -v +``` + +## 🗂️ 项目结构(模块化) + +- 入口(部署不变):`web_outlook_app:app`(`web_outlook_app.py`) +- 后端(Flask):`outlook_web/` + - `outlook_web/app.py`:`create_app()` 装配入口(DB/CSRF/错误处理/Blueprint 注册/调度器启动控制) + - `outlook_web/routes/`:按领域拆分的 Blueprint(URL 不变) + - `outlook_web/repositories/`:SQL 访问与数据映射集中 + - `outlook_web/services/`:业务编排(Graph/IMAP/GPTMail/刷新/删除回退等) + - `outlook_web/security/`:鉴权/加密/CSRF + - `outlook_web/errors.py`:统一错误结构/trace_id/默认脱敏 + - `outlook_web/legacy.py`:迁移期 view/兼容层(逐步瘦身,外部行为不变优先) +- 前端(零构建): + - `templates/index.html`:页面骨架(CSS/JS 外链) + - `static/css/main.css` + - `static/js/main.js` + `static/js/features/*.js` +- 测试:`tests/`(推荐命令:`python -m unittest discover -s tests -v`) + +更详细的开发者说明见:`docs/DEV/00002-前后端拆分-开发者指南.md`。 + +### 如何新增/调整一个 API(推荐流程) + +1. 在 `outlook_web/repositories/` 增加/调整 SQL(必要时) +2. 在 `outlook_web/services/` 编排业务逻辑(尽量保持纯业务,不直接依赖 Flask request) +3. 在 `outlook_web/legacy.py` 增加一个薄的 view 函数(迁移期),仅做鉴权/参数解析/调用 service +4. 在 `outlook_web/routes/.py` 绑定 URL(Blueprint),并确保 URL/方法/响应结构保持契约一致 +5. 补齐/更新单测:优先门禁 `tests/test_smoke_contract.py`,再补充对应边界/回归用例 + ### 数据持久化 数据库文件存储在 `./data` 目录中,通过 Docker Volume 挂载实现持久化。 diff --git a/docs/CR/CR-00001-Outlook邮件管理工具-重构升级-文档审查报告.md b/docs/CR/CR-00001-Outlook邮件管理工具-重构升级-文档审查报告.md new file mode 100644 index 0000000..b540792 --- /dev/null +++ b/docs/CR/CR-00001-Outlook邮件管理工具-重构升级-文档审查报告.md @@ -0,0 +1,161 @@ +# 文档审查报告:Outlook邮件管理工具-重构升级(00001) + +## 📄 文档信息 + +- 主审文档:`docs/TDD/Outlook邮件管理工具-重构升级TDD.md` +- 文档类型:TDD +- 文档编号:未解析到 5 位数字编号(建议统一使用:`00001`) +- 文档标题:Outlook 邮件管理工具|重构升级 TDD(技术交付清单,仅说明做什么) +- 存放路径:`docs/TDD/Outlook邮件管理工具-重构升级TDD.md` +- 关联前置文档(同主题,因缺少编号以标题关联): + - `docs/PRD/Outlook邮件管理工具-重构升级PRD.md` + - `docs/FD/Outlook邮件管理工具-重构升级FD.md` + - `docs/TODO/Outlook邮件管理工具-重构升级TODO.md` + +## 📚 规范与架构参考文件读取结果 + +- 文档规范: + - 未找到 `Rules/开发文档规范.md` + - 未找到 `Rules/项目文档规范.md` +- 项目架构/产品 Steering: + - 未找到 `.kiro/steering/structure.md` + - 未找到 `.kiro/steering/tech.md` + - 未找到 `.kiro/steering/product.md` + +> 影响:部分“格式/必备章节/架构符合性”无法严格按项目规范判定,本报告采用你提供的审查维度清单做最佳努力评估。 + +--- + +## 📊 质量评分 + +- 格式规范性:4/10 +- 内容完整性:5/10 +- 开发可行性:5/10 +- 架构符合性:3/10 +- 功能集成性:3/10 +- 整体评分:4/10 + +--- + +## ✅ 优点 + +- PRD 内容结构完整,包含背景、用户画像/故事、功能需求、非功能需求与验收清单(`docs/PRD/Outlook邮件管理工具-重构升级PRD.md`)。 +- FD/TDD/TODO 在“只说明要完成什么”的约束下,覆盖面较全,且三者之间方向一致(`docs/FD/Outlook邮件管理工具-重构升级FD.md`、`docs/TDD/Outlook邮件管理工具-重构升级TDD.md`、`docs/TODO/Outlook邮件管理工具-重构升级TODO.md`)。 +- TDD 聚焦了稳定性/一致性/可观测等关键技术交付点,并提供了发布回归检查范围(`docs/TDD/Outlook邮件管理工具-重构升级TDD.md` 3.10)。 + +--- + +## ⚠️ 需要改进项 + +### 1) 格式规范(命名/编号/类型) + +- 当前文档命名未体现“类型-编号-描述”的规则,例如: + - `docs/PRD/Outlook邮件管理工具-重构升级PRD.md` + - `docs/FD/Outlook邮件管理工具-重构升级FD.md` + - `docs/TDD/Outlook邮件管理工具-重构升级TDD.md` +- 文档编号缺失,导致无法自动关联同编号前置文档。 +- `TODO` 是否属于允许的文档类型不明确(你给出的类型集合中未包含 TODO)。 + +### 2) 内容完整性(按你提供的审查维度) + +- FD 目前以“功能清单”为主,但缺少“业务流程/交互逻辑”的明确描述,不利于测试用例与验收边界一致(`docs/FD/Outlook邮件管理工具-重构升级FD.md`)。 +- TDD 目前以“技术交付清单”为主,但缺少你定义的 TDD 必备内容(技术架构、API 设计、数据设计等),导致开发落地仍需要额外补充设计文档(`docs/TDD/Outlook邮件管理工具-重构升级TDD.md`)。 + +### 3) 可验收口径仍偏抽象 + +- PRD/FD/TDD 对“可接受时间”“性能基线”给了方向,但缺少明确的验收口径(例如用什么页面/操作测、阈值是什么)(`docs/PRD/Outlook邮件管理工具-重构升级PRD.md` 6.2;`docs/TDD/Outlook邮件管理工具-重构升级TDD.md` 3.8)。 + +--- + +## ❌ 严重问题 + +### S-001:编号缺失导致无法按规则自动关联 + +- 问题描述:无法从文件名解析出 5 位编号,无法按编号自动找到前置文档(PRD/FD/TDD 等)。 +- 影响:一致性检查流程无法自动化,后续文档增多时会迅速失控。 + +### S-002:项目文档规范与 Steering 文件缺失 + +- 问题描述:未找到 `Rules/开发文档规范.md`/`Rules/项目文档规范.md` 与 `.kiro/steering/*`。 +- 影响:无法对“章节必备项、文档类型集合、架构符合性”进行权威判定;审查标准会随人变化。 + +### S-003:TDD 定义与当前写法存在“规范冲突” + +- 你提供的审查维度要求 TDD 包含:技术架构、API 设计、数据库设计是否详细。 +- 但当前 TDD 明确写为“仅说明做什么,不描述实现过程与编码细节”(`docs/TDD/Outlook邮件管理工具-重构升级TDD.md` 1)。 +- 影响:若按你给出的审查标准执行,当前 TDD 会被判为“内容不完整”;需要你确认 TDD 的定位到底是“技术设计”还是“技术交付清单”。 + +--- + +## 🔗 前置文档一致性 + +- PRD → FD: + - 一致性:总体一致(FD 覆盖 PRD 的主要功能块:登录、分组、账号、标签、邮件、刷新、日志、导出、审计、OAuth2、临时邮箱、设置)。 + - 风险:PRD 的“成功指标/验收”在 FD 中未全部转换为可操作的验收点(更多是功能覆盖,缺少边界与异常流)。 +- FD → TDD: + - 一致性:方向一致(TDD 聚焦稳定性/一致性/错误与追踪、升级保障、可观测等,能支撑 FD 的业务功能保真)。 + - 缺口:TDD 未提供“功能 → 入口 → 接口/数据 → 日志/审计”的映射关系,导致集成完整性无法直接验收。 +- TODO 与 FD/TDD: + - 一致性:TODO 基本覆盖 FD/TDD 的关键点,并按 P0/P1 分级(`docs/TODO/Outlook邮件管理工具-重构升级TODO.md`)。 + +--- + +## 🔗 功能集成完整性(🔴 新增) + +> 说明:你提供的集成检查清单以 Android 项目为例(导航/DI/数据库等)。本项目为 Web 管理工具,报告采用“入口点可达性 + 调用链完整性 + 状态/任务运行可验证性”的等价口径进行检查。 + +### 功能组件清单(文档抽取) + +| 组件类型 | 组件名称 | 集成状态 | +|---------|---------|---------| +| 页面/入口 | 登录、主页、分组/账号/邮件/设置等入口 | ⚠️ 文档未明确“入口点清单” | +| 接口/能力 | 账号/分组/标签/邮件读取/删除/导出/刷新/日志/统计 | ⚠️ 文档未列出接口清单与契约 | +| 后台任务 | 定时刷新 | ⚠️ 文档未明确“运行证据获取方式” | +| 数据对象 | 账号、分组、标签、设置、刷新日志、审计日志、临时邮箱等 | ⚠️ 文档未明确数据字典/字段口径 | +| 观测点 | 追踪标识、错误编号、运行状态、健康状态 | ⚠️ 文档未明确展示位置/查询口径 | + +### 集成点验证(以“文档可验收”为准) + +| 集成点 | 需要在文档中明确的内容 | 状态 | 说明 | +|--------|------------------------|------|------| +| 入口点可达 | 每个功能从哪里进入(页面/按钮/菜单/链接) | ❌ | FD/TDD/TODO 仅描述能力,未提供入口点清单 | +| 调用链完整 | 关键功能的处理链路边界(至少到“对外行为层面”) | ⚠️ | TDD 有交付项但缺少“功能-接口-数据-日志”映射 | +| 后台任务可运行 | 定时刷新如何被确认“正在运行” | ⚠️ | TDD 要求可验证,但未定义验证口径与展示位置 | +| 状态一致性 | 二次验证、锁定等状态在重启/扩容下的验收方法 | ⚠️ | TDD 有要求,但缺少验收步骤与判定标准 | +| 错误追踪 | 追踪标识在何处生成/展示/查询 | ⚠️ | TDD 有要求,但缺少展示与查询口径 | + +### ❌ 集成缺失问题(文档层面) + +#### INT-001:缺少“功能入口点清单” + +- 问题描述:无法从文档直接验收“功能是否可访问”,容易出现“功能实现了但用户找不到入口”的遗漏。 +- 建议补充:在 FD 或 TDD 增加“入口点清单”章节,列出每个功能的入口位置与可达路径。 + +#### INT-002:缺少“接口/契约清单” + +- 问题描述:文档未定义关键能力的接口列表、请求/响应字段与错误结构,导致实现/测试/验收口径不一致。 +- 建议补充:增加“接口清单(仅契约)”,至少覆盖读取/刷新/删除/导出/设置/临时邮箱等关键能力。 + +--- + +## 📋 改进建议(按优先级) + +### P0:先补齐“规范与编号”,让审查流程能跑起来 + +1. 统一为该主题分配编号 `00001`,并将 PRD/FD/TDD/TODO 文件名改为 `类型-00001-描述.md` 的形式。 +2. 在仓库补充文档规范文件(至少一种):`Rules/项目文档规范.md`(或 `Rules/开发文档规范.md`),明确: + - 允许的文档类型集合 + - 命名规则与存放路径规则 + - 各类型文档的必备章节模板 + +### P0:明确 TDD 的定位,避免“规范冲突” + +3. 你需要确认:TDD 是“技术设计文档”还是“技术交付清单”。 + - 若是“技术设计文档”:需要补齐技术架构、接口契约、数据字典、错误策略、运行与集成验收口径。 + - 若是“技术交付清单”:建议新增一个“设计型文档”(例如 AD/TD/IMPL)承载架构/API/数据设计与集成映射,避免把 TDD 变成两种角色的混合体。 + +### P1:让 FD/TDD 具备可直接验收的“入口/接口/异常流” + +4. FD 补齐关键业务流程与交互逻辑(至少覆盖:导入、全量刷新进度、失败重试、导出二次验证、定时刷新验证)。 +5. TDD 增加“功能集成映射”章节(入口点清单、接口清单、错误编号清单、追踪标识展示/查询口径、定时刷新运行证据口径)。 + diff --git a/docs/CR/CR-00002-Outlook邮件管理工具-前后端拆分与模块化-文档审查报告.md b/docs/CR/CR-00002-Outlook邮件管理工具-前后端拆分与模块化-文档审查报告.md new file mode 100644 index 0000000..59f6859 --- /dev/null +++ b/docs/CR/CR-00002-Outlook邮件管理工具-前后端拆分与模块化-文档审查报告.md @@ -0,0 +1,154 @@ +# 文档审查报告:Outlook邮件管理工具-前后端拆分与模块化(00002) + +## 📄 文档信息 + +- 主审文档:`docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md` +- 文档类型:TDD +- 文档编号:未解析到 5 位数字编号(建议统一分配编号并落到文件名:如 `TDD-00002-前后端拆分与模块化.md`) +- 文档标题:Outlook 邮件管理工具|前后端拆分与模块化 TDD(技术设计细节) +- 存放路径:`docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md` +- 关联前置文档(同主题,因缺少编号以标题关联): + - `docs/PRD/Outlook邮件管理工具-前后端拆分与模块化PRD.md` + - `docs/FD/Outlook邮件管理工具-前后端拆分与模块化FD.md` + - `docs/TODO/Outlook邮件管理工具-前后端拆分与模块化TODO.md` + +## 📚 规范与架构参考文件读取结果 + +- 文档规范: + - 未找到 `Rules/开发文档规范.md` + - 未找到 `Rules/项目文档规范.md` +- 项目架构/产品 Steering: + - 未找到 `.kiro/steering/structure.md` + - 未找到 `.kiro/steering/tech.md` + - 未找到 `.kiro/steering/product.md` + +> 影响:部分“命名/必备章节/项目架构符合性”无法按项目权威规范判定。本报告按你提供的审查维度清单做最佳努力评估,并基于本仓库(Python/Flask)现实情况对“集成点检查”做等价映射(见下文“功能集成完整性”)。 + +--- + +## 📊 质量评分 + +- 格式规范性:5/10 +- 内容完整性:7/10 +- 开发可行性:7/10 +- 架构符合性:7/10 +- 功能集成性:6/10 +- 整体评分:6.4/10 + +--- + +## ✅ 优点 + +- **拆分目标清晰且可回滚**:明确“行为兼容 + 渐进式拆分 + 每阶段可验收/可回滚”,适合在现有稳定版本上做结构重构(`docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md` 2/6)。 +- **分层边界与依赖方向写得明确**:给出了 `routes -> services -> repositories -> db` 的依赖约束,并强调避免循环依赖(3.1、8)。 +- **关键风险点识别到位**:import-time 副作用(调度器线程)、契约漂移、前端全局变量依赖等风险都有对应缓解策略(4.2、4.9、8)。 +- **与 PRD/FD 一致**:PRD/FD 的“对用户无感、不引入新复杂度、保持接口不变”的核心约束,在 TDD 中被复述并落到迁移阶段与测试门禁(PRD 2/3/5,FD 2,TDD 2/6/7)。 + +--- + +## ⚠️ 需要改进项 + +### 1) 格式规范(命名/编号/类型) + +- 当前文档命名未体现“类型-编号-描述”的规则(无法自动按编号关联前置文档)。 +- 你提供的文档类型集合里未包含 `TODO`,但仓库中大量使用 TODO 文档;建议在“项目文档规范”中明确是否允许 TODO 作为文档类型。 + +### 2) 内容完整性(TDD 维度) + +> 当前 TDD 已是“技术设计细节”,明显比旧的“技术交付清单型 TDD”更可落地;但仍建议补齐两类“可直接验收/可直接开发”的信息。 + +- **缺少“接口契约抽样清单”**:虽强调 URL/响应不变,但未列出需保持不变的关键接口清单与最小字段契约(建议附录列出 10~20 个关键端点与关键字段)。 +- **缺少“迁移后的模块间契约”**:例如 repository 返回的数据形态、service 的错误聚合结构是否必须与现有一致,建议用 1~2 个典型链路给出示例(例如“删除邮件三路回退错误聚合”)。 + +### 3) 前端拆分的“切割策略”可再具体 + +- 当前给了 `api/ui/features/state` 的理想结构,但未说明迁移时如何处理: + - 现有全局函数/事件绑定的过渡方案(例如先 `main.js` 保持旧函数名,再逐步导出/内聚) + - 静态资源缓存/版本策略(避免浏览器缓存导致上线后脚本不更新) + +--- + +## ❌ 严重问题 + +### S-001:缺少项目级“文档规范/架构 Steering”,导致审查标准难以固化 + +- 问题描述:未找到 `Rules/*` 与 `.kiro/steering/*`,无法严格按项目规范判定“必备章节/命名/目录结构”。 +- 影响:后续文档增多时一致性会下降,审查结论难以复用。 +- 建议:补一份最小可用的 `Rules/项目文档规范.md`(至少定义:允许的文档类型、命名规则、存放路径、各类型必备章节)。 + +--- + +## 🔗 前置文档一致性 + +- PRD → FD: + - 一致性:✅(FD 的交付项覆盖 PRD 目标:后端模块化、入口兼容、前端静态资源拆分、测试与文档交付)。 + - 风险:⚠️(FD 对“接口契约不变”的验收口径仍偏抽象,可在 FD/TODO 中增加“抽样验收接口清单”以便执行) +- FD → TDD: + - 一致性:✅(TDD 细化了 FD 的拆分交付项:目录结构、装配策略、依赖方向、分阶段迁移与测试门禁) + - 缺口:⚠️(TDD 尚未给出“最小契约清单”和“典型链路示例”,导致实现时仍可能出现局部行为漂移) +- TDD → TODO: + - 一致性:✅(TODO 的阶段拆解与 TDD 6 章一致) + +--- + +## 🔗 功能集成完整性检查(重点,适用 TDD) + +> 说明:你提供的集成点清单以 Android(导航/DI/Room/Hilt)为例。本仓库为 Python/Flask Web 项目,这里采用等价映射: +> +> - “导航集成” ≈ Blueprint/路由注册 + 前端入口脚本可达 +> - “DI 模块集成” ≈ app 装配(create_app)/依赖装配/初始化顺序 +> - “数据库集成” ≈ init_db/迁移/连接生命周期 +> - “入口点集成” ≈ 页面/按钮/路由是否可访问 +> - “调用链完整” ≈ routes → services → repositories → db + +### 功能组件清单(从 TDD 抽取) + +| 组件类型 | 组件名称(建议) | 集成状态 | +|---------|------------------|---------| +| App 装配 | `outlook_web.app:create_app()` | ⚠️ 设计已明确,尚需落地并补 smoke test | +| 路由注册 | `outlook_web.routes.*`(Blueprint) | ⚠️ 设计已明确,尚需落地与契约抽样测试 | +| 服务层 | `outlook_web.services.*` | ⚠️ 设计已明确,尚需迁移与单测 | +| 仓储层 | `outlook_web.repositories.*` | ⚠️ 设计已明确,尚需迁移与单测 | +| DB/迁移 | `outlook_web.db:init_db()` | ⚠️ 设计已明确,需验证与现有迁移一致 | +| 调度器 | `outlook_web.scheduler.*` | ⚠️ 设计已明确,重点验证 import-time 副作用 | +| 前端入口 | `/static/js/main.js` | ⚠️ 设计已明确,需验证模板引用与缓存策略 | + +### 集成点验证(以“文档可验收”为准) + +| 集成点 | 文档中是否明确 | 状态 | 说明 | +|--------|----------------|------|------| +| 路由/导航注册 | ✅ | ✅ | 明确 Blueprint 注册方式与 URL 不变策略(4.8) | +| 装配/初始化顺序 | ✅ | ✅ | `init_db()` → scheduler 开关启动(4.9) | +| 调用链完整性 | ✅ | ✅ | 分层依赖方向写明(3.1) | +| 静态资源入口 | ✅ | ⚠️ | 明确 `` 引用静态资源(不强制使用 ESModule,避免大规模重写与行为回归)。 +- 回滚点: + - 若外链资源加载/缓存导致页面不可用,优先回退到上一绿色基线(Git 回退/恢复上一版本镜像/回滚发布包)。 +- 切换方式: + - 当前未引入运行开关;如需要“运行侧快速切换内联/外链”,可在后续阶段增加开关(例如 `FRONTEND_EXTERNAL_ASSETS=true|false`),但需同步补齐门禁与回归验证。 + +### 阶段 6:文档与维护指南 + +- 目标: + - README/开发者文档补齐模块职责、依赖方向、测试运行方式与迁移说明。 +- 回滚点: + - 文档变更不影响运行;但若文档与实现不一致,应以实现为准并尽快纠正文档。 diff --git a/docs/RUN/运行与定时刷新验证指南.md b/docs/RUN/运行与定时刷新验证指南.md new file mode 100644 index 0000000..cece03b --- /dev/null +++ b/docs/RUN/运行与定时刷新验证指南.md @@ -0,0 +1,70 @@ +# 运行与定时刷新验证指南 + +- 日期:2026-02-22 +- 目的:给出“官方推荐运行方式”与“定时刷新真实运行”的可验证口径(接口 + 指标)。 + +--- + +## 1) 官方推荐运行方式(建议) + +### Docker(推荐) + +使用仓库提供的 `Dockerfile`(内部使用 Gunicorn 启动,默认 `-w 1`,避免多进程重复启动调度器与 session 共享问题)。 + +关键点: +- 必须设置 `SECRET_KEY` +- 建议设置 `SCHEDULER_AUTOSTART=true`(默认即为 true),用于自动启动后台调度器(心跳 + 定时刷新) +- 持久化挂载 `./data:/app/data`,避免重启后丢数据 + +### 本地直接运行(开发/调试) + +```bash +pip install -r requirements.txt +export SECRET_KEY=xxx +python web_outlook_app.py +``` + +> 注意:`flask run` 的 reloader 可能导致重复启动;若使用,请确认 `SCHEDULER_AUTOSTART` 行为符合预期。 + +--- + +## 2) 如何验证“定时刷新真的在跑” + +### A. 快速健康检查(管理员) + +- 接口:`GET /api/system/health` +- 关注字段: + - `health.database`:数据库是否可用 + - `health.scheduler.enabled`:是否启用定时刷新 + - `health.scheduler.heartbeat_age_seconds`:心跳距离当前的秒数 + - `health.scheduler.healthy`:是否健康(默认阈值:`<= 120s`) + - `health.refresh.locked / running`:是否存在刷新锁/运行中刷新 + +### B. 调度器状态与“未来触发预览” + +- 接口:`GET /api/scheduler/status` +- 关注字段: + - `scheduler.enabled / use_cron`:是否启用、调度方式 + - `scheduler.future_runs / next_due`:未来触发时间预览 + - `scheduler.heartbeat_age_seconds`:调度器心跳年龄 + - `refresh.last_scheduled`:最近一次定时刷新(含触发来源、总数/成功/失败、trace_id) + - `refresh.running`:是否存在运行中的刷新(running 状态) + - `refresh.lock`:分布式锁状态(用于冲突控制) + +### C. 诊断信息(状态一致性/清理策略) + +- 接口:`GET /api/system/diagnostics` +- 关注字段: + - `export_verify_tokens_active`:当前有效导出二次验证 token 数量 + - `login_locked_ip_count`:当前被锁定的 IP 数量 + - `locks`:分布式锁列表 + - `running_runs / last_runs`:刷新运行记录 + - `schema`:数据库升级/版本状态(是否最新、最近一次迁移记录) + +### D. 升级过程可验证(数据兼容/升级保障) + +- 接口:`GET /api/system/upgrade-status` +- 关注字段: + - `schema_version / target_version / up_to_date`:当前版本与目标版本 + - `last_trace_id / last_error`:最近一次升级的追踪标识与错误信息(便于定位) + - `last_migration`:最近一次迁移记录(status/时间/trace_id) diff --git a/docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md b/docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md new file mode 100644 index 0000000..18357d2 --- /dev/null +++ b/docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md @@ -0,0 +1,331 @@ +# Outlook 邮件管理工具|前后端拆分与模块化 TDD(技术设计细节) + +- 文档状态:草案 +- 版本:V0.1 +- 日期:2026-02-23 +- 对齐 PRD:`docs/PRD/Outlook邮件管理工具-前后端拆分与模块化PRD.md` +- 对齐 FD:`docs/FD/Outlook邮件管理工具-前后端拆分与模块化FD.md` +- 对齐 TODO:`docs/TODO/Outlook邮件管理工具-前后端拆分与模块化TODO.md` +- 关联 CR:`docs/CR/CR-00002-Outlook邮件管理工具-前后端拆分与模块化-文档审查报告.md` + +--- + +## 1. 文档目的 + +本 TDD 用于描述“为满足 PRD/FD,前后端拆分与模块化需要采用的技术设计细节”。 +重点回答: + +- 拆成哪些模块、各模块职责边界是什么、依赖方向如何约束 +- 如何保证 **URL/响应契约/错误结构/trace_id/调度器行为** 不回归 +- 如何渐进式迁移(每阶段可回滚、可验收、可测试) + +--- + +## 2. 设计原则与硬约束(必须满足) + +### 2.1 行为兼容性(对用户无感) + +- 保持现有 API 路径/方法/参数语义/响应结构不变。 +- 保持统一错误结构、`trace_id` 透传、默认脱敏策略不变。 +- 保持登录/会话/CSRF 行为不变。 +- 保持定时刷新“推荐运行方式/自启动策略/可验证证据接口”不变。 + +### 2.2 渐进式拆分(可回滚、可验证) + +- 任何阶段都必须可运行且可回归:至少通过 `python -m unittest discover -s tests -v` 与关键接口抽样验收。 +- 允许短期存在兼容层(旧入口 facade / re-export),但每阶段要有明确的“可删除点”。 + +### 2.3 不引入新复杂度 + +- 前端不引入 React/Vue 等框架,不引入 Webpack/Vite 等构建链;以 **ES Module + 静态资源引用** 为主。 +- 后端不引入新框架迁移(仍为 Flask),数据库仍为 SQLite,迁移策略保持现状。 + +--- + +## 3. 总体架构设计 + +### 3.1 后端分层(建议) + +采用典型分层与关注点分离: + +- Presentation(Routes):参数解析/鉴权/调用 service/返回 JSON +- Service(业务编排):回退策略/流程编排/审计触发/错误聚合 +- Repository(数据访问):SQL 与数据映射,不承载业务规则 +- Infrastructure(基础设施):配置、DB、加密、错误模型、调度器、第三方客户端 + +依赖方向约束(必须遵守): + +`routes -> services -> repositories -> db` +`routes/services` 可以依赖 `errors/security/audit/utils`,但 **repositories 不能依赖 routes**。 + +### 3.2 前端模块化(建议) + +- `api` 层:统一 `fetch` 封装、错误结构处理、trace_id 展示、CSRF/header 处理 +- `ui` 层:modal/toast/loading 等通用组件 +- `features` 层:groups/accounts/tags/emails/refresh/export/settings/audit/temp_emails 等业务模块 +- `state` 层:当前分组/账号/分页/缓存等前端状态(可用简单对象,避免引入状态管理框架) + +--- + +## 4. 后端技术设计细节 + +### 4.1 目标目录结构(建议) + +新增后端包(示例命名,可在实现阶段微调): + +``` +outlook_web/ + __init__.py + app.py # create_app + 装配 + config.py # 环境变量读取/默认值/运行模式 + db.py # 连接管理、init_db、迁移/索引 + errors.py # error payload、trace_id、脱敏、全局异常处理 + audit.py # log_audit + 审计查询 + security/ + __init__.py + crypto.py # SECRET_KEY 派生、encrypt/decrypt、hash_password + auth.py # login_required、session 工具、ip 获取 + csrf.py # CSRF 集成与降级 + repositories/ + accounts.py + groups.py + tags.py + temp_emails.py + services/ + graph.py + imap.py + email_delete.py # 删除邮件回退聚合(可选单独文件) + refresh.py # token 刷新编排(可选) + gptmail.py + scheduler/ + scheduler.py # APScheduler 初始化/装配/心跳 + locks.py # distributed_locks 抽象 + runs.py # refresh_runs 记录/查询 + routes/ + system.py + scheduler.py + groups.py + accounts.py + tags.py + emails.py + oauth.py + settings.py + temp_emails.py +``` + +约束: + +- `outlook_web/app.py` 只负责装配与注册,不承载大段业务代码。 +- `init_db()` 与 schema 迁移逻辑集中在 `db.py`,避免散落。 + +### 4.2 应用装配(create_app)与入口兼容 + +#### 4.2.1 目标 + +- 支持 `create_app()`(便于测试与复用)。 +- 同时保持部署入口兼容:现有 `gunicorn ... web_outlook_app:app` 不需要立即改动。 + +#### 4.2.2 设计 + +- `outlook_web/app.py`: + - `create_app()`:创建 Flask app、加载 config、注册 blueprint、注册 error handler、初始化 db/scheduler(按环境变量开关)。 +- `web_outlook_app.py` 在迁移阶段变为 facade: + - `from outlook_web.app import create_app` + - `app = create_app()` + - 继续保留必要的兼容导出(避免影响 tests/与外部启动方式) + +**禁止**:在模块 import 时启动后台线程(调度器必须在 `create_app()` 内按开关启动)。 + +### 4.3 配置与环境变量 + +`outlook_web/config.py` 负责集中读取并提供类型安全的配置: + +- `SECRET_KEY`(必填,且变更会影响历史加密数据) +- `DATABASE_PATH`(默认 `data/outlook_accounts.db`) +- `LOGIN_PASSWORD`(初始登录密码) +- `SCHEDULER_AUTOSTART`(是否在 WSGI 导入场景自启动调度器) +- 其它现有设置项保持原名与语义 + +### 4.4 错误模型、trace_id、脱敏(统一入口) + +`outlook_web/errors.py`: + +- `generate_trace_id()`:若请求头带 `X-Trace-Id` 则复用,否则生成。 +- `build_error_payload(code, message, type, status, details)`:默认对 message/details 脱敏。 +- 全局异常处理: + - `HTTPException` → `HTTP_ERROR` + - 未捕获异常 → `UNCAUGHT_EXCEPTION` +- `after_request`: + - 统一补 `X-Trace-Id` + - legacy `{success:false,error:"..."}` 归一化为结构化 error(保持现有兼容策略) + +### 4.5 数据库与迁移(init_db) + +`outlook_web/db.py`: + +- `create_sqlite_connection()`:统一设置 row_factory、busy_timeout、foreign_keys。 +- `get_db()`:基于 `flask.g` 的连接生命周期管理。 +- `init_db()`: + - 幂等建表、缺列补齐、索引创建、schema 版本写入与迁移记录(保持现有逻辑与表结构) + - 并发迁移使用 `BEGIN IMMEDIATE`(保持一致性) + +### 4.6 Repository(SQL)设计 + +Repository 层只做“数据访问与映射”,以函数形式即可(不强制类): + +- `repositories/accounts.py` + - `get_account_by_id(db, account_id)` / `add_account(db, ...)` / `update_account(db, ...)` / `search_accounts(db, q)` 等 +- 统一约束: + - 不返回敏感字段明文(若业务需要解密,放在 service 层做) + - 所有 SQL 参数化,禁止字符串拼接注入 + +### 4.7 Service(业务编排)设计 + +Service 层负责: + +- Graph/IMAP/GPTMail 调用与错误聚合 +- Token 刷新/删除邮件等“多方式回退策略” +- 审计触发(不记录敏感明文) + +建议把“聚合错误”的结构保持与现有一致(前端依赖 `error.details` 展示)。 + +### 4.8 Routes(Blueprint)设计 + +每个路由文件暴露一个 blueprint: + +- `bp = Blueprint("groups", __name__)` +- 使用 `@login_required` 等装饰器保持一致 +- 注册:`app.register_blueprint(groups.bp)`(不使用 `url_prefix` 或用空前缀,确保 URL 不变) + +### 4.9 调度器(Scheduler)与 import-time 副作用控制 + +目标:避免“import 就启动线程”,但保持既有运行方式下的定时刷新能力。 + +策略: + +- `create_app()` 内: + - 先 `init_db()`,再根据 `SCHEDULER_AUTOSTART`(或 TESTING 环境)决定是否 `init_scheduler()` +- 测试环境默认禁用自启动(现有 `tests/_import_app.py` 已采用环境变量方式) + +--- + +## 5. 前端技术设计细节 + +### 5.1 静态资源拆分与引用 + +新增目录: + +``` +static/ + js/ + css/ +``` + +`templates/index.html`: + +- 移除大段内联脚本,改为引用: + - `` +- CSS 迁移到 `/static/css/main.css` + +### 5.2 API 封装(api.js) + +提供统一方法: + +- `apiGet(path, opts)` / `apiPost(path, body, opts)` / `apiDelete(...)` +- 统一处理: + - JSON parse + - `success=false` 的结构化 error 展示(复用现有 `handleApiError()` 语义) + - `X-Trace-Id` 的获取与展示(用于排障复制) + - CSRF token/header(保持现有策略) + +### 5.3 Feature 模块拆分 + +每个 feature 文件负责: + +- UI 事件绑定(按钮/表单/表格交互) +- 调用 `api.js` 与更新 `state` +- 只依赖 `ui/*` 与 `api.js/state.js`,避免 feature 互相调用导致耦合 + +### 5.4 渐进迁移策略(避免一次性大改) + +- 第一步允许把原来所有脚本“整体搬到 main.js”以保持行为一致; +- 第二步再从 main.js 中按 feature 拆分导出函数/类; +- 第三步再考虑把 HTML 结构拆成 Jinja partials(减少冲突)。 + +--- + +## 6. 迁移阶段与验收(每阶段必做) + +> 每个阶段都必须:单测通过 + 抽样接口验收通过 + 可回滚。 + +### 阶段 0:基线冻结 + +- 锁定现有接口契约与关键行为(以现有 tests + QA 文档为证据)。 + +### 阶段 1:后端骨架 + 兼容入口 + +- 引入 `outlook_web/create_app()`,`web_outlook_app.py` 变为 facade 并继续导出 `app`。 +- 验收: + - Docker/Gunicorn 启动命令不变 + - 单测全过 + +### 阶段 2:拆基础设施(低风险) + +- 拆 `config/errors/security/crypto/db/audit`,旧文件只转发调用。 +- 增补单测:错误结构/trace_id/脱敏不回归。 + +### 阶段 3:拆 repositories + services + +- SQL 下沉 repositories,回退策略与业务编排上移 services。 +- 增补单测:纯逻辑单测(不依赖 Flask app)。 + +### 阶段 4:拆 routes(Blueprint 化) + +- 分模块路由文件并注册蓝图,URL 不变。 +- 增补测试:关键路由存在性与响应结构抽样。 + +### 阶段 5:前端静态资源拆分 + +- 先整体迁移脚本到 `static/js/main.js`,再按 feature 拆分。 +- 抽样回归:登录/分组/账号/导出/审计/临时邮箱/设置等关键路径。 + +--- + +## 7. 测试策略(TDD/回归保障) + +### 7.1 单元测试(优先补 service/repository) + +- repository:SQL 结果映射、边界条件(空/不存在/重复) +- service:回退聚合、错误结构、脱敏、审计 details 生成 + +### 7.2 集成测试(保契约) + +- 使用 Flask `test_client` 验证关键接口: + - `/healthz` + - `/api/system/health` + - `/api/system/upgrade-status` + - `/api/audit-logs` + - `/api/scheduler/status` +- 对“拆分后的装配”增加 smoke test(蓝图是否注册、静态资源是否可访问)。 + +### 7.3 变更门禁 + +- CI 必跑:`python -m unittest discover -s tests -v` + +--- + +## 8. 风险与缓解 + +- 循环依赖风险:通过分层约束与“只从上往下依赖”解决;必要时引入 `types.py` 放共享数据结构。 +- import-time 副作用:调度器启动必须延迟到 `create_app()`,测试环境必须禁用自启动。 +- 契约漂移:每阶段用“抽样契约测试 + QA 清单”兜底;禁止随手改接口字段名。 +- 前端全局变量依赖:第一阶段允许保留 `window.*`,后续再逐步收敛成模块内部状态。 + +--- + +## 9. 交付验收(技术侧) + +- FD 2.x 清单项全部满足。 +- 单测与 CI 通过。 +- 关键抽样接口契约不变。 +- 迁移过程中每阶段的“回滚点”清晰可执行。 diff --git a/docs/TDD/Outlook邮件管理工具-重构升级TDD.md b/docs/TDD/Outlook邮件管理工具-重构升级TDD.md new file mode 100644 index 0000000..13d9b84 --- /dev/null +++ b/docs/TDD/Outlook邮件管理工具-重构升级TDD.md @@ -0,0 +1,143 @@ +# Outlook 邮件管理工具|重构升级 TDD(技术交付清单,仅说明做什么) + +- 文档状态:草案 +- 版本:V1.0 +- 日期:2026-02-22 +- 对齐 PRD:`docs/PRD/Outlook邮件管理工具-重构升级PRD.md` +- 对齐 FD:`docs/FD/Outlook邮件管理工具-重构升级FD.md` + +--- + +## 1. 文档目的 + +本 TDD 用于描述“为满足 PRD/FD,需要在工程与系统层面交付哪些能力与行为”。 +仅描述**需要完成什么功能/能力**与**可验收行为**,不描述实现过程与编码细节。 + +--- + +## 2. 总体约束(必须满足) + +- 保持现有对外使用方式与关键交互不被破坏(包含管理界面与核心接口行为)。 +- 升级后历史数据可继续使用,且升级过程可预期、可验证。 +- 关键链路(读取/刷新/删除/导出)在异常情况下返回结构稳定、可定位、且不泄露敏感信息。 +- 支持长期运行(7×24)与重启/升级后的稳定恢复。 + +--- + +## 3. 技术交付清单(必须完成) + +### 3.1 应用启动与运行形态 + +- 提供“Web 服务运行”能力:可启动并对外提供管理界面与接口服务。 +- 提供“后台刷新任务运行”能力:可在不依赖人工操作的情况下执行定时刷新。 +- 明确并落地“官方推荐运行方式”:保证在推荐方式下,定时刷新可用且可验证。 +- 提供运行状态可见性:管理员可以确认当前系统是否处于“可服务/可刷新”的正常状态。 + +### 3.2 定时刷新可用性与可验证 + +- 定时刷新具备以下能力: + - 启用/停用 + - 支持两类调度配置方式(按天数间隔、按表达式配置) + - 支持配置有效性校验与未来触发时间预览 +- 定时刷新运行必须可被验证: + - 可查看最近一次触发时间 + - 可查看触发来源标识(区分手动/全量/定时/重试) + - 可查看触发结果统计(总数/成功/失败) +- 避免重复触发与冲突: + - 同一周期内不应出现不可解释的重复执行 + - 支持在触发中对重复触发进行明确提示或拒绝 + +### 3.3 状态一致性与持久化(面向重启/扩容) + +- 对以下“影响安全或一致性”的状态提供一致性保障(重启/多实例下仍可用): + - 导出二次验证凭证与有效期管理 + - 登录失败计数、锁定状态与解锁时间 + - 定时刷新最近触发信息与执行中状态(如有) +- 以上状态需具备: + - 过期与清理策略 + - 诊断可见性(管理员能看到状态是否异常) + +### 3.4 错误模型与追踪标识统一 + +- 为所有接口返回提供统一的错误结构(至少包含错误编号、中文提示、追踪标识、可选的细节信息)。 +- 错误细节必须脱敏,禁止泄露令牌、密码、敏感凭据等内容。 +- 对“多方式回退”的失败信息必须能聚合展示,并可按方式分组呈现。 +- 未捕获异常必须被统一处理: + - 返回稳定的错误结构 + - 记录追踪标识,便于排查 + +### 3.5 安全能力交付(不降低现有安全基线) + +- 会话安全: + - 登录态具备合理的过期策略与安全属性 + - 在反向代理场景下来源标识(如 IP)获取行为可控且可解释 +- 输入与内容安全: + - 用户输入在展示与存储前必须符合“净化/长度限制/控制字符处理”等规则 + - 邮件 HTML 默认安全展示:禁止脚本执行与危险行为 + - “信任/原始内容”模式必须明确风险提示与默认关闭 +- 敏感数据保护: + - 敏感字段默认不在接口/界面中明文展示 + - 仅在导出且完成二次验证的场景允许输出敏感内容 + +### 3.6 数据兼容与升级保障 + +- 升级过程中支持对历史数据进行必要的兼容处理,确保升级后可继续使用: + - 账号、分组、标签、设置、刷新历史、审计记录等 +- 升级过程需具备可验证性: + - 可识别并提示升级/迁移是否完成 + - 失败时给出明确错误提示与恢复建议(至少包含追踪标识) +- 默认提供升级前备份提示与导出路径(面向管理员操作)。 + +### 3.7 刷新/导出/审计的行为一致性 + +- 刷新相关: + - 单个刷新、全量刷新、定时刷新、失败重试使用一致的结果记录口径 + - 刷新日志与统计对不同触发来源展示一致 +- 导出相关: + - 所有导出类型均必须经过二次验证 + - 导出文件命名规则一致且可识别(范围、时间) + - 导出行为必须产生审计记录 +- 审计相关: + - 敏感操作必须被记录(导出、删除、修改账号、修改分组、修改设置) + - 审计失败不影响主流程,但必须可被管理员发现 + +### 3.8 性能与容量保障 + +- 支持账号规模达到 1,000 的可用体验: + - 账号列表加载与筛选保持可用 + - 全量刷新过程持续输出进度,不出现长时间无反馈 +- 关键路径性能要求具备可验收口径: + - 明确“可接受时间”的定义与测试方式(验收用) + - 对慢查询/慢操作具备可定位信息(含追踪标识/操作类型) + +### 3.9 可观测与运维友好性 + +- 关键操作必须可被追踪与排障: + - 每次关键操作可关联追踪标识 + - 管理员可查看失败原因分类与建议动作 +- 提供基础健康状态能力: + - 能判断系统是否处于可用状态 + - 能判断定时刷新是否处于可用状态 + +### 3.10 自动化验证(回归保障) + +- 提供一组可重复执行的回归验证清单(面向发布): + - 登录/修改密码/退出 + - 分组增删改查与系统分组保护 + - 账号增删改查、批量导入、批量移动、状态启停 + - 标签增删与批量操作 + - 邮件三类文件夹读取、分页、详情、全屏、信任模式提示 + - 单个刷新、全量刷新、失败重试、定时刷新配置与运行证据 + - 单封/批量删除与二次确认 + - 导出三种模式与二次验证、审计记录生成 + - OAuth2 辅助流程与常见失败提示 + - 临时邮箱全流程与失败分类提示 +- 回归验证需覆盖“重启后再验证”的场景,确保状态与行为一致。 + +--- + +## 4. 交付验收(技术侧) + +- 上述 3.x 清单项逐条可验收,且能通过“运行证据/日志/界面信息/接口返回”进行确认。 +- 不新增或引入未在 PRD/FD 中声明的对外功能变更(除非补充 PRD/变更说明)。 + diff --git a/docs/TODO/Outlook邮件管理工具-前后端拆分与模块化TODO.md b/docs/TODO/Outlook邮件管理工具-前后端拆分与模块化TODO.md new file mode 100644 index 0000000..94217c6 --- /dev/null +++ b/docs/TODO/Outlook邮件管理工具-前后端拆分与模块化TODO.md @@ -0,0 +1,77 @@ +# Outlook 邮件管理工具|前后端拆分与模块化 TODO(待办清单,仅说明要完成什么) + +- 文档状态:草案 +- 版本:V0.1 +- 日期:2026-02-23 +- 对齐 PRD:`docs/PRD/Outlook邮件管理工具-前后端拆分与模块化PRD.md` +- 对齐 FD:`docs/FD/Outlook邮件管理工具-前后端拆分与模块化FD.md` +- 对齐 TDD:`docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md` + +--- + +## P0(必须完成) + +### 0) 文档与基线准备(拆分前门禁) + +- [x] PRD/FD/TDD 已产出并互相对齐 +- [x] 明确“接口契约不变”的抽样验收口径(关键接口清单 + 预期结构):`docs/QA/前后端拆分-接口契约抽样验收口径.md` +- [x] 增加“拆分阶段 smoke tests”(应用装配/关键路由/静态资源可访问):`tests/test_smoke_contract.py` +- [x] 明确每阶段回滚点(保留旧入口/兼容层/切换方式):`docs/RUN/00002-前后端拆分-回滚点与切换方式.md` + +### 1) 后端骨架 + 入口兼容(create_app) + +- [x] 新增后端包结构(例如 `outlook_web/`)并提供 `create_app()`:`outlook_web/app.py` +- [x] 保持部署入口兼容:Gunicorn/Docker 仍可用 `web_outlook_app:app`:`web_outlook_app.py` +- [x] 控制 import-time 副作用:禁止在 import 时启动调度器/线程(迁移到 `create_app()` 受控启动):`outlook_web/legacy.py` +- [x] 拆分后单测仍可跑(`python -m unittest discover -s tests -v`)通过(36/36) + +### 2) 拆基础设施模块(低风险优先) + +- [x] 配置集中化:环境变量读取与默认值集中管理(`outlook_web/config.py`) +- [x] 错误模型集中化:统一错误结构/trace_id/默认脱敏/全局异常处理保持一致:`outlook_web/errors.py` +- [x] 安全模块化:加密/鉴权/CSRF 分离,行为保持一致(`outlook_web/security/*`) +- [x] DB 模块化:连接管理 + `init_db()`/迁移逻辑集中,行为保持一致(`outlook_web/db.py`) +- [x] 审计模块化:`log_audit` 与审计查询入口保持一致(`outlook_web/audit.py`) + +### 3) 拆 repositories(SQL)与 services(业务编排) + +- [x] repositories:accounts/groups/tags/temp_emails 等 SQL 与数据映射集中(`outlook_web/repositories/*`) +- [x] services:Graph/IMAP/GPTMail/刷新编排/删除回退聚合等逻辑集中(`outlook_web/services/*`) +- [x] 多方式回退聚合错误结构保持一致(前端展示不回归)(`outlook_web/services/email_delete.py`) +- [x] 为 service/repository 补充单元测试(优先覆盖回退聚合/脱敏/边界条件)(`tests/test_error_and_trace.py`) + +### 4) Routes 模块化(Blueprint 化,URL 不变) + +- [x] 按领域拆路由模块:system/scheduler/groups/accounts/tags/emails/oauth/settings/temp_emails +- [x] 统一注册路由(create_app 内集中 register blueprint) +- [x] URL/方法/响应结构不变(以抽样契约测试与回归清单验证) + +### 5) 前端静态资源拆分(零构建) + +- [x] 新增 `static/js` 与 `static/css` 目录 +- [x] `templates/index.html` 脚本整体迁出到 `static/js/main.js`(行为不变) +- [x] JS 再逐步按 feature 拆分(api/state/ui/features/*) +- [x] CSS 至少拆出主样式文件(避免模板内联堆叠) +- [x] 前端关键路径抽样回归(登录/分组/账号/刷新/删除/导出/审计/临时邮箱/设置) + +### 6) 文档与维护指南(面向开发者) + +- [x] README 增加“目录结构/模块职责/如何新增 API”说明 +- [x] 增加或更新开发者文档:模块边界、依赖方向、测试运行方式 +- [x] 更新回归清单与验收映射中涉及的入口说明(若存在兼容层则说明迁移路径) + +--- + +## P1(强烈建议完成) + +### 7) 清理兼容层与技术债 + +- [ ] 移除不再需要的旧入口/旧函数 re-export(确保无外部依赖后再删) +- [x] 替换 `from outlook_web.legacy import *` 为 `__getattr__` 代理(避免命名污染/覆盖 `app`) +- [ ] 收敛跨模块全局变量(逐步减少 `window.*` 与隐式依赖) +- [x] 为模块依赖加门禁(避免 routes/repo 循环依赖再次出现)(`tests/test_module_boundaries.py`) + +### 8) 模板与 UI 结构进一步瘦身 + +- [x] 把 `templates/index.html` 按区域拆为 Jinja partials(降低冲突、提升可读性)(`templates/partials/*`) +- [ ] 通用 UI(modal/toast)标准化,减少 feature 之间复制粘贴 diff --git a/docs/TODO/Outlook邮件管理工具-重构升级TODO.md b/docs/TODO/Outlook邮件管理工具-重构升级TODO.md new file mode 100644 index 0000000..d056447 --- /dev/null +++ b/docs/TODO/Outlook邮件管理工具-重构升级TODO.md @@ -0,0 +1,79 @@ +# Outlook 邮件管理工具|重构升级 TODO(待办清单,仅说明要完成什么) + +- 文档状态:草案 +- 版本:V1.0 +- 日期:2026-02-22 +- 对齐 PRD:`docs/PRD/Outlook邮件管理工具-重构升级PRD.md` +- 对齐 FD:`docs/FD/Outlook邮件管理工具-重构升级FD.md` +- 对齐 TDD:`docs/TDD/Outlook邮件管理工具-重构升级TDD.md` +- 完成证据:`docs/QA/P0验收对照表.md`、`docs/QA/回归验证清单.md`、`docs/RUN/运行与定时刷新验证指南.md`、`tests/` + +--- + +## P0(必须完成) + +### 1) 运行与定时刷新可靠性 + +- [x] 交付“Web 服务运行”能力:管理界面与核心接口可用 +- [x] 交付“后台刷新任务运行”能力:可在无人值守场景执行定时刷新 +- [x] 明确并交付“官方推荐运行方式”,并保证定时刷新在该方式下可用且可验证 +- [x] 定时刷新能力齐全:启用/停用、两类调度配置方式、配置校验、未来触发时间预览 +- [x] 定时刷新可验证:最近一次触发时间、触发来源标识、触发结果统计(总数/成功/失败) +- [x] 刷新冲突控制:避免不可解释的重复执行;刷新中再次触发需有明确提示或拒绝策略 + +### 2) 状态一致性(重启/扩容下仍可用) + +- [x] 导出二次验证凭证与有效期管理在重启/扩容下保持一致 +- [x] 登录失败计数、锁定状态与解锁时间在重启/扩容下保持一致 +- [x] 定时刷新最近触发信息与(如有)执行中状态在重启/扩容下保持一致 +- [x] 所有上述状态具备过期与清理策略,并可被管理员诊断查看 + +### 3) 错误与追踪一致性 + +- [x] 所有关键接口返回统一的错误结构(含错误编号、中文提示、追踪标识、可选细节) +- [x] 错误信息默认脱敏,不泄露令牌、密码等敏感内容 +- [x] 多方式回退失败时,能聚合展示各方式失败原因(中文可理解) +- [x] 未捕获异常统一处理:返回稳定错误结构,并可通过追踪标识定位 + +### 4) 数据兼容与升级保障 + +- [x] 升级后历史数据可继续使用(账号、分组、标签、设置、刷新历史、审计记录等) +- [x] 升级过程可验证:能识别升级是否完成;失败时给出明确提示与恢复建议(含追踪标识) +- [x] 升级前提供备份提示与可操作路径 + +### 5) 核心业务功能保真(不缩水且不回归) + +- [x] 登录与会话:登录/退出/修改密码/失败限制/剩余等待提示 +- [x] 分组管理:列表、增删改、颜色/描述、网络访问策略配置、系统分组保护 +- [x] 账号管理:列表、增删改查、状态启停、备注、批量导入(校验+错误定位+结果汇总)、批量移动分组 +- [x] 标签管理:标签增删、账号批量打/去标签、按标签筛选与组合筛选 +- [x] 邮件读取:三类文件夹、分页/滚动加载、详情、全屏、信任模式提示、默认安全展示 +- [x] 邮件删除:单封/批量、二次确认、失败原因分类提示、支持重试 +- [x] Token 刷新:单账号刷新、全量刷新(进度反馈)、仅刷新失败、失败重试、结果记录 +- [x] 刷新日志与统计:近半年查询、失败列表入口、统计展示、过期记录清理 +- [x] 导出:按分组/全量/选择导出、二次验证、文件命名规则、导出审计记录 +- [x] 审计:对导出/删除/修改账号/修改分组/修改设置等敏感操作可追溯 +- [x] OAuth2 辅助:授权链接生成、凭据换取、常见失败原因提示、相关配置校验提示 +- [x] 临时邮箱:生成、列表、邮件列表/详情、删除/清空、失败原因分类提示 +- [x] 设置中心:修改登录密码/临时邮箱凭据/刷新策略;变更生效时机明确;高风险项有提示 + +--- + +## P1(强烈建议完成) + +### 6) 可观测与运维友好 + +- [x] 管理员可查看系统是否“可服务/可刷新”的健康状态信息 +- [x] 关键操作(读取/刷新/删除/导出)均可关联追踪标识,便于排障 +- [x] 失败原因分类与建议动作在界面中可见且一致 + +### 7) 性能与容量基线 + +- [x] 账号规模达到 1,000 时:账号列表加载与筛选保持可用体验 +- [x] 账号规模达到 1,000 时:全量刷新持续输出进度与结果,不出现长时间无反馈 +- [x] 关键路径性能具备可验收口径(可接受时间定义与验证方式) + +### 8) 发布回归保障 + +- [x] 提供可重复执行的回归验证清单(覆盖重启后再验证) +- [x] 回归清单覆盖:登录、分组、账号、标签、邮件读取/详情/全屏/信任模式、刷新全链路、删除、导出+二次验证、审计、OAuth2、临时邮箱、设置 diff --git a/outlook_web/__init__.py b/outlook_web/__init__.py new file mode 100644 index 0000000..15ae151 --- /dev/null +++ b/outlook_web/__init__.py @@ -0,0 +1,4 @@ +from outlook_web.app import create_app + +__all__ = ["create_app"] + diff --git a/outlook_web/app.py b/outlook_web/app.py new file mode 100644 index 0000000..0913d35 --- /dev/null +++ b/outlook_web/app.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from typing import Optional + +_APP_INSTANCE = None + + +def create_app(*, autostart_scheduler: Optional[bool] = None): + """ + 应用工厂(迁移期实现): + - 统一装配入口,便于测试与后续 Blueprint/分层拆分 + - 控制 import-time 副作用:初始化/调度器启动放到 create_app 中受控执行 + - routes 采用 Blueprint 模块化注册(URL 不变) + """ + global _APP_INSTANCE + + if _APP_INSTANCE is None: + from pathlib import Path + + from flask import Flask + from werkzeug.middleware.proxy_fix import ProxyFix + from werkzeug.exceptions import HTTPException + + from outlook_web import config, legacy + from outlook_web.db import register_db + from outlook_web.security.csrf import init_csrf + from outlook_web.routes import ( + accounts, + audit, + emails, + groups, + oauth, + pages, + scheduler, + settings, + system, + tags, + temp_emails, + ) + + # 初始化(DB/目录等) + legacy.init_app() + + repo_root = Path(__file__).resolve().parents[1] + app = Flask( + __name__, + template_folder=str(repo_root / "templates"), + static_folder=str(repo_root / "static"), + static_url_path="/static", + ) + + app.secret_key = config.require_secret_key() + app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 24 * 7 # 7 天 + app.config["SESSION_COOKIE_HTTPONLY"] = True + app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) + + # DB teardown(请求结束释放连接) + register_db(app) + + # CSRF(可选) + _csrf, csrf_exempt, _generate_csrf = init_csrf(app) + + # trace_id + error 结构标准化(复用 legacy 的 hooks 逻辑,保持契约一致) + app.before_request(legacy.ensure_trace_id) + app.after_request(legacy.attach_trace_id_and_normalize_errors) + app.register_error_handler(HTTPException, legacy.handle_http_exception) + app.register_error_handler(Exception, legacy.handle_exception) + + # Blueprint 路由注册(URL 不变) + app.register_blueprint(pages.create_blueprint(impl=legacy, csrf_exempt=csrf_exempt)) + app.register_blueprint(groups.create_blueprint(impl=legacy)) + app.register_blueprint(tags.create_blueprint(impl=legacy)) + app.register_blueprint(accounts.create_blueprint(impl=legacy)) + app.register_blueprint(emails.create_blueprint(impl=legacy)) + app.register_blueprint(temp_emails.create_blueprint(impl=legacy)) + app.register_blueprint(oauth.create_blueprint(impl=legacy)) + app.register_blueprint(settings.create_blueprint(impl=legacy)) + app.register_blueprint(scheduler.create_blueprint(impl=legacy)) + app.register_blueprint(system.create_blueprint(impl=legacy)) + app.register_blueprint(audit.create_blueprint(impl=legacy)) + + _APP_INSTANCE = app + + if autostart_scheduler is None: + from outlook_web import legacy + + if legacy.should_autostart_scheduler(): + legacy.init_scheduler() + elif autostart_scheduler: + from outlook_web import legacy + + legacy.init_scheduler() + + return _APP_INSTANCE diff --git a/outlook_web/audit.py b/outlook_web/audit.py new file mode 100644 index 0000000..ce6895f --- /dev/null +++ b/outlook_web/audit.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from flask import g + +from outlook_web.db import get_db +from outlook_web.security.auth import get_client_ip + + +def log_audit(action: str, resource_type: str, resource_id: str = None, details: str = None): + """ + 记录审计日志 + :param action: 操作类型(如 'export', 'delete', 'update') + :param resource_type: 资源类型(如 'account', 'group') + :param resource_id: 资源ID + :param details: 详细信息 + """ + try: + db = get_db() + user_ip = get_client_ip() + trace_id_value = None + try: + trace_id_value = getattr(g, "trace_id", None) + except Exception: + trace_id_value = None + db.execute( + """ + INSERT INTO audit_logs (action, resource_type, resource_id, user_ip, details, trace_id) + VALUES (?, ?, ?, ?, ?, ?) + """, + (action, resource_type, resource_id, user_ip, details, trace_id_value), + ) + db.commit() + except Exception: + # 审计日志失败不应影响主流程 + pass + + +def query_audit_logs( + *, + limit: int, + offset: int, + action: str, + resource_type: str, +) -> Dict[str, Any]: + db = get_db() + limit = max(1, min(limit or 50, 200)) + offset = max(0, offset or 0) + + action = (action or "").strip() + resource_type = (resource_type or "").strip() + + where_clauses: List[str] = [] + params: List[Any] = [] + if action: + where_clauses.append("action = ?") + params.append(action) + if resource_type: + where_clauses.append("resource_type = ?") + params.append(resource_type) + + where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else "" + + total_row = db.execute(f"SELECT COUNT(*) as c FROM audit_logs {where_sql}", params).fetchone() + total = total_row["c"] if total_row else 0 + + rows = db.execute( + f""" + SELECT id, action, resource_type, resource_id, user_ip, details, trace_id, created_at + FROM audit_logs + {where_sql} + ORDER BY created_at DESC, id DESC + LIMIT ? OFFSET ? + """, + params + [limit, offset], + ).fetchall() + + logs: List[Dict[str, Any]] = [] + for r in rows: + details_text: Optional[str] = r["details"] or "" + if isinstance(details_text, str) and len(details_text) > 800: + details_text = details_text[:800] + "..." + logs.append( + { + "id": r["id"], + "action": r["action"], + "resource_type": r["resource_type"], + "resource_id": r["resource_id"], + "user_ip": r["user_ip"], + "details": details_text, + "trace_id": r["trace_id"], + "created_at": r["created_at"], + } + ) + + return { + "logs": logs, + "total": total, + "limit": limit, + "offset": offset, + } + diff --git a/outlook_web/config.py b/outlook_web/config.py new file mode 100644 index 0000000..5fef71a --- /dev/null +++ b/outlook_web/config.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import os + + +def _getenv(key: str, default: str | None = None) -> str | None: + value = os.getenv(key) + if value is None: + return default + value = value.strip() + return value if value != "" else default + + +def require_secret_key() -> str: + secret_key = _getenv("SECRET_KEY") + if not secret_key: + raise RuntimeError( + "SECRET_KEY environment variable is required. " + "Generate one with: python -c 'import secrets; print(secrets.token_hex(32))'" + ) + return secret_key + + +def get_database_path() -> str: + return _getenv("DATABASE_PATH", "data/outlook_accounts.db") or "data/outlook_accounts.db" + + +def get_login_password_default() -> str: + return _getenv("LOGIN_PASSWORD", "admin123") or "admin123" + + +def get_gptmail_base_url() -> str: + return _getenv("GPTMAIL_BASE_URL", "https://mail.chatgpt.org.uk") or "https://mail.chatgpt.org.uk" + + +def get_gptmail_api_key_default() -> str: + return _getenv("GPTMAIL_API_KEY", "gpt-test") or "gpt-test" + + +def get_oauth_client_id() -> str: + return _getenv("OAUTH_CLIENT_ID", "24d9a0ed-8787-4584-883c-2fd79308940a") or "24d9a0ed-8787-4584-883c-2fd79308940a" + + +def get_oauth_redirect_uri() -> str: + return _getenv("OAUTH_REDIRECT_URI", "http://localhost:8080") or "http://localhost:8080" + + +def env_true(key: str, default: bool) -> bool: + """ + 与旧实现保持一致:只有值为 'true'(忽略大小写)才视为 True;其它值均为 False。 + """ + value = _getenv(key, "true" if default else "false") or ("true" if default else "false") + return value.lower() == "true" + + +def get_scheduler_autostart_default() -> bool: + return env_true("SCHEDULER_AUTOSTART", True) + diff --git a/outlook_web/db.py b/outlook_web/db.py new file mode 100644 index 0000000..1051137 --- /dev/null +++ b/outlook_web/db.py @@ -0,0 +1,652 @@ +from __future__ import annotations + +import os +import sqlite3 +import time +from typing import Optional + +from flask import g + +from outlook_web import config +from outlook_web.errors import generate_trace_id, sanitize_error_details +from outlook_web.security.crypto import ( + encrypt_data, + hash_password, + is_encrypted, + is_password_hashed, +) + +# 数据库 Schema 版本(用于升级可验证/可诊断) +DB_SCHEMA_VERSION = 2 +DB_SCHEMA_VERSION_KEY = "db_schema_version" +DB_SCHEMA_LAST_UPGRADE_TRACE_ID_KEY = "db_schema_last_upgrade_trace_id" +DB_SCHEMA_LAST_UPGRADE_ERROR_KEY = "db_schema_last_upgrade_error" + + +def create_sqlite_connection(database_path: Optional[str] = None) -> sqlite3.Connection: + """创建 SQLite 连接(带基础一致性/并发配置)""" + path = database_path or config.get_database_path() + conn = sqlite3.connect(path, timeout=30) + conn.row_factory = sqlite3.Row + try: + conn.execute("PRAGMA foreign_keys = ON") + except Exception: + pass + try: + conn.execute("PRAGMA busy_timeout = 5000") + except Exception: + pass + return conn + + +def get_db() -> sqlite3.Connection: + """获取数据库连接(绑定到 flask.g 生命周期)""" + db = getattr(g, "_database", None) + if db is None: + db = g._database = create_sqlite_connection() + return db + + +def close_db(_exception=None): + """关闭数据库连接""" + db = getattr(g, "_database", None) + if db is not None: + db.close() + + +def register_db(app): + """向 Flask app 注册 teardown,保证请求结束释放连接""" + app.teardown_appcontext(close_db) + + +def init_db(database_path: Optional[str] = None): + """初始化数据库(含升级记录与可验证状态)""" + path = database_path or config.get_database_path() + login_password_default = config.get_login_password_default() + gptmail_api_key_default = config.get_gptmail_api_key_default() + + db_existed = False + try: + db_existed = os.path.exists(path) and os.path.getsize(path) > 0 + except Exception: + db_existed = False + + conn = create_sqlite_connection(path) + cursor = conn.cursor() + + # 基础并发配置(对既存数据库同样生效) + try: + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") + except Exception: + pass + + migration_id = None + migration_trace_id = None + upgrading = False + + try: + # 获取写锁:避免多进程启动时并发迁移导致的偶发失败 + cursor.execute("BEGIN IMMEDIATE") + + # 创建设置表 + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + # 数据库迁移记录(用于升级可验证/可诊断) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_version INTEGER NOT NULL, + to_version INTEGER NOT NULL, + status TEXT NOT NULL, + started_at REAL NOT NULL, + finished_at REAL, + error TEXT, + trace_id TEXT + ) + """ + ) + cursor.execute( + """ + CREATE INDEX IF NOT EXISTS idx_schema_migrations_started_at + ON schema_migrations(started_at) + """ + ) + + # 在锁内读取当前 schema 版本(保证一致性) + row = cursor.execute( + "SELECT value FROM settings WHERE key = ?", + (DB_SCHEMA_VERSION_KEY,), + ).fetchone() + try: + current_version = int(row["value"]) if row and row["value"] is not None else 0 + except Exception: + current_version = 0 + + upgrading = current_version < DB_SCHEMA_VERSION + if upgrading: + migration_trace_id = generate_trace_id() + if db_existed: + try: + print("=" * 60) + print(f"[升级提示] 检测到数据库需要升级:v{current_version} -> v{DB_SCHEMA_VERSION}") + print(f"[升级提示] 强烈建议先备份数据库文件:{path}") + print(f"[升级提示] 示例:cp \"{path}\" \"{path}.backup\"") + print(f"[升级提示] trace_id={migration_trace_id}") + print("=" * 60) + except Exception: + pass + + cursor.execute( + """ + INSERT INTO schema_migrations (from_version, to_version, status, started_at, trace_id) + VALUES (?, ?, 'running', ?, ?) + """, + (current_version, DB_SCHEMA_VERSION, time.time(), migration_trace_id), + ) + migration_id = cursor.lastrowid + cursor.execute("SAVEPOINT migration_work") + + # -------------------- Schema 创建/迁移(幂等) -------------------- + + # 分组表 + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + description TEXT, + color TEXT DEFAULT '#1a1a1a', + proxy_url TEXT, + is_system INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + # 邮箱账号表 + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + password TEXT, + client_id TEXT NOT NULL, + refresh_token TEXT NOT NULL, + group_id INTEGER, + remark TEXT, + status TEXT DEFAULT 'active', + last_refresh_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (group_id) REFERENCES groups (id) + ) + """ + ) + + # 临时邮箱表 + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS temp_emails ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + status TEXT DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + # 临时邮件表(存储从 GPTMail 获取的邮件) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS temp_email_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT UNIQUE NOT NULL, + email_address TEXT NOT NULL, + from_address TEXT, + subject TEXT, + content TEXT, + html_content TEXT, + has_html INTEGER DEFAULT 0, + timestamp INTEGER, + raw_content TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (email_address) REFERENCES temp_emails (email) + ) + """ + ) + + # 刷新记录表(账号级) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS account_refresh_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + account_email TEXT NOT NULL, + refresh_type TEXT DEFAULT 'manual', + status TEXT NOT NULL, + error_message TEXT, + run_id TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE + ) + """ + ) + cursor.execute( + """ + CREATE INDEX IF NOT EXISTS idx_account_refresh_logs_run_id + ON account_refresh_logs(run_id) + """ + ) + + # 审计日志表 + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT, + user_ip TEXT, + details TEXT, + trace_id TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + cursor.execute( + """ + CREATE INDEX IF NOT EXISTS idx_audit_logs_trace_id + ON audit_logs(trace_id) + """ + ) + cursor.execute( + """ + CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at + ON audit_logs(created_at) + """ + ) + + # 标签表 + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + color TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + # 账号标签关联表 + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS account_tags ( + account_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (account_id, tag_id), + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE + ) + """ + ) + + # 分布式锁(用于刷新冲突控制/多进程一致性) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS distributed_locks ( + name TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + acquired_at REAL NOT NULL, + expires_at REAL NOT NULL + ) + """ + ) + + # 导出二次验证 Token(持久化,支持重启/多进程) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS export_verify_tokens ( + token TEXT PRIMARY KEY, + ip TEXT, + user_agent TEXT, + expires_at REAL NOT NULL, + created_at REAL NOT NULL + ) + """ + ) + + # 登录速率限制(持久化,支持重启/多进程) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS login_attempts ( + ip TEXT PRIMARY KEY, + count INTEGER NOT NULL, + last_attempt_at REAL NOT NULL, + locked_until_at REAL + ) + """ + ) + + # 刷新运行记录(用于“最近触发/来源/统计/运行中状态”的可验证性) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS refresh_runs ( + id TEXT PRIMARY KEY, + trigger_source TEXT NOT NULL, + status TEXT NOT NULL, + requested_by_ip TEXT, + requested_by_user_agent TEXT, + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + finished_at TIMESTAMP, + total INTEGER DEFAULT 0, + success_count INTEGER DEFAULT 0, + failed_count INTEGER DEFAULT 0, + message TEXT, + trace_id TEXT + ) + """ + ) + cursor.execute( + """ + CREATE INDEX IF NOT EXISTS idx_refresh_runs_started_at + ON refresh_runs(started_at) + """ + ) + cursor.execute( + """ + CREATE INDEX IF NOT EXISTS idx_refresh_runs_trigger_source + ON refresh_runs(trigger_source) + """ + ) + + # 兼容旧 schema:补齐缺失列 + cursor.execute("PRAGMA table_info(accounts)") + columns = [col[1] for col in cursor.fetchall()] + + if "group_id" not in columns: + cursor.execute("ALTER TABLE accounts ADD COLUMN group_id INTEGER DEFAULT 1") + if "remark" not in columns: + cursor.execute("ALTER TABLE accounts ADD COLUMN remark TEXT") + if "status" not in columns: + cursor.execute("ALTER TABLE accounts ADD COLUMN status TEXT DEFAULT 'active'") + if "updated_at" not in columns: + cursor.execute("ALTER TABLE accounts ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP") + if "last_refresh_at" not in columns: + cursor.execute("ALTER TABLE accounts ADD COLUMN last_refresh_at TIMESTAMP") + + cursor.execute("PRAGMA table_info(groups)") + group_columns = [col[1] for col in cursor.fetchall()] + if "is_system" not in group_columns: + cursor.execute("ALTER TABLE groups ADD COLUMN is_system INTEGER DEFAULT 0") + if "proxy_url" not in group_columns: + cursor.execute("ALTER TABLE groups ADD COLUMN proxy_url TEXT") + + cursor.execute("PRAGMA table_info(account_refresh_logs)") + refresh_log_columns = [col[1] for col in cursor.fetchall()] + if "run_id" not in refresh_log_columns: + cursor.execute("ALTER TABLE account_refresh_logs ADD COLUMN run_id TEXT") + + cursor.execute("PRAGMA table_info(audit_logs)") + audit_columns = [col[1] for col in cursor.fetchall()] + if "trace_id" not in audit_columns: + cursor.execute("ALTER TABLE audit_logs ADD COLUMN trace_id TEXT") + + # 默认分组 + cursor.execute( + """ + INSERT OR IGNORE INTO groups (name, description, color) + VALUES ('默认分组', '未分组的邮箱', '#666666') + """ + ) + + # 临时邮箱分组(系统分组) + cursor.execute( + """ + INSERT OR IGNORE INTO groups (name, description, color, is_system) + VALUES ('临时邮箱', 'GPTMail 临时邮箱服务', '#00bcf2', 1) + """ + ) + + # 初始化默认设置:登录密码(自动迁移明文 -> 哈希) + cursor.execute("SELECT value FROM settings WHERE key = 'login_password'") + existing_password = cursor.fetchone() + if existing_password: + password_value = existing_password[0] + if password_value and not is_password_hashed(password_value): + hashed_password = hash_password(password_value) + cursor.execute( + """ + UPDATE settings SET value = ? WHERE key = 'login_password' + """, + (hashed_password,), + ) + else: + hashed_password = hash_password(login_password_default) + cursor.execute( + """ + INSERT INTO settings (key, value) + VALUES ('login_password', ?) + """, + (hashed_password,), + ) + + cursor.execute( + """ + INSERT OR IGNORE INTO settings (key, value) + VALUES ('gptmail_api_key', ?) + """, + (gptmail_api_key_default,), + ) + + # 初始化刷新配置 + cursor.execute( + """ + INSERT OR IGNORE INTO settings (key, value) + VALUES ('refresh_interval_days', '30') + """ + ) + cursor.execute( + """ + INSERT OR IGNORE INTO settings (key, value) + VALUES ('refresh_delay_seconds', '5') + """ + ) + cursor.execute( + """ + INSERT OR IGNORE INTO settings (key, value) + VALUES ('refresh_cron', '0 2 * * *') + """ + ) + cursor.execute( + """ + INSERT OR IGNORE INTO settings (key, value) + VALUES ('use_cron_schedule', 'false') + """ + ) + cursor.execute( + """ + INSERT OR IGNORE INTO settings (key, value) + VALUES ('enable_scheduled_refresh', 'true') + """ + ) + + # 索引(性能基线) + cursor.execute( + """ + CREATE INDEX IF NOT EXISTS idx_accounts_last_refresh_at + ON accounts(last_refresh_at) + """ + ) + cursor.execute( + """ + CREATE INDEX IF NOT EXISTS idx_accounts_status + ON accounts(status) + """ + ) + cursor.execute( + """ + CREATE INDEX IF NOT EXISTS idx_accounts_group_id + ON accounts(group_id) + """ + ) + cursor.execute( + """ + CREATE INDEX IF NOT EXISTS idx_account_refresh_logs_account_id + ON account_refresh_logs(account_id) + """ + ) + cursor.execute( + """ + CREATE INDEX IF NOT EXISTS idx_account_refresh_logs_account_id_id + ON account_refresh_logs(account_id, id) + """ + ) + cursor.execute( + """ + CREATE INDEX IF NOT EXISTS idx_account_tags_tag_id + ON account_tags(tag_id) + """ + ) + + # 迁移现有明文数据为加密数据 + migrate_sensitive_data(conn) + + # 升级完成标记:写入 schema 版本,便于“升级可验证” + cursor.execute( + """ + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + """, + (DB_SCHEMA_VERSION_KEY, str(DB_SCHEMA_VERSION)), + ) + + if upgrading and migration_id is not None: + try: + cursor.execute("RELEASE SAVEPOINT migration_work") + except Exception: + pass + cursor.execute( + """ + UPDATE schema_migrations + SET status = 'success', finished_at = ?, error = NULL + WHERE id = ? + """, + (time.time(), migration_id), + ) + cursor.execute( + """ + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + """, + (DB_SCHEMA_LAST_UPGRADE_TRACE_ID_KEY, migration_trace_id), + ) + cursor.execute( + """ + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, '', CURRENT_TIMESTAMP) + """, + (DB_SCHEMA_LAST_UPGRADE_ERROR_KEY,), + ) + + conn.commit() + + except Exception as e: + error_text = sanitize_error_details(str(e)) + try: + if upgrading and migration_id is not None: + try: + cursor.execute("ROLLBACK TO SAVEPOINT migration_work") + cursor.execute("RELEASE SAVEPOINT migration_work") + except Exception: + pass + + cursor.execute( + """ + UPDATE schema_migrations + SET status = 'failed', finished_at = ?, error = ? + WHERE id = ? + """, + (time.time(), error_text, migration_id), + ) + cursor.execute( + """ + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + """, + (DB_SCHEMA_LAST_UPGRADE_TRACE_ID_KEY, migration_trace_id), + ) + cursor.execute( + """ + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + """, + (DB_SCHEMA_LAST_UPGRADE_ERROR_KEY, error_text), + ) + conn.commit() + else: + conn.rollback() + except Exception: + try: + conn.rollback() + except Exception: + pass + raise + finally: + try: + conn.close() + except Exception: + pass + + +def migrate_sensitive_data(conn: sqlite3.Connection): + """迁移现有明文敏感数据为加密数据""" + cursor = conn.cursor() + + # 获取所有账号 + cursor.execute("SELECT id, password, refresh_token FROM accounts") + accounts = cursor.fetchall() + + migrated_count = 0 + for account_id, password, refresh_token in accounts: + needs_update = False + new_password = password + new_refresh_token = refresh_token + + # 检查并加密 password + if password and not is_encrypted(password): + new_password = encrypt_data(password) + needs_update = True + + # 检查并加密 refresh_token + if refresh_token and not is_encrypted(refresh_token): + new_refresh_token = encrypt_data(refresh_token) + needs_update = True + + # 更新数据库 + if needs_update: + cursor.execute( + """ + UPDATE accounts + SET password = ?, refresh_token = ? + WHERE id = ? + """, + (new_password, new_refresh_token, account_id), + ) + migrated_count += 1 + + if migrated_count > 0: + print(f"已迁移 {migrated_count} 个账号的敏感数据为加密存储") + diff --git a/outlook_web/errors.py b/outlook_web/errors.py new file mode 100644 index 0000000..570dd1a --- /dev/null +++ b/outlook_web/errors.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import json +import logging +import re +import uuid +from typing import Any, Dict, Optional + +from flask import current_app, g + +_FALLBACK_LOGGER = logging.getLogger("outlook_web.errors") +if not _FALLBACK_LOGGER.handlers: + _FALLBACK_LOGGER.addHandler(logging.NullHandler()) +_FALLBACK_LOGGER.propagate = False + + +def generate_trace_id() -> str: + return uuid.uuid4().hex + + +def sanitize_error_details(details: Optional[str]) -> str: + if not details: + return "" + sanitized = details + patterns = [ + (r"(?i)(bearer\s+)[A-Za-z0-9\-._~\+/]+=*", r"\1***"), + ( + r"(?i)(refresh_token|access_token|token|password|passwd|secret)\s*[:=]\s*\"?[A-Za-z0-9\-._~\+/]+=*\"?", + r"\1=***", + ), + (r"(?i)(\"refresh_token\"\s*:\s*\")[^\"]+(\"?)", r"\1***\2"), + (r"(?i)(\"access_token\"\s*:\s*\")[^\"]+(\"?)", r"\1***\2"), + (r"(?i)(\"password\"\s*:\s*\")[^\"]+(\"?)", r"\1***\2"), + (r"(?i)(client_secret|refresh_token|access_token)=[^&\s]+", r"\1=***"), + ] + for pattern, repl in patterns: + sanitized = re.sub(pattern, repl, sanitized) + return sanitized + + +def build_error_payload( + code: str, + message: str, + err_type: str = "Error", + status: int = 500, + details: Any = None, + trace_id: Optional[str] = None, +) -> Dict[str, Any]: + if not isinstance(message, str): + message = str(message) + sanitized_message = sanitize_error_details(message) if message else "" + + if details is not None and not isinstance(details, str): + try: + details = json.dumps(details, ensure_ascii=False) + except Exception: + details = str(details) + sanitized_details = sanitize_error_details(details) if details else "" + + request_trace_id = None + try: + request_trace_id = getattr(g, "trace_id", None) + except Exception: + request_trace_id = None + + trace_id_value = trace_id or request_trace_id or generate_trace_id() + payload = { + "code": code, + "message": sanitized_message or "请求失败", + "type": err_type, + "status": status, + "details": sanitized_details, + "trace_id": trace_id_value, + } + + try: + current_app.logger.error( + "trace_id=%s code=%s status=%s type=%s details=%s", + trace_id_value, + code, + status, + err_type, + sanitized_details, + ) + except Exception: + try: + _FALLBACK_LOGGER.error( + "trace_id=%s code=%s status=%s type=%s details=%s", + trace_id_value, + code, + status, + err_type, + sanitized_details, + ) + except Exception: + pass + + return payload diff --git a/outlook_web/legacy.py b/outlook_web/legacy.py new file mode 100644 index 0000000..6e2dc21 --- /dev/null +++ b/outlook_web/legacy.py @@ -0,0 +1,5553 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Outlook 邮件 Web 应用 +基于 Flask 的 Web 界面,支持多邮箱管理和邮件查看 +使用 SQLite 数据库存储邮箱信息,支持分组管理 +支持 GPTMail 临时邮箱服务 +""" + +import email +import imaplib +import sqlite3 +import os +import hashlib +import secrets +import time +import json +import re +import uuid +import bcrypt +import base64 +import html +from datetime import datetime, timedelta, timezone +from email.header import decode_header +from typing import Optional, List, Dict, Any +from pathlib import Path +from urllib.parse import quote +from flask import Flask, render_template, request, jsonify, g, session, redirect, url_for, Response +from functools import wraps +import requests +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from werkzeug.exceptions import HTTPException + +from outlook_web import config +from outlook_web.errors import build_error_payload, generate_trace_id, sanitize_error_details +from outlook_web.security.crypto import ( + decrypt_data, + encrypt_data, + get_cipher, + get_encryption_key, + hash_password, + is_encrypted, + is_password_hashed, + verify_password, +) +from outlook_web.db import ( + DB_SCHEMA_LAST_UPGRADE_ERROR_KEY, + DB_SCHEMA_LAST_UPGRADE_TRACE_ID_KEY, + DB_SCHEMA_VERSION, + DB_SCHEMA_VERSION_KEY, + create_sqlite_connection, + get_db, + init_db, + migrate_sensitive_data, + register_db, +) +from outlook_web.security.auth import ( + ATTEMPT_WINDOW, + EXPORT_VERIFY_TOKEN_TTL_SECONDS, + LOCKOUT_DURATION, + MAX_LOGIN_ATTEMPTS, + check_rate_limit, + check_export_verify_token, + consume_export_verify_token, + get_client_ip, + get_user_agent, + issue_export_verify_token, + login_required, + record_login_failure, + reset_login_attempts, +) +from outlook_web.security.csrf import CSRF_AVAILABLE, init_csrf +from outlook_web.audit import log_audit, query_audit_logs + +_REPO_ROOT = Path(__file__).resolve().parents[1] + +app = Flask( + __name__, + template_folder=str(_REPO_ROOT / "templates"), + static_folder=str(_REPO_ROOT / "static"), + static_url_path="/static", +) +# 强制从环境变量读取 secret_key,不提供默认值以防止安全漏洞 +secret_key = config.require_secret_key() +app.secret_key = secret_key +# 设置 session 过期时间(默认 7 天) +app.config['PERMANENT_SESSION_LIFETIME'] = 60 * 60 * 24 * 7 # 7 天 + +# Session Cookie 配置(适用于 HTTPS 代理环境) +app.config['SESSION_COOKIE_HTTPONLY'] = True +app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' + +# 信任代理头(适用于反向代理环境) +# 这确保 Flask 正确识别 HTTPS 请求 +from werkzeug.middleware.proxy_fix import ProxyFix +app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) + +# DB teardown(请求结束释放连接) +register_db(app) + + + +# 初始化 CSRF 保护(如果可用) +csrf, csrf_exempt, generate_csrf = init_csrf(app) + +# 登录密码配置(可以修改为你想要的密码) +LOGIN_PASSWORD = config.get_login_password_default() + +# ==================== 配置 ==================== +# Token 端点 +TOKEN_URL_LIVE = "https://login.live.com/oauth20_token.srf" +TOKEN_URL_GRAPH = "https://login.microsoftonline.com/common/oauth2/v2.0/token" +TOKEN_URL_IMAP = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" + +# IMAP 服务器配置 +IMAP_SERVER_OLD = "outlook.office365.com" +IMAP_SERVER_NEW = "outlook.live.com" +IMAP_PORT = 993 + +# 数据库文件 +DATABASE = config.get_database_path() + +# GPTMail API 配置 +GPTMAIL_BASE_URL = config.get_gptmail_base_url() +GPTMAIL_API_KEY = config.get_gptmail_api_key_default() # 测试 API Key,可以修改为正式 Key + +# 临时邮箱分组 ID(系统保留) +TEMP_EMAIL_GROUP_ID = -1 + +# OAuth 配置 +OAUTH_CLIENT_ID = config.get_oauth_client_id() +OAUTH_REDIRECT_URI = config.get_oauth_redirect_uri() +OAUTH_SCOPES = [ + "offline_access", + "https://graph.microsoft.com/Mail.Read", + "https://graph.microsoft.com/Mail.ReadWrite", + "https://graph.microsoft.com/User.Read" +] + + +# +# 密码/加密工具已模块化到 outlook_web.security.crypto +# + + +# ==================== 错误处理工具 ==================== +def utcnow() -> datetime: + """返回 naive UTC 时间(等价于旧的 datetime.utcnow()),避免 Python 3.13+ deprecation warning。""" + return datetime.now(timezone.utc).replace(tzinfo=None) + + +@app.before_request +def ensure_trace_id(): + """为每个请求生成/透传 trace_id,便于前后端统一追踪""" + incoming = request.headers.get('X-Trace-Id') or request.headers.get('X-Request-Id') + if incoming: + incoming = incoming.strip() + g.trace_id = incoming[:64] + else: + g.trace_id = generate_trace_id() + + +def summarize_fallback_failures(method_errors: Dict[str, Any], labels: Dict[str, str]) -> str: + """将多方式回退的失败原因聚合成“中文可理解”的摘要文本(用于 error.details 展示)。""" + lines: List[str] = [] + + for key, label in labels.items(): + if key not in method_errors: + continue + err = method_errors.get(key) + if err is None: + text = "未知错误" + elif isinstance(err, dict): + msg = (err.get("message") or err.get("error") or "").strip() + code = (err.get("code") or "").strip() + status = err.get("status") + meta_parts: List[str] = [] + if code: + meta_parts.append(f"code={code}") + if status: + meta_parts.append(f"status={status}") + meta = f" ({', '.join(meta_parts)})" if meta_parts else "" + if msg: + text = f"{msg}{meta}" + else: + raw = json.dumps(err, ensure_ascii=False) + text = raw[:400] + ("..." if len(raw) > 400 else "") + elif isinstance(err, list): + preview_items = [str(x) for x in err[:3]] + preview = "; ".join(preview_items) + if len(err) > 3: + preview += f" ...(共 {len(err)} 条)" + text = preview + else: + text = str(err) + + lines.append(f"{label}:{text}") + + return "\n".join(lines).strip() + + +@app.after_request +def attach_trace_id_and_normalize_errors(response): + """统一写入 X-Trace-Id,并把 legacy 的字符串错误格式标准化为结构化错误""" + trace_id_value = None + try: + trace_id_value = getattr(g, 'trace_id', None) + if trace_id_value: + response.headers.setdefault('X-Trace-Id', trace_id_value) + except Exception: + trace_id_value = None + + try: + if response.is_streamed: + return response + + content_type = response.headers.get('Content-Type', '') or '' + if not content_type.startswith('application/json'): + return response + + data = response.get_json(silent=True) + if not isinstance(data, dict): + return response + + if data.get('success') is not False: + return response + + # 统一补齐 trace_id/status 等字段 + if isinstance(data.get('error'), dict): + error_obj = dict(data['error']) + mutated = False + if not error_obj.get('trace_id') and trace_id_value: + error_obj['trace_id'] = trace_id_value + mutated = True + if not error_obj.get('status'): + error_obj['status'] = response.status_code if response.status_code >= 400 else 400 + mutated = True + if not error_obj.get('code'): + error_obj['code'] = 'UNKNOWN_ERROR' + mutated = True + if not error_obj.get('message'): + error_obj['message'] = '请求失败' + mutated = True + if mutated: + new_data = dict(data) + new_data['error'] = error_obj + response.set_data(json.dumps(new_data, ensure_ascii=False)) + return response + + # legacy:error 为字符串 + if isinstance(data.get('error'), str): + legacy_message = data.get('error') or '请求失败' + status_for_payload = response.status_code if response.status_code >= 400 else 400 + error_payload = build_error_payload( + code='LEGACY_ERROR', + message=legacy_message, + err_type='LegacyError', + status=status_for_payload, + details='', + trace_id=trace_id_value + ) + new_data = dict(data) + new_data['error'] = error_payload + response.set_data(json.dumps(new_data, ensure_ascii=False)) + return response + except Exception: + return response + + return response + + +def get_response_details(response: requests.Response) -> Any: + try: + return response.json() + except Exception: + return response.text or response.reason + + +# ==================== 数据库操作 ==================== + +def create_sqlite_connection() -> sqlite3.Connection: + """创建 SQLite 连接(带基础一致性/并发配置)""" + conn = sqlite3.connect(DATABASE, timeout=30) + conn.row_factory = sqlite3.Row + try: + conn.execute("PRAGMA foreign_keys = ON") + except Exception: + pass + try: + conn.execute("PRAGMA busy_timeout = 5000") + except Exception: + pass + return conn + + +def get_db(): + """获取数据库连接""" + db = getattr(g, '_database', None) + if db is None: + db = g._database = create_sqlite_connection() + return db + + +def init_db(): + """初始化数据库(含升级记录与可验证状态)""" + db_existed = False + try: + db_existed = os.path.exists(DATABASE) and os.path.getsize(DATABASE) > 0 + except Exception: + db_existed = False + + conn = create_sqlite_connection() + cursor = conn.cursor() + + # 基础并发配置(对既存数据库同样生效) + try: + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") + except Exception: + pass + + migration_id = None + migration_trace_id = None + upgrading = False + + try: + # 获取写锁:避免多进程启动时并发迁移导致的偶发失败 + cursor.execute('BEGIN IMMEDIATE') + + # 创建设置表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 数据库迁移记录(用于升级可验证/可诊断) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS schema_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_version INTEGER NOT NULL, + to_version INTEGER NOT NULL, + status TEXT NOT NULL, + started_at REAL NOT NULL, + finished_at REAL, + error TEXT, + trace_id TEXT + ) + ''') + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_schema_migrations_started_at + ON schema_migrations(started_at) + ''') + + # 在锁内读取当前 schema 版本(保证一致性) + row = cursor.execute( + "SELECT value FROM settings WHERE key = ?", + (DB_SCHEMA_VERSION_KEY,) + ).fetchone() + try: + current_version = int(row['value']) if row and row['value'] is not None else 0 + except Exception: + current_version = 0 + + upgrading = current_version < DB_SCHEMA_VERSION + if upgrading: + migration_trace_id = generate_trace_id() + if db_existed: + try: + print("=" * 60) + print(f"[升级提示] 检测到数据库需要升级:v{current_version} -> v{DB_SCHEMA_VERSION}") + print(f"[升级提示] 强烈建议先备份数据库文件:{DATABASE}") + print(f"[升级提示] 示例:cp \"{DATABASE}\" \"{DATABASE}.backup\"") + print(f"[升级提示] trace_id={migration_trace_id}") + print("=" * 60) + except Exception: + pass + + cursor.execute(''' + INSERT INTO schema_migrations (from_version, to_version, status, started_at, trace_id) + VALUES (?, ?, 'running', ?, ?) + ''', (current_version, DB_SCHEMA_VERSION, time.time(), migration_trace_id)) + migration_id = cursor.lastrowid + cursor.execute("SAVEPOINT migration_work") + + # -------------------- Schema 创建/迁移(幂等) -------------------- + + # 分组表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + description TEXT, + color TEXT DEFAULT '#1a1a1a', + proxy_url TEXT, + is_system INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 邮箱账号表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + password TEXT, + client_id TEXT NOT NULL, + refresh_token TEXT NOT NULL, + group_id INTEGER, + remark TEXT, + status TEXT DEFAULT 'active', + last_refresh_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (group_id) REFERENCES groups (id) + ) + ''') + + # 临时邮箱表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS temp_emails ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + status TEXT DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 临时邮件表(存储从 GPTMail 获取的邮件) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS temp_email_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT UNIQUE NOT NULL, + email_address TEXT NOT NULL, + from_address TEXT, + subject TEXT, + content TEXT, + html_content TEXT, + has_html INTEGER DEFAULT 0, + timestamp INTEGER, + raw_content TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (email_address) REFERENCES temp_emails (email) + ) + ''') + + # 刷新记录表(账号级) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS account_refresh_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + account_email TEXT NOT NULL, + refresh_type TEXT DEFAULT 'manual', + status TEXT NOT NULL, + error_message TEXT, + run_id TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE + ) + ''') + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_account_refresh_logs_run_id + ON account_refresh_logs(run_id) + ''') + + # 审计日志表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT, + user_ip TEXT, + details TEXT, + trace_id TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_audit_logs_trace_id + ON audit_logs(trace_id) + ''') + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at + ON audit_logs(created_at) + ''') + + # 标签表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + color TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 账号标签关联表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS account_tags ( + account_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (account_id, tag_id), + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE + ) + ''') + + # 分布式锁(用于刷新冲突控制/多进程一致性) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS distributed_locks ( + name TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + acquired_at REAL NOT NULL, + expires_at REAL NOT NULL + ) + ''') + + # 导出二次验证 Token(持久化,支持重启/多进程) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS export_verify_tokens ( + token TEXT PRIMARY KEY, + ip TEXT, + user_agent TEXT, + expires_at REAL NOT NULL, + created_at REAL NOT NULL + ) + ''') + + # 登录速率限制(持久化,支持重启/多进程) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS login_attempts ( + ip TEXT PRIMARY KEY, + count INTEGER NOT NULL, + last_attempt_at REAL NOT NULL, + locked_until_at REAL + ) + ''') + + # 刷新运行记录(用于“最近触发/来源/统计/运行中状态”的可验证性) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS refresh_runs ( + id TEXT PRIMARY KEY, + trigger_source TEXT NOT NULL, + status TEXT NOT NULL, + requested_by_ip TEXT, + requested_by_user_agent TEXT, + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + finished_at TIMESTAMP, + total INTEGER DEFAULT 0, + success_count INTEGER DEFAULT 0, + failed_count INTEGER DEFAULT 0, + message TEXT, + trace_id TEXT + ) + ''') + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_refresh_runs_started_at + ON refresh_runs(started_at) + ''') + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_refresh_runs_trigger_source + ON refresh_runs(trigger_source) + ''') + + # 兼容旧 schema:补齐缺失列 + cursor.execute("PRAGMA table_info(accounts)") + columns = [col[1] for col in cursor.fetchall()] + + if 'group_id' not in columns: + cursor.execute('ALTER TABLE accounts ADD COLUMN group_id INTEGER DEFAULT 1') + if 'remark' not in columns: + cursor.execute('ALTER TABLE accounts ADD COLUMN remark TEXT') + if 'status' not in columns: + cursor.execute("ALTER TABLE accounts ADD COLUMN status TEXT DEFAULT 'active'") + if 'updated_at' not in columns: + cursor.execute('ALTER TABLE accounts ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP') + if 'last_refresh_at' not in columns: + cursor.execute('ALTER TABLE accounts ADD COLUMN last_refresh_at TIMESTAMP') + + cursor.execute("PRAGMA table_info(groups)") + group_columns = [col[1] for col in cursor.fetchall()] + if 'is_system' not in group_columns: + cursor.execute('ALTER TABLE groups ADD COLUMN is_system INTEGER DEFAULT 0') + if 'proxy_url' not in group_columns: + cursor.execute('ALTER TABLE groups ADD COLUMN proxy_url TEXT') + + cursor.execute("PRAGMA table_info(account_refresh_logs)") + refresh_log_columns = [col[1] for col in cursor.fetchall()] + if 'run_id' not in refresh_log_columns: + cursor.execute('ALTER TABLE account_refresh_logs ADD COLUMN run_id TEXT') + + cursor.execute("PRAGMA table_info(audit_logs)") + audit_columns = [col[1] for col in cursor.fetchall()] + if 'trace_id' not in audit_columns: + cursor.execute('ALTER TABLE audit_logs ADD COLUMN trace_id TEXT') + + # 默认分组 + cursor.execute(''' + INSERT OR IGNORE INTO groups (name, description, color) + VALUES ('默认分组', '未分组的邮箱', '#666666') + ''') + + # 临时邮箱分组(系统分组) + cursor.execute(''' + INSERT OR IGNORE INTO groups (name, description, color, is_system) + VALUES ('临时邮箱', 'GPTMail 临时邮箱服务', '#00bcf2', 1) + ''') + + # 初始化默认设置:登录密码(自动迁移明文 -> 哈希) + cursor.execute("SELECT value FROM settings WHERE key = 'login_password'") + existing_password = cursor.fetchone() + if existing_password: + password_value = existing_password[0] + if password_value and not is_password_hashed(password_value): + hashed_password = hash_password(password_value) + cursor.execute(''' + UPDATE settings SET value = ? WHERE key = 'login_password' + ''', (hashed_password,)) + else: + hashed_password = hash_password(LOGIN_PASSWORD) + cursor.execute(''' + INSERT INTO settings (key, value) + VALUES ('login_password', ?) + ''', (hashed_password,)) + + cursor.execute(''' + INSERT OR IGNORE INTO settings (key, value) + VALUES ('gptmail_api_key', ?) + ''', (GPTMAIL_API_KEY,)) + + # 初始化刷新配置 + cursor.execute(''' + INSERT OR IGNORE INTO settings (key, value) + VALUES ('refresh_interval_days', '30') + ''') + cursor.execute(''' + INSERT OR IGNORE INTO settings (key, value) + VALUES ('refresh_delay_seconds', '5') + ''') + cursor.execute(''' + INSERT OR IGNORE INTO settings (key, value) + VALUES ('refresh_cron', '0 2 * * *') + ''') + cursor.execute(''' + INSERT OR IGNORE INTO settings (key, value) + VALUES ('use_cron_schedule', 'false') + ''') + cursor.execute(''' + INSERT OR IGNORE INTO settings (key, value) + VALUES ('enable_scheduled_refresh', 'true') + ''') + + # 索引(性能基线) + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_accounts_last_refresh_at + ON accounts(last_refresh_at) + ''') + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_accounts_status + ON accounts(status) + ''') + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_accounts_group_id + ON accounts(group_id) + ''') + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_account_refresh_logs_account_id + ON account_refresh_logs(account_id) + ''') + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_account_refresh_logs_account_id_id + ON account_refresh_logs(account_id, id) + ''') + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_account_tags_tag_id + ON account_tags(tag_id) + ''') + + # 迁移现有明文数据为加密数据 + migrate_sensitive_data(conn) + + # 升级完成标记:写入 schema 版本,便于“升级可验证” + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ''', (DB_SCHEMA_VERSION_KEY, str(DB_SCHEMA_VERSION))) + + if upgrading and migration_id is not None: + try: + cursor.execute("RELEASE SAVEPOINT migration_work") + except Exception: + pass + cursor.execute(''' + UPDATE schema_migrations + SET status = 'success', finished_at = ?, error = NULL + WHERE id = ? + ''', (time.time(), migration_id)) + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ''', (DB_SCHEMA_LAST_UPGRADE_TRACE_ID_KEY, migration_trace_id)) + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, '', CURRENT_TIMESTAMP) + ''', (DB_SCHEMA_LAST_UPGRADE_ERROR_KEY,)) + + conn.commit() + + except Exception as e: + error_text = sanitize_error_details(str(e)) + try: + if upgrading and migration_id is not None: + try: + cursor.execute("ROLLBACK TO SAVEPOINT migration_work") + cursor.execute("RELEASE SAVEPOINT migration_work") + except Exception: + pass + + cursor.execute(''' + UPDATE schema_migrations + SET status = 'failed', finished_at = ?, error = ? + WHERE id = ? + ''', (time.time(), error_text, migration_id)) + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ''', (DB_SCHEMA_LAST_UPGRADE_TRACE_ID_KEY, migration_trace_id)) + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ''', (DB_SCHEMA_LAST_UPGRADE_ERROR_KEY, error_text)) + conn.commit() + else: + conn.rollback() + except Exception: + try: + conn.rollback() + except Exception: + pass + raise + finally: + try: + conn.close() + except Exception: + pass + + +def migrate_sensitive_data(conn): + """迁移现有明文敏感数据为加密数据""" + cursor = conn.cursor() + + # 获取所有账号 + cursor.execute('SELECT id, password, refresh_token FROM accounts') + accounts = cursor.fetchall() + + migrated_count = 0 + for account_id, password, refresh_token in accounts: + needs_update = False + new_password = password + new_refresh_token = refresh_token + + # 检查并加密 password + if password and not is_encrypted(password): + new_password = encrypt_data(password) + needs_update = True + + # 检查并加密 refresh_token + if refresh_token and not is_encrypted(refresh_token): + new_refresh_token = encrypt_data(refresh_token) + needs_update = True + + # 更新数据库 + if needs_update: + cursor.execute(''' + UPDATE accounts + SET password = ?, refresh_token = ? + WHERE id = ? + ''', (new_password, new_refresh_token, account_id)) + migrated_count += 1 + + if migrated_count > 0: + print(f"已迁移 {migrated_count} 个账号的敏感数据为加密存储") + + +# 兼容导出:数据库实现已集中到 outlook_web.db(迁移期保留旧函数名) +from outlook_web import db as _db # noqa: E402 + +create_sqlite_connection = _db.create_sqlite_connection +get_db = _db.get_db +init_db = _db.init_db +migrate_sensitive_data = _db.migrate_sensitive_data + + +# ==================== 应用初始化 ==================== + +_APP_INITIALIZED = False + +def init_app(): + """初始化应用(确保目录和数据库存在)""" + global _APP_INITIALIZED + if _APP_INITIALIZED: + return + + # 确保 templates/static 目录存在(支持零构建前端拆分) + try: + (_REPO_ROOT / "templates").mkdir(parents=True, exist_ok=True) + except Exception: + os.makedirs("templates", exist_ok=True) + + try: + (_REPO_ROOT / "static").mkdir(parents=True, exist_ok=True) + except Exception: + os.makedirs("static", exist_ok=True) + + # 确保数据目录存在 + data_dir = os.path.dirname(DATABASE) + if data_dir: + os.makedirs(data_dir, exist_ok=True) + + # 初始化数据库 + init_db() + + print("=" * 60) + print("Outlook 邮件 Web 应用已初始化") + print(f"数据库文件: {DATABASE}") + print(f"GPTMail API: {GPTMAIL_BASE_URL}") + print("=" * 60) + + _APP_INITIALIZED = True + +# 注意:应用初始化由 outlook_web.app:create_app() 调用(避免 import-time 副作用) + + +# ==================== 设置操作 ==================== + +def get_setting(key: str, default: str = '') -> str: + """获取设置值""" + db = get_db() + cursor = db.execute('SELECT value FROM settings WHERE key = ?', (key,)) + row = cursor.fetchone() + return row['value'] if row else default + + +def set_setting(key: str, value: str) -> bool: + """设置值""" + db = get_db() + try: + db.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ''', (key, value)) + db.commit() + return True + except Exception: + return False + + +def get_all_settings() -> Dict[str, str]: + """获取所有设置""" + db = get_db() + cursor = db.execute('SELECT key, value FROM settings') + rows = cursor.fetchall() + return {row['key']: row['value'] for row in rows} + + +def get_login_password() -> str: + """获取登录密码(优先从数据库读取)""" + password = get_setting('login_password') + return password if password else LOGIN_PASSWORD + + +def get_gptmail_api_key() -> str: + """获取 GPTMail API Key(优先从数据库读取)""" + api_key = get_setting('gptmail_api_key') + return api_key if api_key else GPTMAIL_API_KEY + + +# ==================== 分组操作 ==================== + +def load_groups() -> List[Dict]: + """加载所有分组(临时邮箱分组排在最前面)""" + db = get_db() + # 使用 CASE 语句让临时邮箱分组排在最前面 + cursor = db.execute(''' + SELECT * FROM groups + ORDER BY + CASE WHEN name = '临时邮箱' THEN 0 ELSE 1 END, + id + ''') + rows = cursor.fetchall() + return [dict(row) for row in rows] + + +def get_group_by_id(group_id: int) -> Optional[Dict]: + """根据 ID 获取分组""" + db = get_db() + cursor = db.execute('SELECT * FROM groups WHERE id = ?', (group_id,)) + row = cursor.fetchone() + return dict(row) if row else None + + +def add_group(name: str, description: str = '', color: str = '#1a1a1a', proxy_url: str = '') -> Optional[int]: + """添加分组""" + db = get_db() + try: + cursor = db.execute(''' + INSERT INTO groups (name, description, color, proxy_url) + VALUES (?, ?, ?, ?) + ''', (name, description, color, proxy_url or '')) + db.commit() + return cursor.lastrowid + except sqlite3.IntegrityError: + return None + + +def update_group(group_id: int, name: str, description: str, color: str, proxy_url: str = '') -> bool: + """更新分组""" + db = get_db() + try: + db.execute(''' + UPDATE groups SET name = ?, description = ?, color = ?, proxy_url = ? + WHERE id = ? + ''', (name, description, color, proxy_url or '', group_id)) + db.commit() + return True + except Exception: + return False + + +def get_default_group_id() -> int: + """获取默认分组 ID(不依赖固定 id=1,增强兼容性)""" + db = get_db() + try: + row = db.execute("SELECT id FROM groups WHERE name = '默认分组' LIMIT 1").fetchone() + return row['id'] if row else 1 + except Exception: + return 1 + + +def delete_group(group_id: int) -> bool: + """删除分组(将该分组下的邮箱移到默认分组)""" + db = get_db() + try: + row = db.execute('SELECT id, name, is_system FROM groups WHERE id = ?', (group_id,)).fetchone() + if not row: + return False + if row['is_system']: + return False + + default_group_id = get_default_group_id() + if group_id == default_group_id or row['name'] == '默认分组': + return False + + # 将该分组下的邮箱移到默认分组 + db.execute( + 'UPDATE accounts SET group_id = ?, updated_at = CURRENT_TIMESTAMP WHERE group_id = ?', + (default_group_id, group_id) + ) + + # 删除分组 + db.execute('DELETE FROM groups WHERE id = ?', (group_id,)) + db.commit() + return True + except Exception: + return False + + +def get_group_account_count(group_id: int) -> int: + """获取分组下的邮箱数量""" + db = get_db() + cursor = db.execute('SELECT COUNT(*) as count FROM accounts WHERE group_id = ?', (group_id,)) + row = cursor.fetchone() + return row['count'] if row else 0 + + +# ==================== 邮箱账号操作 ==================== + +def load_accounts(group_id: int = None) -> List[Dict]: + """从数据库加载邮箱账号""" + db = get_db() + if group_id: + cursor = db.execute(''' + SELECT a.*, g.name as group_name, g.color as group_color + FROM accounts a + LEFT JOIN groups g ON a.group_id = g.id + WHERE a.group_id = ? + ORDER BY a.created_at DESC + ''', (group_id,)) + else: + cursor = db.execute(''' + SELECT a.*, g.name as group_name, g.color as group_color + FROM accounts a + LEFT JOIN groups g ON a.group_id = g.id + ORDER BY a.created_at DESC + ''') + rows = cursor.fetchall() + + # 批量加载标签,避免 N+1 查询(1,000 账号场景) + tags_by_account: Dict[int, List[Dict[str, Any]]] = {} + account_ids: List[int] = [] + try: + account_ids = [int(r['id']) for r in rows] + except Exception: + account_ids = [] + + if account_ids: + try: + placeholders = ",".join(["?"] * len(account_ids)) + tag_rows = db.execute(f''' + SELECT at.account_id as account_id, t.* + FROM account_tags at + JOIN tags t ON t.id = at.tag_id + WHERE at.account_id IN ({placeholders}) + ORDER BY at.account_id ASC, t.created_at DESC + ''', account_ids).fetchall() + + for tr in tag_rows: + tag_dict = dict(tr) + acc_id = tag_dict.pop('account_id', None) + if acc_id is None: + continue + tags_by_account.setdefault(int(acc_id), []).append(tag_dict) + except Exception: + tags_by_account = {} + + accounts: List[Dict[str, Any]] = [] + for row in rows: + account = dict(row) + + # 解密敏感字段 + if account.get('password'): + try: + account['password'] = decrypt_data(account['password']) + except Exception: + pass # 解密失败保持原值 + if account.get('refresh_token'): + try: + account['refresh_token'] = decrypt_data(account['refresh_token']) + except Exception: + pass # 解密失败保持原值 + + account_id_value = account.get('id') + try: + account_id_value = int(account_id_value) + except Exception: + account_id_value = None + + account['tags'] = tags_by_account.get(account_id_value, []) if account_id_value is not None else [] + accounts.append(account) + return accounts + + +# ==================== 标签管理 ==================== + +def get_tags() -> List[Dict]: + """获取所有标签""" + db = get_db() + cursor = db.execute('SELECT * FROM tags ORDER BY created_at DESC') + return [dict(row) for row in cursor.fetchall()] + + +def add_tag(name: str, color: str) -> Optional[int]: + """添加标签""" + db = get_db() + try: + cursor = db.execute( + 'INSERT INTO tags (name, color) VALUES (?, ?)', + (name, color) + ) + db.commit() + return cursor.lastrowid + except sqlite3.IntegrityError: + return None + + +def delete_tag(tag_id: int) -> bool: + """删除标签""" + db = get_db() + cursor = db.execute('DELETE FROM tags WHERE id = ?', (tag_id,)) + db.commit() + return cursor.rowcount > 0 + + +def get_account_tags(account_id: int) -> List[Dict]: + """获取账号的标签""" + db = get_db() + cursor = db.execute(''' + SELECT t.* + FROM tags t + JOIN account_tags at ON t.id = at.tag_id + WHERE at.account_id = ? + ORDER BY t.created_at DESC + ''', (account_id,)) + return [dict(row) for row in cursor.fetchall()] + + +def add_account_tag(account_id: int, tag_id: int) -> bool: + """给账号添加标签""" + db = get_db() + try: + db.execute( + 'INSERT OR IGNORE INTO account_tags (account_id, tag_id) VALUES (?, ?)', + (account_id, tag_id) + ) + db.commit() + return True + except Exception: + return False + + +def remove_account_tag(account_id: int, tag_id: int) -> bool: + """移除账号标签""" + db = get_db() + db.execute( + 'DELETE FROM account_tags WHERE account_id = ? AND tag_id = ?', + (account_id, tag_id) + ) + db.commit() + return True + + + +def get_account_by_email(email_addr: str) -> Optional[Dict]: + """根据邮箱地址获取账号""" + db = get_db() + cursor = db.execute('SELECT * FROM accounts WHERE email = ?', (email_addr,)) + row = cursor.fetchone() + if not row: + return None + account = dict(row) + # 解密敏感字段 + if account.get('password'): + try: + account['password'] = decrypt_data(account['password']) + except Exception: + pass + if account.get('refresh_token'): + try: + account['refresh_token'] = decrypt_data(account['refresh_token']) + except Exception: + pass + return account + + +def get_account_by_id(account_id: int) -> Optional[Dict]: + """根据 ID 获取账号""" + db = get_db() + cursor = db.execute(''' + SELECT a.*, g.name as group_name, g.color as group_color + FROM accounts a + LEFT JOIN groups g ON a.group_id = g.id + WHERE a.id = ? + ''', (account_id,)) + row = cursor.fetchone() + if not row: + return None + account = dict(row) + # 解密敏感字段 + if account.get('password'): + try: + account['password'] = decrypt_data(account['password']) + except Exception: + pass + if account.get('refresh_token'): + try: + account['refresh_token'] = decrypt_data(account['refresh_token']) + except Exception: + pass + return account + + +def add_account( + email_addr: str, + password: str, + client_id: str, + refresh_token: str, + group_id: int = 1, + remark: str = '', + db: Optional[sqlite3.Connection] = None, + commit: bool = True +) -> bool: + """添加邮箱账号""" + db = db or get_db() + try: + # 加密敏感字段 + encrypted_password = encrypt_data(password) if password else password + encrypted_refresh_token = encrypt_data(refresh_token) if refresh_token else refresh_token + + db.execute(''' + INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark) + VALUES (?, ?, ?, ?, ?, ?) + ''', (email_addr, encrypted_password, client_id, encrypted_refresh_token, group_id, remark)) + if commit: + db.commit() + return True + except sqlite3.IntegrityError: + return False + except Exception: + return False + + +def update_account(account_id: int, email_addr: str, password: Optional[str], client_id: Optional[str], + refresh_token: Optional[str], group_id: int, remark: str, status: str) -> bool: + """更新邮箱账号""" + db = get_db() + try: + existing = db.execute(''' + SELECT password, client_id, refresh_token + FROM accounts + WHERE id = ? + ''', (account_id,)).fetchone() + if not existing: + return False + + new_client_id = client_id.strip() if isinstance(client_id, str) and client_id.strip() else existing['client_id'] + + encrypted_password = existing['password'] + if isinstance(password, str) and password.strip(): + encrypted_password = encrypt_data(password) + + encrypted_refresh_token = existing['refresh_token'] + if isinstance(refresh_token, str) and refresh_token.strip(): + encrypted_refresh_token = encrypt_data(refresh_token) + + if not email_addr or not new_client_id or not encrypted_refresh_token: + return False + + db.execute(''' + UPDATE accounts + SET email = ?, password = ?, client_id = ?, refresh_token = ?, + group_id = ?, remark = ?, status = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (email_addr, encrypted_password, new_client_id, encrypted_refresh_token, group_id, remark, status, account_id)) + db.commit() + return True + except Exception: + return False + + +def delete_account_by_id(account_id: int) -> bool: + """删除邮箱账号""" + db = get_db() + try: + db.execute('DELETE FROM accounts WHERE id = ?', (account_id,)) + db.commit() + return True + except Exception: + return False + + +def delete_account_by_email(email_addr: str) -> bool: + """根据邮箱地址删除账号""" + db = get_db() + try: + db.execute('DELETE FROM accounts WHERE email = ?', (email_addr,)) + db.commit() + return True + except Exception: + return False + + +# 兼容导出:repositories(SQL)已模块化到 outlook_web.repositories.* +from outlook_web.repositories import accounts as _accounts_repo # noqa: E402 +from outlook_web.repositories import groups as _groups_repo # noqa: E402 +from outlook_web.repositories import settings as _settings_repo # noqa: E402 +from outlook_web.repositories import tags as _tags_repo # noqa: E402 + +get_setting = _settings_repo.get_setting +set_setting = _settings_repo.set_setting +get_all_settings = _settings_repo.get_all_settings +get_login_password = _settings_repo.get_login_password +get_gptmail_api_key = _settings_repo.get_gptmail_api_key + +load_groups = _groups_repo.load_groups +get_group_by_id = _groups_repo.get_group_by_id +add_group = _groups_repo.add_group +update_group = _groups_repo.update_group +get_default_group_id = _groups_repo.get_default_group_id +delete_group = _groups_repo.delete_group +get_group_account_count = _groups_repo.get_group_account_count + +load_accounts = _accounts_repo.load_accounts +get_account_by_email = _accounts_repo.get_account_by_email +get_account_by_id = _accounts_repo.get_account_by_id +add_account = _accounts_repo.add_account +update_account = _accounts_repo.update_account +delete_account_by_id = _accounts_repo.delete_account_by_id +delete_account_by_email = _accounts_repo.delete_account_by_email + +get_tags = _tags_repo.get_tags +add_tag = _tags_repo.add_tag +delete_tag = _tags_repo.delete_tag +get_account_tags = _tags_repo.get_account_tags +add_account_tag = _tags_repo.add_account_tag +remove_account_tag = _tags_repo.remove_account_tag + + +# ==================== 工具函数 ==================== + +def sanitize_input(text: str, max_length: int = 500) -> str: + """ + 净化用户输入,防止XSS攻击 + - 转义HTML特殊字符 + - 限制长度 + - 移除控制字符 + """ + if not text: + return "" + + # 限制长度 + text = text[:max_length] + + # 移除控制字符(保留换行和制表符) + text = ''.join(char for char in text if char.isprintable() or char in '\n\t') + + # 转义HTML特殊字符 + text = html.escape(text, quote=True) + + return text + + +def decode_header_value(header_value: str) -> str: + """解码邮件头字段""" + if not header_value: + return "" + try: + decoded_parts = decode_header(str(header_value)) + decoded_string = "" + for part, charset in decoded_parts: + if isinstance(part, bytes): + try: + decoded_string += part.decode(charset if charset else 'utf-8', 'replace') + except (LookupError, UnicodeDecodeError): + decoded_string += part.decode('utf-8', 'replace') + else: + decoded_string += str(part) + return decoded_string + except Exception: + return str(header_value) if header_value else "" + + +def get_email_body(msg) -> str: + """提取邮件正文""" + body = "" + if msg.is_multipart(): + for part in msg.walk(): + content_type = part.get_content_type() + content_disposition = str(part.get("Content-Disposition", "")) + + if content_type == "text/plain" and "attachment" not in content_disposition: + try: + payload = part.get_payload(decode=True) + charset = part.get_content_charset() or 'utf-8' + body = payload.decode(charset, errors='replace') + break + except Exception: + continue + elif content_type == "text/html" and "attachment" not in content_disposition and not body: + try: + payload = part.get_payload(decode=True) + charset = part.get_content_charset() or 'utf-8' + body = payload.decode(charset, errors='replace') + except Exception: + continue + else: + try: + payload = msg.get_payload(decode=True) + charset = msg.get_content_charset() or 'utf-8' + body = payload.decode(charset, errors='replace') + except Exception: + body = str(msg.get_payload()) + + return body + + +def parse_account_string(account_str: str) -> Optional[Dict]: + """ + 解析账号字符串 + 格式: email----password----client_id----refresh_token + """ + parts = account_str.strip().split('----') + if len(parts) >= 4: + return { + 'email': parts[0].strip(), + 'password': parts[1], + 'client_id': parts[2].strip(), + # refresh_token 可能包含 '----',这里把剩余部分合并回去 + 'refresh_token': '----'.join(parts[3:]).strip() + } + return None + + +# ==================== Graph API 方式 ==================== + + +def build_proxies(proxy_url: str) -> Optional[Dict[str, str]]: + """构建 requests 的 proxies 参数""" + if not proxy_url: + return None + return {"http": proxy_url, "https": proxy_url} + + +def get_access_token_graph_result(client_id: str, refresh_token: str, proxy_url: str = None) -> Dict[str, Any]: + """获取 Graph API access_token(包含错误详情)""" + try: + proxies = build_proxies(proxy_url) + res = requests.post( + TOKEN_URL_GRAPH, + data={ + "client_id": client_id, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "scope": "https://graph.microsoft.com/.default" + }, + timeout=30, + proxies=proxies + ) + + if res.status_code != 200: + details = get_response_details(res) + return { + "success": False, + "error": build_error_payload( + "GRAPH_TOKEN_FAILED", + "获取访问令牌失败", + "GraphAPIError", + res.status_code, + details + ) + } + + payload = res.json() + access_token = payload.get("access_token") + if not access_token: + return { + "success": False, + "error": build_error_payload( + "GRAPH_TOKEN_MISSING", + "获取访问令牌失败", + "GraphAPIError", + res.status_code, + payload + ) + } + + return {"success": True, "access_token": access_token} + except Exception as exc: + return { + "success": False, + "error": build_error_payload( + "GRAPH_TOKEN_EXCEPTION", + "获取访问令牌失败", + type(exc).__name__, + 500, + str(exc) + ) + } + + +def get_access_token_graph(client_id: str, refresh_token: str, proxy_url: str = None) -> Optional[str]: + """获取 Graph API access_token""" + result = get_access_token_graph_result(client_id, refresh_token, proxy_url) + if result.get("success"): + return result.get("access_token") + return None + + +def get_emails_graph(client_id: str, refresh_token: str, folder: str = 'inbox', skip: int = 0, top: int = 20, proxy_url: str = None) -> Dict[str, Any]: + """使用 Graph API 获取邮件列表(支持分页和文件夹选择)""" + token_result = get_access_token_graph_result(client_id, refresh_token, proxy_url) + if not token_result.get("success"): + return {"success": False, "error": token_result.get("error")} + + access_token = token_result.get("access_token") + + try: + # 根据文件夹类型选择 API 端点 + # 使用 Well-known folder names,这些是 Microsoft Graph API 的标准文件夹名称 + folder_map = { + 'inbox': 'inbox', + 'junkemail': 'junkemail', # 垃圾邮件的标准名称 + 'deleteditems': 'deleteditems', # 已删除邮件的标准名称 + 'trash': 'deleteditems' # 垃圾箱的别名 + } + folder_name = folder_map.get(folder.lower(), 'inbox') + + url = f"https://graph.microsoft.com/v1.0/me/mailFolders/{folder_name}/messages" + params = { + "$top": top, + "$skip": skip, + "$select": "id,subject,from,receivedDateTime,isRead,hasAttachments,bodyPreview", + "$orderby": "receivedDateTime desc" + } + headers = { + "Authorization": f"Bearer {access_token}", + "Prefer": "outlook.body-content-type='text'" + } + + proxies = build_proxies(proxy_url) + res = requests.get(url, headers=headers, params=params, timeout=30, proxies=proxies) + + if res.status_code != 200: + details = get_response_details(res) + return { + "success": False, + "error": build_error_payload( + "EMAIL_FETCH_FAILED", + "获取邮件失败,请检查账号配置", + "GraphAPIError", + res.status_code, + details + ) + } + + return {"success": True, "emails": res.json().get("value", [])} + except Exception as exc: + return { + "success": False, + "error": build_error_payload( + "EMAIL_FETCH_FAILED", + "获取邮件失败,请检查账号配置", + type(exc).__name__, + 500, + str(exc) + ) + } + + +def get_email_detail_graph(client_id: str, refresh_token: str, message_id: str, proxy_url: str = None) -> Optional[Dict]: + """使用 Graph API 获取邮件详情""" + access_token = get_access_token_graph(client_id, refresh_token, proxy_url) + if not access_token: + return None + + try: + url = f"https://graph.microsoft.com/v1.0/me/messages/{message_id}" + params = { + "$select": "id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,body,bodyPreview" + } + headers = { + "Authorization": f"Bearer {access_token}", + "Prefer": "outlook.body-content-type='html'" + } + + proxies = build_proxies(proxy_url) + res = requests.get(url, headers=headers, params=params, timeout=30, proxies=proxies) + + if res.status_code != 200: + return None + + return res.json() + except Exception: + return None + + +# ==================== IMAP 方式 ==================== + +def get_access_token_imap_result(client_id: str, refresh_token: str) -> Dict[str, Any]: + """获取 IMAP access_token(包含错误详情)""" + try: + res = requests.post( + TOKEN_URL_IMAP, + data={ + "client_id": client_id, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "scope": "https://outlook.office.com/IMAP.AccessAsUser.All offline_access" + }, + timeout=30 + ) + + if res.status_code != 200: + details = get_response_details(res) + return { + "success": False, + "error": build_error_payload( + "IMAP_TOKEN_FAILED", + "获取访问令牌失败", + "IMAPError", + res.status_code, + details + ) + } + + payload = res.json() + access_token = payload.get("access_token") + if not access_token: + return { + "success": False, + "error": build_error_payload( + "IMAP_TOKEN_MISSING", + "获取访问令牌失败", + "IMAPError", + res.status_code, + payload + ) + } + + return {"success": True, "access_token": access_token} + except Exception as exc: + return { + "success": False, + "error": build_error_payload( + "IMAP_TOKEN_EXCEPTION", + "获取访问令牌失败", + type(exc).__name__, + 500, + str(exc) + ) + } + + +def get_access_token_imap(client_id: str, refresh_token: str) -> Optional[str]: + """获取 IMAP access_token""" + result = get_access_token_imap_result(client_id, refresh_token) + if result.get("success"): + return result.get("access_token") + return None + + +def get_emails_imap(account: str, client_id: str, refresh_token: str, folder: str = 'inbox', skip: int = 0, top: int = 20) -> Dict[str, Any]: + """使用 IMAP 获取邮件列表(支持分页和文件夹选择)- 默认使用新版服务器""" + return get_emails_imap_with_server(account, client_id, refresh_token, folder, skip, top, IMAP_SERVER_NEW) + + +def get_emails_imap_with_server(account: str, client_id: str, refresh_token: str, folder: str = 'inbox', skip: int = 0, top: int = 20, server: str = IMAP_SERVER_NEW) -> Dict[str, Any]: + """使用 IMAP 获取邮件列表(支持分页、文件夹选择和服务器选择)""" + token_result = get_access_token_imap_result(client_id, refresh_token) + if not token_result.get("success"): + return {"success": False, "error": token_result.get("error")} + + access_token = token_result.get("access_token") + + connection = None + try: + connection = imaplib.IMAP4_SSL(server, IMAP_PORT) + auth_string = f"user={account}\1auth=Bearer {access_token}\1\1".encode('utf-8') + connection.authenticate('XOAUTH2', lambda x: auth_string) + + # 根据文件夹类型选择 IMAP 文件夹 + # 尝试多种可能的文件夹名称 + folder_map = { + 'inbox': ['"INBOX"', 'INBOX'], + 'junkemail': ['"Junk"', '"Junk Email"', 'Junk', '"垃圾邮件"'], + 'deleteditems': ['"Deleted"', '"Deleted Items"', '"Trash"', 'Deleted', '"已删除邮件"'], + 'trash': ['"Deleted"', '"Deleted Items"', '"Trash"', 'Deleted', '"已删除邮件"'] + } + possible_folders = folder_map.get(folder.lower(), ['"INBOX"']) + + # 尝试选择文件夹 + selected_folder = None + last_error = None + for imap_folder in possible_folders: + try: + status, response = connection.select(imap_folder, readonly=True) + if status == 'OK': + selected_folder = imap_folder + break + else: + last_error = f"select {imap_folder} status={status}" + except Exception as e: + last_error = f"select {imap_folder} error={str(e)}" + continue + + if not selected_folder: + # 如果所有尝试都失败,列出所有可用文件夹以便调试 + try: + status, folder_list = connection.list() + available_folders = [] + if status == 'OK' and folder_list: + for folder_item in folder_list: + if isinstance(folder_item, bytes): + available_folders.append(folder_item.decode('utf-8', errors='ignore')) + else: + available_folders.append(str(folder_item)) + + error_details = { + "last_error": last_error, + "tried_folders": possible_folders, + "available_folders": available_folders[:10] # 只返回前10个 + } + except Exception: + error_details = { + "last_error": last_error, + "tried_folders": possible_folders + } + + return { + "success": False, + "error": build_error_payload( + "EMAIL_FETCH_FAILED", + f"无法访问文件夹,请检查账号配置", + "IMAPSelectError", + 500, + error_details + ) + } + + status, messages = connection.search(None, 'ALL') + if status != 'OK': + return { + "success": False, + "error": build_error_payload( + "EMAIL_FETCH_FAILED", + "获取邮件失败,请检查账号配置", + "IMAPSearchError", + 500, + f"search status={status}" + ) + } + if not messages or not messages[0]: + return {"success": True, "emails": []} + + message_ids = messages[0].split() + # 计算分页范围 + total = len(message_ids) + start_idx = max(0, total - skip - top) + end_idx = total - skip + + if start_idx >= end_idx: + return {"success": True, "emails": []} + + paged_ids = message_ids[start_idx:end_idx][::-1] # 倒序,最新的在前 + + emails = [] + for msg_id in paged_ids: + try: + status, msg_data = connection.fetch(msg_id, '(RFC822)') + if status == 'OK' and msg_data and msg_data[0]: + raw_email = msg_data[0][1] + msg = email.message_from_bytes(raw_email) + + emails.append({ + 'id': msg_id.decode() if isinstance(msg_id, bytes) else str(msg_id), + 'subject': decode_header_value(msg.get("Subject", "无主题")), + 'from': decode_header_value(msg.get("From", "未知发件人")), + 'date': msg.get("Date", "未知时间"), + 'body_preview': get_email_body(msg)[:200] + "..." if len(get_email_body(msg)) > 200 else get_email_body(msg) + }) + except Exception: + continue + + return {"success": True, "emails": emails} + except Exception as exc: + return { + "success": False, + "error": build_error_payload( + "EMAIL_FETCH_FAILED", + "获取邮件失败,请检查账号配置", + type(exc).__name__, + 500, + str(exc) + ) + } + finally: + if connection: + try: + connection.logout() + except Exception: + pass + + +def get_email_detail_imap(account: str, client_id: str, refresh_token: str, message_id: str, folder: str = 'inbox') -> Optional[Dict]: + """使用 IMAP 获取邮件详情""" + access_token = get_access_token_imap(client_id, refresh_token) + if not access_token: + return None + + connection = None + try: + connection = imaplib.IMAP4_SSL(IMAP_SERVER_NEW, IMAP_PORT) + auth_string = f"user={account}\1auth=Bearer {access_token}\1\1".encode('utf-8') + connection.authenticate('XOAUTH2', lambda x: auth_string) + + # 根据文件夹类型选择 IMAP 文件夹 + folder_map = { + 'inbox': ['"INBOX"', 'INBOX'], + 'junkemail': ['"Junk"', '"Junk Email"', 'Junk', '"垃圾邮件"'], + 'deleteditems': ['"Deleted"', '"Deleted Items"', '"Trash"', 'Deleted', '"已删除邮件"'], + 'trash': ['"Deleted"', '"Deleted Items"', '"Trash"', 'Deleted', '"已删除邮件"'] + } + possible_folders = folder_map.get(folder.lower(), ['"INBOX"']) + + # 尝试选择文件夹 + selected_folder = None + for imap_folder in possible_folders: + try: + status, response = connection.select(imap_folder, readonly=True) + if status == 'OK': + selected_folder = imap_folder + break + except Exception: + continue + + if not selected_folder: + return None + + status, msg_data = connection.fetch(message_id.encode() if isinstance(message_id, str) else message_id, '(RFC822)') + if status != 'OK' or not msg_data or not msg_data[0]: + return None + + raw_email = msg_data[0][1] + msg = email.message_from_bytes(raw_email) + + return { + 'id': message_id, + 'subject': decode_header_value(msg.get("Subject", "无主题")), + 'from': decode_header_value(msg.get("From", "未知发件人")), + 'to': decode_header_value(msg.get("To", "")), + 'cc': decode_header_value(msg.get("Cc", "")), + 'date': msg.get("Date", "未知时间"), + 'body': get_email_body(msg) + } + except Exception: + return None + finally: + if connection: + try: + connection.logout() + except Exception: + pass + + +# 兼容导出:Graph/IMAP 服务已模块化到 outlook_web.services.graph / outlook_web.services.imap +from outlook_web.services import graph as _graph_service # noqa: E402 +from outlook_web.services import imap as _imap_service # noqa: E402 + +build_proxies = _graph_service.build_proxies +get_access_token_graph_result = _graph_service.get_access_token_graph_result +get_access_token_graph = _graph_service.get_access_token_graph +get_emails_graph = _graph_service.get_emails_graph +get_email_detail_graph = _graph_service.get_email_detail_graph + +get_access_token_imap_result = _imap_service.get_access_token_imap_result +get_access_token_imap = _imap_service.get_access_token_imap +get_emails_imap = _imap_service.get_emails_imap +get_emails_imap_with_server = _imap_service.get_emails_imap_with_server +get_email_detail_imap = _imap_service.get_email_detail_imap + + +# ==================== 登录验证 ==================== +# 已模块化到 outlook_web.security.auth + + +# ==================== Flask 路由 ==================== + +@app.route('/login', methods=['GET', 'POST']) +@csrf_exempt # 登录接口排除CSRF保护(用户未登录时无法获取token) +def login(): + """登录页面""" + if request.method == 'POST': + try: + # 获取客户端 IP + client_ip = get_client_ip() + + # 检查速率限制 + allowed, remaining_time = check_rate_limit(client_ip) + if not allowed: + trace_id_value = None + try: + trace_id_value = getattr(g, 'trace_id', None) + except Exception: + trace_id_value = None + error_payload = build_error_payload( + code="LOGIN_RATE_LIMITED", + message=f"登录失败次数过多,请在 {remaining_time} 秒后重试", + err_type="RateLimitError", + status=429, + details=f"ip={client_ip}", + trace_id=trace_id_value + ) + return jsonify({'success': False, 'error': error_payload}), 429 + + data = request.json if request.is_json else request.form + password = data.get('password', '') + + # 从数据库获取密码哈希 + stored_password = get_login_password() + + # 验证密码 + if verify_password(password, stored_password): + # 登录成功,重置失败记录 + reset_login_attempts(client_ip) + session['logged_in'] = True + session.permanent = True + session.modified = True # 确保 Flask-Session 保存 session + return jsonify({'success': True, 'message': '登录成功'}) + else: + # 登录失败,记录失败次数 + record_login_failure(client_ip) + trace_id_value = None + try: + trace_id_value = getattr(g, 'trace_id', None) + except Exception: + trace_id_value = None + error_payload = build_error_payload( + code="LOGIN_INVALID_PASSWORD", + message="密码错误", + err_type="AuthError", + status=401, + details=f"ip={client_ip}", + trace_id=trace_id_value + ) + return jsonify({'success': False, 'error': error_payload}), 401 + except Exception as e: + trace_id_value = None + try: + trace_id_value = getattr(g, 'trace_id', None) + except Exception: + trace_id_value = None + try: + app.logger.exception("Login error trace_id=%s", trace_id_value or "unknown") + except Exception: + pass + error_payload = build_error_payload( + code="LOGIN_FAILED", + message="登录处理失败", + err_type="AuthError", + status=500, + details=str(e), + trace_id=trace_id_value + ) + return jsonify({'success': False, 'error': error_payload}), 500 + + # GET 请求返回登录页面 + return render_template('login.html') + + +@app.route('/logout') +def logout(): + """退出登录""" + session.pop('logged_in', None) + return redirect(url_for('pages.login')) + + +@app.route('/') +@login_required +def index(): + """主页""" + return render_template('index.html') + + +@app.route('/api/csrf-token', methods=['GET']) +@csrf_exempt # CSRF token获取接口排除CSRF保护 +def get_csrf_token(): + """获取CSRF Token""" + if CSRF_AVAILABLE: + token = generate_csrf() + return jsonify({'csrf_token': token}) + else: + return jsonify({'csrf_token': None, 'csrf_disabled': True}) + + +# ==================== 分组 API ==================== + +@app.route('/api/groups', methods=['GET']) +@login_required +def api_get_groups(): + """获取所有分组""" + groups = load_groups() + # 添加每个分组的邮箱数量 + for group in groups: + if group['name'] == '临时邮箱': + # 临时邮箱分组从 temp_emails 表获取数量 + group['account_count'] = get_temp_email_count() + else: + group['account_count'] = get_group_account_count(group['id']) + return jsonify({'success': True, 'groups': groups}) + + +@app.route('/api/groups/', methods=['GET']) +@login_required +def api_get_group(group_id): + """获取单个分组""" + group = get_group_by_id(group_id) + if not group: + return jsonify({'success': False, 'error': '分组不存在'}) + group['account_count'] = get_group_account_count(group_id) + return jsonify({'success': True, 'group': group}) + + +@app.route('/api/groups', methods=['POST']) +@login_required +def api_add_group(): + """添加分组""" + data = request.json + name = sanitize_input(data.get('name', '').strip(), max_length=100) + description = sanitize_input(data.get('description', ''), max_length=500) + color = data.get('color', '#1a1a1a') + proxy_url = data.get('proxy_url', '').strip() + + if not name: + return jsonify({'success': False, 'error': '分组名称不能为空'}) + + group_id = add_group(name, description, color, proxy_url) + if group_id: + log_audit('create', 'group', str(group_id), f"创建分组:{name}") + return jsonify({'success': True, 'message': '分组创建成功', 'group_id': group_id}) + else: + return jsonify({'success': False, 'error': '分组名称已存在'}) + + +@app.route('/api/groups/', methods=['PUT']) +@login_required +def api_update_group(group_id): + """更新分组""" + data = request.json + name = sanitize_input(data.get('name', '').strip(), max_length=100) + description = sanitize_input(data.get('description', ''), max_length=500) + color = data.get('color', '#1a1a1a') + proxy_url = data.get('proxy_url', '').strip() + + if not name: + return jsonify({'success': False, 'error': '分组名称不能为空'}) + + existing = get_group_by_id(group_id) + if not existing: + error_payload = build_error_payload( + code="GROUP_NOT_FOUND", + message="分组不存在", + err_type="NotFoundError", + status=404, + details=f"group_id={group_id}", + ) + return jsonify({'success': False, 'error': error_payload}), 404 + + # 系统分组保护:不允许重命名(避免破坏系统逻辑) + if existing.get('is_system') and name != existing.get('name'): + error_payload = build_error_payload( + code="SYSTEM_GROUP_PROTECTED", + message="系统分组不允许重命名", + err_type="ForbiddenError", + status=403, + details=f"group_id={group_id}", + ) + return jsonify({'success': False, 'error': error_payload}), 403 + + if update_group(group_id, name, description, color, proxy_url): + # 不记录 proxy_url 明文(可能包含代理账号/密码) + details = json.dumps({ + "name": name, + "has_description": bool(description), + "color": color, + "proxy_configured": bool(proxy_url) + }, ensure_ascii=False) + log_audit('update', 'group', str(group_id), details) + return jsonify({'success': True, 'message': '分组更新成功'}) + else: + return jsonify({'success': False, 'error': '更新失败'}) + + +@app.route('/api/groups/', methods=['DELETE']) +@login_required +def api_delete_group(group_id): + """删除分组""" + group = get_group_by_id(group_id) + if not group: + error_payload = build_error_payload( + code="GROUP_NOT_FOUND", + message="分组不存在", + err_type="NotFoundError", + status=404, + details=f"group_id={group_id}", + ) + return jsonify({'success': False, 'error': error_payload}), 404 + + if group.get('is_system'): + error_payload = build_error_payload( + code="SYSTEM_GROUP_PROTECTED", + message="系统分组不能删除", + err_type="ForbiddenError", + status=403, + details=f"group_id={group_id}", + ) + return jsonify({'success': False, 'error': error_payload}), 403 + + default_group_id = get_default_group_id() + if group_id == default_group_id or group.get('name') == '默认分组': + error_payload = build_error_payload( + code="DEFAULT_GROUP_PROTECTED", + message="默认分组不能删除", + err_type="ForbiddenError", + status=403, + details=f"group_id={group_id}", + ) + return jsonify({'success': False, 'error': error_payload}), 403 + + if delete_group(group_id): + log_audit('delete', 'group', str(group_id), "删除分组并迁移账号到默认分组") + return jsonify({'success': True, 'message': '分组已删除,邮箱已移至默认分组'}) + else: + return jsonify({'success': False, 'error': '删除失败'}) + + +@app.route('/api/groups//export') +@login_required +def api_export_group(group_id): + """导出分组下的所有邮箱账号为 TXT 文件(需要二次验证)""" + # 检查二次验证token(一次性) + verify_token = request.args.get('verify_token') + ok, error_message = consume_export_verify_token(verify_token) + if not ok: + return jsonify({'success': False, 'error': error_message, 'need_verify': True}), 401 + + group = get_group_by_id(group_id) + if not group: + return jsonify({'success': False, 'error': '分组不存在'}) + + # 使用 load_accounts 获取该分组下的所有账号(自动解密) + accounts = load_accounts(group_id) + + if not accounts: + return jsonify({'success': False, 'error': '该分组下没有邮箱账号'}) + + # 记录审计日志 + log_audit('export', 'group', str(group_id), f"导出分组 '{group['name']}' 的 {len(accounts)} 个账号") + + # 生成导出内容(格式:email----password----client_id----refresh_token) + lines = [] + for acc in accounts: + line = f"{acc['email']}----{acc.get('password', '')}----{acc['client_id']}----{acc['refresh_token']}" + lines.append(line) + + content = '\n'.join(lines) + + # 生成文件名(使用 URL 编码处理中文) + filename = f"{group['name']}_accounts_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" + encoded_filename = quote(filename) + + # 返回文件下载响应 + return Response( + content, + mimetype='text/plain; charset=utf-8', + headers={ + 'Content-Disposition': f"attachment; filename*=UTF-8''{encoded_filename}" + } + ) + + +@app.route('/api/accounts/export') +@login_required +def api_export_all_accounts(): + """导出所有邮箱账号为 TXT 文件(需要二次验证)""" + # 检查二次验证token(一次性) + verify_token = request.args.get('verify_token') + ok, error_message = consume_export_verify_token(verify_token) + if not ok: + return jsonify({'success': False, 'error': error_message, 'need_verify': True}), 401 + + + # 使用 load_accounts 获取所有账号(自动解密) + accounts = load_accounts() + + if not accounts: + return jsonify({'success': False, 'error': '没有邮箱账号'}) + + # 记录审计日志 + log_audit('export', 'all_accounts', None, f"导出所有账号,共 {len(accounts)} 个") + + # 生成导出内容(格式:email----password----client_id----refresh_token) + lines = [] + for acc in accounts: + line = f"{acc['email']}----{acc.get('password', '')}----{acc['client_id']}----{acc['refresh_token']}" + lines.append(line) + + content = '\n'.join(lines) + + # 生成文件名(使用 URL 编码处理中文) + filename = f"all_accounts_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" + encoded_filename = quote(filename) + + # 返回文件下载响应 + return Response( + content, + mimetype='text/plain; charset=utf-8', + headers={ + 'Content-Disposition': f"attachment; filename*=UTF-8''{encoded_filename}" + } + ) + + +@app.route('/api/accounts/export-selected', methods=['POST']) +@login_required +def api_export_selected_accounts(): + """导出选中分组的邮箱账号为 TXT 文件(需要二次验证)""" + data = request.json + group_ids = data.get('group_ids', []) + verify_token = data.get('verify_token') + + ok, error_message = consume_export_verify_token(verify_token) + if not ok: + return jsonify({'success': False, 'error': error_message, 'need_verify': True}), 401 + + if not group_ids: + return jsonify({'success': False, 'error': '请选择要导出的分组'}) + + # 获取选中分组下的所有账号(使用 load_accounts 自动解密) + all_accounts = [] + for group_id in group_ids: + accounts = load_accounts(group_id) + all_accounts.extend(accounts) + + if not all_accounts: + return jsonify({'success': False, 'error': '选中的分组下没有邮箱账号'}) + + # 记录审计日志 + log_audit('export', 'selected_groups', ','.join(map(str, group_ids)), f"导出选中分组的 {len(all_accounts)} 个账号") + + # 生成导出内容 + lines = [] + for acc in all_accounts: + line = f"{acc['email']}----{acc.get('password', '')}----{acc['client_id']}----{acc['refresh_token']}" + lines.append(line) + + content = '\n'.join(lines) + + # 生成文件名 + filename = f"selected_accounts_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" + encoded_filename = quote(filename) + + # 返回文件下载响应 + return Response( + content, + mimetype='text/plain; charset=utf-8', + headers={ + 'Content-Disposition': f"attachment; filename*=UTF-8''{encoded_filename}" + } + ) + + +@app.route('/api/export/verify', methods=['POST']) +@login_required +def api_generate_export_verify_token(): + """生成导出验证token(二次验证)""" + data = request.json + password = data.get('password', '') + + # 验证密码 + db = get_db() + cursor = db.execute("SELECT value FROM settings WHERE key = 'login_password'") + result = cursor.fetchone() + + if not result: + return jsonify({'success': False, 'error': '系统配置错误'}) + + stored_password = result[0] + if not verify_password(password, stored_password): + return jsonify({'success': False, 'error': '密码错误'}) + + client_ip = get_client_ip() + user_agent = get_user_agent() + verify_token = issue_export_verify_token(client_ip, user_agent) + return jsonify({'success': True, 'verify_token': verify_token}) + + +# ==================== 邮箱账号 API ==================== + +@app.route('/api/accounts', methods=['GET']) +@login_required +def api_get_accounts(): + """获取所有账号""" + group_id = request.args.get('group_id', type=int) + accounts = load_accounts(group_id) + + # 获取每个账号的最后刷新状态(批量查询,避免 N+1) + db = get_db() + last_log_by_account: Dict[int, Dict[str, Any]] = {} + try: + account_ids = [int(a.get('id')) for a in accounts if a.get('id') is not None] + except Exception: + account_ids = [] + + if account_ids: + try: + placeholders = ",".join(["?"] * len(account_ids)) + rows = db.execute(f''' + SELECT l.account_id, l.status, l.error_message, l.created_at + FROM account_refresh_logs l + JOIN ( + SELECT account_id, MAX(id) as max_id + FROM account_refresh_logs + WHERE account_id IN ({placeholders}) + GROUP BY account_id + ) latest + ON l.account_id = latest.account_id AND l.id = latest.max_id + ''', account_ids).fetchall() + for r in rows: + try: + last_log_by_account[int(r['account_id'])] = dict(r) + except Exception: + continue + except Exception: + last_log_by_account = {} + + # 返回时隐藏敏感信息 + safe_accounts = [] + for acc in accounts: + acc_id = acc.get('id') + try: + acc_id_int = int(acc_id) + except Exception: + acc_id_int = None + last_refresh_log = last_log_by_account.get(acc_id_int) if acc_id_int is not None else None + + safe_accounts.append({ + 'id': acc['id'], + 'email': acc['email'], + 'client_id': acc['client_id'][:8] + '...' if len(acc['client_id']) > 8 else acc['client_id'], + 'group_id': acc.get('group_id'), + 'group_name': acc.get('group_name', '默认分组'), + 'group_color': acc.get('group_color', '#666666'), + 'remark': acc.get('remark', ''), + 'status': acc.get('status', 'active'), + 'last_refresh_at': acc.get('last_refresh_at', ''), + 'last_refresh_status': last_refresh_log.get('status') if last_refresh_log else None, + 'last_refresh_error': last_refresh_log.get('error_message') if last_refresh_log else None, + 'created_at': acc.get('created_at', ''), + 'updated_at': acc.get('updated_at', ''), + 'tags': acc.get('tags', []) + }) + return jsonify({'success': True, 'accounts': safe_accounts}) + + +# ==================== 标签 API ==================== + +@app.route('/api/tags', methods=['GET']) +@login_required +def api_get_tags(): + """获取所有标签""" + return jsonify({'success': True, 'tags': get_tags()}) + + +@app.route('/api/tags', methods=['POST']) +@login_required +def api_add_tag(): + """添加标签""" + data = request.json + name = sanitize_input(data.get('name', '').strip(), max_length=50) + color = data.get('color', '#1a1a1a') + + if not name: + return jsonify({'success': False, 'error': '标签名称不能为空'}) + + tag_id = add_tag(name, color) + if tag_id: + log_audit('create', 'tag', str(tag_id), json.dumps({'name': name, 'color': color}, ensure_ascii=False)) + return jsonify({'success': True, 'tag': {'id': tag_id, 'name': name, 'color': color}}) + else: + return jsonify({'success': False, 'error': '标签名称已存在'}) + + +@app.route('/api/tags/', methods=['DELETE']) +@login_required +def api_delete_tag(tag_id): + """删除标签""" + if delete_tag(tag_id): + log_audit('delete', 'tag', str(tag_id), "删除标签") + return jsonify({'success': True, 'message': '标签已删除'}) + else: + return jsonify({'success': False, 'error': '删除失败'}) + + +@app.route('/api/accounts/tags', methods=['POST']) +@login_required +def api_batch_manage_tags(): + """批量管理账号标签""" + data = request.json + account_ids = data.get('account_ids', []) + tag_id = data.get('tag_id') + action = data.get('action') # add, remove + + if not account_ids or not tag_id or not action: + return jsonify({'success': False, 'error': '参数不完整'}) + + count = 0 + for acc_id in account_ids: + if action == 'add': + if add_account_tag(acc_id, tag_id): + count += 1 + elif action == 'remove': + if remove_account_tag(acc_id, tag_id): + count += 1 + + try: + details = json.dumps({'action': action, 'tag_id': tag_id, 'accounts': len(account_ids), 'affected': count}, ensure_ascii=False) + except Exception: + details = f"action={action} tag_id={tag_id} accounts={len(account_ids)} affected={count}" + log_audit('update', 'account_tags', str(tag_id), details) + return jsonify({'success': True, 'message': f'成功处理 {count} 个账号'}) + + +@app.route('/api/accounts/batch-update-group', methods=['POST']) +@login_required +def api_batch_update_account_group(): + """批量更新账号分组""" + data = request.json + account_ids = data.get('account_ids', []) + group_id = data.get('group_id') + + if not account_ids: + return jsonify({'success': False, 'error': '请选择要修改的账号'}) + + if not group_id: + return jsonify({'success': False, 'error': '请选择目标分组'}) + + # 验证分组存在 + group = get_group_by_id(group_id) + if not group: + return jsonify({'success': False, 'error': '目标分组不存在'}) + + # 检查是否是临时邮箱分组(系统保留分组) + if group.get('is_system'): + return jsonify({'success': False, 'error': '不能移动到系统分组'}) + + # 批量更新 + db = get_db() + try: + placeholders = ','.join('?' * len(account_ids)) + db.execute(f''' + UPDATE accounts SET group_id = ?, updated_at = CURRENT_TIMESTAMP + WHERE id IN ({placeholders}) + ''', [group_id] + account_ids) + db.commit() + log_audit('update', 'account_group', str(group_id), f"批量移动分组:账号数={len(account_ids)}") + return jsonify({ + 'success': True, + 'message': f'已将 {len(account_ids)} 个账号移动到「{group["name"]}」分组' + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}) + + + +@app.route('/api/accounts/search', methods=['GET']) +@login_required +def api_search_accounts(): + """全局搜索账号""" + query = request.args.get('q', '').strip() + + if not query: + return jsonify({'success': True, 'accounts': []}) + + db = get_db() + # 支持搜索邮箱、备注和标签 + cursor = db.execute(''' + SELECT DISTINCT a.*, g.name as group_name, g.color as group_color + FROM accounts a + LEFT JOIN groups g ON a.group_id = g.id + LEFT JOIN account_tags at ON a.id = at.account_id + LEFT JOIN tags t ON at.tag_id = t.id + WHERE a.email LIKE ? OR a.remark LIKE ? OR t.name LIKE ? + ORDER BY a.created_at DESC + ''', (f'%{query}%', f'%{query}%', f'%{query}%')) + + rows = cursor.fetchall() + + # 批量加载标签与最后刷新状态,避免 N+1 查询(1,000 账号场景) + account_rows: List[Dict[str, Any]] = [dict(r) for r in rows] + try: + account_ids = [int(a.get('id')) for a in account_rows if a.get('id') is not None] + except Exception: + account_ids = [] + + tags_by_account: Dict[int, List[Dict[str, Any]]] = {} + last_log_by_account: Dict[int, Dict[str, Any]] = {} + if account_ids: + try: + placeholders = ",".join(["?"] * len(account_ids)) + tag_rows = db.execute(f''' + SELECT at.account_id as account_id, t.* + FROM account_tags at + JOIN tags t ON t.id = at.tag_id + WHERE at.account_id IN ({placeholders}) + ORDER BY at.account_id ASC, t.created_at DESC + ''', account_ids).fetchall() + for tr in tag_rows: + tag_dict = dict(tr) + acc_id = tag_dict.pop('account_id', None) + if acc_id is None: + continue + tags_by_account.setdefault(int(acc_id), []).append(tag_dict) + except Exception: + tags_by_account = {} + + try: + placeholders = ",".join(["?"] * len(account_ids)) + log_rows = db.execute(f''' + SELECT l.account_id, l.status, l.error_message, l.created_at + FROM account_refresh_logs l + JOIN ( + SELECT account_id, MAX(id) as max_id + FROM account_refresh_logs + WHERE account_id IN ({placeholders}) + GROUP BY account_id + ) latest + ON l.account_id = latest.account_id AND l.id = latest.max_id + ''', account_ids).fetchall() + for lr in log_rows: + try: + last_log_by_account[int(lr['account_id'])] = dict(lr) + except Exception: + continue + except Exception: + last_log_by_account = {} + + safe_accounts = [] + for acc in account_rows: + acc_id = acc.get('id') + try: + acc_id_int = int(acc_id) + except Exception: + acc_id_int = None + + tags = tags_by_account.get(acc_id_int, []) if acc_id_int is not None else [] + last_refresh_log = last_log_by_account.get(acc_id_int) if acc_id_int is not None else None + + safe_accounts.append({ + 'id': acc['id'], + 'email': acc['email'], + 'client_id': acc['client_id'][:8] + '...' if len(acc['client_id']) > 8 else acc['client_id'], + 'group_id': acc['group_id'], + 'group_name': acc['group_name'] if acc['group_name'] else '默认分组', + 'group_color': acc['group_color'] if acc['group_color'] else '#666666', + 'remark': acc['remark'] if acc['remark'] else '', + 'status': acc['status'] if acc['status'] else 'active', + 'created_at': acc['created_at'] if acc['created_at'] else '', + 'updated_at': acc['updated_at'] if acc['updated_at'] else '', + 'tags': tags, + 'last_refresh_at': acc.get('last_refresh_at', ''), + 'last_refresh_status': last_refresh_log.get('status') if last_refresh_log else None, + 'last_refresh_error': last_refresh_log.get('error_message') if last_refresh_log else None + }) + + return jsonify({'success': True, 'accounts': safe_accounts}) + + +@app.route('/api/accounts/', methods=['GET']) +@login_required +def api_get_account(account_id): + """获取单个账号详情""" + account = get_account_by_id(account_id) + if not account: + return jsonify({'success': False, 'error': '账号不存在'}) + + return jsonify({ + 'success': True, + 'account': { + 'id': account['id'], + 'email': account['email'], + # 敏感字段默认不回显(避免泄露);如需查看请走“导出+二次验证” + 'password': '', + 'client_id': account['client_id'], + 'refresh_token': '', + 'has_password': bool(account.get('password')), + 'has_refresh_token': bool(account.get('refresh_token')), + 'group_id': account.get('group_id'), + 'group_name': account.get('group_name', '默认分组'), + 'remark': account.get('remark', ''), + 'status': account.get('status', 'active'), + 'created_at': account.get('created_at', ''), + 'updated_at': account.get('updated_at', '') + } + }) + + +@app.route('/api/accounts', methods=['POST']) +@login_required +def api_add_account(): + """添加账号""" + data = request.json + account_str = data.get('account_string', '') + group_id = data.get('group_id', 1) + + if not account_str: + return jsonify({'success': False, 'error': '请输入账号信息'}) + + # 校验分组 + target_group = get_group_by_id(group_id) + if not target_group: + return jsonify({'success': False, 'error': '分组不存在'}) + if target_group.get('is_system'): + return jsonify({'success': False, 'error': '不能导入到系统分组'}) + + def sanitize_credential_field(value: Any, max_length: int) -> str: + if value is None: + return '' + text = str(value) + text = text.replace('\r', '').replace('\n', '').replace('\t', '') + text = text.strip() + if len(text) > max_length: + text = text[:max_length] + # 移除不可见控制字符 + text = ''.join(ch for ch in text if ch.isprintable()) + return text + + # 支持批量导入(多行)+ 逐行校验与错误定位 + raw_lines = account_str.splitlines() + imported = 0 + failed = 0 + errors: List[Dict[str, Any]] = [] + errors_total = 0 + max_error_details = 50 + + db = get_db() + for line_no, raw in enumerate(raw_lines, start=1): + line = (raw or '').strip() + if not line: + continue + + parsed = parse_account_string(line) + if not parsed: + failed += 1 + errors_total += 1 + if len(errors) < max_error_details: + errors.append({'line': line_no, 'error': '格式错误,应为:邮箱----密码----client_id----refresh_token'}) + continue + + email_addr = sanitize_credential_field(parsed.get('email'), 320) + password = sanitize_credential_field(parsed.get('password'), 500) + client_id = sanitize_credential_field(parsed.get('client_id'), 200) + refresh_token = sanitize_credential_field(parsed.get('refresh_token'), 4096) + + if not email_addr or not client_id or not refresh_token: + failed += 1 + errors_total += 1 + if len(errors) < max_error_details: + errors.append({'line': line_no, 'email': email_addr, 'error': '邮箱、Client ID、Refresh Token 不能为空'}) + continue + + # 基础邮箱格式校验 + if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email_addr): + failed += 1 + errors_total += 1 + if len(errors) < max_error_details: + errors.append({'line': line_no, 'email': email_addr, 'error': '邮箱格式不正确'}) + continue + + ok = add_account(email_addr, password, client_id, refresh_token, group_id, db=db, commit=False) + if ok: + imported += 1 + continue + + failed += 1 + errors_total += 1 + reason = '写入失败' + try: + exists = db.execute('SELECT 1 FROM accounts WHERE email = ? LIMIT 1', (email_addr,)).fetchone() + if exists: + reason = '邮箱已存在' + except Exception: + pass + if len(errors) < max_error_details: + errors.append({'line': line_no, 'email': email_addr, 'error': reason}) + + summary = { + 'group_id': group_id, + 'total_lines': len(raw_lines), + 'imported': imported, + 'failed': failed, + 'errors_total': errors_total, + 'errors_returned': len(errors), + 'errors_truncated': errors_total > len(errors) + } + + message = f'导入完成:成功 {imported} 个,失败 {failed} 个' + + if imported > 0: + try: + db.commit() + except Exception: + try: + db.rollback() + except Exception: + pass + return jsonify({'success': False, 'error': '数据库写入失败,请重试'}) + log_audit('import', 'account', None, f"{message},目标分组ID={group_id}") + return jsonify({'success': True, 'message': message, 'summary': summary, 'errors': errors}) + + return jsonify({'success': False, 'error': message, 'summary': summary, 'errors': errors}) + + +@app.route('/api/accounts/', methods=['PUT']) +@login_required +def api_update_account(account_id): + """更新账号""" + data = request.json + + # 检查是否只更新状态 + if 'status' in data and len(data) == 1: + # 只更新状态 + return api_update_account_status(account_id, data['status']) + + email_addr = (data.get('email') or '').strip() + password = data.get('password') + client_id = data.get('client_id') + refresh_token = data.get('refresh_token') + try: + group_id = int(data.get('group_id', 1) or 1) + except Exception: + group_id = 1 + remark = sanitize_input(data.get('remark', ''), max_length=200) + status = data.get('status', 'active') + + if not email_addr: + return jsonify({'success': False, 'error': '邮箱不能为空'}) + + target_group = get_group_by_id(group_id) + if not target_group: + error_payload = build_error_payload( + code="GROUP_NOT_FOUND", + message="分组不存在", + err_type="NotFoundError", + status=404, + details=f"group_id={group_id}", + ) + return jsonify({'success': False, 'error': error_payload}), 404 + + if target_group.get('is_system'): + error_payload = build_error_payload( + code="SYSTEM_GROUP_PROTECTED", + message="不能移动到系统分组", + err_type="ForbiddenError", + status=403, + details=f"group_id={group_id}", + ) + return jsonify({'success': False, 'error': error_payload}), 403 + + if update_account(account_id, email_addr, password, client_id, refresh_token, group_id, remark, status): + changed_fields = [] + if isinstance(client_id, str) and client_id.strip(): + changed_fields.append('client_id') + if isinstance(password, str) and password.strip(): + changed_fields.append('password') + if isinstance(refresh_token, str) and refresh_token.strip(): + changed_fields.append('refresh_token') + details = json.dumps({ + "email": email_addr, + "group_id": group_id, + "status": status, + "changed_fields": changed_fields + }, ensure_ascii=False) + log_audit('update', 'account', str(account_id), details) + return jsonify({'success': True, 'message': '账号更新成功'}) + else: + return jsonify({'success': False, 'error': '更新失败'}) + + +def api_update_account_status(account_id: int, status: str): + """只更新账号状态""" + db = get_db() + try: + db.execute(''' + UPDATE accounts + SET status = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (status, account_id)) + db.commit() + return jsonify({'success': True, 'message': '状态更新成功'}) + except Exception: + return jsonify({'success': False, 'error': '更新失败'}) + + +@app.route('/api/accounts/', methods=['DELETE']) +@login_required +def api_delete_account(account_id): + """删除账号""" + email_addr = '' + try: + db = get_db() + row = db.execute('SELECT email FROM accounts WHERE id = ?', (account_id,)).fetchone() + email_addr = row['email'] if row else '' + except Exception: + email_addr = '' + if delete_account_by_id(account_id): + log_audit('delete', 'account', str(account_id), f"删除账号:{email_addr}" if email_addr else "删除账号") + return jsonify({'success': True}) + else: + return jsonify({'success': False, 'error': '删除失败'}) + + +@app.route('/api/accounts/email/', methods=['DELETE']) +@login_required +def api_delete_account_by_email(email_addr): + """根据邮箱地址删除账号""" + if delete_account_by_email(email_addr): + log_audit('delete', 'account', email_addr, f"删除账号:{email_addr}") + return jsonify({'success': True}) + else: + return jsonify({'success': False, 'error': '删除失败'}) + + +# ==================== 账号刷新 API ==================== + +REFRESH_LOCK_NAME = "token_refresh" +REFRESH_LOCK_TTL_SECONDS = 60 * 60 * 2 # 2 小时,避免异常中断导致长时间卡死 + + +def compute_refresh_lock_ttl_seconds(total: int, delay_seconds: int) -> int: + try: + total = int(total or 0) + except Exception: + total = 0 + try: + delay_seconds = int(delay_seconds or 0) + except Exception: + delay_seconds = 0 + + # 粗略估算:每个账号至少 2 秒 + 配置延迟,再加 10 分钟缓冲 + estimated = int(total * (max(delay_seconds, 0) + 2) + 600) + ttl = max(REFRESH_LOCK_TTL_SECONDS, estimated) + return min(ttl, 60 * 60 * 24) # 最大 24 小时 + + +def acquire_distributed_lock(conn: sqlite3.Connection, name: str, owner_id: str, ttl_seconds: int) -> tuple[bool, Optional[Dict[str, Any]]]: + """获取分布式锁(基于同一 SQLite 数据库),用于避免并发刷新冲突""" + now_ts = time.time() + expires_at = now_ts + ttl_seconds + + try: + conn.execute('BEGIN IMMEDIATE') + row = conn.execute(''' + SELECT owner_id, acquired_at, expires_at + FROM distributed_locks + WHERE name = ? + ''', (name,)).fetchone() + + if not row: + conn.execute(''' + INSERT INTO distributed_locks (name, owner_id, acquired_at, expires_at) + VALUES (?, ?, ?, ?) + ''', (name, owner_id, now_ts, expires_at)) + conn.commit() + return True, None + + if row['expires_at'] < now_ts: + conn.execute(''' + UPDATE distributed_locks + SET owner_id = ?, acquired_at = ?, expires_at = ? + WHERE name = ? + ''', (owner_id, now_ts, expires_at, name)) + conn.commit() + return True, { + "previous_owner_id": row['owner_id'], + "previous_acquired_at": row['acquired_at'], + "previous_expires_at": row['expires_at'] + } + + conn.rollback() + return False, { + "owner_id": row['owner_id'], + "acquired_at": row['acquired_at'], + "expires_at": row['expires_at'] + } + except Exception as e: + try: + conn.rollback() + except Exception: + pass + return False, {"error": str(e)} + + +def release_distributed_lock(conn: sqlite3.Connection, name: str, owner_id: str) -> bool: + try: + conn.execute('BEGIN IMMEDIATE') + conn.execute(''' + DELETE FROM distributed_locks + WHERE name = ? AND owner_id = ? + ''', (name, owner_id)) + conn.commit() + return True + except Exception: + try: + conn.rollback() + except Exception: + pass + return False + + +def create_refresh_run( + conn: sqlite3.Connection, + trigger_source: str, + trace_id: str, + requested_by_ip: str = None, + requested_by_user_agent: str = None, + total: int = 0, +) -> str: + run_id = uuid.uuid4().hex + conn.execute(''' + INSERT INTO refresh_runs ( + id, trigger_source, status, + requested_by_ip, requested_by_user_agent, + total, success_count, failed_count, + trace_id + ) VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?) + ''', (run_id, trigger_source, 'running', requested_by_ip, requested_by_user_agent, total, trace_id)) + conn.commit() + return run_id + + +def finish_refresh_run( + conn: sqlite3.Connection, + run_id: str, + status: str, + total: int, + success_count: int, + failed_count: int, + message: str = None, +): + conn.execute(''' + UPDATE refresh_runs + SET status = ?, finished_at = CURRENT_TIMESTAMP, + total = ?, success_count = ?, failed_count = ?, message = ? + WHERE id = ? + ''', (status, total, success_count, failed_count, message, run_id)) + conn.commit() + + +def log_refresh_result( + account_id: int, + account_email: str, + refresh_type: str, + status: str, + error_message: str = None, + run_id: str = None, +): + """记录刷新结果到数据库""" + db = get_db() + try: + db.execute(''' + INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message, run_id) + VALUES (?, ?, ?, ?, ?, ?) + ''', (account_id, account_email, refresh_type, status, error_message, run_id)) + + # 更新账号的最后刷新时间 + if status == 'success': + db.execute(''' + UPDATE accounts + SET last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (account_id,)) + + db.commit() + return True + except Exception as e: + print(f"记录刷新结果失败: {str(e)}") + return False + + +def test_refresh_token(client_id: str, refresh_token: str, proxy_url: str = None) -> tuple[bool, str]: + """测试 refresh token 是否有效,返回 (是否成功, 错误信息)""" + try: + # 尝试使用 Graph API 获取 access token + # 使用与 get_access_token_graph 相同的 scope,确保一致性 + proxies = build_proxies(proxy_url) + res = requests.post( + TOKEN_URL_GRAPH, + data={ + "client_id": client_id, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "scope": "https://graph.microsoft.com/.default" + }, + timeout=30, + proxies=proxies + ) + + if res.status_code == 200: + return True, None + else: + error_data = res.json() + error_msg = error_data.get('error_description', error_data.get('error', '未知错误')) + return False, error_msg + except Exception as e: + return False, f"请求异常: {str(e)}" + + +# 兼容导出:refresh token 校验已模块化到 outlook_web.services.graph +test_refresh_token = _graph_service.test_refresh_token + +# 兼容导出:刷新相关 repositories/services 已模块化到 outlook_web.repositories.* / outlook_web.services.refresh +from outlook_web.repositories import distributed_locks as _locks_repo # noqa: E402 +from outlook_web.repositories import refresh_logs as _refresh_logs_repo # noqa: E402 +from outlook_web.repositories import refresh_runs as _refresh_runs_repo # noqa: E402 +from outlook_web.services import refresh as _refresh_service # noqa: E402 + +compute_refresh_lock_ttl_seconds = _refresh_service.compute_refresh_lock_ttl_seconds +acquire_distributed_lock = _locks_repo.acquire_distributed_lock +release_distributed_lock = _locks_repo.release_distributed_lock +create_refresh_run = _refresh_runs_repo.create_refresh_run +finish_refresh_run = _refresh_runs_repo.finish_refresh_run +log_refresh_result = _refresh_logs_repo.log_refresh_result + + +@app.route('/api/accounts//refresh', methods=['POST']) +@login_required +def api_refresh_account(account_id): + """刷新单个账号的 token""" + db = get_db() + cursor = db.execute('SELECT id, email, client_id, refresh_token, group_id FROM accounts WHERE id = ?', (account_id,)) + account = cursor.fetchone() + + if not account: + error_payload = build_error_payload( + "ACCOUNT_NOT_FOUND", + "账号不存在", + "NotFoundError", + 404, + f"account_id={account_id}" + ) + return jsonify({'success': False, 'error': error_payload}) + + account_id = account['id'] + account_email = account['email'] + client_id = account['client_id'] + encrypted_refresh_token = account['refresh_token'] + + # 获取分组代理设置 + proxy_url = '' + if account['group_id']: + group = get_group_by_id(account['group_id']) + if group: + proxy_url = group.get('proxy_url', '') or '' + + # 解密 refresh_token + try: + refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token + except Exception as e: + error_msg = f"解密 token 失败: {str(e)}" + log_refresh_result(account_id, account_email, 'manual', 'failed', error_msg) + error_payload = build_error_payload( + "TOKEN_DECRYPT_FAILED", + "Token 解密失败", + "DecryptionError", + 500, + error_msg + ) + return jsonify({'success': False, 'error': error_payload}) + + # 测试 refresh token + success, error_msg = test_refresh_token(client_id, refresh_token, proxy_url) + + # 记录刷新结果 + log_refresh_result(account_id, account_email, 'manual', 'success' if success else 'failed', error_msg) + + if success: + return jsonify({'success': True, 'message': 'Token 刷新成功'}) + + error_payload = build_error_payload( + "TOKEN_REFRESH_FAILED", + "Token 刷新失败", + "RefreshTokenError", + 400, + error_msg or "未知错误" + ) + return jsonify({'success': False, 'error': error_payload}) + + +@app.route('/api/accounts/refresh-all', methods=['GET']) +@login_required +def api_refresh_all_accounts(): + """刷新所有账号的 token(流式响应,实时返回进度)""" + trace_id_value = None + try: + trace_id_value = getattr(g, 'trace_id', None) + except Exception: + trace_id_value = None + requested_by_ip = get_client_ip() + requested_by_user_agent = get_user_agent() + + def generate(): + yield from _refresh_service.stream_refresh_all_accounts( + trace_id=trace_id_value, + requested_by_ip=requested_by_ip, + requested_by_user_agent=requested_by_user_agent, + lock_name=REFRESH_LOCK_NAME, + test_refresh_token=test_refresh_token, + ) + return + + conn = create_sqlite_connection() + lock_owner_id = uuid.uuid4().hex + lock_acquired = False + run_id = None + + try: + # 获取刷新间隔配置 + delay_row = conn.execute("SELECT value FROM settings WHERE key = 'refresh_delay_seconds'").fetchone() + delay_seconds = int(delay_row['value']) if delay_row else 5 + + # 清理超过半年的刷新记录 + try: + conn.execute("DELETE FROM account_refresh_logs WHERE created_at < datetime('now', '-6 months')") + conn.execute("DELETE FROM refresh_runs WHERE started_at < datetime('now', '-6 months')") + conn.execute("DELETE FROM distributed_locks WHERE expires_at < ?", (time.time(),)) + conn.commit() + except Exception: + pass + + accounts = conn.execute("SELECT id, email, client_id, refresh_token, group_id FROM accounts WHERE status = 'active'").fetchall() + total = len(accounts) + + run_id = create_refresh_run( + conn, + trigger_source='manual_all', + trace_id=trace_id_value or generate_trace_id(), + requested_by_ip=requested_by_ip, + requested_by_user_agent=requested_by_user_agent, + total=total + ) + + ttl_seconds = compute_refresh_lock_ttl_seconds(total, delay_seconds) + ok, lock_info = acquire_distributed_lock(conn, REFRESH_LOCK_NAME, lock_owner_id, ttl_seconds) + if not ok: + finish_refresh_run(conn, run_id, 'skipped', total, 0, 0, "刷新任务冲突:已有刷新在执行") + error_payload = build_error_payload( + code="REFRESH_CONFLICT", + message="当前已有刷新任务执行中,请稍后再试", + err_type="ConflictError", + status=409, + details=lock_info or "", + trace_id=trace_id_value + ) + yield f"data: {json.dumps({'type': 'error', 'error': error_payload}, ensure_ascii=False)}\n\n" + return + lock_acquired = True + + success_count = 0 + failed_count = 0 + failed_list = [] + + yield f"data: {json.dumps({'type': 'start', 'total': total, 'delay_seconds': delay_seconds, 'run_id': run_id, 'trace_id': trace_id_value, 'refresh_type': 'manual_all'}, ensure_ascii=False)}\n\n" + + for index, account in enumerate(accounts, 1): + account_id = account['id'] + account_email = account['email'] + client_id = account['client_id'] + encrypted_refresh_token = account['refresh_token'] + + # 解密 refresh_token + try: + refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token + except Exception as e: + failed_count += 1 + error_msg = f"解密 token 失败: {str(e)}" + failed_list.append({'id': account_id, 'email': account_email, 'error': error_msg}) + try: + conn.execute(''' + INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message, run_id) + VALUES (?, ?, ?, ?, ?, ?) + ''', (account_id, account_email, 'manual_all', 'failed', error_msg, run_id)) + conn.commit() + except Exception: + pass + continue + + yield f"data: {json.dumps({'type': 'progress', 'current': index, 'total': total, 'email': account_email, 'success_count': success_count, 'failed_count': failed_count}, ensure_ascii=False)}\n\n" + + # 获取分组代理设置 + proxy_url = '' + group_id = account['group_id'] + if group_id: + try: + group_row = conn.execute('SELECT proxy_url FROM groups WHERE id = ?', (group_id,)).fetchone() + if group_row: + proxy_url = group_row['proxy_url'] or '' + except Exception: + proxy_url = '' + + success, error_msg = test_refresh_token(client_id, refresh_token, proxy_url) + + try: + conn.execute(''' + INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message, run_id) + VALUES (?, ?, ?, ?, ?, ?) + ''', (account_id, account_email, 'manual_all', 'success' if success else 'failed', error_msg, run_id)) + + if success: + conn.execute(''' + UPDATE accounts + SET last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (account_id,)) + + conn.commit() + except Exception: + pass + + if success: + success_count += 1 + else: + failed_count += 1 + failed_list.append({'id': account_id, 'email': account_email, 'error': error_msg}) + + if index < total and delay_seconds > 0: + yield f"data: {json.dumps({'type': 'delay', 'seconds': delay_seconds}, ensure_ascii=False)}\n\n" + time.sleep(delay_seconds) + + finish_refresh_run( + conn, + run_id, + 'completed', + total, + success_count, + failed_count, + f"完成:成功 {success_count},失败 {failed_count}" + ) + + yield f"data: {json.dumps({'type': 'complete', 'total': total, 'success_count': success_count, 'failed_count': failed_count, 'failed_list': failed_list, 'run_id': run_id}, ensure_ascii=False)}\n\n" + + except Exception as e: + try: + if run_id: + finish_refresh_run(conn, run_id, 'failed', 0, 0, 0, str(e)) + except Exception: + pass + error_payload = build_error_payload( + code="REFRESH_FAILED", + message="刷新执行失败", + err_type="RefreshError", + status=500, + details=str(e), + trace_id=trace_id_value + ) + yield f"data: {json.dumps({'type': 'error', 'error': error_payload}, ensure_ascii=False)}\n\n" + finally: + if lock_acquired: + release_distributed_lock(conn, REFRESH_LOCK_NAME, lock_owner_id) + conn.close() + + return Response(generate(), mimetype='text/event-stream') + + +@app.route('/api/accounts//retry-refresh', methods=['POST']) +@login_required +def api_retry_refresh_account(account_id): + """重试单个失败账号的刷新""" + return api_refresh_account(account_id) + + +@app.route('/api/accounts/refresh-failed', methods=['POST']) +@login_required +def api_refresh_failed_accounts(): + """重试所有失败的账号""" + db = get_db() + trace_id_value = None + try: + trace_id_value = getattr(g, 'trace_id', None) + except Exception: + trace_id_value = None + requested_by_ip = get_client_ip() + requested_by_user_agent = get_user_agent() + + response_data, status_code = _refresh_service.refresh_failed_accounts( + db=db, + trace_id=trace_id_value, + requested_by_ip=requested_by_ip, + requested_by_user_agent=requested_by_user_agent, + lock_name=REFRESH_LOCK_NAME, + test_refresh_token=test_refresh_token, + ) + return jsonify(response_data), status_code + + lock_owner_id = uuid.uuid4().hex + + # 获取最近一次刷新失败的账号列表 + cursor = db.execute(''' + SELECT DISTINCT a.id, a.email, a.client_id, a.refresh_token, a.group_id + FROM accounts a + INNER JOIN ( + SELECT account_id, MAX(created_at) as last_refresh + FROM account_refresh_logs + GROUP BY account_id + ) latest ON a.id = latest.account_id + INNER JOIN account_refresh_logs l ON a.id = l.account_id AND l.created_at = latest.last_refresh + WHERE l.status = 'failed' AND a.status = 'active' + ''') + accounts = cursor.fetchall() + + total = len(accounts) + run_id = create_refresh_run( + db, + trigger_source='retry_failed', + trace_id=trace_id_value or generate_trace_id(), + requested_by_ip=requested_by_ip, + requested_by_user_agent=requested_by_user_agent, + total=total + ) + + ttl_seconds = compute_refresh_lock_ttl_seconds(total, 0) + ok, lock_info = acquire_distributed_lock(db, REFRESH_LOCK_NAME, lock_owner_id, ttl_seconds) + if not ok: + finish_refresh_run(db, run_id, 'skipped', total, 0, 0, "刷新任务冲突:已有刷新在执行") + error_payload = build_error_payload( + code="REFRESH_CONFLICT", + message="当前已有刷新任务执行中,请稍后再试", + err_type="ConflictError", + status=409, + details=lock_info or "", + trace_id=trace_id_value + ) + return jsonify({'success': False, 'error': error_payload}), 409 + + success_count = 0 + failed_count = 0 + failed_list = [] + + try: + for account in accounts: + account_id = account['id'] + account_email = account['email'] + client_id = account['client_id'] + encrypted_refresh_token = account['refresh_token'] + + # 获取分组代理设置 + proxy_url = '' + group_id = account['group_id'] + if group_id: + try: + group = get_group_by_id(group_id) + if group: + proxy_url = group.get('proxy_url', '') or '' + except Exception: + proxy_url = '' + + # 解密 refresh_token + try: + refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token + except Exception as e: + failed_count += 1 + error_msg = f"解密 token 失败: {str(e)}" + failed_list.append({'id': account_id, 'email': account_email, 'error': error_msg}) + log_refresh_result(account_id, account_email, 'retry', 'failed', error_msg, run_id=run_id) + continue + + success, error_msg = test_refresh_token(client_id, refresh_token, proxy_url) + log_refresh_result(account_id, account_email, 'retry', 'success' if success else 'failed', error_msg, run_id=run_id) + + if success: + success_count += 1 + else: + failed_count += 1 + failed_list.append({'id': account_id, 'email': account_email, 'error': error_msg}) + finally: + release_distributed_lock(db, REFRESH_LOCK_NAME, lock_owner_id) + + finish_refresh_run( + db, + run_id, + 'completed', + total, + success_count, + failed_count, + f"完成:成功 {success_count},失败 {failed_count}" + ) + + return jsonify({ + 'success': True, + 'run_id': run_id, + 'total': total, + 'success_count': success_count, + 'failed_count': failed_count, + 'failed_list': failed_list + }) + + +@app.route('/api/accounts/trigger-scheduled-refresh', methods=['GET']) +@login_required +def api_trigger_scheduled_refresh(): + """手动触发定时刷新(支持强制刷新)""" + force = request.args.get('force', 'false').lower() == 'true' + trace_id_value = None + try: + trace_id_value = getattr(g, 'trace_id', None) + except Exception: + trace_id_value = None + requested_by_ip = get_client_ip() + requested_by_user_agent = get_user_agent() + + # 获取配置 + refresh_interval_days = int(get_setting('refresh_interval_days', '30')) + use_cron = get_setting('use_cron_schedule', 'false').lower() == 'true' + + # 执行刷新(使用流式响应) + def generate(): + yield from _refresh_service.stream_trigger_scheduled_refresh( + force=force, + refresh_interval_days=refresh_interval_days, + use_cron=use_cron, + trace_id=trace_id_value, + requested_by_ip=requested_by_ip, + requested_by_user_agent=requested_by_user_agent, + lock_name=REFRESH_LOCK_NAME, + test_refresh_token=test_refresh_token, + ) + return + + from datetime import datetime, timedelta + + conn = create_sqlite_connection() + lock_owner_id = uuid.uuid4().hex + lock_acquired = False + run_id = None + total = 0 + success_count = 0 + failed_count = 0 + + try: + # 获取刷新间隔配置 + cursor_settings = conn.execute("SELECT value FROM settings WHERE key = 'refresh_delay_seconds'") + delay_row = cursor_settings.fetchone() + delay_seconds = int(delay_row['value']) if delay_row else 5 + + # 清理超过半年的刷新记录 + try: + conn.execute("DELETE FROM account_refresh_logs WHERE created_at < datetime('now', '-6 months')") + conn.commit() + except Exception as e: + print(f"清理旧记录失败: {str(e)}") + + accounts = conn.execute("SELECT id, email, client_id, refresh_token, group_id FROM accounts WHERE status = 'active'").fetchall() + + total = len(accounts) + run_id = create_refresh_run( + conn, + trigger_source='scheduled_manual', + trace_id=trace_id_value or generate_trace_id(), + requested_by_ip=requested_by_ip, + requested_by_user_agent=requested_by_user_agent, + total=total + ) + + # 按天数策略:未到周期则跳过(force=true 时跳过检查) + if (not force) and (not use_cron): + row = conn.execute(''' + SELECT finished_at + FROM refresh_runs + WHERE trigger_source IN ('scheduled', 'scheduled_manual') + AND status IN ('completed', 'failed') + AND finished_at IS NOT NULL + ORDER BY finished_at DESC + LIMIT 1 + ''').fetchone() + + if row and row['finished_at']: + try: + last_time = datetime.fromisoformat(row['finished_at']) + except Exception: + last_time = None + + if last_time: + next_due = last_time + timedelta(days=refresh_interval_days) + if utcnow() < next_due: + finish_refresh_run( + conn, + run_id, + 'skipped', + 0, + 0, + 0, + f"距离上次刷新未满 {refresh_interval_days} 天,下次最早:{next_due.strftime('%Y-%m-%d %H:%M:%S')}" + ) + yield f"data: {json.dumps({'type': 'skipped', 'message': '未到刷新周期', 'next_due': next_due.isoformat(), 'run_id': run_id}, ensure_ascii=False)}\n\n" + return + + ttl_seconds = compute_refresh_lock_ttl_seconds(total, delay_seconds) + ok, lock_info = acquire_distributed_lock(conn, REFRESH_LOCK_NAME, lock_owner_id, ttl_seconds) + if not ok: + finish_refresh_run(conn, run_id, 'skipped', total, 0, 0, "刷新任务冲突:已有刷新在执行") + error_payload = build_error_payload( + code="REFRESH_CONFLICT", + message="当前已有刷新任务执行中,请稍后再试", + err_type="ConflictError", + status=409, + details=lock_info or "", + trace_id=trace_id_value + ) + yield f"data: {json.dumps({'type': 'error', 'error': error_payload}, ensure_ascii=False)}\n\n" + return + lock_acquired = True + + success_count = 0 + failed_count = 0 + failed_list = [] + + yield f"data: {json.dumps({'type': 'start', 'total': total, 'delay_seconds': delay_seconds, 'refresh_type': 'scheduled', 'run_id': run_id, 'trace_id': trace_id_value}, ensure_ascii=False)}\n\n" + + for index, account in enumerate(accounts, 1): + account_id = account['id'] + account_email = account['email'] + client_id = account['client_id'] + encrypted_refresh_token = account['refresh_token'] + + # 解密 refresh_token + try: + refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token + except Exception as e: + # 解密失败,记录错误 + failed_count += 1 + error_msg = f"解密 token 失败: {str(e)}" + failed_list.append({ + 'id': account_id, + 'email': account_email, + 'error': error_msg + }) + try: + conn.execute(''' + INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message, run_id) + VALUES (?, ?, ?, ?, ?, ?) + ''', (account_id, account_email, 'scheduled', 'failed', error_msg, run_id)) + conn.commit() + except Exception: + pass + continue + + yield f"data: {json.dumps({'type': 'progress', 'current': index, 'total': total, 'email': account_email, 'success_count': success_count, 'failed_count': failed_count}, ensure_ascii=False)}\n\n" + + # 获取分组代理设置 + proxy_url = '' + group_id = account['group_id'] + if group_id: + group_cursor = conn.execute('SELECT proxy_url FROM groups WHERE id = ?', (group_id,)) + group_row = group_cursor.fetchone() + if group_row: + proxy_url = group_row['proxy_url'] or '' + + success, error_msg = test_refresh_token(client_id, refresh_token, proxy_url) + + try: + conn.execute(''' + INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message, run_id) + VALUES (?, ?, ?, ?, ?, ?) + ''', (account_id, account_email, 'scheduled', 'success' if success else 'failed', error_msg, run_id)) + + if success: + conn.execute(''' + UPDATE accounts + SET last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (account_id,)) + + conn.commit() + except Exception as e: + print(f"记录刷新结果失败: {str(e)}") + + if success: + success_count += 1 + else: + failed_count += 1 + failed_list.append({ + 'id': account_id, + 'email': account_email, + 'error': error_msg + }) + + if index < total and delay_seconds > 0: + yield f"data: {json.dumps({'type': 'delay', 'seconds': delay_seconds}, ensure_ascii=False)}\n\n" + time.sleep(delay_seconds) + + finish_refresh_run( + conn, + run_id, + 'completed', + total, + success_count, + failed_count, + f"完成:成功 {success_count},失败 {failed_count}" + ) + + yield f"data: {json.dumps({'type': 'complete', 'total': total, 'success_count': success_count, 'failed_count': failed_count, 'failed_list': failed_list, 'run_id': run_id}, ensure_ascii=False)}\n\n" + + except Exception as e: + try: + if run_id: + finish_refresh_run(conn, run_id, 'failed', total, success_count, failed_count, str(e)) + except Exception: + pass + error_payload = build_error_payload( + code="REFRESH_FAILED", + message="刷新执行失败", + err_type="RefreshError", + status=500, + details=str(e), + trace_id=trace_id_value + ) + yield f"data: {json.dumps({'type': 'error', 'error': error_payload}, ensure_ascii=False)}\n\n" + finally: + if lock_acquired: + release_distributed_lock(conn, REFRESH_LOCK_NAME, lock_owner_id) + conn.close() + + return Response(generate(), mimetype='text/event-stream') + + +@app.route('/api/accounts/refresh-logs', methods=['GET']) +@login_required +def api_get_refresh_logs(): + """获取所有账号的刷新历史(近半年)""" + db = get_db() + limit = int(request.args.get('limit', 1000)) + offset = int(request.args.get('offset', 0)) + + cursor = db.execute(''' + SELECT l.*, a.email as account_email + FROM account_refresh_logs l + LEFT JOIN accounts a ON l.account_id = a.id + WHERE l.refresh_type IN ('manual', 'manual_all', 'scheduled', 'retry') + AND l.created_at >= datetime('now', '-6 months') + ORDER BY l.created_at DESC + LIMIT ? OFFSET ? + ''', (limit, offset)) + + logs = [] + for row in cursor.fetchall(): + logs.append({ + 'id': row['id'], + 'account_id': row['account_id'], + 'account_email': row['account_email'] or row['account_email'], + 'refresh_type': row['refresh_type'], + 'status': row['status'], + 'error_message': row['error_message'], + 'created_at': row['created_at'] + }) + + return jsonify({'success': True, 'logs': logs}) + + +@app.route('/api/accounts//refresh-logs', methods=['GET']) +@login_required +def api_get_account_refresh_logs(account_id): + """获取单个账号的刷新历史""" + db = get_db() + limit = int(request.args.get('limit', 50)) + offset = int(request.args.get('offset', 0)) + + cursor = db.execute(''' + SELECT * FROM account_refresh_logs + WHERE account_id = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + ''', (account_id, limit, offset)) + + logs = [] + for row in cursor.fetchall(): + logs.append({ + 'id': row['id'], + 'account_id': row['account_id'], + 'account_email': row['account_email'], + 'refresh_type': row['refresh_type'], + 'status': row['status'], + 'error_message': row['error_message'], + 'created_at': row['created_at'] + }) + + return jsonify({'success': True, 'logs': logs}) + + +@app.route('/api/accounts/refresh-logs/failed', methods=['GET']) +@login_required +def api_get_failed_refresh_logs(): + """获取所有失败的刷新记录""" + db = get_db() + + # 获取每个账号最近一次失败的刷新记录 + cursor = db.execute(''' + SELECT l.*, a.email as account_email, a.status as account_status + FROM account_refresh_logs l + INNER JOIN ( + SELECT account_id, MAX(created_at) as last_refresh + FROM account_refresh_logs + GROUP BY account_id + ) latest ON l.account_id = latest.account_id AND l.created_at = latest.last_refresh + LEFT JOIN accounts a ON l.account_id = a.id + WHERE l.status = 'failed' + ORDER BY l.created_at DESC + ''') + + logs = [] + for row in cursor.fetchall(): + logs.append({ + 'id': row['id'], + 'account_id': row['account_id'], + 'account_email': row['account_email'] or row['account_email'], + 'account_status': row['account_status'], + 'refresh_type': row['refresh_type'], + 'status': row['status'], + 'error_message': row['error_message'], + 'created_at': row['created_at'] + }) + + return jsonify({'success': True, 'logs': logs}) + + +@app.route('/api/accounts/refresh-stats', methods=['GET']) +@login_required +def api_get_refresh_stats(): + """获取刷新统计信息(统计当前失败状态的邮箱数量)""" + db = get_db() + + cursor = db.execute(''' + SELECT MAX(created_at) as last_refresh_time + FROM account_refresh_logs + WHERE refresh_type IN ('manual', 'manual_all', 'scheduled', 'retry') + ''') + row = cursor.fetchone() + last_refresh_time = row['last_refresh_time'] if row else None + + cursor = db.execute(''' + SELECT COUNT(*) as total_accounts + FROM accounts + WHERE status = 'active' + ''') + total_accounts = cursor.fetchone()['total_accounts'] + + cursor = db.execute(''' + SELECT COUNT(DISTINCT l.account_id) as failed_count + FROM account_refresh_logs l + INNER JOIN ( + SELECT account_id, MAX(created_at) as last_refresh + FROM account_refresh_logs + GROUP BY account_id + ) latest ON l.account_id = latest.account_id AND l.created_at = latest.last_refresh + INNER JOIN accounts a ON l.account_id = a.id + WHERE l.status = 'failed' AND a.status = 'active' + ''') + failed_count = cursor.fetchone()['failed_count'] + + return jsonify({ + 'success': True, + 'stats': { + 'total': total_accounts, + 'success_count': total_accounts - failed_count, + 'failed_count': failed_count, + 'last_refresh_time': last_refresh_time + } + }) + + +# ==================== 邮件 API ==================== + + + +# ==================== Email Deletion Helpers ==================== + +def delete_emails_graph(client_id: str, refresh_token: str, message_ids: List[str], proxy_url: str = None) -> Dict[str, Any]: + """通过 Graph API 批量删除邮件(永久删除)""" + token_result = get_access_token_graph_result(client_id, refresh_token, proxy_url) + if not token_result.get("success"): + return {"success": False, "error": token_result.get("error")} + + access_token = token_result.get("access_token") + if not access_token: + return { + "success": False, + "error": build_error_payload( + "GRAPH_TOKEN_FAILED", + "获取访问令牌失败", + "GraphAPIError", + 500, + "empty_access_token", + ), + } + + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json' + } + + # Graph API 不支持一次性批量删除所有邮件,需要逐个删除 + # 但可以使用 batch 请求来优化 + # https://learn.microsoft.com/en-us/graph/json-batching + + # 限制每批次请求数量(Graph API 限制为 20) + BATCH_SIZE = 20 + success_count = 0 + failed_count = 0 + errors = [] + + for i in range(0, len(message_ids), BATCH_SIZE): + batch = message_ids[i:i + BATCH_SIZE] + + # 构造 batch 请求 body + batch_requests = [] + for idx, msg_id in enumerate(batch): + batch_requests.append({ + "id": str(idx), + "method": "DELETE", + "url": f"/me/messages/{msg_id}" + }) + + try: + proxies = build_proxies(proxy_url) + response = requests.post( + "https://graph.microsoft.com/v1.0/$batch", + headers=headers, + json={"requests": batch_requests}, + timeout=30, + proxies=proxies + ) + + if response.status_code == 200: + results = response.json().get("responses", []) + for res in results: + if res.get("status") in [200, 204]: + success_count += 1 + else: + failed_count += 1 + # 记录具体错误 + errors.append(f"Msg ID: {batch[int(res['id'])]}, Status: {res.get('status')}") + else: + failed_count += len(batch) + errors.append(f"Batch request failed: {response.text}") + + except Exception as e: + failed_count += len(batch) + errors.append(f"Network error: {str(e)}") + + result = { + "success": success_count > 0, + "partial_success": success_count > 0 and failed_count > 0, + "success_count": success_count, + "failed_count": failed_count, + "errors": errors, + } + + if not result["success"]: + result["error"] = build_error_payload( + "EMAIL_DELETE_FAILED", + "删除邮件失败", + "GraphAPIError", + 502, + {"failed_count": failed_count, "errors": errors[:10]}, + ) + + return result + +def delete_emails_imap(email_addr: str, client_id: str, refresh_token: str, message_ids: List[str], server: str) -> Dict[str, Any]: + """通过 IMAP 删除邮件(永久删除)""" + access_token = get_access_token_graph(client_id, refresh_token) + if not access_token: + return {"success": False, "error": "获取 Access Token 失败"} + + try: + # 生成 OAuth2 认证字符串 + auth_string = 'user=%s\x01auth=Bearer %s\x01\x01' % (email_addr, access_token) + + # 连接 IMAP + imap = imaplib.IMAP4_SSL(server, IMAP_PORT) + imap.authenticate('XOAUTH2', lambda x: auth_string.encode('utf-8')) + + # 选择文件夹 + imap.select('INBOX') + + # IMAP 删除需要 UID。如果我们没有 UID,这很难。 + # 鉴于我们只实现了 Graph 删除,并且 fallback 到 IMAP 比较复杂, + # 这里暂时返回不支持,或仅做简单的尝试(如果 ID 恰好是 UID) + # 但通常 Graph ID 不是 UID。 + + return {"success": False, "error": "IMAP 删除暂不支持 (ID 格式不兼容)"} + + except Exception as e: + return {"success": False, "error": str(e)} + + +# 兼容导出:邮件删除实现已模块化到 outlook_web.services.graph / outlook_web.services.imap +delete_emails_graph = _graph_service.delete_emails_graph +delete_emails_imap = _imap_service.delete_emails_imap + + +@app.route('/api/emails/') +@login_required +def api_get_emails(email_addr): + """获取邮件列表(支持分页,不使用缓存)""" + account = get_account_by_email(email_addr) + + if not account: + error_payload = build_error_payload( + "ACCOUNT_NOT_FOUND", + "账号不存在", + "NotFoundError", + 404, + f"email={email_addr}" + ) + return jsonify({'success': False, 'error': error_payload}) + + folder = request.args.get('folder', 'inbox') # inbox, junkemail, deleteditems + skip = int(request.args.get('skip', 0)) + top = int(request.args.get('top', 20)) + + # 获取分组代理设置 + proxy_url = '' + if account.get('group_id'): + group = get_group_by_id(account['group_id']) + if group: + proxy_url = group.get('proxy_url', '') or '' + + # 收集所有错误信息 + all_errors = {} + + # 1. 尝试 Graph API + graph_result = get_emails_graph(account['client_id'], account['refresh_token'], folder, skip, top, proxy_url) + if graph_result.get("success"): + emails = graph_result.get("emails", []) + # 更新刷新时间 + db = get_db() + db.execute(''' + UPDATE accounts + SET last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE email = ? + ''', (email_addr,)) + db.commit() + + # 格式化 Graph API 返回的数据 + formatted = [] + for e in emails: + formatted.append({ + 'id': e.get('id'), + 'subject': e.get('subject', '无主题'), + 'from': e.get('from', {}).get('emailAddress', {}).get('address', '未知'), + 'date': e.get('receivedDateTime', ''), + 'is_read': e.get('isRead', False), + 'has_attachments': e.get('hasAttachments', False), + 'body_preview': e.get('bodyPreview', '') + }) + + return jsonify({ + 'success': True, + 'emails': formatted, + 'method': 'Graph API', + 'has_more': len(formatted) >= top + }) + else: + graph_error = graph_result.get("error") + all_errors["graph"] = graph_error + + # 如果是代理错误,不再回退 IMAP + if isinstance(graph_error, dict) and graph_error.get('type') in ('ProxyError', 'ConnectionError'): + return jsonify({ + 'success': False, + 'error': '代理连接失败,请检查分组代理设置', + 'details': all_errors + }) + + imap_new_result = get_emails_imap_with_server( + account['email'], account['client_id'], account['refresh_token'], + folder, skip, top, IMAP_SERVER_NEW + ) + if imap_new_result.get("success"): + return jsonify({ + 'success': True, + 'emails': imap_new_result.get("emails", []), + 'method': 'IMAP (New)', + 'has_more': False # IMAP 分页暂未完全实现,视情况 + }) + else: + all_errors["imap_new"] = imap_new_result.get("error") + + # 3. 尝试旧版 IMAP (outlook.office365.com) + imap_old_result = get_emails_imap_with_server( + account['email'], account['client_id'], account['refresh_token'], + folder, skip, top, IMAP_SERVER_OLD + ) + if imap_old_result.get("success"): + return jsonify({ + 'success': True, + 'emails': imap_old_result.get("emails", []), + 'method': 'IMAP (Old)', + 'has_more': False + }) + else: + all_errors["imap_old"] = imap_old_result.get("error") + + return jsonify({ + 'success': False, + 'error': '无法获取邮件,所有方式均失败', + 'details': all_errors + }) + +@app.route('/api/emails/delete', methods=['POST']) +@login_required +def api_delete_emails(): + """批量删除邮件(永久删除)""" + data = request.json + email_addr = data.get('email', '') + message_ids = data.get('ids', []) + + if not email_addr or not message_ids: + return jsonify({'success': False, 'error': '参数不完整'}) + + account = get_account_by_email(email_addr) + if not account: + return jsonify({'success': False, 'error': '账号不存在'}) + + # 获取分组代理设置 + proxy_url = '' + if account.get('group_id'): + group = get_group_by_id(account['group_id']) + if group: + proxy_url = group.get('proxy_url', '') or '' + + from outlook_web.services import email_delete as _email_delete_service + + response_data, method_used = _email_delete_service.delete_emails_with_fallback( + email_addr=email_addr, + client_id=account["client_id"], + refresh_token=account["refresh_token"], + message_ids=message_ids, + proxy_url=proxy_url, + delete_emails_graph=delete_emails_graph, + delete_emails_imap=delete_emails_imap, + imap_server_new=IMAP_SERVER_NEW, + imap_server_old=IMAP_SERVER_OLD, + ) + + if method_used == "graph": + log_audit("delete", "email", email_addr, f"删除邮件 {len(message_ids)} 封(Graph API)") + elif method_used == "imap_new": + log_audit("delete", "email", email_addr, f"删除邮件 {len(message_ids)} 封(IMAP New)") + elif method_used == "imap_old": + log_audit("delete", "email", email_addr, f"删除邮件 {len(message_ids)} 封(IMAP Old)") + + return jsonify(response_data) + + + +@app.route('/api/email//') +@login_required +def api_get_email_detail(email_addr, message_id): + """获取邮件详情""" + account = get_account_by_email(email_addr) + + if not account: + return jsonify({'success': False, 'error': '账号不存在'}) + + method = request.args.get('method', 'graph') + folder = request.args.get('folder', 'inbox') + + if method == 'graph': + # 获取分组代理设置 + proxy_url = '' + if account.get('group_id'): + group = get_group_by_id(account['group_id']) + if group: + proxy_url = group.get('proxy_url', '') or '' + + detail = get_email_detail_graph(account['client_id'], account['refresh_token'], message_id, proxy_url) + if detail: + return jsonify({ + 'success': True, + 'email': { + 'id': detail.get('id'), + 'subject': detail.get('subject', '无主题'), + 'from': detail.get('from', {}).get('emailAddress', {}).get('address', '未知'), + 'to': ', '.join([r.get('emailAddress', {}).get('address', '') for r in detail.get('toRecipients', [])]), + 'cc': ', '.join([r.get('emailAddress', {}).get('address', '') for r in detail.get('ccRecipients', [])]), + 'date': detail.get('receivedDateTime', ''), + 'body': detail.get('body', {}).get('content', ''), + 'body_type': detail.get('body', {}).get('contentType', 'text') + } + }) + + # 如果 Graph API 失败,尝试 IMAP + detail = get_email_detail_imap(account['email'], account['client_id'], account['refresh_token'], message_id, folder) + if detail: + return jsonify({'success': True, 'email': detail}) + + return jsonify({'success': False, 'error': '获取邮件详情失败'}) + + +# ==================== GPTMail 临时邮箱 API ==================== + +def gptmail_request(method: str, endpoint: str, params: dict = None, json_data: dict = None) -> Optional[Dict]: + """发送 GPTMail API 请求""" + try: + url = f"{GPTMAIL_BASE_URL}{endpoint}" + # 从数据库获取 API Key + api_key = get_gptmail_api_key() + headers = { + "X-API-Key": api_key, + "Content-Type": "application/json" + } + + if method.upper() == 'GET': + response = requests.get(url, headers=headers, params=params, timeout=30) + elif method.upper() == 'POST': + response = requests.post(url, headers=headers, json=json_data, timeout=30) + elif method.upper() == 'DELETE': + response = requests.delete(url, headers=headers, params=params, timeout=30) + else: + return None + + if response.status_code == 200: + return response.json() + else: + return {'success': False, 'error': f'API 请求失败: {response.status_code}'} + except Exception as e: + return {'success': False, 'error': f'请求异常: {str(e)}'} + + +def generate_temp_email(prefix: str = None, domain: str = None) -> Optional[str]: + """生成临时邮箱地址""" + json_data = {} + if prefix: + json_data['prefix'] = prefix + if domain: + json_data['domain'] = domain + + if json_data: + result = gptmail_request('POST', '/api/generate-email', json_data=json_data) + else: + result = gptmail_request('GET', '/api/generate-email') + + if result and result.get('success'): + return result.get('data', {}).get('email') + return None + + +def get_temp_emails_from_api(email_addr: str) -> Optional[List[Dict]]: + """从 GPTMail API 获取邮件列表""" + result = gptmail_request('GET', '/api/emails', params={'email': email_addr}) + + if result and result.get('success'): + return result.get('data', {}).get('emails', []) + return None + + +def get_temp_email_detail_from_api(message_id: str) -> Optional[Dict]: + """从 GPTMail API 获取邮件详情""" + result = gptmail_request('GET', f'/api/email/{message_id}') + + if result and result.get('success'): + return result.get('data') + return None + + +def delete_temp_email_from_api(message_id: str) -> bool: + """从 GPTMail API 删除邮件""" + result = gptmail_request('DELETE', f'/api/email/{message_id}') + return result and result.get('success', False) + + +def clear_temp_emails_from_api(email_addr: str) -> bool: + """清空 GPTMail 邮箱的所有邮件""" + result = gptmail_request('DELETE', '/api/emails/clear', params={'email': email_addr}) + return result and result.get('success', False) + + +# 兼容导出:GPTMail 服务已模块化到 outlook_web.services.gptmail +from outlook_web.services import gptmail as _gptmail_service # noqa: E402 + +gptmail_request = _gptmail_service.gptmail_request +generate_temp_email = _gptmail_service.generate_temp_email +get_temp_emails_from_api = _gptmail_service.get_temp_emails_from_api +get_temp_email_detail_from_api = _gptmail_service.get_temp_email_detail_from_api +delete_temp_email_from_api = _gptmail_service.delete_temp_email_from_api +clear_temp_emails_from_api = _gptmail_service.clear_temp_emails_from_api + + +# ==================== 临时邮箱数据库操作 ==================== + +def get_temp_email_group_id() -> int: + """获取临时邮箱分组的 ID""" + db = get_db() + cursor = db.execute("SELECT id FROM groups WHERE name = '临时邮箱'") + row = cursor.fetchone() + return row['id'] if row else 2 + + +def load_temp_emails() -> List[Dict]: + """加载所有临时邮箱""" + db = get_db() + cursor = db.execute('SELECT * FROM temp_emails ORDER BY created_at DESC') + rows = cursor.fetchall() + return [dict(row) for row in rows] + + +def get_temp_email_by_address(email_addr: str) -> Optional[Dict]: + """根据邮箱地址获取临时邮箱""" + db = get_db() + cursor = db.execute('SELECT * FROM temp_emails WHERE email = ?', (email_addr,)) + row = cursor.fetchone() + return dict(row) if row else None + + +def add_temp_email(email_addr: str) -> bool: + """添加临时邮箱""" + db = get_db() + try: + db.execute('INSERT INTO temp_emails (email) VALUES (?)', (email_addr,)) + db.commit() + return True + except sqlite3.IntegrityError: + return False + + +def delete_temp_email(email_addr: str) -> bool: + """删除临时邮箱及其所有邮件""" + db = get_db() + try: + db.execute('DELETE FROM temp_email_messages WHERE email_address = ?', (email_addr,)) + db.execute('DELETE FROM temp_emails WHERE email = ?', (email_addr,)) + db.commit() + return True + except Exception: + return False + + +def save_temp_email_messages(email_addr: str, messages: List[Dict]) -> int: + """保存临时邮件到数据库""" + db = get_db() + saved = 0 + for msg in messages: + try: + db.execute(''' + INSERT OR REPLACE INTO temp_email_messages + (message_id, email_address, from_address, subject, content, html_content, has_html, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + msg.get('id'), + email_addr, + msg.get('from_address', ''), + msg.get('subject', ''), + msg.get('content', ''), + msg.get('html_content', ''), + 1 if msg.get('has_html') else 0, + msg.get('timestamp', 0) + )) + saved += 1 + except Exception: + continue + db.commit() + return saved + + +def get_temp_email_messages(email_addr: str) -> List[Dict]: + """获取临时邮箱的所有邮件(从数据库)""" + db = get_db() + cursor = db.execute(''' + SELECT * FROM temp_email_messages + WHERE email_address = ? + ORDER BY timestamp DESC + ''', (email_addr,)) + rows = cursor.fetchall() + return [dict(row) for row in rows] + + +def get_temp_email_message_by_id(message_id: str) -> Optional[Dict]: + """根据 ID 获取临时邮件""" + db = get_db() + cursor = db.execute('SELECT * FROM temp_email_messages WHERE message_id = ?', (message_id,)) + row = cursor.fetchone() + return dict(row) if row else None + + +def delete_temp_email_message(message_id: str) -> bool: + """删除临时邮件""" + db = get_db() + try: + db.execute('DELETE FROM temp_email_messages WHERE message_id = ?', (message_id,)) + db.commit() + return True + except Exception: + return False + + +def get_temp_email_count() -> int: + """获取临时邮箱数量""" + db = get_db() + cursor = db.execute('SELECT COUNT(*) as count FROM temp_emails') + row = cursor.fetchone() + return row['count'] if row else 0 + + +# 兼容导出:临时邮箱 repositories(SQL)已模块化到 outlook_web.repositories.temp_emails +from outlook_web.repositories import temp_emails as _temp_emails_repo # noqa: E402 + +get_temp_email_group_id = _temp_emails_repo.get_temp_email_group_id +load_temp_emails = _temp_emails_repo.load_temp_emails +get_temp_email_by_address = _temp_emails_repo.get_temp_email_by_address +add_temp_email = _temp_emails_repo.add_temp_email +delete_temp_email = _temp_emails_repo.delete_temp_email +save_temp_email_messages = _temp_emails_repo.save_temp_email_messages +get_temp_email_messages = _temp_emails_repo.get_temp_email_messages +get_temp_email_message_by_id = _temp_emails_repo.get_temp_email_message_by_id +delete_temp_email_message = _temp_emails_repo.delete_temp_email_message +get_temp_email_count = _temp_emails_repo.get_temp_email_count + + +# ==================== 临时邮箱 API 路由 ==================== + +@app.route('/api/temp-emails', methods=['GET']) +@login_required +def api_get_temp_emails(): + """获取所有临时邮箱""" + emails = load_temp_emails() + return jsonify({'success': True, 'emails': emails}) + + +@app.route('/api/temp-emails/generate', methods=['POST']) +@login_required +def api_generate_temp_email(): + """生成新的临时邮箱""" + data = request.json or {} + prefix = data.get('prefix') + domain = data.get('domain') + + email_addr = generate_temp_email(prefix, domain) + + if email_addr: + if add_temp_email(email_addr): + log_audit('create', 'temp_email', email_addr, "生成临时邮箱") + return jsonify({'success': True, 'email': email_addr, 'message': '临时邮箱创建成功'}) + else: + return jsonify({'success': False, 'error': '邮箱已存在'}) + else: + return jsonify({'success': False, 'error': '生成临时邮箱失败,请稍后重试'}) + + +@app.route('/api/temp-emails/', methods=['DELETE']) +@login_required +def api_delete_temp_email(email_addr): + """删除临时邮箱""" + if delete_temp_email(email_addr): + log_audit('delete', 'temp_email', email_addr, "删除临时邮箱") + return jsonify({'success': True, 'message': '临时邮箱已删除'}) + else: + return jsonify({'success': False, 'error': '删除失败'}) + + +@app.route('/api/temp-emails//messages', methods=['GET']) +@login_required +def api_get_temp_email_messages(email_addr): + """获取临时邮箱的邮件列表""" + api_messages = get_temp_emails_from_api(email_addr) + + if api_messages: + save_temp_email_messages(email_addr, api_messages) + + messages = get_temp_email_messages(email_addr) + + formatted = [] + for msg in messages: + formatted.append({ + 'id': msg.get('message_id'), + 'from': msg.get('from_address', '未知'), + 'subject': msg.get('subject', '无主题'), + 'body_preview': (msg.get('content', '') or '')[:200], + 'date': msg.get('created_at', ''), + 'timestamp': msg.get('timestamp', 0), + 'has_html': msg.get('has_html', 0) + }) + + return jsonify({ + 'success': True, + 'emails': formatted, + 'count': len(formatted), + 'method': 'GPTMail' + }) + + +@app.route('/api/temp-emails//messages/', methods=['GET']) +@login_required +def api_get_temp_email_message_detail(email_addr, message_id): + """获取临时邮件详情""" + msg = get_temp_email_message_by_id(message_id) + + if not msg: + api_msg = get_temp_email_detail_from_api(message_id) + if api_msg: + save_temp_email_messages(email_addr, [api_msg]) + msg = get_temp_email_message_by_id(message_id) + + if msg: + return jsonify({ + 'success': True, + 'email': { + 'id': msg.get('message_id'), + 'from': msg.get('from_address', '未知'), + 'to': email_addr, + 'subject': msg.get('subject', '无主题'), + 'body': msg.get('html_content') if msg.get('has_html') else msg.get('content', ''), + 'body_type': 'html' if msg.get('has_html') else 'text', + 'date': msg.get('created_at', ''), + 'timestamp': msg.get('timestamp', 0) + } + }) + else: + return jsonify({'success': False, 'error': '邮件不存在'}) + + +@app.route('/api/temp-emails//messages/', methods=['DELETE']) +@login_required +def api_delete_temp_email_message(email_addr, message_id): + """删除临时邮件""" + delete_temp_email_from_api(message_id) + if delete_temp_email_message(message_id): + log_audit('delete', 'temp_email_message', message_id, f"删除临时邮件(email={email_addr})") + return jsonify({'success': True, 'message': '邮件已删除'}) + else: + return jsonify({'success': False, 'error': '删除失败'}) + + +@app.route('/api/temp-emails//clear', methods=['DELETE']) +@login_required +def api_clear_temp_email_messages(email_addr): + """清空临时邮箱的所有邮件""" + clear_temp_emails_from_api(email_addr) + db = get_db() + try: + row = db.execute( + 'SELECT COUNT(*) as c FROM temp_email_messages WHERE email_address = ?', + (email_addr,) + ).fetchone() + deleted_count = row['c'] if row else 0 + db.execute('DELETE FROM temp_email_messages WHERE email_address = ?', (email_addr,)) + db.commit() + log_audit('delete', 'temp_email_messages', email_addr, f"清空临时邮箱邮件(count={deleted_count})") + return jsonify({'success': True, 'message': '邮件已清空'}) + except Exception: + return jsonify({'success': False, 'error': '清空失败'}) + + +@app.route('/api/temp-emails//refresh', methods=['POST']) +@login_required +def api_refresh_temp_email_messages(email_addr): + """刷新临时邮箱的邮件""" + api_messages = get_temp_emails_from_api(email_addr) + + if api_messages is not None: + saved = save_temp_email_messages(email_addr, api_messages) + messages = get_temp_email_messages(email_addr) + + formatted = [] + for msg in messages: + formatted.append({ + 'id': msg.get('message_id'), + 'from': msg.get('from_address', '未知'), + 'subject': msg.get('subject', '无主题'), + 'body_preview': (msg.get('content', '') or '')[:200], + 'date': msg.get('created_at', ''), + 'timestamp': msg.get('timestamp', 0), + 'has_html': msg.get('has_html', 0) + }) + + return jsonify({ + 'success': True, + 'emails': formatted, + 'count': len(formatted), + 'new_count': saved, + 'method': 'GPTMail' + }) + else: + return jsonify({'success': False, 'error': '获取邮件失败'}) + + +# ==================== OAuth Token API ==================== + +@app.route('/api/oauth/auth-url', methods=['GET']) +@login_required +def api_get_oauth_auth_url(): + """生成 OAuth 授权 URL""" + import urllib.parse + + base_auth_url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" + params = { + "client_id": OAUTH_CLIENT_ID, + "response_type": "code", + "redirect_uri": OAUTH_REDIRECT_URI, + "response_mode": "query", + "scope": " ".join(OAUTH_SCOPES), + "state": "12345" + } + auth_url = f"{base_auth_url}?{urllib.parse.urlencode(params)}" + + return jsonify({ + 'success': True, + 'auth_url': auth_url, + 'client_id': OAUTH_CLIENT_ID, + 'redirect_uri': OAUTH_REDIRECT_URI + }) + + +@app.route('/api/oauth/exchange-token', methods=['POST']) +@login_required +def api_exchange_oauth_token(): + """使用授权码换取 Refresh Token""" + import urllib.parse + + data = request.json + redirected_url = data.get('redirected_url', '').strip() + verify_token = data.get('verify_token') + + if not redirected_url: + return jsonify({'success': False, 'error': '请提供授权后的完整 URL'}) + + # 从 URL 中提取 code + try: + parsed_url = urllib.parse.urlparse(redirected_url) + query_params = urllib.parse.parse_qs(parsed_url.query) + auth_code = query_params['code'][0] + except (KeyError, IndexError): + return jsonify({'success': False, 'error': '无法从 URL 中提取授权码,请检查 URL 是否正确'}) + + # 二次验证(敏感信息:refresh_token 不默认明文返回) + ok, error_message = check_export_verify_token(verify_token) + if not ok: + return jsonify({'success': False, 'error': error_message, 'need_verify': True}), 401 + + # 使用 Code 换取 Token (Public Client 不需要 client_secret) + token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" + token_data = { + "client_id": OAUTH_CLIENT_ID, + "code": auth_code, + "redirect_uri": OAUTH_REDIRECT_URI, + "grant_type": "authorization_code", + "scope": " ".join(OAUTH_SCOPES) + } + + try: + response = requests.post(token_url, data=token_data, timeout=30) + except Exception as e: + return jsonify({'success': False, 'error': f'请求失败: {str(e)}'}) + + if response.status_code == 200: + tokens = response.json() + refresh_token = tokens.get('refresh_token') + + if not refresh_token: + return jsonify({'success': False, 'error': '未能获取 Refresh Token'}) + + # 成功后再消费一次性验证 token(避免失败时消耗 token) + ok, error_message = consume_export_verify_token(verify_token) + if not ok: + return jsonify({'success': False, 'error': error_message, 'need_verify': True}), 401 + + log_audit('oauth_exchange', 'oauth', None, "换取 Refresh Token 成功(已二次验证)") + + return jsonify({ + 'success': True, + 'refresh_token': refresh_token, + 'client_id': OAUTH_CLIENT_ID, + 'token_type': tokens.get('token_type'), + 'expires_in': tokens.get('expires_in'), + 'scope': tokens.get('scope') + }) + else: + error_data = response.json() if response.headers.get('content-type', '').startswith('application/json') else {} + error_msg = error_data.get('error_description', response.text) + return jsonify({'success': False, 'error': f'获取令牌失败: {error_msg}'}) + + +# ==================== 设置 API ==================== + +@app.route('/api/settings/validate-cron', methods=['POST']) +@login_required +def api_validate_cron(): + """验证 Cron 表达式""" + try: + from croniter import croniter + from datetime import datetime + except ImportError: + return jsonify({'success': False, 'error': 'croniter 库未安装,请运行: pip install croniter'}) + + data = request.json + cron_expr = data.get('cron_expression', '').strip() + + if not cron_expr: + return jsonify({'success': False, 'error': 'Cron 表达式不能为空'}) + + try: + base_time = datetime.now() + cron = croniter(cron_expr, base_time) + + next_run = cron.get_next(datetime) + + future_runs = [] + temp_cron = croniter(cron_expr, base_time) + for _ in range(5): + future_runs.append(temp_cron.get_next(datetime).isoformat()) + + return jsonify({ + 'success': True, + 'valid': True, + 'next_run': next_run.isoformat(), + 'future_runs': future_runs + }) + except Exception as e: + return jsonify({ + 'success': False, + 'valid': False, + 'error': f'Cron 表达式无效: {str(e)}' + }) + + +@app.route('/api/settings', methods=['GET']) +@login_required +def api_get_settings(): + """获取所有设置""" + all_settings = get_all_settings() + + def mask_secret_value(value: str, head: int = 4, tail: int = 4) -> str: + if not value: + return '' + safe_value = str(value) + if len(safe_value) <= head + tail: + return '*' * len(safe_value) + return safe_value[:head] + ('*' * (len(safe_value) - head - tail)) + safe_value[-tail:] + + # 仅返回前端需要的设置项(避免把敏感字段/内部状态直接返回) + safe_settings = { + 'refresh_interval_days': all_settings.get('refresh_interval_days', '30'), + 'refresh_delay_seconds': all_settings.get('refresh_delay_seconds', '5'), + 'refresh_cron': all_settings.get('refresh_cron', '0 2 * * *'), + 'use_cron_schedule': all_settings.get('use_cron_schedule', 'false'), + 'enable_scheduled_refresh': all_settings.get('enable_scheduled_refresh', 'true') + } + + # 敏感字段:不返回明文/哈希,仅提供“是否已设置/脱敏展示” + login_password_value = all_settings.get('login_password') or '' + gptmail_api_key_value = all_settings.get('gptmail_api_key') or '' + safe_settings['login_password_set'] = bool(login_password_value) + safe_settings['gptmail_api_key_set'] = bool(gptmail_api_key_value) + safe_settings['gptmail_api_key_masked'] = mask_secret_value(gptmail_api_key_value) if gptmail_api_key_value else '' + + return jsonify({'success': True, 'settings': safe_settings}) + + +@app.route('/api/settings', methods=['PUT']) +@login_required +def api_update_settings(): + """更新设置""" + data = request.json + updated = [] + errors = [] + scheduler_reload_needed = False + + # 更新登录密码 + if 'login_password' in data: + new_password = data['login_password'].strip() + if new_password: + if len(new_password) < 8: + errors.append('密码长度至少为 8 位') + else: + # 哈希新密码 + hashed_password = hash_password(new_password) + if set_setting('login_password', hashed_password): + updated.append('登录密码') + else: + errors.append('更新登录密码失败') + + # 更新 GPTMail API Key + if 'gptmail_api_key' in data: + new_api_key = data['gptmail_api_key'].strip() + if new_api_key: + if set_setting('gptmail_api_key', new_api_key): + updated.append('GPTMail API Key') + else: + errors.append('更新 GPTMail API Key 失败') + + # 更新刷新周期 + if 'refresh_interval_days' in data: + try: + days = int(data['refresh_interval_days']) + if days < 1 or days > 90: + errors.append('刷新周期必须在 1-90 天之间') + elif set_setting('refresh_interval_days', str(days)): + updated.append('刷新周期') + else: + errors.append('更新刷新周期失败') + except ValueError: + errors.append('刷新周期必须是数字') + + # 更新刷新间隔 + if 'refresh_delay_seconds' in data: + try: + seconds = int(data['refresh_delay_seconds']) + if seconds < 0 or seconds > 60: + errors.append('刷新间隔必须在 0-60 秒之间') + elif set_setting('refresh_delay_seconds', str(seconds)): + updated.append('刷新间隔') + else: + errors.append('更新刷新间隔失败') + except ValueError: + errors.append('刷新间隔必须是数字') + + # 更新 Cron 表达式 + if 'refresh_cron' in data: + cron_expr = data['refresh_cron'].strip() + if cron_expr: + try: + from croniter import croniter + from datetime import datetime + croniter(cron_expr, datetime.now()) + if set_setting('refresh_cron', cron_expr): + updated.append('Cron 表达式') + scheduler_reload_needed = True + else: + errors.append('更新 Cron 表达式失败') + except ImportError: + errors.append('croniter 库未安装') + except Exception as e: + errors.append(f'Cron 表达式无效: {str(e)}') + + # 更新刷新策略 + if 'use_cron_schedule' in data: + use_cron = str(data['use_cron_schedule']).lower() + if use_cron in ('true', 'false'): + if set_setting('use_cron_schedule', use_cron): + updated.append('刷新策略') + scheduler_reload_needed = True + else: + errors.append('更新刷新策略失败') + else: + errors.append('刷新策略必须是 true 或 false') + + # 更新定时刷新开关 + if 'enable_scheduled_refresh' in data: + enable = str(data['enable_scheduled_refresh']).lower() + if enable in ('true', 'false'): + if set_setting('enable_scheduled_refresh', enable): + updated.append('定时刷新开关') + scheduler_reload_needed = True + else: + errors.append('更新定时刷新开关失败') + else: + errors.append('定时刷新开关必须是 true 或 false') + + if errors: + return jsonify({'success': False, 'error': ';'.join(errors)}) + + if updated: + scheduler_reloaded = None + if scheduler_reload_needed: + try: + scheduler = init_scheduler() + if scheduler: + configure_scheduler_jobs(scheduler) + scheduler_reloaded = True + else: + scheduler_reloaded = False + except Exception: + scheduler_reloaded = False + + try: + details = json.dumps({ + "updated": updated, + "scheduler_reload_needed": scheduler_reload_needed, + "scheduler_reloaded": scheduler_reloaded + }, ensure_ascii=False) + except Exception: + details = f"updated={','.join(updated)}" + log_audit('update', 'settings', None, details) + return jsonify({ + 'success': True, + 'message': f'已更新:{", ".join(updated)}', + 'scheduler_reloaded': scheduler_reloaded + }) + else: + return jsonify({'success': False, 'error': '没有需要更新的设置'}) + + +@app.route('/api/scheduler/status', methods=['GET']) +@login_required +def api_get_scheduler_status(): + """获取调度器/定时刷新状态(用于验证“看起来已开启但实际未运行”的问题)""" + conn = create_sqlite_connection() + try: + enable_scheduled = get_setting('enable_scheduled_refresh', 'true').lower() == 'true' + use_cron = get_setting('use_cron_schedule', 'false').lower() == 'true' + refresh_interval_days = int(get_setting('refresh_interval_days', '30')) + refresh_cron = get_setting('refresh_cron', '0 2 * * *') + + # 心跳 + heartbeat_row = conn.execute(''' + SELECT value, updated_at + FROM settings + WHERE key = 'scheduler_heartbeat' + ''').fetchone() + + heartbeat = None + heartbeat_age_seconds = None + if heartbeat_row: + try: + heartbeat = json.loads(heartbeat_row['value']) if heartbeat_row['value'] else None + except Exception: + heartbeat = {"raw": heartbeat_row['value']} + try: + hb_time = datetime.fromisoformat(heartbeat_row['updated_at']) + heartbeat_age_seconds = int((utcnow() - hb_time).total_seconds()) + except Exception: + heartbeat_age_seconds = None + + # 锁状态 + lock_row = conn.execute(''' + SELECT owner_id, acquired_at, expires_at + FROM distributed_locks + WHERE name = ? + ''', (REFRESH_LOCK_NAME,)).fetchone() + now_ts = time.time() + lock_info = None + if lock_row and lock_row['expires_at'] and lock_row['expires_at'] > now_ts: + lock_info = { + "locked": True, + "owner_id": lock_row['owner_id'], + "acquired_at": lock_row['acquired_at'], + "expires_at": lock_row['expires_at'] + } + else: + lock_info = {"locked": False} + + # 最近一次定时刷新(含手动触发 scheduled_manual) + last_scheduled_run = conn.execute(''' + SELECT id, trigger_source, status, started_at, finished_at, + total, success_count, failed_count, message, trace_id, requested_by_ip + FROM refresh_runs + WHERE trigger_source IN ('scheduled', 'scheduled_manual') + ORDER BY started_at DESC + LIMIT 1 + ''').fetchone() + + last_scheduled = dict(last_scheduled_run) if last_scheduled_run else None + + running_run = conn.execute(''' + SELECT id, trigger_source, status, started_at, total, success_count, failed_count, trace_id + FROM refresh_runs + WHERE status = 'running' + ORDER BY started_at DESC + LIMIT 1 + ''').fetchone() + + running = dict(running_run) if running_run else None + + # 未来触发时间预览 + future_runs = [] + next_due = None + if enable_scheduled: + if use_cron: + try: + from croniter import croniter + base_time = datetime.now() + it = croniter(refresh_cron, base_time) + for _ in range(5): + future_runs.append(it.get_next(datetime).isoformat()) + except Exception: + future_runs = [] + else: + # 按天数策略:基于最近一次已完成的 scheduled/scheduled_manual 计算 next_due + row = conn.execute(''' + SELECT finished_at + FROM refresh_runs + WHERE trigger_source IN ('scheduled', 'scheduled_manual') + AND status IN ('completed', 'failed') + AND finished_at IS NOT NULL + ORDER BY finished_at DESC + LIMIT 1 + ''').fetchone() + last_finished_at = row['finished_at'] if row else None + try: + last_time = datetime.fromisoformat(last_finished_at) if last_finished_at else None + except Exception: + last_time = None + + base = last_time if last_time else utcnow() + next_due_dt = base + timedelta(days=refresh_interval_days) + next_due = next_due_dt.isoformat() + future_runs.append(next_due_dt.isoformat()) + + return jsonify({ + 'success': True, + 'scheduler': { + 'autostart': config.get_scheduler_autostart_default(), + 'enabled': enable_scheduled, + 'use_cron': use_cron, + 'refresh_cron': refresh_cron, + 'refresh_interval_days': refresh_interval_days, + 'future_runs': future_runs, + 'next_due': next_due, + 'heartbeat': heartbeat, + 'heartbeat_updated_at': heartbeat_row['updated_at'] if heartbeat_row else None, + 'heartbeat_age_seconds': heartbeat_age_seconds + }, + 'refresh': { + 'lock': lock_info, + 'running': running, + 'last_scheduled': last_scheduled + } + }) + finally: + conn.close() + + +@app.route('/healthz', methods=['GET']) +def healthz(): + """基础健康检查(用于容器/反代探活)""" + return jsonify({'status': 'ok'}), 200 + + +@app.route('/api/system/health', methods=['GET']) +@login_required +def api_system_health(): + """管理员健康检查:可服务/可刷新状态概览""" + conn = create_sqlite_connection() + try: + # DB 可用性 + db_ok = True + try: + conn.execute("SELECT 1").fetchone() + except Exception: + db_ok = False + + # Scheduler 心跳 + heartbeat_row = conn.execute(''' + SELECT updated_at + FROM settings + WHERE key = 'scheduler_heartbeat' + ''').fetchone() + + heartbeat_age_seconds = None + if heartbeat_row and heartbeat_row['updated_at']: + try: + hb_time = datetime.fromisoformat(heartbeat_row['updated_at']) + heartbeat_age_seconds = int((utcnow() - hb_time).total_seconds()) + except Exception: + heartbeat_age_seconds = None + + scheduler_enabled = get_setting('enable_scheduled_refresh', 'true').lower() == 'true' + scheduler_autostart = config.get_scheduler_autostart_default() + scheduler_healthy = (heartbeat_age_seconds is not None) and (heartbeat_age_seconds <= 120) + + # 刷新锁/运行中 + lock_row = conn.execute(''' + SELECT owner_id, expires_at + FROM distributed_locks + WHERE name = ? + ''', (REFRESH_LOCK_NAME,)).fetchone() + locked = bool(lock_row and lock_row['expires_at'] and lock_row['expires_at'] > time.time()) + + running_run = conn.execute(''' + SELECT id, trigger_source, started_at, trace_id + FROM refresh_runs + WHERE status = 'running' + ORDER BY started_at DESC + LIMIT 1 + ''').fetchone() + + return jsonify({ + 'success': True, + 'health': { + 'service': 'ok', + 'database': 'ok' if db_ok else 'error', + 'scheduler': { + 'enabled': scheduler_enabled, + 'autostart': scheduler_autostart, + 'heartbeat_age_seconds': heartbeat_age_seconds, + 'healthy': scheduler_healthy if scheduler_enabled else True + }, + 'refresh': { + 'locked': locked, + 'running': dict(running_run) if running_run else None + }, + 'server_time_utc': utcnow().isoformat() + 'Z' + } + }) + finally: + conn.close() + + +@app.route('/api/system/diagnostics', methods=['GET']) +@login_required +def api_system_diagnostics(): + """管理员诊断信息:关键状态一致性/过期清理可见性""" + conn = create_sqlite_connection() + try: + now_ts = time.time() + + export_tokens_count = conn.execute(''' + SELECT COUNT(*) as c + FROM export_verify_tokens + WHERE expires_at > ? + ''', (now_ts,)).fetchone()['c'] + + locked_ip_count = conn.execute(''' + SELECT COUNT(*) as c + FROM login_attempts + WHERE locked_until_at IS NOT NULL AND locked_until_at > ? + ''', (now_ts,)).fetchone()['c'] + + running_runs = conn.execute(''' + SELECT id, trigger_source, started_at, trace_id + FROM refresh_runs + WHERE status = 'running' + ORDER BY started_at DESC + LIMIT 5 + ''').fetchall() + + last_runs = conn.execute(''' + SELECT id, trigger_source, status, started_at, finished_at, total, success_count, failed_count, trace_id + FROM refresh_runs + ORDER BY started_at DESC + LIMIT 10 + ''').fetchall() + + locks = conn.execute(''' + SELECT name, owner_id, acquired_at, expires_at + FROM distributed_locks + ORDER BY name ASC + ''').fetchall() + + # 数据库升级状态(可验证) + schema_version_row = conn.execute( + "SELECT value, updated_at FROM settings WHERE key = ?", + (DB_SCHEMA_VERSION_KEY,) + ).fetchone() + try: + schema_version = int(schema_version_row['value']) if schema_version_row else 0 + except Exception: + schema_version = 0 + + last_migration = None + try: + mig = conn.execute(''' + SELECT id, from_version, to_version, status, started_at, finished_at, error, trace_id + FROM schema_migrations + ORDER BY started_at DESC + LIMIT 1 + ''').fetchone() + last_migration = dict(mig) if mig else None + except Exception: + last_migration = None + + return jsonify({ + 'success': True, + 'diagnostics': { + 'export_verify_tokens_active': export_tokens_count, + 'login_locked_ip_count': locked_ip_count, + 'running_runs': [dict(r) for r in running_runs], + 'last_runs': [dict(r) for r in last_runs], + 'locks': [dict(r) for r in locks], + 'schema': { + 'version': schema_version, + 'target_version': DB_SCHEMA_VERSION, + 'up_to_date': schema_version >= DB_SCHEMA_VERSION, + 'last_migration': last_migration + } + } + }) + finally: + conn.close() + + +@app.route('/api/system/upgrade-status', methods=['GET']) +@login_required +def api_system_upgrade_status(): + """数据库升级状态(用于验收“升级过程可验证/失败可定位”)""" + conn = create_sqlite_connection() + try: + row = conn.execute( + "SELECT value, updated_at FROM settings WHERE key = ?", + (DB_SCHEMA_VERSION_KEY,) + ).fetchone() + try: + schema_version = int(row['value']) if row and row['value'] is not None else 0 + except Exception: + schema_version = 0 + + last_trace_row = conn.execute( + "SELECT value FROM settings WHERE key = ?", + (DB_SCHEMA_LAST_UPGRADE_TRACE_ID_KEY,) + ).fetchone() + last_error_row = conn.execute( + "SELECT value FROM settings WHERE key = ?", + (DB_SCHEMA_LAST_UPGRADE_ERROR_KEY,) + ).fetchone() + + last_migration = None + try: + mig = conn.execute(''' + SELECT id, from_version, to_version, status, started_at, finished_at, error, trace_id + FROM schema_migrations + ORDER BY started_at DESC + LIMIT 1 + ''').fetchone() + last_migration = dict(mig) if mig else None + except Exception: + last_migration = None + + backup_hint = { + "database_path": DATABASE, + "linux_example": f"cp \"{DATABASE}\" \"{DATABASE}.backup\"", + "windows_example": f"copy \"{DATABASE}\" \"{DATABASE}.backup\"" + } + + return jsonify({ + "success": True, + "upgrade": { + "schema_version": schema_version, + "target_version": DB_SCHEMA_VERSION, + "up_to_date": schema_version >= DB_SCHEMA_VERSION, + "last_upgrade_trace_id": (last_trace_row['value'] if last_trace_row else ""), + "last_upgrade_error": (last_error_row['value'] if last_error_row else ""), + "last_migration": last_migration, + "backup_hint": backup_hint + } + }) + finally: + conn.close() + + +@app.route('/api/audit-logs', methods=['GET']) +@login_required +def api_get_audit_logs(): + """获取审计日志(敏感操作可追溯)""" + data = query_audit_logs( + limit=request.args.get("limit", type=int) or 50, + offset=request.args.get("offset", type=int) or 0, + action=request.args.get("action") or "", + resource_type=request.args.get("resource_type") or "", + ) + return jsonify({"success": True, **data}) + + +# ==================== 定时任务调度器 ==================== + +_scheduler_instance = None + + +def scheduler_heartbeat_task(): + """调度器心跳,用于验证后台任务是否真实运行""" + try: + payload = { + "at": utcnow().isoformat() + "Z", + "pid": os.getpid() + } + conn = create_sqlite_connection() + try: + conn.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('scheduler_heartbeat', ?, CURRENT_TIMESTAMP) + ''', (json.dumps(payload, ensure_ascii=False),)) + conn.commit() + finally: + conn.close() + except Exception: + pass + + +def configure_scheduler_jobs(scheduler) -> None: + """根据当前 settings 重新配置定时刷新 Job(配置变更即时生效)""" + try: + from apscheduler.triggers.cron import CronTrigger + except Exception: + return + + with app.app_context(): + enable_scheduled = get_setting('enable_scheduled_refresh', 'true').lower() == 'true' + use_cron = get_setting('use_cron_schedule', 'false').lower() == 'true' + refresh_interval_days = int(get_setting('refresh_interval_days', '30')) + cron_expr = get_setting('refresh_cron', '0 2 * * *') + + # 心跳 Job:始终存在(可服务/可刷新可验证) + try: + scheduler.remove_job('scheduler_heartbeat') + except Exception: + pass + scheduler.add_job( + func=scheduler_heartbeat_task, + trigger='interval', + seconds=60, + id='scheduler_heartbeat', + name='Scheduler Heartbeat', + replace_existing=True, + max_instances=1, + coalesce=True, + misfire_grace_time=60 + ) + + # 刷新 Job:根据 enable_scheduled 决定是否启用 + try: + scheduler.remove_job('token_refresh') + except Exception: + pass + + if not enable_scheduled: + print("✓ 定时刷新已禁用(调度器仍运行心跳)") + return + + if use_cron: + try: + from croniter import croniter + croniter(cron_expr, datetime.now()) + parts = cron_expr.split() + if len(parts) != 5: + raise ValueError("Cron 表达式格式错误,应为 5 段") + minute, hour, day, month, day_of_week = parts + trigger = CronTrigger(minute=minute, hour=hour, day=day, month=month, day_of_week=day_of_week) + scheduler.add_job( + func=scheduled_refresh_task, + trigger=trigger, + id='token_refresh', + name='Token 定时刷新', + replace_existing=True, + max_instances=1, + coalesce=True, + misfire_grace_time=600 + ) + print(f"✓ 定时任务已配置:Cron 表达式 '{cron_expr}'") + return + except Exception as e: + print(f"⚠ Cron 配置无效:{str(e)},回退到默认配置") + + scheduler.add_job( + func=scheduled_refresh_task, + trigger=CronTrigger(hour=2, minute=0), + id='token_refresh', + name='Token 定时刷新', + replace_existing=True, + max_instances=1, + coalesce=True, + misfire_grace_time=600 + ) + print(f"✓ 定时任务已配置:每天凌晨 2:00 检查刷新(周期:{refresh_interval_days} 天)") + + +def init_scheduler(): + """初始化定时任务调度器""" + global _scheduler_instance + + if _scheduler_instance is not None: + return _scheduler_instance + + try: + from apscheduler.schedulers.background import BackgroundScheduler + import atexit + + scheduler = BackgroundScheduler() + configure_scheduler_jobs(scheduler) + atexit.register(lambda: scheduler.shutdown()) + + scheduler.start() + _scheduler_instance = scheduler + print("✓ 调度器已启动") + return scheduler + except ImportError: + print("⚠ APScheduler 未安装,定时任务功能不可用") + print(" 安装命令:pip install APScheduler>=3.10.0") + return None + except Exception as e: + print(f"⚠ 定时任务初始化失败:{str(e)}") + return None + + +def scheduled_refresh_task(): + """定时刷新任务(由调度器调用)""" + from datetime import datetime, timedelta + + trace_id = generate_trace_id() + run_id = None + lock_owner_id = uuid.uuid4().hex + lock_acquired = False + + conn = create_sqlite_connection() + + try: + with app.app_context(): + enable_scheduled = get_setting('enable_scheduled_refresh', 'true').lower() == 'true' + use_cron = get_setting('use_cron_schedule', 'false').lower() == 'true' + refresh_interval_days = int(get_setting('refresh_interval_days', '30')) + delay_seconds = int(get_setting('refresh_delay_seconds', '5')) + + run_id = create_refresh_run(conn, 'scheduled', trace_id, total=0) + + if not enable_scheduled: + finish_refresh_run(conn, run_id, 'skipped', 0, 0, 0, "定时刷新已禁用") + return + + # 按天数策略:未到周期则跳过(不产生账号级刷新日志) + if not use_cron: + row = conn.execute(''' + SELECT finished_at + FROM refresh_runs + WHERE trigger_source = 'scheduled' + AND status IN ('completed', 'failed') + AND finished_at IS NOT NULL + ORDER BY finished_at DESC + LIMIT 1 + ''').fetchone() + + if row and row['finished_at']: + try: + last_time = datetime.fromisoformat(row['finished_at']) + except Exception: + last_time = None + + if last_time: + next_due = last_time + timedelta(days=refresh_interval_days) + if utcnow() < next_due: + finish_refresh_run( + conn, + run_id, + 'skipped', + 0, + 0, + 0, + f"距离上次刷新未满 {refresh_interval_days} 天,下次最早:{next_due.strftime('%Y-%m-%d %H:%M:%S')}" + ) + return + + # 清理过期/历史状态 + try: + conn.execute("DELETE FROM account_refresh_logs WHERE created_at < datetime('now', '-6 months')") + conn.execute("DELETE FROM refresh_runs WHERE started_at < datetime('now', '-6 months')") + conn.execute("DELETE FROM export_verify_tokens WHERE expires_at < ?", (time.time(),)) + conn.execute("DELETE FROM distributed_locks WHERE expires_at < ?", (time.time(),)) + conn.commit() + except Exception: + pass + + accounts = conn.execute(''' + SELECT id, email, client_id, refresh_token, group_id + FROM accounts + WHERE status = 'active' + ''').fetchall() + + total = len(accounts) + conn.execute('UPDATE refresh_runs SET total = ? WHERE id = ?', (total, run_id)) + conn.commit() + + ttl_seconds = compute_refresh_lock_ttl_seconds(total, delay_seconds) + ok, _info = acquire_distributed_lock(conn, REFRESH_LOCK_NAME, lock_owner_id, ttl_seconds) + if not ok: + finish_refresh_run(conn, run_id, 'skipped', total, 0, 0, "刷新任务冲突:已有刷新在执行") + return + lock_acquired = True + + print(f"[定时任务] 开始执行 Token 刷新... trace_id={trace_id} run_id={run_id}") + result = trigger_refresh_internal(conn, accounts, refresh_type='scheduled', run_id=run_id, delay_seconds=delay_seconds) + finish_refresh_run( + conn, + run_id, + 'completed', + total, + result['success_count'], + result['failed_count'], + f"完成:成功 {result['success_count']},失败 {result['failed_count']}" + ) + print(f"[定时任务] Token 刷新完成 trace_id={trace_id} run_id={run_id}") + + except Exception as e: + try: + if run_id: + finish_refresh_run(conn, run_id, 'failed', 0, 0, 0, str(e)) + except Exception: + pass + try: + app.logger.exception("Scheduled refresh task failed trace_id=%s", trace_id) + except Exception: + pass + finally: + if lock_acquired: + release_distributed_lock(conn, REFRESH_LOCK_NAME, lock_owner_id) + conn.close() + + +def trigger_refresh_internal( + conn: sqlite3.Connection, + accounts: List[sqlite3.Row], + refresh_type: str, + run_id: str, + delay_seconds: int, +) -> Dict[str, Any]: + """内部触发刷新(不通过 HTTP),返回统计""" + total = len(accounts) + success_count = 0 + failed_count = 0 + + for index, account in enumerate(accounts, 1): + account_id = account['id'] + account_email = account['email'] + client_id = account['client_id'] + encrypted_refresh_token = account['refresh_token'] + + # 解密 refresh_token + try: + refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token + except Exception as e: + failed_count += 1 + error_msg = f"解密 token 失败: {str(e)}" + try: + conn.execute(''' + INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message, run_id) + VALUES (?, ?, ?, ?, ?, ?) + ''', (account_id, account_email, refresh_type, 'failed', error_msg, run_id)) + conn.commit() + except Exception: + pass + continue + + # 获取分组代理设置 + proxy_url = '' + group_id = account['group_id'] + if group_id: + try: + group_row = conn.execute('SELECT proxy_url FROM groups WHERE id = ?', (group_id,)).fetchone() + if group_row: + proxy_url = group_row['proxy_url'] or '' + except Exception: + proxy_url = '' + + success, error_msg = test_refresh_token(client_id, refresh_token, proxy_url) + + try: + conn.execute(''' + INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message, run_id) + VALUES (?, ?, ?, ?, ?, ?) + ''', (account_id, account_email, refresh_type, 'success' if success else 'failed', error_msg, run_id)) + + if success: + conn.execute(''' + UPDATE accounts + SET last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (account_id,)) + + conn.commit() + except Exception: + pass + + if success: + success_count += 1 + else: + failed_count += 1 + + if index < total and delay_seconds > 0: + time.sleep(delay_seconds) + + return { + "total": total, + "success_count": success_count, + "failed_count": failed_count + } + + +def should_autostart_scheduler() -> bool: + """在 WSGI/Gunicorn 场景下自动启动调度器;避免 Flask CLI/重载器导致重复启动""" + autostart = config.get_scheduler_autostart_default() + if not autostart: + return False + + # Flask CLI (flask run) + reloader:父进程不启动 + if config.env_true('FLASK_RUN_FROM_CLI', False) and not config.env_true('WERKZEUG_RUN_MAIN', False): + return False + + return True + + +# 注意:调度器启动由 outlook_web.app:create_app() / __main__ 入口控制(避免 import-time 副作用) + + +# ==================== 错误处理 ==================== + +@app.errorhandler(HTTPException) +def handle_http_exception(error: HTTPException): + """处理可预期的 HTTP 异常,返回统一错误结构(仅对 API/JSON 请求返回 JSON)""" + status_code = error.code or 500 + + message_map = { + 400: "请求参数错误", + 401: "未授权", + 403: "无权限", + 404: "资源不存在", + 405: "请求方法不被允许", + 429: "请求过于频繁,请稍后再试", + } + message = message_map.get(status_code, "请求失败") + + trace_id_value = None + try: + trace_id_value = getattr(g, 'trace_id', None) + except Exception: + trace_id_value = None + + error_payload = build_error_payload( + code="HTTP_ERROR", + message=message, + err_type="HttpError", + status=status_code, + details=str(error), + trace_id=trace_id_value + ) + + if request.path.startswith('/api/') or request.is_json: + return jsonify({'success': False, 'error': error_payload}), status_code + + return f"{message} (trace_id={error_payload.get('trace_id')})", status_code + + +@app.errorhandler(Exception) +def handle_exception(error): + """处理未捕获的异常""" + trace_id_value = None + try: + trace_id_value = getattr(g, 'trace_id', None) + except Exception: + trace_id_value = None + + try: + app.logger.exception("Unhandled exception trace_id=%s", trace_id_value or "unknown") + except Exception: + pass + + error_payload = build_error_payload( + code="INTERNAL_ERROR", + message="服务器内部错误", + err_type="UnhandledException", + status=500, + details=str(error), + trace_id=trace_id_value + ) + + if request.path.startswith('/api/') or request.is_json: + return jsonify({'success': False, 'error': error_payload}), 500 + + return f"服务器内部错误 (trace_id={error_payload.get('trace_id')})", 500 + + +# ==================== 主程序 ==================== + +if __name__ == '__main__': + # 从环境变量获取配置 + port = int(os.getenv('PORT', 5000)) + host = os.getenv('HOST', '0.0.0.0') + debug = os.getenv('FLASK_ENV', 'production') != 'production' + + print("=" * 60) + print("Outlook 邮件 Web 应用") + print("=" * 60) + print(f"访问地址: http://{host}:{port}") + print(f"运行模式: {'开发' if debug else '生产'}") + print("=" * 60) + + # 初始化定时任务 + if not debug or os.getenv('WERKZEUG_RUN_MAIN') == 'true': + init_scheduler() + else: + print("✓ 调试重载器父进程:跳过启动调度器") + + app.run(debug=debug, host=host, port=port) diff --git a/outlook_web/repositories/__init__.py b/outlook_web/repositories/__init__.py new file mode 100644 index 0000000..a446863 --- /dev/null +++ b/outlook_web/repositories/__init__.py @@ -0,0 +1,5 @@ +"""数据访问层(repositories)。 + +目标:把 SQL 与数据映射集中到本目录,routes/services 不直接写 SQL。 +""" + diff --git a/outlook_web/repositories/accounts.py b/outlook_web/repositories/accounts.py new file mode 100644 index 0000000..a286b34 --- /dev/null +++ b/outlook_web/repositories/accounts.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import sqlite3 +from typing import Any, Dict, List, Optional + +from outlook_web.db import get_db +from outlook_web.security.crypto import decrypt_data, encrypt_data + + +def load_accounts(group_id: int = None) -> List[Dict]: + """从数据库加载邮箱账号(自动解密敏感字段,批量加载 tags 避免 N+1)""" + db = get_db() + if group_id: + cursor = db.execute( + """ + SELECT a.*, g.name as group_name, g.color as group_color + FROM accounts a + LEFT JOIN groups g ON a.group_id = g.id + WHERE a.group_id = ? + ORDER BY a.created_at DESC + """, + (group_id,), + ) + else: + cursor = db.execute( + """ + SELECT a.*, g.name as group_name, g.color as group_color + FROM accounts a + LEFT JOIN groups g ON a.group_id = g.id + ORDER BY a.created_at DESC + """ + ) + rows = cursor.fetchall() + + tags_by_account: Dict[int, List[Dict[str, Any]]] = {} + try: + account_ids = [int(r["id"]) for r in rows] + except Exception: + account_ids = [] + + if account_ids: + try: + placeholders = ",".join(["?"] * len(account_ids)) + tag_rows = db.execute( + f""" + SELECT at.account_id as account_id, t.* + FROM account_tags at + JOIN tags t ON t.id = at.tag_id + WHERE at.account_id IN ({placeholders}) + ORDER BY at.account_id ASC, t.created_at DESC + """, + account_ids, + ).fetchall() + + for tr in tag_rows: + tag_dict = dict(tr) + acc_id = tag_dict.pop("account_id", None) + if acc_id is None: + continue + tags_by_account.setdefault(int(acc_id), []).append(tag_dict) + except Exception: + tags_by_account = {} + + accounts: List[Dict[str, Any]] = [] + for row in rows: + account = dict(row) + + if account.get("password"): + try: + account["password"] = decrypt_data(account["password"]) + except Exception: + pass + if account.get("refresh_token"): + try: + account["refresh_token"] = decrypt_data(account["refresh_token"]) + except Exception: + pass + + account_id_value = account.get("id") + try: + account_id_value = int(account_id_value) + except Exception: + account_id_value = None + + account["tags"] = tags_by_account.get(account_id_value, []) if account_id_value is not None else [] + accounts.append(account) + return accounts + + +def get_account_by_email(email_addr: str) -> Optional[Dict]: + """根据邮箱地址获取账号(自动解密敏感字段)""" + db = get_db() + cursor = db.execute("SELECT * FROM accounts WHERE email = ?", (email_addr,)) + row = cursor.fetchone() + if not row: + return None + account = dict(row) + if account.get("password"): + try: + account["password"] = decrypt_data(account["password"]) + except Exception: + pass + if account.get("refresh_token"): + try: + account["refresh_token"] = decrypt_data(account["refresh_token"]) + except Exception: + pass + return account + + +def get_account_by_id(account_id: int) -> Optional[Dict]: + """根据 ID 获取账号(含 group_name/group_color,自动解密敏感字段)""" + db = get_db() + cursor = db.execute( + """ + SELECT a.*, g.name as group_name, g.color as group_color + FROM accounts a + LEFT JOIN groups g ON a.group_id = g.id + WHERE a.id = ? + """, + (account_id,), + ) + row = cursor.fetchone() + if not row: + return None + account = dict(row) + if account.get("password"): + try: + account["password"] = decrypt_data(account["password"]) + except Exception: + pass + if account.get("refresh_token"): + try: + account["refresh_token"] = decrypt_data(account["refresh_token"]) + except Exception: + pass + return account + + +def add_account( + email_addr: str, + password: str, + client_id: str, + refresh_token: str, + group_id: int = 1, + remark: str = "", + db: Optional[sqlite3.Connection] = None, + commit: bool = True, +) -> bool: + """添加邮箱账号(支持外部事务批量导入)""" + db = db or get_db() + try: + encrypted_password = encrypt_data(password) if password else password + encrypted_refresh_token = encrypt_data(refresh_token) if refresh_token else refresh_token + + db.execute( + """ + INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark) + VALUES (?, ?, ?, ?, ?, ?) + """, + (email_addr, encrypted_password, client_id, encrypted_refresh_token, group_id, remark), + ) + if commit: + db.commit() + return True + except sqlite3.IntegrityError: + return False + except Exception: + return False + + +def update_account( + account_id: int, + email_addr: str, + password: Optional[str], + client_id: Optional[str], + refresh_token: Optional[str], + group_id: int, + remark: str, + status: str, +) -> bool: + """更新邮箱账号""" + db = get_db() + try: + existing = db.execute( + """ + SELECT password, client_id, refresh_token + FROM accounts + WHERE id = ? + """, + (account_id,), + ).fetchone() + if not existing: + return False + + new_client_id = client_id.strip() if isinstance(client_id, str) and client_id.strip() else existing["client_id"] + + encrypted_password = existing["password"] + if isinstance(password, str) and password.strip(): + encrypted_password = encrypt_data(password) + + encrypted_refresh_token = existing["refresh_token"] + if isinstance(refresh_token, str) and refresh_token.strip(): + encrypted_refresh_token = encrypt_data(refresh_token) + + if not email_addr or not new_client_id or not encrypted_refresh_token: + return False + + db.execute( + """ + UPDATE accounts + SET email = ?, password = ?, client_id = ?, refresh_token = ?, + group_id = ?, remark = ?, status = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (email_addr, encrypted_password, new_client_id, encrypted_refresh_token, group_id, remark, status, account_id), + ) + db.commit() + return True + except Exception: + return False + + +def delete_account_by_id(account_id: int) -> bool: + """删除邮箱账号""" + db = get_db() + try: + db.execute("DELETE FROM accounts WHERE id = ?", (account_id,)) + db.commit() + return True + except Exception: + return False + + +def delete_account_by_email(email_addr: str) -> bool: + """根据邮箱地址删除账号""" + db = get_db() + try: + db.execute("DELETE FROM accounts WHERE email = ?", (email_addr,)) + db.commit() + return True + except Exception: + return False + diff --git a/outlook_web/repositories/distributed_locks.py b/outlook_web/repositories/distributed_locks.py new file mode 100644 index 0000000..57bcf22 --- /dev/null +++ b/outlook_web/repositories/distributed_locks.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import time +from typing import Any, Dict, Optional + +import sqlite3 + + +def acquire_distributed_lock( + conn: sqlite3.Connection, + name: str, + owner_id: str, + ttl_seconds: int, +) -> tuple[bool, Optional[Dict[str, Any]]]: + """获取分布式锁(基于同一 SQLite 数据库),用于避免并发刷新冲突""" + now_ts = time.time() + expires_at = now_ts + ttl_seconds + + try: + conn.execute("BEGIN IMMEDIATE") + row = conn.execute( + """ + SELECT owner_id, acquired_at, expires_at + FROM distributed_locks + WHERE name = ? + """, + (name,), + ).fetchone() + + if not row: + conn.execute( + """ + INSERT INTO distributed_locks (name, owner_id, acquired_at, expires_at) + VALUES (?, ?, ?, ?) + """, + (name, owner_id, now_ts, expires_at), + ) + conn.commit() + return True, None + + if row["expires_at"] < now_ts: + conn.execute( + """ + UPDATE distributed_locks + SET owner_id = ?, acquired_at = ?, expires_at = ? + WHERE name = ? + """, + (owner_id, now_ts, expires_at, name), + ) + conn.commit() + return True, { + "previous_owner_id": row["owner_id"], + "previous_acquired_at": row["acquired_at"], + "previous_expires_at": row["expires_at"], + } + + conn.rollback() + return False, { + "owner_id": row["owner_id"], + "acquired_at": row["acquired_at"], + "expires_at": row["expires_at"], + } + except Exception as e: + try: + conn.rollback() + except Exception: + pass + return False, {"error": str(e)} + + +def release_distributed_lock(conn: sqlite3.Connection, name: str, owner_id: str) -> bool: + try: + conn.execute("BEGIN IMMEDIATE") + conn.execute("DELETE FROM distributed_locks WHERE name = ? AND owner_id = ?", (name, owner_id)) + conn.commit() + return True + except Exception: + try: + conn.rollback() + except Exception: + pass + return False + diff --git a/outlook_web/repositories/groups.py b/outlook_web/repositories/groups.py new file mode 100644 index 0000000..dc541d8 --- /dev/null +++ b/outlook_web/repositories/groups.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import sqlite3 +from typing import Dict, List, Optional + +from outlook_web.db import get_db + + +def load_groups() -> List[Dict]: + """加载所有分组(临时邮箱分组排在最前面)""" + db = get_db() + cursor = db.execute( + """ + SELECT * FROM groups + ORDER BY + CASE WHEN name = '临时邮箱' THEN 0 ELSE 1 END, + id + """ + ) + rows = cursor.fetchall() + return [dict(row) for row in rows] + + +def get_group_by_id(group_id: int) -> Optional[Dict]: + """根据 ID 获取分组""" + db = get_db() + cursor = db.execute("SELECT * FROM groups WHERE id = ?", (group_id,)) + row = cursor.fetchone() + return dict(row) if row else None + + +def add_group( + name: str, + description: str = "", + color: str = "#1a1a1a", + proxy_url: str = "", +) -> Optional[int]: + """添加分组""" + db = get_db() + try: + cursor = db.execute( + """ + INSERT INTO groups (name, description, color, proxy_url) + VALUES (?, ?, ?, ?) + """, + (name, description, color, proxy_url or ""), + ) + db.commit() + return cursor.lastrowid + except sqlite3.IntegrityError: + return None + + +def update_group( + group_id: int, + name: str, + description: str, + color: str, + proxy_url: str = "", +) -> bool: + """更新分组""" + db = get_db() + try: + db.execute( + """ + UPDATE groups SET name = ?, description = ?, color = ?, proxy_url = ? + WHERE id = ? + """, + (name, description, color, proxy_url or "", group_id), + ) + db.commit() + return True + except Exception: + return False + + +def get_default_group_id() -> int: + """获取默认分组 ID(不依赖固定 id=1,增强兼容性)""" + db = get_db() + try: + row = db.execute("SELECT id FROM groups WHERE name = '默认分组' LIMIT 1").fetchone() + return row["id"] if row else 1 + except Exception: + return 1 + + +def delete_group(group_id: int) -> bool: + """删除分组(将该分组下的邮箱移到默认分组)""" + db = get_db() + try: + row = db.execute("SELECT id, name, is_system FROM groups WHERE id = ?", (group_id,)).fetchone() + if not row: + return False + if row["is_system"]: + return False + + default_group_id = get_default_group_id() + if group_id == default_group_id or row["name"] == "默认分组": + return False + + db.execute( + "UPDATE accounts SET group_id = ?, updated_at = CURRENT_TIMESTAMP WHERE group_id = ?", + (default_group_id, group_id), + ) + db.execute("DELETE FROM groups WHERE id = ?", (group_id,)) + db.commit() + return True + except Exception: + return False + + +def get_group_account_count(group_id: int) -> int: + """获取分组下的邮箱数量""" + db = get_db() + cursor = db.execute("SELECT COUNT(*) as count FROM accounts WHERE group_id = ?", (group_id,)) + row = cursor.fetchone() + return row["count"] if row else 0 + diff --git a/outlook_web/repositories/refresh_logs.py b/outlook_web/repositories/refresh_logs.py new file mode 100644 index 0000000..ec33975 --- /dev/null +++ b/outlook_web/repositories/refresh_logs.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import Optional + +from outlook_web.db import get_db + + +def log_refresh_result( + account_id: int, + account_email: str, + refresh_type: str, + status: str, + error_message: str = None, + run_id: str = None, +) -> bool: + """记录刷新结果到数据库""" + db = get_db() + try: + db.execute( + """ + INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message, run_id) + VALUES (?, ?, ?, ?, ?, ?) + """, + (account_id, account_email, refresh_type, status, error_message, run_id), + ) + + if status == "success": + db.execute( + """ + UPDATE accounts + SET last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (account_id,), + ) + + db.commit() + return True + except Exception: + return False + diff --git a/outlook_web/repositories/refresh_runs.py b/outlook_web/repositories/refresh_runs.py new file mode 100644 index 0000000..1228ecd --- /dev/null +++ b/outlook_web/repositories/refresh_runs.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import sqlite3 +import uuid +from typing import Optional + + +def create_refresh_run( + conn: sqlite3.Connection, + trigger_source: str, + trace_id: str, + requested_by_ip: str = None, + requested_by_user_agent: str = None, + total: int = 0, +) -> str: + run_id = uuid.uuid4().hex + conn.execute( + """ + INSERT INTO refresh_runs ( + id, trigger_source, status, + requested_by_ip, requested_by_user_agent, + total, success_count, failed_count, + trace_id + ) VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?) + """, + (run_id, trigger_source, "running", requested_by_ip, requested_by_user_agent, total, trace_id), + ) + conn.commit() + return run_id + + +def finish_refresh_run( + conn: sqlite3.Connection, + run_id: str, + status: str, + total: int, + success_count: int, + failed_count: int, + message: str = None, +): + conn.execute( + """ + UPDATE refresh_runs + SET status = ?, finished_at = CURRENT_TIMESTAMP, + total = ?, success_count = ?, failed_count = ?, message = ? + WHERE id = ? + """, + (status, total, success_count, failed_count, message, run_id), + ) + conn.commit() + diff --git a/outlook_web/repositories/settings.py b/outlook_web/repositories/settings.py new file mode 100644 index 0000000..72948f8 --- /dev/null +++ b/outlook_web/repositories/settings.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import Dict + +from outlook_web import config +from outlook_web.db import get_db + + +def get_setting(key: str, default: str = "") -> str: + """获取设置值""" + db = get_db() + cursor = db.execute("SELECT value FROM settings WHERE key = ?", (key,)) + row = cursor.fetchone() + return row["value"] if row else default + + +def set_setting(key: str, value: str) -> bool: + """设置值""" + db = get_db() + try: + db.execute( + """ + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + """, + (key, value), + ) + db.commit() + return True + except Exception: + return False + + +def get_all_settings() -> Dict[str, str]: + """获取所有设置""" + db = get_db() + cursor = db.execute("SELECT key, value FROM settings") + rows = cursor.fetchall() + return {row["key"]: row["value"] for row in rows} + + +def get_login_password() -> str: + """获取登录密码(优先从数据库读取)""" + password = get_setting("login_password") + return password if password else config.get_login_password_default() + + +def get_gptmail_api_key() -> str: + """获取 GPTMail API Key(优先从数据库读取)""" + api_key = get_setting("gptmail_api_key") + return api_key if api_key else config.get_gptmail_api_key_default() + diff --git a/outlook_web/repositories/tags.py b/outlook_web/repositories/tags.py new file mode 100644 index 0000000..c4bb868 --- /dev/null +++ b/outlook_web/repositories/tags.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import sqlite3 +from typing import Dict, List, Optional + +from outlook_web.db import get_db + + +def get_tags() -> List[Dict]: + """获取所有标签""" + db = get_db() + cursor = db.execute("SELECT * FROM tags ORDER BY created_at DESC") + return [dict(row) for row in cursor.fetchall()] + + +def add_tag(name: str, color: str) -> Optional[int]: + """添加标签""" + db = get_db() + try: + cursor = db.execute("INSERT INTO tags (name, color) VALUES (?, ?)", (name, color)) + db.commit() + return cursor.lastrowid + except sqlite3.IntegrityError: + return None + + +def delete_tag(tag_id: int) -> bool: + """删除标签""" + db = get_db() + cursor = db.execute("DELETE FROM tags WHERE id = ?", (tag_id,)) + db.commit() + return cursor.rowcount > 0 + + +def get_account_tags(account_id: int) -> List[Dict]: + """获取账号的标签""" + db = get_db() + cursor = db.execute( + """ + SELECT t.* + FROM tags t + JOIN account_tags at ON t.id = at.tag_id + WHERE at.account_id = ? + ORDER BY t.created_at DESC + """, + (account_id,), + ) + return [dict(row) for row in cursor.fetchall()] + + +def add_account_tag(account_id: int, tag_id: int) -> bool: + """给账号添加标签""" + db = get_db() + try: + db.execute( + "INSERT OR IGNORE INTO account_tags (account_id, tag_id) VALUES (?, ?)", + (account_id, tag_id), + ) + db.commit() + return True + except Exception: + return False + + +def remove_account_tag(account_id: int, tag_id: int) -> bool: + """移除账号标签""" + db = get_db() + db.execute("DELETE FROM account_tags WHERE account_id = ? AND tag_id = ?", (account_id, tag_id)) + db.commit() + return True + diff --git a/outlook_web/repositories/temp_emails.py b/outlook_web/repositories/temp_emails.py new file mode 100644 index 0000000..7e42792 --- /dev/null +++ b/outlook_web/repositories/temp_emails.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import sqlite3 +from typing import Dict, List, Optional + +from outlook_web.db import get_db + + +def get_temp_email_group_id() -> int: + """获取临时邮箱分组的 ID""" + db = get_db() + cursor = db.execute("SELECT id FROM groups WHERE name = '临时邮箱'") + row = cursor.fetchone() + return row["id"] if row else 2 + + +def load_temp_emails() -> List[Dict]: + """加载所有临时邮箱""" + db = get_db() + cursor = db.execute("SELECT * FROM temp_emails ORDER BY created_at DESC") + rows = cursor.fetchall() + return [dict(row) for row in rows] + + +def get_temp_email_by_address(email_addr: str) -> Optional[Dict]: + """根据邮箱地址获取临时邮箱""" + db = get_db() + cursor = db.execute("SELECT * FROM temp_emails WHERE email = ?", (email_addr,)) + row = cursor.fetchone() + return dict(row) if row else None + + +def add_temp_email(email_addr: str) -> bool: + """添加临时邮箱""" + db = get_db() + try: + db.execute("INSERT INTO temp_emails (email) VALUES (?)", (email_addr,)) + db.commit() + return True + except sqlite3.IntegrityError: + return False + + +def delete_temp_email(email_addr: str) -> bool: + """删除临时邮箱及其所有邮件""" + db = get_db() + try: + db.execute("DELETE FROM temp_email_messages WHERE email_address = ?", (email_addr,)) + db.execute("DELETE FROM temp_emails WHERE email = ?", (email_addr,)) + db.commit() + return True + except Exception: + return False + + +def save_temp_email_messages(email_addr: str, messages: List[Dict]) -> int: + """保存临时邮件到数据库""" + db = get_db() + saved = 0 + for msg in messages: + try: + db.execute( + """ + INSERT OR REPLACE INTO temp_email_messages + (message_id, email_address, from_address, subject, content, html_content, has_html, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + msg.get("id"), + email_addr, + msg.get("from_address", ""), + msg.get("subject", ""), + msg.get("content", ""), + msg.get("html_content", ""), + 1 if msg.get("has_html") else 0, + msg.get("timestamp", 0), + ), + ) + saved += 1 + except Exception: + continue + db.commit() + return saved + + +def get_temp_email_messages(email_addr: str) -> List[Dict]: + """获取临时邮箱的所有邮件(从数据库)""" + db = get_db() + cursor = db.execute( + """ + SELECT * FROM temp_email_messages + WHERE email_address = ? + ORDER BY timestamp DESC + """, + (email_addr,), + ) + rows = cursor.fetchall() + return [dict(row) for row in rows] + + +def get_temp_email_message_by_id(message_id: str) -> Optional[Dict]: + """根据 ID 获取临时邮件""" + db = get_db() + cursor = db.execute("SELECT * FROM temp_email_messages WHERE message_id = ?", (message_id,)) + row = cursor.fetchone() + return dict(row) if row else None + + +def delete_temp_email_message(message_id: str) -> bool: + """删除临时邮件""" + db = get_db() + try: + db.execute("DELETE FROM temp_email_messages WHERE message_id = ?", (message_id,)) + db.commit() + return True + except Exception: + return False + + +def get_temp_email_count() -> int: + """获取临时邮箱数量""" + db = get_db() + cursor = db.execute("SELECT COUNT(*) as count FROM temp_emails") + row = cursor.fetchone() + return row["count"] if row else 0 + diff --git a/outlook_web/routes/__init__.py b/outlook_web/routes/__init__.py new file mode 100644 index 0000000..c3236f5 --- /dev/null +++ b/outlook_web/routes/__init__.py @@ -0,0 +1,5 @@ +"""HTTP 路由层(Blueprint)。 + +目标:按领域拆分 routes,并在 create_app 内集中注册,保持 URL 不变。 +""" + diff --git a/outlook_web/routes/accounts.py b/outlook_web/routes/accounts.py new file mode 100644 index 0000000..d4c27b3 --- /dev/null +++ b/outlook_web/routes/accounts.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from flask import Blueprint + + +def create_blueprint(*, impl) -> Blueprint: + bp = Blueprint("accounts", __name__) + + bp.add_url_rule("/api/accounts", view_func=impl.api_get_accounts, methods=["GET"]) + bp.add_url_rule("/api/accounts", view_func=impl.api_add_account, methods=["POST"]) + bp.add_url_rule("/api/accounts/", view_func=impl.api_get_account, methods=["GET"]) + bp.add_url_rule("/api/accounts/", view_func=impl.api_update_account, methods=["PUT"]) + bp.add_url_rule("/api/accounts/", view_func=impl.api_delete_account, methods=["DELETE"]) + bp.add_url_rule("/api/accounts/email/", view_func=impl.api_delete_account_by_email, methods=["DELETE"]) + + bp.add_url_rule("/api/accounts/search", view_func=impl.api_search_accounts, methods=["GET"]) + bp.add_url_rule("/api/accounts/batch-update-group", view_func=impl.api_batch_update_account_group, methods=["POST"]) + bp.add_url_rule("/api/accounts/tags", view_func=impl.api_batch_manage_tags, methods=["POST"]) + + bp.add_url_rule("/api/accounts/export", view_func=impl.api_export_all_accounts, methods=["GET"]) + bp.add_url_rule("/api/accounts/export-selected", view_func=impl.api_export_selected_accounts, methods=["POST"]) + bp.add_url_rule("/api/export/verify", view_func=impl.api_generate_export_verify_token, methods=["POST"]) + + bp.add_url_rule("/api/accounts//refresh", view_func=impl.api_refresh_account, methods=["POST"]) + bp.add_url_rule("/api/accounts/refresh-all", view_func=impl.api_refresh_all_accounts, methods=["GET"]) + bp.add_url_rule("/api/accounts//retry-refresh", view_func=impl.api_retry_refresh_account, methods=["POST"]) + bp.add_url_rule("/api/accounts/refresh-failed", view_func=impl.api_refresh_failed_accounts, methods=["POST"]) + bp.add_url_rule("/api/accounts/trigger-scheduled-refresh", view_func=impl.api_trigger_scheduled_refresh, methods=["GET"]) + + bp.add_url_rule("/api/accounts/refresh-logs", view_func=impl.api_get_refresh_logs, methods=["GET"]) + bp.add_url_rule("/api/accounts//refresh-logs", view_func=impl.api_get_account_refresh_logs, methods=["GET"]) + bp.add_url_rule("/api/accounts/refresh-logs/failed", view_func=impl.api_get_failed_refresh_logs, methods=["GET"]) + bp.add_url_rule("/api/accounts/refresh-stats", view_func=impl.api_get_refresh_stats, methods=["GET"]) + + return bp + diff --git a/outlook_web/routes/audit.py b/outlook_web/routes/audit.py new file mode 100644 index 0000000..3c2bc20 --- /dev/null +++ b/outlook_web/routes/audit.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from flask import Blueprint + + +def create_blueprint(*, impl) -> Blueprint: + bp = Blueprint("audit", __name__) + bp.add_url_rule("/api/audit-logs", view_func=impl.api_get_audit_logs, methods=["GET"]) + return bp diff --git a/outlook_web/routes/emails.py b/outlook_web/routes/emails.py new file mode 100644 index 0000000..d5639bc --- /dev/null +++ b/outlook_web/routes/emails.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from flask import Blueprint + + +def create_blueprint(*, impl) -> Blueprint: + bp = Blueprint("emails", __name__) + bp.add_url_rule("/api/emails/", view_func=impl.api_get_emails, methods=["GET"]) + bp.add_url_rule("/api/emails/delete", view_func=impl.api_delete_emails, methods=["POST"]) + bp.add_url_rule("/api/email//", view_func=impl.api_get_email_detail, methods=["GET"]) + return bp + diff --git a/outlook_web/routes/groups.py b/outlook_web/routes/groups.py new file mode 100644 index 0000000..a60db64 --- /dev/null +++ b/outlook_web/routes/groups.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from flask import Blueprint + + +def create_blueprint(*, impl) -> Blueprint: + bp = Blueprint("groups", __name__) + bp.add_url_rule("/api/groups", view_func=impl.api_get_groups, methods=["GET"]) + bp.add_url_rule("/api/groups/", view_func=impl.api_get_group, methods=["GET"]) + bp.add_url_rule("/api/groups", view_func=impl.api_add_group, methods=["POST"]) + bp.add_url_rule("/api/groups/", view_func=impl.api_update_group, methods=["PUT"]) + bp.add_url_rule("/api/groups/", view_func=impl.api_delete_group, methods=["DELETE"]) + bp.add_url_rule("/api/groups//export", view_func=impl.api_export_group, methods=["GET"]) + return bp + diff --git a/outlook_web/routes/oauth.py b/outlook_web/routes/oauth.py new file mode 100644 index 0000000..0d7bb40 --- /dev/null +++ b/outlook_web/routes/oauth.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from flask import Blueprint + + +def create_blueprint(*, impl) -> Blueprint: + bp = Blueprint("oauth", __name__) + bp.add_url_rule("/api/oauth/auth-url", view_func=impl.api_get_oauth_auth_url, methods=["GET"]) + bp.add_url_rule("/api/oauth/exchange-token", view_func=impl.api_exchange_oauth_token, methods=["POST"]) + return bp + diff --git a/outlook_web/routes/pages.py b/outlook_web/routes/pages.py new file mode 100644 index 0000000..0c32cbd --- /dev/null +++ b/outlook_web/routes/pages.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import Callable, Optional + +from flask import Blueprint + + +def create_blueprint(*, impl, csrf_exempt: Optional[Callable] = None) -> Blueprint: + bp = Blueprint("pages", __name__) + + login_view = impl.login + csrf_token_view = impl.get_csrf_token + if csrf_exempt is not None: + login_view = csrf_exempt(login_view) + csrf_token_view = csrf_exempt(csrf_token_view) + + bp.add_url_rule("/login", view_func=login_view, methods=["GET", "POST"]) + bp.add_url_rule("/logout", view_func=impl.logout, methods=["GET"]) + bp.add_url_rule("/", view_func=impl.index, methods=["GET"]) + bp.add_url_rule("/api/csrf-token", view_func=csrf_token_view, methods=["GET"]) + return bp + diff --git a/outlook_web/routes/scheduler.py b/outlook_web/routes/scheduler.py new file mode 100644 index 0000000..744fd6d --- /dev/null +++ b/outlook_web/routes/scheduler.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from flask import Blueprint + + +def create_blueprint(*, impl) -> Blueprint: + bp = Blueprint("scheduler", __name__) + bp.add_url_rule("/api/scheduler/status", view_func=impl.api_get_scheduler_status, methods=["GET"]) + return bp diff --git a/outlook_web/routes/settings.py b/outlook_web/routes/settings.py new file mode 100644 index 0000000..85ec0f0 --- /dev/null +++ b/outlook_web/routes/settings.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from flask import Blueprint + + +def create_blueprint(*, impl) -> Blueprint: + bp = Blueprint("settings", __name__) + bp.add_url_rule("/api/settings/validate-cron", view_func=impl.api_validate_cron, methods=["POST"]) + bp.add_url_rule("/api/settings", view_func=impl.api_get_settings, methods=["GET"]) + bp.add_url_rule("/api/settings", view_func=impl.api_update_settings, methods=["PUT"]) + return bp + diff --git a/outlook_web/routes/system.py b/outlook_web/routes/system.py new file mode 100644 index 0000000..944d6d8 --- /dev/null +++ b/outlook_web/routes/system.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from flask import Blueprint + + +def create_blueprint(*, impl) -> Blueprint: + bp = Blueprint("system", __name__) + bp.add_url_rule("/healthz", view_func=impl.healthz, methods=["GET"]) + bp.add_url_rule("/api/system/health", view_func=impl.api_system_health, methods=["GET"]) + bp.add_url_rule("/api/system/diagnostics", view_func=impl.api_system_diagnostics, methods=["GET"]) + bp.add_url_rule("/api/system/upgrade-status", view_func=impl.api_system_upgrade_status, methods=["GET"]) + return bp + diff --git a/outlook_web/routes/tags.py b/outlook_web/routes/tags.py new file mode 100644 index 0000000..e1a0520 --- /dev/null +++ b/outlook_web/routes/tags.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from flask import Blueprint + + +def create_blueprint(*, impl) -> Blueprint: + bp = Blueprint("tags", __name__) + bp.add_url_rule("/api/tags", view_func=impl.api_get_tags, methods=["GET"]) + bp.add_url_rule("/api/tags", view_func=impl.api_add_tag, methods=["POST"]) + bp.add_url_rule("/api/tags/", view_func=impl.api_delete_tag, methods=["DELETE"]) + return bp + diff --git a/outlook_web/routes/temp_emails.py b/outlook_web/routes/temp_emails.py new file mode 100644 index 0000000..d1c6ecd --- /dev/null +++ b/outlook_web/routes/temp_emails.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from flask import Blueprint + + +def create_blueprint(*, impl) -> Blueprint: + bp = Blueprint("temp_emails", __name__) + bp.add_url_rule("/api/temp-emails", view_func=impl.api_get_temp_emails, methods=["GET"]) + bp.add_url_rule("/api/temp-emails/generate", view_func=impl.api_generate_temp_email, methods=["POST"]) + bp.add_url_rule("/api/temp-emails/", view_func=impl.api_delete_temp_email, methods=["DELETE"]) + bp.add_url_rule("/api/temp-emails//messages", view_func=impl.api_get_temp_email_messages, methods=["GET"]) + bp.add_url_rule( + "/api/temp-emails//messages/", + view_func=impl.api_get_temp_email_message_detail, + methods=["GET"], + ) + bp.add_url_rule( + "/api/temp-emails//messages/", + view_func=impl.api_delete_temp_email_message, + methods=["DELETE"], + ) + bp.add_url_rule("/api/temp-emails//clear", view_func=impl.api_clear_temp_email_messages, methods=["DELETE"]) + bp.add_url_rule("/api/temp-emails//refresh", view_func=impl.api_refresh_temp_email_messages, methods=["POST"]) + return bp + diff --git a/outlook_web/security/__init__.py b/outlook_web/security/__init__.py new file mode 100644 index 0000000..80c9217 --- /dev/null +++ b/outlook_web/security/__init__.py @@ -0,0 +1,18 @@ +from outlook_web.security.crypto import ( + decrypt_data, + encrypt_data, + hash_password, + is_encrypted, + is_password_hashed, + verify_password, +) + +__all__ = [ + "decrypt_data", + "encrypt_data", + "hash_password", + "is_encrypted", + "is_password_hashed", + "verify_password", +] + diff --git a/outlook_web/security/auth.py b/outlook_web/security/auth.py new file mode 100644 index 0000000..14b8617 --- /dev/null +++ b/outlook_web/security/auth.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import secrets +import time +from functools import wraps +from typing import Optional + +from flask import g, jsonify, redirect, request, session, url_for + +from outlook_web.db import get_db +from outlook_web.errors import build_error_payload + +# 速率限制配置 +MAX_LOGIN_ATTEMPTS = 5 # 最大失败次数 +LOCKOUT_DURATION = 300 # 锁定时长(秒)- 5分钟 +ATTEMPT_WINDOW = 600 # 失败计数窗口(秒)- 10分钟 + +# 导出二次验证 Token(持久化存储,支持重启/多进程) +EXPORT_VERIFY_TOKEN_TTL_SECONDS = 300 # 5 分钟有效期 + + +def check_rate_limit(ip: str) -> tuple[bool, Optional[int]]: + """ + 检查 IP 是否被速率限制 + 返回: (是否允许登录, 剩余锁定秒数) + """ + current_time = time.time() + db = get_db() + + try: + row = db.execute( + """ + SELECT count, last_attempt_at, locked_until_at + FROM login_attempts + WHERE ip = ? + """, + (ip,), + ).fetchone() + + if not row: + return True, None + + locked_until_at = row["locked_until_at"] + if locked_until_at and current_time < locked_until_at: + remaining = int(locked_until_at - current_time) + return False, remaining + + last_attempt_at = row["last_attempt_at"] + if last_attempt_at and (current_time - last_attempt_at > ATTEMPT_WINDOW): + db.execute( + """ + UPDATE login_attempts + SET count = 0, last_attempt_at = ?, locked_until_at = NULL + WHERE ip = ? + """, + (current_time, ip), + ) + db.commit() + return True, None + + count = row["count"] or 0 + if count >= MAX_LOGIN_ATTEMPTS: + locked_until_at = current_time + LOCKOUT_DURATION + db.execute( + """ + UPDATE login_attempts + SET locked_until_at = ? + WHERE ip = ? + """, + (locked_until_at, ip), + ) + db.commit() + return False, LOCKOUT_DURATION + + return True, None + except Exception: + # 速率限制出错时,为避免误拒绝,默认放行 + return True, None + + +def record_login_failure(ip: str): + """记录登录失败""" + current_time = time.time() + db = get_db() + + try: + # 清理过期记录,避免无限增长 + db.execute( + """ + DELETE FROM login_attempts + WHERE last_attempt_at < ? + """, + (current_time - (ATTEMPT_WINDOW * 2),), + ) + + row = db.execute( + """ + SELECT count, last_attempt_at + FROM login_attempts + WHERE ip = ? + """, + (ip,), + ).fetchone() + + if not row: + db.execute( + """ + INSERT INTO login_attempts (ip, count, last_attempt_at, locked_until_at) + VALUES (?, ?, ?, NULL) + """, + (ip, 1, current_time), + ) + else: + last_attempt_at = row["last_attempt_at"] or 0 + count = row["count"] or 0 + if current_time - last_attempt_at <= ATTEMPT_WINDOW: + new_count = count + 1 + else: + new_count = 1 + + db.execute( + """ + UPDATE login_attempts + SET count = ?, last_attempt_at = ? + WHERE ip = ? + """, + (new_count, current_time, ip), + ) + + db.commit() + except Exception: + pass + + +def reset_login_attempts(ip: str): + """重置登录失败记录(登录成功时调用)""" + db = get_db() + try: + db.execute("DELETE FROM login_attempts WHERE ip = ?", (ip,)) + db.commit() + except Exception: + pass + + +def login_required(f): + """登录验证装饰器""" + + @wraps(f) + def decorated_function(*args, **kwargs): + if not session.get("logged_in"): + if request.is_json or request.path.startswith("/api/"): + trace_id_value = None + try: + trace_id_value = getattr(g, "trace_id", None) + except Exception: + trace_id_value = None + error_payload = build_error_payload( + code="AUTH_REQUIRED", + message="请先登录", + err_type="AuthError", + status=401, + details="need_login", + trace_id=trace_id_value, + ) + return jsonify({"success": False, "error": error_payload, "need_login": True}), 401 + return redirect(url_for("pages.login")) + return f(*args, **kwargs) + + return decorated_function + + +def get_client_ip() -> str: + """获取客户端 IP(兼容反向代理)""" + try: + client_ip = request.headers.get("X-Forwarded-For") or request.remote_addr + if client_ip: + client_ip = client_ip.split(",")[0].strip() + return client_ip or "unknown" + except Exception: + return "unknown" + + +def get_user_agent() -> str: + try: + return (request.headers.get("User-Agent") or "")[:300] + except Exception: + return "" + + +def issue_export_verify_token(client_ip: str, user_agent: str) -> str: + """生成并持久化一次性导出验证 token""" + db = get_db() + now_ts = time.time() + verify_token = secrets.token_urlsafe(32) + expires_at = now_ts + EXPORT_VERIFY_TOKEN_TTL_SECONDS + + db.execute( + """ + INSERT INTO export_verify_tokens (token, ip, user_agent, expires_at, created_at) + VALUES (?, ?, ?, ?, ?) + """, + (verify_token, client_ip, user_agent, expires_at, now_ts), + ) + db.execute("DELETE FROM export_verify_tokens WHERE expires_at < ?", (now_ts,)) + db.commit() + return verify_token + + +def consume_export_verify_token(verify_token: str) -> tuple[bool, str]: + """校验并消费一次性导出验证 token(成功则删除)""" + if not verify_token: + return False, "需要二次验证" + + db = get_db() + now_ts = time.time() + + try: + db.execute("BEGIN IMMEDIATE") + row = db.execute( + """ + SELECT expires_at + FROM export_verify_tokens + WHERE token = ? + """, + (verify_token,), + ).fetchone() + + if not row: + db.rollback() + return False, "需要二次验证" + + expires_at = row["expires_at"] or 0 + if float(expires_at) < now_ts: + db.execute("DELETE FROM export_verify_tokens WHERE token = ?", (verify_token,)) + db.commit() + return False, "验证已过期,请重新验证" + + db.execute("DELETE FROM export_verify_tokens WHERE token = ?", (verify_token,)) + db.commit() + return True, "" + except Exception: + try: + db.rollback() + except Exception: + pass + return False, "验证失败,请重试" + + +def check_export_verify_token(verify_token: str) -> tuple[bool, str]: + """校验一次性导出验证 token(不消费)""" + if not verify_token: + return False, "需要二次验证" + + db = get_db() + now_ts = time.time() + try: + row = db.execute( + """ + SELECT expires_at + FROM export_verify_tokens + WHERE token = ? + """, + (verify_token,), + ).fetchone() + if not row: + return False, "需要二次验证" + if float(row["expires_at"] or 0) < now_ts: + return False, "验证已过期,请重新验证" + return True, "" + except Exception: + return False, "验证失败,请重试" diff --git a/outlook_web/security/crypto.py b/outlook_web/security/crypto.py new file mode 100644 index 0000000..bc35dc6 --- /dev/null +++ b/outlook_web/security/crypto.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import base64 +from typing import Optional + +import bcrypt +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +from outlook_web import config + +_cipher_suite: Optional[Fernet] = None + + +def hash_password(password: str) -> str: + """使用 bcrypt 哈希密码""" + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(password.encode("utf-8"), salt) + return hashed.decode("utf-8") + + +def verify_password(password: str, hashed: str) -> bool: + """验证密码是否匹配哈希值""" + try: + return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8")) + except Exception: + return False + + +def is_password_hashed(password: str) -> bool: + """检查密码是否已经是 bcrypt 哈希值""" + return password.startswith("$2b$") or password.startswith("$2a$") or password.startswith("$2y$") + + +def get_encryption_key() -> bytes: + """ + 从 SECRET_KEY 派生加密密钥 + 使用 PBKDF2 从 SECRET_KEY 派生 32 字节密钥 + """ + secret_key = config.require_secret_key() + + # 使用固定盐(因为我们需要确保重启后能解密) + # 注意:这里使用固定盐是为了确保密钥一致性,安全性依赖于 SECRET_KEY 的强度 + salt = b"outlook_email_encryption_salt_v1" + + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=100000, + ) + key = base64.urlsafe_b64encode(kdf.derive(secret_key.encode())) + return key + + +def get_cipher() -> Fernet: + """获取加密器实例(单例模式)""" + global _cipher_suite + if _cipher_suite is None: + key = get_encryption_key() + _cipher_suite = Fernet(key) + return _cipher_suite + + +def encrypt_data(data: str) -> str: + """ + 加密敏感数据 + 返回 base64 编码的加密字符串,带有 'enc:' 前缀标识 + """ + if not data: + return data + + # 如果已经加密,直接返回 + if data.startswith("enc:"): + return data + + cipher = get_cipher() + encrypted = cipher.encrypt(data.encode("utf-8")) + return "enc:" + encrypted.decode("utf-8") + + +def decrypt_data(encrypted_data: str) -> str: + """ + 解密敏感数据 + 如果数据未加密(没有 'enc:' 前缀),直接返回原始数据 + """ + if not encrypted_data: + return encrypted_data + + # 如果没有加密标识,返回原始数据(向后兼容) + if not encrypted_data.startswith("enc:"): + return encrypted_data + + try: + cipher = get_cipher() + encrypted_bytes = encrypted_data[4:].encode("utf-8") # 移除 'enc:' 前缀 + decrypted = cipher.decrypt(encrypted_bytes) + return decrypted.decode("utf-8") + except Exception as e: + # 解密失败,可能是密钥变更或数据损坏 + import sys + + error_msg = f"Failed to decrypt data: {str(e)}" + print(f"[ERROR] {error_msg}", file=sys.stderr) + print(f"[ERROR] Data preview: {encrypted_data[:50]}...", file=sys.stderr) + print("[ERROR] This usually means SECRET_KEY has changed or data is corrupted", file=sys.stderr) + raise RuntimeError(error_msg) + + +def is_encrypted(data: str) -> bool: + """检查数据是否已加密""" + return bool(data) and data.startswith("enc:") + diff --git a/outlook_web/security/csrf.py b/outlook_web/security/csrf.py new file mode 100644 index 0000000..0a9e220 --- /dev/null +++ b/outlook_web/security/csrf.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import Callable, Optional, Tuple + + +try: + from flask_wtf.csrf import CSRFProtect, generate_csrf as _generate_csrf # type: ignore + + CSRF_AVAILABLE = True +except ImportError: # pragma: no cover + CSRFProtect = None # type: ignore + _generate_csrf = None # type: ignore + CSRF_AVAILABLE = False + print("Warning: flask-wtf not installed. CSRF protection is disabled. Install with: pip install flask-wtf") + + +def init_csrf(app) -> Tuple[Optional[object], Callable, Optional[Callable]]: + """ + 初始化 CSRF 保护(如果可用) + 返回:csrf 实例、csrf_exempt 装饰器、generate_csrf 函数(可能为 None) + """ + if CSRF_AVAILABLE: + csrf = CSRFProtect(app) # type: ignore[misc] + app.config["WTF_CSRF_TIME_LIMIT"] = None # CSRF token 不过期 + app.config["WTF_CSRF_SSL_STRICT"] = False # 允许非HTTPS环境(开发环境) + print("CSRF protection enabled") + + def csrf_exempt(f): + return csrf.exempt(f) + + return csrf, csrf_exempt, _generate_csrf + + # 显式禁用 CSRF 保护 + app.config["WTF_CSRF_ENABLED"] = False + app.config["WTF_CSRF_CHECK_DEFAULT"] = False + print("CSRF protection disabled") + + def csrf_exempt(f): + return f + + return None, csrf_exempt, None + diff --git a/outlook_web/services/__init__.py b/outlook_web/services/__init__.py new file mode 100644 index 0000000..d38491c --- /dev/null +++ b/outlook_web/services/__init__.py @@ -0,0 +1,6 @@ +"""业务编排层(services)。 + +目标:集中对外部系统(Graph/IMAP/GPTMail)与业务回退编排(刷新、删除等)的逻辑, +routes 只负责参数校验与 HTTP 协议层适配。 +""" + diff --git a/outlook_web/services/email_delete.py b/outlook_web/services/email_delete.py new file mode 100644 index 0000000..b1b8879 --- /dev/null +++ b/outlook_web/services/email_delete.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import json +from typing import Any, Callable, Dict, List, Optional, Tuple + +from outlook_web.errors import build_error_payload + + +def summarize_fallback_failures(method_errors: Dict[str, Any], labels: Dict[str, str]) -> str: + """将多方式回退的失败原因聚合成“中文可理解”的摘要文本(用于 error.details 展示)。""" + lines: List[str] = [] + + for key, label in labels.items(): + if key not in method_errors: + continue + err = method_errors.get(key) + if err is None: + text = "未知错误" + elif isinstance(err, dict): + msg = (err.get("message") or err.get("error") or "").strip() + code = (err.get("code") or "").strip() + status = err.get("status") + meta_parts: List[str] = [] + if code: + meta_parts.append(f"code={code}") + if status: + meta_parts.append(f"status={status}") + meta = f" ({', '.join(meta_parts)})" if meta_parts else "" + if msg: + text = f"{msg}{meta}" + else: + raw = json.dumps(err, ensure_ascii=False) + text = raw[:400] + ("..." if len(raw) > 400 else "") + elif isinstance(err, list): + preview_items = [str(x) for x in err[:3]] + preview = "; ".join(preview_items) + if len(err) > 3: + preview += f" ...(共 {len(err)} 条)" + text = preview + else: + text = str(err) + + lines.append(f"{label}:{text}") + + return "\n".join(lines).strip() + + +def delete_emails_with_fallback( + *, + email_addr: str, + client_id: str, + refresh_token: str, + message_ids: List[str], + proxy_url: str, + delete_emails_graph: Callable[[str, str, List[str], Optional[str]], Dict[str, Any]], + delete_emails_imap: Callable[[str, str, str, List[str], str], Dict[str, Any]], + imap_server_new: str, + imap_server_old: str, +) -> Tuple[Dict[str, Any], Optional[str]]: + """ + 删除邮件(Graph 优先,IMAP 回退)并保持对外错误聚合结构一致。 + + 返回: (response_data, method);method 为 'graph'/'imap_new'/'imap_old' 或 None。 + """ + graph_res = delete_emails_graph(client_id, refresh_token, message_ids, proxy_url) + if graph_res.get("success"): + return graph_res, "graph" + + graph_error = graph_res.get("error") + graph_errors_list = graph_res.get("errors") or [] + is_proxy_error = ( + (isinstance(graph_error, dict) and graph_error.get("type") in ("ProxyError", "ConnectionError")) + or (isinstance(graph_error, str) and "ProxyError" in graph_error) + or any("ProxyError" in str(x) for x in graph_errors_list[:5]) + ) + if is_proxy_error: + return graph_res, None + + method_errors: Dict[str, Any] = { + "graph": graph_error or graph_errors_list or "Graph API 删除失败", + } + + imap_res = delete_emails_imap(email_addr, client_id, refresh_token, message_ids, imap_server_new) + if imap_res.get("success"): + return imap_res, "imap_new" + method_errors["imap_new"] = imap_res.get("error") or imap_res + + imap_old_res = delete_emails_imap(email_addr, client_id, refresh_token, message_ids, imap_server_old) + if imap_old_res.get("success"): + return imap_old_res, "imap_old" + method_errors["imap_old"] = imap_old_res.get("error") or imap_old_res + + summary = summarize_fallback_failures( + method_errors, + { + "graph": "Graph API", + "imap_new": "IMAP(新服务器)", + "imap_old": "IMAP(旧服务器)", + }, + ) + error_payload = build_error_payload( + "EMAIL_DELETE_ALL_METHODS_FAILED", + "删除邮件失败,所有方式均失败", + "FallbackError", + 502, + summary, + ) + + response_data = dict(graph_res) + response_data.setdefault("success_count", 0) + response_data.setdefault("failed_count", len(message_ids)) + response_data.setdefault("errors", []) + response_data.update( + { + "success": False, + "error": error_payload, + "details": method_errors, + } + ) + return response_data, None + diff --git a/outlook_web/services/gptmail.py b/outlook_web/services/gptmail.py new file mode 100644 index 0000000..aaf597b --- /dev/null +++ b/outlook_web/services/gptmail.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from typing import Dict, List, Optional + +import requests + +from outlook_web import config +from outlook_web.repositories.settings import get_gptmail_api_key + + +def gptmail_request( + method: str, + endpoint: str, + params: dict = None, + json_data: dict = None, +) -> Optional[Dict]: + """发送 GPTMail API 请求""" + try: + url = f"{config.get_gptmail_base_url()}{endpoint}" + api_key = get_gptmail_api_key() + headers = { + "X-API-Key": api_key, + "Content-Type": "application/json", + } + + if method.upper() == "GET": + response = requests.get(url, headers=headers, params=params, timeout=30) + elif method.upper() == "POST": + response = requests.post(url, headers=headers, json=json_data, timeout=30) + elif method.upper() == "DELETE": + response = requests.delete(url, headers=headers, params=params, timeout=30) + else: + return None + + if response.status_code == 200: + return response.json() + return {"success": False, "error": f"API 请求失败: {response.status_code}"} + except Exception as e: + return {"success": False, "error": f"请求异常: {str(e)}"} + + +def generate_temp_email(prefix: str = None, domain: str = None) -> Optional[str]: + """生成临时邮箱地址""" + json_data = {} + if prefix: + json_data["prefix"] = prefix + if domain: + json_data["domain"] = domain + + if json_data: + result = gptmail_request("POST", "/api/generate-email", json_data=json_data) + else: + result = gptmail_request("GET", "/api/generate-email") + + if result and result.get("success"): + return result.get("data", {}).get("email") + return None + + +def get_temp_emails_from_api(email_addr: str) -> Optional[List[Dict]]: + """从 GPTMail API 获取邮件列表""" + result = gptmail_request("GET", "/api/emails", params={"email": email_addr}) + if result and result.get("success"): + return result.get("data", {}).get("emails", []) + return None + + +def get_temp_email_detail_from_api(message_id: str) -> Optional[Dict]: + """从 GPTMail API 获取邮件详情""" + result = gptmail_request("GET", f"/api/email/{message_id}") + if result and result.get("success"): + return result.get("data") + return None + + +def delete_temp_email_from_api(message_id: str) -> bool: + """从 GPTMail API 删除邮件""" + result = gptmail_request("DELETE", f"/api/email/{message_id}") + return bool(result and result.get("success", False)) + + +def clear_temp_emails_from_api(email_addr: str) -> bool: + """清空 GPTMail 邮箱的所有邮件""" + result = gptmail_request("DELETE", "/api/emails/clear", params={"email": email_addr}) + return bool(result and result.get("success", False)) + diff --git a/outlook_web/services/graph.py b/outlook_web/services/graph.py new file mode 100644 index 0000000..f8da05a --- /dev/null +++ b/outlook_web/services/graph.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import requests + +from outlook_web.errors import build_error_payload +from outlook_web.services.http import get_response_details + +# Token 端点 +TOKEN_URL_GRAPH = "https://login.microsoftonline.com/common/oauth2/v2.0/token" + + +def build_proxies(proxy_url: str) -> Optional[Dict[str, str]]: + """构建 requests 的 proxies 参数""" + if not proxy_url: + return None + return {"http": proxy_url, "https": proxy_url} + + +def get_access_token_graph_result(client_id: str, refresh_token: str, proxy_url: str = None) -> Dict[str, Any]: + """获取 Graph API access_token(包含错误详情)""" + try: + proxies = build_proxies(proxy_url) + res = requests.post( + TOKEN_URL_GRAPH, + data={ + "client_id": client_id, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "scope": "https://graph.microsoft.com/.default", + }, + timeout=30, + proxies=proxies, + ) + + if res.status_code != 200: + details = get_response_details(res) + return { + "success": False, + "error": build_error_payload( + "GRAPH_TOKEN_FAILED", + "获取访问令牌失败", + "GraphAPIError", + res.status_code, + details, + ), + } + + payload = res.json() + access_token = payload.get("access_token") + if not access_token: + return { + "success": False, + "error": build_error_payload( + "GRAPH_TOKEN_MISSING", + "获取访问令牌失败", + "GraphAPIError", + res.status_code, + payload, + ), + } + + return {"success": True, "access_token": access_token} + except Exception as exc: + return { + "success": False, + "error": build_error_payload( + "GRAPH_TOKEN_EXCEPTION", + "获取访问令牌失败", + type(exc).__name__, + 500, + str(exc), + ), + } + + +def get_access_token_graph(client_id: str, refresh_token: str, proxy_url: str = None) -> Optional[str]: + """获取 Graph API access_token""" + result = get_access_token_graph_result(client_id, refresh_token, proxy_url) + if result.get("success"): + return result.get("access_token") + return None + + +def get_emails_graph( + client_id: str, + refresh_token: str, + folder: str = "inbox", + skip: int = 0, + top: int = 20, + proxy_url: str = None, +) -> Dict[str, Any]: + """使用 Graph API 获取邮件列表(支持分页和文件夹选择)""" + token_result = get_access_token_graph_result(client_id, refresh_token, proxy_url) + if not token_result.get("success"): + return {"success": False, "error": token_result.get("error")} + + access_token = token_result.get("access_token") + + try: + folder_map = { + "inbox": "inbox", + "junkemail": "junkemail", + "deleteditems": "deleteditems", + "trash": "deleteditems", + } + folder_name = folder_map.get((folder or "").lower(), "inbox") + + url = f"https://graph.microsoft.com/v1.0/me/mailFolders/{folder_name}/messages" + params = { + "$top": top, + "$skip": skip, + "$select": "id,subject,from,receivedDateTime,isRead,hasAttachments,bodyPreview", + "$orderby": "receivedDateTime desc", + } + headers = { + "Authorization": f"Bearer {access_token}", + "Prefer": "outlook.body-content-type='text'", + } + + proxies = build_proxies(proxy_url) + res = requests.get(url, headers=headers, params=params, timeout=30, proxies=proxies) + + if res.status_code != 200: + details = get_response_details(res) + return { + "success": False, + "error": build_error_payload( + "EMAIL_FETCH_FAILED", + "获取邮件失败,请检查账号配置", + "GraphAPIError", + res.status_code, + details, + ), + } + + return {"success": True, "emails": res.json().get("value", [])} + except Exception as exc: + return { + "success": False, + "error": build_error_payload( + "EMAIL_FETCH_FAILED", + "获取邮件失败,请检查账号配置", + type(exc).__name__, + 500, + str(exc), + ), + } + + +def get_email_detail_graph( + client_id: str, + refresh_token: str, + message_id: str, + proxy_url: str = None, +) -> Optional[Dict]: + """使用 Graph API 获取邮件详情""" + access_token = get_access_token_graph(client_id, refresh_token, proxy_url) + if not access_token: + return None + + try: + url = f"https://graph.microsoft.com/v1.0/me/messages/{message_id}" + params = {"$select": "id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,body,bodyPreview"} + headers = { + "Authorization": f"Bearer {access_token}", + "Prefer": "outlook.body-content-type='html'", + } + + proxies = build_proxies(proxy_url) + res = requests.get(url, headers=headers, params=params, timeout=30, proxies=proxies) + + if res.status_code != 200: + return None + + return res.json() + except Exception: + return None + + +def test_refresh_token(client_id: str, refresh_token: str, proxy_url: str = None) -> tuple[bool, str | None]: + """测试 refresh token 是否有效,返回 (是否成功, 错误信息)""" + try: + proxies = build_proxies(proxy_url) + res = requests.post( + TOKEN_URL_GRAPH, + data={ + "client_id": client_id, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "scope": "https://graph.microsoft.com/.default", + }, + timeout=30, + proxies=proxies, + ) + + if res.status_code == 200: + return True, None + + error_data = res.json() + error_msg = error_data.get("error_description", error_data.get("error", "未知错误")) + return False, error_msg + except Exception as e: + return False, f"请求异常: {str(e)}" + + +def delete_emails_graph( + client_id: str, + refresh_token: str, + message_ids: List[str], + proxy_url: str = None, +) -> Dict[str, Any]: + """通过 Graph API 批量删除邮件(永久删除)""" + token_result = get_access_token_graph_result(client_id, refresh_token, proxy_url) + if not token_result.get("success"): + return {"success": False, "error": token_result.get("error")} + + access_token = token_result.get("access_token") + if not access_token: + return { + "success": False, + "error": build_error_payload( + "GRAPH_TOKEN_FAILED", + "获取访问令牌失败", + "GraphAPIError", + 500, + "empty_access_token", + ), + } + + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + + # Graph API batch 请求每次最多 20 + batch_size = 20 + success_count = 0 + failed_count = 0 + errors: List[str] = [] + + for i in range(0, len(message_ids), batch_size): + batch = message_ids[i : i + batch_size] + + batch_requests = [] + for idx, msg_id in enumerate(batch): + batch_requests.append({"id": str(idx), "method": "DELETE", "url": f"/me/messages/{msg_id}"}) + + try: + proxies = build_proxies(proxy_url) + response = requests.post( + "https://graph.microsoft.com/v1.0/$batch", + headers=headers, + json={"requests": batch_requests}, + timeout=30, + proxies=proxies, + ) + + if response.status_code == 200: + results = response.json().get("responses", []) + for res in results: + if res.get("status") in [200, 204]: + success_count += 1 + else: + failed_count += 1 + try: + errors.append(f"Msg ID: {batch[int(res['id'])]}, Status: {res.get('status')}") + except Exception: + errors.append(f"Status: {res.get('status')}") + else: + failed_count += len(batch) + errors.append(f"Batch request failed: {response.text}") + except Exception as e: + failed_count += len(batch) + errors.append(f"Network error: {str(e)}") + + result: Dict[str, Any] = { + "success": success_count > 0, + "partial_success": success_count > 0 and failed_count > 0, + "success_count": success_count, + "failed_count": failed_count, + "errors": errors, + } + + if not result["success"]: + result["error"] = build_error_payload( + "EMAIL_DELETE_FAILED", + "删除邮件失败", + "GraphAPIError", + 502, + {"failed_count": failed_count, "errors": errors[:10]}, + ) + + return result diff --git a/outlook_web/services/http.py b/outlook_web/services/http.py new file mode 100644 index 0000000..2852076 --- /dev/null +++ b/outlook_web/services/http.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Any + +import requests + + +def get_response_details(response: requests.Response) -> Any: + try: + return response.json() + except Exception: + return response.text or response.reason + diff --git a/outlook_web/services/imap.py b/outlook_web/services/imap.py new file mode 100644 index 0000000..51c2da8 --- /dev/null +++ b/outlook_web/services/imap.py @@ -0,0 +1,379 @@ +from __future__ import annotations + +import email +import imaplib +from email.header import decode_header +from typing import Any, Dict, List, Optional + +import requests + +from outlook_web.errors import build_error_payload +from outlook_web.services.http import get_response_details +from outlook_web.services.graph import get_access_token_graph + +# Token 端点 +TOKEN_URL_IMAP = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" + +# IMAP 服务器配置 +IMAP_SERVER_NEW = "outlook.live.com" +IMAP_PORT = 993 + + +def decode_header_value(header_value: str) -> str: + """解码邮件头字段""" + if not header_value: + return "" + try: + decoded_parts = decode_header(str(header_value)) + decoded_string = "" + for part, charset in decoded_parts: + if isinstance(part, bytes): + try: + decoded_string += part.decode(charset if charset else "utf-8", "replace") + except (LookupError, UnicodeDecodeError): + decoded_string += part.decode("utf-8", "replace") + else: + decoded_string += str(part) + return decoded_string + except Exception: + return str(header_value) if header_value else "" + + +def get_email_body(msg) -> str: + """提取邮件正文""" + body = "" + if msg.is_multipart(): + for part in msg.walk(): + content_type = part.get_content_type() + content_disposition = str(part.get("Content-Disposition", "")) + + if content_type == "text/plain" and "attachment" not in content_disposition: + try: + payload = part.get_payload(decode=True) + charset = part.get_content_charset() or "utf-8" + body = payload.decode(charset, errors="replace") + break + except Exception: + continue + elif content_type == "text/html" and "attachment" not in content_disposition and not body: + try: + payload = part.get_payload(decode=True) + charset = part.get_content_charset() or "utf-8" + body = payload.decode(charset, errors="replace") + except Exception: + continue + else: + try: + payload = msg.get_payload(decode=True) + charset = msg.get_content_charset() or "utf-8" + body = payload.decode(charset, errors="replace") + except Exception: + body = str(msg.get_payload()) + + return body + + +def get_access_token_imap_result(client_id: str, refresh_token: str) -> Dict[str, Any]: + """获取 IMAP access_token(包含错误详情)""" + try: + res = requests.post( + TOKEN_URL_IMAP, + data={ + "client_id": client_id, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "scope": "https://outlook.office.com/IMAP.AccessAsUser.All offline_access", + }, + timeout=30, + ) + + if res.status_code != 200: + details = get_response_details(res) + return { + "success": False, + "error": build_error_payload( + "IMAP_TOKEN_FAILED", + "获取访问令牌失败", + "IMAPError", + res.status_code, + details, + ), + } + + payload = res.json() + access_token = payload.get("access_token") + if not access_token: + return { + "success": False, + "error": build_error_payload( + "IMAP_TOKEN_MISSING", + "获取访问令牌失败", + "IMAPError", + res.status_code, + payload, + ), + } + + return {"success": True, "access_token": access_token} + except Exception as exc: + return { + "success": False, + "error": build_error_payload( + "IMAP_TOKEN_EXCEPTION", + "获取访问令牌失败", + type(exc).__name__, + 500, + str(exc), + ), + } + + +def get_access_token_imap(client_id: str, refresh_token: str) -> Optional[str]: + """获取 IMAP access_token""" + result = get_access_token_imap_result(client_id, refresh_token) + if result.get("success"): + return result.get("access_token") + return None + + +def get_emails_imap( + account: str, + client_id: str, + refresh_token: str, + folder: str = "inbox", + skip: int = 0, + top: int = 20, +) -> Dict[str, Any]: + """使用 IMAP 获取邮件列表(支持分页和文件夹选择)- 默认使用新版服务器""" + return get_emails_imap_with_server(account, client_id, refresh_token, folder, skip, top, IMAP_SERVER_NEW) + + +def get_emails_imap_with_server( + account: str, + client_id: str, + refresh_token: str, + folder: str = "inbox", + skip: int = 0, + top: int = 20, + server: str = IMAP_SERVER_NEW, +) -> Dict[str, Any]: + """使用 IMAP 获取邮件列表(支持分页、文件夹选择和服务器选择)""" + token_result = get_access_token_imap_result(client_id, refresh_token) + if not token_result.get("success"): + return {"success": False, "error": token_result.get("error")} + + access_token = token_result.get("access_token") + + connection = None + try: + connection = imaplib.IMAP4_SSL(server, IMAP_PORT) + auth_string = f"user={account}\1auth=Bearer {access_token}\1\1".encode("utf-8") + connection.authenticate("XOAUTH2", lambda x: auth_string) + + folder_map = { + "inbox": ['"INBOX"', "INBOX"], + "junkemail": ['"Junk"', '"Junk Email"', "Junk", '"垃圾邮件"'], + "deleteditems": ['"Deleted"', '"Deleted Items"', '"Trash"', "Deleted", '"已删除邮件"'], + "trash": ['"Deleted"', '"Deleted Items"', '"Trash"', "Deleted", '"已删除邮件"'], + } + possible_folders = folder_map.get((folder or "").lower(), ['"INBOX"']) + + selected_folder = None + last_error = None + for imap_folder in possible_folders: + try: + status, response = connection.select(imap_folder, readonly=True) + if status == "OK": + selected_folder = imap_folder + break + last_error = f"select {imap_folder} status={status}" + except Exception as e: + last_error = f"select {imap_folder} error={str(e)}" + continue + + if not selected_folder: + try: + status, folder_list = connection.list() + available_folders = [] + if status == "OK" and folder_list: + for folder_item in folder_list: + if isinstance(folder_item, bytes): + available_folders.append(folder_item.decode("utf-8", errors="ignore")) + else: + available_folders.append(str(folder_item)) + + error_details = { + "last_error": last_error, + "tried_folders": possible_folders, + "available_folders": available_folders[:10], + } + except Exception: + error_details = {"last_error": last_error, "tried_folders": possible_folders} + + return { + "success": False, + "error": build_error_payload( + "EMAIL_FETCH_FAILED", + "无法访问文件夹,请检查账号配置", + "IMAPSelectError", + 500, + error_details, + ), + } + + status, messages = connection.search(None, "ALL") + if status != "OK": + return { + "success": False, + "error": build_error_payload( + "EMAIL_FETCH_FAILED", + "获取邮件失败,请检查账号配置", + "IMAPSearchError", + 500, + f"search status={status}", + ), + } + if not messages or not messages[0]: + return {"success": True, "emails": []} + + message_ids = messages[0].split() + total = len(message_ids) + start_idx = max(0, total - skip - top) + end_idx = total - skip + + if start_idx >= end_idx: + return {"success": True, "emails": []} + + paged_ids = message_ids[start_idx:end_idx][::-1] + + emails_data = [] + for msg_id in paged_ids: + try: + status, msg_data = connection.fetch(msg_id, "(RFC822)") + if status == "OK" and msg_data and msg_data[0]: + raw_email = msg_data[0][1] + msg = email.message_from_bytes(raw_email) + + body_preview = get_email_body(msg) + emails_data.append( + { + "id": msg_id.decode() if isinstance(msg_id, bytes) else str(msg_id), + "subject": decode_header_value(msg.get("Subject", "无主题")), + "from": decode_header_value(msg.get("From", "未知发件人")), + "date": msg.get("Date", "未知时间"), + "body_preview": body_preview[:200] + "..." if len(body_preview) > 200 else body_preview, + } + ) + except Exception: + continue + + return {"success": True, "emails": emails_data} + except Exception as exc: + return { + "success": False, + "error": build_error_payload( + "EMAIL_FETCH_FAILED", + "获取邮件失败,请检查账号配置", + type(exc).__name__, + 500, + str(exc), + ), + } + finally: + if connection: + try: + connection.logout() + except Exception: + pass + + +def get_email_detail_imap( + account: str, + client_id: str, + refresh_token: str, + message_id: str, + folder: str = "inbox", +) -> Optional[Dict]: + """使用 IMAP 获取邮件详情""" + access_token = get_access_token_imap(client_id, refresh_token) + if not access_token: + return None + + connection = None + try: + connection = imaplib.IMAP4_SSL(IMAP_SERVER_NEW, IMAP_PORT) + auth_string = f"user={account}\1auth=Bearer {access_token}\1\1".encode("utf-8") + connection.authenticate("XOAUTH2", lambda x: auth_string) + + folder_map = { + "inbox": ['"INBOX"', "INBOX"], + "junkemail": ['"Junk"', '"Junk Email"', "Junk", '"垃圾邮件"'], + "deleteditems": ['"Deleted"', '"Deleted Items"', '"Trash"', "Deleted", '"已删除邮件"'], + "trash": ['"Deleted"', '"Deleted Items"', '"Trash"', "Deleted", '"已删除邮件"'], + } + possible_folders = folder_map.get((folder or "").lower(), ['"INBOX"']) + + selected_folder = None + for imap_folder in possible_folders: + try: + status, response = connection.select(imap_folder, readonly=True) + if status == "OK": + selected_folder = imap_folder + break + except Exception: + continue + + if not selected_folder: + return None + + fetch_id = message_id.encode() if isinstance(message_id, str) else message_id + status, msg_data = connection.fetch(fetch_id, "(RFC822)") + if status != "OK" or not msg_data or not msg_data[0]: + return None + + raw_email = msg_data[0][1] + msg = email.message_from_bytes(raw_email) + + return { + "id": message_id, + "subject": decode_header_value(msg.get("Subject", "无主题")), + "from": decode_header_value(msg.get("From", "未知发件人")), + "to": decode_header_value(msg.get("To", "")), + "cc": decode_header_value(msg.get("Cc", "")), + "date": msg.get("Date", "未知时间"), + "body": get_email_body(msg), + } + except Exception: + return None + finally: + if connection: + try: + connection.logout() + except Exception: + pass + + +def delete_emails_imap( + email_addr: str, + client_id: str, + refresh_token: str, + message_ids: List[str], + server: str, +) -> Dict[str, Any]: + """通过 IMAP 删除邮件(永久删除)""" + access_token = get_access_token_graph(client_id, refresh_token) + if not access_token: + return {"success": False, "error": "获取 Access Token 失败"} + + try: + auth_string = "user=%s\x01auth=Bearer %s\x01\x01" % (email_addr, access_token) + + imap = imaplib.IMAP4_SSL(server, IMAP_PORT) + imap.authenticate("XOAUTH2", lambda x: auth_string.encode("utf-8")) + + imap.select("INBOX") + + # Graph message id 与 IMAP UID 不兼容:保留原行为(暂不支持) + return {"success": False, "error": "IMAP 删除暂不支持 (ID 格式不兼容)"} + except Exception as e: + return {"success": False, "error": str(e)} diff --git a/outlook_web/services/refresh.py b/outlook_web/services/refresh.py new file mode 100644 index 0000000..38bc24c --- /dev/null +++ b/outlook_web/services/refresh.py @@ -0,0 +1,651 @@ +from __future__ import annotations + +import json +import time +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple + +from outlook_web.db import create_sqlite_connection +from outlook_web.errors import build_error_payload, generate_trace_id +from outlook_web.security.crypto import decrypt_data +from outlook_web.repositories.distributed_locks import acquire_distributed_lock, release_distributed_lock +from outlook_web.repositories.refresh_runs import create_refresh_run, finish_refresh_run + + +REFRESH_LOCK_TTL_SECONDS = 60 * 60 * 2 # 2 小时,避免异常中断导致长时间卡死 + + +def utcnow() -> datetime: + """返回 naive UTC 时间(等价于旧的 datetime.utcnow())""" + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def compute_refresh_lock_ttl_seconds(total: int, delay_seconds: int) -> int: + try: + total = int(total or 0) + except Exception: + total = 0 + try: + delay_seconds = int(delay_seconds or 0) + except Exception: + delay_seconds = 0 + + estimated = int(total * (max(delay_seconds, 0) + 2) + 600) + ttl = max(REFRESH_LOCK_TTL_SECONDS, estimated) + return min(ttl, 60 * 60 * 24) # 最大 24 小时 + + +def stream_refresh_all_accounts( + *, + trace_id: Optional[str], + requested_by_ip: str, + requested_by_user_agent: str, + lock_name: str, + test_refresh_token: Callable[[str, str, Optional[str]], Tuple[bool, Optional[str]]], +) -> Iterator[str]: + """刷新所有账号 token(SSE 流式输出)""" + conn = create_sqlite_connection() + lock_owner_id = uuid.uuid4().hex + lock_acquired = False + run_id = None + + try: + delay_row = conn.execute("SELECT value FROM settings WHERE key = 'refresh_delay_seconds'").fetchone() + delay_seconds = int(delay_row["value"]) if delay_row else 5 + + try: + conn.execute("DELETE FROM account_refresh_logs WHERE created_at < datetime('now', '-6 months')") + conn.execute("DELETE FROM refresh_runs WHERE started_at < datetime('now', '-6 months')") + conn.execute("DELETE FROM distributed_locks WHERE expires_at < ?", (time.time(),)) + conn.commit() + except Exception: + pass + + accounts = conn.execute( + "SELECT id, email, client_id, refresh_token, group_id FROM accounts WHERE status = 'active'" + ).fetchall() + total = len(accounts) + + run_id = create_refresh_run( + conn, + trigger_source="manual_all", + trace_id=trace_id or generate_trace_id(), + requested_by_ip=requested_by_ip, + requested_by_user_agent=requested_by_user_agent, + total=total, + ) + + ttl_seconds = compute_refresh_lock_ttl_seconds(total, delay_seconds) + ok, lock_info = acquire_distributed_lock(conn, lock_name, lock_owner_id, ttl_seconds) + if not ok: + finish_refresh_run(conn, run_id, "skipped", total, 0, 0, "刷新任务冲突:已有刷新在执行") + error_payload = build_error_payload( + code="REFRESH_CONFLICT", + message="当前已有刷新任务执行中,请稍后再试", + err_type="ConflictError", + status=409, + details=lock_info or "", + trace_id=trace_id, + ) + yield f"data: {json.dumps({'type': 'error', 'error': error_payload}, ensure_ascii=False)}\n\n" + return + lock_acquired = True + + success_count = 0 + failed_count = 0 + failed_list: List[Dict[str, Any]] = [] + + yield ( + "data: " + + json.dumps( + { + "type": "start", + "total": total, + "delay_seconds": delay_seconds, + "run_id": run_id, + "trace_id": trace_id, + "refresh_type": "manual_all", + }, + ensure_ascii=False, + ) + + "\n\n" + ) + + for index, account in enumerate(accounts, 1): + account_id = account["id"] + account_email = account["email"] + client_id = account["client_id"] + encrypted_refresh_token = account["refresh_token"] + + try: + refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token + except Exception as e: + failed_count += 1 + error_msg = f"解密 token 失败: {str(e)}" + failed_list.append({"id": account_id, "email": account_email, "error": error_msg}) + try: + conn.execute( + """ + INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message, run_id) + VALUES (?, ?, ?, ?, ?, ?) + """, + (account_id, account_email, "manual_all", "failed", error_msg, run_id), + ) + conn.commit() + except Exception: + pass + continue + + yield ( + "data: " + + json.dumps( + { + "type": "progress", + "current": index, + "total": total, + "email": account_email, + "success_count": success_count, + "failed_count": failed_count, + }, + ensure_ascii=False, + ) + + "\n\n" + ) + + proxy_url = "" + group_id = account["group_id"] + if group_id: + try: + group_row = conn.execute("SELECT proxy_url FROM groups WHERE id = ?", (group_id,)).fetchone() + if group_row: + proxy_url = group_row["proxy_url"] or "" + except Exception: + proxy_url = "" + + success, error_msg = test_refresh_token(client_id, refresh_token, proxy_url) + + try: + conn.execute( + """ + INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message, run_id) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + account_id, + account_email, + "manual_all", + "success" if success else "failed", + error_msg, + run_id, + ), + ) + + if success: + conn.execute( + """ + UPDATE accounts + SET last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (account_id,), + ) + conn.commit() + except Exception: + pass + + if success: + success_count += 1 + else: + failed_count += 1 + failed_list.append({"id": account_id, "email": account_email, "error": error_msg}) + + if index < total and delay_seconds > 0: + yield f"data: {json.dumps({'type': 'delay', 'seconds': delay_seconds}, ensure_ascii=False)}\n\n" + time.sleep(delay_seconds) + + finish_refresh_run( + conn, + run_id, + "completed", + total, + success_count, + failed_count, + f"完成:成功 {success_count},失败 {failed_count}", + ) + + yield ( + "data: " + + json.dumps( + { + "type": "complete", + "total": total, + "success_count": success_count, + "failed_count": failed_count, + "failed_list": failed_list, + "run_id": run_id, + }, + ensure_ascii=False, + ) + + "\n\n" + ) + except Exception as e: + try: + if run_id: + finish_refresh_run(conn, run_id, "failed", 0, 0, 0, str(e)) + except Exception: + pass + error_payload = build_error_payload( + code="REFRESH_FAILED", + message="刷新执行失败", + err_type="RefreshError", + status=500, + details=str(e), + trace_id=trace_id, + ) + yield f"data: {json.dumps({'type': 'error', 'error': error_payload}, ensure_ascii=False)}\n\n" + finally: + if lock_acquired: + release_distributed_lock(conn, lock_name, lock_owner_id) + try: + conn.close() + except Exception: + pass + + +def stream_trigger_scheduled_refresh( + *, + force: bool, + refresh_interval_days: int, + use_cron: bool, + trace_id: Optional[str], + requested_by_ip: str, + requested_by_user_agent: str, + lock_name: str, + test_refresh_token: Callable[[str, str, Optional[str]], Tuple[bool, Optional[str]]], +) -> Iterator[str]: + """手动触发定时刷新(SSE 流式输出)""" + conn = create_sqlite_connection() + lock_owner_id = uuid.uuid4().hex + lock_acquired = False + run_id = None + total = 0 + success_count = 0 + failed_count = 0 + + try: + delay_row = conn.execute("SELECT value FROM settings WHERE key = 'refresh_delay_seconds'").fetchone() + delay_seconds = int(delay_row["value"]) if delay_row else 5 + + try: + conn.execute("DELETE FROM account_refresh_logs WHERE created_at < datetime('now', '-6 months')") + conn.commit() + except Exception: + pass + + accounts = conn.execute( + "SELECT id, email, client_id, refresh_token, group_id FROM accounts WHERE status = 'active'" + ).fetchall() + + total = len(accounts) + run_id = create_refresh_run( + conn, + trigger_source="scheduled_manual", + trace_id=trace_id or generate_trace_id(), + requested_by_ip=requested_by_ip, + requested_by_user_agent=requested_by_user_agent, + total=total, + ) + + if (not force) and (not use_cron): + row = conn.execute( + """ + SELECT finished_at + FROM refresh_runs + WHERE trigger_source IN ('scheduled', 'scheduled_manual') + AND status IN ('completed', 'failed') + AND finished_at IS NOT NULL + ORDER BY finished_at DESC + LIMIT 1 + """ + ).fetchone() + + if row and row["finished_at"]: + try: + last_time = datetime.fromisoformat(row["finished_at"]) + except Exception: + last_time = None + + if last_time: + next_due = last_time + timedelta(days=refresh_interval_days) + if utcnow() < next_due: + finish_refresh_run( + conn, + run_id, + "skipped", + 0, + 0, + 0, + f"距离上次刷新未满 {refresh_interval_days} 天,下次最早:{next_due.strftime('%Y-%m-%d %H:%M:%S')}", + ) + yield ( + "data: " + + json.dumps( + { + "type": "skipped", + "message": "未到刷新周期", + "next_due": next_due.isoformat(), + "run_id": run_id, + }, + ensure_ascii=False, + ) + + "\n\n" + ) + return + + ttl_seconds = compute_refresh_lock_ttl_seconds(total, delay_seconds) + ok, lock_info = acquire_distributed_lock(conn, lock_name, lock_owner_id, ttl_seconds) + if not ok: + finish_refresh_run(conn, run_id, "skipped", total, 0, 0, "刷新任务冲突:已有刷新在执行") + error_payload = build_error_payload( + code="REFRESH_CONFLICT", + message="当前已有刷新任务执行中,请稍后再试", + err_type="ConflictError", + status=409, + details=lock_info or "", + trace_id=trace_id, + ) + yield f"data: {json.dumps({'type': 'error', 'error': error_payload}, ensure_ascii=False)}\n\n" + return + lock_acquired = True + + success_count = 0 + failed_count = 0 + failed_list: List[Dict[str, Any]] = [] + + yield ( + "data: " + + json.dumps( + { + "type": "start", + "total": total, + "delay_seconds": delay_seconds, + "refresh_type": "scheduled", + "run_id": run_id, + "trace_id": trace_id, + }, + ensure_ascii=False, + ) + + "\n\n" + ) + + for index, account in enumerate(accounts, 1): + account_id = account["id"] + account_email = account["email"] + client_id = account["client_id"] + encrypted_refresh_token = account["refresh_token"] + + try: + refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token + except Exception as e: + failed_count += 1 + error_msg = f"解密 token 失败: {str(e)}" + failed_list.append({"id": account_id, "email": account_email, "error": error_msg}) + try: + conn.execute( + """ + INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message, run_id) + VALUES (?, ?, ?, ?, ?, ?) + """, + (account_id, account_email, "scheduled", "failed", error_msg, run_id), + ) + conn.commit() + except Exception: + pass + continue + + yield ( + "data: " + + json.dumps( + { + "type": "progress", + "current": index, + "total": total, + "email": account_email, + "success_count": success_count, + "failed_count": failed_count, + }, + ensure_ascii=False, + ) + + "\n\n" + ) + + proxy_url = "" + group_id = account["group_id"] + if group_id: + group_row = conn.execute("SELECT proxy_url FROM groups WHERE id = ?", (group_id,)).fetchone() + if group_row: + proxy_url = group_row["proxy_url"] or "" + + success, error_msg = test_refresh_token(client_id, refresh_token, proxy_url) + + try: + conn.execute( + """ + INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message, run_id) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + account_id, + account_email, + "scheduled", + "success" if success else "failed", + error_msg, + run_id, + ), + ) + + if success: + conn.execute( + """ + UPDATE accounts + SET last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (account_id,), + ) + + conn.commit() + except Exception: + pass + + if success: + success_count += 1 + else: + failed_count += 1 + failed_list.append({"id": account_id, "email": account_email, "error": error_msg}) + + if index < total and delay_seconds > 0: + yield f"data: {json.dumps({'type': 'delay', 'seconds': delay_seconds}, ensure_ascii=False)}\n\n" + time.sleep(delay_seconds) + + finish_refresh_run( + conn, + run_id, + "completed", + total, + success_count, + failed_count, + f"完成:成功 {success_count},失败 {failed_count}", + ) + + yield ( + "data: " + + json.dumps( + { + "type": "complete", + "total": total, + "success_count": success_count, + "failed_count": failed_count, + "failed_list": failed_list, + "run_id": run_id, + }, + ensure_ascii=False, + ) + + "\n\n" + ) + except Exception as e: + try: + if run_id: + finish_refresh_run(conn, run_id, "failed", total, success_count, failed_count, str(e)) + except Exception: + pass + error_payload = build_error_payload( + code="REFRESH_FAILED", + message="刷新执行失败", + err_type="RefreshError", + status=500, + details=str(e), + trace_id=trace_id, + ) + yield f"data: {json.dumps({'type': 'error', 'error': error_payload}, ensure_ascii=False)}\n\n" + finally: + if lock_acquired: + release_distributed_lock(conn, lock_name, lock_owner_id) + try: + conn.close() + except Exception: + pass + + +def refresh_failed_accounts( + *, + db, + trace_id: Optional[str], + requested_by_ip: str, + requested_by_user_agent: str, + lock_name: str, + test_refresh_token: Callable[[str, str, Optional[str]], Tuple[bool, Optional[str]]], +) -> Tuple[Dict[str, Any], int]: + """重试所有失败的账号(非流式)""" + lock_owner_id = uuid.uuid4().hex + + cursor = db.execute( + """ + SELECT DISTINCT a.id, a.email, a.client_id, a.refresh_token, a.group_id + FROM accounts a + INNER JOIN ( + SELECT account_id, MAX(created_at) as last_refresh + FROM account_refresh_logs + GROUP BY account_id + ) latest ON a.id = latest.account_id + INNER JOIN account_refresh_logs l ON a.id = l.account_id AND l.created_at = latest.last_refresh + WHERE l.status = 'failed' AND a.status = 'active' + """ + ) + accounts = cursor.fetchall() + + total = len(accounts) + run_id = create_refresh_run( + db, + trigger_source="retry_failed", + trace_id=trace_id or generate_trace_id(), + requested_by_ip=requested_by_ip, + requested_by_user_agent=requested_by_user_agent, + total=total, + ) + + ttl_seconds = compute_refresh_lock_ttl_seconds(total, 0) + ok, lock_info = acquire_distributed_lock(db, lock_name, lock_owner_id, ttl_seconds) + if not ok: + finish_refresh_run(db, run_id, "skipped", total, 0, 0, "刷新任务冲突:已有刷新在执行") + error_payload = build_error_payload( + code="REFRESH_CONFLICT", + message="当前已有刷新任务执行中,请稍后再试", + err_type="ConflictError", + status=409, + details=lock_info or "", + trace_id=trace_id, + ) + return {"success": False, "error": error_payload}, 409 + + success_count = 0 + failed_count = 0 + failed_list: List[Dict[str, Any]] = [] + + try: + for account in accounts: + account_id = account["id"] + account_email = account["email"] + client_id = account["client_id"] + encrypted_refresh_token = account["refresh_token"] + + proxy_url = "" + group_id = account["group_id"] + if group_id: + try: + group_row = db.execute("SELECT proxy_url FROM groups WHERE id = ?", (group_id,)).fetchone() + if group_row: + proxy_url = group_row["proxy_url"] or "" + except Exception: + proxy_url = "" + + try: + refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token + except Exception as e: + failed_count += 1 + error_msg = f"解密 token 失败: {str(e)}" + failed_list.append({"id": account_id, "email": account_email, "error": error_msg}) + try: + from outlook_web.repositories.refresh_logs import log_refresh_result + + log_refresh_result(account_id, account_email, "retry", "failed", error_msg, run_id=run_id) + except Exception: + pass + continue + + success, error_msg = test_refresh_token(client_id, refresh_token, proxy_url) + try: + from outlook_web.repositories.refresh_logs import log_refresh_result + + log_refresh_result( + account_id, + account_email, + "retry", + "success" if success else "failed", + error_msg, + run_id=run_id, + ) + except Exception: + pass + + if success: + success_count += 1 + else: + failed_count += 1 + failed_list.append({"id": account_id, "email": account_email, "error": error_msg}) + finally: + release_distributed_lock(db, lock_name, lock_owner_id) + + finish_refresh_run( + db, + run_id, + "completed", + total, + success_count, + failed_count, + f"完成:成功 {success_count},失败 {failed_count}", + ) + + return ( + { + "success": True, + "run_id": run_id, + "total": total, + "success_count": success_count, + "failed_count": failed_count, + "failed_list": failed_list, + }, + 200, + ) + diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 0000000..0f597a6 --- /dev/null +++ b/static/css/main.css @@ -0,0 +1,1292 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: #ffffff; + color: #1a1a1a; + line-height: 1.5; + } + + /* 顶部导航栏 */ + .navbar { + height: 56px; + background-color: #ffffff; + border-bottom: 1px solid #e5e5e5; + display: flex; + align-items: center; + padding: 0 24px; + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + } + + .navbar-brand { + font-size: 18px; + font-weight: 600; + color: #1a1a1a; + display: flex; + align-items: center; + gap: 8px; + } + + .navbar-brand svg { + width: 24px; + height: 24px; + } + + .navbar-center { + flex: 1; + display: flex; + justify-content: center; + } + + .current-account { + font-size: 14px; + color: #666; + background-color: #f5f5f5; + padding: 6px 16px; + border-radius: 20px; + display: none; + cursor: pointer; + transition: all 0.2s; + } + + .current-account:hover { + background-color: #e8e8e8; + } + + .current-account.show { + display: block; + } + + .current-account strong { + color: #1a1a1a; + font-weight: 500; + } + + .navbar-actions { + display: flex; + gap: 12px; + } + + .navbar-btn { + background: none; + border: 1px solid #e5e5e5; + color: #666; + cursor: pointer; + font-size: 13px; + padding: 6px 12px; + border-radius: 6px; + transition: all 0.15s; + } + + .navbar-btn:hover { + background-color: #f5f5f5; + border-color: #ccc; + } + + /* GitHub Star 按钮 */ + .github-star-btn { + display: inline-flex; + align-items: center; + gap: 6px; + background: linear-gradient(180deg, #fafbfc 0%, #eff3f6 90%); + border: 1px solid rgba(27, 31, 35, 0.15); + color: #24292e; + cursor: pointer; + font-size: 13px; + font-weight: 500; + padding: 5px 12px; + border-radius: 6px; + transition: all 0.15s; + text-decoration: none; + box-shadow: 0 1px 0 rgba(27, 31, 35, 0.04); + } + + .github-star-btn:hover { + background: linear-gradient(180deg, #f3f4f6 0%, #e9ecef 90%); + border-color: rgba(27, 31, 35, 0.25); + box-shadow: 0 1px 0 rgba(27, 31, 35, 0.1); + } + + .github-star-btn svg { + width: 16px; + height: 16px; + } + + /* 主容器 */ + .main-container { + display: flex; + margin-top: 56px; + height: calc(100vh - 56px); + } + + /* 分组面板 */ + .group-panel { + width: 200px; + background-color: #fafafa; + border-right: 1px solid #e5e5e5; + display: flex; + flex-direction: column; + flex-shrink: 0; + } + + .panel-header { + padding: 0 16px; + height: 52px; + border-bottom: 1px solid #e5e5e5; + display: flex; + justify-content: space-between; + align-items: center; + } + + .panel-title { + font-size: 14px; + font-weight: 600; + color: #1a1a1a; + } + + .panel-action-btn { + background: none; + border: none; + color: #666; + cursor: pointer; + font-size: 18px; + padding: 2px 6px; + border-radius: 4px; + line-height: 1; + } + + .panel-action-btn:hover { + background-color: #e5e5e5; + } + + .group-list { + flex: 1; + overflow-y: auto; + } + + .group-item { + padding: 10px 16px; + cursor: pointer; + transition: background-color 0.15s; + border-left: 3px solid transparent; + } + + .group-item:hover { + background-color: #f0f0f0; + } + + .group-item.active { + background-color: #e8e8e8; + border-left-color: #1a1a1a; + } + + .group-row-1 { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; + } + + .group-color { + width: 10px; + height: 10px; + border-radius: 3px; + flex-shrink: 0; + } + + .group-name { + font-size: 14px; + font-weight: 500; + color: #1a1a1a; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + } + + .group-row-2 { + display: flex; + align-items: center; + justify-content: space-between; + padding-left: 20px; + } + + .group-count { + font-size: 12px; + color: #999; + background-color: #f0f0f0; + padding: 2px 8px; + border-radius: 10px; + } + + .group-item.active .group-count { + background-color: #d8d8d8; + } + + .group-actions { + display: flex; + gap: 4px; + opacity: 0; + transition: opacity 0.15s; + } + + .group-item:hover .group-actions { + opacity: 1; + } + + .group-action-btn { + background: none; + border: none; + color: #999; + cursor: pointer; + padding: 2px 6px; + font-size: 12px; + border-radius: 4px; + } + + .group-action-btn:hover { + background-color: #e0e0e0; + color: #666; + } + + /* 邮箱账号面板 */ + .account-panel { + width: 260px; + background-color: #ffffff; + border-right: 1px solid #e5e5e5; + display: flex; + flex-direction: column; + flex-shrink: 0; + } + + .account-panel-header { + padding: 0 16px; + height: 52px; + border-bottom: 1px solid #e5e5e5; + display: flex; + align-items: center; + } + + .account-panel-title { + font-size: 14px; + font-weight: 600; + color: #1a1a1a; + display: flex; + align-items: center; + gap: 8px; + } + + .account-panel-title .group-color { + width: 12px; + height: 12px; + } + + .search-container { + padding: 8px 16px; + border-bottom: 1px solid #e5e5e5; + } + + .search-input { + width: 100%; + padding: 8px 12px; + border: 1px solid #e5e5e5; + border-radius: 6px; + font-size: 13px; + outline: none; + transition: border-color 0.2s; + } + + .search-input:focus { + border-color: #1a1a1a; + } + + .account-number { + color: #666; + font-weight: 500; + margin-right: 4px; + } + + .sort-btn:hover { + background-color: #f5f5f5 !important; + border-color: #ccc !important; + } + + .sort-btn.active { + background-color: #1a1a1a !important; + color: #ffffff !important; + border-color: #1a1a1a !important; + } + + .account-list { + flex: 1; + overflow-y: auto; + } + + .account-panel-footer { + padding: 16px; + border-top: 1px solid #e5e5e5; + } + + .add-account-btn { + width: 100%; + padding: 10px 16px; + background-color: #1a1a1a; + color: #ffffff; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; + } + + .add-account-btn:hover { + background-color: #333; + } + + .account-item { + padding: 10px 16px; + cursor: pointer; + transition: background-color 0.15s; + border-left: 3px solid transparent; + border-bottom: 1px solid #f5f5f5; + } + + .account-item:hover { + background-color: #f5f5f5; + } + + .account-item.active { + background-color: #f0f0f0; + border-left-color: #1a1a1a; + } + + .account-item.inactive { + opacity: 0.6; + } + + .account-item.inactive .account-email { + text-decoration: line-through; + color: #999; + } + + .account-remark { + font-size: 12px; + color: #666; + padding-left: 0; + margin-top: 4px; + white-space: pre-wrap; + word-break: break-all; + line-height: 1.4; + } + + .account-email { + font-size: 13px; + color: #1a1a1a; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-bottom: 6px; + } + + .account-status-tag { + font-size: 10px; + padding: 1px 6px; + border-radius: 3px; + margin-left: 8px; + background-color: #f0f0f0; + color: #999; + } + + .tag-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + margin-right: 4px; + margin-top: 2px; + color: white; + line-height: 1.4; + } + + .account-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; + } + + .account-actions { + display: flex; + gap: 8px; + opacity: 0; + transition: opacity 0.15s; + } + + .account-item:hover .account-actions { + opacity: 1; + } + + .account-action-btn { + background: none; + border: none; + color: #666; + cursor: pointer; + padding: 2px 0; + font-size: 11px; + border-radius: 4px; + display: flex; + align-items: center; + gap: 2px; + } + + .account-action-btn:hover { + color: #1a1a1a; + } + + + .account-action-btn.delete:hover { + color: #dc3545; + } + + .batch-btn.danger { + background-color: #dc3545; + color: white; + border: none; + cursor: pointer; + padding: 6px 12px; + border-radius: 4px; + font-size: 13px; + transition: background-color 0.15s; + } + + .batch-btn.danger:hover { + background-color: #c82333; + } + + /* 内容区域 */ + .content-area { + flex: 1; + display: flex; + overflow: hidden; + } + + /* 邮件列表面板 */ + .email-list-panel { + width: 380px; + background-color: #ffffff; + border-right: 1px solid #e5e5e5; + display: flex; + flex-direction: column; + flex-shrink: 0; + } + + .email-list-panel.hidden { + display: none; + } + + /* 邮件列表头部 */ + .email-list-header { + padding: 16px 20px; + border-bottom: 1px solid #e5e5e5; + background-color: #ffffff; + } + + .email-list-header h2 { + font-size: 16px; + font-weight: 600; + color: #1a1a1a; + } + + .email-count { + font-size: 13px; + color: #999; + font-weight: 400; + margin-left: 8px; + } + + .refresh-btn { + background: none; + border: 1px solid #e5e5e5; + color: #666; + cursor: pointer; + font-size: 13px; + padding: 6px 12px; + border-radius: 6px; + transition: all 0.15s; + } + + .refresh-btn:hover { + background-color: #f5f5f5; + border-color: #ccc; + } + + .email-list { + flex: 1; + overflow-y: auto; + } + + .email-item { + padding: 16px 20px; + border-bottom: 1px solid #f0f0f0; + cursor: pointer; + transition: background-color 0.15s; + display: flex; + align-items: flex-start; + } + + .email-checkbox-wrapper { + padding-right: 12px; + padding-top: 2px; + display: flex; + align-items: center; + } + + .email-checkbox { + width: 16px; + height: 16px; + cursor: pointer; + } + + .email-item:hover { + background-color: #fafafa; + } + + .email-item.active { + background-color: #f5f5f5; + } + + .email-item.unread .email-subject { + font-weight: 600; + } + + .email-from { + font-size: 14px; + font-weight: 500; + color: #1a1a1a; + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .email-subject { + font-size: 14px; + color: #1a1a1a; + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .email-preview { + font-size: 13px; + color: #999; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .email-date { + font-size: 12px; + color: #999; + margin-top: 8px; + } + + /* 邮件详情面板 */ + .email-detail-panel { + flex: 1; + background-color: #ffffff; + display: flex; + flex-direction: column; + min-width: 0; + } + + .email-detail-toolbar { + padding: 12px 24px; + border-bottom: 1px solid #e5e5e5; + display: flex; + align-items: center; + gap: 12px; + background-color: #fafafa; + } + + .back-btn { + display: none; + background: none; + border: 1px solid #e5e5e5; + color: #666; + cursor: pointer; + font-size: 13px; + padding: 6px 12px; + border-radius: 6px; + transition: all 0.15s; + } + + .back-btn:hover { + background-color: #f0f0f0; + } + + .toggle-list-btn { + background: none; + border: 1px solid #e5e5e5; + color: #666; + cursor: pointer; + font-size: 13px; + padding: 6px 12px; + border-radius: 6px; + transition: all 0.15s; + } + + .toggle-list-btn:hover { + background-color: #f0f0f0; + } + + .email-detail-header { + padding: 24px 32px; + border-bottom: 1px solid #e5e5e5; + } + + .email-detail-subject { + font-size: 24px; + font-weight: 600; + color: #1a1a1a; + margin-bottom: 16px; + line-height: 1.3; + } + + .email-detail-meta { + font-size: 14px; + color: #666; + line-height: 1.8; + } + + .email-detail-meta-row { + display: flex; + margin-bottom: 4px; + } + + .email-detail-meta-label { + color: #999; + width: 60px; + flex-shrink: 0; + } + + .email-detail-meta-value { + color: #1a1a1a; + } + + .email-detail-body { + flex: 1; + padding: 24px 32px; + overflow-y: auto; + background-color: #ffffff; + min-height: 0; + } + + .email-detail-body iframe { + width: 100%; + min-height: 600px; + border: none; + background-color: #ffffff; + display: block; + overflow: visible; + } + + .email-body-text { + white-space: pre-wrap; + font-size: 15px; + line-height: 1.7; + color: #333; + } + + /* 全屏查看邮件 */ + .fullscreen-email-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.95); + z-index: 2000; + overflow: hidden; + } + + .fullscreen-email-modal.show { + display: flex; + flex-direction: column; + } + + .fullscreen-email-header { + background-color: #1a1a1a; + color: #ffffff; + padding: 16px 24px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #333; + flex-shrink: 0; + } + + .fullscreen-email-title { + font-size: 16px; + font-weight: 500; + } + + .fullscreen-close-btn { + background: none; + border: none; + color: #ffffff; + font-size: 24px; + cursor: pointer; + padding: 4px 12px; + border-radius: 4px; + transition: background-color 0.2s; + } + + .fullscreen-close-btn:hover { + background-color: rgba(255, 255, 255, 0.1); + } + + .fullscreen-email-content { + flex: 1; + overflow-y: auto; + background-color: #ffffff; + padding: 32px; + } + + .fullscreen-email-content iframe { + width: 100%; + border: none; + background-color: #ffffff; + display: block; + } + + .fullscreen-email-content .email-body-text { + max-width: 900px; + margin: 0 auto; + } + + .fullscreen-btn { + background: none; + border: 1px solid #e5e5e5; + color: #666; + cursor: pointer; + font-size: 13px; + padding: 6px 12px; + border-radius: 6px; + transition: all 0.15s; + margin-left: auto; + } + + .fullscreen-btn:hover { + background-color: #f0f0f0; + } + + /* 空状态 */ + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: #999; + padding: 40px; + } + + .empty-state-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; + } + + .empty-state-text { + font-size: 15px; + text-align: center; + } + + /* 加载状态 */ + .loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + padding: 40px; + } + + .loading-spinner { + width: 32px; + height: 32px; + border: 2px solid #e5e5e5; + border-top-color: #1a1a1a; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + .loading-small { + padding: 20px; + } + + .loading-small .loading-spinner { + width: 20px; + height: 20px; + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + + /* 模态框 */ + .modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.4); + z-index: 1000; + align-items: center; + justify-content: center; + } + + .modal.show { + display: flex; + } + + .modal-content { + background-color: #ffffff; + border-radius: 12px; + width: 520px; + max-width: 90%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); + position: relative; + z-index: 1001; + } + + #refreshErrorModal .modal-content { + width: 640px; + } + + .error-card { + background-color: #fafafa; + border: 1px solid #e5e5e5; + border-radius: 8px; + padding: 16px; + margin-bottom: 20px; + } + + .error-card-title { + font-size: 14px; + font-weight: 600; + color: #dc3545; + margin-bottom: 12px; + } + + .error-message-box { + background-color: #ffffff; + border: 1px solid #e5e5e5; + padding: 12px; + border-radius: 6px; + font-family: 'SF Mono', Monaco, 'Courier New', monospace; + font-size: 13px; + color: #333; + max-height: 150px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; + } + + .solution-list { + list-style-type: none; + padding-left: 0; + margin-top: 16px; + } + + .solution-list li { + font-size: 14px; + color: #333; + margin-bottom: 10px; + display: flex; + align-items: flex-start; + } + + .solution-list li::before { + content: '✓'; + color: #28a745; + font-weight: 600; + margin-right: 10px; + margin-top: 2px; + } + + + .modal-header { + padding: 20px 24px; + border-bottom: 1px solid #e5e5e5; + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + background-color: #ffffff; + } + + .modal-header h3 { + font-size: 18px; + font-weight: 600; + color: #1a1a1a; + } + + .modal-close { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #999; + padding: 4px; + line-height: 1; + border-radius: 4px; + transition: all 0.15s; + } + + .modal-close:hover { + background-color: #f0f0f0; + color: #666; + } + + .modal-body { + padding: 24px; + } + + .form-group { + margin-bottom: 20px; + } + + .form-label { + display: block; + font-size: 14px; + font-weight: 500; + color: #333; + margin-bottom: 8px; + } + + .form-input { + width: 100%; + padding: 10px 14px; + border: 1px solid #e5e5e5; + border-radius: 6px; + font-size: 14px; + transition: border-color 0.15s; + } + + .form-input:focus { + outline: none; + border-color: #1a1a1a; + } + + .form-textarea { + width: 100%; + height: 120px; + padding: 12px 14px; + border: 1px solid #e5e5e5; + border-radius: 6px; + font-size: 13px; + font-family: 'SF Mono', Monaco, 'Courier New', monospace; + resize: vertical; + transition: border-color 0.15s; + } + + .form-textarea:focus { + outline: none; + border-color: #1a1a1a; + } + + .form-select { + width: 100%; + padding: 10px 14px; + border: 1px solid #e5e5e5; + border-radius: 6px; + font-size: 14px; + background-color: #ffffff; + cursor: pointer; + } + + .form-select:focus { + outline: none; + border-color: #1a1a1a; + } + + .form-hint { + font-size: 12px; + color: #999; + margin-top: 6px; + } + + .color-picker-group { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 12px; + } + + .color-option { + width: 32px; + height: 32px; + border-radius: 6px; + cursor: pointer; + border: 2px solid transparent; + transition: all 0.15s; + } + + .color-option:hover { + transform: scale(1.1); + } + + .color-option.selected { + border-color: #1a1a1a; + } + + .custom-color-row { + display: flex; + align-items: center; + gap: 12px; + margin-top: 8px; + } + + .custom-color-input { + width: 60px; + height: 36px; + padding: 2px; + border: 1px solid #e5e5e5; + border-radius: 6px; + cursor: pointer; + } + + .custom-color-label { + font-size: 13px; + color: #666; + } + + .custom-color-hex { + width: 80px; + padding: 8px 10px; + border: 1px solid #e5e5e5; + border-radius: 6px; + font-size: 13px; + font-family: monospace; + } + + .modal-footer { + padding: 16px 24px; + border-top: 1px solid #e5e5e5; + display: flex; + justify-content: flex-end; + gap: 12px; + position: sticky; + bottom: 0; + background-color: #ffffff; + } + + .btn { + padding: 10px 20px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.15s; + } + + .btn-primary { + background-color: #1a1a1a; + color: #ffffff; + } + + .btn-primary:hover { + background-color: #333; + } + + .btn-secondary { + background-color: #f5f5f5; + color: #1a1a1a; + border: 1px solid #e5e5e5; + } + + .btn-secondary:hover { + background-color: #e5e5e5; + } + + .btn-danger { + background-color: #dc3545; + color: #ffffff; + } + + .btn-danger:hover { + background-color: #c82333; + } + + .btn-warning { + background-color: #ffc107; + color: #1a1a1a; + } + + .btn-warning:hover { + background-color: #e0a800; + } + + .btn-sm { + padding: 4px 12px; + font-size: 12px; + } + + /* 消息提示 */ + .toast { + position: fixed; + top: 80px; + left: 50%; + transform: translateX(-50%) translateY(-20px); + padding: 14px 24px; + background-color: #1a1a1a; + color: #ffffff; + border-radius: 8px; + font-size: 14px; + z-index: 2000; + opacity: 0; + transition: all 0.3s; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + min-width: 300px; + text-align: center; + pointer-events: none; + } + + .toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); + pointer-events: auto; + } + + .toast.success { + background-color: #1a1a1a; + } + + .toast.error { + background-color: #dc3545; + } + + /* 方法标签 */ + .method-tag { + font-size: 11px; + padding: 3px 8px; + background-color: #f5f5f5; + color: #666; + border-radius: 4px; + margin-left: 8px; + font-weight: 500; + } + + /* 文件夹切换标签 */ + .folder-tabs { + display: inline-flex; + gap: 8px; + background-color: #f5f5f5; + padding: 4px; + border-radius: 8px; + } + + .folder-tab { + padding: 8px 16px; + border: none; + background: transparent; + color: #666; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: all 0.2s; + font-weight: 500; + white-space: nowrap; + } + + .folder-tab:hover { + background-color: #e5e5e5; + color: #333; + } + + .folder-tab.active { + background-color: #1a1a1a; + color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + /* 响应式 */ + @media (max-width: 1200px) { + .group-panel { + width: 180px; + } + + .account-panel { + width: 220px; + } + + .email-list-panel { + width: 320px; + } + } + + @media (max-width: 1024px) { + .group-panel { + width: 160px; + } + + .account-panel { + width: 200px; + } + + .email-list-panel { + width: 280px; + } + } + + @media (max-width: 768px) { + + .group-panel, + .account-panel { + position: fixed; + left: -280px; + top: 56px; + height: calc(100vh - 56px); + z-index: 50; + transition: left 0.3s; + } + + .group-panel.show, + .account-panel.show { + left: 0; + } + + .email-list-panel { + width: 100%; + } + + .email-list-panel.hidden { + display: none; + } + + .back-btn { + display: block; + } + } diff --git a/static/health.txt b/static/health.txt new file mode 100644 index 0000000..4f25baf --- /dev/null +++ b/static/health.txt @@ -0,0 +1,2 @@ +ok + diff --git a/static/js/features/accounts.js b/static/js/features/accounts.js new file mode 100644 index 0000000..4428ba2 --- /dev/null +++ b/static/js/features/accounts.js @@ -0,0 +1,490 @@ + // ==================== 账号相关 ==================== + + // 选择账号 + function selectAccount(email) { + currentAccount = email; + isTempEmailGroup = false; + currentFolder = 'inbox'; // 重置为收件箱 + + document.getElementById('currentAccount').classList.add('show'); + document.getElementById('currentAccountEmail').textContent = email; + + document.querySelectorAll('.account-item').forEach(item => { + item.classList.remove('active'); + const emailEl = item.querySelector('.account-email'); + if (emailEl && emailEl.textContent.includes(email)) { + item.classList.add('active'); + } + }); + + // 显示文件夹切换按钮 + const folderTabs = document.getElementById('folderTabs'); + if (folderTabs) { + folderTabs.style.display = 'flex'; + // 重置为收件箱 + document.querySelectorAll('.folder-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.folder === 'inbox'); + }); + } + + const cacheKey = `${email}_inbox`; + + // 检查缓存 + if (emailListCache[cacheKey]) { + const cache = emailListCache[cacheKey]; + currentEmails = cache.emails; + hasMoreEmails = cache.has_more; + currentSkip = cache.skip; + currentMethod = cache.method || 'graph'; + + // 恢复 UI + const methodTag = document.getElementById('methodTag'); + methodTag.textContent = currentMethod; + methodTag.style.display = 'inline'; + document.getElementById('emailCount').textContent = `(${currentEmails.length})`; + + renderEmailList(currentEmails); + } else { + document.getElementById('emailList').innerHTML = ` +
+
📬
+
点击"获取邮件"按钮获取邮件
+
+ `; + document.getElementById('emailCount').textContent = ''; + document.getElementById('methodTag').style.display = 'none'; + currentEmails = []; + } + + document.getElementById('emailDetail').innerHTML = ` +
+
📄
+
选择一封邮件查看详情
+
+ `; + document.getElementById('emailDetailToolbar').style.display = 'none'; + } + + // 显示添加账号模态框 + function showAddAccountModal() { + document.getElementById('accountInput').value = ''; + // 设置默认分组为当前选中的分组 + if (currentGroupId) { + document.getElementById('importGroupSelect').value = currentGroupId; + } + document.getElementById('addAccountModal').classList.add('show'); + } + + // 隐藏添加账号模态框 + function hideAddAccountModal() { + document.getElementById('addAccountModal').classList.remove('show'); + } + + // 添加账号 + async function addAccount() { + const input = document.getElementById('accountInput').value.trim(); + const groupId = parseInt(document.getElementById('importGroupSelect').value); + + if (!input) { + showToast('请输入账号信息', 'error'); + return; + } + + try { + const response = await fetch('/api/accounts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ account_string: input, group_id: groupId }) + }); + + const data = await response.json(); + + if (data.success) { + showToast(data.message, 'success'); + hideAddAccountModal(); + + // 清除该分组的缓存 + delete accountsCache[groupId]; + + // 刷新分组列表(更新数量) + loadGroups(); + + // 如果当前选中的就是这个分组,刷新邮箱列表 + if (currentGroupId === groupId) { + loadAccountsByGroup(groupId, true); + } + } else { + handleApiError(data, '导入邮箱失败'); + } + } catch (error) { + showToast('添加失败', 'error'); + } + } + + // 显示编辑账号模态框 + async function showEditAccountModal(accountId) { + try { + const response = await fetch(`/api/accounts/${accountId}`); + const data = await response.json(); + + if (data.success) { + const acc = data.account; + document.getElementById('editAccountId').value = acc.id; + document.getElementById('editEmail').value = acc.email; + document.getElementById('editPassword').value = acc.password || ''; + document.getElementById('editClientId').value = acc.client_id; + document.getElementById('editRefreshToken').value = acc.refresh_token; + document.getElementById('editGroupSelect').value = acc.group_id || 1; + document.getElementById('editRemark').value = acc.remark || ''; + document.getElementById('editStatus').value = acc.status || 'active'; + document.getElementById('editAccountModal').classList.add('show'); + } + } catch (error) { + showToast('加载账号信息失败', 'error'); + } + } + + // 隐藏编辑账号模态框 + function hideEditAccountModal() { + document.getElementById('editAccountModal').classList.remove('show'); + } + + // 更新账号 + async function updateAccount() { + const accountId = document.getElementById('editAccountId').value; + const oldGroupId = currentGroupId; + const newGroupId = parseInt(document.getElementById('editGroupSelect').value); + + const data = { + email: document.getElementById('editEmail').value.trim(), + password: document.getElementById('editPassword').value, + client_id: document.getElementById('editClientId').value.trim(), + refresh_token: document.getElementById('editRefreshToken').value.trim(), + group_id: newGroupId, + remark: document.getElementById('editRemark').value.trim(), + status: document.getElementById('editStatus').value + }; + + if (!data.email || !data.client_id || !data.refresh_token) { + showToast('邮箱、Client ID 和 Refresh Token 不能为空', 'error'); + return; + } + + try { + const response = await fetch(`/api/accounts/${accountId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (result.success) { + showToast(result.message, 'success'); + hideEditAccountModal(); + + // 清除相关分组的缓存 + delete accountsCache[oldGroupId]; + if (oldGroupId !== newGroupId) { + delete accountsCache[newGroupId]; + } + + // 刷新分组列表 + loadGroups(); + + // 刷新当前分组的邮箱列表 + if (currentGroupId) { + loadAccountsByGroup(currentGroupId, true); + } + } else { + showToast(result.error, 'error'); + } + } catch (error) { + showToast('更新失败', 'error'); + } + } + + // 删除当前编辑的账号 + async function deleteCurrentAccount() { + const accountId = document.getElementById('editAccountId').value; + const email = document.getElementById('editEmail').value; + const groupId = parseInt(document.getElementById('editGroupSelect').value); + + if (!confirm(`确定要删除账号 ${email} 吗?`)) { + return; + } + + try { + const response = await fetch(`/api/accounts/${accountId}`, { method: 'DELETE' }); + const data = await response.json(); + + if (data.success) { + showToast('删除成功', 'success'); + hideEditAccountModal(); + + // 清除缓存 + delete accountsCache[groupId]; + + if (currentAccount === email) { + currentAccount = null; + document.getElementById('currentAccount').classList.remove('show'); + document.getElementById('emailList').innerHTML = ` +
+
📬
+
请从左侧选择一个邮箱账号
+
+ `; + document.getElementById('emailDetail').innerHTML = ` +
+
📄
+
选择一封邮件查看详情
+
+ `; + } + + // 刷新分组列表 + loadGroups(); + + // 刷新当前分组的邮箱列表 + if (currentGroupId) { + loadAccountsByGroup(currentGroupId, true); + } + } + } catch (error) { + showToast('删除失败', 'error'); + } + } + + // 切换账号状态(启用/停用) + async function toggleAccountStatus(accountId, currentStatus) { + const newStatus = currentStatus === 'inactive' ? 'active' : 'inactive'; + const action = newStatus === 'inactive' ? '停用' : '启用'; + + try { + const response = await fetch(`/api/accounts/${accountId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: newStatus }) + }); + + const data = await response.json(); + + if (data.success) { + showToast(`${action}成功`, 'success'); + + // 清除当前分组的缓存 + if (currentGroupId) { + delete accountsCache[currentGroupId]; + loadAccountsByGroup(currentGroupId, true); + } + } else { + handleApiError(data, `${action}账号失败`); + } + } catch (error) { + showToast(`${action}失败`, 'error'); + } + } + + // 删除账号(快捷方式) + async function deleteAccount(accountId, email) { + if (!confirm(`确定要删除账号 ${email} 吗?`)) { + return; + } + + try { + const response = await fetch(`/api/accounts/${accountId}`, { method: 'DELETE' }); + const data = await response.json(); + + if (data.success) { + showToast('删除成功', 'success'); + + // 清除当前分组的缓存 + if (currentGroupId) { + delete accountsCache[currentGroupId]; + } + + if (currentAccount === email) { + currentAccount = null; + document.getElementById('currentAccount').classList.remove('show'); + document.getElementById('emailList').innerHTML = ` +
+
📬
+
请从左侧选择一个邮箱账号
+
+ `; + document.getElementById('emailDetail').innerHTML = ` +
+
📄
+
选择一封邮件查看详情
+
+ `; + } + + // 刷新分组列表 + loadGroups(); + + // 刷新当前分组的邮箱列表 + if (currentGroupId) { + loadAccountsByGroup(currentGroupId, true); + } + } else { + handleApiError(data, '删除账号失败'); + } + } catch (error) { + showToast('删除失败', 'error'); + } + } + + // 显示导出邮箱模态框 + async function showExportModal() { + document.getElementById('exportModal').classList.add('show'); + await loadExportGroupList(); + } + + // 隐藏导出邮箱模态框 + function hideExportModal() { + document.getElementById('exportModal').classList.remove('show'); + } + + // 加载导出分组列表 + async function loadExportGroupList() { + const container = document.getElementById('exportGroupList'); + container.innerHTML = '
'; + + try { + // 使用已加载的分组数据 + if (groups.length === 0) { + container.innerHTML = '
暂无分组
'; + } else { + container.innerHTML = groups.map(group => ` + + `).join(''); + } + } catch (error) { + container.innerHTML = '
加载失败
'; + } + + // 重置全选复选框 + document.getElementById('selectAllGroups').checked = false; + } + + // 全选/取消全选分组 + function toggleSelectAllGroups() { + const selectAll = document.getElementById('selectAllGroups').checked; + document.querySelectorAll('.export-group-checkbox').forEach(cb => { + cb.checked = selectAll; + }); + } + + // 存储待导出的分组ID + let pendingExportGroupIds = []; + + // 导出选中的分组 + async function exportSelectedGroups() { + const checkboxes = document.querySelectorAll('.export-group-checkbox:checked'); + const groupIds = Array.from(checkboxes).map(cb => parseInt(cb.value)); + + if (groupIds.length === 0) { + showToast('请选择要导出的分组', 'error'); + return; + } + + // 保存待导出的分组ID + pendingExportGroupIds = groupIds; + + // 显示密码确认对话框 + hideExportModal(); + showExportVerifyModal(); + } + + // 显示导出密码确认对话框 + function showExportVerifyModal() { + document.getElementById('exportVerifyModal').classList.add('show'); + document.getElementById('exportVerifyPassword').value = ''; + document.getElementById('exportVerifyPassword').focus(); + } + + // 隐藏导出密码确认对话框 + function hideExportVerifyModal() { + document.getElementById('exportVerifyModal').classList.remove('show'); + document.getElementById('exportVerifyPassword').value = ''; + } + + // 确认导出验证 + async function confirmExportVerify() { + const password = document.getElementById('exportVerifyPassword').value; + + if (!password) { + showToast('请输入密码', 'error'); + return; + } + + try { + // 获取验证token + const verifyResponse = await fetch('/api/export/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }) + }); + + const verifyData = await verifyResponse.json(); + + if (!verifyData.success) { + showToast(verifyData.error || '密码错误', 'error'); + return; + } + + const verifyToken = verifyData.verify_token; + + // 执行导出 + const response = await fetch('/api/accounts/export-selected', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + group_ids: pendingExportGroupIds, + verify_token: verifyToken + }) + }); + + if (response.ok) { + // 获取文件名 + const contentDisposition = response.headers.get('Content-Disposition'); + let filename = 'accounts.txt'; + if (contentDisposition) { + const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?([^;\n]+)/i); + if (match) { + filename = decodeURIComponent(match[1]); + } + } + + // 下载文件 + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + showToast('导出成功', 'success'); + hideExportVerifyModal(); + } else { + const data = await response.json(); + handleApiError(data, '导出失败'); + } + } catch (error) { + showToast('导出失败', 'error'); + } + } + diff --git a/static/js/features/emails.js b/static/js/features/emails.js new file mode 100644 index 0000000..3499429 --- /dev/null +++ b/static/js/features/emails.js @@ -0,0 +1,645 @@ + // ==================== 邮件相关 ==================== + + // 加载邮件列表 + async function loadEmails(email, forceRefresh = false) { + const container = document.getElementById('emailList'); + + // 切换账号/刷新时清除选中状态 + selectedEmailIds.clear(); + updateEmailBatchActionBar(); + + // 检查缓存 + const cacheKey = `${email}_${currentFolder}`; + if (!forceRefresh && emailListCache[cacheKey]) { + const cache = emailListCache[cacheKey]; + currentEmails = cache.emails; + hasMoreEmails = cache.has_more; + currentSkip = cache.skip; + currentMethod = cache.method || 'graph'; + + // 恢复 UI + const methodTag = document.getElementById('methodTag'); + methodTag.textContent = currentMethod; + methodTag.style.display = 'inline'; + document.getElementById('emailCount').textContent = `(${currentEmails.length})`; + + renderEmailList(currentEmails); + return; + } + + // 禁用按钮 + const refreshBtn = document.querySelector('.refresh-btn'); + const folderTabs = document.querySelectorAll('.folder-tab'); + if (refreshBtn) { + refreshBtn.disabled = true; + refreshBtn.textContent = '获取中...'; + } + folderTabs.forEach(tab => tab.disabled = true); + + // 重置分页状态 + currentSkip = 0; + hasMoreEmails = true; + + container.innerHTML = '
'; + + try { + // 每次只查询20封邮件 + const response = await fetch( + `/api/emails/${encodeURIComponent(email)}?method=${currentMethod}&folder=${currentFolder}&skip=0&top=20` + ); + const data = await response.json(); + + if (data.success) { + currentEmails = data.emails; + currentMethod = data.method === 'Graph API' ? 'graph' : 'imap'; + hasMoreEmails = data.has_more; + + // 保存到缓存 + emailListCache[cacheKey] = { + emails: currentEmails, + has_more: hasMoreEmails, + skip: currentSkip, + method: currentMethod + }; + + // 显示使用的方法和邮件数量 + const methodTag = document.getElementById('methodTag'); + methodTag.textContent = data.method; + methodTag.style.display = 'inline'; + + document.getElementById('emailCount').textContent = `(${data.emails.length})`; + + renderEmailList(data.emails); + } else { + // 显示详细的多方法失败弹框 + if (data.details) { + showEmailFetchErrorModal(data.details); + } else { + handleApiError(data, '获取邮件失败'); + } + container.innerHTML = ` +
+
⚠️
+
获取邮件失败,点击查看详情
+
+ `; + window._lastFetchErrorDetails = data.details || {}; + } + } catch (error) { + container.innerHTML = ` +
+
⚠️
+
网络错误,请重试
+
+ `; + } finally { + // 启用按钮 + if (refreshBtn) { + refreshBtn.disabled = false; + refreshBtn.textContent = '获取邮件'; + } + folderTabs.forEach(tab => tab.disabled = false); + } + } + + // 渲染邮件列表 + // Selected email IDs + let selectedEmailIds = new Set(); + let isBatchSelectMode = false; + + function renderEmailList(emails) { + const container = document.getElementById('emailList'); + const actionBar = document.getElementById('emailBatchActionBar'); + + if (emails.length === 0) { + container.innerHTML = ` +
+
📭
+
收件箱为空
+
+ `; + // Reset selection + selectedEmailIds.clear(); + updateEmailBatchActionBar(); + return; + } + + // 根据是否是临时邮箱选择不同的点击处理函数 + const clickHandler = isTempEmailGroup ? 'getTempEmailDetail' : 'selectEmail'; + + container.innerHTML = emails.map((email, index) => { + const isChecked = selectedEmailIds.has(email.id); + return ` + + `}).join(''); + + updateEmailBatchActionBar(); + } + + function toggleEmailSelection(emailId) { + if (selectedEmailIds.has(emailId)) { + selectedEmailIds.delete(emailId); + } else { + selectedEmailIds.add(emailId); + } + + // Re-render to update checkbox UI (or efficiently update DOM) + // For simplicity, we just find the checkbox and update it + // implementation below is cheap + renderEmailList(currentEmails); + } + + function updateEmailBatchActionBar() { + const bar = document.getElementById('emailBatchActionBar'); + if (selectedEmailIds.size > 0) { + bar.style.display = 'flex'; + document.getElementById('emailSelectedCount').textContent = `已选 ${selectedEmailIds.size} 项`; + } else { + bar.style.display = 'none'; + } + } + + async function confirmBatchDeleteEmails() { + if (selectedEmailIds.size === 0) return; + + if (!confirm(`确定要永久删除选中的 ${selectedEmailIds.size} 封邮件吗?此操作不可恢复!`)) { + return; + } + + await deleteEmails(Array.from(selectedEmailIds)); + } + + async function confirmDeleteCurrentEmail() { + if (!currentEmailDetail || !currentEmailDetail.id) return; + + if (!confirm('确定要永久删除这封邮件吗?此操作不可恢复!')) { + return; + } + + await deleteEmails([currentEmailDetail.id]); + } + + async function deleteEmails(ids) { + showToast('正在删除...', 'info'); + + try { + const response = await fetch('/api/emails/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: currentAccount, + ids: ids + }) + }); + + const result = await response.json(); + + if (result.success) { + showToast(`成功删除 ${result.success_count} 封邮件`); + + // Remove deleted emails from currentEmails + const deletedIds = new Set(ids); // Ideally result should return what was deleted + currentEmails = currentEmails.filter(e => !deletedIds.has(e.id)); + selectedEmailIds.clear(); + + renderEmailList(currentEmails); + + // If current viewed email was deleted, clear view + if (currentEmailDetail && deletedIds.has(currentEmailDetail.id)) { + document.getElementById('emailDetail').innerHTML = ` +
+
🗑️
+
邮件已删除
+
+ `; + document.getElementById('emailDetailToolbar').style.display = 'none'; + } + + // If errors + if (result.failed_count > 0) { + console.warn('Deletion errors:', result.errors); + showToast(`部分删除失败 (${result.failed_count} 封)`, 'warning'); + } + } else { + showToast('删除失败: ' + (result.error || '未知错误'), 'error'); + } + } catch (e) { + showToast('网络错误', 'error'); + console.error(e); + } + } + + // 选择邮件 + async function selectEmail(messageId, index) { + // 更新 UI + document.querySelectorAll('.email-item').forEach((item, i) => { + item.classList.toggle('active', i === index); + }); + + // 这里不重置 currentEmailDetail,等到 fetch 成功后再设置 + + // 重置信任模式 + document.getElementById('trustEmailCheckbox').checked = false; + isTrustedMode = false; + + // 显示工具栏 + document.getElementById('emailDetailToolbar').style.display = 'flex'; + + // 加载邮件详情 + const container = document.getElementById('emailDetail'); + container.innerHTML = '
'; + + try { + const response = await fetch(`/api/email/${encodeURIComponent(currentAccount)}/${encodeURIComponent(messageId)}?method=${currentMethod}&folder=${currentFolder}`); + const data = await response.json(); + + if (data.success) { + currentEmailDetail = data.email; + renderEmailDetail(data.email); + } else { + handleApiError(data, '加载邮件详情失败'); + container.innerHTML = ` +
+
⚠️
+
${data.error && data.error.message ? data.error.message : '加载失败'}
+
+ `; + } + } catch (error) { + container.innerHTML = ` +
+
⚠️
+
网络错误,请重试
+
+ `; + } + } + + // 渲染邮件详情 + function renderEmailDetail(email) { + const container = document.getElementById('emailDetail'); + + const isHtml = email.body_type === 'html' || + (email.body && (email.body.includes(''))); + + const bodyContent = isHtml + ? `` + : ``; + + container.innerHTML = ` + + + `; + + // 如果是 HTML 内容,设置 iframe 内容 + if (isHtml) { + const iframe = document.getElementById('emailBodyFrame'); + if (iframe) { + let sanitizedBody; + if (isTrustedMode) { + sanitizedBody = email.body; // 信任模式:不过滤 + } else { + // 使用 DOMPurify 净化 HTML 内容,防止 XSS 攻击 + sanitizedBody = DOMPurify.sanitize(email.body, { + ALLOWED_TAGS: ['a', 'b', 'i', 'u', 'strong', 'em', 'p', 'br', 'div', 'span', 'img', 'table', 'tr', 'td', 'th', 'thead', 'tbody', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'code'], + ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'style', 'class', 'width', 'height', 'align', 'border', 'cellpadding', 'cellspacing'], + ALLOW_DATA_ATTR: false, + FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button'], + FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'] + }); + } + + const htmlContent = ` + + + + + + + ${sanitizedBody} + + `; + iframe.srcdoc = htmlContent; + } + } + } + + // 动态调整 iframe 高度 + function adjustIframeHeight(iframe) { + try { + // 多次尝试调整高度,确保内容完全加载 + const adjustHeight = () => { + if (iframe.contentDocument && iframe.contentDocument.body) { + const body = iframe.contentDocument.body; + const html = iframe.contentDocument.documentElement; + // 获取实际内容高度(取最大值) + const height = Math.max( + body.scrollHeight, + body.offsetHeight, + html.clientHeight, + html.scrollHeight, + html.offsetHeight + ); + // 设置最小高度为 600px,添加 100px 余量确保长邮件能完整显示 + iframe.style.height = Math.max(height + 100, 600) + 'px'; + } + }; + + // 立即调整一次 + adjustHeight(); + // 100ms 后再调整(等待图片等资源加载) + setTimeout(adjustHeight, 100); + // 300ms 后再调整 + setTimeout(adjustHeight, 300); + // 500ms 后再调整(确保所有内容都已加载) + setTimeout(adjustHeight, 500); + // 1秒后最后调整一次 + setTimeout(adjustHeight, 1000); + // 2秒后再次调整(处理延迟加载的内容) + setTimeout(adjustHeight, 2000); + + // 监听 iframe 内容变化 + if (iframe.contentDocument) { + const observer = new MutationObserver(adjustHeight); + observer.observe(iframe.contentDocument.body, { + childList: true, + subtree: true, + attributes: true + }); + + // 监听图片加载完成事件 + const images = iframe.contentDocument.querySelectorAll('img'); + images.forEach(img => { + img.addEventListener('load', adjustHeight); + img.addEventListener('error', adjustHeight); + }); + } + } catch (e) { + console.log('Cannot adjust iframe height:', e); + } + } + + // 切换邮件列表显示 + function toggleEmailList() { + const panel = document.getElementById('emailListPanel'); + const toggleText = document.getElementById('toggleListText'); + + isListVisible = !isListVisible; + + if (isListVisible) { + panel.classList.remove('hidden'); + toggleText.textContent = '隐藏列表'; + } else { + panel.classList.add('hidden'); + toggleText.textContent = '显示列表'; + } + } + + // 全屏查看邮件 + let currentFullscreenEmail = null; + + function openFullscreenEmail() { + const emailDetail = document.getElementById('emailDetail'); + const modal = document.getElementById('fullscreenEmailModal'); + const content = document.getElementById('fullscreenEmailContent'); + const title = document.getElementById('fullscreenEmailTitle'); + + // 获取当前邮件的标题 + const subjectElement = emailDetail.querySelector('.email-detail-subject'); + if (subjectElement) { + title.textContent = subjectElement.textContent; + } + + // 克隆邮件内容 + const emailHeader = emailDetail.querySelector('.email-detail-header'); + const emailBody = emailDetail.querySelector('.email-detail-body'); + + if (emailHeader && emailBody) { + // 清空内容 + content.innerHTML = ''; + + // 克隆头部信息 + const headerClone = emailHeader.cloneNode(true); + content.appendChild(headerClone); + + // 处理邮件正文 + const iframe = emailBody.querySelector('iframe'); + const textContent = emailBody.querySelector('.email-body-text'); + + if (iframe) { + // 如果是 HTML 邮件,创建新的 iframe + const newIframe = document.createElement('iframe'); + newIframe.id = 'fullscreenEmailBodyFrame'; + newIframe.style.width = '100%'; + newIframe.style.border = 'none'; + newIframe.style.backgroundColor = '#ffffff'; + + // 复制原 iframe 的内容 + if (iframe.contentDocument) { + const htmlContent = iframe.contentDocument.documentElement.outerHTML; + newIframe.srcdoc = htmlContent; + } + + content.appendChild(newIframe); + + // 调整 iframe 高度 + newIframe.onload = function () { + adjustFullscreenIframeHeight(newIframe); + }; + } else if (textContent) { + // 如果是纯文本邮件,直接克隆 + const textClone = textContent.cloneNode(true); + content.appendChild(textClone); + } + + // 显示模态框 + modal.classList.add('show'); + document.body.style.overflow = 'hidden'; + } + } + + // 切换信任模式 + function toggleTrustMode(checkbox) { + if (checkbox.checked) { + if (confirm('⚠️ 警告:启用信任模式将直接显示邮件原始内容,不进行任何安全过滤。\n\n这可能包含恶意脚本或不安全的内容。您确定要继续吗?')) { + isTrustedMode = true; + if (currentEmailDetail) { + renderEmailDetail(currentEmailDetail); + } + } else { + checkbox.checked = false; + } + } else { + isTrustedMode = false; + if (currentEmailDetail) { + renderEmailDetail(currentEmailDetail); + } + } + } + + function closeFullscreenEmail() { + const modal = document.getElementById('fullscreenEmailModal'); + modal.classList.remove('show'); + document.body.style.overflow = ''; + } + + function closeFullscreenEmailOnBackdrop(event) { + // 只有点击背景时才关闭,点击内容区域不关闭 + if (event.target.id === 'fullscreenEmailModal') { + closeFullscreenEmail(); + } + } + + function adjustFullscreenIframeHeight(iframe) { + try { + const adjustHeight = () => { + if (iframe.contentDocument && iframe.contentDocument.body) { + const body = iframe.contentDocument.body; + const html = iframe.contentDocument.documentElement; + const height = Math.max( + body.scrollHeight, + body.offsetHeight, + html.clientHeight, + html.scrollHeight, + html.offsetHeight + ); + // 全屏模式下设置实际高度,添加余量 + iframe.style.height = (height + 100) + 'px'; + } + }; + + // 多次调整高度 + adjustHeight(); + setTimeout(adjustHeight, 100); + setTimeout(adjustHeight, 300); + setTimeout(adjustHeight, 500); + setTimeout(adjustHeight, 1000); + + // 监听内容变化 + if (iframe.contentDocument) { + const observer = new MutationObserver(adjustHeight); + observer.observe(iframe.contentDocument.body, { + childList: true, + subtree: true, + attributes: true + }); + + // 监听图片加载 + const images = iframe.contentDocument.querySelectorAll('img'); + images.forEach(img => { + img.addEventListener('load', adjustHeight); + img.addEventListener('error', adjustHeight); + }); + } + } catch (e) { + console.log('Cannot adjust fullscreen iframe height:', e); + } + } + + // 显示邮件列表(移动端) + function showEmailList() { + document.getElementById('emailListPanel').classList.remove('hidden'); + isListVisible = true; + document.getElementById('toggleListText').textContent = '隐藏列表'; + } + + // 刷新邮件 + function refreshEmails() { + if (currentAccount) { + if (isTempEmailGroup) { + loadTempEmailMessages(currentAccount); + } else { + // 清除当前缓存并强制刷新 + const cacheKey = `${currentAccount}_${currentFolder}`; + delete emailListCache[cacheKey]; + loadEmails(currentAccount, true); + } + } else { + showToast('请先选择一个邮箱账号', 'error'); + } + } + + // 复制邮箱地址 + function copyEmail(email) { + navigator.clipboard.writeText(email).then(() => { + showToast('邮箱地址已复制', 'success'); + }).catch(() => { + // 降级方案 + const textarea = document.createElement('textarea'); + textarea.value = email; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + showToast('邮箱地址已复制', 'success'); + }); + } + + // 复制当前邮箱 + function copyCurrentEmail() { + const emailElement = document.getElementById('currentAccountEmail'); + if (emailElement && emailElement.textContent) { + const email = emailElement.textContent.replace(' (临时)', '').trim(); + copyEmail(email); + } + } + + // 退出登录 + function logout() { + if (confirm('确定要退出登录吗?')) { + window.location.href = '/logout'; + } + } + diff --git a/static/js/features/groups.js b/static/js/features/groups.js new file mode 100644 index 0000000..78b5858 --- /dev/null +++ b/static/js/features/groups.js @@ -0,0 +1,481 @@ + // ==================== 分组相关 ==================== + + // 加载分组列表 + async function loadGroups() { + const container = document.getElementById('groupList'); + container.innerHTML = '
'; + + try { + const response = await fetch('/api/groups'); + const data = await response.json(); + + if (data.success) { + groups = data.groups; + + // 找到临时邮箱分组 + const tempGroup = groups.find(g => g.name === '临时邮箱'); + if (tempGroup) { + tempEmailGroupId = tempGroup.id; + } + + renderGroupList(data.groups); + updateGroupSelects(); + + // 如果之前选中了分组,保持选中状态并刷新邮箱列表 + if (currentGroupId) { + const group = groups.find(g => g.id === currentGroupId); + if (group) { + // 刷新当前分组的邮箱列表 + if (currentGroupId === tempEmailGroupId) { + loadTempEmails(true); + } else { + loadAccountsByGroup(currentGroupId, true); + } + } + } + } + } catch (error) { + container.innerHTML = '
加载失败
'; + showToast('加载分组失败', 'error'); + } + } + + // 渲染分组列表 + function renderGroupList(groups) { + const container = document.getElementById('groupList'); + + if (groups.length === 0) { + container.innerHTML = ` +
+
暂无分组
+
+ `; + return; + } + + container.innerHTML = groups.map(group => { + const isSystem = group.is_system === 1 || group.name === '临时邮箱'; + const isTempGroup = group.name === '临时邮箱'; + const isDefault = group.id === 1; + + return ` +
+
+
+ ${escapeHtml(group.name)}${isTempGroup ? ' ⚡' : ''} +
+
+ ${group.account_count || 0} 个邮箱 +
+ ${!isSystem ? `` : ''} + ${!isDefault && !isSystem ? `` : ''} +
+
+
+ `; + }).join(''); + } + + // 选择分组 + async function selectGroup(groupId) { + currentGroupId = groupId; + + // 清空搜索框 + const searchInput = document.getElementById('globalSearch'); + if (searchInput) { + searchInput.value = ''; + } + + // 检查是否是临时邮箱分组 + const group = groups.find(g => g.id === groupId); + isTempEmailGroup = group && group.name === '临时邮箱'; + + // 更新分组列表 UI + document.querySelectorAll('.group-item').forEach(item => { + item.classList.toggle('active', parseInt(item.dataset.groupId) === groupId); + }); + + // 更新邮箱面板标题 + if (group) { + document.getElementById('currentGroupName').textContent = group.name; + document.getElementById('currentGroupColor').style.backgroundColor = group.color || '#666'; + + // 更新导入邮箱时的默认分组 + const importSelect = document.getElementById('importGroupSelect'); + if (importSelect) { + importSelect.value = groupId; + } + } + + // 更新底部按钮 + updateAccountPanelFooter(); + + // 加载该分组的邮箱 + if (isTempEmailGroup) { + await loadTempEmails(); + } else { + await loadAccountsByGroup(groupId); + } + } + + // 更新账号面板底部按钮 + function updateAccountPanelFooter() { + const footer = document.querySelector('.account-panel-footer'); + if (isTempEmailGroup) { + footer.innerHTML = ` + + `; + } else { + footer.innerHTML = ` + + + `; + } + } + + // 加载分组下的账号 + async function loadAccountsByGroup(groupId, forceRefresh = false) { + const container = document.getElementById('accountList'); + + // 如果有缓存且不强制刷新,直接使用缓存 + if (!forceRefresh && accountsCache[groupId]) { + renderAccountList(accountsCache[groupId]); + return; + } + + container.innerHTML = '
'; + + try { + const response = await fetch(`/api/accounts?group_id=${groupId}`); + const data = await response.json(); + + if (data.success) { + // 缓存数据 + accountsCache[groupId] = data.accounts; + renderAccountList(data.accounts); + } + } catch (error) { + container.innerHTML = '
加载失败
'; + } + } + + // 渲染邮箱列表 + function renderAccountList(accounts) { + const container = document.getElementById('accountList'); + + if (accounts.length === 0) { + container.innerHTML = ` +
+
📭
+
该分组暂无邮箱
+
+ `; + return; + } + + container.innerHTML = accounts.map((acc, index) => ` + + `).join(''); + } + + // 排序相关变量 + let currentSortBy = 'refresh_time'; + let currentSortOrder = 'asc'; + + // 排序账号列表 + function sortAccounts(sortBy) { + // 如果点击同一个排序按钮,切换排序顺序 + if (currentSortBy === sortBy) { + currentSortOrder = currentSortOrder === 'asc' ? 'desc' : 'asc'; + } else { + currentSortBy = sortBy; + currentSortOrder = sortBy === 'refresh_time' ? 'asc' : 'asc'; + } + + // 更新按钮状态 + document.querySelectorAll('.sort-btn').forEach(btn => { + btn.classList.remove('active'); + btn.style.backgroundColor = '#ffffff'; + btn.style.color = '#666'; + btn.style.borderColor = '#e5e5e5'; + }); + + const activeBtn = document.querySelector(`[data-sort="${sortBy}"]`); + if (activeBtn) { + activeBtn.classList.add('active'); + activeBtn.style.backgroundColor = '#1a1a1a'; + activeBtn.style.color = '#ffffff'; + activeBtn.style.borderColor = '#1a1a1a'; + } + + // 重新加载并排序账号列表 + if (accountsCache[currentGroupId]) { + const sortedAccounts = applyFiltersAndSort(accountsCache[currentGroupId]); + renderAccountList(sortedAccounts); + } + } + + // 应用筛选和排序 + function applyFiltersAndSort(accounts) { + let result = [...accounts]; + + // 1. Tag 筛选 + // Get checked tags + const checkedBoxes = document.querySelectorAll('.tag-filter-checkbox:checked'); + const selectedTagIds = Array.from(checkedBoxes).map(cb => parseInt(cb.value)); + + if (selectedTagIds.length > 0) { + result = result.filter(acc => { + if (!acc.tags) return false; + // Check if account has ANY of the selected tags (OR logic) + // If you want AND logic, use every() instead of some() + return acc.tags.some(t => selectedTagIds.includes(t.id)); + }); + } + + // 2. 排序 + return result.sort((a, b) => { + if (currentSortBy === 'refresh_time') { + const timeA = a.last_refresh_at ? new Date(a.last_refresh_at) : new Date(0); + const timeB = b.last_refresh_at ? new Date(b.last_refresh_at) : new Date(0); + return currentSortOrder === 'asc' ? timeA - timeB : timeB - timeA; + } else { + const emailA = a.email.toLowerCase(); + const emailB = b.email.toLowerCase(); + return currentSortOrder === 'asc' + ? emailA.localeCompare(emailB) + : emailB.localeCompare(emailA); + } + }); + } + + // Tag Filter Change Handler + function handleTagFilterChange() { + if (accountsCache[currentGroupId]) { + const filteredAccounts = applyFiltersAndSort(accountsCache[currentGroupId]); + renderAccountList(filteredAccounts); + } + } + + // 防抖函数 + function debounce(func, wait) { + let timeout; + return function (...args) { + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(this, args), wait); + }; + } + + // 全局搜索函数 + async function searchAccounts(query) { + const container = document.getElementById('accountList'); + const titleElement = document.getElementById('currentGroupName'); + + if (!query.trim()) { + loadAccountsByGroup(currentGroupId); + return; + } + + container.innerHTML = '
'; + + try { + const response = await fetch(`/api/accounts/search?q=${encodeURIComponent(query)}`); + const data = await response.json(); + + if (data.success) { + titleElement.textContent = `搜索结果 (${data.accounts.length})`; + renderAccountList(data.accounts); + } else { + container.innerHTML = '
搜索失败
'; + } + } catch (error) { + console.error('搜索失败:', error); + container.innerHTML = '
搜索失败,请重试
'; + } + } + + // 更新分组下拉选择框 + function updateGroupSelects() { + const selects = ['importGroupSelect', 'editGroupSelect']; + selects.forEach(selectId => { + const select = document.getElementById(selectId); + if (select) { + const currentValue = select.value; + // 过滤掉临时邮箱分组(导入邮箱时不能选择临时邮箱分组) + const filteredGroups = selectId === 'importGroupSelect' + ? groups.filter(g => g.name !== '临时邮箱') + : groups; + + select.innerHTML = filteredGroups.map(g => + `` + ).join(''); + // 恢复之前的选择 + if (currentValue && filteredGroups.find(g => g.id === parseInt(currentValue))) { + select.value = currentValue; + } else if (currentGroupId && filteredGroups.find(g => g.id === currentGroupId)) { + select.value = currentGroupId; + } + } + }); + } + + // 显示添加分组模态框 + function showAddGroupModal() { + editingGroupId = null; + document.getElementById('groupModalTitle').textContent = '添加分组'; + document.getElementById('groupName').value = ''; + document.getElementById('groupDescription').value = ''; + selectedColor = '#1a1a1a'; + document.querySelectorAll('.color-option').forEach(o => { + o.classList.toggle('selected', o.dataset.color === selectedColor); + }); + document.getElementById('customColorInput').value = selectedColor; + document.getElementById('customColorHex').value = selectedColor; + document.getElementById('groupProxyUrl').value = ''; + document.getElementById('addGroupModal').classList.add('show'); + } + + // 隐藏添加分组模态框 + function hideAddGroupModal() { + document.getElementById('addGroupModal').classList.remove('show'); + } + + // 编辑分组 + async function editGroup(groupId) { + try { + const response = await fetch(`/api/groups/${groupId}`); + const data = await response.json(); + + if (data.success) { + editingGroupId = groupId; + document.getElementById('groupModalTitle').textContent = '编辑分组'; + document.getElementById('groupName').value = data.group.name; + document.getElementById('groupDescription').value = data.group.description || ''; + selectedColor = data.group.color || '#1a1a1a'; + + // 检查是否是预设颜色 + let isPresetColor = false; + document.querySelectorAll('.color-option').forEach(o => { + if (o.dataset.color === selectedColor) { + o.classList.add('selected'); + isPresetColor = true; + } else { + o.classList.remove('selected'); + } + }); + + // 更新自定义颜色输入框 + document.getElementById('customColorInput').value = selectedColor; + document.getElementById('customColorHex').value = selectedColor; + + // 填充代理设置 + document.getElementById('groupProxyUrl').value = data.group.proxy_url || ''; + + document.getElementById('addGroupModal').classList.add('show'); + } + } catch (error) { + showToast('加载分组信息失败', 'error'); + } + } + + // 保存分组 + async function saveGroup() { + const name = document.getElementById('groupName').value.trim(); + const description = document.getElementById('groupDescription').value.trim(); + + if (!name) { + showToast('请输入分组名称', 'error'); + return; + } + + try { + const url = editingGroupId ? `/api/groups/${editingGroupId}` : '/api/groups'; + const method = editingGroupId ? 'PUT' : 'POST'; + + const response = await fetch(url, { + method: method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + description, + color: selectedColor, + proxy_url: document.getElementById('groupProxyUrl').value.trim() + }) + }); + + const data = await response.json(); + + if (data.success) { + showToast(data.message, 'success'); + hideAddGroupModal(); + loadGroups(); + } else { + handleApiError(data, '保存分组失败'); + } + } catch (error) { + showToast('保存失败', 'error'); + } + } + + // 删除分组 + async function deleteGroup(groupId) { + if (!confirm('确定要删除该分组吗?分组下的邮箱将移至默认分组。')) { + return; + } + + try { + const response = await fetch(`/api/groups/${groupId}`, { method: 'DELETE' }); + const data = await response.json(); + + if (data.success) { + showToast(data.message, 'success'); + // 清除缓存 + delete accountsCache[groupId]; + // 如果删除的是当前选中的分组,切换到默认分组 + if (currentGroupId === groupId) { + currentGroupId = 1; + } + loadGroups(); + } else { + handleApiError(data, '删除分组失败'); + } + } catch (error) { + showToast('删除失败', 'error'); + } + } + diff --git a/static/js/features/temp_emails.js b/static/js/features/temp_emails.js new file mode 100644 index 0000000..b04743a --- /dev/null +++ b/static/js/features/temp_emails.js @@ -0,0 +1,315 @@ + // ==================== 临时邮箱相关 ==================== + + // 加载临时邮箱列表 + async function loadTempEmails(forceRefresh = false) { + const container = document.getElementById('accountList'); + + if (!forceRefresh && accountsCache['temp']) { + renderTempEmailList(accountsCache['temp']); + return; + } + + container.innerHTML = '
'; + + try { + const response = await fetch('/api/temp-emails'); + const data = await response.json(); + + if (data.success) { + accountsCache['temp'] = data.emails; + renderTempEmailList(data.emails); + + const group = groups.find(g => g.name === '临时邮箱'); + if (group) { + group.account_count = data.emails.length; + renderGroupList(groups); + } + } + } catch (error) { + container.innerHTML = '
加载失败
'; + } + } + + // 渲染临时邮箱列表 + function renderTempEmailList(emails) { + const container = document.getElementById('accountList'); + + if (emails.length === 0) { + container.innerHTML = ` +
+
⚡
+
暂无临时邮箱
点击下方按钮生成
+
+ `; + return; + } + + container.innerHTML = emails.map(email => ` + + `).join(''); + } + + // 生成临时邮箱 + async function generateTempEmail() { + // 获取按钮并显示加载状态 + const btn = document.querySelector('.account-panel-footer .add-account-btn'); + const originalText = btn.textContent; + btn.disabled = true; + btn.textContent = '⏳ 生成中...'; + btn.style.opacity = '0.7'; + btn.style.cursor = 'not-allowed'; + + try { + const response = await fetch('/api/temp-emails/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + + const data = await response.json(); + + if (data.success) { + showToast(`临时邮箱已生成: ${data.email}`, 'success'); + delete accountsCache['temp']; + loadTempEmails(true); + loadGroups(); + } else { + handleApiError(data, '生成临时邮箱失败'); + } + } catch (error) { + showToast('生成临时邮箱失败', 'error'); + } finally { + // 恢复按钮状态 + btn.disabled = false; + btn.textContent = originalText; + btn.style.opacity = '1'; + btn.style.cursor = 'pointer'; + } + } + + // 选择临时邮箱 + function selectTempEmail(email) { + currentAccount = email; + isTempEmailGroup = true; + + document.getElementById('currentAccount').classList.add('show'); + document.getElementById('currentAccountEmail').textContent = email + ' (临时)'; + + document.querySelectorAll('.account-item').forEach(item => { + item.classList.remove('active'); + const emailEl = item.querySelector('.account-email'); + if (emailEl && emailEl.textContent.includes(email)) { + item.classList.add('active'); + } + }); + + // 隐藏文件夹切换按钮(临时邮箱不支持文件夹) + const folderTabs = document.getElementById('folderTabs'); + if (folderTabs) { + folderTabs.style.display = 'none'; + } + + document.getElementById('emailList').innerHTML = ` +
+
📬
+
点击"获取邮件"按钮获取邮件
+
+ `; + + document.getElementById('emailDetail').innerHTML = ` +
+
📄
+
选择一封邮件查看详情
+
+ `; + document.getElementById('emailDetailToolbar').style.display = 'none'; + document.getElementById('emailCount').textContent = ''; + document.getElementById('methodTag').style.display = 'none'; + } + + // 清空临时邮箱的所有邮件 + async function clearTempEmailMessages(email) { + if (!confirm(`确定要清空临时邮箱 ${email} 的所有邮件吗?`)) { + return; + } + + try { + const response = await fetch(`/api/temp-emails/${encodeURIComponent(email)}/clear`, { + method: 'DELETE' + }); + + const data = await response.json(); + + if (data.success) { + showToast('邮件已清空', 'success'); + + // 如果当前选中的就是这个邮箱,清空邮件列表 + if (currentAccount === email) { + currentEmails = []; + document.getElementById('emailCount').textContent = '(0)'; + document.getElementById('emailList').innerHTML = ` +
+
📭
+
收件箱为空
+
+ `; + document.getElementById('emailDetail').innerHTML = ` +
+
📄
+
选择一封邮件查看详情
+
+ `; + document.getElementById('emailDetailToolbar').style.display = 'none'; + } + } else { + handleApiError(data, '清空临时邮箱失败'); + } + } catch (error) { + showToast('清空失败', 'error'); + } + } + + // 删除临时邮箱 + async function deleteTempEmail(email) { + if (!confirm(`确定要删除临时邮箱 ${email} 吗?\n该邮箱的所有邮件也将被删除。`)) { + return; + } + + try { + const response = await fetch(`/api/temp-emails/${encodeURIComponent(email)}`, { + method: 'DELETE' + }); + + const data = await response.json(); + + if (data.success) { + showToast('临时邮箱已删除', 'success'); + delete accountsCache['temp']; + + if (currentAccount === email) { + currentAccount = null; + document.getElementById('currentAccount').classList.remove('show'); + document.getElementById('emailList').innerHTML = ` +
+
📬
+
请从左侧选择一个邮箱账号
+
+ `; + document.getElementById('emailDetail').innerHTML = ` +
+
📄
+
选择一封邮件查看详情
+
+ `; + } + + loadTempEmails(true); + loadGroups(); + } else { + handleApiError(data, '删除临时邮箱失败'); + } + } catch (error) { + showToast('删除失败', 'error'); + } + } + + // 加载临时邮箱的邮件 + async function loadTempEmailMessages(email) { + const container = document.getElementById('emailList'); + container.innerHTML = '
'; + + // 禁用按钮 + const refreshBtn = document.querySelector('.refresh-btn'); + if (refreshBtn) { + refreshBtn.disabled = true; + refreshBtn.textContent = '获取中...'; + } + + try { + const response = await fetch(`/api/temp-emails/${encodeURIComponent(email)}/messages`); + const data = await response.json(); + + if (data.success) { + currentEmails = data.emails; + currentMethod = 'gptmail'; + + const methodTag = document.getElementById('methodTag'); + methodTag.textContent = 'GPTMail'; + methodTag.style.display = 'inline'; + methodTag.style.backgroundColor = '#00bcf2'; + methodTag.style.color = 'white'; + + document.getElementById('emailCount').textContent = `(${data.count})`; + + renderEmailList(data.emails); + } else { + handleApiError(data, '加载临时邮件失败'); + container.innerHTML = ` +
+
⚠️
+
${data.error && data.error.message ? data.error.message : '加载失败'}
+
+ `; + } + } catch (error) { + container.innerHTML = ` +
+
⚠️
+
网络错误,请重试
+
+ `; + } finally { + // 启用按钮 + if (refreshBtn) { + refreshBtn.disabled = false; + refreshBtn.textContent = '获取邮件'; + } + } + } + + // 获取临时邮件详情 + async function getTempEmailDetail(messageId, index) { + document.querySelectorAll('.email-item').forEach((item, i) => { + item.classList.toggle('active', i === index); + }); + + document.getElementById('emailDetailToolbar').style.display = 'flex'; + + const container = document.getElementById('emailDetail'); + container.innerHTML = '
'; + + try { + const response = await fetch(`/api/temp-emails/${encodeURIComponent(currentAccount)}/messages/${encodeURIComponent(messageId)}`); + const data = await response.json(); + + if (data.success) { + renderEmailDetail(data.email); + } else { + handleApiError(data, '加载邮件详情失败'); + container.innerHTML = ` +
+
⚠️
+
${data.error && data.error.message ? data.error.message : '加载失败'}
+
+ `; + } + } catch (error) { + container.innerHTML = ` +
+
⚠️
+
网络错误,请重试
+
+ `; + } + } + diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..fafd6bc --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,1591 @@ +// 全局状态 + let csrfToken = null; + let currentAccount = null; + let currentGroupId = null; + let currentEmails = []; + let currentMethod = 'graph'; + let currentFolder = 'inbox'; // 当前文件夹:inbox 或 deleteditems + let isListVisible = true; + let groups = []; + let accountsCache = {}; // 缓存各分组的邮箱列表 + let editingGroupId = null; + let selectedColor = '#1a1a1a'; + let isTempEmailGroup = false; // 是否是临时邮箱分组 + let tempEmailGroupId = null; // 临时邮箱分组 ID + let isLoadingMore = false; // 是否正在加载更多邮件 + let hasMoreEmails = true; // 是否还有更多邮件 + let currentSkip = 0; // 当前分页偏移量 + + // 缓存与信任模式 + let emailListCache = {}; // 结构: { "account_folder": { emails: [], hasMore: bool, skip: int, method: str, count: int } } + let currentEmailDetail = null; // 当前查看的邮件详细数据 + let isTrustedMode = false; // 是否处于信任模式(不过滤 HTML) + + // ==================== CSRF 防护 ==================== + + // 初始化 CSRF Token + async function initCSRFToken() { + try { + const response = await fetch('/api/csrf-token'); + const data = await response.json(); + csrfToken = data.csrf_token; + if (data.csrf_disabled) { + console.warn('CSRF protection is disabled. Install flask-wtf for better security.'); + } + } catch (error) { + console.error('Failed to initialize CSRF token:', error); + } + } + + // 包装 fetch 请求,自动添加 CSRF Token + const originalFetch = window.fetch; + window.fetch = function (url, options = {}) { + // 只对非 GET 请求添加 CSRF Token + if (options.method && options.method.toUpperCase() !== 'GET' && csrfToken) { + options.headers = options.headers || {}; + if (options.headers instanceof Headers) { + options.headers.append('X-CSRFToken', csrfToken); + } else { + options.headers['X-CSRFToken'] = csrfToken; + } + } + return originalFetch(url, options); + }; + + // 初始化 + document.addEventListener('DOMContentLoaded', async function () { + // 初始化 CSRF Token + await initCSRFToken(); + + closeAllModals(); // 修复:应用启动时关闭所有模态框,防止浏览器缓存导致残留的模态框背景层 + loadGroups(); + if (typeof loadTags === 'function') { + loadTags(); + } + initColorPicker(); + initColorPicker(); + initEmailListScroll(); + + // 绑定搜索框事件 + const searchInput = document.getElementById('globalSearch'); + if (searchInput) { + const debouncedSearch = debounce((e) => { + searchAccounts(e.target.value); + }, 300); + searchInput.addEventListener('input', debouncedSearch); + } + }); + + // 初始化颜色选择器 + function initColorPicker() { + document.querySelectorAll('.color-option').forEach(option => { + option.addEventListener('click', function () { + document.querySelectorAll('.color-option').forEach(o => o.classList.remove('selected')); + this.classList.add('selected'); + selectedColor = this.dataset.color; + // 同步更新自定义颜色输入框 + document.getElementById('customColorInput').value = selectedColor; + document.getElementById('customColorHex').value = selectedColor; + }); + }); + } + + // 初始化邮件列表滚动监听 + function initEmailListScroll() { + const emailList = document.getElementById('emailList'); + emailList.addEventListener('scroll', function () { + // 检查是否滚动到底部 + if (emailList.scrollHeight - emailList.scrollTop <= emailList.clientHeight + 50) { + if (!isLoadingMore && hasMoreEmails && currentAccount && !isTempEmailGroup) { + loadMoreEmails(); + } + } + }); + } + + // 加载更多邮件 + async function loadMoreEmails() { + if (isLoadingMore || !hasMoreEmails) return; + + isLoadingMore = true; + currentSkip += 20; // 每页20封 + + // 在列表底部显示加载状态 + const emailList = document.getElementById('emailList'); + const loadingDiv = document.createElement('div'); + loadingDiv.className = 'loading loading-small'; + loadingDiv.id = 'loadingMore'; + loadingDiv.innerHTML = '
'; + emailList.appendChild(loadingDiv); + + // 禁用按钮 + const refreshBtn = document.querySelector('.refresh-btn'); + const folderTabs = document.querySelectorAll('.folder-tab'); + if (refreshBtn) { + refreshBtn.disabled = true; + } + folderTabs.forEach(tab => tab.disabled = true); + + try { + const response = await fetch( + `/api/emails/${encodeURIComponent(currentAccount)}?method=${currentMethod}&folder=${currentFolder}&skip=${currentSkip}&top=20` + ); + const data = await response.json(); + + if (data.success && data.emails.length > 0) { + // 追加新邮件到列表 + currentEmails = currentEmails.concat(data.emails); + hasMoreEmails = data.has_more; + + // 移除加载状态 + const loadingEl = document.getElementById('loadingMore'); + if (loadingEl) loadingEl.remove(); + + // 重新渲染邮件列表 + renderEmailList(currentEmails); + + // 更新邮件数量 + document.getElementById('emailCount').textContent = `(${currentEmails.length})`; + + // 更新缓存 + if (currentAccount && !isTempEmailGroup) { + const cacheKey = `${currentAccount}_${currentFolder}`; + if (emailListCache[cacheKey]) { + emailListCache[cacheKey].emails = currentEmails; + emailListCache[cacheKey].has_more = hasMoreEmails; + emailListCache[cacheKey].skip = currentSkip; + } + } + } else { + hasMoreEmails = false; + // 显示"没有更多邮件" + const loadingEl = document.getElementById('loadingMore'); + if (loadingEl) { + loadingEl.innerHTML = '
没有更多邮件了
'; + } + } + } catch (error) { + const loadingEl = document.getElementById('loadingMore'); + if (loadingEl) loadingEl.remove(); + showToast('加载失败', 'error'); + } finally { + isLoadingMore = false; + // 启用按钮 + if (refreshBtn) { + refreshBtn.disabled = false; + } + folderTabs.forEach(tab => tab.disabled = false); + } + } + + // 切换文件夹(不触发查询) + function switchFolder(folder) { + if (currentFolder === folder) return; + + currentFolder = folder; + + // 更新按钮状态 + document.querySelectorAll('.folder-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.folder === folder); + }); + + const cacheKey = `${currentAccount}_${folder}`; + + // 检查是否有缓存 + if (emailListCache[cacheKey]) { + const cache = emailListCache[cacheKey]; + currentEmails = cache.emails; + hasMoreEmails = cache.has_more; + currentSkip = cache.skip; + currentMethod = cache.method || 'graph'; + + // 恢复 UI + const methodTag = document.getElementById('methodTag'); + methodTag.textContent = currentMethod; + methodTag.style.display = 'inline'; + document.getElementById('emailCount').textContent = `(${currentEmails.length})`; + + renderEmailList(currentEmails); + } else { + // 清空邮件列表,显示提示 + document.getElementById('emailList').innerHTML = ` +
+
📬
+
点击"获取邮件"按钮获取${folder === 'inbox' ? '收件箱' : '垃圾邮件'}
+
+ `; + document.getElementById('emailCount').textContent = ''; + document.getElementById('methodTag').style.display = 'none'; + + // 重置分页状态 + currentEmails = []; + currentSkip = 0; + hasMoreEmails = true; + } + } + + // 选择自定义颜色(颜色选择器) + function selectCustomColor(color) { + selectedColor = color; + document.getElementById('customColorHex').value = color; + // 取消预设颜色的选中状态 + document.querySelectorAll('.color-option').forEach(o => o.classList.remove('selected')); + } + + // 选择自定义颜色(十六进制输入) + function selectCustomColorHex(value) { + // 验证十六进制颜色格式 + const hexPattern = /^#[0-9A-Fa-f]{6}$/; + if (hexPattern.test(value)) { + selectedColor = value; + document.getElementById('customColorInput').value = value; + // 取消预设颜色的选中状态 + document.querySelectorAll('.color-option').forEach(o => o.classList.remove('selected')); + } else { + showToast('请输入有效的十六进制颜色(如 #FF5500)', 'error'); + } + } + + // 显示消息提示 + function showToast(message, type = 'info', errorDetail = null) { + const toast = document.getElementById('toast'); + toast.innerHTML = ''; + + // Message span + const messageSpan = document.createElement('span'); + messageSpan.textContent = message; + toast.appendChild(messageSpan); + + if (errorDetail && type === 'error') { + const detailLink = document.createElement('a'); + detailLink.href = 'javascript:void(0)'; + detailLink.textContent = ' [详情]'; + detailLink.style.color = '#ffdddd'; + detailLink.style.textDecoration = 'underline'; + detailLink.style.marginLeft = '8px'; + detailLink.onclick = function (e) { + e.stopPropagation(); + showErrorDetailModal(errorDetail); + }; + toast.appendChild(detailLink); + + // Ensure the toast remains visible long enough for the user to click the link + clearTimeout(toast.timer); + toast.timer = setTimeout(() => { + toast.className = 'toast'; + }, 8000); // 8 seconds for errors with details + } else { + clearTimeout(toast.timer); + toast.timer = setTimeout(() => { + toast.className = 'toast'; + }, 3000); // 3 seconds for regular messages + } + toast.className = 'toast show ' + type; + } + + // 显示刷新错误信息 + function showRefreshError(accountId, errorMessage, accountEmail) { + document.getElementById('refreshErrorModal').classList.add('show'); + document.getElementById('refreshErrorEmail').textContent = `账号:${accountEmail || '未知'}`; + document.getElementById('refreshErrorMessage').textContent = errorMessage; + document.getElementById('editAccountFromErrorBtn').onclick = function () { + hideRefreshErrorModal(); + showEditAccountModal(accountId); + }; + } + + // 隐藏刷新错误模态框 + function hideRefreshErrorModal() { + document.getElementById('refreshErrorModal').classList.remove('show'); + } + + // ==================== 统一错误处理相关 ==================== + + // 显示统一错误详情模态框 + function showErrorDetailModal(error) { + document.getElementById('errorDetailModal').classList.add('show'); + document.getElementById('errorModalUserMessage').textContent = error.message || '发生未知错误'; + document.getElementById('errorModalCode').textContent = error.code || '-'; + document.getElementById('errorModalType').textContent = error.type || '-'; + document.getElementById('errorModalStatus').textContent = error.status || '-'; + document.getElementById('errorModalTraceId').textContent = error.trace_id || '-'; + + const detailsEl = document.getElementById('errorModalDetails'); + const detailsContainer = document.getElementById('errorModalDetailsContainer'); + const toggleBtn = document.getElementById('toggleTraceBtn'); + + detailsEl.textContent = error.details || '暂无详细技术堆栈信息'; + + // 重置堆栈显示状态 + detailsContainer.style.display = 'none'; + toggleBtn.textContent = '显示堆栈/细节'; + } + + // 隐藏统一错误详情模态框 + function hideErrorDetailModal() { + document.getElementById('errorDetailModal').classList.remove('show'); + } + + // 邮件获取失败详情弹框 + function showEmailFetchErrorModal(details) { + if (!details) return; + + const methodNames = { + 'graph': 'Graph API', + 'imap_new': 'IMAP(新服务器)', + 'imap_old': 'IMAP(旧服务器)' + }; + + function translateError(err) { + if (!err) return '未知错误'; + // err 可能是 string 或 object + if (typeof err === 'string') return err; + + const code = err.code || ''; + const details = typeof err.details === 'string' ? err.details : JSON.stringify(err.details || ''); + const msg = err.message || ''; + + // 翻译常见错误 + if (code === 'GRAPH_TOKEN_EXCEPTION' && details.includes('ProxyError')) { + return '代理连接失败:无法连接到代理服务器,请检查代理地址是否正确以及代理是否在运行'; + } + if (code === 'GRAPH_TOKEN_FAILED' || code === 'IMAP_TOKEN_FAILED') { + if (details.includes('invalid_grant')) { + return 'Token 已失效或权限不足:请重新授权登录或更换 refresh_token'; + } + if (details.includes('invalid_client')) { + return 'Client ID 无效:请检查 client_id 配置是否正确'; + } + return `令牌获取失败:${msg}`; + } + if (code === 'EMAIL_FETCH_FAILED') { + return `获取邮件失败:${msg}`; + } + if (code === 'IMAP_CONNECTION_FAILED') { + return 'IMAP 连接失败:无法连接到邮件服务器'; + } + return msg || details || '未知错误'; + } + + let html = ''; + const methods = ['graph', 'imap_new', 'imap_old']; + methods.forEach(method => { + const err = details[method]; + if (err !== undefined) { + const name = methodNames[method] || method; + const reason = translateError(err); + const codeText = (err && typeof err === 'object') ? (err.code || '-') : '-'; + html += ` +
+
${name}
+
${reason}
+
错误代码: ${codeText}
+
+ `; + } + }); + + if (!html) { + html = '
无详细错误信息
'; + } + + document.getElementById('emailFetchErrorContent').innerHTML = html; + document.getElementById('emailFetchErrorModal').classList.add('show'); + } + + function hideEmailFetchErrorModal() { + document.getElementById('emailFetchErrorModal').classList.remove('show'); + } + + // 切换堆栈信息的显示/隐藏 + function toggleStackTrace() { + const container = document.getElementById('errorModalDetailsContainer'); + const btn = document.getElementById('toggleTraceBtn'); + + if (container.style.display === 'none') { + container.style.display = 'block'; + btn.textContent = '隐藏堆栈/细节'; + } else { + container.style.display = 'none'; + btn.textContent = '显示堆栈/细节'; + } + } + + // 复制错误详情到剪贴板 + function copyErrorDetails() { + const userMessage = document.getElementById('errorModalUserMessage').textContent; + const details = document.getElementById('errorModalDetails').textContent; + const code = document.getElementById('errorModalCode').textContent; + const type = document.getElementById('errorModalType').textContent; + const status = document.getElementById('errorModalStatus').textContent; + const traceId = document.getElementById('errorModalTraceId').textContent; + + const fullErrorText = ` +【用户错误信息】 +${userMessage} + +【错误详情】 +Code: ${code} +Type: ${type} +Status: ${status} +Trace ID: ${traceId} + +【技术堆栈/细节】 +${details} + `.trim(); + + navigator.clipboard.writeText(fullErrorText).then(() => { + showToast('错误详情已复制', 'success'); + }).catch(() => { + // 降级方案 + const textarea = document.createElement('textarea'); + textarea.value = fullErrorText; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + showToast('错误详情已复制', 'success'); + }); + } + + // 统一处理 API 响应错误 + function handleApiError(data, defaultMessage = '请求失败') { + if (!data.success) { + // 检查是否是统一错误格式 + if (data.error && data.error.message) { + const error = data.error; + // 使用后端提供的 message 作为用户友好信息 + const userMessage = error.message; + + // 调用 showToast 携带完整的错误对象 + showToast(userMessage, 'error', error); + } else { + // 兼容旧的或非标准错误格式 + const errorMessage = data.error || defaultMessage; + showToast(errorMessage, 'error'); + } + return true; + } + return false; + } + + function escapeJs(str) { + if (!str) return ''; + return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r'); + } + + // ==================== 工具函数 ==================== + + // HTML 转义 + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // 格式化日期 + function formatDate(dateStr) { + if (!dateStr) return ''; + try { + const date = new Date(dateStr); + if (isNaN(date.getTime())) return dateStr; + + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + + if (isToday) { + return '今天 ' + date.toLocaleTimeString('zh-CN', { + timeZone: 'Asia/Shanghai', + hour: '2-digit', + minute: '2-digit' + }); + } else { + return date.toLocaleDateString('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: 'long', + day: 'numeric' + }) + ' ' + date.toLocaleTimeString('zh-CN', { + timeZone: 'Asia/Shanghai', + hour: '2-digit', + minute: '2-digit' + }); + } + } catch (e) { + return dateStr; + } + } + + // ==================== OAuth Refresh Token 相关 ==================== + + // 显示获取 Refresh Token 模态框 + async function showGetRefreshTokenModal() { + document.getElementById('getRefreshTokenModal').classList.add('show'); + + // 重置表单 + document.getElementById('redirectUrlInput').value = ''; + document.getElementById('refreshTokenResult').style.display = 'none'; + document.getElementById('refreshTokenOutput').value = ''; + + // 重置按钮状态 + const btn = document.getElementById('exchangeTokenBtn'); + btn.disabled = false; + btn.textContent = '换取 Token'; + btn.style.display = ''; + + // 获取授权 URL + try { + const response = await fetch('/api/oauth/auth-url'); + const data = await response.json(); + + if (data.success) { + document.getElementById('authUrlInput').value = data.auth_url; + } else { + showToast('获取授权链接失败', 'error'); + } + } catch (error) { + showToast('获取授权链接失败', 'error'); + } + } + + // 隐藏获取 Refresh Token 模态框 + function hideGetRefreshTokenModal() { + document.getElementById('getRefreshTokenModal').classList.remove('show'); + } + + // 复制授权 URL + function copyAuthUrl() { + const input = document.getElementById('authUrlInput'); + input.select(); + document.execCommand('copy'); + showToast('授权链接已复制到剪贴板', 'success'); + } + + // 打开授权 URL + function openAuthUrl() { + const url = document.getElementById('authUrlInput').value; + if (url) { + window.open(url, '_blank'); + showToast('已在新窗口打开授权页面', 'info'); + } + } + + // 换取 Token + async function exchangeToken() { + const redirectUrl = document.getElementById('redirectUrlInput').value.trim(); + + if (!redirectUrl) { + showToast('请先粘贴授权后的完整 URL', 'error'); + return; + } + + const btn = document.getElementById('exchangeTokenBtn'); + btn.disabled = true; + btn.textContent = '⏳ 换取中...'; + + try { + const response = await fetch('/api/oauth/exchange-token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + redirected_url: redirectUrl + }) + }); + + const data = await response.json(); + + if (data.success) { + // 生成完整的导入格式 + const importFormat = `your@outlook.com----yourpassword----${data.client_id}----${data.refresh_token}`; + + // 显示结果 + document.getElementById('refreshTokenOutput').value = importFormat; + document.getElementById('refreshTokenResult').style.display = 'block'; + + showToast('✅ Refresh Token 获取成功!', 'success'); + + // 重置按钮状态(不隐藏,允许重复使用) + btn.disabled = false; + btn.textContent = '换取 Token'; + } else { + handleApiError(data, '换取 Token 失败'); + btn.disabled = false; + btn.textContent = '换取 Token'; + } + } catch (error) { + showToast('换取 Token 失败: ' + error.message, 'error'); + btn.disabled = false; + btn.textContent = '换取 Token'; + } + } + + // ==================== 设置相关 ==================== + + // 显示设置模态框 + async function showSettingsModal() { + document.getElementById('settingsModal').classList.add('show'); + await loadSettings(); + } + + // 隐藏设置模态框 + function hideSettingsModal() { + document.getElementById('settingsModal').classList.remove('show'); + // 清空密码输入框 + document.getElementById('settingsPassword').value = ''; + } + + // 加载设置 + async function loadSettings() { + try { + const response = await fetch('/api/settings'); + const data = await response.json(); + + if (data.success) { + // 密码不显示,只显示 API Key + document.getElementById('settingsApiKey').value = data.settings.gptmail_api_key || ''; + // 密码框留空 + document.getElementById('settingsPassword').value = ''; + + // 加载刷新配置 + document.getElementById('refreshIntervalDays').value = data.settings.refresh_interval_days || '30'; + document.getElementById('refreshDelaySeconds').value = data.settings.refresh_delay_seconds || '5'; + document.getElementById('refreshCron').value = data.settings.refresh_cron || '0 2 * * *'; + + // 设置定时刷新开关 + const enableScheduled = data.settings.enable_scheduled_refresh !== 'false'; + document.getElementById('enableScheduledRefresh').checked = enableScheduled; + + // 设置刷新策略单选框 + const useCron = data.settings.use_cron_schedule === 'true'; + document.querySelector('input[name="refreshStrategy"][value="' + (useCron ? 'cron' : 'days') + '"]').checked = true; + toggleRefreshStrategy(); + } + } catch (error) { + showToast('加载设置失败', 'error'); + } + } + + // 切换刷新策略 + function toggleRefreshStrategy() { + const strategy = document.querySelector('input[name="refreshStrategy"]:checked').value; + document.getElementById('daysStrategyContainer').style.display = strategy === 'days' ? 'block' : 'none'; + document.getElementById('cronStrategyContainer').style.display = strategy === 'cron' ? 'block' : 'none'; + } + + // 选择 Cron 样例 + async function selectCronExample(cronExpr) { + document.getElementById('refreshCron').value = cronExpr; + await validateCronExpression(); + } + + // 验证 Cron 表达式 + async function validateCronExpression() { + const cronExpr = document.getElementById('refreshCron').value.trim(); + const resultEl = document.getElementById('cronValidationResult'); + + if (!cronExpr) { + resultEl.innerHTML = ''; + return; + } + + try { + const response = await fetch('/api/settings/validate-cron', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cron_expression: cronExpr }) + }); + + const data = await response.json(); + + if (data.success && data.valid) { + const nextRun = new Date(data.next_run).toLocaleString('zh-CN'); + resultEl.innerHTML = ` +
+ ✓ 表达式有效
+ 下次执行: ${nextRun} +
+ `; + } else { + resultEl.innerHTML = ` +
+ ✗ ${data.error && data.error.message ? data.error.message : (data.error || '表达式无效')} +
+ `; + } + } catch (error) { + resultEl.innerHTML = ` +
+ ✗ 验证失败: ${error.message} +
+ `; + } + } + + // 保存设置 + async function saveSettings() { + const password = document.getElementById('settingsPassword').value; + const apiKey = document.getElementById('settingsApiKey').value.trim(); + const refreshDays = document.getElementById('refreshIntervalDays').value; + const refreshDelay = document.getElementById('refreshDelaySeconds').value; + const refreshCron = document.getElementById('refreshCron').value.trim(); + const strategy = document.querySelector('input[name="refreshStrategy"]:checked').value; + const enableScheduled = document.getElementById('enableScheduledRefresh').checked; + + const settings = {}; + + // 只有输入了密码才更新密码 + if (password) { + settings.login_password = password; + } + + // API Key 可以为空(清除) + settings.gptmail_api_key = apiKey; + + // 刷新配置 + const days = parseInt(refreshDays); + const delay = parseInt(refreshDelay); + + if (isNaN(days) || days < 1 || days > 90) { + showToast('刷新周期必须在 1-90 天之间', 'error'); + return; + } + + if (isNaN(delay) || delay < 0 || delay > 60) { + showToast('刷新间隔必须在 0-60 秒之间', 'error'); + return; + } + + settings.refresh_interval_days = days; + settings.refresh_delay_seconds = delay; + settings.use_cron_schedule = strategy === 'cron'; + settings.enable_scheduled_refresh = enableScheduled; + + if (strategy === 'cron') { + if (!refreshCron) { + showToast('请输入 Cron 表达式', 'error'); + return; + } + settings.refresh_cron = refreshCron; + } + + try { + const response = await fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings) + }); + + const data = await response.json(); + + if (data.success) { + showToast('设置已保存,重启应用后生效', 'success'); + hideSettingsModal(); + } else { + handleApiError(data, '保存设置失败'); + } + } catch (error) { + showToast('保存设置失败', 'error'); + } + } + + // ==================== 工具函数 ==================== + + // 相对时间格式化 + function formatRelativeTime(timestamp) { + if (!timestamp) return '从未刷新'; + + const now = new Date(); + // 如果时间戳不包含时区信息,假定为 UTC 时间并添加 Z + let dateStr = timestamp; + if (typeof dateStr === 'string' && !dateStr.includes('Z') && !dateStr.includes('+') && !dateStr.includes('-', 10)) { + dateStr = dateStr + 'Z'; + } + const past = new Date(dateStr); + const diffMs = now - past; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return '刚刚'; + if (diffMins < 60) return `${diffMins} 分钟前`; + if (diffHours < 24) return `${diffHours} 小时前`; + if (diffDays < 30) return `${diffDays} 天前`; + return `${Math.floor(diffDays / 30)} 月前`; + } + + // ==================== Token 刷新管理 ==================== + + // 显示刷新模态框 + async function showRefreshModal() { + document.getElementById('refreshModal').classList.add('show'); + // 加载统计数据 + await loadRefreshStats(); + // 自动加载失败列表(如果有失败记录) + await autoLoadFailedListIfNeeded(); + } + + // 自动加载失败列表(如果有失败记录) + async function autoLoadFailedListIfNeeded() { + try { + const response = await fetch('/api/accounts/refresh-logs/failed'); + const data = await response.json(); + + if (data.success && data.logs && data.logs.length > 0) { + // 有失败记录,自动显示失败列表 + showFailedListFromData(data.logs.map(log => ({ + id: log.account_id, + email: log.account_email, + error: log.error_message + }))); + } + } catch (error) { + console.error('自动加载失败列表失败:', error); + } + } + + // 隐藏刷新模态框 + function hideRefreshModal() { + const modal = document.getElementById('refreshModal'); + modal.classList.remove('show'); + + // 确保所有内容都被隐藏,防止残留 + const progress = document.getElementById('refreshProgress'); + if (progress) { + progress.style.display = 'none'; + } + const failedList = document.getElementById('failedListContainer'); + if (failedList) { + failedList.style.display = 'none'; + } + const logsContainer = document.getElementById('refreshLogsContainer'); + if (logsContainer) { + logsContainer.style.display = 'none'; + } + + // 重置按钮状态 + const refreshAllBtn = document.getElementById('refreshAllBtn'); + if (refreshAllBtn) { + refreshAllBtn.disabled = false; + refreshAllBtn.textContent = '🔄 全量刷新'; + } + + const retryFailedBtn = document.getElementById('retryFailedBtn'); + if (retryFailedBtn) { + retryFailedBtn.disabled = false; + retryFailedBtn.textContent = '🔁 重试失败'; + } + } + + // 加载刷新统计 + async function loadRefreshStats() { + try { + const response = await fetch('/api/accounts/refresh-stats'); + const data = await response.json(); + + console.log('刷新统计数据:', data); + + if (data.success) { + const stats = data.stats; + + // 优先使用保存的本地刷新时间 + if (window.lastRefreshTime && window.lastRefreshTime instanceof Date) { + document.getElementById('lastRefreshTime').textContent = formatDateTime(window.lastRefreshTime.toISOString()); + } else if (stats.last_refresh_time) { + document.getElementById('lastRefreshTime').textContent = formatDateTime(stats.last_refresh_time); + } else { + document.getElementById('lastRefreshTime').textContent = '-'; + } + + document.getElementById('totalRefreshCount').textContent = stats.total; + document.getElementById('successRefreshCount').textContent = stats.success_count; + document.getElementById('failedRefreshCount').textContent = stats.failed_count; + + console.log('统计数据已更新到页面'); + } + } catch (error) { + console.error('加载刷新统计失败:', error); + } + } + + // 全量刷新所有账号 + async function refreshAllAccounts() { + const btn = document.getElementById('refreshAllBtn'); + const progress = document.getElementById('refreshProgress'); + const progressText = document.getElementById('refreshProgressText'); + + if (btn.disabled) return; + + if (!confirm('确定要刷新所有账号的 Token 吗?')) { + return; + } + + btn.disabled = true; + btn.textContent = '刷新中...'; + progress.style.display = 'block'; + progressText.innerHTML = '正在初始化...'; + + try { + const eventSource = new EventSource('/api/accounts/trigger-scheduled-refresh?force=true'); + let totalCount = 0; + let successCount = 0; + let failedCount = 0; + + eventSource.onmessage = function (event) { + try { + const data = JSON.parse(event.data); + + if (data.type === 'start') { + totalCount = data.total; + const delayInfo = data.delay_seconds > 0 ? `(间隔 ${data.delay_seconds} 秒)` : ''; + progressText.innerHTML = `总共 ${totalCount} 个账号${delayInfo},准备开始刷新...`; + // 初始化统计 + document.getElementById('totalRefreshCount').textContent = totalCount; + document.getElementById('successRefreshCount').textContent = '0'; + document.getElementById('failedRefreshCount').textContent = '0'; + } else if (data.type === 'progress') { + successCount = data.success_count; + failedCount = data.failed_count; + // 实时更新统计 + document.getElementById('successRefreshCount').textContent = successCount; + document.getElementById('failedRefreshCount').textContent = failedCount; + progressText.innerHTML = ` + 正在处理: ${data.email}
+ 进度: ${data.current}/${data.total} | + 成功: ${successCount} | + 失败: ${failedCount} + `; + } else if (data.type === 'delay') { + progressText.innerHTML += `
等待 ${data.seconds} 秒后继续...`; + } else if (data.type === 'complete') { + eventSource.close(); + progress.style.display = 'none'; + btn.disabled = false; + btn.textContent = '🔄 全量刷新'; + + // 直接更新统计数据,使用本地时间 + const now = new Date(); + window.lastRefreshTime = now; // 保存刷新时间 + document.getElementById('lastRefreshTime').textContent = '刚刚'; + document.getElementById('totalRefreshCount').textContent = data.total; + document.getElementById('successRefreshCount').textContent = data.success_count; + document.getElementById('failedRefreshCount').textContent = data.failed_count; + + showToast(`刷新完成!成功: ${data.success_count}, 失败: ${data.failed_count}`, + data.failed_count > 0 ? 'warning' : 'success'); + + // 如果有失败的,显示失败列表 + if (data.failed_count > 0) { + showFailedListFromData(data.failed_list); + } + + // 刷新账号列表以更新刷新时间 + if (currentGroupId) { + loadAccountsByGroup(currentGroupId, true); + } + } + } catch (e) { + console.error('解析进度数据失败:', e); + } + }; + + eventSource.onerror = function (error) { + console.error('EventSource 错误:', error); + eventSource.close(); + progress.style.display = 'none'; + btn.disabled = false; + btn.textContent = '🔄 全量刷新'; + showToast('刷新过程中出现错误', 'error'); + }; + + } catch (error) { + progress.style.display = 'none'; + btn.disabled = false; + btn.textContent = '🔄 全量刷新'; + showToast('刷新请求失败', 'error'); + } + } + + // 重试失败的账号 + async function retryFailedAccounts() { + const btn = document.getElementById('retryFailedBtn'); + const progress = document.getElementById('refreshProgress'); + const progressText = document.getElementById('refreshProgressText'); + + if (btn.disabled) return; + + btn.disabled = true; + btn.textContent = '重试中...'; + progress.style.display = 'block'; + progressText.textContent = '正在重试失败的账号...'; + + try { + const response = await fetch('/api/accounts/refresh-failed', { + method: 'POST' + }); + const data = await response.json(); + + progress.style.display = 'none'; + btn.disabled = false; + btn.textContent = '🔁 重试失败'; + + if (data.success) { + if (data.total === 0) { + showToast('没有需要重试的失败账号', 'info'); + } else { + showToast(`重试完成!成功: ${data.success_count}, 失败: ${data.failed_count}`, + data.failed_count > 0 ? 'warning' : 'success'); + + // 刷新统计 + loadRefreshStats(); + + // 如果还有失败的,显示失败列表 + if (data.failed_count > 0) { + showFailedListFromData(data.failed_list); + } else { + hideFailedList(); + } + } + } else { + handleApiError(data, '重试失败'); + } + } catch (error) { + progress.style.display = 'none'; + btn.disabled = false; + btn.textContent = '🔁 重试失败'; + showToast('重试请求失败', 'error'); + } + } + + // 单个账号重试 + async function retrySingleAccount(accountId, accountEmail) { + try { + const response = await fetch(`/api/accounts/${accountId}/retry-refresh`, { + method: 'POST' + }); + const data = await response.json(); + + if (data.success) { + showToast(`${accountEmail} 刷新成功`, 'success'); + loadRefreshStats(); + + // 刷新失败列表 + loadFailedLogs(); + } else { + handleApiError(data, `${accountEmail} 刷新失败`); + } + } catch (error) { + handleApiError({ success: false, error: { message: '刷新请求失败', details: error.message, code: 'NETWORK_ERROR', type: 'Frontend' } }); + } + } + + // 显示失败列表(从数据) + function showFailedListFromData(failedList) { + const container = document.getElementById('failedListContainer'); + const listEl = document.getElementById('failedList'); + + // 隐藏其他列表 + hideRefreshLogs(); + + if (!failedList || failedList.length === 0) { + container.style.display = 'none'; + return; + } + + let html = ''; + failedList.forEach(item => { + html += ` +
+
+
${escapeHtml(item.email)}
+
${escapeHtml(item.error || '未知错误')}
+
+ +
+ `; + }); + + listEl.innerHTML = html; + container.style.display = 'block'; + } + + // 隐藏失败列表 + function hideFailedList() { + document.getElementById('failedListContainer').style.display = 'none'; + } + + // 加载失败日志 + async function loadFailedLogs() { + const container = document.getElementById('failedListContainer'); + const listEl = document.getElementById('failedList'); + + hideRefreshLogs(); + + try { + const response = await fetch('/api/accounts/refresh-logs/failed'); + const data = await response.json(); + + if (data.success) { + if (data.logs.length === 0) { + listEl.innerHTML = '
暂无失败状态的邮箱
'; + } else { + let html = ''; + data.logs.forEach(log => { + html += ` +
+
+
${escapeHtml(log.account_email)}
+
${escapeHtml(log.error_message || '未知错误')}
+
最后刷新: ${formatDateTime(log.created_at)}
+
+ +
+ `; + }); + listEl.innerHTML = html; + } + container.style.display = 'block'; + } + } catch (error) { + showToast('加载失败邮箱列表失败', 'error'); + } + } + + // 加载刷新历史 + async function loadRefreshLogs() { + const container = document.getElementById('refreshLogsContainer'); + const listEl = document.getElementById('refreshLogsList'); + + try { + const response = await fetch('/api/accounts/refresh-logs?limit=1000'); + const data = await response.json(); + + if (data.success) { + if (data.logs.length === 0) { + listEl.innerHTML = '
暂无全量刷新历史
'; + } else { + listEl.innerHTML = `
近半年刷新历史(共 ${data.logs.length} 条)
`; + let html = ''; + data.logs.forEach(log => { + const statusColor = log.status === 'success' ? '#28a745' : '#dc3545'; + const statusText = log.status === 'success' ? '成功' : '失败'; + const typeText = log.refresh_type === 'manual' ? '手动' : '自动'; + const typeColor = log.refresh_type === 'manual' ? '#007bff' : '#28a745'; + const typeBgColor = log.refresh_type === 'manual' ? '#e7f3ff' : '#e8f5e9'; + + html += ` +
+
+
${escapeHtml(log.account_email)}
+
+ ${typeText} + ${statusText} +
+
+
${formatDateTime(log.created_at)}
+ ${log.error_message ? `
${escapeHtml(log.error_message)}
` : ''} +
+ `; + }); + listEl.innerHTML += html; + } + container.style.display = 'block'; + } + } catch (error) { + showToast('加载刷新历史失败', 'error'); + } + } + + // 隐藏刷新历史 + function hideRefreshLogs() { + document.getElementById('refreshLogsContainer').style.display = 'none'; + } + + // 格式化日期时间 + function formatDateTime(dateStr) { + if (!dateStr) return '-'; + + let date; + if (dateStr instanceof Date) { + date = dateStr; + } else { + // 如果字符串不包含时区信息,假定为 UTC 时间 + if (!dateStr.includes('Z') && !dateStr.includes('+') && !dateStr.includes('-', 10)) { + dateStr = dateStr + 'Z'; + } + date = new Date(dateStr); + } + + const now = new Date(); + const diff = now - date; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return '刚刚'; + if (minutes < 60) return `${minutes}分钟前`; + if (hours < 24) return `${hours}小时前`; + if (days < 7) return `${days}天前`; + + return date.toLocaleString('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + } + + // 统一关闭所有模态框的函数 (修复 bug:防止模态框意外残留) + function closeAllModals() { + hideAddGroupModal(); + hideAddAccountModal(); + hideEditAccountModal(); + hideExportModal(); + hideSettingsModal(); + hideRefreshModal(); + hideRefreshErrorModal(); + hideErrorDetailModal(); + hideGetRefreshTokenModal(); + closeFullscreenEmail(); + } + + // HTML 转义 + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // 键盘快捷键 + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { + hideAddGroupModal(); + hideAddAccountModal(); + hideEditAccountModal(); + hideExportModal(); + hideSettingsModal(); + hideRefreshModal(); + hideRefreshErrorModal(); + hideErrorDetailModal(); + hideGetRefreshTokenModal(); + closeFullscreenEmail(); + } + }); + // ==================== 标签管理 ==================== + + let allTags = []; + + // 显示标签管理模态框 + async function showTagManagementModal() { + document.getElementById('tagManagementModal').classList.add('show'); + await loadTags(); + } + + // 隐藏标签管理模态框 + function hideTagManagementModal() { + document.getElementById('tagManagementModal').classList.remove('show'); + } + + // 加载标签列表 + async function loadTags() { + try { + const response = await fetch('/api/tags'); + const data = await response.json(); + if (data.success) { + allTags = data.tags; + renderTagList(); + updateTagFilter(); // Update Filter Dropdown + } + } catch (error) { + showToast('加载标签失败', 'error'); + } + } + + // 更新标签筛选下拉框 + function updateTagFilter() { + const container = document.getElementById('tagFilterContainer'); + if (!container) return; + + if (allTags.length === 0) { + container.style.display = 'none'; + return; + } + + container.style.display = 'flex'; + + let html = ''; + allTags.forEach(tag => { + html += ` + + `; + }); + container.innerHTML = html; + /* Old dropdown code removed */ + + + } + + // 渲染标签列表 + function renderTagList() { + const listEl = document.getElementById('tagList'); + if (!allTags.length) { + listEl.innerHTML = '
暂无标签
'; + return; + } + + let html = ''; + allTags.forEach(tag => { + html += ` +
+
+ ${escapeHtml(tag.name)} +
+ +
+ `; + }); + listEl.innerHTML = html; + } + + // 创建标签 + async function createTag() { + const nameInput = document.getElementById('newTagName'); + const colorInput = document.getElementById('newTagColor'); + const name = nameInput.value.trim(); + const color = colorInput.value; + + if (!name) { + showToast('请输入标签名称', 'error'); + return; + } + + try { + const response = await fetch('/api/tags', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, color }) + }); + const data = await response.json(); + + if (data.success) { + nameInput.value = ''; + showToast('标签创建成功', 'success'); + await loadTags(); + // 刷新账号列表以重新加载标签(如果是在查看列表时添加标签,可能不需要立即刷新列表,但为了保持一致性可以刷新) + // 但通常添加标签不影响当前列表显示,除非是给账号打标 + } else { + showToast(data.error || '创建失败', 'error'); + } + } catch (error) { + showToast('创建标签失败', 'error'); + } + } + + // 删除标签 + async function deleteTag(id) { + if (!confirm('确定要删除这个标签吗?')) return; + + try { + const response = await fetch(`/api/tags/${id}`, { method: 'DELETE' }); + const data = await response.json(); + + if (data.success) { + showToast('标签已删除', 'success'); + await loadTags(); + // 刷新账号列表以更新标签显示 + if (currentGroupId) { + loadAccountsByGroup(currentGroupId, true); + } + } else { + showToast(data.error || '删除失败', 'error'); + } + } catch (error) { + showToast('删除标签失败', 'error'); + } + } + + // ==================== 批量操作 ==================== + + // 更新批量操作栏状态 + function updateBatchActionBar() { + const checked = document.querySelectorAll('.account-select-checkbox:checked'); + const bar = document.getElementById('batchActionBar'); + const countSpan = document.getElementById('selectedCount'); + + if (checked.length > 0) { + bar.style.display = 'flex'; + countSpan.textContent = `已选 ${checked.length} 项`; + } else { + bar.style.display = 'none'; + } + } + + let batchActionType = ''; // 'add' or 'remove' + + // 显示批量打标模态框 + async function showBatchTagModal(type) { + batchActionType = type; + document.getElementById('batchTagTitle').textContent = type === 'add' ? '批量添加标签' : '批量移除标签'; + document.getElementById('batchTagModal').classList.add('show'); + + // 加载标签选项 + await loadTagsForSelect(); + } + + function hideBatchTagModal() { + document.getElementById('batchTagModal').classList.remove('show'); + } + + // 加载标签到下拉框 + async function loadTagsForSelect() { + const select = document.getElementById('batchTagSelect'); + select.innerHTML = ''; + + try { + const response = await fetch('/api/tags'); + const data = await response.json(); + if (data.success) { + let html = ''; + data.tags.forEach(tag => { + html += ``; + }); + select.innerHTML = html; + } + } catch (error) { + select.innerHTML = ''; + } + } + + // 确认批量打标 + async function confirmBatchTag() { + const tagId = document.getElementById('batchTagSelect').value; + if (!tagId) { + showToast('请选择标签', 'error'); + return; + } + + const checked = document.querySelectorAll('.account-select-checkbox:checked'); + const accountIds = Array.from(checked).map(cb => parseInt(cb.value)); + + if (accountIds.length === 0) return; + + try { + const response = await fetch('/api/accounts/tags', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + account_ids: accountIds, + tag_id: parseInt(tagId), + action: batchActionType + }) + }); + + const data = await response.json(); + if (data.success) { + showToast(data.message, 'success'); + hideBatchTagModal(); + // 刷新列表 + if (currentGroupId) { + loadAccountsByGroup(currentGroupId, true); + } + // 隐藏操作栏 + document.querySelectorAll('.account-select-checkbox').forEach(cb => cb.checked = false); + updateBatchActionBar(); + } else { + showToast(data.error || '操作失败', 'error'); + } + } catch (error) { + showToast('请求失败', 'error'); + } + } + + // ==================== 批量移动分组 ==================== + + // 显示批量移动分组模态框 + async function showBatchMoveGroupModal() { + document.getElementById('batchMoveGroupModal').classList.add('show'); + await loadGroupsForBatchMove(); + } + + function hideBatchMoveGroupModal() { + document.getElementById('batchMoveGroupModal').classList.remove('show'); + } + + // 加载分组到下拉框 + async function loadGroupsForBatchMove() { + const select = document.getElementById('batchMoveGroupSelect'); + select.innerHTML = ''; + + try { + const response = await fetch('/api/groups'); + const data = await response.json(); + if (data.success) { + let html = ''; + data.groups.filter(g => !g.is_system).forEach(group => { + html += ``; + }); + select.innerHTML = html; + } + } catch (error) { + select.innerHTML = ''; + } + } + + // 确认批量移动分组 + async function confirmBatchMoveGroup() { + const groupId = document.getElementById('batchMoveGroupSelect').value; + if (!groupId) { + showToast('请选择目标分组', 'error'); + return; + } + + const checked = document.querySelectorAll('.account-select-checkbox:checked'); + const accountIds = Array.from(checked).map(cb => parseInt(cb.value)); + + if (accountIds.length === 0) return; + + try { + const response = await fetch('/api/accounts/batch-update-group', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + account_ids: accountIds, + group_id: parseInt(groupId) + }) + }); + + const data = await response.json(); + if (data.success) { + showToast(data.message, 'success'); + hideBatchMoveGroupModal(); + // 刷新分组列表 + loadGroups(); + // 刷新当前分组的邮箱列表 + if (currentGroupId) { + delete accountsCache[currentGroupId]; + loadAccountsByGroup(currentGroupId, true); + } + // 清除选择 + document.querySelectorAll('.account-select-checkbox').forEach(cb => cb.checked = false); + updateBatchActionBar(); + } else { + showToast(data.error || '操作失败', 'error'); + } + } catch (error) { + showToast('请求失败', 'error'); + } + } diff --git a/templates/index.html b/templates/index.html index 448bfe5..7bd688f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -7,1300 +7,7 @@ Outlook 邮件管理 - + @@ -1480,4175 +187,9 @@ - - + {% include 'partials/modals.html' %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - -
-
-
邮件详情
- -
-
- -
-
+ {% include 'partials/scripts.html' %} - \ No newline at end of file + diff --git a/templates/login.html b/templates/login.html index ad3ceb4..68066b8 100644 --- a/templates/login.html +++ b/templates/login.html @@ -180,7 +180,8 @@ if (data.success) { window.location.href = '/'; } else { - errorMessage.textContent = data.error || '登录失败'; + const msg = (data.error && data.error.message) ? data.error.message : (data.error || '登录失败'); + errorMessage.textContent = msg; errorMessage.classList.add('show'); loginBtn.disabled = false; loginBtn.textContent = '登 录'; @@ -201,4 +202,4 @@ }); - \ No newline at end of file + diff --git a/templates/partials/modals.html b/templates/partials/modals.html new file mode 100644 index 0000000..6592bce --- /dev/null +++ b/templates/partials/modals.html @@ -0,0 +1,644 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
邮件详情
+ +
+
+ +
+
diff --git a/templates/partials/scripts.html b/templates/partials/scripts.html new file mode 100644 index 0000000..a2e9b45 --- /dev/null +++ b/templates/partials/scripts.html @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..7fce339 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +# 使 tests 成为可导入的包,便于复用测试夹具。 + diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..c509545 Binary files /dev/null and b/tests/__pycache__/__init__.cpython-313.pyc differ diff --git a/tests/__pycache__/_import_app.cpython-313.pyc b/tests/__pycache__/_import_app.cpython-313.pyc new file mode 100644 index 0000000..654cc8e Binary files /dev/null and b/tests/__pycache__/_import_app.cpython-313.pyc differ diff --git a/tests/__pycache__/test_core_features.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_core_features.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..46aa4ff Binary files /dev/null and b/tests/__pycache__/test_core_features.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_core_features.cpython-313.pyc b/tests/__pycache__/test_core_features.cpython-313.pyc new file mode 100644 index 0000000..dc888ac Binary files /dev/null and b/tests/__pycache__/test_core_features.cpython-313.pyc differ diff --git a/tests/__pycache__/test_distributed_lock.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_distributed_lock.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..a23f48d Binary files /dev/null and b/tests/__pycache__/test_distributed_lock.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_distributed_lock.cpython-313.pyc b/tests/__pycache__/test_distributed_lock.cpython-313.pyc new file mode 100644 index 0000000..6a871e1 Binary files /dev/null and b/tests/__pycache__/test_distributed_lock.cpython-313.pyc differ diff --git a/tests/__pycache__/test_error_and_trace.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_error_and_trace.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..797914e Binary files /dev/null and b/tests/__pycache__/test_error_and_trace.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_error_and_trace.cpython-313.pyc b/tests/__pycache__/test_error_and_trace.cpython-313.pyc new file mode 100644 index 0000000..94622f7 Binary files /dev/null and b/tests/__pycache__/test_error_and_trace.cpython-313.pyc differ diff --git a/tests/__pycache__/test_masking_audit_and_import.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_masking_audit_and_import.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..0495ba0 Binary files /dev/null and b/tests/__pycache__/test_masking_audit_and_import.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_masking_audit_and_import.cpython-313.pyc b/tests/__pycache__/test_masking_audit_and_import.cpython-313.pyc new file mode 100644 index 0000000..c8c1bfe Binary files /dev/null and b/tests/__pycache__/test_masking_audit_and_import.cpython-313.pyc differ diff --git a/tests/__pycache__/test_module_boundaries.cpython-313.pyc b/tests/__pycache__/test_module_boundaries.cpython-313.pyc new file mode 100644 index 0000000..0f3af40 Binary files /dev/null and b/tests/__pycache__/test_module_boundaries.cpython-313.pyc differ diff --git a/tests/__pycache__/test_smoke_contract.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_smoke_contract.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..745ec29 Binary files /dev/null and b/tests/__pycache__/test_smoke_contract.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_smoke_contract.cpython-313.pyc b/tests/__pycache__/test_smoke_contract.cpython-313.pyc new file mode 100644 index 0000000..aef042e Binary files /dev/null and b/tests/__pycache__/test_smoke_contract.cpython-313.pyc differ diff --git a/tests/_import_app.py b/tests/_import_app.py new file mode 100644 index 0000000..e739a0f --- /dev/null +++ b/tests/_import_app.py @@ -0,0 +1,35 @@ +import importlib +import os +import sys +import tempfile +from pathlib import Path + +_TEMP_DIR = tempfile.TemporaryDirectory(prefix="outlookEmail-tests-") +_DB_PATH = Path(_TEMP_DIR.name) / "test.db" + + +def import_web_app_module(): + """ + 以“可测试”的方式导入 web_outlook_app: + - 注入必要环境变量(SECRET_KEY / DATABASE_PATH 等) + - 禁用调度器自启动,避免测试期间启动后台线程 + - 将 DB 指向临时文件,避免污染本地 data/ + """ + os.environ["SECRET_KEY"] = "test-secret-key-32bytes-minimum-0000000000000000" + os.environ["LOGIN_PASSWORD"] = "testpass123" # >= 8 + os.environ["SCHEDULER_AUTOSTART"] = "false" + os.environ["DATABASE_PATH"] = str(_DB_PATH) + + if "web_outlook_app" in sys.modules: + return sys.modules["web_outlook_app"] + + module = importlib.import_module("web_outlook_app") + + # 测试配置:禁用 CSRF(避免额外 token 依赖),开启 TESTING + module.app.config.update( + TESTING=True, + WTF_CSRF_ENABLED=False, + WTF_CSRF_CHECK_DEFAULT=False, + ) + return module + diff --git a/tests/test_core_features.py b/tests/test_core_features.py new file mode 100644 index 0000000..aa9edb8 --- /dev/null +++ b/tests/test_core_features.py @@ -0,0 +1,217 @@ +import json +import unittest +import uuid +from unittest.mock import patch + +from tests._import_app import import_web_app_module + + +class CoreFeatureTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = import_web_app_module() + cls.app = cls.module.app + + def _login(self, client, password: str = "testpass123"): + resp = client.post("/login", json={"password": password}) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + return resp + + def test_logout_revokes_session(self): + client = self.app.test_client() + self._login(client) + + resp = client.get("/logout", follow_redirects=False) + self.assertIn(resp.status_code, (301, 302)) + + resp = client.get("/api/system/health") + self.assertEqual(resp.status_code, 401) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertEqual(data.get("need_login"), True) + + def test_group_crud_and_audit_trace_id(self): + client = self.app.test_client() + self._login(client) + + unique = uuid.uuid4().hex + group_name = f"g_{unique}" + + create = client.post( + "/api/groups", + json={"name": group_name, "description": "desc", "color": "#123456", "proxy_url": ""}, + ) + self.assertEqual(create.status_code, 200) + create_data = create.get_json() + self.assertEqual(create_data.get("success"), True) + group_id = create_data.get("group_id") + self.assertIsInstance(group_id, int) + + create_trace_id = create.headers.get("X-Trace-Id") + self.assertTrue(create_trace_id) + + audit = client.get("/api/audit-logs?resource_type=group&limit=200") + self.assertEqual(audit.status_code, 200) + audit_data = audit.get_json() + self.assertEqual(audit_data.get("success"), True) + logs = audit_data.get("logs") or [] + self.assertTrue(any(l.get("trace_id") == create_trace_id for l in logs)) + + update = client.put( + f"/api/groups/{group_id}", + json={"name": group_name, "description": "desc2", "color": "#654321", "proxy_url": ""}, + ) + self.assertEqual(update.status_code, 200) + self.assertEqual(update.get_json().get("success"), True) + + delete = client.delete(f"/api/groups/{group_id}") + self.assertEqual(delete.status_code, 200) + self.assertEqual(delete.get_json().get("success"), True) + + # 删除后再次获取:应返回错误(legacy 会被归一化为结构化错误) + get_after = client.get(f"/api/groups/{group_id}") + self.assertEqual(get_after.status_code, 200) + data = get_after.get_json() + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("message"), "分组不存在") + + def test_tag_crud_and_duplicate_name(self): + client = self.app.test_client() + self._login(client) + + unique = uuid.uuid4().hex + tag_name = f"t_{unique}" + + create = client.post("/api/tags", json={"name": tag_name, "color": "#abcdef"}) + self.assertEqual(create.status_code, 200) + data = create.get_json() + self.assertEqual(data.get("success"), True) + tag_id = data.get("tag", {}).get("id") + self.assertIsInstance(tag_id, int) + + dup = client.post("/api/tags", json={"name": tag_name, "color": "#000000"}) + self.assertEqual(dup.status_code, 200) + dup_data = dup.get_json() + self.assertEqual(dup_data.get("success"), False) + self.assertIsInstance(dup_data.get("error"), dict) + self.assertEqual(dup_data["error"].get("message"), "标签名称已存在") + + listing = client.get("/api/tags") + self.assertEqual(listing.status_code, 200) + listing_data = listing.get_json() + self.assertEqual(listing_data.get("success"), True) + self.assertIn(tag_name, [t.get("name") for t in (listing_data.get("tags") or [])]) + + delete = client.delete(f"/api/tags/{tag_id}") + self.assertEqual(delete.status_code, 200) + self.assertEqual(delete.get_json().get("success"), True) + + def test_export_verify_and_export_all_download_contains_account(self): + client = self.app.test_client() + self._login(client) + + unique = uuid.uuid4().hex + email_addr = f"export_{unique}@example.com" + client_id = "cid_" + unique + refresh_token = "rt_" + unique + + conn = self.module.create_sqlite_connection() + try: + conn.execute( + """ + INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark, status) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (email_addr, "pw", client_id, refresh_token, 1, "", "active"), + ) + conn.commit() + finally: + conn.close() + + verify = client.post("/api/export/verify", json={"password": "testpass123"}) + self.assertEqual(verify.status_code, 200) + verify_data = verify.get_json() + self.assertEqual(verify_data.get("success"), True) + verify_token = verify_data.get("verify_token") + self.assertTrue(verify_token) + + export = client.get(f"/api/accounts/export?verify_token={verify_token}") + self.assertEqual(export.status_code, 200) + self.assertIn("text/plain", export.headers.get("Content-Type", "")) + body = export.get_data(as_text=True) + self.assertIn(email_addr, body) + self.assertIn(client_id, body) + self.assertIn(refresh_token, body) + + export_trace_id = export.headers.get("X-Trace-Id") + self.assertTrue(export_trace_id) + + audit = client.get("/api/audit-logs?limit=200") + self.assertEqual(audit.status_code, 200) + audit_data = audit.get_json() + self.assertEqual(audit_data.get("success"), True) + logs = audit_data.get("logs") or [] + self.assertTrue(any(l.get("trace_id") == export_trace_id for l in logs)) + + def test_oauth_auth_url_endpoint(self): + client = self.app.test_client() + self._login(client) + + resp = client.get("/api/oauth/auth-url") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + self.assertIn("auth_url", data) + self.assertIn("login.microsoftonline.com", data.get("auth_url", "")) + + def test_temp_email_generate_and_list_mocked(self): + client = self.app.test_client() + self._login(client) + + email_addr = f"temp_{uuid.uuid4().hex}@example.com" + with patch.object(self.module.impl, "generate_temp_email", return_value=email_addr): + created = client.post("/api/temp-emails/generate", json={"prefix": "x", "domain": "y"}) + self.assertEqual(created.status_code, 200) + created_data = created.get_json() + self.assertEqual(created_data.get("success"), True) + self.assertEqual(created_data.get("email"), email_addr) + + listing = client.get("/api/temp-emails") + self.assertEqual(listing.status_code, 200) + listing_data = listing.get_json() + self.assertEqual(listing_data.get("success"), True) + self.assertIn(email_addr, [e.get("email") for e in (listing_data.get("emails") or [])]) + + def test_refresh_all_stream_has_start_and_complete_events(self): + client = self.app.test_client() + self._login(client) + + conn = self.module.create_sqlite_connection() + try: + conn.execute( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", + ("refresh_delay_seconds", "0"), + ) + conn.commit() + finally: + conn.close() + + with patch.object(self.module.impl, "test_refresh_token", return_value=(True, None)): + resp = client.get("/api/accounts/refresh-all") + + self.assertEqual(resp.status_code, 200) + self.assertIn("text/event-stream", resp.headers.get("Content-Type", "")) + + payload = resp.get_data(as_text=True) + events = [] + for line in payload.splitlines(): + if line.startswith("data: "): + events.append(json.loads(line[len("data: ") :])) + + self.assertGreaterEqual(len(events), 2) + self.assertEqual(events[0].get("type"), "start") + self.assertTrue(events[0].get("trace_id")) + self.assertTrue(any(e.get("type") == "complete" for e in events)) diff --git a/tests/test_distributed_lock.py b/tests/test_distributed_lock.py new file mode 100644 index 0000000..d502749 --- /dev/null +++ b/tests/test_distributed_lock.py @@ -0,0 +1,55 @@ +import time +import unittest +import uuid + +from tests._import_app import import_web_app_module + + +class DistributedLockTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = import_web_app_module() + + def test_acquire_and_release_distributed_lock(self): + conn = self.module.create_sqlite_connection() + try: + lock_name = "test_lock_" + uuid.uuid4().hex + owner_a = "a_" + uuid.uuid4().hex + owner_b = "b_" + uuid.uuid4().hex + + ok, info = self.module.acquire_distributed_lock(conn, lock_name, owner_a, ttl_seconds=60) + self.assertTrue(ok) + self.assertIsNone(info) + + ok2, info2 = self.module.acquire_distributed_lock(conn, lock_name, owner_b, ttl_seconds=60) + self.assertFalse(ok2) + self.assertIsInstance(info2, dict) + self.assertEqual(info2.get("owner_id"), owner_a) + + released = self.module.release_distributed_lock(conn, lock_name, owner_a) + self.assertTrue(released) + + ok3, info3 = self.module.acquire_distributed_lock(conn, lock_name, owner_b, ttl_seconds=60) + self.assertTrue(ok3) + self.assertIsNone(info3) + finally: + conn.close() + + def test_expired_lock_can_be_taken_over(self): + conn = self.module.create_sqlite_connection() + try: + lock_name = "test_lock_expired_" + uuid.uuid4().hex + owner_a = "a_" + uuid.uuid4().hex + owner_b = "b_" + uuid.uuid4().hex + + ok, _ = self.module.acquire_distributed_lock(conn, lock_name, owner_a, ttl_seconds=1) + self.assertTrue(ok) + time.sleep(1.2) + + ok2, info2 = self.module.acquire_distributed_lock(conn, lock_name, owner_b, ttl_seconds=60) + self.assertTrue(ok2) + self.assertIsInstance(info2, dict) + self.assertEqual(info2.get("previous_owner_id"), owner_a) + finally: + conn.close() + diff --git a/tests/test_error_and_trace.py b/tests/test_error_and_trace.py new file mode 100644 index 0000000..de9a329 --- /dev/null +++ b/tests/test_error_and_trace.py @@ -0,0 +1,429 @@ +import json +import unittest +import uuid +from unittest.mock import patch + +from tests._import_app import import_web_app_module + + +class ErrorAndTraceTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = import_web_app_module() + cls.app = cls.module.app + + def test_healthz(self): + client = self.app.test_client() + resp = client.get("/healthz") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("status"), "ok") + + def test_login_required_returns_structured_error(self): + client = self.app.test_client() + resp = client.get("/api/system/health") + self.assertEqual(resp.status_code, 401) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertEqual(data.get("need_login"), True) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("code"), "AUTH_REQUIRED") + self.assertEqual(data["error"].get("status"), 401) + self.assertTrue(data["error"].get("trace_id")) + + def test_api_404_has_structured_error_and_trace_id(self): + client = self.app.test_client() + resp = client.get("/api/__not_found__") + self.assertEqual(resp.status_code, 404) + data = resp.get_json() + self.assertIsInstance(data, dict) + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("code"), "HTTP_ERROR") + self.assertEqual(data["error"].get("status"), 404) + self.assertTrue(data["error"].get("trace_id")) + self.assertEqual(resp.headers.get("X-Trace-Id"), data["error"].get("trace_id")) + + def test_trace_id_can_be_propagated_from_request(self): + client = self.app.test_client() + resp = client.get("/api/__not_found__", headers={"X-Trace-Id": "trace_from_test"}) + data = resp.get_json() + self.assertEqual(resp.headers.get("X-Trace-Id"), "trace_from_test") + self.assertEqual(data["error"].get("trace_id"), "trace_from_test") + + def test_sanitize_error_details_masks_tokens(self): + sanitized = self.module.sanitize_error_details( + 'Bearer abcdefg refresh_token=xyz password: "123456"' + ) + self.assertIn("Bearer ***", sanitized) + self.assertIn("refresh_token=***", sanitized) + self.assertIn('"123456"', 'Bearer abcdefg refresh_token=xyz password: "123456"') # sanity + self.assertNotIn("123456", sanitized) + + def test_build_error_payload_sanitizes_message_and_details(self): + payload = self.module.build_error_payload( + "TEST", + 'Bearer abcdefg refresh_token=xyz password: "123456"', + "TestError", + 400, + 'Bearer abcdefg refresh_token=xyz password: "123456"', + ) + self.assertIn("Bearer ***", payload.get("message", "")) + self.assertIn("refresh_token=***", payload.get("message", "")) + self.assertNotIn("abcdefg", payload.get("message", "")) + self.assertNotIn("123456", payload.get("message", "")) + + self.assertIn("Bearer ***", payload.get("details", "")) + self.assertIn("refresh_token=***", payload.get("details", "")) + self.assertNotIn("abcdefg", payload.get("details", "")) + self.assertNotIn("123456", payload.get("details", "")) + + def test_delete_emails_all_methods_fail_returns_aggregated_error(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + email_addr = "user@example.com" + graph_error = { + "code": "EMAIL_DELETE_FAILED", + "message": "Graph 删除失败", + "type": "GraphAPIError", + "status": 502, + "details": "graph_failed", + "trace_id": "", + } + + with patch.object( + self.module.impl, + "get_account_by_email", + return_value={"email": email_addr, "client_id": "cid", "refresh_token": "rt", "group_id": None}, + ), patch.object( + self.module.impl, + "delete_emails_graph", + return_value={"success": False, "error": graph_error, "success_count": 0, "failed_count": 2, "errors": ["e1"]}, + ), patch.object( + self.module.impl, + "delete_emails_imap", + side_effect=[ + {"success": False, "error": "imap_new_failed"}, + {"success": False, "error": "imap_old_failed"}, + ], + ): + resp = client.post( + "/api/emails/delete", + json={"email": email_addr, "ids": ["m1", "m2"]}, + ) + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("code"), "EMAIL_DELETE_ALL_METHODS_FAILED") + self.assertEqual(data["error"].get("status"), 502) + self.assertTrue(data["error"].get("trace_id")) + self.assertEqual(resp.headers.get("X-Trace-Id"), data["error"].get("trace_id")) + + details_text = data["error"].get("details") or "" + self.assertIn("Graph API", details_text) + self.assertIn("IMAP(新服务器)", details_text) + self.assertIn("IMAP(旧服务器)", details_text) + + def test_delete_emails_proxy_error_does_not_fallback_to_imap(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + email_addr = "user@example.com" + graph_res = { + "success": False, + "error": { + "code": "EMAIL_DELETE_FAILED", + "message": "代理连接失败", + "type": "ProxyError", + "status": 502, + "details": "proxy_failed", + "trace_id": "", + }, + "success_count": 0, + "failed_count": 2, + "errors": ["ProxyError: connect failed"], + } + + with patch.object( + self.module.impl, + "get_account_by_email", + return_value={"email": email_addr, "client_id": "cid", "refresh_token": "rt", "group_id": None}, + ), patch.object( + self.module.impl, + "delete_emails_graph", + return_value=graph_res, + ), patch.object( + self.module.impl, + "delete_emails_imap", + ) as imap_mock: + resp = client.post( + "/api/emails/delete", + json={"email": email_addr, "ids": ["m1", "m2"]}, + ) + imap_mock.assert_not_called() + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("type"), "ProxyError") + self.assertTrue(data["error"].get("trace_id")) + self.assertEqual(resp.headers.get("X-Trace-Id"), data["error"].get("trace_id")) + + def test_legacy_error_string_is_normalized_to_structured_error(self): + client = self.app.test_client() + + # 登录 + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + self.assertEqual(login.get_json().get("success"), True) + + # 触发一个 legacy 的字符串错误(200 + {success:false, error:"..."}) + resp = client.get("/api/groups/999999") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("code"), "LEGACY_ERROR") + self.assertEqual(data["error"].get("message"), "分组不存在") + self.assertTrue(data["error"].get("trace_id")) + + # 响应体应是 JSON(确保 after_request 的 set_data 没破坏格式) + json.loads(resp.get_data(as_text=True)) + + def test_scheduler_status_endpoint_is_accessible_after_login(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + resp = client.get("/api/scheduler/status") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + self.assertIn("scheduler", data) + self.assertIn("refresh", data) + + def test_validate_cron_endpoint(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + resp = client.post("/api/settings/validate-cron", json={"cron_expression": "0 2 * * *"}) + data = resp.get_json() + self.assertEqual(resp.status_code, 200) + self.assertEqual(data.get("success"), True) + self.assertEqual(data.get("valid"), True) + self.assertIsInstance(data.get("future_runs"), list) + self.assertGreaterEqual(len(data.get("future_runs")), 1) + + def test_system_diagnostics_includes_schema(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + resp = client.get("/api/system/diagnostics") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + diagnostics = data.get("diagnostics") or {} + self.assertIn("schema", diagnostics) + self.assertIn("version", diagnostics["schema"]) + self.assertIn("target_version", diagnostics["schema"]) + self.assertIn("up_to_date", diagnostics["schema"]) + + def test_system_upgrade_status_endpoint(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + resp = client.get("/api/system/upgrade-status") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + upgrade = data.get("upgrade") or {} + self.assertIn("schema_version", upgrade) + self.assertIn("target_version", upgrade) + self.assertIn("up_to_date", upgrade) + self.assertIn("backup_hint", upgrade) + + def test_login_rate_limiting_locks_after_max_attempts(self): + client = self.app.test_client() + ip = "10.0.0.99" + + for _ in range(self.module.MAX_LOGIN_ATTEMPTS): + resp = client.post( + "/login", + json={"password": "wrong_password"}, + headers={"X-Forwarded-For": ip}, + ) + self.assertEqual(resp.status_code, 401) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("code"), "LOGIN_INVALID_PASSWORD") + + resp = client.post( + "/login", + json={"password": "wrong_password"}, + headers={"X-Forwarded-For": ip}, + ) + self.assertEqual(resp.status_code, 429) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("code"), "LOGIN_RATE_LIMITED") + self.assertEqual(data["error"].get("status"), 429) + self.assertTrue(data["error"].get("trace_id")) + + def test_export_requires_verify_token(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + resp = client.post( + "/api/accounts/export-selected", + json={"group_ids": [1], "verify_token": "invalid_token"}, + ) + self.assertEqual(resp.status_code, 401) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertEqual(data.get("need_verify"), True) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("status"), 401) + self.assertTrue(data["error"].get("trace_id")) + + def test_accounts_list_and_search_include_tags_and_last_refresh(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + unique = uuid.uuid4().hex + email_addr = f"test_{unique}@example.com" + client_id = "client_id_" + unique + refresh_token = "rt_" + unique + tag_name = "tag_" + unique + + conn = self.module.create_sqlite_connection() + try: + # 插入账号 + cur = conn.execute( + ''' + INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark, status) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', + (email_addr, "", client_id, refresh_token, 1, "remark", "active"), + ) + account_id = cur.lastrowid + + # 插入标签并关联 + cur = conn.execute( + "INSERT INTO tags (name, color) VALUES (?, ?)", + (tag_name, "#111111"), + ) + tag_id = cur.lastrowid + conn.execute( + "INSERT OR IGNORE INTO account_tags (account_id, tag_id) VALUES (?, ?)", + (account_id, tag_id), + ) + + # 插入刷新日志(用于 last_refresh_status/error) + conn.execute( + ''' + INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message) + VALUES (?, ?, ?, ?, ?) + ''', + (account_id, email_addr, "manual", "failed", "network_error"), + ) + + conn.commit() + finally: + conn.close() + + # 列表接口:应返回 tags + last_refresh_* + resp = client.get("/api/accounts") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + + accounts = data.get("accounts") or [] + target = next((a for a in accounts if a.get("email") == email_addr), None) + self.assertIsNotNone(target) + self.assertTrue(target.get("client_id", "").startswith(client_id[:8])) + self.assertEqual(target.get("last_refresh_status"), "failed") + self.assertEqual(target.get("last_refresh_error"), "network_error") + self.assertIsInstance(target.get("tags"), list) + self.assertIn(tag_name, [t.get("name") for t in target.get("tags")]) + + # 搜索接口:按标签名应可搜索到 + resp = client.get(f"/api/accounts/search?q={tag_name}") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + accounts = data.get("accounts") or [] + target = next((a for a in accounts if a.get("email") == email_addr), None) + self.assertIsNotNone(target) + self.assertEqual(target.get("last_refresh_status"), "failed") + self.assertIn(tag_name, [t.get("name") for t in target.get("tags")]) + + def test_system_group_cannot_be_deleted(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + conn = self.module.create_sqlite_connection() + try: + row = conn.execute("SELECT id FROM groups WHERE is_system = 1 LIMIT 1").fetchone() + self.assertIsNotNone(row) + system_group_id = row["id"] + finally: + conn.close() + + resp = client.delete(f"/api/groups/{system_group_id}") + self.assertEqual(resp.status_code, 403) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("code"), "SYSTEM_GROUP_PROTECTED") + self.assertEqual(data["error"].get("status"), 403) + + def test_account_cannot_be_moved_to_system_group_via_update(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + unique = uuid.uuid4().hex + email_addr = f"move_{unique}@example.com" + + conn = self.module.create_sqlite_connection() + try: + system_row = conn.execute("SELECT id FROM groups WHERE is_system = 1 LIMIT 1").fetchone() + self.assertIsNotNone(system_row) + system_group_id = system_row["id"] + + cur = conn.execute( + ''' + INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark, status) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', + (email_addr, "", "client", "rt", 1, "", "active"), + ) + account_id = cur.lastrowid + conn.commit() + finally: + conn.close() + + resp = client.put( + f"/api/accounts/{account_id}", + json={"email": email_addr, "group_id": system_group_id}, + ) + self.assertEqual(resp.status_code, 403) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("code"), "SYSTEM_GROUP_PROTECTED") + self.assertEqual(data["error"].get("status"), 403) diff --git a/tests/test_masking_audit_and_import.py b/tests/test_masking_audit_and_import.py new file mode 100644 index 0000000..08e45af --- /dev/null +++ b/tests/test_masking_audit_and_import.py @@ -0,0 +1,228 @@ +import unittest +import uuid +from unittest.mock import patch + +from tests._import_app import import_web_app_module + + +class MaskingAuditAndImportTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = import_web_app_module() + cls.app = cls.module.app + + def _login(self, client): + resp = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.get_json().get("success"), True) + + def _default_group_id(self) -> int: + conn = self.module.create_sqlite_connection() + try: + row = conn.execute("SELECT id FROM groups WHERE name = '默认分组' LIMIT 1").fetchone() + return row["id"] if row else 1 + finally: + conn.close() + + def test_settings_get_does_not_leak_secrets(self): + client = self.app.test_client() + self._login(client) + + resp = client.get("/api/settings") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + + settings = data.get("settings") or {} + self.assertNotIn("login_password", settings) + self.assertNotIn("gptmail_api_key", settings) + self.assertIn("login_password_set", settings) + self.assertIn("gptmail_api_key_set", settings) + + def test_account_get_does_not_return_password_or_refresh_token(self): + unique = uuid.uuid4().hex + email_addr = f"mask_{unique}@example.com" + password = f"pass_{unique}" + refresh_token = f"rt_{unique}" + client_id = f"cid_{unique}" + + conn = self.module.create_sqlite_connection() + try: + cur = conn.execute( + """ + INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark, status) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (email_addr, password, client_id, refresh_token, self._default_group_id(), "", "active"), + ) + account_id = cur.lastrowid + conn.commit() + finally: + conn.close() + + client = self.app.test_client() + self._login(client) + resp = client.get(f"/api/accounts/{account_id}") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + + account = data.get("account") or {} + self.assertEqual(account.get("id"), account_id) + self.assertEqual(account.get("email"), email_addr) + self.assertEqual(account.get("password"), "") + self.assertEqual(account.get("refresh_token"), "") + self.assertEqual(account.get("has_password"), True) + self.assertEqual(account.get("has_refresh_token"), True) + + body_text = resp.get_data(as_text=True) + self.assertNotIn(password, body_text) + self.assertNotIn(refresh_token, body_text) + + def test_system_group_cannot_be_renamed(self): + client = self.app.test_client() + self._login(client) + + conn = self.module.create_sqlite_connection() + try: + row = conn.execute("SELECT id, name FROM groups WHERE is_system = 1 LIMIT 1").fetchone() + self.assertIsNotNone(row) + system_group_id = row["id"] + system_group_name = row["name"] + finally: + conn.close() + + resp = client.put( + f"/api/groups/{system_group_id}", + json={"name": system_group_name + "_new", "description": "x", "color": "#111111"}, + ) + self.assertEqual(resp.status_code, 403) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("code"), "SYSTEM_GROUP_PROTECTED") + self.assertEqual(data["error"].get("status"), 403) + + def test_batch_import_returns_line_errors_and_parses_refresh_token_with_delimiter(self): + client = self.app.test_client() + self._login(client) + + unique = uuid.uuid4().hex + email_addr = f"import_{unique}@example.com" + client_id = f"client_{unique}" + refresh_token = f"rt_{unique}----tail" + + account_string = "\n".join( + [ + "bad_line_without_delimiter", + f"{email_addr}----p----{client_id}----{refresh_token}", + ] + ) + + resp = client.post( + "/api/accounts", + json={"account_string": account_string, "group_id": self._default_group_id()}, + ) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + + summary = data.get("summary") or {} + self.assertEqual(summary.get("imported"), 1) + self.assertEqual(summary.get("failed"), 1) + self.assertEqual(summary.get("total_lines"), 2) + + errors = data.get("errors") or [] + self.assertGreaterEqual(len(errors), 1) + self.assertEqual(errors[0].get("line"), 1) + + conn = self.module.create_sqlite_connection() + try: + row = conn.execute( + "SELECT refresh_token FROM accounts WHERE email = ? LIMIT 1", + (email_addr,), + ).fetchone() + self.assertIsNotNone(row) + stored = row["refresh_token"] + finally: + conn.close() + + stored_plain = self.module.decrypt_data(stored) if stored else stored + self.assertEqual(stored_plain, refresh_token) + + def test_audit_logs_endpoint_includes_group_create(self): + client = self.app.test_client() + self._login(client) + + unique = uuid.uuid4().hex + group_name = f"审计测试_{unique}" + create = client.post( + "/api/groups", + json={"name": group_name, "description": "", "color": "#222222", "proxy_url": ""}, + ) + self.assertEqual(create.status_code, 200) + created = create.get_json() + self.assertEqual(created.get("success"), True) + group_id = created.get("group_id") + self.assertIsInstance(group_id, int) + + resp = client.get("/api/audit-logs?action=create&resource_type=group&limit=200") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + + logs = data.get("logs") or [] + matched = [ + r + for r in logs + if str(r.get("resource_id")) == str(group_id) and group_name in (r.get("details") or "") + ] + self.assertTrue(matched) + self.assertTrue(matched[0].get("trace_id")) + + def test_temp_email_actions_are_audited(self): + client = self.app.test_client() + self._login(client) + + unique = uuid.uuid4().hex + email_addr = f"tmp_{unique}@example.com" + + with patch.object(self.module.impl, "generate_temp_email", return_value=email_addr): + resp = client.post("/api/temp-emails/generate", json={}) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.get_json().get("success"), True) + + create_audit = client.get("/api/audit-logs?resource_type=temp_email&action=create&limit=200") + self.assertEqual(create_audit.status_code, 200) + create_data = create_audit.get_json() + self.assertEqual(create_data.get("success"), True) + create_logs = create_data.get("logs") or [] + self.assertTrue([r for r in create_logs if r.get("resource_id") == email_addr]) + + with patch.object(self.module.impl, "clear_temp_emails_from_api", return_value=True): + conn = self.module.create_sqlite_connection() + try: + conn.execute( + """ + INSERT INTO temp_email_messages (message_id, email_address, subject, content, timestamp) + VALUES (?, ?, ?, ?, ?) + """, + ("msg_" + unique, email_addr, "s", "c", 1), + ) + conn.commit() + finally: + conn.close() + + clear_resp = client.delete(f"/api/temp-emails/{email_addr}/clear") + self.assertEqual(clear_resp.status_code, 200) + self.assertEqual(clear_resp.get_json().get("success"), True) + + audit_resp = client.get("/api/audit-logs?resource_type=temp_email_messages&action=delete&limit=200") + self.assertEqual(audit_resp.status_code, 200) + audit = audit_resp.get_json() + self.assertEqual(audit.get("success"), True) + + logs = audit.get("logs") or [] + matched = [r for r in logs if (r.get("resource_id") == email_addr)] + self.assertTrue(matched) + self.assertTrue(matched[0].get("trace_id")) diff --git a/tests/test_module_boundaries.py b/tests/test_module_boundaries.py new file mode 100644 index 0000000..cc48145 --- /dev/null +++ b/tests/test_module_boundaries.py @@ -0,0 +1,74 @@ +import ast +import unittest +from pathlib import Path + + +class ModuleBoundaryTests(unittest.TestCase): + @staticmethod + def _collect_import_modules(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + modules: set[str] = set() + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + modules.add(alias.name) + elif isinstance(node, ast.ImportFrom): + if node.module: + modules.add(node.module) + return modules + + def _assert_forbidden_imports(self, *, path: Path, forbidden_prefixes: tuple[str, ...]): + imports = self._collect_import_modules(path) + for module in sorted(imports): + for prefix in forbidden_prefixes: + if module == prefix or module.startswith(prefix + "."): + self.fail(f"模块边界违规:{path} 导入了禁止依赖 {module!r}(匹配前缀 {prefix!r})") + + def test_routes_do_not_import_services_repos_or_legacy(self): + repo_root = Path(__file__).resolve().parents[1] + routes_dir = repo_root / "outlook_web" / "routes" + self.assertTrue(routes_dir.exists()) + + forbidden = ( + "outlook_web.legacy", + "outlook_web.services", + "outlook_web.repositories", + "outlook_web.db", + ) + for path in sorted(routes_dir.glob("*.py")): + if path.name == "__init__.py": + continue + self._assert_forbidden_imports(path=path, forbidden_prefixes=forbidden) + + def test_repositories_do_not_depend_on_flask_routes_or_services(self): + repo_root = Path(__file__).resolve().parents[1] + repos_dir = repo_root / "outlook_web" / "repositories" + self.assertTrue(repos_dir.exists()) + + forbidden = ( + "flask", + "outlook_web.routes", + "outlook_web.services", + "outlook_web.legacy", + ) + for path in sorted(repos_dir.glob("*.py")): + if path.name == "__init__.py": + continue + self._assert_forbidden_imports(path=path, forbidden_prefixes=forbidden) + + def test_services_do_not_depend_on_flask_routes_or_legacy(self): + repo_root = Path(__file__).resolve().parents[1] + services_dir = repo_root / "outlook_web" / "services" + self.assertTrue(services_dir.exists()) + + forbidden = ( + "flask", + "outlook_web.routes", + "outlook_web.legacy", + ) + for path in sorted(services_dir.glob("*.py")): + if path.name == "__init__.py": + continue + self._assert_forbidden_imports(path=path, forbidden_prefixes=forbidden) + diff --git a/tests/test_smoke_contract.py b/tests/test_smoke_contract.py new file mode 100644 index 0000000..19a7700 --- /dev/null +++ b/tests/test_smoke_contract.py @@ -0,0 +1,126 @@ +import unittest + +from tests._import_app import import_web_app_module + + +class SmokeContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = import_web_app_module() + cls.app = cls.module.app + + def _login(self, client): + resp = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + + def test_healthz_contract(self): + client = self.app.test_client() + resp = client.get("/healthz") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("status"), "ok") + + def test_pages_are_accessible(self): + client = self.app.test_client() + + # 登录页(未登录也可访问) + resp = client.get("/login") + self.assertEqual(resp.status_code, 200) + self.assertIn("text/html", (resp.headers.get("Content-Type") or "")) + + # 主页(需要登录) + self._login(client) + resp = client.get("/") + self.assertEqual(resp.status_code, 200) + self.assertIn("text/html", (resp.headers.get("Content-Type") or "")) + html = resp.get_data(as_text=True) + # 前端静态资源应已迁出(零构建):不再内联 style/script + self.assertNotIn("