feat: 账号导入导出无缝迁移(PRD-00006/FD-00006)
- 新增 auto 智能混合导入模式:自动识别 Outlook/IMAP/GPTMail 行类型 - 域名推断 provider(gmail/qq/163/126/yahoo/aliyun/outlook) - 自动按 provider 创建/复用分组 - 支持重复策略(skip/overwrite)和兜底 IMAP 设置 - 导出升级为 v2:头部元信息 + GPTMail 分段 + 时间戳文件名 - 前端导入模态框支持 auto 模式 UI 交互 - 新增 41 个单元测试(163 总测试全部通过) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
1 parent
d9a6889bd8
commit
973412d44c
12 files changed
+1563
-35
No files matched your search
@@ -0,0 +1,349 @@
|
||||
# TODO-00006|账号导入导出无缝迁移 — 任务待办清单
|
||||
|
||||
- **文档状态**: 草案
|
||||
- **版本**: V1.0
|
||||
- **日期**: 2026-03-04
|
||||
- **对齐 PRD**: `docs/PRD/PRD-00006-账号导入导出无缝迁移.md`
|
||||
- **对齐 FD**: `docs/FD/FD-00006-账号导入导出无缝迁移.md`
|
||||
- **对齐 TDD**: `docs/TDD/TDD-00006-账号导入导出无缝迁移.md`
|
||||
|
||||
---
|
||||
|
||||
## 前置准备
|
||||
|
||||
### 0.1 文档确认
|
||||
|
||||
- [x] PRD 已完成(`docs/PRD/PRD-00006-账号导入导出无缝迁移.md`)
|
||||
- [x] FD 已完成(`docs/FD/FD-00006-账号导入导出无缝迁移.md`)
|
||||
- [x] TDD 已完成(`docs/TDD/TDD-00006-账号导入导出无缝迁移.md`)
|
||||
- [ ] 阅读并理解 PRD/FD/TDD 的所有约束条款与边界情况
|
||||
- [ ] 明确 P0 验收标准(对齐 `docs/TDD/TDD-00006-账号导入导出无缝迁移.md` §14.2)
|
||||
|
||||
### 0.2 环境准备
|
||||
|
||||
- [ ] 确认开发环境正常(Python/Flask/SQLite)
|
||||
- [ ] 安装依赖:`pip install -r requirements.txt`
|
||||
- [ ] 确认本地 DB 可访问:`data/outlook_accounts.db`
|
||||
|
||||
### 0.3 基线测试(改动前必做)
|
||||
|
||||
- [ ] 运行现有测试:`python -m unittest discover -s tests -v`
|
||||
- [ ] 记录当前测试通过数量(作为基线)
|
||||
- [ ] 手动验证现有核心链路无回归:
|
||||
- [ ] Outlook 账号导入/导出/邮件读取
|
||||
- [ ] IMAP 单类型导入(provider != outlook)正常
|
||||
- [ ] 临时邮箱(GPTMail)模块:生成、查看邮件、清空邮件正常
|
||||
- [ ] 导出二次验证(2FA)机制正常
|
||||
|
||||
### 0.4 Git 准备(可选)
|
||||
|
||||
- [ ] 创建功能分支:`git checkout -b feature/accounts-import-export-v2`
|
||||
- [ ] 确认工作区干净:`git status`
|
||||
- [ ] 记录当前 commit hash(用于回滚参考)
|
||||
|
||||
---
|
||||
|
||||
## P0(必须完成)
|
||||
|
||||
---
|
||||
|
||||
### 阶段 1:Provider/域名/分组配置扩展(后端)
|
||||
|
||||
**目标**:为 auto 混合导入提供“域名推断 + 自动分组名 + 已知 provider 集合 + providers 列表新增 auto 选项”。
|
||||
**涉及文件**:`outlook_web/services/providers.py`
|
||||
|
||||
#### 1.1 `outlook_web/services/providers.py` — 域名推断与分组名映射
|
||||
|
||||
- [ ] 新增 `DOMAIN_PROVIDER_MAP`(覆盖 gmail/qq/163/126/yahoo/aliyun/outlook 等常见域名)
|
||||
- [ ] 新增 `infer_provider_from_email(email) -> Optional[str]`
|
||||
- [ ] 新增 `PROVIDER_GROUP_NAME`(provider → 分组名,含 `gptmail: 临时邮箱`)
|
||||
- [ ] 新增 `KNOWN_PROVIDER_KEYS = set(MAIL_PROVIDERS.keys())`(用于 3 段格式校验)
|
||||
|
||||
#### 1.2 `outlook_web/services/providers.py` — `GET /api/providers` 返回新增 `auto`
|
||||
|
||||
- [ ] 修改 `get_provider_list()`:在列表首位插入 `auto` 选项(key/label/account_type/note)
|
||||
- [ ] 确保 `auto` **不进入** `MAIL_PROVIDERS`(仅用于 UI 触发模式)
|
||||
- [ ] 验证:
|
||||
- [ ] `GET /api/providers` 返回包含 `auto` 且顺序位于第一位
|
||||
- [ ] 现有 provider 顺序(outlook 在前、custom 在后)保持用户预期
|
||||
|
||||
---
|
||||
|
||||
### 阶段 2:Repository 层改造(自动分组 + 覆盖更新)
|
||||
|
||||
**目标**:支持 auto 模式的“自动分组查找/创建”与“覆盖更新凭据”。
|
||||
**涉及文件**:`outlook_web/repositories/groups.py`、`outlook_web/repositories/accounts.py`
|
||||
|
||||
#### 2.1 `outlook_web/repositories/groups.py` — 新增 `get_group_by_name()`
|
||||
|
||||
- [ ] 新增函数 `get_group_by_name(name: str) -> Optional[Dict]`
|
||||
- [ ] SQL 规则:`LOWER(name) = LOWER(?)`(大小写不敏感)
|
||||
- [ ] 验证:
|
||||
- [ ] 分组存在/不存在返回正确
|
||||
- [ ] 大小写不同也能匹配
|
||||
|
||||
#### 2.2 `outlook_web/repositories/accounts.py` — 新增 `update_account_credentials()`
|
||||
|
||||
- [ ] 新增函数 `update_account_credentials(account_id: int, **fields) -> bool`
|
||||
- [ ] 字段白名单:
|
||||
- [ ] `password`、`client_id`、`refresh_token`
|
||||
- [ ] `imap_password`、`imap_host`、`imap_port`
|
||||
- [ ] `account_type`、`provider`、`group_id`
|
||||
- [ ] 敏感字段自动加密:`password/refresh_token/imap_password`
|
||||
- [ ] 必须更新 `updated_at = CURRENT_TIMESTAMP`
|
||||
- [ ] 验证:
|
||||
- [ ] 仅白名单字段可更新,其他字段(如 remark/status)不会被覆盖
|
||||
- [ ] 更新后 DB 中敏感字段为加密串
|
||||
|
||||
---
|
||||
|
||||
### 阶段 3:导入端(provider=auto)实现(后端)
|
||||
|
||||
**目标**:实现“智能混合导入”完整闭环:类型识别、域名推断、自动分组、重复策略、GPTMail 导入到 `temp_emails`。
|
||||
**涉及文件**:`outlook_web/controllers/accounts.py`
|
||||
|
||||
#### 3.1 `outlook_web/controllers/accounts.py` — `api_add_account()` 增加 auto 分支
|
||||
|
||||
- [ ] 在 `api_add_account()` 中新增 `provider == 'auto'` 分支,调用 `_handle_auto_import(data)`
|
||||
- [ ] 注意:auto 模式允许 `group_id=null`(自动分组),因此不能沿用“入口先校验 group_id”的旧逻辑
|
||||
- [ ] 保证 `provider != auto` 的 Outlook/IMAP 现有导入流程不变(向后兼容)
|
||||
|
||||
#### 3.2 `outlook_web/controllers/accounts.py` — 类型识别 `_detect_line_type()`
|
||||
|
||||
- [ ] 新增 `_detect_line_type(line, fallback_host, fallback_port)`,覆盖:
|
||||
- [ ] custom 5 段:`email----imap_pwd----custom----host----port`
|
||||
- [ ] outlook ≥4 段:`email----pwd----client_id----refresh_token(可含----)`
|
||||
- [ ] imap 3 段:`email----imap_pwd----provider`(provider 必须在 `KNOWN_PROVIDER_KEYS`)
|
||||
- [ ] 2 段:域名推断成功 → 标准 IMAP;推断失败 → custom 兜底(依赖 fallback_host)
|
||||
- [ ] 1 段:GPTMail(仅邮箱地址,建议白名单域名,如 `gptmail.com`)
|
||||
- [ ] 注释行 `#` 与空行由主循环跳过
|
||||
- [ ] 验证 refresh_token 合并逻辑正确(token 中包含 `----` 不会被截断)
|
||||
|
||||
#### 3.3 `outlook_web/controllers/accounts.py` — 自动分组 `_resolve_auto_group()`
|
||||
|
||||
- [ ] 新增 `_resolve_auto_group(provider, group_cache, groups_created)`:
|
||||
- [ ] 使用 `groups_repo.get_group_by_name()`
|
||||
- [ ] 不存在则 `groups_repo.add_group()` 自动创建
|
||||
- [ ] 使用 `group_cache` 缓存 provider→group_id(性能)
|
||||
- [ ] 记录新创建分组名到 `groups_created`
|
||||
|
||||
#### 3.4 `outlook_web/controllers/accounts.py` — 重复策略与覆盖更新
|
||||
|
||||
- [ ] 新增 `_overwrite_account(existing, detect_result, group_id)`:
|
||||
- [ ] Outlook:更新 `password/client_id/refresh_token/account_type/provider/group_id`
|
||||
- [ ] IMAP:更新 `imap_password/imap_host/imap_port/account_type/provider/group_id`
|
||||
- [ ] 保留 `remark/tags/status/created_at`
|
||||
- [ ] 调用 `accounts_repo.update_account_credentials()`
|
||||
- [ ] `duplicate_strategy` 白名单校验:仅允许 `skip/overwrite`,否则回退到 `skip`
|
||||
- [ ] 重复账号统计:
|
||||
- [ ] skip → `skipped += 1`
|
||||
- [ ] overwrite → 计入 imported(代表处理成功)
|
||||
|
||||
#### 3.5 `outlook_web/controllers/accounts.py` — GPTMail 导入 `_handle_gptmail_import()`
|
||||
|
||||
- [ ] 遇到 GPTMail 行时:
|
||||
- [ ] 写入 `temp_emails`(`temp_emails_repo.add_temp_email()`),不写入 `accounts`
|
||||
- [ ] 调用 `gptmail.get_temp_emails_from_api(email)` 做可用性检查
|
||||
- [ ] API 不可用时尝试重新注册:`gptmail.generate_temp_email(prefix, domain)`
|
||||
- [ ] 单次导入 GPTMail 数量做上限(建议 20)
|
||||
- [ ] 失败写入 `errors`(包含 line/email/error)
|
||||
|
||||
#### 3.6 `outlook_web/controllers/accounts.py` — auto 导入主流程 `_handle_auto_import()`
|
||||
|
||||
- [ ] 完成逐行处理:空行/注释跳过;错误不中断;统计汇总
|
||||
- [ ] `group_id` 处理:
|
||||
- [ ] `group_id is None` → 自动分组(Outlook/IMAP)
|
||||
- [ ] `group_id is int` → 校验分组存在且 `is_system=0`,Outlook/IMAP 全部落该分组
|
||||
- [ ] GPTMail 行忽略 group_id(写入 temp_emails)
|
||||
- [ ] 响应 `summary` 增强:
|
||||
- [ ] `mode: "auto"`
|
||||
- [ ] `imported/skipped/failed/total_lines`
|
||||
- [ ] `by_provider` 分类统计
|
||||
- [ ] `groups_created`
|
||||
- [ ] `errors_total/errors_returned/errors_truncated`
|
||||
- [ ] `success` 规则:
|
||||
- [ ] 只要有 imported 或 skipped > 0 即可视为整体成功(导入完成且有有效处理)
|
||||
- [ ] imported==0 且 skipped==0 → success=false
|
||||
- [ ] 记录审计日志(不写入敏感字段原文)
|
||||
|
||||
---
|
||||
|
||||
### 阶段 4:导出端增强(v2:头部元信息 + GPTMail + 文件名)
|
||||
|
||||
**目标**:导出文件可直接导回(含元信息头、分段注释、GPTMail 分段、文件名时间戳)。
|
||||
**涉及文件**:`outlook_web/controllers/accounts.py`
|
||||
|
||||
#### 4.1 `_build_export_text()` 升级为 v2
|
||||
|
||||
- [ ] 将导出文本升级为 v2:
|
||||
- [ ] 头部元信息注释块(导出时间/总数/各类型统计/格式版本 v2)
|
||||
- [ ] Outlook 分段(不改变单行格式)
|
||||
- [ ] IMAP 分段(不改变单行格式,custom 输出 5 段)
|
||||
- [ ] GPTMail 分段(从 `temp_emails` 表导出,仅邮箱地址)
|
||||
- [ ] 文件末尾保证有 `\n`
|
||||
- [ ] 统计口径:accounts + temp_emails
|
||||
- [ ] 建议使用 `io.StringIO` 拼接(大量账号时更稳)
|
||||
|
||||
#### 4.2 导出文件名统一为 `accounts_export_YYYYMMDD_HHMMSS.txt`
|
||||
|
||||
- [ ] 全量导出文件名:`accounts_export_YYYYMMDD_HHMMSS.txt`
|
||||
- [ ] 导出选中分组文件名:`accounts_export_selected_YYYYMMDD_HHMMSS.txt`
|
||||
- [ ] `Content-Disposition` 继续使用 `filename*=UTF-8''{quote(filename)}` 兼容中文
|
||||
|
||||
#### 4.3 export-selected 对 GPTMail 的处理
|
||||
|
||||
- [ ] `POST /api/accounts/export-selected`:
|
||||
- [ ] 仅当 `group_ids` 包含“临时邮箱”系统分组 id 时,才附加 GPTMail 分段
|
||||
- [ ] 否则不导出 GPTMail
|
||||
|
||||
---
|
||||
|
||||
### 阶段 5:前端交互改造(导入模态框 + 请求构建 + 结果展示)
|
||||
|
||||
**目标**:UI 支持“智能识别(混合导入)”,并展示更清晰的导入结果。
|
||||
**涉及文件**:`templates/partials/modals.html`、`static/js/features/accounts.js`
|
||||
|
||||
#### 5.1 `templates/partials/modals.html` — 导入模态框新增元素
|
||||
|
||||
- [ ] 新增 `duplicateStrategyGroup`(skip/overwrite 单选)
|
||||
- [ ] 新增 `fallbackImapGroup`(fallback host/port,可选)
|
||||
- [ ] 默认 `display:none;`,仅 provider=auto 时显示
|
||||
|
||||
#### 5.2 `static/js/features/accounts.js` — `onProviderChange('auto')`
|
||||
|
||||
- [ ] provider=auto 时:
|
||||
- [ ] 隐藏 `customImapFields`
|
||||
- [ ] 显示 `duplicateStrategyGroup` + `fallbackImapGroup`
|
||||
- [ ] 更新 textarea placeholder/hint(提示混合格式)
|
||||
- [ ] 隐藏“获取 Token”按钮
|
||||
- [ ] 禁用分组下拉并显示“自动按类型分组”
|
||||
- [ ] provider != auto 时:保持现有行为不变(回归)
|
||||
|
||||
#### 5.3 `static/js/features/accounts.js` — `addAccount()` 构建 auto 请求体
|
||||
|
||||
- [ ] provider=auto:
|
||||
- [ ] `payload.provider='auto'`
|
||||
- [ ] `payload.group_id=null`
|
||||
- [ ] `payload.duplicate_strategy` 从 radio 读取(默认 skip)
|
||||
- [ ] `payload.imap_host/imap_port` 仅当 fallback host 填写时提交
|
||||
- [ ] 导入结果展示:
|
||||
- [ ] 当 `data.summary.mode === 'auto'` 时,展示按类型统计与自动创建分组列表
|
||||
- [ ] 成功后刷新分组列表(可能新增分组)
|
||||
|
||||
---
|
||||
|
||||
### 阶段 6:测试补充(单元 + 集成)
|
||||
|
||||
**目标**:覆盖 auto 导入/导出 v2 的核心路径与边界,确保无回归。
|
||||
**涉及文件**:`tests/`
|
||||
|
||||
#### 6.1 单元测试(建议新增文件)
|
||||
|
||||
- [ ] `tests/test_domain_provider_map.py`
|
||||
- [ ] `infer_provider_from_email()` 已知/未知域名
|
||||
- [ ] `tests/test_detect_line_type.py`
|
||||
- [ ] Outlook ≥4 段(含 `----` token)
|
||||
- [ ] custom 5 段
|
||||
- [ ] IMAP 3 段合法/非法 provider
|
||||
- [ ] 2 段域名推断成功/失败(含 fallback)
|
||||
- [ ] GPTMail 1 段
|
||||
- [ ] `tests/test_auto_import.py`
|
||||
- [ ] 混合文件全链路(含统计与 errors 行号)
|
||||
- [ ] skip/overwrite 两种重复策略
|
||||
- [ ] 自动分组创建/复用
|
||||
- [ ] 显式 group_id 统一落组
|
||||
- [ ] GPTMail 导入写入 `temp_emails`(mock GPTMail API)
|
||||
- [ ] `tests/test_export_enhanced_v2.py`
|
||||
- [ ] 头部元信息存在且统计正确
|
||||
- [ ] GPTMail 分段存在且仅邮箱地址
|
||||
- [ ] export-selected 仅在包含“临时邮箱”分组时输出 GPTMail
|
||||
- [ ] 文件名包含时间戳(header 校验)
|
||||
|
||||
#### 6.2 测试运行与回归
|
||||
|
||||
- [ ] 运行全量测试:`python -m unittest discover -s tests -v`
|
||||
- [ ] 确认通过数量不低于基线,且新增用例全部通过
|
||||
|
||||
---
|
||||
|
||||
### 阶段 7:P0 端到端验收(手动)
|
||||
|
||||
- [ ] **导出→导入闭环**:导出全量(含临时邮箱)→ 清空数据 → `provider=auto` 直接导入 → 账号恢复
|
||||
- [ ] **混合文件导入**:Outlook + Gmail + QQ + custom + GPTMail 同文件一次导入成功
|
||||
- [ ] **域名推断**:2 段 Gmail/QQ/163/126/yahoo/aliyun 能正确识别 provider
|
||||
- [ ] **未知域名兜底**:2 段未知域名 + fallback host/port 能导入为 custom;无 fallback 时该行失败但不影响其他行
|
||||
- [ ] **重复策略**:
|
||||
- [ ] skip:重复邮箱被跳过,计数正确
|
||||
- [ ] overwrite:凭据更新,remark/tags 保留
|
||||
- [ ] **自动分组**:不存在分组自动创建;存在同名分组复用
|
||||
- [ ] **向后兼容**:原有 provider=outlook / provider=qq 等单类型导入行为不变
|
||||
- [ ] **导出安全**:导出仍要求二次验证 token(X-Export-Token)
|
||||
|
||||
---
|
||||
|
||||
## P1(建议完成)
|
||||
|
||||
- [ ] 对微软域名 2 段格式给出更明确的提示(可能需要 OAuth 4 段)
|
||||
- [ ] auto 导入新增 `warnings` 字段(与 errors 分离,不影响 success)
|
||||
- [ ] 大批量导入性能优化:预加载 accounts email 集合减少逐行查库(可选)
|
||||
|
||||
---
|
||||
|
||||
## P2(可选)
|
||||
|
||||
- [ ] 域名推断扩展:更多 yahoo TLD、企业邮箱白名单策略
|
||||
- [ ] 导出头部元信息加入“导出来源实例标识”(如 instance_id,便于排查)
|
||||
- [ ] 导出格式 JSON/CSV(Out of Scope,本版本不做)
|
||||
|
||||
---
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
| 风险点 | 应对措施 |
|
||||
|---|---|
|
||||
| auto 模式允许 `group_id=null`,可能破坏旧入口校验逻辑 | 将 group 校验逻辑下沉到 auto 分支内部,其他分支保持不变 |
|
||||
| 覆盖更新误操作 | UI 明示“覆盖更新”风险 + 审计记录覆盖对象与字段名(不记录值) |
|
||||
| GPTMail API 不稳定/限流 | mock 覆盖测试;导入限流 + 单次导入数量上限 |
|
||||
| 导出文件包含明文敏感信息 | 保持 2FA 二次验证不变;审计/日志不输出敏感字段 |
|
||||
| 旧导出文件兼容性 | auto 导入跳过 `#` 注释行;旧文件无头部也可直接导入 |
|
||||
|
||||
---
|
||||
|
||||
## 进度跟踪
|
||||
|
||||
### 总体进度
|
||||
|
||||
- [x] 前置准备(100%)
|
||||
- [x] 阶段 1:Provider/域名配置(100%)
|
||||
- [x] 阶段 2:Repository 改造(100%)
|
||||
- [x] 阶段 3:auto 导入(100%)
|
||||
- [x] 阶段 4:导出 v2(100%)
|
||||
- [x] 阶段 5:前端改造(100%)
|
||||
- [x] 阶段 6:测试补充(100%)
|
||||
- [ ] 阶段 7:P0 验收(待手动验收)
|
||||
|
||||
### 文件改造进度
|
||||
|
||||
| 文件 | 状态 |
|
||||
|---|---|
|
||||
| `outlook_web/services/providers.py` | ✅ 完成 |
|
||||
| `outlook_web/repositories/groups.py` | ✅ 完成 |
|
||||
| `outlook_web/repositories/accounts.py` | ✅ 完成 |
|
||||
| `outlook_web/controllers/accounts.py` | ✅ 完成 |
|
||||
| `templates/partials/modals.html` | ✅ 完成 |
|
||||
| `static/js/features/accounts.js` | ✅ 完成 |
|
||||
| `tests/`(新增/改造) | ✅ 完成 |
|
||||
|
||||
---
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [PRD-00006-账号导入导出无缝迁移](../PRD/PRD-00006-账号导入导出无缝迁移.md)
|
||||
- [FD-00006-账号导入导出无缝迁移](../FD/FD-00006-账号导入导出无缝迁移.md)
|
||||
- [TDD-00006-账号导入导出无缝迁移](../TDD/TDD-00006-账号导入导出无缝迁移.md)
|
||||
|
||||
---
|
||||
|
||||
**文档版本:** V1.0
|
||||
**最后更新:** 2026-03-04
|
||||
|
||||
@@ -171,6 +171,10 @@ def api_add_account() -> Any:
|
||||
if not account_str:
|
||||
return jsonify({"success": False, "error": "请输入账号信息"})
|
||||
|
||||
# FD-00006: auto 模式允许 group_id=null(自动分组),需在分组校验前分流
|
||||
if provider == "auto":
|
||||
return _handle_auto_import(data)
|
||||
|
||||
# 校验分组
|
||||
target_group = groups_repo.get_group_by_id(group_id)
|
||||
if not target_group:
|
||||
@@ -504,6 +508,438 @@ def api_get_providers() -> Any:
|
||||
return jsonify({"success": True, "providers": get_provider_list()})
|
||||
|
||||
|
||||
# ==================== Auto 混合导入 (FD-00006) ====================
|
||||
|
||||
|
||||
def _detect_line_type(
|
||||
line: str,
|
||||
fallback_host: str = "",
|
||||
fallback_port: int = 993,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
根据分隔后的段数和内容特征,自动判断一行账号的类型。
|
||||
返回 {"type", "provider", "fields", "error", "auto_group_name"}。
|
||||
"""
|
||||
from outlook_web.services.providers import (
|
||||
KNOWN_PROVIDER_KEYS,
|
||||
MAIL_PROVIDERS,
|
||||
PROVIDER_GROUP_NAME,
|
||||
infer_provider_from_email,
|
||||
)
|
||||
|
||||
parts = line.split("----")
|
||||
n = len(parts)
|
||||
|
||||
def _err(msg: str) -> Dict[str, Any]:
|
||||
return {"type": "error", "provider": "", "fields": {}, "error": msg, "auto_group_name": ""}
|
||||
|
||||
# n >= 5 且 parts[2] == "custom" → 自定义 IMAP
|
||||
if n >= 5 and (parts[2] or "").strip().lower() == "custom":
|
||||
email = parts[0].strip()
|
||||
imap_pwd = parts[1].strip()
|
||||
host = (parts[3] or "").strip()
|
||||
try:
|
||||
port = int((parts[4] or "").strip() or 993)
|
||||
except Exception:
|
||||
port = 993
|
||||
if not email or not imap_pwd or not host:
|
||||
return _err("custom 5段格式缺少必要字段")
|
||||
return {
|
||||
"type": "imap",
|
||||
"provider": "custom",
|
||||
"fields": {"email": email, "imap_password": imap_pwd, "imap_host": host, "imap_port": port},
|
||||
"error": None,
|
||||
"auto_group_name": PROVIDER_GROUP_NAME.get("custom", "自定义IMAP"),
|
||||
}
|
||||
|
||||
# n >= 4 → Outlook(OAuth)
|
||||
if n >= 4:
|
||||
email = parts[0].strip()
|
||||
password = parts[1].strip()
|
||||
client_id = parts[2].strip()
|
||||
refresh_token = "----".join(parts[3:]).strip()
|
||||
if not email or not client_id or not refresh_token:
|
||||
return _err("Outlook 格式缺少 client_id 或 refresh_token")
|
||||
return {
|
||||
"type": "outlook",
|
||||
"provider": "outlook",
|
||||
"fields": {"email": email, "password": password, "client_id": client_id, "refresh_token": refresh_token},
|
||||
"error": None,
|
||||
"auto_group_name": PROVIDER_GROUP_NAME.get("outlook", "Outlook"),
|
||||
}
|
||||
|
||||
# n == 3 → 检查第3段是否为已知 provider
|
||||
if n == 3:
|
||||
email = parts[0].strip()
|
||||
imap_pwd = parts[1].strip()
|
||||
prov = (parts[2] or "").strip().lower()
|
||||
if not email or not imap_pwd:
|
||||
return _err("3段格式缺少邮箱或密码")
|
||||
if prov not in KNOWN_PROVIDER_KEYS:
|
||||
return _err(f"未知的 provider: {prov}")
|
||||
cfg = MAIL_PROVIDERS.get(prov, {})
|
||||
host = cfg.get("imap_host", "")
|
||||
port = int(cfg.get("imap_port", 993))
|
||||
if prov == "custom":
|
||||
return _err("3段格式不支持 custom(需要5段包含 host/port)")
|
||||
return {
|
||||
"type": "imap",
|
||||
"provider": prov,
|
||||
"fields": {"email": email, "imap_password": imap_pwd, "imap_host": host, "imap_port": port},
|
||||
"error": None,
|
||||
"auto_group_name": PROVIDER_GROUP_NAME.get(prov, prov),
|
||||
}
|
||||
|
||||
# n == 2 → 域名推断
|
||||
if n == 2:
|
||||
email = parts[0].strip()
|
||||
imap_pwd = parts[1].strip()
|
||||
if not email or not imap_pwd:
|
||||
return _err("2段格式缺少邮箱或密码")
|
||||
prov = infer_provider_from_email(email)
|
||||
if prov:
|
||||
cfg = MAIL_PROVIDERS.get(prov, {})
|
||||
host = cfg.get("imap_host", "")
|
||||
port = int(cfg.get("imap_port", 993))
|
||||
return {
|
||||
"type": "imap",
|
||||
"provider": prov,
|
||||
"fields": {"email": email, "imap_password": imap_pwd, "imap_host": host, "imap_port": port},
|
||||
"error": None,
|
||||
"auto_group_name": PROVIDER_GROUP_NAME.get(prov, prov),
|
||||
}
|
||||
# 推断失败 → custom 兜底
|
||||
if fallback_host:
|
||||
return {
|
||||
"type": "imap",
|
||||
"provider": "custom",
|
||||
"fields": {"email": email, "imap_password": imap_pwd, "imap_host": fallback_host, "imap_port": fallback_port},
|
||||
"error": None,
|
||||
"auto_group_name": PROVIDER_GROUP_NAME.get("custom", "自定义IMAP"),
|
||||
}
|
||||
return _err("未知域名且未提供兜底 IMAP 服务器地址")
|
||||
|
||||
# n == 1 → GPTMail
|
||||
if n == 1:
|
||||
email = parts[0].strip()
|
||||
if not email or "@" not in email:
|
||||
return _err("无法解析的行")
|
||||
if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email):
|
||||
return _err("邮箱格式不正确")
|
||||
return {
|
||||
"type": "gptmail",
|
||||
"provider": "gptmail",
|
||||
"fields": {"email": email},
|
||||
"error": None,
|
||||
"auto_group_name": PROVIDER_GROUP_NAME.get("gptmail", "临时邮箱"),
|
||||
}
|
||||
|
||||
return _err("无法解析的行")
|
||||
|
||||
|
||||
def _resolve_auto_group(
|
||||
provider: str,
|
||||
group_cache: Dict[str, int],
|
||||
groups_created: List[str],
|
||||
) -> int:
|
||||
"""根据 provider 查找或创建分组,使用缓存避免重复查询。"""
|
||||
from outlook_web.services.providers import PROVIDER_GROUP_NAME
|
||||
|
||||
if provider in group_cache:
|
||||
return group_cache[provider]
|
||||
|
||||
group_name = PROVIDER_GROUP_NAME.get(provider, provider)
|
||||
existing = groups_repo.get_group_by_name(group_name)
|
||||
if existing:
|
||||
group_cache[provider] = existing["id"]
|
||||
return existing["id"]
|
||||
|
||||
new_id = groups_repo.add_group(group_name)
|
||||
if new_id:
|
||||
group_cache[provider] = new_id
|
||||
groups_created.append(group_name)
|
||||
return new_id
|
||||
|
||||
# 创建失败时尝试再次查找(可能并发创建)
|
||||
existing = groups_repo.get_group_by_name(group_name)
|
||||
if existing:
|
||||
group_cache[provider] = existing["id"]
|
||||
return existing["id"]
|
||||
|
||||
# 兜底:使用默认分组
|
||||
default_id = groups_repo.get_default_group_id()
|
||||
group_cache[provider] = default_id
|
||||
return default_id
|
||||
|
||||
|
||||
def _overwrite_account(existing: Dict, detect_result: Dict, group_id: int) -> bool:
|
||||
"""覆盖更新已存在账号的凭据字段,保留 remark/tags/status。"""
|
||||
fields: Dict[str, Any] = {"group_id": group_id}
|
||||
d = detect_result
|
||||
prov = d["provider"]
|
||||
f = d["fields"]
|
||||
|
||||
if d["type"] == "outlook":
|
||||
fields["password"] = f.get("password", "")
|
||||
fields["client_id"] = f.get("client_id", "")
|
||||
fields["refresh_token"] = f.get("refresh_token", "")
|
||||
fields["account_type"] = "outlook"
|
||||
fields["provider"] = "outlook"
|
||||
elif d["type"] == "imap":
|
||||
fields["imap_password"] = f.get("imap_password", "")
|
||||
fields["imap_host"] = f.get("imap_host", "")
|
||||
fields["imap_port"] = f.get("imap_port", 993)
|
||||
fields["account_type"] = "imap"
|
||||
fields["provider"] = prov
|
||||
|
||||
return accounts_repo.update_account_credentials(existing["id"], **fields)
|
||||
|
||||
|
||||
def _handle_gptmail_import(
|
||||
email: str,
|
||||
errors: List[Dict[str, Any]],
|
||||
line_num: int,
|
||||
gptmail_count: int,
|
||||
max_gptmail: int = 20,
|
||||
) -> bool:
|
||||
"""处理 GPTMail 临时邮箱的导入,写入 temp_emails 表。"""
|
||||
from outlook_web.repositories import temp_emails as temp_emails_repo
|
||||
from outlook_web.services import gptmail
|
||||
|
||||
if gptmail_count >= max_gptmail:
|
||||
errors.append({"line": line_num, "email": email, "error": f"GPTMail 单次导入上限 {max_gptmail} 个", "detected_type": "gptmail"})
|
||||
return False
|
||||
|
||||
# 检查是否已存在
|
||||
existing = temp_emails_repo.get_temp_email_by_address(email)
|
||||
if existing:
|
||||
return True # 已存在视为跳过(成功)
|
||||
|
||||
# 尝试可用性检查
|
||||
try:
|
||||
result = gptmail.get_temp_emails_from_api(email)
|
||||
if result and result.get("success"):
|
||||
ok = temp_emails_repo.add_temp_email(email)
|
||||
return ok
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# API 不可用时尝试重新注册
|
||||
try:
|
||||
if "@" in email:
|
||||
prefix, domain = email.rsplit("@", 1)
|
||||
result = gptmail.generate_temp_email(prefix, domain)
|
||||
if result and result.get("success"):
|
||||
ok = temp_emails_repo.add_temp_email(email)
|
||||
return ok
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 直接添加(即使 API 不可用也保存地址)
|
||||
ok = temp_emails_repo.add_temp_email(email)
|
||||
return ok
|
||||
|
||||
|
||||
def _handle_auto_import(data: Dict[str, Any]) -> Any:
|
||||
"""处理 provider="auto" 的智能混合导入。"""
|
||||
account_str = data.get("account_string", "")
|
||||
duplicate_strategy = (data.get("duplicate_strategy") or "skip").strip().lower()
|
||||
if duplicate_strategy not in ("skip", "overwrite"):
|
||||
duplicate_strategy = "skip"
|
||||
fallback_host = (data.get("imap_host") or "").strip()
|
||||
try:
|
||||
fallback_port = int(data.get("imap_port") or 993)
|
||||
except Exception:
|
||||
fallback_port = 993
|
||||
explicit_group_id = data.get("group_id")
|
||||
|
||||
# 验证显式 group_id(如果提供)
|
||||
use_auto_group = explicit_group_id is None
|
||||
if not use_auto_group:
|
||||
try:
|
||||
explicit_group_id = int(explicit_group_id)
|
||||
except Exception:
|
||||
explicit_group_id = None
|
||||
use_auto_group = True
|
||||
if explicit_group_id is not None:
|
||||
target_group = groups_repo.get_group_by_id(explicit_group_id)
|
||||
if not target_group:
|
||||
return jsonify({"success": False, "error": "指定的分组不存在"})
|
||||
if target_group.get("is_system"):
|
||||
return jsonify({"success": False, "error": "不能导入到系统分组"})
|
||||
|
||||
raw_lines = account_str.splitlines()
|
||||
imported = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
by_provider: Dict[str, Dict[str, int]] = {}
|
||||
groups_created: List[str] = []
|
||||
errors: List[Dict[str, Any]] = []
|
||||
errors_total = 0
|
||||
max_error_details = 50
|
||||
group_cache: Dict[str, int] = {}
|
||||
gptmail_count = 0
|
||||
|
||||
for line_num, raw in enumerate(raw_lines, 1):
|
||||
line = (raw or "").strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
result = _detect_line_type(line, fallback_host, fallback_port)
|
||||
|
||||
if result["type"] == "error":
|
||||
failed += 1
|
||||
errors_total += 1
|
||||
if len(errors) < max_error_details:
|
||||
errors.append({"line": line_num, "email": "", "error": result["error"]})
|
||||
continue
|
||||
|
||||
fields = result["fields"]
|
||||
email = fields.get("email", "").strip()
|
||||
prov = result["provider"]
|
||||
|
||||
# 邮箱格式校验
|
||||
if not email or not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email):
|
||||
failed += 1
|
||||
errors_total += 1
|
||||
if len(errors) < max_error_details:
|
||||
errors.append({"line": line_num, "email": email, "error": "邮箱格式不正确", "detected_type": result["type"]})
|
||||
continue
|
||||
|
||||
# 初始化 provider 统计
|
||||
if prov not in by_provider:
|
||||
by_provider[prov] = {"imported": 0, "skipped": 0, "failed": 0}
|
||||
|
||||
# GPTMail 特殊处理:写入 temp_emails
|
||||
if result["type"] == "gptmail":
|
||||
from outlook_web.repositories import temp_emails as temp_emails_repo
|
||||
|
||||
existing_temp = temp_emails_repo.get_temp_email_by_address(email)
|
||||
if existing_temp:
|
||||
if duplicate_strategy == "skip":
|
||||
skipped += 1
|
||||
by_provider[prov]["skipped"] += 1
|
||||
continue
|
||||
# overwrite 对 GPTMail 无意义(无凭据可更新),视为跳过
|
||||
skipped += 1
|
||||
by_provider[prov]["skipped"] += 1
|
||||
continue
|
||||
|
||||
ok = _handle_gptmail_import(email, errors, line_num, gptmail_count)
|
||||
if ok:
|
||||
imported += 1
|
||||
gptmail_count += 1
|
||||
by_provider[prov]["imported"] += 1
|
||||
else:
|
||||
failed += 1
|
||||
errors_total += 1
|
||||
by_provider[prov]["failed"] += 1
|
||||
continue
|
||||
|
||||
# 解析分组(Outlook/IMAP)
|
||||
if use_auto_group:
|
||||
group_id = _resolve_auto_group(prov, group_cache, groups_created)
|
||||
else:
|
||||
group_id = explicit_group_id
|
||||
|
||||
# 检查重复
|
||||
existing = accounts_repo.get_account_by_email(email)
|
||||
if existing:
|
||||
if duplicate_strategy == "skip":
|
||||
skipped += 1
|
||||
by_provider[prov]["skipped"] += 1
|
||||
continue
|
||||
elif duplicate_strategy == "overwrite":
|
||||
ok = _overwrite_account(existing, result, group_id)
|
||||
if ok:
|
||||
imported += 1
|
||||
by_provider[prov]["imported"] += 1
|
||||
log_audit(
|
||||
"overwrite",
|
||||
"account",
|
||||
str(existing["id"]),
|
||||
f"覆盖更新 email={email}, provider={prov}",
|
||||
)
|
||||
else:
|
||||
failed += 1
|
||||
errors_total += 1
|
||||
by_provider[prov]["failed"] += 1
|
||||
if len(errors) < max_error_details:
|
||||
errors.append({"line": line_num, "email": email, "error": "覆盖更新失败", "detected_type": result["type"]})
|
||||
continue
|
||||
|
||||
# 新增账号
|
||||
if result["type"] == "outlook":
|
||||
ok = accounts_repo.add_account(
|
||||
email_addr=email,
|
||||
password=fields.get("password", ""),
|
||||
client_id=fields.get("client_id", ""),
|
||||
refresh_token=fields.get("refresh_token", ""),
|
||||
group_id=group_id,
|
||||
account_type="outlook",
|
||||
provider="outlook",
|
||||
)
|
||||
elif result["type"] == "imap":
|
||||
ok = accounts_repo.add_account(
|
||||
email_addr=email,
|
||||
password="",
|
||||
client_id="",
|
||||
refresh_token="",
|
||||
group_id=group_id,
|
||||
account_type="imap",
|
||||
provider=prov,
|
||||
imap_host=fields.get("imap_host", ""),
|
||||
imap_port=fields.get("imap_port", 993),
|
||||
imap_password=fields.get("imap_password", ""),
|
||||
)
|
||||
else:
|
||||
ok = False
|
||||
|
||||
if ok:
|
||||
imported += 1
|
||||
by_provider[prov]["imported"] += 1
|
||||
else:
|
||||
failed += 1
|
||||
errors_total += 1
|
||||
by_provider[prov]["failed"] += 1
|
||||
reason = "写入失败"
|
||||
try:
|
||||
exists = get_db().execute("SELECT 1 FROM accounts WHERE email = ? LIMIT 1", (email,)).fetchone()
|
||||
if exists:
|
||||
reason = "邮箱已存在"
|
||||
except Exception:
|
||||
pass
|
||||
if len(errors) < max_error_details:
|
||||
errors.append({"line": line_num, "email": email, "error": reason, "detected_type": result["type"]})
|
||||
|
||||
summary = {
|
||||
"mode": "auto",
|
||||
"total_lines": len(raw_lines),
|
||||
"imported": imported,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"by_provider": by_provider,
|
||||
"groups_created": groups_created,
|
||||
"duplicate_strategy": duplicate_strategy,
|
||||
"errors_total": errors_total,
|
||||
"errors_returned": len(errors),
|
||||
"errors_truncated": errors_total > len(errors),
|
||||
}
|
||||
|
||||
success = imported > 0 or skipped > 0
|
||||
message = f"混合导入完成:成功 {imported} 个,跳过 {skipped} 个,失败 {failed} 个"
|
||||
|
||||
if imported > 0 or skipped > 0:
|
||||
log_audit(
|
||||
"import",
|
||||
"account",
|
||||
None,
|
||||
f"{message},mode=auto,duplicate_strategy={duplicate_strategy}",
|
||||
)
|
||||
|
||||
return jsonify({"success": success, "message": message, "summary": summary, "errors": errors})
|
||||
|
||||
|
||||
@login_required
|
||||
def api_update_account(account_id: int) -> Any:
|
||||
"""更新账号"""
|
||||
@@ -886,20 +1322,31 @@ def api_search_accounts() -> Any:
|
||||
# ==================== 导出功能 API ====================
|
||||
|
||||
|
||||
def _build_export_text(accounts: List[Dict[str, Any]]) -> str:
|
||||
def _build_export_text(accounts: List[Dict[str, Any]], temp_emails: Optional[List[Dict]] = None) -> str:
|
||||
"""构建导出文本 v2:头部元信息 + 分段 + GPTMail 分段。"""
|
||||
import io
|
||||
|
||||
from outlook_web.services.providers import MAIL_PROVIDERS, get_provider_list
|
||||
|
||||
outlook_lines: List[str] = []
|
||||
imap_groups: Dict[str, List[str]] = {}
|
||||
gptmail_lines: List[str] = []
|
||||
|
||||
for acc in accounts or []:
|
||||
atype = (acc.get("account_type") or "outlook").strip().lower()
|
||||
prov = (acc.get("provider") or "").strip().lower()
|
||||
|
||||
# GPTMail 账号(如果存在于 accounts 表中)
|
||||
if prov == "gptmail":
|
||||
gptmail_lines.append(acc.get("email", ""))
|
||||
continue
|
||||
|
||||
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()
|
||||
provider = prov or "custom"
|
||||
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}"
|
||||
@@ -908,36 +1355,66 @@ def _build_export_text(accounts: List[Dict[str, Any]]) -> str:
|
||||
|
||||
imap_groups.setdefault(provider, []).append(line)
|
||||
|
||||
parts: List[str] = []
|
||||
# 追加 temp_emails 中的 GPTMail
|
||||
for te in temp_emails or []:
|
||||
email = te.get("email", "")
|
||||
if email and email not in gptmail_lines:
|
||||
gptmail_lines.append(email)
|
||||
|
||||
# 统计
|
||||
total = len(outlook_lines) + sum(len(v) for v in imap_groups.values()) + len(gptmail_lines)
|
||||
buf = io.StringIO()
|
||||
|
||||
# 头部元信息
|
||||
buf.write("# ============================================\n")
|
||||
buf.write("# Outlook Email Plus — 账号导出\n")
|
||||
buf.write(f"# 导出时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||
buf.write(f"# 账号总数:{total}\n")
|
||||
if outlook_lines:
|
||||
parts.append("# === Outlook 账号 ===")
|
||||
parts.extend(outlook_lines)
|
||||
buf.write(f"# Outlook:{len(outlook_lines)}\n")
|
||||
for prov_key, lines in imap_groups.items():
|
||||
label = (MAIL_PROVIDERS.get(prov_key, {}) or {}).get("label", prov_key)
|
||||
buf.write(f"# {label}:{len(lines)}\n")
|
||||
if gptmail_lines:
|
||||
buf.write(f"# 临时邮箱:{len(gptmail_lines)}\n")
|
||||
buf.write("# 格式版本:v2\n")
|
||||
buf.write("# ============================================\n")
|
||||
|
||||
# Outlook 分段
|
||||
if outlook_lines:
|
||||
buf.write("\n# === Outlook 账号 ===\n")
|
||||
for line in outlook_lines:
|
||||
buf.write(line + "\n")
|
||||
|
||||
# IMAP 分段(按 provider 排序)
|
||||
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"]
|
||||
|
||||
provider_order = [p for p in provider_order if p not in ("outlook", "auto")]
|
||||
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)
|
||||
buf.write(f"\n# === IMAP 账号({label})===\n")
|
||||
for line in lines:
|
||||
buf.write(line + "\n")
|
||||
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)
|
||||
buf.write(f"\n# === IMAP 账号({label})===\n")
|
||||
for line in lines:
|
||||
buf.write(line + "\n")
|
||||
|
||||
return "\n".join(parts).strip() + "\n"
|
||||
# GPTMail 分段
|
||||
if gptmail_lines:
|
||||
buf.write("\n# === 临时邮箱(GPTMail)===\n")
|
||||
for line in gptmail_lines:
|
||||
buf.write(line + "\n")
|
||||
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -964,16 +1441,21 @@ def api_export_all_accounts() -> Any:
|
||||
# 使用 load_accounts 获取所有账号(自动解密)
|
||||
accounts = accounts_repo.load_accounts()
|
||||
|
||||
if not accounts:
|
||||
# 加载 GPTMail 临时邮箱
|
||||
from outlook_web.repositories import temp_emails as temp_emails_repo
|
||||
|
||||
temp_emails = temp_emails_repo.load_temp_emails()
|
||||
|
||||
if not accounts and not temp_emails:
|
||||
return jsonify({"success": False, "error": "没有邮箱账号"})
|
||||
|
||||
# 记录审计日志
|
||||
log_audit("export", "all_accounts", None, f"导出所有账号,共 {len(accounts)} 个")
|
||||
log_audit("export", "all_accounts", None, f"导出所有账号,共 {len(accounts)} 个账号 + {len(temp_emails)} 个临时邮箱")
|
||||
|
||||
content = _build_export_text(accounts)
|
||||
content = _build_export_text(accounts, temp_emails)
|
||||
|
||||
# 生成文件名(使用 URL 编码处理中文)
|
||||
filename = f"all_accounts_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
|
||||
filename = f"accounts_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
|
||||
encoded_filename = quote(filename)
|
||||
|
||||
# 返回文件下载响应
|
||||
@@ -1015,7 +1497,15 @@ def api_export_selected_accounts() -> Any:
|
||||
accounts = accounts_repo.load_accounts(group_id)
|
||||
all_accounts.extend(accounts)
|
||||
|
||||
if not all_accounts:
|
||||
# 仅当选中了"临时邮箱"系统分组时才附加 GPTMail
|
||||
from outlook_web.repositories import temp_emails as temp_emails_repo
|
||||
|
||||
temp_emails: List[Dict] = []
|
||||
temp_group = groups_repo.get_group_by_name("临时邮箱")
|
||||
if temp_group and temp_group["id"] in group_ids:
|
||||
temp_emails = temp_emails_repo.load_temp_emails()
|
||||
|
||||
if not all_accounts and not temp_emails:
|
||||
return jsonify({"success": False, "error": "选中的分组下没有邮箱账号"})
|
||||
|
||||
# 记录审计日志
|
||||
@@ -1023,13 +1513,13 @@ def api_export_selected_accounts() -> Any:
|
||||
"export",
|
||||
"selected_groups",
|
||||
",".join(map(str, group_ids)),
|
||||
f"导出选中分组的 {len(all_accounts)} 个账号",
|
||||
f"导出选中分组的 {len(all_accounts)} 个账号 + {len(temp_emails)} 个临时邮箱",
|
||||
)
|
||||
|
||||
content = _build_export_text(all_accounts)
|
||||
content = _build_export_text(all_accounts, temp_emails)
|
||||
|
||||
# 生成文件名
|
||||
filename = f"selected_accounts_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
|
||||
filename = f"accounts_export_selected_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
|
||||
encoded_filename = quote(filename)
|
||||
|
||||
# 返回文件下载响应
|
||||
|
||||
@@ -337,3 +337,33 @@ def delete_account_by_email(email_addr: str) -> bool:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def update_account_credentials(account_id: int, **fields) -> bool:
|
||||
"""仅更新账号的凭据相关字段(用于覆盖导入场景),敏感字段自动加密。"""
|
||||
allowed = {
|
||||
"password", "client_id", "refresh_token",
|
||||
"imap_password", "imap_host", "imap_port",
|
||||
"account_type", "provider", "group_id",
|
||||
}
|
||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
# 加密敏感字段
|
||||
for key in ("password", "refresh_token", "imap_password"):
|
||||
if key in updates and updates[key]:
|
||||
updates[key] = encrypt_data(updates[key])
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||||
values = list(updates.values()) + [account_id]
|
||||
db.execute(
|
||||
f"UPDATE accounts SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
values,
|
||||
)
|
||||
db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -115,3 +115,11 @@ def get_group_account_count(group_id: int) -> int:
|
||||
cursor = db.execute("SELECT COUNT(*) as count FROM accounts WHERE group_id = ?", (group_id,))
|
||||
row = cursor.fetchone()
|
||||
return row["count"] if row else 0
|
||||
|
||||
|
||||
def get_group_by_name(name: str) -> Optional[Dict]:
|
||||
"""按名称查找分组(精确匹配,不区分大小写)"""
|
||||
db = get_db()
|
||||
cursor = db.execute("SELECT * FROM groups WHERE LOWER(name) = LOWER(?)", (name,))
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# 对齐:PRD-00005 / FD-00005 / TDD-00005
|
||||
# 对齐:PRD-00005 / FD-00005 / TDD-00005 / PRD-00006 / FD-00006
|
||||
# 职责:集中维护“邮箱提供商”元数据与 IMAP 文件夹映射,避免前后端重复维护默认 host/port 与 folder 兼容策略。
|
||||
|
||||
# 邮箱提供商配置(用于前端选择与默认 IMAP host/port)
|
||||
@@ -65,6 +65,57 @@ MAIL_PROVIDERS: Dict[str, Dict[str, Any]] = {
|
||||
},
|
||||
}
|
||||
|
||||
# FD-00006: 域名 → provider 反向映射(用于 auto 模式域名推断)
|
||||
DOMAIN_PROVIDER_MAP: Dict[str, str] = {
|
||||
# Gmail
|
||||
"gmail.com": "gmail",
|
||||
"googlemail.com": "gmail",
|
||||
# QQ
|
||||
"qq.com": "qq",
|
||||
"foxmail.com": "qq",
|
||||
# 163
|
||||
"163.com": "163",
|
||||
# 126
|
||||
"126.com": "126",
|
||||
# Yahoo
|
||||
"yahoo.com": "yahoo",
|
||||
"yahoo.co.jp": "yahoo",
|
||||
"yahoo.co.uk": "yahoo",
|
||||
# 阿里云
|
||||
"aliyun.com": "aliyun",
|
||||
"alimail.com": "aliyun",
|
||||
# 微软(2段格式按 IMAP 兜底处理,OAuth 至少4段)
|
||||
"outlook.com": "outlook",
|
||||
"hotmail.com": "outlook",
|
||||
"live.com": "outlook",
|
||||
"live.cn": "outlook",
|
||||
}
|
||||
|
||||
# FD-00006: provider → 自动分组名映射
|
||||
PROVIDER_GROUP_NAME: Dict[str, str] = {
|
||||
"outlook": "Outlook",
|
||||
"gmail": "Gmail",
|
||||
"qq": "QQ邮箱",
|
||||
"163": "163邮箱",
|
||||
"126": "126邮箱",
|
||||
"yahoo": "Yahoo",
|
||||
"aliyun": "阿里云邮箱",
|
||||
"custom": "自定义IMAP",
|
||||
"gptmail": "临时邮箱",
|
||||
}
|
||||
|
||||
# FD-00006: 已知 provider key 集合(用于 3 段格式校验)
|
||||
KNOWN_PROVIDER_KEYS: set = set(MAIL_PROVIDERS.keys())
|
||||
|
||||
|
||||
def infer_provider_from_email(email: str) -> Optional[str]:
|
||||
"""从邮箱地址推断 provider。返回 provider key 或 None。"""
|
||||
if not email or "@" not in email:
|
||||
return None
|
||||
domain = email.rsplit("@", 1)[-1].strip().lower()
|
||||
return DOMAIN_PROVIDER_MAP.get(domain)
|
||||
|
||||
|
||||
# provider -> 逻辑文件夹名(inbox/junkemail/deleteditems)-> 候选 IMAP 文件夹名列表
|
||||
PROVIDER_FOLDER_MAP: Dict[str, Dict[str, List[str]]] = {
|
||||
"gmail": {
|
||||
@@ -109,9 +160,16 @@ def get_imap_folder_candidates(provider: str, folder: str) -> List[str]:
|
||||
|
||||
|
||||
def get_provider_list() -> List[Dict[str, Any]]:
|
||||
"""返回供前端展示的 provider 列表(顺序固定:outlook 在前,custom 在后)"""
|
||||
"""返回供前端展示的 provider 列表(auto 在最前,outlook 其次,custom 在后)"""
|
||||
result: List[Dict[str, Any]] = [
|
||||
{
|
||||
"key": "auto",
|
||||
"label": "🔍 智能识别(混合导入)",
|
||||
"account_type": "mixed",
|
||||
"note": "自动识别每行的账号类型,支持混合文件一键导入",
|
||||
}
|
||||
]
|
||||
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
|
||||
|
||||
@@ -100,6 +100,9 @@
|
||||
const hint = document.getElementById('accountFormatHint');
|
||||
const customFields = document.getElementById('customImapFields');
|
||||
const getTokenBtn = document.getElementById('getRefreshTokenBtnInAdd');
|
||||
const duplicateGroup = document.getElementById('duplicateStrategyGroup');
|
||||
const fallbackGroup = document.getElementById('fallbackImapGroup');
|
||||
const importGroupSelect = document.getElementById('importGroupSelect');
|
||||
|
||||
if (!input || !hint || !customFields) return;
|
||||
|
||||
@@ -108,6 +111,35 @@
|
||||
getTokenBtn.style.display = (p === 'outlook') ? '' : 'none';
|
||||
}
|
||||
|
||||
// 重置 auto 模式特有的 UI
|
||||
if (duplicateGroup) duplicateGroup.style.display = 'none';
|
||||
if (fallbackGroup) fallbackGroup.style.display = 'none';
|
||||
if (importGroupSelect) {
|
||||
importGroupSelect.disabled = false;
|
||||
}
|
||||
|
||||
if (p === 'auto') {
|
||||
customFields.style.display = 'none';
|
||||
if (duplicateGroup) duplicateGroup.style.display = '';
|
||||
if (fallbackGroup) fallbackGroup.style.display = '';
|
||||
input.placeholder = '支持混合格式,每行一个账号...\nOutlook: 邮箱----密码----client_id----refresh_token\nIMAP: 邮箱----授权码----provider\n或: 邮箱----密码(自动识别类型)\nGPTMail: 仅邮箱地址';
|
||||
hint.textContent = '智能识别模式:自动按每行格式和邮箱域名判断类型,自动分组';
|
||||
if (getTokenBtn) getTokenBtn.style.display = 'none';
|
||||
if (importGroupSelect) {
|
||||
importGroupSelect.disabled = true;
|
||||
const savedHTML = importGroupSelect.innerHTML;
|
||||
importGroupSelect.dataset.savedOptions = savedHTML;
|
||||
importGroupSelect.innerHTML = '<option value="">自动按类型分组</option>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 恢复分组选择器(从 auto 切换回来时)
|
||||
if (importGroupSelect && importGroupSelect.dataset.savedOptions) {
|
||||
importGroupSelect.innerHTML = importGroupSelect.dataset.savedOptions;
|
||||
delete importGroupSelect.dataset.savedOptions;
|
||||
}
|
||||
|
||||
if (p === 'outlook') {
|
||||
customFields.style.display = 'none';
|
||||
input.placeholder = '邮箱----密码----client_id----refresh_token';
|
||||
@@ -148,6 +180,14 @@
|
||||
const portEl = document.getElementById('imapPort');
|
||||
if (hostEl) hostEl.value = '';
|
||||
if (portEl) portEl.value = '993';
|
||||
|
||||
// 重置 auto 模式的字段
|
||||
const fbHostEl = document.getElementById('fallbackImapHost');
|
||||
const fbPortEl = document.getElementById('fallbackImapPort');
|
||||
if (fbHostEl) fbHostEl.value = '';
|
||||
if (fbPortEl) fbPortEl.value = '993';
|
||||
const skipRadio = document.querySelector('input[name="duplicateStrategy"][value="skip"]');
|
||||
if (skipRadio) skipRadio.checked = true;
|
||||
});
|
||||
document.getElementById('addAccountModal').classList.add('show');
|
||||
}
|
||||
@@ -171,7 +211,19 @@
|
||||
|
||||
try {
|
||||
const payload = { account_string: input, group_id: groupId };
|
||||
if (provider && provider !== 'outlook') {
|
||||
|
||||
if (provider === 'auto') {
|
||||
payload.provider = 'auto';
|
||||
payload.group_id = null;
|
||||
const strategyEl = document.querySelector('input[name="duplicateStrategy"]:checked');
|
||||
payload.duplicate_strategy = strategyEl ? strategyEl.value : 'skip';
|
||||
const fbHost = (document.getElementById('fallbackImapHost')?.value || '').trim();
|
||||
const fbPort = parseInt(document.getElementById('fallbackImapPort')?.value || '993', 10);
|
||||
if (fbHost) {
|
||||
payload.imap_host = fbHost;
|
||||
payload.imap_port = fbPort || 993;
|
||||
}
|
||||
} else if (provider && provider !== 'outlook') {
|
||||
payload.provider = provider;
|
||||
if (provider === 'custom') {
|
||||
const host = (document.getElementById('imapHost')?.value || '').trim();
|
||||
@@ -202,17 +254,44 @@
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast(data.message, 'success');
|
||||
// Auto 模式增强结果展示
|
||||
if (data.summary && data.summary.mode === 'auto') {
|
||||
let msg = data.message;
|
||||
const s = data.summary;
|
||||
if (s.by_provider && Object.keys(s.by_provider).length > 0) {
|
||||
msg += '\n\n--- 按类型统计 ---';
|
||||
const provNames = {outlook:'Outlook',gmail:'Gmail',qq:'QQ邮箱','163':'163邮箱','126':'126邮箱',yahoo:'Yahoo',aliyun:'阿里云邮箱',custom:'自定义IMAP',gptmail:'临时邮箱'};
|
||||
for (const [prov, stats] of Object.entries(s.by_provider)) {
|
||||
const name = provNames[prov] || prov;
|
||||
msg += `\n${name}:成功 ${stats.imported || 0}`;
|
||||
if (stats.skipped) msg += `,跳过 ${stats.skipped}`;
|
||||
if (stats.failed) msg += `,失败 ${stats.failed}`;
|
||||
}
|
||||
}
|
||||
if (s.groups_created && s.groups_created.length > 0) {
|
||||
msg += `\n\n✨ 自动创建分组:${s.groups_created.join('、')}`;
|
||||
}
|
||||
showToast(msg, 'success');
|
||||
} else {
|
||||
showToast(data.message, 'success');
|
||||
}
|
||||
hideAddAccountModal();
|
||||
|
||||
// 清除该分组的缓存
|
||||
delete accountsCache[groupId];
|
||||
// 清除缓存并刷新分组列表(可能有新分组)
|
||||
if (typeof accountsCache !== 'undefined') {
|
||||
if (provider === 'auto') {
|
||||
// auto 模式可能影响多个分组,清除所有缓存
|
||||
for (const key in accountsCache) { delete accountsCache[key]; }
|
||||
} else {
|
||||
delete accountsCache[groupId];
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新分组列表(更新数量)
|
||||
loadGroups();
|
||||
|
||||
// 如果当前选中的就是这个分组,刷新邮箱列表
|
||||
if (currentGroupId === groupId) {
|
||||
if (currentGroupId && currentGroupId === groupId) {
|
||||
loadAccountsByGroup(groupId, true);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -84,6 +84,31 @@
|
||||
<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" id="duplicateStrategyGroup" style="display:none;">
|
||||
<label class="form-label">重复账号处理</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="duplicateStrategy" value="skip" checked>
|
||||
跳过重复(已存在的账号保持不变)
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="duplicateStrategy" value="overwrite">
|
||||
覆盖更新(用导入数据更新已存在账号的凭据)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 兜底 IMAP 设置(仅混合导入模式显示)-->
|
||||
<div class="form-group" id="fallbackImapGroup" style="display:none;">
|
||||
<label class="form-label">未知域名的 IMAP 设置(可选)</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" class="form-input" id="fallbackImapHost"
|
||||
placeholder="imap.example.com" style="flex:1;">
|
||||
<input type="number" class="form-input" id="fallbackImapPort"
|
||||
placeholder="993" value="993" style="width:80px;" min="1" max="65535">
|
||||
</div>
|
||||
<div class="form-hint">当邮箱域名无法自动识别时,使用此 IMAP 服务器地址</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">账号信息</label>
|
||||
<textarea class="form-textarea" id="accountInput"
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""tests/test_auto_import.py — FD-00006 auto 混合导入集成测试"""
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from tests._import_app import clear_login_attempts, import_web_app_module
|
||||
|
||||
|
||||
class TestAutoImport(unittest.TestCase):
|
||||
@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()
|
||||
from outlook_web.db import get_db
|
||||
|
||||
db = get_db()
|
||||
# 清理测试数据
|
||||
db.execute("DELETE FROM accounts WHERE email LIKE '%@autotest.%' OR email LIKE '%@gmail.com' OR email LIKE '%@qq.com'")
|
||||
db.execute("DELETE FROM temp_emails WHERE email LIKE '%@autotest.%' OR email LIKE '%@gptmail.com'")
|
||||
db.commit()
|
||||
|
||||
def _login(self, client):
|
||||
resp = client.post("/login", json={"password": "testpass123"})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def _import_auto(self, client, account_string, **kwargs):
|
||||
payload = {"account_string": account_string, "provider": "auto"}
|
||||
payload.update(kwargs)
|
||||
resp = client.post("/api/accounts", json=payload, content_type="application/json")
|
||||
return resp.get_json()
|
||||
|
||||
def test_mixed_import_outlook_and_imap(self):
|
||||
"""混合文件:Outlook + IMAP 3段 + 2段域名推断"""
|
||||
client = self.app.test_client()
|
||||
self._login(client)
|
||||
|
||||
text = (
|
||||
"ol@autotest.com----pwd----cid----rtoken\n"
|
||||
"qq@qq.com----authcode----qq\n"
|
||||
"gm@gmail.com----apppass"
|
||||
)
|
||||
data = self._import_auto(client, text)
|
||||
self.assertTrue(data["success"])
|
||||
s = data["summary"]
|
||||
self.assertEqual(s["mode"], "auto")
|
||||
self.assertEqual(s["imported"], 3)
|
||||
self.assertEqual(s["failed"], 0)
|
||||
self.assertIn("outlook", s["by_provider"])
|
||||
self.assertIn("qq", s["by_provider"])
|
||||
self.assertIn("gmail", s["by_provider"])
|
||||
|
||||
def test_skip_duplicate_strategy(self):
|
||||
"""skip 模式:重复邮箱被跳过"""
|
||||
client = self.app.test_client()
|
||||
self._login(client)
|
||||
|
||||
text = "dup@autotest.com----pwd----cid----rtoken"
|
||||
data = self._import_auto(client, text, duplicate_strategy="skip")
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["summary"]["imported"], 1)
|
||||
|
||||
# 再次导入同一个
|
||||
data = self._import_auto(client, text, duplicate_strategy="skip")
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["summary"]["skipped"], 1)
|
||||
self.assertEqual(data["summary"]["imported"], 0)
|
||||
|
||||
def test_overwrite_duplicate_strategy(self):
|
||||
"""overwrite 模式:凭据更新"""
|
||||
client = self.app.test_client()
|
||||
self._login(client)
|
||||
|
||||
text = "ow@autotest.com----pwd----cid----rtoken_old"
|
||||
data = self._import_auto(client, text)
|
||||
self.assertTrue(data["success"])
|
||||
|
||||
# overwrite
|
||||
text2 = "ow@autotest.com----pwd2----cid2----rtoken_new"
|
||||
data = self._import_auto(client, text2, duplicate_strategy="overwrite")
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["summary"]["imported"], 1)
|
||||
self.assertEqual(data["summary"]["skipped"], 0)
|
||||
|
||||
def test_comment_and_empty_lines_skipped(self):
|
||||
"""注释行和空行被跳过"""
|
||||
client = self.app.test_client()
|
||||
self._login(client)
|
||||
|
||||
text = "# This is a comment\n\nvalid@autotest.com----pwd----cid----rt\n \n# Another comment"
|
||||
data = self._import_auto(client, text)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["summary"]["imported"], 1)
|
||||
|
||||
def test_custom_5_segment(self):
|
||||
"""custom 5段格式"""
|
||||
client = self.app.test_client()
|
||||
self._login(client)
|
||||
|
||||
text = "c@autotest.com----pwd----custom----mail.autotest.com----993"
|
||||
data = self._import_auto(client, text)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertIn("custom", data["summary"]["by_provider"])
|
||||
|
||||
def test_auto_groups_created(self):
|
||||
"""自动分组应被创建"""
|
||||
client = self.app.test_client()
|
||||
self._login(client)
|
||||
|
||||
text = "ag@gmail.com----apppass"
|
||||
data = self._import_auto(client, text)
|
||||
self.assertTrue(data["success"])
|
||||
s = data["summary"]
|
||||
# Gmail 分组可能已存在也可能新创建
|
||||
self.assertIn("gmail", s["by_provider"])
|
||||
|
||||
def test_explicit_group_id(self):
|
||||
"""指定 group_id 时所有账号落入该分组"""
|
||||
client = self.app.test_client()
|
||||
self._login(client)
|
||||
|
||||
# 获取默认分组 ID
|
||||
with self.app.app_context():
|
||||
from outlook_web.repositories import groups as groups_repo
|
||||
|
||||
default_id = groups_repo.get_default_group_id()
|
||||
|
||||
text = "eg@autotest.com----pwd----cid----rt"
|
||||
data = self._import_auto(client, text, group_id=default_id)
|
||||
self.assertTrue(data["success"])
|
||||
|
||||
def test_unknown_domain_no_fallback_fails(self):
|
||||
"""2段未知域名无 fallback → 该行失败"""
|
||||
client = self.app.test_client()
|
||||
self._login(client)
|
||||
|
||||
text = "u@unknowndomain.xyz----pwd"
|
||||
data = self._import_auto(client, text)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertEqual(data["summary"]["failed"], 1)
|
||||
|
||||
def test_unknown_domain_with_fallback_succeeds(self):
|
||||
"""2段未知域名有 fallback → 成功"""
|
||||
client = self.app.test_client()
|
||||
self._login(client)
|
||||
|
||||
text = "uf@autotest.xyz----pwd"
|
||||
data = self._import_auto(client, text, imap_host="mail.autotest.xyz", imap_port=993)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["summary"]["imported"], 1)
|
||||
|
||||
def test_error_line_numbers(self):
|
||||
"""错误行号应正确"""
|
||||
client = self.app.test_client()
|
||||
self._login(client)
|
||||
|
||||
text = "valid@autotest.com----pwd----cid----rt\ninvalid-no-at\n"
|
||||
data = self._import_auto(client, text)
|
||||
# At least one error
|
||||
errors = data.get("errors", [])
|
||||
if errors:
|
||||
self.assertEqual(errors[0]["line"], 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,93 @@
|
||||
"""tests/test_detect_line_type.py — FD-00006 行类型识别单元测试"""
|
||||
import unittest
|
||||
|
||||
from tests._import_app import import_web_app_module
|
||||
|
||||
|
||||
class TestDetectLineType(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.module = import_web_app_module()
|
||||
cls.app = cls.module.app
|
||||
|
||||
def _detect(self, line, fallback_host="", fallback_port=993):
|
||||
with self.app.app_context():
|
||||
from outlook_web.controllers.accounts import _detect_line_type
|
||||
|
||||
return _detect_line_type(line, fallback_host, fallback_port)
|
||||
|
||||
# --- Outlook ≥4 段 ---
|
||||
def test_outlook_4_parts(self):
|
||||
r = self._detect("user@outlook.com----pwd----client123----refresh_tok")
|
||||
self.assertEqual(r["type"], "outlook")
|
||||
self.assertEqual(r["provider"], "outlook")
|
||||
self.assertEqual(r["fields"]["email"], "user@outlook.com")
|
||||
self.assertEqual(r["fields"]["client_id"], "client123")
|
||||
self.assertEqual(r["fields"]["refresh_token"], "refresh_tok")
|
||||
|
||||
def test_outlook_token_contains_separator(self):
|
||||
r = self._detect("u@o.com----p----cid----part1----part2----part3")
|
||||
self.assertEqual(r["type"], "outlook")
|
||||
self.assertEqual(r["fields"]["refresh_token"], "part1----part2----part3")
|
||||
|
||||
# --- Custom 5 段 ---
|
||||
def test_custom_5_parts(self):
|
||||
r = self._detect("user@corp.com----pwd123----custom----mail.corp.com----993")
|
||||
self.assertEqual(r["type"], "imap")
|
||||
self.assertEqual(r["provider"], "custom")
|
||||
self.assertEqual(r["fields"]["imap_host"], "mail.corp.com")
|
||||
self.assertEqual(r["fields"]["imap_port"], 993)
|
||||
|
||||
def test_custom_5_parts_case_insensitive(self):
|
||||
r = self._detect("u@c.com----p----Custom----h.com----995")
|
||||
self.assertEqual(r["type"], "imap")
|
||||
self.assertEqual(r["provider"], "custom")
|
||||
|
||||
# --- IMAP 3 段 ---
|
||||
def test_imap_3_parts_known_provider(self):
|
||||
r = self._detect("user@qq.com----authcode----qq")
|
||||
self.assertEqual(r["type"], "imap")
|
||||
self.assertEqual(r["provider"], "qq")
|
||||
self.assertEqual(r["fields"]["imap_host"], "imap.qq.com")
|
||||
|
||||
def test_imap_3_parts_unknown_provider(self):
|
||||
r = self._detect("user@x.com----pwd----unknownprov")
|
||||
self.assertEqual(r["type"], "error")
|
||||
self.assertIn("未知", r["error"])
|
||||
|
||||
def test_imap_3_parts_custom_not_allowed(self):
|
||||
r = self._detect("u@x.com----p----custom")
|
||||
self.assertEqual(r["type"], "error")
|
||||
|
||||
# --- 2 段:域名推断 ---
|
||||
def test_2_parts_gmail_inferred(self):
|
||||
r = self._detect("user@gmail.com----apppassword")
|
||||
self.assertEqual(r["type"], "imap")
|
||||
self.assertEqual(r["provider"], "gmail")
|
||||
self.assertEqual(r["fields"]["imap_host"], "imap.gmail.com")
|
||||
|
||||
def test_2_parts_unknown_with_fallback(self):
|
||||
r = self._detect("user@corp.com----pwd", fallback_host="mail.corp.com", fallback_port=995)
|
||||
self.assertEqual(r["type"], "imap")
|
||||
self.assertEqual(r["provider"], "custom")
|
||||
self.assertEqual(r["fields"]["imap_host"], "mail.corp.com")
|
||||
self.assertEqual(r["fields"]["imap_port"], 995)
|
||||
|
||||
def test_2_parts_unknown_no_fallback(self):
|
||||
r = self._detect("user@corp.com----pwd")
|
||||
self.assertEqual(r["type"], "error")
|
||||
|
||||
# --- 1 段:GPTMail ---
|
||||
def test_gptmail_1_part(self):
|
||||
r = self._detect("temp@gptmail.com")
|
||||
self.assertEqual(r["type"], "gptmail")
|
||||
self.assertEqual(r["provider"], "gptmail")
|
||||
self.assertEqual(r["fields"]["email"], "temp@gptmail.com")
|
||||
|
||||
def test_1_part_invalid_email(self):
|
||||
r = self._detect("not-an-email")
|
||||
self.assertEqual(r["type"], "error")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""tests/test_domain_provider_map.py — FD-00006 域名推断单元测试"""
|
||||
import unittest
|
||||
|
||||
|
||||
class TestDomainProviderMap(unittest.TestCase):
|
||||
def test_infer_gmail(self):
|
||||
from outlook_web.services.providers import infer_provider_from_email
|
||||
|
||||
self.assertEqual(infer_provider_from_email("user@gmail.com"), "gmail")
|
||||
self.assertEqual(infer_provider_from_email("user@googlemail.com"), "gmail")
|
||||
|
||||
def test_infer_qq(self):
|
||||
from outlook_web.services.providers import infer_provider_from_email
|
||||
|
||||
self.assertEqual(infer_provider_from_email("user@qq.com"), "qq")
|
||||
self.assertEqual(infer_provider_from_email("user@foxmail.com"), "qq")
|
||||
|
||||
def test_infer_163(self):
|
||||
from outlook_web.services.providers import infer_provider_from_email
|
||||
|
||||
self.assertEqual(infer_provider_from_email("user@163.com"), "163")
|
||||
|
||||
def test_infer_126(self):
|
||||
from outlook_web.services.providers import infer_provider_from_email
|
||||
|
||||
self.assertEqual(infer_provider_from_email("user@126.com"), "126")
|
||||
|
||||
def test_infer_yahoo(self):
|
||||
from outlook_web.services.providers import infer_provider_from_email
|
||||
|
||||
self.assertEqual(infer_provider_from_email("user@yahoo.com"), "yahoo")
|
||||
self.assertEqual(infer_provider_from_email("user@yahoo.co.jp"), "yahoo")
|
||||
|
||||
def test_infer_aliyun(self):
|
||||
from outlook_web.services.providers import infer_provider_from_email
|
||||
|
||||
self.assertEqual(infer_provider_from_email("user@aliyun.com"), "aliyun")
|
||||
self.assertEqual(infer_provider_from_email("user@alimail.com"), "aliyun")
|
||||
|
||||
def test_infer_outlook(self):
|
||||
from outlook_web.services.providers import infer_provider_from_email
|
||||
|
||||
self.assertEqual(infer_provider_from_email("user@outlook.com"), "outlook")
|
||||
self.assertEqual(infer_provider_from_email("user@hotmail.com"), "outlook")
|
||||
self.assertEqual(infer_provider_from_email("user@live.com"), "outlook")
|
||||
self.assertEqual(infer_provider_from_email("user@live.cn"), "outlook")
|
||||
|
||||
def test_infer_unknown_domain(self):
|
||||
from outlook_web.services.providers import infer_provider_from_email
|
||||
|
||||
self.assertIsNone(infer_provider_from_email("user@unknown.org"))
|
||||
self.assertIsNone(infer_provider_from_email("user@company.co"))
|
||||
|
||||
def test_infer_invalid_input(self):
|
||||
from outlook_web.services.providers import infer_provider_from_email
|
||||
|
||||
self.assertIsNone(infer_provider_from_email(""))
|
||||
self.assertIsNone(infer_provider_from_email(None))
|
||||
self.assertIsNone(infer_provider_from_email("no-at-sign"))
|
||||
|
||||
def test_known_provider_keys(self):
|
||||
from outlook_web.services.providers import KNOWN_PROVIDER_KEYS, MAIL_PROVIDERS
|
||||
|
||||
self.assertEqual(KNOWN_PROVIDER_KEYS, set(MAIL_PROVIDERS.keys()))
|
||||
self.assertIn("outlook", KNOWN_PROVIDER_KEYS)
|
||||
self.assertIn("gmail", KNOWN_PROVIDER_KEYS)
|
||||
self.assertIn("custom", KNOWN_PROVIDER_KEYS)
|
||||
|
||||
def test_provider_group_name(self):
|
||||
from outlook_web.services.providers import PROVIDER_GROUP_NAME
|
||||
|
||||
self.assertEqual(PROVIDER_GROUP_NAME["outlook"], "Outlook")
|
||||
self.assertEqual(PROVIDER_GROUP_NAME["gptmail"], "临时邮箱")
|
||||
|
||||
def test_get_provider_list_has_auto_first(self):
|
||||
from outlook_web.services.providers import get_provider_list
|
||||
|
||||
providers = get_provider_list()
|
||||
self.assertGreater(len(providers), 0)
|
||||
self.assertEqual(providers[0]["key"], "auto")
|
||||
self.assertEqual(providers[0]["account_type"], "mixed")
|
||||
# auto 不应出现在第二个及以后的位置
|
||||
keys = [p["key"] for p in providers]
|
||||
self.assertEqual(keys.count("auto"), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,137 @@
|
||||
"""tests/test_export_enhanced_v2.py — FD-00006 导出 v2 增强测试"""
|
||||
import unittest
|
||||
|
||||
from tests._import_app import clear_login_attempts, import_web_app_module
|
||||
|
||||
|
||||
class TestExportEnhancedV2(unittest.TestCase):
|
||||
@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 test_build_export_text_v2_header(self):
|
||||
"""导出文本包含 v2 头部元信息"""
|
||||
with self.app.app_context():
|
||||
from outlook_web.controllers.accounts import _build_export_text
|
||||
|
||||
accounts = [
|
||||
{
|
||||
"email": "test@outlook.com",
|
||||
"password": "pwd",
|
||||
"client_id": "cid",
|
||||
"refresh_token": "rt",
|
||||
"account_type": "outlook",
|
||||
"provider": "outlook",
|
||||
},
|
||||
]
|
||||
content = _build_export_text(accounts)
|
||||
self.assertIn("# Outlook Email Plus — 账号导出", content)
|
||||
self.assertIn("# 格式版本:v2", content)
|
||||
self.assertIn("# 账号总数:1", content)
|
||||
self.assertIn("Outlook:1", content)
|
||||
|
||||
def test_build_export_text_v2_outlook_section(self):
|
||||
"""Outlook 分段格式不变"""
|
||||
with self.app.app_context():
|
||||
from outlook_web.controllers.accounts import _build_export_text
|
||||
|
||||
accounts = [
|
||||
{
|
||||
"email": "a@outlook.com",
|
||||
"password": "p",
|
||||
"client_id": "c",
|
||||
"refresh_token": "r",
|
||||
"account_type": "outlook",
|
||||
"provider": "outlook",
|
||||
},
|
||||
]
|
||||
content = _build_export_text(accounts)
|
||||
self.assertIn("# === Outlook 账号 ===", content)
|
||||
self.assertIn("a@outlook.com----p----c----r", content)
|
||||
|
||||
def test_build_export_text_v2_imap_section(self):
|
||||
"""IMAP 分段格式正确"""
|
||||
with self.app.app_context():
|
||||
from outlook_web.controllers.accounts import _build_export_text
|
||||
|
||||
accounts = [
|
||||
{
|
||||
"email": "a@gmail.com",
|
||||
"imap_password": "imap_pwd",
|
||||
"account_type": "imap",
|
||||
"provider": "gmail",
|
||||
},
|
||||
]
|
||||
content = _build_export_text(accounts)
|
||||
self.assertIn("# === IMAP 账号(Gmail)===", content)
|
||||
self.assertIn("a@gmail.com----imap_pwd----gmail", content)
|
||||
|
||||
def test_build_export_text_v2_custom_5_segment(self):
|
||||
"""Custom IMAP 输出 5 段"""
|
||||
with self.app.app_context():
|
||||
from outlook_web.controllers.accounts import _build_export_text
|
||||
|
||||
accounts = [
|
||||
{
|
||||
"email": "a@corp.com",
|
||||
"imap_password": "pwd",
|
||||
"account_type": "imap",
|
||||
"provider": "custom",
|
||||
"imap_host": "mail.corp.com",
|
||||
"imap_port": 995,
|
||||
},
|
||||
]
|
||||
content = _build_export_text(accounts)
|
||||
self.assertIn("a@corp.com----pwd----custom----mail.corp.com----995", content)
|
||||
|
||||
def test_build_export_text_v2_gptmail_section(self):
|
||||
"""GPTMail 分段仅邮箱地址"""
|
||||
with self.app.app_context():
|
||||
from outlook_web.controllers.accounts import _build_export_text
|
||||
|
||||
temp_emails = [{"email": "t1@gptmail.com"}, {"email": "t2@gptmail.com"}]
|
||||
content = _build_export_text([], temp_emails)
|
||||
self.assertIn("# === 临时邮箱(GPTMail)===", content)
|
||||
self.assertIn("t1@gptmail.com", content)
|
||||
self.assertIn("t2@gptmail.com", content)
|
||||
# GPTMail 行不应包含 ----
|
||||
for line in content.split("\n"):
|
||||
if "@gptmail.com" in line and not line.startswith("#"):
|
||||
self.assertNotIn("----", line)
|
||||
|
||||
def test_build_export_text_v2_mixed_counts(self):
|
||||
"""混合账号统计正确"""
|
||||
with self.app.app_context():
|
||||
from outlook_web.controllers.accounts import _build_export_text
|
||||
|
||||
accounts = [
|
||||
{"email": "a@o.com", "password": "p", "client_id": "c", "refresh_token": "r", "account_type": "outlook", "provider": "outlook"},
|
||||
{"email": "b@o.com", "password": "p", "client_id": "c", "refresh_token": "r", "account_type": "outlook", "provider": "outlook"},
|
||||
{"email": "c@gmail.com", "imap_password": "ip", "account_type": "imap", "provider": "gmail"},
|
||||
]
|
||||
temp_emails = [{"email": "t@gptmail.com"}]
|
||||
content = _build_export_text(accounts, temp_emails)
|
||||
self.assertIn("# 账号总数:4", content)
|
||||
self.assertIn("Outlook:2", content)
|
||||
self.assertIn("Gmail:1", content)
|
||||
self.assertIn("临时邮箱:1", content)
|
||||
|
||||
def test_export_ends_with_newline(self):
|
||||
"""导出文件末尾有换行"""
|
||||
with self.app.app_context():
|
||||
from outlook_web.controllers.accounts import _build_export_text
|
||||
|
||||
accounts = [
|
||||
{"email": "a@o.com", "password": "p", "client_id": "c", "refresh_token": "r", "account_type": "outlook", "provider": "outlook"},
|
||||
]
|
||||
content = _build_export_text(accounts)
|
||||
self.assertTrue(content.endswith("\n"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -58,11 +58,14 @@ class MultiMailboxSupportTests(unittest.TestCase):
|
||||
self.assertEqual(data.get("success"), True)
|
||||
|
||||
providers = data.get("providers") or []
|
||||
self.assertEqual(len(providers), 8)
|
||||
self.assertEqual(providers[0].get("key"), "outlook")
|
||||
# PRD-00006 / FD-00006:providers 列表新增 "auto"(智能识别混合导入)
|
||||
self.assertEqual(len(providers), 9)
|
||||
self.assertEqual(providers[0].get("key"), "auto")
|
||||
self.assertEqual(providers[1].get("key"), "outlook")
|
||||
self.assertEqual(providers[-1].get("key"), "custom")
|
||||
|
||||
keys = [p.get("key") for p in providers]
|
||||
self.assertIn("auto", keys)
|
||||
self.assertIn("qq", keys)
|
||||
self.assertIn("163", keys)
|
||||
|
||||
|
||||
Reference in new issue
Block a user