feat(multi-mailbox): 实现多邮箱统一管理功能(PRD-00005)

核心变更:
- 数据库: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 <noreply@anthropic.com>
This commit is contained in:
胡绍康andClaude Opus 4.6 committed 2026-03-03 22:54:39 +08:00
1 parent 4df3d5fecd
commit 64d8a6c357
21 files changed
+4567 -39

No files matched your search

+55
View File
@@ -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.
@@ -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. **代码复用**:如何最大化利用当前项目的现有代码?
---
**注意**:这是一个真实的开源项目,请提供实用、可落地的分析建议,而不是理论性的讨论。
+485
View File
@@ -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/<email_addr>`
- `GET /api/emails/<email_addr>/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": "<message_uid_or_message_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/<email_addr>`
文件:`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/<email_addr>/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/<email>` 仍优先 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 进行模块化拆解 | 状态:草案*
+937
View File
@@ -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/<email_addr>`
- `GET /api/emails/<email_addr>/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/<email_addr>`(邮件列表)
在获取账号信息后,加路由分发:
```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/<email_addr>/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
<!-- 邮箱类型选择(新增) -->
<div class="form-group">
<label for="accountProvider">邮箱类型</label>
<select id="accountProvider" class="form-select" onchange="onProviderChange(this.value)">
<!-- 由 JS 动态填充,来自 GET /api/providers -->
</select>
</div>
<!-- 自定义 IMAP 服务器配置(默认隐藏) -->
<div id="customImapFields" style="display:none;">
<div class="form-group">
<label for="imapHost">IMAP 服务器地址</label>
<input type="text" id="imapHost" class="form-input" placeholder="imap.example.com">
</div>
<div class="form-group">
<label for="imapPort">IMAP 端口</label>
<input type="number" id="imapPort" class="form-input" value="993">
</div>
</div>
<!-- 格式提示(动态更新) -->
<div id="accountFormatHint" class="form-hint"></div>
```
#### 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 = `<span class="account-provider-tag">${providerLabel}</span>`;
// 插入到邮箱地址后面
```
```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": "<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_addr>` 触发 imap_generic,不调用 graph.py |
| `test_emails_api_routes_outlook` | `GET /api/emails/<outlook_addr>` 走现有逻辑,不调用 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/<email>` 仍优先 Graph,失败后仍可 XOAUTH2 IMAP 回退
- [ ] 旧 Outlook 批量导入格式仍可用:`email----password----client_id----refresh_token`
- [ ] 新增 IMAP 账号可导入(QQ / 163 / Gmail 任一)并可拉取邮件列表和详情
- [ ] 新增 IMAP 账号可执行验证码提取(成功或返回"未找到")
- [ ] IMAP 账号调用删除接口返回"不支持远程删除",且无远程副作用
- [ ] 定时刷新任务不处理 IMAP 账号
- [ ] `imap_password` 在 DB 中为加密串(不以明文出现)
---
*文档版本:V1.0 | 状态:草案*
@@ -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/<imap_email>` |
| **预期结果** | 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/<outlook_email>` |
| **预期结果** | `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": "<imap_email>", "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/<imap_email>/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/<outlook_email>/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 | 状态:草案*
@@ -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/<email_addr>` — 邮件列表路由分发
- [ ] 在获取账号信息后,读取 `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/<email_addr>/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/<outlook>` 走现有链路,method tag 为 `Graph` 或 `IMAP (XOAUTH2)`
- [ ] IMAP 账号:`GET /api/emails/<imap>` 走 `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(如 `<span class="account-provider-tag">QQ邮箱</span>`)
- [ ] 确认 `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/<email>` 仍优先 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
+69
View File
@@ -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()` 或仅作为兜底。
+13
View File
@@ -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)
+271 -17
View File
@@ -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"
+135 -1
View File
@@ -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"):
+31 -5
View File
@@ -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
+86 -4
View File
@@ -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()
+5
View File
@@ -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/<int:account_id>",
view_func=accounts_controller.api_get_account,
+459
View File
@@ -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)<script.*?>.*?</script>", " ", html_text)
text = re.sub(r"(?is)<style.*?>.*?</style>", " ", 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
+129
View File
@@ -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
+7 -1
View File
@@ -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)
+5
View File
@@ -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);
}
+132 -2
View File
@@ -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 => `
<option value="${escapeHtml(p.key)}">${escapeHtml(p.label || p.key)}</option>
`).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;
}
+30 -4
View File
@@ -116,6 +116,7 @@
}
}
} catch (error) {
console.error('加载邮件列表失败:', error);
container.innerHTML = `
<div class="empty-state">
<span class="empty-icon">⚠️</span><p>网络错误,请重试</p>
@@ -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 = `
<div class="empty-state">
<span class="empty-icon">⚠️</span><p>邮件渲染失败: ${escapeHtml(renderError.message || '未知错误')}</p>
</div>
`;
}
} else {
handleApiError(data, '加载邮件详情失败');
container.innerHTML = `
@@ -308,9 +325,10 @@
`;
}
} catch (error) {
console.error('加载邮件详情失败:', error);
container.innerHTML = `
<div class="empty-state">
<span class="empty-icon">⚠️</span><p>网络错误,请重试</p>
<span class="empty-icon">⚠️</span><p>网络错误,请重试 (${escapeHtml(error.message || '')})</p>
</div>
`;
}
@@ -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(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/on\w+="[^"]*"/gi, '')
.replace(/on\w+='[^']*'/gi, '');
}
const htmlContent = `
+21 -5
View File
@@ -70,15 +70,30 @@
<!-- 分组选项将通过 JS 动态加载 -->
</select>
</div>
<div class="form-group">
<label class="form-label">邮箱类型</label>
<select class="form-select" id="accountProvider" onchange="onProviderChange(this.value)">
<option value="outlook">Outlook</option>
</select>
<div class="form-hint">提示:QQ/网易/Gmail 等请使用授权码/应用专用密码(非网页登录密码)</div>
</div>
<div class="form-group" id="customImapFields" style="display:none;">
<label class="form-label">自定义 IMAP 配置</label>
<input type="text" class="form-input" id="imapHost" placeholder="imap.example.com">
<div style="height: 8px;"></div>
<input type="number" class="form-input" id="imapPort" placeholder="993" value="993" min="1" max="65535">
<div class="form-hint">仅自定义 IMAP 需要填写;端口通常为 993(SSL)</div>
</div>
<div class="form-group">
<label class="form-label">账号信息</label>
<textarea class="form-textarea" id="accountInput"
placeholder="邮箱----密码----client_id----refresh_token"></textarea>
<div class="form-hint">格式:邮箱----密码----client_id----refresh_token,支持批量导入(每行一个)</div>
<div class="form-hint" id="accountFormatHint">格式:邮箱----密码----client_id----refresh_token,支持批量导入(每行一个)</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="hideAddAccountModal()">取消</button>
<button class="btn btn-primary" id="getRefreshTokenBtnInAdd" onclick="showGetRefreshTokenModal()" style="background-color: #0078d4;">🔑 获取 Token</button>
<button class="btn btn-primary" onclick="addAccount()">导入</button>
</div>
</div>
@@ -93,19 +108,20 @@
</div>
<div class="modal-body">
<input type="hidden" id="editAccountId">
<input type="hidden" id="editAccountType">
<div class="form-group">
<label class="form-label">邮箱地址</label>
<input type="text" class="form-input" id="editEmail" placeholder="example@outlook.com">
</div>
<div class="form-group">
<label class="form-label">密码</label>
<input type="text" class="form-input" id="editPassword" placeholder="可选">
<label class="form-label" id="editPasswordLabel">密码</label>
<input type="text" class="form-input" id="editPassword" placeholder="可选,留空则不修改">
</div>
<div class="form-group">
<div class="form-group" id="editClientIdGroup">
<label class="form-label">Client ID</label>
<input type="text" class="form-input" id="editClientId">
</div>
<div class="form-group">
<div class="form-group" id="editRefreshTokenGroup">
<label class="form-label">Refresh Token</label>
<textarea class="form-textarea" id="editRefreshToken" style="height: 80px;"></textarea>
</div>
+355
View File
@@ -0,0 +1,355 @@
import unittest
import uuid
from unittest.mock import patch
from tests._import_app import clear_login_attempts, import_web_app_module
class MultiMailboxSupportTests(unittest.TestCase):
"""
对齐:PRD-00005 / FD-00005 / TDD-00005 / TEST-00005
目标:验证多邮箱(Outlook + IMAP provider)核心能力与回归门禁。
"""
@classmethod
def setUpClass(cls):
cls.module = import_web_app_module()
cls.app = cls.module.app
def setUp(self):
with self.app.app_context():
clear_login_attempts()
def _login(self, client, password: str = "testpass123"):
resp = client.post("/login", json={"password": password})
self.assertEqual(resp.status_code, 200)
data = resp.get_json()
self.assertEqual(data.get("success"), True)
def _default_group_id(self) -> int:
conn = self.module.create_sqlite_connection()
try:
row = conn.execute(
"SELECT id FROM groups WHERE name = '默认分组' LIMIT 1"
).fetchone()
return int(row["id"]) if row else 1
finally:
conn.close()
def test_db_schema_v3_has_multi_mailbox_columns(self):
conn = self.module.create_sqlite_connection()
try:
cols = conn.execute("PRAGMA table_info(accounts)").fetchall()
names = {c[1] for c in cols} # (cid, name, type, notnull, dflt_value, pk)
finally:
conn.close()
self.assertIn("account_type", names)
self.assertIn("provider", names)
self.assertIn("imap_host", names)
self.assertIn("imap_port", names)
self.assertIn("imap_password", names)
def test_providers_api_returns_fixed_order(self):
client = self.app.test_client()
self._login(client)
resp = client.get("/api/providers")
self.assertEqual(resp.status_code, 200)
data = resp.get_json()
self.assertEqual(data.get("success"), True)
providers = data.get("providers") or []
self.assertEqual(len(providers), 8)
self.assertEqual(providers[0].get("key"), "outlook")
self.assertEqual(providers[-1].get("key"), "custom")
keys = [p.get("key") for p in providers]
self.assertIn("qq", keys)
self.assertIn("163", keys)
def test_provider_folder_candidates_contains_utf7_for_qq_junk(self):
from outlook_web.services.providers import get_imap_folder_candidates
candidates = get_imap_folder_candidates("qq", "junkemail")
self.assertIn("&V4NXPpCuTvY-", candidates)
default_candidates = get_imap_folder_candidates("unknown", "inbox")
self.assertIn("INBOX", default_candidates)
def test_import_imap_qq_stores_encrypted_password_and_imap_fields(self):
client = self.app.test_client()
self._login(client)
unique = uuid.uuid4().hex
email_addr = f"qq_{unique}@qq.com"
imap_pwd = f"auth_{unique}"
resp = client.post(
"/api/accounts",
json={
"provider": "qq",
"account_string": f"{email_addr}----{imap_pwd}",
"group_id": self._default_group_id(),
},
)
self.assertEqual(resp.status_code, 200)
data = resp.get_json()
self.assertEqual(data.get("success"), True)
conn = self.module.create_sqlite_connection()
try:
row = conn.execute(
"""
SELECT account_type, provider, imap_host, imap_port, imap_password, client_id, refresh_token
FROM accounts
WHERE email = ?
LIMIT 1
""",
(email_addr,),
).fetchone()
finally:
conn.close()
self.assertIsNotNone(row)
self.assertEqual(row["account_type"], "imap")
self.assertEqual(row["provider"], "qq")
self.assertEqual(row["imap_host"], "imap.qq.com")
self.assertEqual(int(row["imap_port"]), 993)
self.assertEqual(row["client_id"], "")
self.assertEqual(row["refresh_token"], "")
stored_imap_pwd = row["imap_password"]
self.assertTrue(stored_imap_pwd)
self.assertNotEqual(stored_imap_pwd, imap_pwd)
self.assertTrue(stored_imap_pwd.startswith("enc:"))
def test_emails_api_routes_to_imap_generic_by_account_type(self):
client = self.app.test_client()
self._login(client)
unique = uuid.uuid4().hex
email_addr = f"imap_{unique}@example.com"
encrypted_pwd = self.module.encrypt_data("pw_" + unique)
conn = self.module.create_sqlite_connection()
try:
conn.execute(
"""
INSERT INTO accounts (email, password, client_id, refresh_token, account_type, provider, imap_host, imap_port, imap_password, group_id, remark, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
email_addr,
"",
"",
"",
"imap",
"qq",
"imap.qq.com",
993,
encrypted_pwd,
self._default_group_id(),
"",
"active",
),
)
conn.commit()
finally:
conn.close()
fake_result = {
"success": True,
"emails": [
{
"id": "1",
"subject": "s",
"from": "f",
"date": "d",
"is_read": True,
"has_attachments": False,
"body_preview": "p",
}
],
"method": "IMAP (Generic)",
"has_more": False,
}
with patch(
"outlook_web.controllers.emails.get_emails_imap_generic",
return_value=fake_result,
):
resp = client.get(f"/api/emails/{email_addr}?folder=inbox&skip=0&top=20")
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.get_json(), fake_result)
delete_resp = client.post(
"/api/emails/delete", json={"email": email_addr, "ids": ["1"]}
)
self.assertEqual(delete_resp.status_code, 400)
delete_data = delete_resp.get_json()
self.assertEqual(delete_data.get("success"), False)
err = delete_data.get("error") or {}
self.assertIsInstance(err, dict)
self.assertIn("不支持远程删除", err.get("message", ""))
def test_imap_generic_connect_error_does_not_leak_password(self):
from outlook_web.services.imap_generic import get_emails_imap_generic
secret_pwd = "top-secret-imap-password"
result = get_emails_imap_generic(
email_addr="u@example.com",
imap_password=secret_pwd,
imap_host="", # 触发 ValueError -> connect failed
imap_port=993,
folder="inbox",
provider="qq",
skip=0,
top=1,
)
self.assertEqual(result.get("success"), False)
self.assertEqual(result.get("error_code"), "IMAP_CONNECT_FAILED")
# 确保返回内容不包含明文密码
self.assertNotIn(secret_pwd, str(result))
def test_scheduler_skips_imap_accounts(self):
from outlook_web.services.scheduler import scheduled_refresh_task
unique = uuid.uuid4().hex
outlook_email = f"out_{unique}@outlook.com"
imap_email = f"imap_{unique}@example.com"
conn = self.module.create_sqlite_connection()
try:
conn.execute(
"""
INSERT INTO accounts (email, password, client_id, refresh_token, account_type, provider, group_id, remark, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
outlook_email,
"",
"client_" + unique,
self.module.encrypt_data("rt_" + unique),
"outlook",
"outlook",
self._default_group_id(),
"",
"active",
),
)
conn.execute(
"""
INSERT INTO accounts (email, password, client_id, refresh_token, account_type, provider, imap_host, imap_port, imap_password, group_id, remark, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
imap_email,
"",
"",
"",
"imap",
"qq",
"imap.qq.com",
993,
self.module.encrypt_data("pw_" + unique),
self._default_group_id(),
"",
"active",
),
)
conn.commit()
finally:
conn.close()
called = []
def fake_test_refresh_token(client_id, refresh_token, proxy_url):
called.append((client_id, refresh_token, proxy_url))
return True, "ok"
with patch("outlook_web.services.scheduler.time.sleep", return_value=None), patch(
"outlook_web.services.scheduler.acquire_distributed_lock",
return_value=(True, {}),
), patch("outlook_web.services.scheduler.release_distributed_lock", return_value=None):
scheduled_refresh_task(self.app, fake_test_refresh_token)
self.assertGreaterEqual(len(called), 1)
self.assertTrue(any(cid == "client_" + unique for cid, _, _ in called))
self.assertFalse(any(cid == "" for cid, _, _ in called)) # 不应包含 IMAP 账号(空 client_id)
def test_export_format_outlook_first_then_imap_grouped(self):
client = self.app.test_client()
self._login(client)
unique = uuid.uuid4().hex
outlook_email = f"exp_out_{unique}@outlook.com"
imap_email = f"exp_qq_{unique}@qq.com"
conn = self.module.create_sqlite_connection()
try:
conn.execute(
"""
INSERT INTO accounts (email, password, client_id, refresh_token, account_type, provider, group_id, remark, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
outlook_email,
self.module.encrypt_data("p_" + unique),
"cid_" + unique,
self.module.encrypt_data("rt_" + unique),
"outlook",
"outlook",
self._default_group_id(),
"",
"active",
),
)
conn.execute(
"""
INSERT INTO accounts (email, password, client_id, refresh_token, account_type, provider, imap_host, imap_port, imap_password, group_id, remark, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
imap_email,
"",
"",
"",
"imap",
"qq",
"imap.qq.com",
993,
self.module.encrypt_data("imap_pw_" + unique),
self._default_group_id(),
"",
"active",
),
)
conn.commit()
finally:
conn.close()
verify = client.post("/api/export/verify", json={"password": "testpass123"})
self.assertEqual(verify.status_code, 200)
verify_data = verify.get_json()
self.assertEqual(verify_data.get("success"), True)
token = verify_data.get("verify_token")
self.assertTrue(token)
export_resp = client.get("/api/accounts/export", headers={"X-Export-Token": token})
self.assertEqual(export_resp.status_code, 200)
content = export_resp.get_data(as_text=True)
outlook_pos = content.find("# === Outlook 账号 ===")
imap_pos = content.find("# === IMAP 账号(QQ 邮箱)===")
self.assertNotEqual(outlook_pos, -1)
self.assertNotEqual(imap_pos, -1)
self.assertLess(outlook_pos, imap_pos)
self.assertIn(outlook_email, content)
self.assertIn(imap_email, content)
self.assertIn("----qq", content) # IMAP 行格式:email----imap_password----provider