From 64d8a6c357762a422a87b94f1fab76be71cacb06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E7=BB=8D=E5=BA=B7?= <3052147989@qq.com> Date: Tue, 3 Mar 2026 22:54:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(multi-mailbox):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=A4=9A=E9=82=AE=E7=AE=B1=E7=BB=9F=E4=B8=80=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=88PRD-00005=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心变更: - 数据库:Schema v3,accounts 表新增 account_type/provider/imap_host/imap_port/imap_password 字段 - 后端:新增通用 IMAP 服务(imap_generic.py)与邮箱提供商配置(providers.py) - 导入:支持 Outlook/QQ/163/Gmail/自定义 IMAP 多格式批量导入,兼容注释行与导出格式 - 导出:按邮箱类型分组导出,保留 provider 信息便于重新导入 - 邮件读取:IMAP 账号走通用 IMAP 服务,Outlook 保持原 Graph/XOAUTH2 链路 - 验证码提取:IMAP 账号通过 IMAP 获取邮件详情后提取 - 安全约束:IMAP 账号禁用远程删除,敏感字段(imap_password)加密存储 - 前端:新增邮箱类型下拉、自定义 IMAP 配置区、provider 标签显示 - 调度器:定时刷新仅处理 Outlook 账号(IMAP 无 OAuth token 刷新语义) - 日志:配置 outlook_web 命名空间日志输出到 stderr 技术细节: - 保持 Outlook 旧格式完全兼容(四段式:email----password----client_id----refresh_token) - IMAP 账号使用空字符串占位 client_id/refresh_token(满足 DB NOT NULL 约束) - 支持导出格式智能识别(5 段 custom、3 段 provider、2 段配合输入框) - 前端 DOMPurify 未加载时回退为基本 HTML 过滤(CDN 不可达场景) Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 55 + docs/AI分析提示词-多邮箱统一管理功能.md | 271 +++++ docs/FD/FD-00005-多邮箱统一管理.md | 485 +++++++++ docs/TDD/TDD-00005-多邮箱统一管理.md | 937 ++++++++++++++++++ .../TEST-00005-多邮箱统一管理-测试文档.md | 645 ++++++++++++ docs/TODO/TODO-00005-多邮箱统一管理.md | 426 ++++++++ docs/待办事项.md | 69 ++ outlook_web/app.py | 13 + outlook_web/controllers/accounts.py | 288 +++++- outlook_web/controllers/emails.py | 136 ++- outlook_web/db.py | 36 +- outlook_web/repositories/accounts.py | 90 +- outlook_web/routes/accounts.py | 5 + outlook_web/services/imap_generic.py | 459 +++++++++ outlook_web/services/providers.py | 129 +++ outlook_web/services/scheduler.py | 8 +- static/css/main.css | 5 + static/js/features/accounts.js | 134 ++- static/js/features/emails.js | 34 +- templates/partials/modals.html | 26 +- tests/test_multi_mailbox.py | 355 +++++++ 21 files changed, 4567 insertions(+), 39 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/AI分析提示词-多邮箱统一管理功能.md create mode 100644 docs/FD/FD-00005-多邮箱统一管理.md create mode 100644 docs/TDD/TDD-00005-多邮箱统一管理.md create mode 100644 docs/TEST/TEST-00005-多邮箱统一管理-测试文档.md create mode 100644 docs/TODO/TODO-00005-多邮箱统一管理.md create mode 100644 docs/待办事项.md create mode 100644 outlook_web/services/imap_generic.py create mode 100644 outlook_web/services/providers.py create mode 100644 tests/test_multi_mailbox.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..73983bd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +- `outlook_web/`: Flask backend (`app.py`, `routes/`, `services/`, `repositories/`, `security/`). +- `static/` + `templates/`: frontend assets and HTML templates. +- `data/`: runtime data (SQLite DB default: `data/outlook_accounts.db`). Do not commit production data. +- `tests/`: unit/contract tests (CI runs `unittest` discovery). +- Top-level entrypoints: `web_outlook_app.py` (web app), plus utility scripts like `start.py` / `outlook_mail_reader.py`. + +## Build, Test, and Development Commands + +Local (Windows/macOS/Linux): + +```bash +python -m venv .venv +# Windows: .\.venv\Scripts\activate +# macOS/Linux: source .venv/bin/activate +pip install -r requirements.txt +python web_outlook_app.py +``` + +Testing: + +```bash +python -m unittest discover -s tests -v +``` + +Docker: + +```bash +docker build -t outlook-email-plus . +docker run --rm -p 5000:5000 -v ./data:/app/data outlook-email-plus +``` + +## Coding Style & Naming Conventions + +- Python: format with `black` (line length `127`) and sort imports with `isort` (profile `black`). +- Lint/type/security checks used in CI: `flake8` (incl. complexity), `mypy`, `bandit`. +- Naming: modules and functions use `snake_case`; classes use `PascalCase`; constants use `UPPER_SNAKE_CASE`. + +## Testing Guidelines + +- Prefer focused unit tests in `tests/test_*.py`; keep tests deterministic (avoid network calls unless explicitly mocked). +- Add/adjust tests when changing `outlook_web/` service logic or security-sensitive behavior (auth, token refresh, encryption). + +## Commit & Pull Request Guidelines + +- Commit messages follow a Conventional-Commits-like prefix: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`, `ci:`, `style:`, `release:`. +- PRs should follow `.github/PULL_REQUEST_TEMPLATE.md`: include what changed, why, and screenshots for UI changes; link related issues when applicable. + +## Security & Configuration Tips + +- Configure via environment variables or `.env` (see `.env.example`). Always set a strong `SECRET_KEY`; never commit credentials or refresh tokens. +- If debugging locally, use sample data and keep secrets out of logs and screenshots. diff --git a/docs/AI分析提示词-多邮箱统一管理功能.md b/docs/AI分析提示词-多邮箱统一管理功能.md new file mode 100644 index 0000000..fe94e1c --- /dev/null +++ b/docs/AI分析提示词-多邮箱统一管理功能.md @@ -0,0 +1,271 @@ +# 多邮箱统一管理功能 - AI 深度分析提示词 + +## 📋 项目背景 + +### 当前项目:outlookEmail +- **技术栈**:Flask + SQLite + Graph API/IMAP +- **主要功能**:Outlook 邮箱管理工具,支持多账号管理、邮件读取、标签分组 +- **架构特点**: + - 模块化设计(outlook_web/) + - 已支持多账号存储(accounts 表) + - 使用 Graph API 和 IMAP 两种方式读取邮件 + - 有定时任务调度器(scheduler) + - 邮件可能是实时获取,缺少本地持久化存储 + +### 参考项目:MailAggregator_Pro +- **技术栈**:FastAPI + SQLAlchemy(异步)+ SQLite + React +- **核心功能**: + - 多邮箱 IMAP 聚合(Gmail、QQ、163、Yahoo、阿里邮箱等) + - 统一邮件存储和查询 + - 规则引擎自动打标签 + - 后台轮询任务 + - Telegram 推送 + Webhook 通知 + - 每个账号独立轮询间隔控制 + +## 🎯 需求目标 + +在当前 outlookEmail 项目中,实现类似 MailAggregator_Pro 的多邮箱统一管理功能: + +1. **支持多种邮箱提供商**:Gmail、QQ邮箱、163邮箱、126邮箱、Outlook、Yahoo、阿里邮箱等 +2. **统一 IMAP 接口**:通过 IMAP 协议统一拉取所有邮箱的邮件 +3. **本地邮件存储**:将所有邮箱的邮件存储到本地数据库 +4. **后台自动轮询**:定时拉取所有活跃账号的新邮件 +5. **统一查询界面**:在一个界面查看所有邮箱的邮件 +6. **可选高级功能**:规则引擎、推送通知、Webhook + +## 📊 关键架构对比 + +### 当前项目架构 +``` +outlookEmail/ +├── outlook_web/ +│ ├── app.py # Flask 应用入口 +│ ├── db.py # 数据库初始化和连接 +│ ├── repositories/ # 数据访问层 +│ │ ├── accounts.py # 账号管理(已支持多账号) +│ │ ├── groups.py # 分组管理 +│ │ └── tags.py # 标签管理 +│ ├── services/ # 业务逻辑层 +│ │ ├── graph.py # Graph API 服务 +│ │ ├── imap.py # IMAP 服务 +│ │ └── scheduler.py # 定时任务 +│ ├── routes/ # API 路由 +│ └── security/ # 安全模块(加密、认证) +└── data/ # SQLite 数据库 + +数据库表: +- accounts: 邮箱账号(email, password, client_id, refresh_token, group_id, remark, status) +- groups: 账号分组 +- tags: 标签 +- account_tags: 账号标签关联 +- settings: 系统设置 +- temp_emails: 临时邮件(可能用于缓存) +``` + +### 参考项目架构 +``` +MailAggregator_Pro/ +├── app/ +│ ├── api/ # FastAPI 路由 +│ │ ├── accounts.py # 账号管理 API +│ │ ├── emails.py # 邮件查询 API +│ │ ├── rules.py # 规则管理 API +│ │ └── settings.py # 系统设置 API +│ ├── models/ # SQLAlchemy 模型 +│ │ ├── email.py # EmailAccount + EmailRecord +│ │ ├── poll_status.py # AccountPollStatus +│ │ └── mail_rule.py # MailRule +│ ├── services/ # 业务逻辑 +│ │ ├── fetcher.py # 邮件拉取服务(核心) +│ │ ├── rules_engine.py # 规则引擎 +│ │ ├── telegram.py # Telegram 推送 +│ │ └── webhook.py # Webhook 通知 +│ ├── worker/ # 后台任务 +│ │ └── poller.py # 轮询循环(核心) +│ └── core/ # 核心模块 +│ ├── database.py # 异步数据库 +│ └── encryption.py # 加密服务 +└── frontend/ + └── src/ + └── config/ + └── mailProviders.ts # 邮箱提供商配置(核心) + +数据库表: +- accounts: 邮箱账号(email, provider, encrypted_pwd, host, port, is_active, sort_order, telegram_push_enabled, push_template, poll_interval_seconds) +- emails: 统一邮件存储(message_id, account_id, subject, sender, content_summary, body_text, body_html, received_at, is_read, labels) +- account_poll_status: 轮询状态(account_id, last_started_at, last_finished_at, last_success_at, last_error) +- mail_rules: 邮件规则 +- telegram_filter_rules: Telegram 推送规则 +``` + +## 🔑 核心实现要点 + +### 1. 邮箱提供商配置(mailProviders.ts) +```typescript +export const MAIL_PROVIDERS: MailProviderPreset[] = [ + { + key: "gmail", + label: "Gmail", + host: "imap.gmail.com", + port: 993, + note: "需要在账户中开启 IMAP,并使用应用专用密码" + }, + { + key: "qq", + label: "QQ 邮箱", + host: "imap.qq.com", + port: 993, + note: "需要在 QQ 邮箱中开启 IMAP,并使用授权码" + }, + // ... 更多提供商 +]; +``` + +### 2. 邮件拉取服务(fetcher.py) +- 使用 `imap_tools` 库统一处理所有 IMAP 邮箱 +- 通过 `message_id` 去重,避免重复入库 +- 首次同步:拉取历史邮件,不推送通知 +- 增量同步:只拉取新邮件,推送通知 + +### 3. 后台轮询任务(poller.py) +- 全局轮询循环,每 5 秒检查一次 +- 遍历所有 `is_active=True` 的账号 +- 每个账号有独立的轮询间隔 +- 记录轮询状态和错误信息 + +### 4. 统一邮件存储 +- 所有邮箱的邮件存储在同一个 `emails` 表 +- 通过 `account_id` 外键关联账号 +- 支持跨账号查询和筛选 + +## 📝 需要深度分析的问题 + +### 1. 架构设计 +- **问题**:如何在 Flask 同步架构中实现高效的多账号轮询? +- **考虑**: + - 是否需要改造为异步架构(FastAPI + asyncio)? + - 如何避免轮询任务阻塞主线程? + - 如何处理大量账号的并发拉取? + +### 2. 数据库设计 +- **问题**:如何设计邮件存储表以支持高效查询? +- **考虑**: + - 索引策略(account_id, received_at, message_id) + - 邮件正文存储(body_text, body_html)的性能影响 + - 如何处理大量邮件的存储和查询性能? + - 是否需要分表或归档策略? + +### 3. IMAP 兼容性 +- **问题**:不同邮箱提供商的 IMAP 实现差异如何处理? +- **考虑**: + - Gmail 的特殊标签系统 + - QQ/163 邮箱的中文编码问题 + - Outlook 的文件夹结构差异 + - 错误处理和重试策略 + +### 4. **问题**:如何优化大量邮件的拉取和存储性能? +- **考虑**: + - 批量插入 vs 单条插入 + - 邮件去重策略(message_id 唯一索引) + - 增量同步的时间窗口设置 + - 数据库连接池管理 + +### 5. 用户体验 +- **问题**:如何设计友好的多邮箱管理界面? +- **考虑**: + - 邮箱提供商选择(下拉菜单 + 自动填充) + - 授权码/应用密码的获取指引 + - 轮询状态和错误提示 + - 统一邮件列表的筛选和排序 + +### 6. 安全性 +- **问题**:如何安全存储多个邮箱的授权信息? +- **考虑**: + - 密码加密算法(Fernet vs AES) + - 密钥管理(环境变量 vs 配置文件) + - 数据库加密 + - API 认证和授权 + +### 7. 迁移策略 +- **问题**:如何从当前架构平滑迁移到新架构? +- **考虑**: + - 数据库 Schema 升级 + - 现有账号数据的迁移 + - 向后兼容性 + - 分阶段实施计划 + +### 8. 扩展性 +- **问题**:如何设计可扩展的架构以支持未来功能? +- **考虑**: + - 规则引擎的设计 + - 推送通知的扩展(Telegram、邮件、Webhook) + - 插件化架构 + - 多租户支持 + +## 🎯 期望的分析输出 + +请从以下维度进行深度分析: + +### 1. 技术方案设计 +- 详细的架构设计方案 +- 数据库 Schema 设计 +- 核心模块的实现思路 +- 技术选型建议(同步 vs 异步、Flask vs FastAPI) + +### 2. 实施路线图 +- 分阶段实施计划(MVP → 完整功能) +- 每个阶段的关键任务和交付物 +- 风险评估和应对策略 +- 时间和资源估算 + +### 3. 代码实现建议 +- 关键模块的伪代码或示例代码 +- 最佳实践和设计模式 +- 性能优化建议 +- 测试策略 + +### 4. 潜在问题和解决方案 +- 可能遇到的技术难点 +- 兼容性问题 +- 性能瓶颈 +- 安全风险 + +### 5. 与参考项目的差异分析 +- 哪些设计可以直接借鉴? +- 哪些需要根据当前项目调整? +- 哪些功能可以简化或省略? +- 哪些功能需要增强? + +## 📚 参考资料 + +### 常见邮箱的 IMAP 配置 +| 邮箱服务商 | IMAP 服务器 | 端口 | 授权方式 | +|-----------|------------|------|---------| +| Gmail | imap.gmail.com | 993 | 应用专用密码 | +| QQ邮箱 | imap.qq.com | 993 | 授权码 | +| 163邮箱 | imap.163.com | 993 | 授权码 | +| 126邮箱 | imap.126.com | 993 | 授权码 | +| Outlook | outlook.office365.com | 993 | 应用密码 | +| Yahoo | imap.mail.yahoo.com | 993 | 应用密码 | +| 阿里邮箱 | imap.aliyun.com | 993 | 密码 | + +### 相关技术栈 +- **IMAP 库**:`imap_tools`(Python) +- **异步框架**:FastAPI + SQLAlchemy(异步) +- **加密库**:`cryptography`(Fernet) +- **任务调度**:APScheduler / asyncio +- **前端框架**:React + TypeScript + +## 💡 分析重点 + +请特别关注以下方面: + +1. **架构演进路径**:如何从当前的 Outlook 单一邮箱架构演进到多邮箱统一管理架构? +2. **最小可行产品(MVP)**:第一阶段应该实现哪些核心功能? +3. **性能和可扩展性**:如何设计以支持 100+ 邮箱账号的场景? +4. **用户体验**:如何让用户轻松添加和管理多个邮箱? +5. **代码复用**:如何最大化利用当前项目的现有代码? + +--- + +**注意**:这是一个真实的开源项目,请提供实用、可落地的分析建议,而不是理论性的讨论。 diff --git a/docs/FD/FD-00005-多邮箱统一管理.md b/docs/FD/FD-00005-多邮箱统一管理.md new file mode 100644 index 0000000..82a9c90 --- /dev/null +++ b/docs/FD/FD-00005-多邮箱统一管理.md @@ -0,0 +1,485 @@ +# FD-00005|多邮箱统一管理 — 功能实现设计文档 + +## 文档信息 + +- **文档编号**: FD-00005 +- **创建日期**: 2026-03-02 +- **版本**: V1.0 +- **状态**: 草案 +- **对齐 PRD**: `docs/PRD/PRD-00005-多邮箱统一管理.md` +- **覆盖范围**: 后端(Flask + SQLite)+ 前端(templates + static/js) + +--- + +## 一、功能概述 + +### 1.1 设计目标 + +在**保持现有 Outlook 功能完全不变**(Graph API 主路径、XOAUTH2 IMAP 回退路径、现有 API/前端调用方式)前提下: + +1. 支持新增 IMAP 类型邮箱(Gmail / QQ / 163 / 126 / Yahoo / 阿里邮箱 / 自定义 IMAP)。 +2. 在同一界面统一管理:账号新增、分组复用、邮件列表/详情、验证码提取。 +3. IMAP 账号支持授权码/应用专用密码的**加密存储**与**脱敏展示**。 +4. 导入/导出兼容:Outlook 保持原格式;IMAP 提供新格式与“按 provider 分类导出”。 + +### 1.2 核心约束(必须遵守) + +1. **不改造既有 Outlook 流程**:任何 Outlook 账号的 Graph + XOAUTH2 行为不做逻辑变更(仅允许在“按账号类型路由”层面加分支)。 +2. **不改变现有 API 的 URL 与基本响应结构**: + - `GET /api/emails/` + - `GET /api/emails//extract-verification` + - `POST /api/emails/delete` +3. **遵循 PRD 10.1 决策**:`accounts.client_id` / `accounts.refresh_token` **保持 NOT NULL**(不做 ALTER COLUMN),IMAP 账号使用占位空字符串 `''`。 +4. **IMAP 不做远程删除**:IMAP 类型账号的删除接口直接返回“不支持远程删除”,不执行任何远程 IMAP 操作。 +5. **定时刷新任务只刷新 Outlook**:scheduler 查询必须过滤 `account_type='outlook' OR account_type IS NULL`。 + +### 1.3 Out of Scope(本期明确不做) + +- 本地邮件持久化、后台轮询、规则引擎、推送/Webhook、框架迁移、IMAP 远程删除。 + +--- + +## 二、数据模型与迁移设计(SQLite) + +### 2.1 accounts 表新增字段(兼容旧数据) + +目标是在不破坏既有表结构的情况下,新增 IMAP 所需配置字段: + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---:|---| +| `account_type` | TEXT | `'outlook'` | `'outlook'` / `'imap'`(老数据自动视为 outlook) | +| `provider` | TEXT | `'outlook'` | `'outlook'` / `'gmail'` / `'qq'` / `'163'` / `'126'` / `'yahoo'` / `'aliyun'` / `'custom'` | +| `imap_host` | TEXT | NULL | IMAP 服务器地址(IMAP 必填,Outlook 忽略) | +| `imap_port` | INTEGER | 993 | IMAP 端口(默认 SSL 993) | +| `imap_password` | TEXT | NULL | IMAP 授权码/应用专用密码(**加密存储**) | + +迁移方式(在 `outlook_web/db.py:init_db()` 中做幂等迁移): + +```sql +ALTER TABLE accounts ADD COLUMN account_type TEXT DEFAULT 'outlook'; +ALTER TABLE accounts ADD COLUMN provider TEXT DEFAULT 'outlook'; +ALTER TABLE accounts ADD COLUMN imap_host TEXT; +ALTER TABLE accounts ADD COLUMN imap_port INTEGER DEFAULT 993; +ALTER TABLE accounts ADD COLUMN imap_password TEXT; +``` + +> 注:SQLite `ALTER TABLE ADD COLUMN` 可幂等实现需“先检测列是否存在”,实现方式:`PRAGMA table_info(accounts)` 扫描列名后再执行对应 `ALTER`。 + +### 2.2 既有 NOT NULL 约束的处理(PRD 10.1) + +由于 `client_id` / `refresh_token` 为 NOT NULL: + +- **Outlook**:按现有逻辑存储真实值(refresh_token 仍加密存储)。 +- **IMAP**:写入占位空字符串: + - `client_id = ''` + - `refresh_token = ''` + - `password = ''`(保持 `password` 语义为 Outlook 账号登录密码/展示用;IMAP 密码独立存放在 `imap_password`) + +### 2.3 敏感字段加密迁移 + +现有 `migrate_sensitive_data()` 已覆盖 `password`、`refresh_token` 的加密迁移。 + +本期新增: + +- 将 `imap_password` 纳入迁移逻辑(若发现非加密明文则执行 `encrypt_data()`)。 + +### 2.4 Schema 版本 + +`outlook_web/db.py` 当前 `DB_SCHEMA_VERSION = 2`,本需求涉及 accounts 表新增列,需升级为: + +- `DB_SCHEMA_VERSION = 3` +- 在迁移成功后写入 settings:`db_schema_version=3` + +--- + +## 三、邮箱提供商配置(后端常量) + +### 3.1 providers 常量文件 + +新增文件:`outlook_web/services/providers.py` + +包含两部分: + +1. `MAIL_PROVIDERS`:用于前端下拉展示与默认 IMAP host/port 注入(**不入库**)。 +2. `PROVIDER_FOLDER_MAP`:IMAP 文件夹名映射(对齐 Outlook 的 `inbox/junkemail/deleteditems` 三类)。 + +关键规则: + +- `provider='custom'`:必须由用户提供 `imap_host/imap_port`,其余 provider 从常量读取默认值。 +- 文件夹映射中允许多候选值(按顺序尝试 `SELECT` 成功的那一个)。 +- QQ/163 的非 ASCII 文件夹名可能为 IMAP UTF-7 编码(如 `&V4NXPpCuTvY-`),实现时需验证并保留扩展点。 + +### 3.2 providers API + +新增 API: + +- `GET /api/providers` + +返回结构(稳定、可扩展): + +```json +{ + "success": true, + "providers": [ + {"key":"outlook","label":"Outlook","account_type":"outlook","note":"..."}, + {"key":"qq","label":"QQ 邮箱","account_type":"imap","note":"..."} + ] +} +``` + +--- + +## 四、IMAP 通用服务设计(新增) + +### 4.1 新增 `imap_generic` 服务 + +新增文件:`outlook_web/services/imap_generic.py` + +职责: + +- 使用 `imaplib.IMAP4_SSL(host, port)` 建立连接 +- 使用 `connection.login(email, imap_password)` 登录 +- 通过 providers 的 folder map 将 `folder(inbox/junkemail/deleteditems)` 映射到实际 IMAP 文件夹名 +- 统一输出邮件列表/详情结构,尽量对齐现有 `api_get_emails()` 的 JSON 字段(`id/subject/from/date/body_preview/is_read/has_attachments`) + +### 4.2 公共返回结构(建议) + +邮件列表: + +```json +{ + "success": true, + "emails": [ + { + "id": "", + "subject": "...", + "from": "...", + "date": "...", + "is_read": false, + "has_attachments": false, + "body_preview": "..." + } + ], + "method": "IMAP (Generic)", + "has_more": false +} +``` + +说明: + +- IMAP 天然分页成本较高,本期可以先保持 `has_more=false`(与现有 Outlook IMAP 回退一致)。 +- `id` 推荐使用 IMAP UID(稳定且可 fetch),并在 `get_email_detail_imap_generic()` 中以 UID fetch。 + +### 4.3 错误处理与脱敏 + +IMAP 错误常见场景: + +- 认证失败(授权码错误/应用密码失效) +- 服务器不可达/端口错误 +- SSL 握手失败 +- Folder 不存在 + +要求: + +- 对外错误信息不得包含 `imap_password`、refresh_token 等敏感信息。 +- `outlook_web/errors.py` 的错误载荷(如 `build_error_payload`)可复用,建议为 IMAP 定义 code: + - `IMAP_AUTH_FAILED` + - `IMAP_CONNECT_FAILED` + - `IMAP_FOLDER_NOT_FOUND` + +--- + +## 五、账号导入/编辑/导出(后端 + repo) + +### 5.1 Repository 层改造(accounts) + +文件:`outlook_web/repositories/accounts.py` + +改造点: + +1. `load_accounts()` / `get_account_by_*()`:补充解密 `imap_password`(若存在),并确保返回包含 `account_type/provider/imap_host/imap_port` 字段。 +2. `add_account()`:扩展签名支持新增字段;并对 `imap_password` 进行加密存储。 +3. `update_account()`:根据 `account_type` 放宽校验规则: + - Outlook:仍要求 `client_id` / `refresh_token` 非空 + - IMAP:允许 `client_id=''` / `refresh_token=''`,但要求 `imap_password` 与 `imap_host`(custom)满足约束 + +### 5.2 添加账号 API(兼容旧接口 + 新增 IMAP 模式) + +文件:`outlook_web/controllers/accounts.py`,接口:`POST /api/accounts` + +兼容策略: + +- **旧请求(Outlook 批量导入)保持不变**:继续支持 `account_string` 格式 `email----password----client_id----refresh_token`。 +- **新增 IMAP**:允许前端传入结构化 JSON(单个添加)或“先选 provider 再提交文本”的批量导入。 + +建议请求体格式: + +1) Outlook(保持现状): + +```json +{ "account_string": "a@outlook.com----pwd----client_id----refresh_token", "group_id": 1 } +``` + +2) IMAP(批量导入,provider 非 custom): + +```json +{ + "provider": "qq", + "group_id": 1, + "account_string": "user1@qq.com----授权码1\nuser2@qq.com----授权码2" +} +``` + +3) IMAP(批量导入,provider=custom): + +```json +{ + "provider": "custom", + "group_id": 1, + "account_string": "user@custom.com----密码----imap.custom.com----993" +} +``` + +解析规则: + +- 若 `provider` 缺失或为 `outlook`:按旧逻辑解析 4 段。 +- 若 `provider` 为 IMAP: + - 非 custom:每行 2 段(email----imap_password) + - custom:每行 4 段(email----imap_password----imap_host----imap_port) + +入库规则(与 PRD 对齐): + +- IMAP:`password=''`、`client_id=''`、`refresh_token=''`,真实凭据写入 `imap_password`(加密)并填充 `imap_host/imap_port/provider/account_type='imap'`。 +- Outlook:保持原字段语义不变,同时写入 `account_type='outlook'`、`provider='outlook'`。 + +### 5.3 导出账号(按 provider 分类) + +文件:`outlook_web/controllers/accounts.py` + +接口: + +- `GET /api/accounts/export`(现有接口,保持不变) +- `POST /api/accounts/export-selected`(现有接口,保持不变) + +导出格式(PRD 10.3): + +``` +# === Outlook 账号 === +user1@outlook.com----password----client_id----refresh_token + +# === IMAP 账号(QQ邮箱)=== +user1@qq.com----授权码----qq + +# === IMAP 账号(自定义)=== +user@custom.com----密码----custom----imap.custom.com----993 +``` + +实现要点: + +- 导出必须使用 `load_accounts()`(自动解密)后再拼接。 +- 需要保证 Outlook 在前、IMAP 在后;IMAP 再按 provider 分组。 +- 注意:导出文件包含敏感数据(refresh_token / imap_password),必须保留“二次验证”机制不变。 + +--- + +## 六、邮件 API 路由与行为(按账号类型分发) + +### 6.1 邮件列表 `GET /api/emails/` + +文件:`outlook_web/controllers/emails.py` + +改造原则: + +- `account_type='outlook' or NULL`:继续走现有逻辑(Graph -> IMAP XOAUTH2 回退)。 +- `account_type='imap'`:直接走 `imap_generic`,不再尝试 Graph,也不走 XOAUTH2 IMAP。 + +文件夹参数保持不变: + +- `folder=inbox|junkemail|deleteditems` + +IMAP folder 映射流程: + +1. 根据 `provider` 取 `PROVIDER_FOLDER_MAP[provider]`,无则退回 `_default`。 +2. 依次尝试候选文件夹名,找到第一个可 `SELECT` 的文件夹。 + +### 6.2 邮件详情(建议补齐统一入口) + +当前验证码提取会需要“获取邮件详情”,建议为 IMAP 新增: + +- `imap_generic.get_email_detail_imap_generic(...)` + +并在 `extract-verification` 中复用该能力。 + +### 6.3 验证码提取 `GET /api/emails//extract-verification` + +文件:`outlook_web/controllers/emails.py` + +保持外部接口不变,仅内部按账号类型路由: + +- Outlook:保持现有流程(Graph/IMAP XOAUTH2 获取最新邮件 + 详情)。 +- IMAP: + 1. `imap_generic.get_emails_imap_generic(folder='INBOX', top=1)` + 2. `imap_generic.get_email_detail_imap_generic(message_id)` + 3. 复用 `outlook_web/services/verification_extract.py:extract_verification_info()` + +--- + +## 七、删除 API 行为调整(IMAP 禁止远程删除) + +接口:`POST /api/emails/delete` + +文件:`outlook_web/controllers/emails.py` + +行为: + +- Outlook:保持原实现(`email_delete_service` + Graph API 远程永久删除)。 +- IMAP:直接返回: + +```json +{ "success": false, "error": "IMAP 邮箱不支持远程删除,请在邮箱客户端中操作" } +``` + +并记录审计日志(操作类型 `delete`,对象 `imap_email`)。 + +--- + +## 八、前端交互设计(模板 + JS) + +### 8.1 添加账号模态框:增加 provider 选择与动态表单 + +文件:`templates/partials/modals.html` + +改造点(保持现有结构尽量少改): + +1. 在“选择分组”上方或下方新增“邮箱类型/提供商”下拉: + - 默认 `Outlook` + - 选项来自 `GET /api/providers` 或前端常量(建议后端提供,避免重复维护) +2. 根据 provider 动态更新: + - textarea placeholder + - 格式提示(form-hint) + - custom 时显示 `imap_host` / `imap_port` 输入(单个添加模式)或提示批量格式含 host/port + +### 8.2 addAccount() 请求体适配 + +文件:`static/js/features/accounts.js` + +当前实现固定发送: + +```js +{ account_string: input, group_id: groupId } +``` + +调整为: + +- Outlook:保持原字段 +- IMAP:增加 `provider` 字段(以及 custom 的 host/port,若采用结构化输入模式) + +### 8.3 账号列表显示:增加类型标识 + +文件:`templates/index.html` + `static/js/features/accounts.js` + +后端 `GET /api/accounts` 需要在返回列表中包含: + +- `account_type` +- `provider` + +前端在账号卡片上展示 provider label tag(如 `[QQ邮箱]`、`[Gmail]`、`[自定义]`),用于快速识别。 + +### 8.4 编辑账号(建议最小可用) + +文件:`templates/partials/modals.html` + `static/js/features/accounts.js` + +建议: + +- Outlook:保持原字段(password/client_id/refresh_token) +- IMAP:增加可编辑字段: + - provider(只读或可改,建议先只读) + - imap_password(可选更新,不回显明文;支持重新设置) + - imap_host/imap_port(custom 必填;非 custom 只读展示) + +--- + +## 九、定时刷新任务与刷新按钮兼容性 + +### 9.1 Scheduler 过滤(必须) + +文件:`outlook_web/services/scheduler.py` 或相关查询处 + +将原查询: + +```sql +FROM accounts WHERE status = 'active' +``` + +改为: + +```sql +FROM accounts +WHERE status = 'active' + AND (account_type = 'outlook' OR account_type IS NULL) +``` + +### 9.2 刷新按钮(PRD 10.2) + +IMAP 账号保留刷新按钮,点击走现有逻辑可能失败(Graph token 获取失败),属于“已知但可接受”的行为。 + +后续优化(不在本期验收范围): + +- 将 IMAP 刷新按钮改为“测试连接”。 + +--- + +## 十、实施顺序(建议) + +1. **Phase 1(后端基础)** + - `db.py`:schema version 升级 + accounts 新列迁移 + imap_password 加密迁移 + - `services/providers.py`:providers + folder map + - `services/imap_generic.py`:IMAP 通用服务 +2. **Phase 2(业务接入)** + - `repositories/accounts.py`:CRUD 支持新字段 + 解密/加密 + - `controllers/accounts.py`:新增 IMAP 导入解析 + providers API + 导出格式分组 + - `controllers/emails.py`:按 account_type 路由(列表/验证码/删除分支) + - `services/scheduler.py`:过滤 account_type +3. **Phase 3(前端)** + - `templates/partials/modals.html`:添加账号表单改造 + - `static/js/features/accounts.js`:导入逻辑请求体/提示适配 + - 账号列表 tag 展示 + +--- + +## 十一、验收检查表 + +### P0(必须) + +- [ ] 旧 Outlook 账号:`GET /api/emails/` 仍优先 Graph,失败后仍可 XOAUTH2 IMAP 回退 +- [ ] 旧 Outlook 批量导入格式仍可用:`email----password----client_id----refresh_token` +- [ ] 新增 IMAP 账号可导入(QQ/163/Gmail 任一)并可拉取邮件列表/详情 +- [ ] 新增 IMAP 账号可执行验证码提取(至少能从最新一封邮件中提取到验证码或明确返回未找到) +- [ ] IMAP 账号调用删除接口返回“不支持远程删除”,且没有远程副作用 +- [ ] 定时刷新任务不再处理 IMAP 账号(仅 Outlook) +- [ ] `imap_password` 在 DB 中为加密串(不以明文出现) + +### P1(建议) + +- [ ] `GET /api/providers` 返回 providers 列表,前端下拉可用 +- [ ] 导出文件按 Outlook/IMAP provider 分类,且包含 provider 标识(满足 PRD 10.3) +- [ ] 账号列表可清晰区分 Outlook 与 IMAP + +### P2(可选) + +- [ ] IMAP 文件夹映射覆盖垃圾箱/已删除(至少 gmail/qq/163 之一验证通过) +- [ ] 错误码与用户提示更精细(认证失败/连接失败/文件夹不存在) + +--- + +## 十二、回滚与风险控制 + +- 数据库升级前必须提示备份(现有 `init_db()` 已具备升级提示机制),建议升级 trace_id 可追踪。 +- 增量迁移仅 `ADD COLUMN`,回滚主要依赖 DB 备份文件。 +- IMAP 连接失败、授权码错误等问题必须保证: + - 不影响同分组下 Outlook 账号正常使用 + - 错误消息脱敏,不泄漏密码/token + +--- + +*文档版本:V1.0 | 已基于 Sequential Thinking 进行模块化拆解 | 状态:草案* diff --git a/docs/TDD/TDD-00005-多邮箱统一管理.md b/docs/TDD/TDD-00005-多邮箱统一管理.md new file mode 100644 index 0000000..d47097e --- /dev/null +++ b/docs/TDD/TDD-00005-多邮箱统一管理.md @@ -0,0 +1,937 @@ +# TDD-00005|多邮箱统一管理 — 技术设计细节文档 + +- **文档编号**: TDD-00005 +- **创建日期**: 2026-03-02 +- **版本**: V1.0 +- **状态**: 草案 +- **对齐 PRD**: `docs/PRD/PRD-00005-多邮箱统一管理.md` +- **对齐 FD**: `docs/FD/FD-00005-多邮箱统一管理.md` + +--- + +## 目录 + +1. [文档目的](#1-文档目的) +2. [设计原则与硬约束](#2-设计原则与硬约束) +3. [技术架构设计](#3-技术架构设计) +4. [文件变更清单](#4-文件变更清单) +5. [DB 迁移设计(Schema v2 → v3)](#5-db-迁移设计schema-v2--v3) +6. [新增服务层设计](#6-新增服务层设计) +7. [Repository 层改造](#7-repository-层改造) +8. [Controller 层改造](#8-controller-层改造) +9. [前端改造设计](#9-前端改造设计) +10. [错误处理策略](#10-错误处理策略) +11. [安全设计](#11-安全设计) +12. [测试策略](#12-测试策略) + +--- + +## 1. 文档目的 + +本 TDD 描述"多邮箱统一管理"功能的完整技术实现细节。 + +重点回答: + +- 如何在**完全不改造现有 Outlook 流程**的前提下,接入标准 IMAP 邮箱 +- 如何通过 `account_type` 字段实现**按账号类型路由分发** +- 如何对 `accounts` 表进行**幂等增量迁移**(不破坏旧数据) +- 新增的 `imap_generic.py` / `providers.py` 的**精确函数签名与行为** +- `repositories/accounts.py`、`controllers/emails.py` 的**最小改造点** +- 前端表单如何**动态切换** Outlook / IMAP 两种模式 +- `imap_password` 等敏感字段的**加解密策略** + +--- + +## 2. 设计原则与硬约束 + +### 2.1 现有 Outlook 流程零改动 + +- `services/graph.py`、`services/imap.py`(XOAUTH2):**完全不动** +- Outlook 的 Graph → IMAP XOAUTH2 回退链:**完全不动** +- 现有 API 的 URL 与基本响应结构:**完全不动** + - `GET /api/emails/` + - `GET /api/emails//extract-verification` + - `POST /api/emails/delete` + - `GET /api/accounts`、`POST /api/accounts`、`GET /api/accounts/export`、`POST /api/accounts/export-selected` + +### 2.2 DB 约束处理 + +- `accounts.client_id` / `accounts.refresh_token` 为 `NOT NULL`:**不做 ALTER COLUMN** +- IMAP 账号入库时,上述字段使用占位空字符串 `''` +- 迁移只做 `ADD COLUMN`(幂等),不做 `DROP COLUMN` / `RENAME` + +### 2.3 IMAP 行为限制 + +- IMAP 账号**不执行远程删除**:delete 接口直接返回错误响应 +- **定时刷新(scheduler)只处理 Outlook**:查询必须过滤 `account_type='outlook' OR account_type IS NULL` +- IMAP 连接统一使用 `IMAP4_SSL`(端口 993,SSL) + +--- + +## 3. 技术架构设计 + +### 3.1 路由分发模型 + +``` +用户请求(前端 or API) + ↓ +Controller 层 + ↓ + 读取 account 的 account_type + ↓ ↓ + 'outlook' / NULL 'imap' + ↓ ↓ + 【现有链路,完全不动】 【新增 imap_generic】 + graph.py → imap.py(XOAUTH2) imap_generic.py + (fallback chain) (LOGIN 认证) +``` + +### 3.2 新增模块关系图 + +``` +services/ + providers.py ← 邮箱提供商常量(MAIL_PROVIDERS + PROVIDER_FOLDER_MAP) + imap_generic.py ← 通用 IMAP 服务(引用 providers.py) + +repositories/ + accounts.py ← 改造:支持 imap_password 加解密 + 新字段 CRUD + +controllers/ + accounts.py ← 改造:添加账号时解析 IMAP 格式 + providers API + 导出分类 + emails.py ← 改造:按 account_type 路由(列表/验证码/删除) + +db.py ← 改造:DB_SCHEMA_VERSION 3 + 新增 5 列迁移 +``` + +--- + +## 4. 文件变更清单 + +| 文件 | 变更类型 | 变更内容概要 | +|---|---|---| +| `outlook_web/db.py` | **改造** | `DB_SCHEMA_VERSION=3`,新增 5 列迁移,`imap_password` 加密迁移 | +| `outlook_web/services/providers.py` | **新增** | `MAIL_PROVIDERS` 常量 + `PROVIDER_FOLDER_MAP` | +| `outlook_web/services/imap_generic.py` | **新增** | `get_emails_imap_generic()` + `get_email_detail_imap_generic()` | +| `outlook_web/repositories/accounts.py` | **改造** | `load_accounts()` 解密 `imap_password`;`add_account()` 支持新字段并加密 | +| `outlook_web/controllers/accounts.py` | **改造** | 解析 IMAP 导入格式;新增 `GET /api/providers`;导出分类格式 | +| `outlook_web/controllers/emails.py` | **改造** | 列表/验证码/删除按 `account_type` 路由 | +| `outlook_web/services/scheduler.py` | **改造** | 定时任务查询加 `account_type` 过滤 | +| `templates/partials/modals.html` | **改造** | 添加账号模态框增加 provider 下拉 + 动态表单 | +| `static/js/features/accounts.js` | **改造** | `addAccount()` 请求体适配;账号列表 provider tag 展示 | + +**完全不动的文件:** +`services/graph.py`、`services/imap.py`、`services/refresh.py`、`security/`、`routes/`、`services/verification_extractor.py` + +--- + +## 5. DB 迁移设计(Schema v2 → v3) + +### 5.1 版本常量更新 + +```python +# outlook_web/db.py +DB_SCHEMA_VERSION = 3 # 原为 2 +``` + +### 5.2 新增列的 DDL + +在 `init_db()` 中,紧接现有幂等 `ADD COLUMN` 块之后,新增以下幂等迁移: + +```python +# --- Schema v3:多邮箱支持 --- +cursor.execute("PRAGMA table_info(accounts)") +columns = [col[1] for col in cursor.fetchall()] + +if "account_type" not in columns: + cursor.execute( + "ALTER TABLE accounts ADD COLUMN account_type TEXT DEFAULT 'outlook'" + ) +if "provider" not in columns: + cursor.execute( + "ALTER TABLE accounts ADD COLUMN provider TEXT DEFAULT 'outlook'" + ) +if "imap_host" not in columns: + cursor.execute("ALTER TABLE accounts ADD COLUMN imap_host TEXT") +if "imap_port" not in columns: + cursor.execute( + "ALTER TABLE accounts ADD COLUMN imap_port INTEGER DEFAULT 993" + ) +if "imap_password" not in columns: + cursor.execute("ALTER TABLE accounts ADD COLUMN imap_password TEXT") +``` + +> **注意**:`PRAGMA table_info(accounts)` 已在前面的代码块中执行过一次(用于检测 group_id 等旧列)。为避免重复查询,应将上述检测合并到同一个列名列表变量中。具体做法:将现有代码中的 +> ```python +> cursor.execute("PRAGMA table_info(accounts)") +> columns = [col[1] for col in cursor.fetchall()] +> ``` +> 合并为一次查询,统一检测所有列(含旧列 + 新列)。 + +### 5.3 imap_password 加密迁移 + +在现有 `migrate_sensitive_data()` 中,补充对 `imap_password` 的迁移逻辑: + +```python +# 在 migrate_sensitive_data() 内,处理完 password/refresh_token 之后追加: +rows = conn.execute( + "SELECT id, imap_password FROM accounts WHERE imap_password IS NOT NULL AND imap_password != ''" +).fetchall() +for row in rows: + val = row["imap_password"] + if val and not is_encrypted(val): + encrypted = encrypt_data(val) + conn.execute( + "UPDATE accounts SET imap_password = ? WHERE id = ?", + (encrypted, row["id"]), + ) +``` + +### 5.4 schema_version 写入 + +在迁移成功后的版本写入逻辑中(现有代码已有),`DB_SCHEMA_VERSION=3` 会自动被写入 `settings` 表的 `db_schema_version`,无需额外改动。 + +--- + +## 6. 新增服务层设计 + +### 6.1 `outlook_web/services/providers.py` + +#### 6.1.1 `MAIL_PROVIDERS` 常量 + +```python +from typing import Any, Dict + +MAIL_PROVIDERS: Dict[str, Dict[str, Any]] = { + "outlook": { + "label": "Outlook", + "imap_host": "outlook.live.com", + "imap_port": 993, + "account_type": "outlook", + "note": "使用 OAuth2 认证(client_id + refresh_token)", + }, + "gmail": { + "label": "Gmail", + "imap_host": "imap.gmail.com", + "imap_port": 993, + "account_type": "imap", + "note": "需在 Google 账号中开启 IMAP,并使用应用专用密码(非登录密码)", + }, + "qq": { + "label": "QQ 邮箱", + "imap_host": "imap.qq.com", + "imap_port": 993, + "account_type": "imap", + "note": "需在 QQ 邮箱设置中开启 IMAP 服务,使用授权码(非 QQ 密码)", + }, + "163": { + "label": "163 邮箱", + "imap_host": "imap.163.com", + "imap_port": 993, + "account_type": "imap", + "note": "需在 163 邮箱设置中开启 IMAP 服务,使用授权码", + }, + "126": { + "label": "126 邮箱", + "imap_host": "imap.126.com", + "imap_port": 993, + "account_type": "imap", + "note": "需在 126 邮箱设置中开启 IMAP 服务,使用授权码", + }, + "yahoo": { + "label": "Yahoo 邮箱", + "imap_host": "imap.mail.yahoo.com", + "imap_port": 993, + "account_type": "imap", + "note": "需在 Yahoo 账号安全设置中生成应用密码", + }, + "aliyun": { + "label": "阿里邮箱", + "imap_host": "imap.aliyun.com", + "imap_port": 993, + "account_type": "imap", + "note": "使用阿里邮箱登录密码", + }, + "custom": { + "label": "自定义 IMAP", + "imap_host": "", + "imap_port": 993, + "account_type": "imap", + "note": "请手动填写 IMAP 服务器地址和端口", + }, +} +``` + +#### 6.1.2 `PROVIDER_FOLDER_MAP` 常量 + +```python +PROVIDER_FOLDER_MAP: Dict[str, Dict[str, list]] = { + "gmail": { + "inbox": ["INBOX"], + "junkemail": ["[Gmail]/Spam", "[Gmail]/垃圾邮件"], + "deleteditems": ["[Gmail]/Trash", "[Gmail]/已删除邮件"], + }, + "qq": { + "inbox": ["INBOX"], + "junkemail": ["Junk", "&V4NXPpCuTvY-"], + "deleteditems": ["Deleted Messages", "&XfJT0ZABkK5O9g-"], + }, + "163": { + "inbox": ["INBOX"], + "junkemail": ["&V4NXPpCuTvY-"], + "deleteditems": ["&XfJT0ZABkK5O9g-"], + }, + "yahoo": { + "inbox": ["INBOX"], + "junkemail": ["Bulk Mail"], + "deleteditems": ["Trash"], + }, + "_default": { + "inbox": ["INBOX"], + "junkemail": ["Junk", "Spam", "SPAM", "Bulk Mail"], + "deleteditems": ["Trash", "Deleted", "Deleted Messages"], + }, +} +``` + +#### 6.1.3 辅助函数 + +```python +def get_imap_folder_candidates(provider: str, folder: str) -> list: + """ + 根据 provider 和逻辑文件夹名(inbox/junkemail/deleteditems), + 返回候选 IMAP 文件夹名列表(按优先级排序)。 + 不存在的 provider 退回 _default。 + """ + folder_map = PROVIDER_FOLDER_MAP.get(provider, PROVIDER_FOLDER_MAP["_default"]) + return folder_map.get(folder, PROVIDER_FOLDER_MAP["_default"].get(folder, ["INBOX"])) + + +def get_provider_list() -> list: + """返回供前端展示的 provider 列表(顺序固定:outlook 在前)""" + order = ["outlook", "gmail", "qq", "163", "126", "yahoo", "aliyun", "custom"] + result = [] + for key in order: + if key in MAIL_PROVIDERS: + p = MAIL_PROVIDERS[key] + result.append({ + "key": key, + "label": p["label"], + "account_type": p["account_type"], + "note": p.get("note", ""), + }) + return result +``` + +--- + +### 6.2 `outlook_web/services/imap_generic.py` + +#### 6.2.1 模块职责 + +- 使用 `imaplib.IMAP4_SSL` 建立连接(LOGIN 认证,不走 XOAUTH2) +- 与现有 `services/imap.py`(Outlook XOAUTH2)完全独立并存 +- 输出邮件结构对齐现有 `get_emails_imap()` / `get_emails_graph()` 的 JSON 字段 + +#### 6.2.2 内部辅助:IMAP 文件夹探测 + +```python +def _resolve_imap_folder( + mail: imaplib.IMAP4_SSL, + candidates: list, +) -> Optional[str]: + """ + 按顺序尝试 SELECT 候选文件夹名,返回第一个成功的文件夹名。 + 若全部失败,返回 None。 + """ + for folder_name in candidates: + try: + status, _ = mail.select(folder_name, readonly=True) + if status == "OK": + return folder_name + except Exception: + continue + return None +``` + +#### 6.2.3 邮件列表函数签名 + +```python +def get_emails_imap_generic( + email_addr: str, + imap_password: str, + imap_host: str, + imap_port: int = 993, + folder: str = "inbox", # 逻辑文件夹:inbox / junkemail / deleteditems + provider: str = "_default", # 用于 PROVIDER_FOLDER_MAP 查找 + skip: int = 0, + top: int = 20, +) -> Dict[str, Any]: + """ + 标准 IMAP 邮件列表(LOGIN 认证)。 + 返回格式对齐现有 api_get_emails() 响应: + { + "success": True, + "emails": [...], + "method": "IMAP (Generic)", + "has_more": False + } + + 每封邮件字段: + id str IMAP UID(字符串) + subject str + from str + date str RFC2822 日期字符串 + is_read bool + has_attachments bool + body_preview str 正文前 200 字符(纯文本) + + 错误时返回: + { + "success": False, + "error": "<脱敏消息>", + "error_code": "IMAP_AUTH_FAILED" | "IMAP_CONNECT_FAILED" | "IMAP_FOLDER_NOT_FOUND" + } + """ +``` + +#### 6.2.4 邮件详情函数签名 + +```python +def get_email_detail_imap_generic( + email_addr: str, + imap_password: str, + imap_host: str, + imap_port: int = 993, + message_id: str = "", # IMAP UID(字符串) + folder: str = "inbox", + provider: str = "_default", +) -> Optional[Dict[str, Any]]: + """ + 标准 IMAP 邮件详情(按 UID fetch)。 + 返回字段: + id, subject, from, date, is_read, has_attachments, + body_text (str), body_html (str, 可为空) + + 失败时返回 None,并在 caller 侧由 controller 处理错误。 + """ +``` + +#### 6.2.5 实现要点 + +| 要点 | 具体实现 | +|---|---| +| 连接方式 | `imaplib.IMAP4_SSL(imap_host, imap_port)`,超时设置 30s(`socket.setdefaulttimeout`) | +| 登录 | `mail.login(email_addr, imap_password)` | +| UID 使用 | `mail.uid('SEARCH', ...)` / `mail.uid('FETCH', uid, '(FLAGS BODY[HEADER.FIELDS ...])')` | +| 邮件排序 | 按 UID 倒序取最新 `skip+top` 封 | +| 分页 | 本期 `has_more=False`(不分页,与现有 IMAP XOAUTH2 回退保持一致) | +| is_read | 检查 FLAGS 中是否包含 `\\Seen` | +| body_preview | 优先取 `text/plain` part,截取前 200 字符;无 plain 则从 `text/html` strip 标签后截取 | +| 关闭 | `finally` 块中调用 `mail.logout()` | + +--- + +## 7. Repository 层改造 + +文件:`outlook_web/repositories/accounts.py` + +### 7.1 `load_accounts()` 改造 + +在现有解密 `password` 和 `refresh_token` 的代码块之后,**追加** `imap_password` 解密: + +```python +# 在 load_accounts() 的 for row in rows 循环中追加: +if account.get("imap_password"): + try: + account["imap_password"] = decrypt_data(account["imap_password"]) + except Exception: + pass # 解密失败不影响其他字段 +``` + +返回的账号字典将包含新字段(若 DB 中存在):`account_type`、`provider`、`imap_host`、`imap_port`、`imap_password`(已解密)。 + +### 7.2 `add_account()` 改造 + +扩展函数签名,新增可选参数: + +```python +def add_account( + email_addr: str, + password: str, + client_id: str, + refresh_token: str, + group_id: int = None, + remark: str = None, + # --- 新增参数 --- + account_type: str = "outlook", + provider: str = "outlook", + imap_host: str = None, + imap_port: int = 993, + imap_password: str = None, +) -> int: + """返回新插入的账号 id""" +``` + +改造点: + +1. 若 `imap_password` 不为空,调用 `encrypt_data(imap_password)` 后再入库。 +2. `INSERT` 语句补充 5 个新字段的列名与值(使用 `?` 占位符)。 +3. `account_type` / `provider` / `imap_host` / `imap_port` 直接入库(无加密需求)。 + +### 7.3 校验规则 + +在 `add_account()` 入参校验中,按 `account_type` 分支: + +```python +if account_type == "imap": + if not imap_password: + raise ValueError("IMAP 账号必须提供 imap_password") + if provider == "custom" and not imap_host: + raise ValueError("自定义 IMAP 账号必须提供 imap_host") + # client_id / refresh_token 为占位空字符串,不做非空校验 +else: # outlook + if not client_id or not refresh_token: + raise ValueError("Outlook 账号必须提供 client_id 和 refresh_token") +``` + +--- + +## 8. Controller 层改造 + +### 8.1 `controllers/accounts.py` + +#### 8.1.1 `POST /api/accounts`:IMAP 导入解析 + +在现有 Outlook 解析逻辑(4 段格式)基础上,增加分支: + +```python +provider = request.json.get("provider", "outlook") if request.json else "outlook" + +if provider == "outlook" or not provider: + # 现有逻辑不动:解析 account_string,4 段格式 + ... +else: + # IMAP 解析分支 + account_string = request.json.get("account_string", "") + lines = [l.strip() for l in account_string.splitlines() if l.strip()] + provider_cfg = MAIL_PROVIDERS.get(provider, {}) + imap_host = provider_cfg.get("imap_host", "") + imap_port = provider_cfg.get("imap_port", 993) + + for line in lines: + if provider == "custom": + # 4 段:email----imap_password----imap_host----imap_port + parts = line.split("----") + if len(parts) < 2: + continue + email = parts[0].strip() + imap_pwd = parts[1].strip() + h = parts[2].strip() if len(parts) > 2 else "" + p = int(parts[3].strip()) if len(parts) > 3 else 993 + else: + # 2 段:email----imap_password + parts = line.split("----") + if len(parts) < 2: + continue + email = parts[0].strip() + imap_pwd = parts[1].strip() + h = imap_host + p = imap_port + + add_account( + email_addr=email, + password="", + client_id="", + refresh_token="", + group_id=group_id, + remark=remark, + account_type="imap", + provider=provider, + imap_host=h, + imap_port=p, + imap_password=imap_pwd, + ) +``` + +#### 8.1.2 `GET /api/providers`(新增路由) + +```python +@accounts_bp.route("/api/providers", methods=["GET"]) +def api_get_providers(): + """返回邮箱提供商列表,用于前端下拉选择""" + from outlook_web.services.providers import get_provider_list + return jsonify({"success": True, "providers": get_provider_list()}) +``` + +路由注册:在 `outlook_web/routes/` 的 accounts 路由文件中追加该路由(或在 `controllers/accounts.py` 中直接注册,保持与现有风格一致)。 + +#### 8.1.3 导出格式改造(`GET /api/accounts/export` + `POST /api/accounts/export-selected`) + +现有导出逻辑生成纯文本,改造后按 Outlook/IMAP 分组: + +```python +def _build_export_text(accounts: list) -> str: + outlook_lines = [] + imap_groups: Dict[str, list] = {} # provider -> [lines] + + for acc in accounts: + atype = acc.get("account_type") or "outlook" + if atype == "outlook": + line = f"{acc['email']}----{acc.get('password','')}----{acc.get('client_id','')}----{acc.get('refresh_token','')}" + outlook_lines.append(line) + else: + provider = acc.get("provider", "custom") + imap_pwd = acc.get("imap_password", "") + if provider == "custom": + line = f"{acc['email']}----{imap_pwd}----{provider}----{acc.get('imap_host','')}----{acc.get('imap_port', 993)}" + else: + line = f"{acc['email']}----{imap_pwd}----{provider}" + imap_groups.setdefault(provider, []).append(line) + + parts = [] + if outlook_lines: + parts.append("# === Outlook 账号 ===") + parts.extend(outlook_lines) + + for provider, lines in imap_groups.items(): + label = MAIL_PROVIDERS.get(provider, {}).get("label", provider) + parts.append(f"\n# === IMAP 账号({label})===") + parts.extend(lines) + + return "\n".join(parts) +``` + +> 现有导出接口的"二次验证"(export_verify_tokens 机制)**保持不变**。 + +### 8.2 `controllers/emails.py` + +#### 8.2.1 `GET /api/emails/`(邮件列表) + +在获取账号信息后,加路由分发: + +```python +account = get_account_by_email(email_addr) +if not account: + return jsonify({"success": False, "error": "账号不存在"}), 404 + +account_type = account.get("account_type") or "outlook" + +if account_type == "imap": + # --- 新增 IMAP 分支 --- + from outlook_web.services.imap_generic import get_emails_imap_generic + result = get_emails_imap_generic( + email_addr=email_addr, + imap_password=account.get("imap_password", ""), + imap_host=account.get("imap_host", ""), + imap_port=account.get("imap_port", 993), + folder=request.args.get("folder", "inbox"), + provider=account.get("provider", "_default"), + skip=int(request.args.get("skip", 0)), + top=int(request.args.get("top", 20)), + ) + return jsonify(result) +else: + # 现有 Outlook 逻辑完全不动 + ... +``` + +#### 8.2.2 `GET /api/emails//extract-verification`(验证码提取) + +```python +account_type = account.get("account_type") or "outlook" + +if account_type == "imap": + from outlook_web.services.imap_generic import ( + get_emails_imap_generic, + get_email_detail_imap_generic, + ) + from outlook_web.services.verification_extractor import extract_verification_info + + # 取最新 1 封 + emails_result = get_emails_imap_generic( + email_addr=email_addr, + imap_password=account.get("imap_password", ""), + imap_host=account.get("imap_host", ""), + imap_port=account.get("imap_port", 993), + folder="inbox", + provider=account.get("provider", "_default"), + skip=0, + top=1, + ) + if not emails_result.get("success") or not emails_result.get("emails"): + return jsonify({"success": False, "error": "无法获取邮件"}) + + latest = emails_result["emails"][0] + detail = get_email_detail_imap_generic( + email_addr=email_addr, + imap_password=account.get("imap_password", ""), + imap_host=account.get("imap_host", ""), + imap_port=account.get("imap_port", 993), + message_id=latest["id"], + folder="inbox", + provider=account.get("provider", "_default"), + ) + if not detail: + return jsonify({"success": False, "error": "无法获取邮件详情"}) + + verification = extract_verification_info(detail) + return jsonify({"success": True, **verification}) +else: + # 现有 Outlook 逻辑完全不动 + ... +``` + +#### 8.2.3 `POST /api/emails/delete`(删除) + +```python +account_type = account.get("account_type") or "outlook" + +if account_type == "imap": + # 记录审计日志 + log_audit(action="delete", resource_type="imap_email", resource_id=message_id) + return jsonify({ + "success": False, + "error": "IMAP 邮箱不支持远程删除,请在邮箱客户端中操作" + }) +else: + # 现有 Outlook 删除逻辑完全不动 + ... +``` + +### 8.3 `services/scheduler.py` + +在 `scheduled_refresh_task` 内(第 268 行附近),将原查询: + +```python +"SELECT id, email, client_id, refresh_token, group_id FROM accounts WHERE status = 'active'" +``` + +改为: + +```python +""" +SELECT id, email, client_id, refresh_token, group_id +FROM accounts +WHERE status = 'active' + AND (account_type = 'outlook' OR account_type IS NULL) +""" +``` + +--- + +## 9. 前端改造设计 + +### 9.1 `templates/partials/modals.html`:添加账号模态框 + +#### 9.1.1 新增 provider 下拉(在分组选择之前) + +```html + +
+ + +
+ + + + + +
+``` + +#### 9.1.2 格式提示文案 + +| provider | placeholder 文本 | hint 文本 | +|---|---|---| +| outlook | `email----password----client_id----refresh_token` | 每行一个账号,4 段以 `----` 分隔 | +| qq/163/126 等 | `user@qq.com----授权码` | 每行一个账号,使用授权码(非登录密码) | +| custom | `user@domain.com----密码` | 需在上方填写 IMAP 服务器地址 | + +### 9.2 `static/js/features/accounts.js` + +#### 9.2.1 页面初始化:加载 providers + +```javascript +async function loadProviders() { + const resp = await fetch('/api/providers'); + const data = await resp.json(); + const select = document.getElementById('accountProvider'); + if (!select) return; + data.providers.forEach(p => { + const opt = document.createElement('option'); + opt.value = p.key; + opt.textContent = p.label; + select.appendChild(opt); + }); + onProviderChange(select.value); // 初始化提示文案 +} +``` + +#### 9.2.2 `onProviderChange(provider)` 函数 + +```javascript +function onProviderChange(provider) { + const customFields = document.getElementById('customImapFields'); + const hint = document.getElementById('accountFormatHint'); + const textarea = document.getElementById('accountInput'); // 现有 textarea + + if (provider === 'outlook') { + customFields.style.display = 'none'; + textarea.placeholder = 'email----password----client_id----refresh_token'; + hint.textContent = '每行一个账号,4 段以 ---- 分隔'; + } else if (provider === 'custom') { + customFields.style.display = ''; + textarea.placeholder = 'user@domain.com----密码'; + hint.textContent = '需在上方填写 IMAP 服务器地址和端口'; + } else { + customFields.style.display = 'none'; + textarea.placeholder = 'user@example.com----授权码'; + hint.textContent = '每行一个账号,使用授权码(非登录密码),以 ---- 分隔'; + } +} +``` + +#### 9.2.3 `addAccount()` 请求体适配 + +```javascript +async function addAccount() { + const provider = document.getElementById('accountProvider')?.value || 'outlook'; + const accountString = document.getElementById('accountInput').value.trim(); + const groupId = document.getElementById('accountGroup').value; + + let body; + if (provider === 'outlook') { + // 保持原格式,向后兼容 + body = { account_string: accountString, group_id: groupId }; + } else { + body = { provider, account_string: accountString, group_id: groupId }; + if (provider === 'custom') { + // custom 字段在 account_string 中已包含,无需额外字段 + } + } + + const resp = await fetch('/api/accounts', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCSRFToken() }, + body: JSON.stringify(body), + }); + // 现有的响应处理逻辑不动 + ... +} +``` + +#### 9.2.4 账号列表:provider tag 展示 + +在现有 `renderAccountList()` 函数中,为每个账号卡片追加 provider tag: + +```javascript +// 在账号卡片 HTML 模板中追加 tag(示例) +const providerLabel = getProviderLabel(account.provider || 'outlook'); +const tagHtml = ``; +// 插入到邮箱地址后面 +``` + +```javascript +function getProviderLabel(provider) { + const labels = { + outlook: 'Outlook', gmail: 'Gmail', qq: 'QQ邮箱', + '163': '163邮箱', '126': '126邮箱', yahoo: 'Yahoo', + aliyun: '阿里邮箱', custom: '自定义', + }; + return labels[provider] || provider; +} +``` + +确保 `GET /api/accounts` 返回的账号对象中包含 `account_type` 和 `provider` 字段(来自 `load_accounts()` 改造后自动包含)。 + +--- + +## 10. 错误处理策略 + +### 10.1 IMAP 错误码与场景 + +| 场景 | error_code | 用户提示(脱敏) | +|---|---|---| +| 认证失败(授权码错误/过期) | `IMAP_AUTH_FAILED` | "IMAP 认证失败,请检查授权码是否正确" | +| 服务器不可达/端口错误 | `IMAP_CONNECT_FAILED` | "IMAP 服务器连接失败,请检查网络或服务器配置" | +| 指定文件夹不存在 | `IMAP_FOLDER_NOT_FOUND` | "邮箱文件夹不存在,请联系管理员" | +| SSL 握手失败 | `IMAP_CONNECT_FAILED` | 同上 | +| 其他未知错误 | `IMAP_UNKNOWN_ERROR` | "邮件获取失败,请稍后重试" | + +### 10.2 错误脱敏要求 + +在 `imap_generic.py` 中,所有 `except` 块必须: +1. 不将 `imap_password` 包含在日志或返回的 error 字符串中 +2. 使用 `sanitize_error_details(str(e))` 处理异常信息(复用 `outlook_web/errors.py`) +3. 对外返回结构统一为 `{"success": False, "error": "<脱敏消息>", "error_code": ""}` + +### 10.3 IMAP 错误不影响 Outlook 账号 + +由于路由分发在 controller 层,IMAP 错误只影响当前 IMAP 账号的请求,不会传播到其他 Outlook 账号的调用链。 + +--- + +## 11. 安全设计 + +### 11.1 imap_password 加解密 + +| 操作 | 函数 | 时机 | +|---|---|---| +| 入库前加密 | `encrypt_data(imap_password)` | `add_account()` 中,在 INSERT 之前 | +| 读取后解密 | `decrypt_data(account["imap_password"])` | `load_accounts()` 中,在返回前 | +| 迁移加密 | `encrypt_data()` + `is_encrypted()` | `migrate_sensitive_data()` 中补充逻辑 | + +复用现有 `outlook_web/security/crypto.py` 中的 `encrypt_data` / `decrypt_data` / `is_encrypted`,不引入新的加密方案。 + +### 11.2 传输安全 + +- 所有 IMAP 连接使用 `imaplib.IMAP4_SSL`(TLS,端口 993) +- 不使用 `IMAP4`(明文) + +### 11.3 导出脱敏 + +导出文件包含明文 `imap_password`(已在 `load_accounts()` 解密),属于敏感操作。现有的"导出二次验证(export_verify_tokens)"机制覆盖此场景,**不需要额外改动**。 + +--- + +## 12. 测试策略 + +### 12.1 后端单元测试要点 + +| 测试项 | 验证内容 | +|---|---| +| `test_db_migration_v3` | 升级后 accounts 表包含 5 个新列;旧数据 account_type 默认为 `outlook` | +| `test_add_imap_account` | imap_password 入库为加密串;client_id/refresh_token 为 `''` | +| `test_load_accounts_decrypts_imap_password` | 读取时 imap_password 已解密 | +| `test_imap_generic_auth_failure` | 认证失败时返回 `IMAP_AUTH_FAILED`,不暴露密码 | +| `test_imap_folder_resolution` | provider=qq,folder=inbox,返回候选列表 `["INBOX"]` | +| `test_emails_api_routes_imap` | `GET /api/emails/` 触发 imap_generic,不调用 graph.py | +| `test_emails_api_routes_outlook` | `GET /api/emails/` 走现有逻辑,不调用 imap_generic | +| `test_delete_imap_returns_unsupported` | IMAP 账号调用 delete 接口,返回 `success=False` + 错误消息 | +| `test_scheduler_skips_imap` | 定时任务查询结果不包含 account_type='imap' 的账号 | +| `test_export_format_mixed` | 混合 Outlook/QQ 导出,Outlook 在前,IMAP 按 provider 分组 | + +### 12.2 P0 验收项(对应 FD 十一章) + +- [ ] 旧 Outlook 账号:`GET /api/emails/` 仍优先 Graph,失败后仍可 XOAUTH2 IMAP 回退 +- [ ] 旧 Outlook 批量导入格式仍可用:`email----password----client_id----refresh_token` +- [ ] 新增 IMAP 账号可导入(QQ / 163 / Gmail 任一)并可拉取邮件列表和详情 +- [ ] 新增 IMAP 账号可执行验证码提取(成功或返回"未找到") +- [ ] IMAP 账号调用删除接口返回"不支持远程删除",且无远程副作用 +- [ ] 定时刷新任务不处理 IMAP 账号 +- [ ] `imap_password` 在 DB 中为加密串(不以明文出现) + +--- + +*文档版本:V1.0 | 状态:草案* diff --git a/docs/TEST/TEST-00005-多邮箱统一管理-测试文档.md b/docs/TEST/TEST-00005-多邮箱统一管理-测试文档.md new file mode 100644 index 0000000..4319056 --- /dev/null +++ b/docs/TEST/TEST-00005-多邮箱统一管理-测试文档.md @@ -0,0 +1,645 @@ +# TEST-00005|多邮箱统一管理 — 测试文档 + +- **文档编号**: TEST-00005 +- **创建日期**: 2026-03-02 +- **版本**: V1.0 +- **状态**: 草案 +- **对齐 PRD**: `docs/PRD/PRD-00005-多邮箱统一管理.md` +- **对齐 FD**: `docs/FD/FD-00005-多邮箱统一管理.md` +- **对齐 TDD**: `docs/TDD/TDD-00005-多邮箱统一管理.md` +- **测试文件**: `tests/test_multi_mailbox.py` + +--- + +## 目录 + +1. [测试概述](#1-测试概述) +2. [测试策略](#2-测试策略) +3. [测试环境配置](#3-测试环境配置) +4. [P0 — 数据库迁移测试](#4-p0--数据库迁移测试) +5. [P0 — providers 模块单元测试](#5-p0--providers-模块单元测试) +6. [P0 — imap_generic 服务测试](#6-p0--imap_generic-服务测试) +7. [P0 — Repository IMAP 账号 CRUD 测试](#7-p0--repository-imap-账号-crud-测试) +8. [P0 — Controller emails 路由分发测试](#8-p0--controller-emails-路由分发测试) +9. [P0 — Controller accounts IMAP 接口测试](#9-p0--controller-accounts-imap-接口测试) +10. [P0 — Scheduler 过滤测试](#10-p0--scheduler-过滤测试) +11. [P0 — Outlook 功能回归测试](#11-p0--outlook-功能回归测试) +12. [P1 — 导出格式测试](#12-p1--导出格式测试) +13. [P2 — IMAP 文件夹映射测试](#13-p2--imap-文件夹映射测试) +14. [手动验收测试清单](#14-手动验收测试清单) +15. [测试数据准备](#15-测试数据准备) +16. [验收标准与门禁](#16-验收标准与门禁) + +--- + +## 1. 测试概述 + +### 1.1 测试目标 + +本测试文档覆盖"多邮箱统一管理"功能的完整验证,确保: + +1. **新功能正确性** + - DB Schema v3 迁移幂等、不破坏旧数据 + - IMAP 通用服务能正确认证、拉取邮件、返回统一格式 + - 按 `account_type` 路由分发逻辑准确 + - IMAP 账号的敏感字段加解密正确 + - 定时刷新任务只处理 Outlook 账号 + +2. **Outlook 流程零回归** + - 现有 Graph API → IMAP XOAUTH2 回退链不受影响 + - 现有批量导入格式(4 段)仍正常 + - 现有邮件删除、验证码提取、账号导出不受影响 + +3. **安全约束满足** + - IMAP 密码在 DB 中以加密形式存储 + - 错误消息中不暴露 imap_password + - 导出二次验证机制不受影响 + +### 1.2 测试范围 + +**包含:** +- 数据库迁移(单元/集成) +- providers.py 纯单元测试 +- imap_generic.py 单元测试(mock IMAP 连接) +- Repository CRUD 集成测试 +- Controller API 集成测试(mock 服务层) +- Scheduler 查询过滤验证 +- Outlook 全量回归(集成) +- 手动端到端测试 + +**不包含:** +- 真实 IMAP 服务器连通性测试(需要外部网络) +- 前端自动化 E2E 测试(Selenium/Playwright) +- 性能压测 + +### 1.3 测试分类 + +| 类型 | 工具 | 覆盖层 | +|---|---|---| +| 单元测试 | `unittest` + `unittest.mock` | providers.py / imap_generic.py | +| 集成测试 | Flask test client + 临时 SQLite DB | Controller / Repository / DB | +| 回归测试 | 现有测试套件(不改动) | 所有 Outlook 功能 | +| 手动测试 | 浏览器 + curl | 前端交互 / 真实 IMAP | + +--- + +## 2. 测试策略 + +### 2.1 核心测试原则 + +1. **隔离 IMAP 网络调用**:所有涉及 `imaplib.IMAP4_SSL` 的测试均通过 `unittest.mock.patch` 模拟,不发起真实网络请求。 +2. **真实 DB 操作**:Repository 和 Controller 测试使用临时 SQLite 文件(`_import_app.py` 已处理),验证实际 SQL 行为。 +3. **最小改动原则**:新增测试类,不修改现有测试文件,确保回归测试基线不变。 +4. **按 account_type 分支测试**:每个 Controller 路由测试都要包含 Outlook 和 IMAP 两个分支。 + +### 2.2 Mock 策略 + +| 被 Mock 对象 | 路径 | 用途 | +|---|---|---| +| `imaplib.IMAP4_SSL` | `outlook_web.services.imap_generic.imaplib.IMAP4_SSL` | 模拟 IMAP 连接成功/失败 | +| `imap_generic.get_emails_imap_generic` | `outlook_web.controllers.emails.get_emails_imap_generic` | Controller 路由分发测试 | +| `imap_generic.get_email_detail_imap_generic` | `outlook_web.controllers.emails.get_email_detail_imap_generic` | 验证码提取测试 | +| `graph.get_emails` | `outlook_web.services.graph.get_emails` | Outlook 回归 mock | + +--- + +## 3. 测试环境配置 + +### 3.1 运行方式 + +```bash +# 运行所有多邮箱测试 +python -m unittest tests.test_multi_mailbox -v + +# 运行全量测试套件(含回归) +python -m unittest discover -s tests -v +``` + +### 3.2 依赖 + +- Python 标准库:`unittest`、`unittest.mock`、`sqlite3`、`tempfile`、`imaplib` +- 项目依赖:`outlook_web.db`、`outlook_web.security.crypto`、`outlook_web.repositories.accounts` +- 新增依赖(实现后):`outlook_web.services.providers`、`outlook_web.services.imap_generic` + +--- + +## 4. P0 — 数据库迁移测试 + +### TC-DB-01:新建数据库包含 5 个 IMAP 新列 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-DB-01 | +| **优先级** | P0 | +| **前置条件** | 全新临时数据库(无历史数据) | +| **操作步骤** | 调用 `init_db(temp_path)`,然后执行 `PRAGMA table_info(accounts)` | +| **预期结果** | accounts 表包含:`account_type`、`provider`、`imap_host`、`imap_port`、`imap_password` | + +### TC-DB-02:旧数据库增量迁移(幂等) + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-DB-02 | +| **优先级** | P0 | +| **前置条件** | 已有 accounts 表但不含 5 个新列;表中有 1 条旧 Outlook 账号数据 | +| **操作步骤** | 1. 创建旧 schema DB → 2. 插入旧账号 → 3. 调用 `init_db(path)` → 4. 查询列和数据 | +| **预期结果** | 5 个新列已添加;旧账号数据完整保留;`account_type` 列对旧记录为 `NULL` 或 `'outlook'`(SQLite ADD COLUMN DEFAULT 行为:旧行得到 NULL) | + +### TC-DB-03:迁移幂等性(重复执行不报错) + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-DB-03 | +| **优先级** | P0 | +| **操作步骤** | 对同一数据库连续调用 `init_db(path)` 两次 | +| **预期结果** | 第二次调用不报错,列不重复 | + +### TC-DB-04:Schema 版本升级为 3 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-DB-04 | +| **优先级** | P0 | +| **操作步骤** | `init_db(path)` 完成后,查询 `settings` 表中 `db_schema_version` 的值 | +| **预期结果** | 值为 `'3'` | + +### TC-DB-05:imap_password 加密迁移 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-DB-05 | +| **优先级** | P0 | +| **前置条件** | 已有 IMAP 账号,`imap_password` 列存储的是明文字符串 | +| **操作步骤** | 调用 `migrate_sensitive_data()` 或 `init_db()` → 查询该账号的 `imap_password` | +| **预期结果** | `imap_password` 为加密格式(`is_encrypted()` 返回 True) | + +--- + +## 5. P0 — providers 模块单元测试 + +### TC-PROV-01:get_imap_folder_candidates — 已知 provider + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-PROV-01 | +| **优先级** | P0 | +| **测试数据** | provider='qq', folder='junkemail' | +| **预期结果** | 返回 `["Junk", "&V4NXPpCuTvY-"]` | + +### TC-PROV-02:get_imap_folder_candidates — 未知 provider 退回 _default + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-PROV-02 | +| **优先级** | P0 | +| **测试数据** | provider='unknown_xyz', folder='inbox' | +| **预期结果** | 返回 `_default` 中 inbox 的候选列表 `["INBOX"]` | + +### TC-PROV-03:get_imap_folder_candidates — 所有已知 provider 的 inbox 必有候选 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-PROV-03 | +| **优先级** | P0 | +| **操作步骤** | 遍历所有已知 provider(gmail/qq/163/yahoo),对 folder='inbox' 调用函数 | +| **预期结果** | 每个 provider 返回非空列表,且必包含 `"INBOX"` | + +### TC-PROV-04:get_provider_list — 返回 8 个 provider,outlook 在第一位 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-PROV-04 | +| **优先级** | P0 | +| **预期结果** | 列表长度为 8;第一个元素的 `key` 为 `'outlook'`;最后一个为 `'custom'` | + +### TC-PROV-05:get_provider_list — 每个元素包含必要字段 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-PROV-05 | +| **优先级** | P0 | +| **预期结果** | 每个元素包含 `key`、`label`、`account_type`、`note` 字段;outlook 的 `account_type` 为 `'outlook'`;其余 IMAP provider 的 `account_type` 为 `'imap'` | + +--- + +## 6. P0 — imap_generic 服务测试 + +### TC-IMAP-01:认证失败返回 IMAP_AUTH_FAILED,不暴露密码 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-IMAP-01 | +| **优先级** | P0 | +| **Mock 设置** | `IMAP4_SSL.login()` 抛出 `imaplib.error("AUTHENTICATIONFAILED")` | +| **预期结果** | 返回 `{"success": False, "error_code": "IMAP_AUTH_FAILED"}`;`error` 字段不含 imap_password 字符串 | + +### TC-IMAP-02:连接失败返回 IMAP_CONNECT_FAILED + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-IMAP-02 | +| **优先级** | P0 | +| **Mock 设置** | `IMAP4_SSL.__init__` 抛出 `ConnectionRefusedError` | +| **预期结果** | 返回 `{"success": False, "error_code": "IMAP_CONNECT_FAILED"}` | + +### TC-IMAP-03:文件夹不存在返回 IMAP_FOLDER_NOT_FOUND + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-IMAP-03 | +| **优先级** | P0 | +| **Mock 设置** | 连接和登录成功;`mail.select()` 始终返回 `('NO', [b'No such mailbox'])` | +| **预期结果** | 返回 `{"success": False, "error_code": "IMAP_FOLDER_NOT_FOUND"}` | + +### TC-IMAP-04:正常拉取邮件列表返回统一格式 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-IMAP-04 | +| **优先级** | P0 | +| **Mock 设置** | 模拟完整正常 IMAP 交互;`uid('SEARCH')` 返回 2 个 UID;`uid('FETCH')` 返回邮件头 | +| **预期结果** | `{"success": True, "emails": [2 封邮件], "method": "IMAP (Generic)", "has_more": False}`;每封邮件包含 `id`、`subject`、`from`、`date`、`is_read`、`has_attachments`、`body_preview` | + +### TC-IMAP-05:logout 在 finally 块中被调用(正常和异常场景均调用) + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-IMAP-05 | +| **优先级** | P0 | +| **Mock 设置** | 正常场景和认证失败场景(登录成功后 SEARCH 失败) | +| **预期结果** | 两种场景下 `mail.logout()` 都被调用(通过 `assert_called` 验证) | + +### TC-IMAP-06:get_email_detail_imap_generic — 正常返回详情 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-IMAP-06 | +| **优先级** | P0 | +| **Mock 设置** | 模拟 `uid('FETCH', uid, 'BODY[]')` 返回完整邮件 MIME 内容 | +| **预期结果** | 返回包含 `body_text`(或 `body_html`)的字典 | + +### TC-IMAP-07:get_email_detail_imap_generic — 失败返回 None + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-IMAP-07 | +| **优先级** | P0 | +| **Mock 设置** | `mail.uid('FETCH', ...)` 返回 `('NO', [])` | +| **预期结果** | 返回 `None` | + +--- + +## 7. P0 — Repository IMAP 账号 CRUD 测试 + +### TC-REPO-01:add_account(IMAP) — imap_password 加密入库,占位字段为空字符串 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-REPO-01 | +| **优先级** | P0 | +| **操作步骤** | 调用 `add_account(email, password='', client_id='', refresh_token='', account_type='imap', provider='qq', imap_host='imap.qq.com', imap_port=993, imap_password='my-auth-code')` → 直接查询 DB | +| **预期结果** | DB 中 `imap_password` 为加密串(`is_encrypted()=True`);`client_id=''`;`refresh_token=''`;`account_type='imap'`;`provider='qq'` | + +### TC-REPO-02:load_accounts() — 自动解密 imap_password + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-REPO-02 | +| **优先级** | P0 | +| **前置条件** | DB 中有一条 IMAP 账号,`imap_password` 为加密串 | +| **操作步骤** | 调用 `load_accounts()` → 获取该账号 | +| **预期结果** | 账号字典中 `imap_password` 为明文原始值 | + +### TC-REPO-03:add_account(IMAP,无 imap_password) — 抛出 ValueError + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-REPO-03 | +| **优先级** | P0 | +| **预期结果** | 抛出 `ValueError`,提示 imap_password 必须提供 | + +### TC-REPO-04:add_account(custom IMAP,无 imap_host) — 抛出 ValueError + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-REPO-04 | +| **优先级** | P0 | +| **测试数据** | `provider='custom'`,`imap_host=None` | +| **预期结果** | 抛出 `ValueError`,提示 custom IMAP 必须提供 imap_host | + +### TC-REPO-05:load_accounts() — 返回包含所有新字段 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-REPO-05 | +| **优先级** | P0 | +| **预期结果** | 账号字典包含 `account_type`、`provider`、`imap_host`、`imap_port` 字段 | + +--- + +## 8. P0 — Controller emails 路由分发测试 + +### TC-EMAIL-01:IMAP 账号 — GET /api/emails 触发 imap_generic,不调用 graph + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-EMAIL-01 | +| **优先级** | P0 | +| **前置条件** | DB 中有一条 account_type='imap' 的账号 | +| **Mock** | patch `get_emails_imap_generic` 返回成功结果;patch `graph.get_emails` 未调用 | +| **操作步骤** | GET `/api/emails/` | +| **预期结果** | HTTP 200;`get_emails_imap_generic` 被调用一次;`graph.get_emails` 未被调用;响应中 `method` 为 `"IMAP (Generic)"` | + +### TC-EMAIL-02:Outlook 账号 — GET /api/emails 不调用 imap_generic + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-EMAIL-02 | +| **优先级** | P0 | +| **前置条件** | DB 中有一条 account_type='outlook'(或 NULL)的账号 | +| **Mock** | patch `get_emails_imap_generic`;patch `graph.get_emails` 返回成功 | +| **操作步骤** | GET `/api/emails/` | +| **预期结果** | `get_emails_imap_generic` 未被调用;`graph.get_emails` 被调用 | + +### TC-EMAIL-03:IMAP 账号 — POST /api/emails/delete 返回"不支持远程删除" + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-EMAIL-03 | +| **优先级** | P0 | +| **前置条件** | DB 中有一条 account_type='imap' 的账号 | +| **操作步骤** | POST `/api/emails/delete` with `{"email_addr": "", "message_id": "123"}` | +| **预期结果** | HTTP 200;响应 `{"success": False, "error": "IMAP 邮箱不支持远程删除..."}`;审计日志中有对应记录 | + +### TC-EMAIL-04:IMAP 账号 — 验证码提取走 imap_generic 链路 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-EMAIL-04 | +| **优先级** | P0 | +| **前置条件** | DB 中有一条 account_type='imap' 的账号 | +| **Mock** | `get_emails_imap_generic` 返回 1 封邮件;`get_email_detail_imap_generic` 返回含验证码的邮件详情 | +| **操作步骤** | GET `/api/emails//extract-verification` | +| **预期结果** | HTTP 200;`get_emails_imap_generic` 和 `get_email_detail_imap_generic` 各被调用一次;响应中包含验证码 | + +### TC-EMAIL-05:IMAP 账号 — imap_generic 返回失败时验证码提取返回错误 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-EMAIL-05 | +| **优先级** | P0 | +| **Mock** | `get_emails_imap_generic` 返回 `{"success": False, ...}` | +| **预期结果** | 响应 `success=False`,不抛出未捕获异常 | + +--- + +## 9. P0 — Controller accounts IMAP 接口测试 + +### TC-ACC-01:GET /api/providers — 返回完整 providers 列表 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-ACC-01 | +| **优先级** | P0 | +| **操作步骤** | GET `/api/providers`(已登录) | +| **预期结果** | HTTP 200;`{"success": True, "providers": [...]}`;providers 长度为 8;第一个 key 为 `'outlook'` | + +### TC-ACC-02:POST /api/accounts with provider='qq' — 以 IMAP 格式导入 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-ACC-02 | +| **优先级** | P0 | +| **请求体** | `{"provider": "qq", "account_string": "user@qq.com----my-auth-code", "group_id": 1}` | +| **预期结果** | HTTP 200;`success=True`;DB 中新增账号 `account_type='imap'`,`provider='qq'`,`imap_password` 为加密串,`client_id=''` | + +### TC-ACC-03:POST /api/accounts without provider — 使用旧 Outlook 格式(回归) + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-ACC-03 | +| **优先级** | P0 | +| **请求体** | `{"account_string": "user@outlook.com----pwd----cid----rt", "group_id": 1}` | +| **预期结果** | HTTP 200;`success=True`;DB 中新增账号 `account_type='outlook'`,`client_id` 和 `refresh_token` 非空 | + +### TC-ACC-04:POST /api/accounts with provider='custom' — 自定义 IMAP 格式 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-ACC-04 | +| **优先级** | P0 | +| **请求体** | `{"provider": "custom", "account_string": "user@corp.com----pwd----imap.corp.com----993", "group_id": 1}` | +| **预期结果** | DB 中 `imap_host='imap.corp.com'`,`imap_port=993`,`provider='custom'` | + +--- + +## 10. P0 — Scheduler 过滤测试 + +### TC-SCHED-01:定时刷新任务查询不返回 IMAP 账号 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-SCHED-01 | +| **优先级** | P0 | +| **前置条件** | DB 中存在 1 条 account_type='outlook' 账号 + 1 条 account_type='imap' 账号 | +| **操作步骤** | 直接执行 scheduler 中的账号查询 SQL(或调用相关函数) | +| **预期结果** | 返回结果**不包含** account_type='imap' 的账号;仅包含 outlook 账号 | + +### TC-SCHED-02:account_type=NULL 的旧账号被定时任务处理 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-SCHED-02 | +| **优先级** | P0 | +| **前置条件** | DB 中存在 1 条 account_type=NULL 的旧账号(模拟迁移前的旧数据) | +| **预期结果** | 该账号出现在查询结果中(`account_type IS NULL` 也应被刷新) | + +--- + +## 11. P0 — Outlook 功能回归测试 + +### TC-REG-01:现有全量测试套件通过 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-REG-01 | +| **优先级** | P0 | +| **操作步骤** | `python -m unittest discover -s tests -v` | +| **预期结果** | 所有测试通过,数量不少于改动前基线 | + +### TC-REG-02:旧 Outlook 账号邮件读取——Graph API 路径 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-REG-02 | +| **优先级** | P0 | +| **Mock** | `graph.get_emails` 返回成功结果 | +| **预期结果** | `account_type=NULL/outlook` 的账号走 graph 路径;`method='Graph API'` | + +### TC-REG-03:旧 Outlook 账号验证码提取——功能不变 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-REG-03 | +| **优先级** | P0 | +| **操作步骤** | GET `/api/emails//extract-verification` | +| **预期结果** | 走现有链路;`imap_generic` 未被调用 | + +--- + +## 12. P1 — 导出格式测试 + +### TC-EXP-01:混合账号导出 — Outlook 在前,IMAP 按 provider 分组在后 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-EXP-01 | +| **优先级** | P1 | +| **前置条件** | DB 中有 Outlook 账号、QQ 账号、163 账号各 1 条 | +| **预期结果** | 导出文本中 `=== Outlook 账号 ===` 在前;`=== IMAP 账号(QQ邮箱)===` 和 `=== IMAP 账号(163邮箱)===` 在后 | + +### TC-EXP-02:IMAP 非 custom 导出格式 — `email----imap_password----provider` + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-EXP-02 | +| **优先级** | P1 | +| **预期结果** | QQ 账号导出行为 `user@qq.com----明文授权码----qq` | + +### TC-EXP-03:IMAP custom 导出格式 — 含 host/port + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-EXP-03 | +| **优先级** | P1 | +| **预期结果** | custom 账号导出行为 `user@corp.com----明文密码----custom----imap.corp.com----993` | + +--- + +## 13. P2 — IMAP 文件夹映射测试 + +### TC-FOLDER-01:QQ 邮箱 junkemail 候选包含 UTF-7 编码文件夹名 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-FOLDER-01 | +| **优先级** | P2 | +| **预期结果** | `get_imap_folder_candidates('qq', 'junkemail')` 包含 `"&V4NXPpCuTvY-"` | + +### TC-FOLDER-02:_resolve_imap_folder 按优先级尝试,返回第一个成功的文件夹 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-FOLDER-02 | +| **优先级** | P2 | +| **Mock 设置** | `mail.select('INBOX')` 返回 `('NO', [])`;`mail.select('Inbox')` 返回 `('OK', [])` | +| **预期结果** | 返回 `'Inbox'` | + +--- + +## 14. 手动验收测试清单 + +### 端到端流程测试(P0) + +| 编号 | 测试步骤 | 预期结果 | +|---|---|---| +| E2E-01 | 打开添加账号模态框,默认 provider 为 Outlook | textarea placeholder 显示 4 段格式 | +| E2E-02 | 切换 provider 为 QQ邮箱 | placeholder 变为 `email----授权码`;hint 提示使用授权码 | +| E2E-03 | 切换 provider 为自定义 IMAP | 显示 IMAP 服务器地址和端口输入框 | +| E2E-04 | 切换回 Outlook | 服务器配置区消失;placeholder 恢复 4 段格式 | +| E2E-05 | 使用 Outlook 格式导入一个账号 | 账号列表出现,tag 显示 `[Outlook]` | +| E2E-06 | 使用 QQ 格式导入一个账号(用真实授权码) | 账号列表出现,tag 显示 `[QQ邮箱]` | +| E2E-07 | 点击 QQ 账号 → 拉取邮件 | 邮件列表显示(或 IMAP 错误提示) | +| E2E-08 | 对 QQ 账号邮件执行"删除" | 提示"不支持远程删除" | +| E2E-09 | 对 QQ 账号执行"验证码提取" | 成功提取(或返回"未找到") | +| E2E-10 | 导出全部账号 | 下载文件中 Outlook 在前,IMAP 在后 | +| E2E-11 | 验证定时刷新任务日志 | 日志中只有 Outlook 账号被刷新,无 IMAP 账号 | + +--- + +## 15. 测试数据准备 + +```python +# Outlook 测试账号(用于回归测试) +OUTLOOK_ACCOUNT = { + "email": "test-outlook@outlook.com", + "password": "test_password", + "client_id": "test_client_id", + "refresh_token": "test_refresh_token", +} + +# QQ 邮箱 IMAP 测试账号 +QQ_IMAP_ACCOUNT = { + "email": "test@qq.com", + "account_type": "imap", + "provider": "qq", + "imap_host": "imap.qq.com", + "imap_port": 993, + "imap_password": "test-auth-code-123", +} + +# 自定义 IMAP 测试账号 +CUSTOM_IMAP_ACCOUNT = { + "email": "test@corp.com", + "account_type": "imap", + "provider": "custom", + "imap_host": "imap.corp.com", + "imap_port": 993, + "imap_password": "corp-password", +} + +# 模拟 IMAP 邮件列表响应 +MOCK_IMAP_EMAILS_RESPONSE = { + "success": True, + "emails": [ + { + "id": "42", + "subject": "验证码:123456", + "from": "noreply@example.com", + "date": "Mon, 02 Mar 2026 10:00:00 +0800", + "is_read": False, + "has_attachments": False, + "body_preview": "您的验证码是 123456,有效期5分钟。", + } + ], + "method": "IMAP (Generic)", + "has_more": False, +} + +# 模拟 IMAP 邮件详情响应 +MOCK_IMAP_EMAIL_DETAIL = { + "id": "42", + "subject": "验证码:123456", + "from": "noreply@example.com", + "date": "Mon, 02 Mar 2026 10:00:00 +0800", + "is_read": False, + "has_attachments": False, + "body_text": "您的验证码是 123456,有效期5分钟。", + "body_html": "", +} +``` + +--- + +## 16. 验收标准与门禁 + +### P0 验收门禁(必须全部通过才能合并) + +| 门禁项 | 标准 | +|---|---| +| 自动化测试 | `python -m unittest discover -s tests -v` 全部通过 | +| 测试数量 | 通过数量 ≥ 改动前基线 + 本次新增用例数 | +| imap_password DB 检查 | `SELECT imap_password FROM accounts WHERE account_type='imap'` 结果均为加密格式 | +| Outlook 回归 | TC-REG-01、TC-REG-02、TC-REG-03 全部通过 | +| IMAP 路由分发 | TC-EMAIL-01、TC-EMAIL-02、TC-EMAIL-03 全部通过 | +| 定时任务过滤 | TC-SCHED-01 通过 | + +### P1 验收建议 + +- TC-ACC-01(providers API)通过 +- TC-EXP-01(导出格式)通过 +- E2E-05~E2E-09(至少用 QQ 或 163 真实账号完成) + +--- + +*文档版本:V1.0 | 状态:草案* diff --git a/docs/TODO/TODO-00005-多邮箱统一管理.md b/docs/TODO/TODO-00005-多邮箱统一管理.md new file mode 100644 index 0000000..d40277b --- /dev/null +++ b/docs/TODO/TODO-00005-多邮箱统一管理.md @@ -0,0 +1,426 @@ +# TODO-00005|多邮箱统一管理 — 任务待办清单 + +- **文档状态**: 草案 +- **版本**: V1.0 +- **日期**: 2026-03-02 +- **对齐 PRD**: `docs/PRD/PRD-00005-多邮箱统一管理.md` +- **对齐 FD**: `docs/FD/FD-00005-多邮箱统一管理.md` +- **对齐 TDD**: `docs/TDD/TDD-00005-多邮箱统一管理.md` + +--- + +## 前置准备 + +### 0.1 文档确认 + +- [x] PRD 文档已完成(`docs/PRD/PRD-00005-多邮箱统一管理.md`) +- [x] FD 文档已完成(`docs/FD/FD-00005-多邮箱统一管理.md`) +- [x] TDD 文档已完成(`docs/TDD/TDD-00005-多邮箱统一管理.md`) +- [ ] 阅读并理解 PRD / FD / TDD 所有约束条款 +- [ ] 明确 P0 验收标准(TDD §12.2) + +### 0.2 环境准备 + +- [ ] 确认开发环境正常(Python、Flask、SQLite) +- [ ] 确认依赖已安装:`pip install -r requirements.txt` +- [ ] 确认数据库文件存在且可访问:`data/outlook_accounts.db` + +### 0.3 基线测试(改动前必做) + +- [ ] 运行所有现有测试:`python -m unittest discover -s tests -v` +- [ ] 记录当前全部测试通过的数量(作为基线) +- [ ] 手动验证以下核心功能正常: + - [ ] Outlook 账号登录 / 邮件读取 / 验证码提取 + - [ ] Graph API 正常 → IMAP XOAUTH2 回退正常 + - [ ] 批量导入 `email----password----client_id----refresh_token` 格式正常 + - [ ] 定时刷新任务正常 + +### 0.4 Git 准备 + +- [ ] 创建功能分支:`git checkout -b feature/multi-mailbox-support` +- [ ] 确认工作区干净:`git status` +- [ ] 记录当前 commit hash(用于回滚参考) + +--- + +## P0(必须完成) + +--- + +### 阶段 1:后端基础 + +**目标**:完成数据库迁移、邮箱提供商常量、通用 IMAP 服务。 +**涉及文件**:`db.py`、`services/providers.py`(新增)、`services/imap_generic.py`(新增) + +--- + +#### 1.1 `outlook_web/db.py` — Schema v2 → v3 迁移 + +- [ ] 将 `DB_SCHEMA_VERSION` 从 `2` 改为 `3` +- [ ] 将原有 `PRAGMA table_info(accounts)` 查询与下方新增列检测**合并为一次查询**(避免重复 PRAGMA) +- [ ] 在幂等迁移块中追加以下 5 列(先检测列是否存在,再执行 ALTER): + - [ ] `account_type TEXT DEFAULT 'outlook'` + - [ ] `provider TEXT DEFAULT 'outlook'` + - [ ] `imap_host TEXT` + - [ ] `imap_port INTEGER DEFAULT 993` + - [ ] `imap_password TEXT` +- [ ] 在 `migrate_sensitive_data()` 中追加对 `imap_password` 的加密迁移逻辑(检测 `is_encrypted`,非加密则调用 `encrypt_data`) +- [ ] 验证: + - [ ] 新数据库初始化后 `PRAGMA table_info(accounts)` 包含 5 个新列 + - [ ] 旧数据库升级后,原有 Outlook 账号的 `account_type` 默认为 `'outlook'` + - [ ] `db_schema_version` 在 settings 表中写入为 `3` +- [ ] 提交:`git commit -m "feat: db schema v3, add IMAP columns to accounts"` + +--- + +#### 1.2 `outlook_web/services/providers.py` — 新增文件 + +- [ ] 创建文件 `outlook_web/services/providers.py` +- [ ] 编写 `MAIL_PROVIDERS` 字典常量(含 outlook / gmail / qq / 163 / 126 / yahoo / aliyun / custom 共 8 个 provider) +- [ ] 编写 `PROVIDER_FOLDER_MAP` 字典常量(含 gmail / qq / 163 / yahoo / _default 5 个 provider 的 inbox / junkemail / deleteditems 映射) +- [ ] 编写 `get_imap_folder_candidates(provider, folder)` 函数(返回候选文件夹名列表,缺省退回 `_default`) +- [ ] 编写 `get_provider_list()` 函数(返回固定顺序的 provider 列表供前端使用) +- [ ] 验证: + - [ ] `get_imap_folder_candidates("qq", "junkemail")` 返回 `["Junk", "&V4NXPpCuTvY-"]` + - [ ] `get_imap_folder_candidates("unknown", "inbox")` 退回 `_default`,返回 `["INBOX"]` + - [ ] `get_provider_list()` 返回列表,outlook 位于第一位,custom 位于最后 +- [ ] 提交:`git commit -m "feat: add services/providers.py with MAIL_PROVIDERS constants"` + +--- + +#### 1.3 `outlook_web/services/imap_generic.py` — 新增文件 + +- [ ] 创建文件 `outlook_web/services/imap_generic.py` +- [ ] 编写内部辅助函数 `_resolve_imap_folder(mail, candidates)`(按优先级 SELECT 文件夹,返回第一个成功的文件夹名) +- [ ] 编写 `get_emails_imap_generic(email_addr, imap_password, imap_host, imap_port, folder, provider, skip, top)` 函数: + - [ ] 使用 `imaplib.IMAP4_SSL(imap_host, imap_port)` 建立连接(含超时设置) + - [ ] 使用 `mail.login(email_addr, imap_password)` 登录 + - [ ] 调用 `get_imap_folder_candidates()` 获取候选文件夹,调用 `_resolve_imap_folder()` 探测 + - [ ] 使用 `mail.uid('SEARCH', ...)` 获取 UID 列表,按倒序截取 `skip+top` 封 + - [ ] 使用 `mail.uid('FETCH', ...)` 获取邮件头(subject / from / date / flags) + - [ ] 返回格式:`{"success": True, "emails": [...], "method": "IMAP (Generic)", "has_more": False}` + - [ ] 每封邮件字段:`id`(IMAP UID 字符串)、`subject`、`from`、`date`、`is_read`、`has_attachments`、`body_preview` + - [ ] `finally` 块中调用 `mail.logout()` + - [ ] 错误时返回 `{"success": False, "error": "<脱敏消息>", "error_code": "IMAP_AUTH_FAILED|IMAP_CONNECT_FAILED|IMAP_FOLDER_NOT_FOUND"}` + - [ ] 所有 `except` 块中,错误信息通过 `sanitize_error_details()` 脱敏(不暴露 `imap_password`) +- [ ] 编写 `get_email_detail_imap_generic(email_addr, imap_password, imap_host, imap_port, message_id, folder, provider)` 函数: + - [ ] 以 UID fetch 完整邮件(`BODY[]`) + - [ ] 解析 MIME,提取 `body_text`(text/plain)和 `body_html`(text/html) + - [ ] 失败时返回 `None` +- [ ] 验证: + - [ ] 认证失败时返回 `error_code="IMAP_AUTH_FAILED"`,错误消息不包含密码 + - [ ] 文件夹不存在时返回 `error_code="IMAP_FOLDER_NOT_FOUND"` + - [ ] 正常拉取时 `success=True`,`emails` 为列表 +- [ ] 提交:`git commit -m "feat: add services/imap_generic.py for standard IMAP access"` + +--- + +#### 1.4 阶段 1 验收 + +- [ ] 运行所有测试:`python -m unittest discover -s tests -v` +- [ ] 确认测试数量不少于基线(新增模块不破坏旧测试) +- [ ] 手动确认 Outlook 账号邮件读取功能**完全正常**(回归) +- [ ] 手动确认定时刷新任务**不受影响** + +--- + +### 阶段 2:业务接入 + +**目标**:Repository / Controller / Scheduler 按 account_type 路由分发,IMAP 账号增删改查全流程打通。 +**涉及文件**:`repositories/accounts.py`、`controllers/accounts.py`、`controllers/emails.py`、`services/scheduler.py` + +--- + +#### 2.1 `outlook_web/repositories/accounts.py` — 改造 + +- [ ] 在 `load_accounts()` 的 `for row in rows` 循环中,在现有 `password` / `refresh_token` 解密后,追加 `imap_password` 解密逻辑(解密失败不影响其他字段) +- [ ] 扩展 `add_account()` 函数签名,新增 5 个可选参数:`account_type`、`provider`、`imap_host`、`imap_port`、`imap_password` +- [ ] 在 `add_account()` 中,若 `imap_password` 不为空,调用 `encrypt_data(imap_password)` 后再入库 +- [ ] 在 `add_account()` 中,`INSERT` 语句补充 5 个新字段 +- [ ] 在 `add_account()` 中,增加校验分支: + - [ ] `account_type='imap'`:要求 `imap_password` 非空;`provider='custom'` 时要求 `imap_host` 非空 + - [ ] `account_type='outlook'`(或默认):要求 `client_id` / `refresh_token` 非空 +- [ ] 验证: + - [ ] 添加 IMAP 账号后,DB 中 `imap_password` 为加密串,`client_id` / `refresh_token` 为 `''` + - [ ] `load_accounts()` 返回的账号包含 `account_type`、`provider`、`imap_host`、`imap_port`、`imap_password`(已解密) + - [ ] 现有 Outlook 账号读取行为**不受影响** +- [ ] 提交:`git commit -m "feat: extend repositories/accounts to support IMAP fields"` + +--- + +#### 2.2 `outlook_web/controllers/accounts.py` — 改造 + +##### 2.2.1 `POST /api/accounts` — 解析 IMAP 导入格式 + +- [ ] 读取请求中的 `provider` 字段(默认为 `'outlook'`) +- [ ] 若 `provider` 为 `'outlook'` 或缺失:保持现有 4 段格式解析逻辑不动 +- [ ] 若 `provider` 为其他 IMAP provider,新增分支: + - [ ] 从 `MAIL_PROVIDERS` 读取 `imap_host` / `imap_port` 默认值 + - [ ] 按行解析 `account_string`: + - [ ] 非 custom:2 段格式(`email----imap_password`) + - [ ] custom:4 段格式(`email----imap_password----imap_host----imap_port`) + - [ ] 调用 `add_account(... account_type='imap', provider=provider, imap_host=..., imap_port=..., imap_password=...)`,`password=''`、`client_id=''`、`refresh_token=''` + +##### 2.2.2 `GET /api/providers` — 新增接口 + +- [ ] 注册新路由 `GET /api/providers` +- [ ] 调用 `get_provider_list()` 并返回 `{"success": True, "providers": [...]}` + +##### 2.2.3 导出格式改造 + +- [ ] 在导出逻辑(`GET /api/accounts/export` 和 `POST /api/accounts/export-selected`)中调用改造后的 `_build_export_text()` 函数: + - [ ] Outlook 账号放前,格式:`email----password----client_id----refresh_token` + - [ ] IMAP 账号按 provider 分组放后,非 custom 格式:`email----imap_password----provider` + - [ ] custom 格式:`email----imap_password----custom----imap_host----imap_port` + - [ ] 每个分组前加注释行:`# === Outlook 账号 ===` / `# === IMAP 账号(QQ邮箱)===` +- [ ] 确认导出的"二次验证"机制(export_verify_tokens)**完全不动** + +- [ ] 验证: + - [ ] 混合 Outlook + QQ 账号导出,Outlook 在前,QQ 在后,格式符合 PRD 10.3 + - [ ] `GET /api/providers` 返回 8 个 provider,outlook 在前 + - [ ] 旧 Outlook 批量导入格式仍正常(4 段格式) + - [ ] QQ 邮箱批量导入(2 段格式)正常入库,`imap_password` 为加密串 +- [ ] 提交:`git commit -m "feat: accounts controller supports IMAP import/export and providers API"` + +--- + +#### 2.3 `outlook_web/controllers/emails.py` — 改造 + +##### 2.3.1 `GET /api/emails/` — 邮件列表路由分发 + +- [ ] 在获取账号信息后,读取 `account.get("account_type")` +- [ ] 若 `account_type == 'imap'`:调用 `get_emails_imap_generic(...)`,透传 `folder` / `skip` / `top` 参数 +- [ ] 若 `account_type` 为 `'outlook'` 或 `None`:执行现有逻辑(Graph → XOAUTH2 回退),完全不动 + +##### 2.3.2 `GET /api/emails//extract-verification` — 验证码提取路由分发 + +- [ ] 若 `account_type == 'imap'`: + - [ ] 调用 `get_emails_imap_generic(... folder='inbox', top=1)` 取最新 1 封 + - [ ] 调用 `get_email_detail_imap_generic(...)` 取详情 + - [ ] 调用现有 `extract_verification_info(detail)` 提取验证码(复用,不改) +- [ ] 若 `account_type` 为 `'outlook'` 或 `None`:现有逻辑完全不动 + +##### 2.3.3 `POST /api/emails/delete` — IMAP 禁止远程删除 + +- [ ] 若 `account_type == 'imap'`: + - [ ] 调用 `log_audit(action="delete", resource_type="imap_email", ...)` 记录审计日志 + - [ ] 直接返回 `{"success": False, "error": "IMAP 邮箱不支持远程删除,请在邮箱客户端中操作"}` + - [ ] **不执行任何远程 IMAP 操作** +- [ ] 若 `account_type` 为 `'outlook'` 或 `None`:现有逻辑完全不动 + +- [ ] 验证: + - [ ] Outlook 账号:`GET /api/emails/` 走现有链路,method tag 为 `Graph` 或 `IMAP (XOAUTH2)` + - [ ] IMAP 账号:`GET /api/emails/` 走 `imap_generic`,method 为 `IMAP (Generic)` + - [ ] IMAP 账号删除返回 `success=False` 并含错误消息,无远程副作用 + - [ ] IMAP 账号验证码提取能从最新邮件中提取(或明确返回"未找到") +- [ ] 提交:`git commit -m "feat: emails controller routes by account_type for IMAP support"` + +--- + +#### 2.4 `outlook_web/services/scheduler.py` — 改造 + +- [ ] 找到第 268 行附近的 accounts 查询语句 +- [ ] 将原查询: + ```sql + FROM accounts WHERE status = 'active' + ``` + 改为: + ```sql + FROM accounts + WHERE status = 'active' + AND (account_type = 'outlook' OR account_type IS NULL) + ``` +- [ ] 验证: + - [ ] 存在 IMAP 账号时,定时刷新任务的账号列表中**不包含** `account_type='imap'` 的账号 + - [ ] Outlook 账号的定时刷新**正常执行**,不受影响 +- [ ] 提交:`git commit -m "fix: scheduler skips IMAP accounts in refresh task"` + +--- + +#### 2.5 阶段 2 验收 + +- [ ] 运行所有测试:`python -m unittest discover -s tests -v` +- [ ] 确认测试通过数量不少于基线 +- [ ] 手动回归测试(Outlook 流程全量): + - [ ] Outlook 批量导入(4 段格式)正常 + - [ ] Outlook 邮件读取(Graph → IMAP XOAUTH2 回退)正常 + - [ ] Outlook 验证码提取正常 + - [ ] Outlook 邮件删除(Graph 远程删除)正常 + - [ ] 定时刷新任务正常(IMAP 账号被跳过) +- [ ] 手动测试(IMAP 新功能): + - [ ] QQ 邮箱批量导入(2 段格式)正常入库 + - [ ] QQ 邮箱邮件列表拉取正常(或按预期返回 IMAP 错误) + - [ ] QQ 邮箱验证码提取正常 + - [ ] QQ 邮箱调用删除接口返回"不支持远程删除" + - [ ] `GET /api/providers` 正常返回 + +--- + +### 阶段 3:前端改造 + +**目标**:添加账号表单支持 IMAP provider 选择,账号列表展示 provider tag。 +**涉及文件**:`templates/partials/modals.html`、`static/js/features/accounts.js` + +--- + +#### 3.1 `templates/partials/modals.html` — 添加账号模态框改造 + +- [ ] 在"选择分组"下拉上方(或下方)新增"邮箱类型"下拉,`id="accountProvider"`,绑定 `onchange="onProviderChange(this.value)"` +- [ ] 在 provider 下拉之后新增"自定义 IMAP 配置"区块(`id="customImapFields"`,默认 `display:none`): + - [ ] IMAP 服务器地址输入框(`id="imapHost"`) + - [ ] IMAP 端口输入框(`id="imapPort"`,默认 `993`) +- [ ] 在 textarea 下方新增格式提示 div(`id="accountFormatHint"`,动态更新文字) +- [ ] 确保现有模态框的所有 DOM ID、CSS 类名、其他字段**保持不变** + +--- + +#### 3.2 `static/js/features/accounts.js` — 功能适配 + +##### 3.2.1 页面初始化:加载 providers + +- [ ] 新增 `loadProviders()` 异步函数,调用 `GET /api/providers` 填充 `#accountProvider` 下拉选项 +- [ ] 在添加账号模态框打开时(或页面初始化时)调用 `loadProviders()` +- [ ] 初始化完成后调用一次 `onProviderChange('outlook')` 设置默认提示 + +##### 3.2.2 新增 `onProviderChange(provider)` 函数 + +- [ ] `provider='outlook'`:隐藏 `customImapFields`,textarea placeholder 设为 4 段格式,hint 文字更新 +- [ ] `provider='custom'`:显示 `customImapFields`,textarea placeholder 设为 2 段格式,hint 提示填写服务器 +- [ ] 其他 IMAP provider:隐藏 `customImapFields`,placeholder 设为 2 段格式,hint 提示使用授权码 + +##### 3.2.3 `addAccount()` 请求体适配 + +- [ ] 读取 `#accountProvider` 的当前值 +- [ ] 若 provider 为 `'outlook'` 或空:发送旧格式 `{ account_string, group_id }`(向后兼容,不改) +- [ ] 若 provider 为其他值:发送新格式 `{ provider, account_string, group_id }` +- [ ] 若 provider 为 `'custom'`:检查 `imapHost` 和 `imapPort`(若采用文本批量方式,host/port 内嵌在 account_string 中,无需额外字段) + +##### 3.2.4 账号列表:provider tag 展示 + +- [ ] 新增 `getProviderLabel(provider)` 辅助函数(返回 provider 的中文 label) +- [ ] 在 `renderAccountList()` / 账号卡片模板中,为每个账号追加 provider tag HTML(如 ``) +- [ ] 确认 `GET /api/accounts` 返回的账号对象包含 `account_type` 和 `provider` 字段(已由 repository 改造保证) + +- [ ] 验证: + - [ ] 选择 Outlook,textarea placeholder 显示 4 段格式,自定义 IMAP 配置区不显示 + - [ ] 选择 QQ邮箱,textarea placeholder 显示 2 段格式,hint 提示使用授权码 + - [ ] 选择自定义 IMAP,自定义 IMAP 配置区显示 + - [ ] 使用 Outlook 格式导入账号,功能正常(回归) + - [ ] 使用 QQ 格式导入账号,request body 包含 `provider: 'qq'` + - [ ] 账号列表中 QQ 账号卡片显示 `[QQ邮箱]` tag,Outlook 账号显示 `[Outlook]` tag +- [ ] 提交:`git commit -m "feat: frontend add provider selector and IMAP account support"` + +--- + +#### 3.3 阶段 3 验收 + +- [ ] 运行所有测试:`python -m unittest discover -s tests -v` +- [ ] 确认测试通过数量不少于基线 +- [ ] 手动端到端测试(完整流程): + - [ ] 打开添加账号模态框,默认为 Outlook,格式提示正确 + - [ ] 切换到 QQ邮箱,提示变为授权码格式 + - [ ] 切换到自定义 IMAP,显示服务器地址/端口输入框 + - [ ] 使用 Outlook 格式成功导入 Outlook 账号,账号列表显示 `[Outlook]` tag + - [ ] 使用 QQ 格式成功导入 QQ 账号,账号列表显示 `[QQ邮箱]` tag + - [ ] 点击 QQ 账号,能拉取邮件列表(或返回 IMAP 错误提示) + - [ ] 点击 QQ 账号邮件,能获取详情 + - [ ] 对 QQ 账号执行删除,前端收到"不支持远程删除"提示 + +--- + +### 阶段 4:P0 全量验收 + +- [ ] **旧 Outlook 账号**:`GET /api/emails/` 仍优先 Graph,失败后仍可 XOAUTH2 IMAP 回退 ✅ +- [ ] **旧 Outlook 批量导入格式**仍可用:`email----password----client_id----refresh_token` ✅ +- [ ] **新增 IMAP 账号可导入**(QQ / 163 / Gmail 任一)并可拉取邮件列表 / 详情 ✅ +- [ ] **新增 IMAP 账号可执行验证码提取**(成功提取或明确返回"未找到") ✅ +- [ ] **IMAP 账号调用删除接口**返回"不支持远程删除",且无远程副作用 ✅ +- [ ] **定时刷新任务不处理 IMAP 账号** ✅ +- [ ] **`imap_password` 在 DB 中为加密串**(不以明文出现) ✅ +- [ ] 运行所有测试全部通过 ✅ + +--- + +## P1(建议完成) + +### 5.1 `GET /api/providers` 前端联动 + +- [ ] 确认 `GET /api/providers` 正常返回 8 个 provider +- [ ] 确认前端下拉选项来自 API(而非硬编码),避免重复维护 + +### 5.2 导出文件按 Outlook / IMAP provider 分类 + +- [ ] 确认导出文件格式符合 PRD 10.3(含分组注释行) +- [ ] 确认混合账号导出,Outlook 在前,IMAP 按 provider 分组在后 + +### 5.3 账号列表可清晰区分 Outlook 与 IMAP + +- [ ] 确认各类账号在列表中有可见的 provider tag +- [ ] 账号卡片视觉区分明确,用户可快速识别邮箱类型 + +--- + +## P2(可选) + +### 6.1 IMAP 文件夹映射验证 + +- [ ] 测试 Gmail / QQ / 163 至少一个 provider 的垃圾邮件文件夹映射正常 +- [ ] 测试 Gmail / QQ / 163 至少一个 provider 的已删除邮件文件夹映射正常 +- [ ] 验证 QQ/163 的 IMAP UTF-7 编码文件夹名(如 `&V4NXPpCuTvY-`)能被正确 SELECT + +### 6.2 IMAP 错误提示精细化 + +- [ ] 认证失败时前端提示明确(如"授权码错误,请重新生成") +- [ ] 连接失败时前端提示明确(如"服务器不可达,请检查网络") +- [ ] 文件夹不存在时提示明确 + +--- + +## 风险与注意事项 + +| 风险点 | 应对措施 | +|---|---| +| DB 迁移失败 | 迁移前打印备份提示(现有机制已有);迁移只做 ADD COLUMN,可安全回滚至 DB 备份 | +| IMAP 连接阻塞 | `socket.setdefaulttimeout(30)` 设置全局超时;IMAP 错误不影响同分组 Outlook 账号 | +| imap_password 明文泄露 | 所有 except 块调用 `sanitize_error_details()`;入库时必须先 `encrypt_data()` | +| 旧 Outlook 流程回归 | 每个阶段结束后必须回归测试 Outlook 邮件读取 + 验证码提取 + 定时刷新 | +| 前端请求体不兼容 | Outlook 分支发送旧格式,向后兼容;后端按 `provider` 字段是否存在做区分 | + +--- + +## 进度跟踪 + +### 总体进度 + +- [ ] 前置准备(0%) +- [ ] 阶段 1:后端基础(0%) +- [ ] 阶段 2:业务接入(0%) +- [ ] 阶段 3:前端改造(0%) +- [ ] 阶段 4:P0 全量验收(0%) + +### 文件改造进度 + +| 文件 | 状态 | +|---|---| +| `outlook_web/db.py` | ⬜ 待开始 | +| `outlook_web/services/providers.py`(新增) | ⬜ 待开始 | +| `outlook_web/services/imap_generic.py`(新增) | ⬜ 待开始 | +| `outlook_web/repositories/accounts.py` | ⬜ 待开始 | +| `outlook_web/controllers/accounts.py` | ⬜ 待开始 | +| `outlook_web/controllers/emails.py` | ⬜ 待开始 | +| `outlook_web/services/scheduler.py` | ⬜ 待开始 | +| `templates/partials/modals.html` | ⬜ 待开始 | +| `static/js/features/accounts.js` | ⬜ 待开始 | + +--- + +## 参考文档 + +- [PRD-00005-多邮箱统一管理](../PRD/PRD-00005-多邮箱统一管理.md) +- [FD-00005-多邮箱统一管理](../FD/FD-00005-多邮箱统一管理.md) +- [TDD-00005-多邮箱统一管理](../TDD/TDD-00005-多邮箱统一管理.md) + +--- + +**文档版本:** V1.0 +**最后更新:** 2026-03-02 diff --git a/docs/待办事项.md b/docs/待办事项.md new file mode 100644 index 0000000..4606bec --- /dev/null +++ b/docs/待办事项.md @@ -0,0 +1,69 @@ +# 待办事项(Backlog) + +> 最后更新:2026-03-03 +> 说明:用于记录已完成需求后的遗留问题、技术债与下一步优化项(按优先级排序)。 + +--- + +## TODO-00005|多邮箱统一管理 — 遗留问题/技术债 + +### 总体评价 + +P0 主链路已完成并通过全量单测回归(导入/导出/邮件列表/详情/验证码提取/IMAP 禁删/调度任务跳过 IMAP)。 +当前主要风险集中在 **IMAP(Generic) 列表拉取的性能** 与 **前后端 provider 展示的单一数据源**。 + +### 具体问题列表(按严重程度排序) + +1) **IMAP(Generic) 邮件列表拉取性能/带宽风险(高)** +- 现状:`outlook_web/services/imap_generic.py` 列表为生成 `body_preview` 与 `has_attachments`,对每封邮件使用 `UID FETCH ... RFC822` 拉取整封邮件。 +- 影响:遇到大邮件/多邮件时列表加载显著变慢;后端 CPU/内存/网络开销偏大;可能触发 IMAP 服务端限流/断连。 +- 关联文件:`outlook_web/services/imap_generic.py` + +2) **IMAP 账号“编辑/刷新”链路未适配(中)** +- 现状:前端编辑账号校验仍强制 `client_id/refresh_token`;Repo 的 `update_account()` 也要求非空,IMAP 账号不满足。 +- 影响:IMAP 账号导入后若需修改授权码/服务器配置,缺少一致的编辑入口(只能删除重导)。 +- 关联文件:`static/js/features/accounts.js`、`outlook_web/repositories/accounts.py`、`outlook_web/controllers/accounts.py` + +3) **provider label 存在“双维护”(低)** +- 现状:后端 `MAIL_PROVIDERS` 定义 label;前端 `getProviderLabel()` 又硬编码一份。 +- 影响:后续新增/修改 provider 文案容易不一致。 +- 关联文件:`outlook_web/services/providers.py`、`static/js/features/groups.js` + +4) **IMAP 连接超时设置的全局副作用(低)** +- 现状:为兼容旧版本 `imaplib.IMAP4_SSL`,在不支持 `timeout=` 参数时会调用 `socket.setdefaulttimeout(30)`(全局)。 +- 影响:可能影响进程内其他网络连接的默认超时语义,增加排查成本。 +- 关联文件:`outlook_web/services/imap_generic.py` + +### 改进建议和示例代码 + +#### A) IMAP(Generic) 列表拉取改为“轻量 FETCH”(建议优先) + +目标:列表接口只拉取必要信息(FLAGS + HEADER + 预览片段),避免整封 RFC822。 + +建议方向(伪代码): + +```python +# 仅抓取必要字段:FLAGS + 头部 + BODYSTRUCTURE + 正文前 N 字节(按需) +mail.uid( + "FETCH", + uid, + "(FLAGS BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)] BODYSTRUCTURE BODY.PEEK[TEXT]<0.2048>)", +) +``` + +验收标准(建议写入任务卡): +- 列表接口不再使用 `RFC822` 拉取整封邮件; +- `body_preview` 来自 partial fetch(或仅 header+subject 预览兜底); +- `has_attachments` 使用 `BODYSTRUCTURE`/part 判断(或降级为“未知/False”并在详情再判定)。 + +#### B) IMAP 账号编辑能力(P1) + +建议: +- 后端为 IMAP 账号新增更新接口或在 `update_account()` 增加 `account_type` 分支校验; +- 前端编辑模态框按 `account_type/provider` 动态展示字段(授权码/host/port)。 + +#### C) provider label 单一来源 + +建议: +- 账号列表 tag 展示优先使用 `GET /api/providers` 返回的 `label`(由前端缓存映射),移除硬编码 `getProviderLabel()` 或仅作为兜底。 + diff --git a/outlook_web/app.py b/outlook_web/app.py index 5d0e173..8ec95ee 100644 --- a/outlook_web/app.py +++ b/outlook_web/app.py @@ -95,6 +95,19 @@ def create_app(*, autostart_scheduler: Optional[bool] = None): # DB teardown(请求结束释放连接) register_db(app) + # 配置日志(确保 outlook_web 命名空间的日志输出到 stderr) + import logging + import sys + + _log_handler = logging.StreamHandler(sys.stderr) + _log_handler.setFormatter( + logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%H:%M:%S") + ) + _ow_logger = logging.getLogger("outlook_web") + if not _ow_logger.handlers: + _ow_logger.addHandler(_log_handler) + _ow_logger.setLevel(logging.INFO) + # CSRF(可选) _csrf, csrf_exempt, _generate_csrf = init_csrf(app) diff --git a/outlook_web/controllers/accounts.py b/outlook_web/controllers/accounts.py index 79cffa8..629cb86 100644 --- a/outlook_web/controllers/accounts.py +++ b/outlook_web/controllers/accounts.py @@ -109,6 +109,8 @@ def api_get_accounts() -> Any: { "id": acc["id"], "email": acc["email"], + "account_type": acc.get("account_type") or "outlook", + "provider": acc.get("provider") or "outlook", "client_id": ( acc["client_id"][:8] + "..." if len(acc["client_id"]) > 8 @@ -157,6 +159,8 @@ def api_get_account(account_id: int) -> Any: "group_name": account.get("group_name", "默认分组"), "remark": account.get("remark", ""), "status": account.get("status", "active"), + "account_type": account.get("account_type") or "outlook", + "provider": account.get("provider") or "outlook", "created_at": account.get("created_at", ""), "updated_at": account.get("updated_at", ""), }, @@ -167,9 +171,12 @@ def api_get_account(account_id: int) -> Any: @login_required def api_add_account() -> Any: """添加账号""" - data = request.json + data = request.json or {} account_str = data.get("account_string", "") group_id = data.get("group_id", 1) + provider = (data.get("provider") or "outlook").strip().lower() + custom_imap_host = (data.get("imap_host") or "").strip() + custom_imap_port = data.get("imap_port") if not account_str: return jsonify({"success": False, "error": "请输入账号信息"}) @@ -206,6 +213,9 @@ def api_add_account() -> Any: } return None + def is_comment_line(line: str) -> bool: + return bool(line) and line.lstrip().startswith("#") + # 支持批量导入(多行)+ 逐行校验与错误定位 raw_lines = account_str.splitlines() imported = 0 @@ -215,10 +225,203 @@ def api_add_account() -> Any: max_error_details = 50 db = get_db() + + # -------------------- IMAP provider 导入分支 -------------------- + # 对齐:PRD-00005 / FD-00005 / TDD-00005 + # 约束:不改动 Outlook 旧格式;IMAP 账号使用 client_id/refresh_token 空字符串占位(DB NOT NULL 约束不变)。 + if provider and provider != "outlook": + from outlook_web.services.providers import MAIL_PROVIDERS + + provider_cfg = MAIL_PROVIDERS.get(provider, {}) + default_imap_host = (provider_cfg.get("imap_host") or "").strip() + default_imap_port = int(provider_cfg.get("imap_port") or 993) + + # custom 可从 request body 提供全局 host/port(兼容前端“自定义 IMAP 配置”输入) + if provider == "custom": + if custom_imap_port is None or str(custom_imap_port).strip() == "": + custom_port_val = 993 + else: + try: + custom_port_val = int(str(custom_imap_port).strip()) + except Exception: + custom_port_val = 993 + else: + custom_port_val = None + + for line_no, raw in enumerate(raw_lines, start=1): + line = (raw or "").strip() + if not line or is_comment_line(line): + continue + + parts = [p.strip() for p in line.split("----")] + email_addr = sanitize_credential_field(parts[0] if len(parts) > 0 else "", 320) + imap_pwd = sanitize_credential_field(parts[1] if len(parts) > 1 else "", 500) + + if len(parts) < 2 or not email_addr or not imap_pwd: + failed += 1 + errors_total += 1 + if len(errors) < max_error_details: + errors.append( + { + "line": line_no, + "email": email_addr, + "error": "格式错误,应为:邮箱----IMAP授权码/应用密码(custom 可包含 host/port)", + } + ) + 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 + + imap_host = default_imap_host + imap_port = default_imap_port + + if provider == "custom": + # 兼容两类输入: + # 1) 5 段(导出格式):email----imap_password----custom----imap_host----imap_port + # 2) 4 段(文本批量):email----imap_password----imap_host----imap_port + # 3) 2 段(配合输入框):email----imap_password(host/port 从 request body 取) + if len(parts) >= 5 and (parts[2] or "").strip().lower() == "custom": + imap_host = (parts[3] or "").strip() + try: + imap_port = int((parts[4] or "").strip() or 993) + except Exception: + imap_port = 993 + elif len(parts) >= 4: + imap_host = (parts[2] or "").strip() + try: + imap_port = int((parts[3] or "").strip() or 993) + except Exception: + imap_port = 993 + else: + imap_host = custom_imap_host + imap_port = custom_port_val if custom_port_val is not None else 993 + + if not imap_host: + failed += 1 + errors_total += 1 + if len(errors) < max_error_details: + errors.append( + { + "line": line_no, + "email": email_addr, + "error": "自定义 IMAP 必须提供服务器地址(imap_host)", + } + ) + continue + else: + # 兼容导出格式:email----imap_password----provider + if len(parts) >= 3: + line_provider = (parts[2] or "").strip().lower() + if line_provider and line_provider != provider: + # 明确不一致:提示用户切换 provider 或修正文本 + failed += 1 + errors_total += 1 + if len(errors) < max_error_details: + errors.append( + { + "line": line_no, + "email": email_addr, + "error": f"provider 不匹配:当前选择 {provider},文本为 {line_provider}", + } + ) + continue + + if not imap_host: + failed += 1 + errors_total += 1 + if len(errors) < max_error_details: + errors.append( + { + "line": line_no, + "email": email_addr, + "error": "未找到该 provider 的默认 IMAP 配置,请使用自定义 IMAP", + } + ) + continue + + ok = accounts_repo.add_account( + email_addr, + password="", + client_id="", + refresh_token="", + group_id=group_id, + remark="", + account_type="imap", + provider=provider, + imap_host=imap_host, + imap_port=imap_port, + imap_password=imap_pwd, + 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},provider={provider}", + ) + return jsonify( + {"success": True, "message": message, "summary": summary, "errors": errors} + ) + + return jsonify( + {"success": False, "error": message, "summary": summary, "errors": errors} + ) + + # -------------------- Outlook(旧格式)导入分支:保持现有逻辑完全不动 -------------------- for line_no, raw in enumerate(raw_lines, start=1): line = (raw or "").strip() if not line: continue + if is_comment_line(line): + continue parsed = parse_account_string(line) if not parsed: @@ -319,6 +522,14 @@ def api_add_account() -> Any: ) +@login_required +def api_get_providers() -> Any: + """返回邮箱提供商列表,用于前端下拉选择(PRD-00005 / TDD-00005)""" + from outlook_web.services.providers import get_provider_list + + return jsonify({"success": True, "providers": get_provider_list()}) + + @login_required def api_update_account(account_id: int) -> Any: """更新账号""" @@ -688,6 +899,8 @@ def api_search_accounts() -> Any: { "id": acc["id"], "email": acc["email"], + "account_type": acc.get("account_type") or "outlook", + "provider": acc.get("provider") or "outlook", "client_id": ( acc["client_id"][:8] + "..." if len(acc["client_id"]) > 8 @@ -715,6 +928,59 @@ def api_search_accounts() -> Any: # ==================== 导出功能 API ==================== +def _build_export_text(accounts: List[Dict[str, Any]]) -> str: + from outlook_web.services.providers import MAIL_PROVIDERS, get_provider_list + + outlook_lines: List[str] = [] + imap_groups: Dict[str, List[str]] = {} + + for acc in accounts or []: + atype = (acc.get("account_type") or "outlook").strip().lower() + if atype == "outlook": + line = f"{acc.get('email','')}----{acc.get('password','')}----{acc.get('client_id','')}----{acc.get('refresh_token','')}" + outlook_lines.append(line) + continue + + provider = (acc.get("provider") or "custom").strip().lower() + imap_pwd = acc.get("imap_password", "") or "" + if provider == "custom": + line = f"{acc.get('email','')}----{imap_pwd}----{provider}----{acc.get('imap_host','') or ''}----{acc.get('imap_port', 993) or 993}" + else: + line = f"{acc.get('email','')}----{imap_pwd}----{provider}" + + imap_groups.setdefault(provider, []).append(line) + + parts: List[str] = [] + if outlook_lines: + parts.append("# === Outlook 账号 ===") + parts.extend(outlook_lines) + + provider_order = [p.get("key") for p in get_provider_list() if p.get("key")] + provider_order = [p for p in provider_order if p != "outlook"] + + appended = set() + for provider in provider_order: + lines = imap_groups.get(provider) or [] + if not lines: + continue + label = (MAIL_PROVIDERS.get(provider, {}) or {}).get("label", provider) + if parts: + parts.append("") + parts.append(f"# === IMAP 账号({label})===") + parts.extend(lines) + appended.add(provider) + + for provider, lines in imap_groups.items(): + if provider in appended: + continue + label = (MAIL_PROVIDERS.get(provider, {}) or {}).get("label", provider) + if parts: + parts.append("") + parts.append(f"# === IMAP 账号({label})===") + parts.extend(lines) + + return "\n".join(parts).strip() + "\n" + @login_required def api_export_all_accounts() -> Any: @@ -746,13 +1012,7 @@ def api_export_all_accounts() -> Any: # 记录审计日志 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) + content = _build_export_text(accounts) # 生成文件名(使用 URL 编码处理中文) filename = f"all_accounts_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" @@ -777,9 +1037,9 @@ def api_export_selected_accounts() -> Any: get_user_agent, ) - data = request.json + data = request.json or {} group_ids = data.get("group_ids", []) - verify_token = data.get("verify_token") + verify_token = request.headers.get("X-Export-Token") or data.get("verify_token") client_ip = get_client_ip() user_agent = get_user_agent() @@ -810,13 +1070,7 @@ def api_export_selected_accounts() -> Any: 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) + content = _build_export_text(all_accounts) # 生成文件名 filename = f"selected_accounts_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" diff --git a/outlook_web/controllers/emails.py b/outlook_web/controllers/emails.py index 912f8c5..c4a1dd5 100644 --- a/outlook_web/controllers/emails.py +++ b/outlook_web/controllers/emails.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from typing import Any from flask import jsonify, request @@ -14,6 +15,9 @@ from outlook_web.security.auth import login_required from outlook_web.services import email_delete as email_delete_service from outlook_web.services import graph as graph_service from outlook_web.services import imap as imap_service +from outlook_web.services.imap_generic import get_email_detail_imap_generic, get_emails_imap_generic + +_LOGGER = logging.getLogger("outlook_web.controllers.emails") # IMAP 服务器配置 IMAP_SERVER_OLD = "outlook.office365.com" @@ -42,6 +46,21 @@ def api_get_emails(email_addr: str) -> Any: skip = int(request.args.get("skip", 0)) top = int(request.args.get("top", 20)) + # PRD-00005 / FD-00005 / TDD-00005:按 account_type 路由分发(Outlook 链路保持原样,IMAP 走通用 IMAP 服务) + account_type = (account.get("account_type") or "outlook").strip().lower() + if account_type == "imap": + result = get_emails_imap_generic( + email_addr=email_addr, + imap_password=account.get("imap_password", "") or "", + imap_host=account.get("imap_host", "") or "", + imap_port=account.get("imap_port", 993) or 993, + folder=folder, + provider=account.get("provider", "_default") or "_default", + skip=skip, + top=top, + ) + return jsonify(result) + # 获取分组代理设置 proxy_url = "" if account.get("group_id"): @@ -178,6 +197,18 @@ def api_delete_emails() -> Any: if not account: return jsonify({"success": False, "error": "账号不存在"}) + # PRD-00005:IMAP 账号不支持远程删除(避免误操作与跨厂商副作用) + account_type = (account.get("account_type") or "outlook").strip().lower() + if account_type == "imap": + error_payload = build_error_payload( + "IMAP_DELETE_NOT_SUPPORTED", + "IMAP 邮箱不支持远程删除,请在邮箱客户端中操作", + "NotSupportedError", + 400, + f"email={email_addr}", + ) + return jsonify({"success": False, "error": error_payload}), 400 + # 获取分组代理设置 proxy_url = "" if account.get("group_id"): @@ -219,13 +250,34 @@ def api_delete_emails() -> Any: @login_required def api_get_email_detail(email_addr: str, message_id: str) -> Any: """获取邮件详情""" + _LOGGER.info("email_detail_request email=%s message_id=%s", email_addr, message_id) account = accounts_repo.get_account_by_email(email_addr) if not account: + _LOGGER.warning("email_detail_account_not_found email=%s", email_addr) return jsonify({"success": False, "error": "账号不存在"}) - method = request.args.get("method", "graph") + account_type = (account.get("account_type") or "outlook").strip().lower() folder = request.args.get("folder", "inbox") + _LOGGER.info("email_detail_type=%s provider=%s folder=%s", account_type, account.get("provider", "N/A"), folder) + + if account_type == "imap": + detail = get_email_detail_imap_generic( + email_addr=email_addr, + imap_password=account.get("imap_password", "") or "", + imap_host=account.get("imap_host", "") or "", + imap_port=account.get("imap_port", 993) or 993, + message_id=message_id, + folder=folder, + provider=account.get("provider", "_default") or "_default", + ) + if detail: + _LOGGER.info("email_detail_imap_ok email=%s subject=%s", email_addr, detail.get("subject", "?")[:40]) + return jsonify({"success": True, "email": detail}) + _LOGGER.warning("email_detail_imap_failed email=%s message_id=%s", email_addr, message_id) + return jsonify({"success": False, "error": "获取邮件详情失败"}) + + method = request.args.get("method", "graph") if method == "graph": # 获取分组代理设置 @@ -309,6 +361,88 @@ def api_extract_verification(email_addr: str) -> Any: ) return jsonify({"success": False, "error": error_payload}), 404 + # PRD-00005:IMAP 账号验证码提取走 IMAP(Generic)→ 详情 → extractor;Outlook 保持原 Graph→IMAP XOAUTH2 回退链 + account_type = (account.get("account_type") or "outlook").strip().lower() + if account_type == "imap": + emails_result = get_emails_imap_generic( + email_addr=email_addr, + imap_password=account.get("imap_password", "") or "", + imap_host=account.get("imap_host", "") or "", + imap_port=account.get("imap_port", 993) or 993, + folder="inbox", + provider=account.get("provider", "_default") or "_default", + skip=0, + top=1, + ) + + if not emails_result.get("success"): + error_payload = build_error_payload( + "EMAIL_FETCH_FAILED", + "获取邮件失败", + "IMAPError", + 500, + emails_result, + ) + return jsonify({"success": False, "error": error_payload}), 500 + + emails = emails_result.get("emails") or [] + if not emails: + error_payload = build_error_payload( + "EMAIL_NOT_FOUND", + "未找到邮件", + "NotFoundError", + 404, + f"email={email_addr}", + ) + return jsonify({"success": False, "error": error_payload}), 404 + + latest_email = emails[0] + detail = get_email_detail_imap_generic( + email_addr=email_addr, + imap_password=account.get("imap_password", "") or "", + imap_host=account.get("imap_host", "") or "", + imap_port=account.get("imap_port", 993) or 993, + message_id=latest_email.get("id") or "", + folder="inbox", + provider=account.get("provider", "_default") or "_default", + ) + + if not detail: + error_payload = build_error_payload( + "EMAIL_DETAIL_NOT_FOUND", + "获取邮件详情失败", + "NotFoundError", + 404, + f"email={email_addr}", + ) + return jsonify({"success": False, "error": error_payload}), 404 + + # 构建邮件对象用于提取(避免把 HTML 放进 body 导致 extractor 不走 HTML->text) + email_obj = { + "subject": detail.get("subject", ""), + "body": detail.get("body_text", ""), + "body_html": detail.get("body_html", ""), + "body_preview": latest_email.get("body_preview", ""), + } + + try: + result = extract_verification_info(email_obj) + return jsonify({"success": True, "data": result, "message": "提取成功"}) + except ValueError as e: + error_payload = build_error_payload( + "VERIFICATION_NOT_FOUND", + str(e), + "NotFoundError", + 404, + f"email={email_addr}", + ) + return jsonify({"success": False, "error": error_payload}), 404 + except Exception as e: + error_payload = build_error_payload( + "EXTRACT_ERROR", "提取失败", "ExtractError", 500, str(e) + ) + return jsonify({"success": False, "error": error_payload}), 500 + # 获取分组代理设置 proxy_url = "" if account.get("group_id"): diff --git a/outlook_web/db.py b/outlook_web/db.py index 9ebca1d..8244095 100644 --- a/outlook_web/db.py +++ b/outlook_web/db.py @@ -17,7 +17,8 @@ from outlook_web.security.crypto import ( ) # 数据库 Schema 版本(用于升级可验证/可诊断) -DB_SCHEMA_VERSION = 2 +# v3:对齐 PRD-00005 / FD-00005 / TDD-00005(accounts 表新增多邮箱字段:account_type/provider/imap_host/imap_port/imap_password) +DB_SCHEMA_VERSION = 3 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" @@ -186,6 +187,11 @@ def init_db(database_path: Optional[str] = None): password TEXT, client_id TEXT NOT NULL, refresh_token TEXT NOT NULL, + account_type TEXT DEFAULT 'outlook', + provider TEXT DEFAULT 'outlook', + imap_host TEXT, + imap_port INTEGER DEFAULT 993, + imap_password TEXT, group_id INTEGER, remark TEXT, status TEXT DEFAULT 'active', @@ -394,6 +400,20 @@ def init_db(database_path: Optional[str] = None): ) if "last_refresh_at" not in columns: cursor.execute("ALTER TABLE accounts ADD COLUMN last_refresh_at TIMESTAMP") + if "account_type" not in columns: + cursor.execute( + "ALTER TABLE accounts ADD COLUMN account_type TEXT DEFAULT 'outlook'" + ) + if "provider" not in columns: + cursor.execute("ALTER TABLE accounts ADD COLUMN provider TEXT DEFAULT 'outlook'") + if "imap_host" not in columns: + cursor.execute("ALTER TABLE accounts ADD COLUMN imap_host TEXT") + if "imap_port" not in columns: + cursor.execute( + "ALTER TABLE accounts ADD COLUMN imap_port INTEGER DEFAULT 993" + ) + if "imap_password" not in columns: + cursor.execute("ALTER TABLE accounts ADD COLUMN imap_password TEXT") cursor.execute("PRAGMA table_info(groups)") group_columns = [col[1] for col in cursor.fetchall()] @@ -644,14 +664,15 @@ def migrate_sensitive_data(conn: sqlite3.Connection): cursor = conn.cursor() # 获取所有账号 - cursor.execute("SELECT id, password, refresh_token FROM accounts") + cursor.execute("SELECT id, password, refresh_token, imap_password FROM accounts") accounts = cursor.fetchall() migrated_count = 0 - for account_id, password, refresh_token in accounts: + for account_id, password, refresh_token, imap_password in accounts: needs_update = False new_password = password new_refresh_token = refresh_token + new_imap_password = imap_password # 检查并加密 password if password and not is_encrypted(password): @@ -663,15 +684,20 @@ def migrate_sensitive_data(conn: sqlite3.Connection): new_refresh_token = encrypt_data(refresh_token) needs_update = True + # 检查并加密 imap_password + if imap_password and not is_encrypted(imap_password): + new_imap_password = encrypt_data(imap_password) + needs_update = True + # 更新数据库 if needs_update: cursor.execute( """ UPDATE accounts - SET password = ?, refresh_token = ? + SET password = ?, refresh_token = ?, imap_password = ? WHERE id = ? """, - (new_password, new_refresh_token, account_id), + (new_password, new_refresh_token, new_imap_password, account_id), ) migrated_count += 1 diff --git a/outlook_web/repositories/accounts.py b/outlook_web/repositories/accounts.py index 036f7cc..5359c2c 100644 --- a/outlook_web/repositories/accounts.py +++ b/outlook_web/repositories/accounts.py @@ -75,6 +75,11 @@ def load_accounts(group_id: int = None) -> List[Dict]: account["refresh_token"] = decrypt_data(account["refresh_token"]) except Exception: pass + if account.get("imap_password"): + try: + account["imap_password"] = decrypt_data(account["imap_password"]) + except Exception: + pass account_id_value = account.get("id") try: @@ -109,6 +114,11 @@ def get_account_by_email(email_addr: str) -> Optional[Dict]: account["refresh_token"] = decrypt_data(account["refresh_token"]) except Exception: pass + if account.get("imap_password"): + try: + account["imap_password"] = decrypt_data(account["imap_password"]) + except Exception: + pass return account @@ -138,6 +148,11 @@ def get_account_by_id(account_id: int) -> Optional[Dict]: account["refresh_token"] = decrypt_data(account["refresh_token"]) except Exception: pass + if account.get("imap_password"): + try: + account["imap_password"] = decrypt_data(account["imap_password"]) + except Exception: + pass return account @@ -148,27 +163,59 @@ def add_account( refresh_token: str, group_id: int = 1, remark: str = "", + account_type: str = "outlook", + provider: str = "outlook", + imap_host: str = "", + imap_port: int = 993, + imap_password: str = "", db: Optional[sqlite3.Connection] = None, commit: bool = True, ) -> bool: """添加邮箱账号(支持外部事务批量导入)""" db = db or get_db() try: + account_type = (account_type or "outlook").strip().lower() + provider = (provider or ("outlook" if account_type != "imap" else "custom")).strip().lower() + + # PRD-00005 / TDD-00005: + # - Outlook:必须提供 client_id/refresh_token(OAuth2) + # - IMAP:必须提供 imap_password;client_id/refresh_token 在 DB 中使用空字符串占位(保持 NOT NULL 约束) + if account_type == "imap": + if not (imap_password or "").strip(): + return False + if provider == "custom" and not (imap_host or "").strip(): + return False + else: + if not (client_id or "").strip() or not (refresh_token or "").strip(): + return False + encrypted_password = encrypt_data(password) if password else password encrypted_refresh_token = ( encrypt_data(refresh_token) if refresh_token else refresh_token ) + encrypted_imap_password = ( + encrypt_data(imap_password) if imap_password else imap_password + ) db.execute( """ - INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO accounts ( + email, password, client_id, refresh_token, + account_type, provider, imap_host, imap_port, imap_password, + group_id, remark + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( email_addr, encrypted_password, - client_id, + client_id or "", encrypted_refresh_token, + account_type, + provider, + imap_host or "", + int(imap_port) if imap_port else 993, + encrypted_imap_password, group_id, remark, ), @@ -197,7 +244,7 @@ def update_account( try: existing = db.execute( """ - SELECT password, client_id, refresh_token + SELECT password, client_id, refresh_token, account_type, imap_password FROM accounts WHERE id = ? """, @@ -206,6 +253,41 @@ def update_account( if not existing: return False + account_type = (existing["account_type"] or "outlook").strip().lower() + + # PRD-00005 / TDD-00005:IMAP 账号不要求 client_id/refresh_token(DB 约束使用空字符串占位) + # 允许更新:email/group/remark/status;如用户在 UI 的“密码”栏输入内容,则视为更新 imap_password。 + if account_type == "imap": + encrypted_imap_password = existing["imap_password"] + if isinstance(password, str) and password.strip(): + encrypted_imap_password = encrypt_data(password) + + if not email_addr: + return False + + db.execute( + """ + UPDATE accounts + SET email = ?, + imap_password = ?, + group_id = ?, + remark = ?, + status = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + ( + email_addr, + encrypted_imap_password, + group_id, + remark, + status, + account_id, + ), + ) + db.commit() + return True + new_client_id = ( client_id.strip() if isinstance(client_id, str) and client_id.strip() diff --git a/outlook_web/routes/accounts.py b/outlook_web/routes/accounts.py index e1dc222..74185e8 100644 --- a/outlook_web/routes/accounts.py +++ b/outlook_web/routes/accounts.py @@ -15,6 +15,11 @@ def create_blueprint() -> Blueprint: bp.add_url_rule( "/api/accounts", view_func=accounts_controller.api_add_account, methods=["POST"] ) + bp.add_url_rule( + "/api/providers", + view_func=accounts_controller.api_get_providers, + methods=["GET"], + ) bp.add_url_rule( "/api/accounts/", view_func=accounts_controller.api_get_account, diff --git a/outlook_web/services/imap_generic.py b/outlook_web/services/imap_generic.py new file mode 100644 index 0000000..3aa24f2 --- /dev/null +++ b/outlook_web/services/imap_generic.py @@ -0,0 +1,459 @@ +from __future__ import annotations + +import email +import imaplib +import logging +import re +import socket +from email.header import decode_header +from typing import Any, Dict, List, Optional, Tuple + +from outlook_web.errors import build_error_payload, sanitize_error_details +from outlook_web.services.providers import get_imap_folder_candidates + +_LOGGER = logging.getLogger("outlook_web.imap_generic") + + +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 _strip_html(html_text: str) -> str: + if not html_text: + return "" + try: + text = re.sub(r"(?is).*?", " ", html_text) + text = re.sub(r"(?is).*?", " ", text) + text = re.sub(r"(?s)<[^>]+>", " ", text) + text = re.sub(r"\s+", " ", text) + return text.strip() + except Exception: + return html_text + + +def _extract_text_and_html(msg: email.message.Message) -> Tuple[str, str]: + """提取 text/plain 与 text/html(不含附件 part)。""" + text_part = "" + html_part = "" + + def _decode_payload(part: email.message.Message) -> str: + try: + payload = part.get_payload(decode=True) + charset = part.get_content_charset() or "utf-8" + if isinstance(payload, (bytes, bytearray)): + return payload.decode(charset, errors="replace") + return str(payload) if payload is not None else "" + except Exception: + try: + return str(part.get_payload()) + except Exception: + return "" + + if msg.is_multipart(): + for part in msg.walk(): + try: + content_disposition = str(part.get("Content-Disposition", "") or "") + except Exception: + content_disposition = "" + if "attachment" in content_disposition.lower(): + continue + + content_type = (part.get_content_type() or "").lower() + if content_type == "text/plain" and not text_part: + text_part = _decode_payload(part) + elif content_type == "text/html" and not html_part: + html_part = _decode_payload(part) + + if text_part and html_part: + break + else: + content_type = (msg.get_content_type() or "").lower() + if content_type == "text/html": + html_part = _decode_payload(msg) + else: + text_part = _decode_payload(msg) + + return text_part or "", html_part or "" + + +def _has_attachments(msg: email.message.Message) -> bool: + try: + if not msg.is_multipart(): + return False + for part in msg.walk(): + try: + disp = str(part.get("Content-Disposition", "") or "").lower() + except Exception: + disp = "" + if "attachment" in disp: + return True + return False + except Exception: + return False + + +def _extract_flags_from_fetch(fetch_item: Any) -> str: + try: + if isinstance(fetch_item, tuple) and fetch_item: + meta = fetch_item[0] + if isinstance(meta, (bytes, bytearray)): + return meta.decode("utf-8", errors="ignore") + return str(meta) + if isinstance(fetch_item, (bytes, bytearray)): + return fetch_item.decode("utf-8", errors="ignore") + return str(fetch_item) + except Exception: + return "" + + +def _quote_if_needed(folder_name: str) -> List[str]: + name = (folder_name or "").strip() + if not name: + return [] + if name.startswith('"') and name.endswith('"'): + return [name] + if " " in name: + return [name, f'"{name}"'] + return [name] + + +def _resolve_imap_folder( + mail: imaplib.IMAP4_SSL, + candidates: List[str], +) -> Optional[str]: + """按优先级 SELECT 文件夹,返回第一个成功的文件夹名。""" + for folder_name in candidates or []: + for try_name in _quote_if_needed(folder_name): + try: + status, resp = mail.select(try_name, readonly=True) + _LOGGER.debug("imap_select try=%s status=%s resp=%s", try_name, status, resp) + if status == "OK": + return try_name + except Exception as exc: + _LOGGER.debug("imap_select_exception try=%s err=%s", try_name, exc) + continue + return None + + +def _create_imap_connection( + imap_host: str, + imap_port: int, +) -> imaplib.IMAP4_SSL: + host = (imap_host or "").strip() + port = int(imap_port) if imap_port else 993 + if not host: + raise ValueError("IMAP host 不能为空") + + _LOGGER.info("imap_connect host=%s port=%s", host, port) + + # 优先使用 imaplib 的 timeout 参数(低风险;旧版本 Python 可能不支持) + try: + mail = imaplib.IMAP4_SSL(host, port, timeout=30) + except TypeError: + old_timeout = socket.getdefaulttimeout() + socket.setdefaulttimeout(30) + try: + mail = imaplib.IMAP4_SSL(host, port) + finally: + socket.setdefaulttimeout(old_timeout) + + # 发送 IMAP ID 命令(163/126 等网易邮箱要求此命令,否则报 Unsafe Login) + try: + imaplib.Commands["ID"] = ("NONAUTH", "AUTH", "SELECTED") + mail._simple_command( + "ID", + '("name" "outlookmail" "version" "1.0" "vendor" "outlookmail")', + ) + _LOGGER.debug("imap_id_sent host=%s", host) + except Exception as exc: + _LOGGER.debug("imap_id_skipped host=%s reason=%s", host, exc) + + return mail + + +def get_emails_imap_generic( + email_addr: str, + imap_password: str, + imap_host: str, + imap_port: int = 993, + folder: str = "inbox", + provider: str = "_default", + skip: int = 0, + top: int = 20, +) -> Dict[str, Any]: + """ + 标准 IMAP 邮件列表(LOGIN 认证)。 + + 返回格式: + { + "success": True, + "emails": [...], + "method": "IMAP (Generic)", + "has_more": False + } + + 错误时返回: + { + "success": False, + "error": "<脱敏消息>", + "error_code": "IMAP_AUTH_FAILED|IMAP_CONNECT_FAILED|IMAP_FOLDER_NOT_FOUND" + } + """ + mail = None + try: + skip = max(0, int(skip or 0)) + top = max(1, int(top or 20)) + + mail = _create_imap_connection(imap_host, imap_port) + try: + mail.login(email_addr, imap_password) + _LOGGER.info("imap_login_ok email=%s provider=%s", email_addr, provider) + except imaplib.IMAP4.error as e: + _LOGGER.warning("imap_login_failed email=%s provider=%s err=%s", email_addr, provider, sanitize_error_details(str(e))) + message = sanitize_error_details(str(e)) or "IMAP 认证失败" + provider_key = (provider or "").strip().lower() + if provider_key == "gmail": + message = ( + "IMAP 认证失败:Gmail 通常需要“应用专用密码”(非登录密码),并在 Gmail 设置中开启 IMAP" + ) + + return { + "success": False, + "error": build_error_payload( + "IMAP_AUTH_FAILED", + message, + "IMAPAuthError", + 401, + "", + ), + "error_code": "IMAP_AUTH_FAILED", + } + + candidates = get_imap_folder_candidates(provider, folder) + selected = _resolve_imap_folder(mail, candidates) + if not selected: + _LOGGER.warning( + "imap_folder_not_found email=%s provider=%s folder=%s candidates=%s", + email_addr, provider, folder, candidates, + ) + return { + "success": False, + "error": build_error_payload( + "IMAP_FOLDER_NOT_FOUND", + "IMAP 文件夹不存在或无权限访问", + "IMAPFolderError", + 400, + {"provider": provider, "folder": folder, "candidates": candidates}, + ), + "error_code": "IMAP_FOLDER_NOT_FOUND", + } + + status, data = mail.uid("SEARCH", None, "ALL") + if status != "OK": + return { + "success": False, + "error": build_error_payload( + "IMAP_SEARCH_FAILED", + "IMAP 搜索邮件失败", + "IMAPSearchError", + 502, + f"status={status}", + ), + "error_code": "IMAP_CONNECT_FAILED", + } + + uid_bytes = data[0] if data else b"" + if not uid_bytes: + return {"success": True, "emails": [], "method": "IMAP (Generic)", "has_more": False} + + uids = uid_bytes.split() + total = len(uids) + start_idx = max(0, total - skip - top) + end_idx = total - skip + if start_idx >= end_idx: + return {"success": True, "emails": [], "method": "IMAP (Generic)", "has_more": False} + + paged_uids = uids[start_idx:end_idx][::-1] + + emails_data: List[Dict[str, Any]] = [] + for uid in paged_uids: + try: + f_status, f_data = mail.uid("FETCH", uid, "(FLAGS RFC822)") + if f_status != "OK" or not f_data: + continue + + # 兼容不同 IMAP 服务器返回结构 + raw_email = None + flags_text = "" + for item in f_data: + if not item: + continue + if isinstance(item, tuple) and len(item) >= 2: + flags_text = _extract_flags_from_fetch(item) + raw_email = item[1] + break + + if not raw_email: + continue + + msg = email.message_from_bytes(raw_email) + subject = decode_header_value(msg.get("Subject", "无主题")) + from_text = decode_header_value(msg.get("From", "未知发件人")) + date_text = msg.get("Date", "未知时间") + + body_text, body_html = _extract_text_and_html(msg) + preview = (body_text or _strip_html(body_html))[:200] + if (body_text or body_html) and len(body_text or _strip_html(body_html)) > 200: + preview = preview + "..." + + emails_data.append( + { + "id": uid.decode("utf-8", errors="ignore") + if isinstance(uid, (bytes, bytearray)) + else str(uid), + "subject": subject, + "from": from_text, + "date": date_text, + "is_read": "\\Seen" in (flags_text or ""), + "has_attachments": _has_attachments(msg), + "body_preview": preview, + } + ) + except Exception: + continue + + return { + "success": True, + "emails": emails_data, + "method": "IMAP (Generic)", + "has_more": False, + } + except Exception as exc: + try: + _LOGGER.warning( + "imap_generic_failed provider=%s host=%s port=%s email=%s err=%s", + (provider or "").strip().lower(), + (imap_host or "").strip(), + imap_port, + (email_addr or "").strip(), + sanitize_error_details(str(exc)), + ) + except Exception: + pass + return { + "success": False, + "error": build_error_payload( + "IMAP_CONNECT_FAILED", + sanitize_error_details(str(exc)) or "IMAP 连接失败", + "IMAPConnectError", + 502, + "", + ), + "error_code": "IMAP_CONNECT_FAILED", + } + finally: + if mail: + try: + mail.logout() + except Exception: + pass + + +def get_email_detail_imap_generic( + email_addr: str, + imap_password: str, + imap_host: str, + imap_port: int = 993, + message_id: str = "", + folder: str = "inbox", + provider: str = "_default", +) -> Optional[Dict[str, Any]]: + """标准 IMAP 邮件详情(按 UID fetch),失败返回 None。""" + if not message_id: + return None + + mail = None + try: + mail = _create_imap_connection(imap_host, imap_port) + try: + mail.login(email_addr, imap_password) + _LOGGER.info("imap_detail_login_ok email=%s uid=%s", email_addr, message_id) + except imaplib.IMAP4.error as e: + _LOGGER.warning("imap_detail_login_failed email=%s err=%s", email_addr, sanitize_error_details(str(e))) + return None + + candidates = get_imap_folder_candidates(provider, folder) + selected = _resolve_imap_folder(mail, candidates) + if not selected: + _LOGGER.warning("imap_detail_folder_not_found email=%s folder=%s", email_addr, folder) + return None + + uid = message_id.encode("utf-8") if isinstance(message_id, str) else message_id + status, data = mail.uid("FETCH", uid, "(FLAGS BODY[])") + if status != "OK" or not data: + return None + + raw_email = None + flags_text = "" + for item in data: + if not item: + continue + if isinstance(item, tuple) and len(item) >= 2: + flags_text = _extract_flags_from_fetch(item) + raw_email = item[1] + break + + if not raw_email: + return None + + msg = email.message_from_bytes(raw_email) + body_text, body_html = _extract_text_and_html(msg) + + detail: Dict[str, Any] = { + "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", "未知时间"), + "is_read": "\\Seen" in (flags_text or ""), + "has_attachments": _has_attachments(msg), + "body_text": body_text or "", + "body_html": body_html or "", + } + + # 兼容现有前端/验证码提取:提供 body/body_type + if detail["body_html"]: + detail["body"] = detail["body_html"] + detail["body_type"] = "html" + else: + detail["body"] = detail["body_text"] + detail["body_type"] = "text" + + return detail + except Exception: + return None + finally: + if mail: + try: + mail.logout() + except Exception: + pass diff --git a/outlook_web/services/providers.py b/outlook_web/services/providers.py new file mode 100644 index 0000000..0e157f1 --- /dev/null +++ b/outlook_web/services/providers.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from typing import Any, Dict, List + +# 对齐:PRD-00005 / FD-00005 / TDD-00005 +# 职责:集中维护“邮箱提供商”元数据与 IMAP 文件夹映射,避免前后端重复维护默认 host/port 与 folder 兼容策略。 + +# 邮箱提供商配置(用于前端选择与默认 IMAP host/port) +MAIL_PROVIDERS: Dict[str, Dict[str, Any]] = { + "outlook": { + "label": "Outlook", + "imap_host": "outlook.live.com", + "imap_port": 993, + "account_type": "outlook", + "note": "使用 OAuth2 认证(client_id + refresh_token)", + }, + "gmail": { + "label": "Gmail", + "imap_host": "imap.gmail.com", + "imap_port": 993, + "account_type": "imap", + "note": "需开启 IMAP,并使用应用专用密码(非登录密码)", + }, + "qq": { + "label": "QQ 邮箱", + "imap_host": "imap.qq.com", + "imap_port": 993, + "account_type": "imap", + "note": "需开启 IMAP 服务,使用授权码(非 QQ 密码)", + }, + "163": { + "label": "163 邮箱", + "imap_host": "imap.163.com", + "imap_port": 993, + "account_type": "imap", + "note": "需开启 IMAP 服务,使用授权码", + }, + "126": { + "label": "126 邮箱", + "imap_host": "imap.126.com", + "imap_port": 993, + "account_type": "imap", + "note": "需开启 IMAP 服务,使用授权码", + }, + "yahoo": { + "label": "Yahoo 邮箱", + "imap_host": "imap.mail.yahoo.com", + "imap_port": 993, + "account_type": "imap", + "note": "需在账号安全设置中生成应用密码", + }, + "aliyun": { + "label": "阿里邮箱", + "imap_host": "imap.aliyun.com", + "imap_port": 993, + "account_type": "imap", + "note": "使用阿里邮箱登录密码", + }, + "custom": { + "label": "自定义 IMAP", + "imap_host": "", + "imap_port": 993, + "account_type": "imap", + "note": "请手动填写 IMAP 服务器地址和端口", + }, +} + +# provider -> 逻辑文件夹名(inbox/junkemail/deleteditems)-> 候选 IMAP 文件夹名列表 +PROVIDER_FOLDER_MAP: Dict[str, Dict[str, List[str]]] = { + "gmail": { + "inbox": ["INBOX"], + "junkemail": ["[Gmail]/Spam", "[Gmail]/垃圾邮件"], + "deleteditems": ["[Gmail]/Trash", "[Gmail]/已删除邮件"], + }, + "qq": { + "inbox": ["INBOX"], + "junkemail": ["Junk", "&V4NXPpCuTvY-"], + "deleteditems": ["Deleted Messages", "&XfJT0ZABkK5O9g-"], + }, + "163": { + "inbox": ["INBOX"], + "junkemail": ["&V4NXPpCuTvY-"], + "deleteditems": ["&XfJT0ZABkK5O9g-"], + }, + "yahoo": { + "inbox": ["INBOX"], + "junkemail": ["Bulk Mail"], + "deleteditems": ["Trash"], + }, + "_default": { + "inbox": ["INBOX"], + "junkemail": ["Junk", "Spam", "SPAM", "Bulk Mail"], + "deleteditems": ["Trash", "Deleted", "Deleted Messages"], + }, +} + + +def get_imap_folder_candidates(provider: str, folder: str) -> List[str]: + """ + 根据 provider 和逻辑文件夹名(inbox/junkemail/deleteditems), + 返回候选 IMAP 文件夹名列表(按优先级排序)。 + 不存在的 provider 退回 _default。 + """ + provider_key = (provider or "").strip() or "_default" + folder_key = (folder or "").strip().lower() or "inbox" + + folder_map = PROVIDER_FOLDER_MAP.get(provider_key, PROVIDER_FOLDER_MAP["_default"]) + return folder_map.get( + folder_key, PROVIDER_FOLDER_MAP["_default"].get(folder_key, ["INBOX"]) + ) + + +def get_provider_list() -> List[Dict[str, Any]]: + """返回供前端展示的 provider 列表(顺序固定:outlook 在前,custom 在后)""" + order = ["outlook", "gmail", "qq", "163", "126", "yahoo", "aliyun", "custom"] + result: List[Dict[str, Any]] = [] + for key in order: + if key not in MAIL_PROVIDERS: + continue + p = MAIL_PROVIDERS[key] + result.append( + { + "key": key, + "label": p.get("label", key), + "account_type": p.get("account_type", "imap" if key != "outlook" else "outlook"), + "note": p.get("note", ""), + } + ) + return result diff --git a/outlook_web/services/scheduler.py b/outlook_web/services/scheduler.py index c350c50..b8d2a48 100644 --- a/outlook_web/services/scheduler.py +++ b/outlook_web/services/scheduler.py @@ -264,8 +264,14 @@ def scheduled_refresh_task(app, test_refresh_token): except Exception: pass + # PRD-00005 / TDD-00005:定时刷新只处理 Outlook 账号(IMAP 账号无 OAuth token 刷新语义) accounts = conn.execute( - "SELECT id, email, client_id, refresh_token, group_id FROM accounts WHERE status = 'active'" + """ + SELECT id, email, client_id, refresh_token, group_id + FROM accounts + WHERE status = 'active' + AND (account_type = 'outlook' OR account_type IS NULL) + """ ).fetchall() total = len(accounts) diff --git a/static/css/main.css b/static/css/main.css index de1cc8e..13f8646 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -424,6 +424,11 @@ textarea.form-input { padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.65rem; font-weight: 600; background: rgba(184,92,56,0.08); color: var(--clr-primary); } +.account-provider-tag { + padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.65rem; font-weight: 600; + background: rgba(0,0,0,0.06); color: var(--text-muted); +} +[data-theme="dark"] .account-provider-tag { background: rgba(255,255,255,0.08); color: var(--text-secondary); } .account-actions { display: flex; gap: 2px; opacity: 0; transition: opacity var(--transition); } diff --git a/static/js/features/accounts.js b/static/js/features/accounts.js index 4c10e3c..0107f35 100644 --- a/static/js/features/accounts.js +++ b/static/js/features/accounts.js @@ -66,6 +66,67 @@ document.getElementById('emailDetailToolbar').style.display = 'none'; } + // Provider 下拉缓存 + let providersLoaded = false; + let providerOptions = []; + + // 加载邮箱 providers(用于导入下拉) + async function loadProviders() { + if (providersLoaded) return; + + const select = document.getElementById('accountProvider'); + if (!select) return; + + try { + const resp = await fetch('/api/providers'); + const data = await resp.json(); + if (!data.success || !Array.isArray(data.providers)) return; + + providerOptions = data.providers; + select.innerHTML = data.providers.map(p => ` + + `).join(''); + + providersLoaded = true; + } catch (e) { + // 静默失败:保留默认 Outlook 选项 + } + } + + // Provider 切换:更新 placeholder / hint / custom IMAP 配置区显示 + function onProviderChange(provider) { + const p = (provider || 'outlook').toLowerCase(); + const input = document.getElementById('accountInput'); + const hint = document.getElementById('accountFormatHint'); + const customFields = document.getElementById('customImapFields'); + const getTokenBtn = document.getElementById('getRefreshTokenBtnInAdd'); + + if (!input || !hint || !customFields) return; + + // "获取Token"按钮仅 Outlook 类型可见 + if (getTokenBtn) { + getTokenBtn.style.display = (p === 'outlook') ? '' : 'none'; + } + + if (p === 'outlook') { + customFields.style.display = 'none'; + input.placeholder = '邮箱----密码----client_id----refresh_token'; + hint.textContent = '格式:邮箱----密码----client_id----refresh_token,支持批量导入(每行一个)'; + return; + } + + if (p === 'custom') { + customFields.style.display = ''; + input.placeholder = '邮箱----IMAP授权码/应用密码'; + hint.textContent = '格式:邮箱----IMAP授权码/应用密码(每行一个)。自定义 IMAP 需填写上方服务器/端口;也支持:邮箱----授权码----imap_host----imap_port'; + return; + } + + customFields.style.display = 'none'; + input.placeholder = '邮箱----IMAP授权码/应用密码'; + hint.textContent = '格式:邮箱----IMAP授权码/应用密码,支持批量导入(每行一个)'; + } + // 显示添加账号模态框 function showAddAccountModal() { document.getElementById('accountInput').value = ''; @@ -73,6 +134,21 @@ if (currentGroupId) { document.getElementById('importGroupSelect').value = currentGroupId; } + // 加载 providers 并初始化默认状态 + loadProviders().finally(() => { + const sel = document.getElementById('accountProvider'); + if (sel) { + sel.value = 'outlook'; + onProviderChange('outlook'); + } else { + onProviderChange('outlook'); + } + + const hostEl = document.getElementById('imapHost'); + const portEl = document.getElementById('imapPort'); + if (hostEl) hostEl.value = ''; + if (portEl) portEl.value = '993'; + }); document.getElementById('addAccountModal').classList.add('show'); } @@ -85,6 +161,8 @@ async function addAccount() { const input = document.getElementById('accountInput').value.trim(); const groupId = parseInt(document.getElementById('importGroupSelect').value); + const providerEl = document.getElementById('accountProvider'); + const provider = providerEl ? (providerEl.value || 'outlook') : 'outlook'; if (!input) { showToast('请输入账号信息', 'error'); @@ -92,10 +170,33 @@ } try { + const payload = { account_string: input, group_id: groupId }; + if (provider && provider !== 'outlook') { + payload.provider = provider; + if (provider === 'custom') { + const host = (document.getElementById('imapHost')?.value || '').trim(); + const portRaw = (document.getElementById('imapPort')?.value || '').trim(); + const port = parseInt(portRaw || '993', 10) || 993; + + if (!host) { + // 允许每行内嵌 host/port:email----授权码----imap_host----imap_port(或导出格式 5 段) + const lines = input.split('\n').map(l => (l || '').trim()).filter(l => l && !l.startsWith('#')); + const hasInlineHost = lines.some(l => (l.split('----').length >= 4)); + if (!hasInlineHost) { + showToast('请填写 IMAP 服务器地址(或在文本中每行包含 host/port)', 'error'); + return; + } + } else { + payload.imap_host = host; + payload.imap_port = port; + } + } + } + const response = await fetch('/api/accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ account_string: input, group_id: groupId }) + body: JSON.stringify(payload) }); const data = await response.json(); @@ -130,7 +231,10 @@ if (data.success) { const acc = data.account; + const isImap = (acc.account_type || 'outlook') === 'imap'; + document.getElementById('editAccountId').value = acc.id; + document.getElementById('editAccountType').value = acc.account_type || 'outlook'; document.getElementById('editEmail').value = acc.email; document.getElementById('editPassword').value = acc.password || ''; document.getElementById('editClientId').value = acc.client_id; @@ -138,6 +242,24 @@ document.getElementById('editGroupSelect').value = acc.group_id || 1; document.getElementById('editRemark').value = acc.remark || ''; document.getElementById('editStatus').value = acc.status || 'active'; + + // IMAP 账号:隐藏 Client ID / Refresh Token,调整密码标签 + const clientIdGroup = document.getElementById('editClientIdGroup'); + const refreshTokenGroup = document.getElementById('editRefreshTokenGroup'); + const passwordLabel = document.getElementById('editPasswordLabel'); + + if (isImap) { + clientIdGroup.style.display = 'none'; + refreshTokenGroup.style.display = 'none'; + passwordLabel.textContent = '授权码 / 应用密码'; + document.getElementById('editPassword').placeholder = '留空则不修改'; + } else { + clientIdGroup.style.display = ''; + refreshTokenGroup.style.display = ''; + passwordLabel.textContent = '密码'; + document.getElementById('editPassword').placeholder = '可选,留空则不修改'; + } + document.getElementById('editAccountModal').classList.add('show'); } } catch (error) { @@ -153,6 +275,8 @@ // 更新账号 async function updateAccount() { const accountId = document.getElementById('editAccountId').value; + const accountType = document.getElementById('editAccountType').value || 'outlook'; + const isImap = accountType === 'imap'; const oldGroupId = currentGroupId; const newGroupId = parseInt(document.getElementById('editGroupSelect').value); @@ -166,7 +290,13 @@ status: document.getElementById('editStatus').value }; - if (!data.email || !data.client_id || !data.refresh_token) { + if (!data.email) { + showToast('邮箱地址不能为空', 'error'); + return; + } + + // Outlook 账号需要 Client ID 和 Refresh Token + if (!isImap && (!data.client_id || !data.refresh_token)) { showToast('邮箱、Client ID 和 Refresh Token 不能为空', 'error'); return; } diff --git a/static/js/features/emails.js b/static/js/features/emails.js index 9339440..acb5132 100644 --- a/static/js/features/emails.js +++ b/static/js/features/emails.js @@ -116,6 +116,7 @@ } } } catch (error) { + console.error('加载邮件列表失败:', error); container.innerHTML = `
⚠️

网络错误,请重试

@@ -260,7 +261,13 @@ showToast(`部分删除失败 (${result.failed_count} 封)`, 'warning'); } } else { - showToast('删除失败: ' + (result.error || '未知错误'), 'error'); + let msg = '未知错误'; + if (typeof result.error === 'string') { + msg = result.error; + } else if (result.error && result.error.message) { + msg = result.error.message; + } + showToast('删除失败: ' + msg, 'error'); } } catch (e) { showToast('网络错误', 'error'); @@ -298,7 +305,17 @@ if (data.success) { currentEmailDetail = data.email; - renderEmailDetail(data.email); + try { + renderEmailDetail(data.email); + } catch (renderError) { + console.error('渲染邮件详情失败:', renderError); + // 渲染失败时回退为纯文本显示 + container.innerHTML = ` +
+ ⚠️

邮件渲染失败: ${escapeHtml(renderError.message || '未知错误')}

+
+ `; + } } else { handleApiError(data, '加载邮件详情失败'); container.innerHTML = ` @@ -308,9 +325,10 @@ `; } } catch (error) { + console.error('加载邮件详情失败:', error); container.innerHTML = `
- ⚠️

网络错误,请重试

+ ⚠️

网络错误,请重试 (${escapeHtml(error.message || '')})

`; } @@ -363,7 +381,7 @@ let sanitizedBody; if (isTrustedMode) { sanitizedBody = email.body; // 信任模式:不过滤 - } else { + } else if (typeof DOMPurify !== 'undefined') { // 使用 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'], @@ -372,6 +390,14 @@ FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button'], FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'] }); + } else { + // DOMPurify 未加载(CDN 不可达),回退为基本过滤 + console.warn('DOMPurify 未加载,使用基本 HTML 过滤'); + sanitizedBody = email.body + .replace(//gi, '') + .replace(//gi, '') + .replace(/on\w+="[^"]*"/gi, '') + .replace(/on\w+='[^']*'/gi, ''); } const htmlContent = ` diff --git a/templates/partials/modals.html b/templates/partials/modals.html index a6f6575..8eaf746 100644 --- a/templates/partials/modals.html +++ b/templates/partials/modals.html @@ -70,15 +70,30 @@
+
+ + +
提示:QQ/网易/Gmail 等请使用授权码/应用专用密码(非网页登录密码)
+
+
-
格式:邮箱----密码----client_id----refresh_token,支持批量导入(每行一个)
+
格式:邮箱----密码----client_id----refresh_token,支持批量导入(每行一个)
@@ -93,19 +108,20 @@