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:
1 parent
4df3d5fecd
commit
64d8a6c357
21 files changed
+4567
-39
No files matched your search
@@ -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. **代码复用**:如何最大化利用当前项目的现有代码?
|
||||
|
||||
---
|
||||
|
||||
**注意**:这是一个真实的开源项目,请提供实用、可落地的分析建议,而不是理论性的讨论。
|
||||
@@ -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 进行模块化拆解 | 状态:草案*
|
||||
@@ -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
|
||||
@@ -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()` 或仅作为兜底。
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user