Spaces:
Paused
Paused
| # Mail Provider 抽象层设计 | |
| > 基于 task #1 (contract-auditor) + task #2 (upstream-researcher) 的产出。 | |
| > 目标:让 AutoTeam 同时支持 `dreamhunter2333/cloudflare_temp_email`(现状)与 | |
| > `maillab/cloud-mail`(社区想用的真正"cloudmail"),且不破坏现有调用方。 | |
| --- | |
| ## 0. 现状问题(一句话) | |
| `cloudmail.py` 把 `dreamhunter2333/cloudflare_temp_email` 的 `/admin/*` 路由 + | |
| `x-admin-auth` header 硬编码成 `CloudMailClient`。命名误导,无法切换后端。 | |
| 调用面:当前 `CloudMailClient` 在 8 个文件、约 **31 个调用点**被实例化或调用 | |
| (`api.py`、`manager.py`、`codex_auth.py`、`invite.py`、`account_ops.py`、 | |
| `setup_wizard.py`、`accounts.py` 间接、tests)。任何抽象层改造必须保持向后兼容。 | |
| --- | |
| ## 1. MailProvider 抽象基类 | |
| 放在新文件 `src/autoteam/mail/base.py`,使用 `abc.ABC`。 | |
| 方法集**严格覆盖** `cloudmail.py` 现有公开方法,不增不减。 | |
| ```python | |
| from abc import ABC, abstractmethod | |
| from dataclasses import dataclass, field | |
| from typing import Any | |
| @dataclass | |
| class Email: | |
| """统一邮件 IR — provider 无关的中间表示。""" | |
| id: int # provider 主键,统一为 int(cf 用 mails.id;maillab 用 emailId) | |
| recipient: str # 收件人邮箱(小写) | |
| sender: str # 发件人邮箱 | |
| subject: str | |
| text: str | None # 纯文本正文(cf 解析自 MIME;maillab 取 text 字段) | |
| html: str | None # HTML 正文(cf 解析自 MIME;maillab 取 content/message) | |
| received_at: int # epoch seconds(cf created_at 转换;maillab createTime 转换) | |
| raw: dict = field(default_factory=dict) # provider 原始结构,做兜底/调试 | |
| @dataclass | |
| class Account: | |
| """临时邮箱账户。""" | |
| account_id: int # provider 主键 | |
| email: str # 完整邮箱地址(小写) | |
| password: str | None = None # cf 不返;maillab 可能返 | |
| create_time: int | None = None | |
| extra: dict = field(default_factory=dict) # 扩展字段(jwt、mail_count 等) | |
| class MailProvider(ABC): | |
| """所有 mail backend 必须实现的接口。命名/语义保持与现有 CloudMailClient 调用方一致。""" | |
| # ---- 鉴权 ---- | |
| @abstractmethod | |
| def login(self) -> str: | |
| """初始化鉴权,返回一个不透明 token 字符串(仅用于日志展示)。失败抛 Exception。""" | |
| # ---- 账户管理 ---- | |
| @abstractmethod | |
| def create_temp_email(self, prefix: str | None = None, domain: str | None = None) -> tuple[int, str]: | |
| """创建临时邮箱,返回 (account_id, email)。""" | |
| @abstractmethod | |
| def list_accounts(self, size: int = 200) -> list[dict]: | |
| """列出已创建的临时邮箱。返回与现版兼容的 dict 列表(保留 accountId/email/...)。""" | |
| @abstractmethod | |
| def delete_account(self, account_id: int | str) -> dict: | |
| """删除账户。account_id 允许是数字 id 或完整 email(自动解析)。返回 {code, message?}。""" | |
| # ---- 邮件读取 ---- | |
| @abstractmethod | |
| def search_emails_by_recipient( | |
| self, to_email: str, size: int = 10, account_id: int | None = None | |
| ) -> list[dict]: | |
| """按收件人查邮件(最新优先)。返回 dict 列表(兼容字段,详见 §2.1)。""" | |
| @abstractmethod | |
| def list_emails(self, account_id: int | str, size: int = 10) -> list[dict]: | |
| """按 account_id 查邮件。账户名解析交给 provider 自己实现。""" | |
| def get_latest_emails(self, account_id: int | str, email_id: int = 0, all_receive: int = 0) -> list[dict]: | |
| """旧名兼容;默认实现委托 list_emails。子类可覆写。""" | |
| return self.list_emails(account_id, size=5) | |
| # ---- 邮件删除 ---- | |
| @abstractmethod | |
| def delete_emails_for(self, to_email: str) -> int: | |
| """删除指定收件人的全部邮件,返回删除数量(或 1 表示批量成功)。""" | |
| # ---- 等待 / OTP / 链接(共用实现,不抽象) ---- | |
| def wait_for_email(self, to_email: str, timeout: int | None = None, sender_keyword: str | None = None) -> dict: | |
| """轮询等待。base.py 提供默认实现:循环调用 search_emails_by_recipient + sender_keyword 过滤。""" | |
| ... # 默认实现见 base.py(搬现 cloudmail.py 的逻辑) | |
| @staticmethod | |
| def extract_verification_code(email_data: dict) -> str | None: | |
| """从邮件 text/html 提取 6 位 OTP。纯文本处理,与 provider 无关 → 放 base 静态方法。""" | |
| ... | |
| @staticmethod | |
| def extract_invite_link(email_data: dict) -> str | None: | |
| """从邮件正文提取邀请链接。同上,纯文本处理。""" | |
| ... | |
| ``` | |
| ### 1.1 设计原则 | |
| - **方法集与现 `CloudMailClient` 的公开 API 一一对应**——避免改 31 个调用点。 | |
| - **`Email` / `Account` dataclass 仅作内部 IR**;现阶段 `search_emails_by_recipient` 等方法对外 | |
| 仍返回 dict,等后续把调用方逐步迁到 dataclass 后再切。这样首版改造可以零回归。 | |
| - **`wait_for_email` / `extract_verification_code` / `extract_invite_link` 不抽象**: | |
| - 等待是 `search_emails_by_recipient` 之上的轮询循环; | |
| - OTP / 邀请链接抽取是纯文本正则,跟后端无关。 | |
| - 全部放 base 类当 mixin,子类零改动。 | |
| --- | |
| ## 2. 字段映射表 | |
| ### 2.1 邮件 dict 字段(现调用方依赖的契约) | |
| 调用方实际只读这些 key(grep 结果证实): | |
| `emailId`、`accountEmail`、`receiveEmail`、`toEmail`、`sendEmail`、 | |
| `sender`、`subject`、`text`、`content`(=html)、`createTime`、`raw`。 | |
| | 统一字段 | cf_temp_email 来源 | maillab 来源 | | |
| | --------------- | ----------------------------------------- | ------------------------------------- | | |
| | `emailId` | `mails.id` | `emailId` | | |
| | `accountEmail` | `mails.address` | `toEmail` | | |
| | `receiveEmail` | `mails.address` | `toEmail` | | |
| | `toEmail` | MIME `To:` header(_parse_mime 解出) | `toEmail` | | |
| | `sendEmail` | `mails.source` 或 MIME `From:` | `sendEmail` | | |
| | `sender` | MIME `From:` | `name`(发件人显示名) | | |
| | `subject` | MIME `Subject:` | `subject` | | |
| | `text` | MIME text/plain part 解码 | `text`(如缺则从 html 剥) | | |
| | `content` | MIME text/html part 解码 | `content` 或 `message`(待验证) | | |
| | `messageId` | MIME `Message-ID:` | 无;置 None | | |
| | `createTime` | `mails.created_at`(已是 epoch) | `createTime`(需检查是否要 ÷1000) | | |
| | `raw` | `mails.raw`(原始 MIME 字符串) | 整个 email 对象 dump | | |
| ### 2.2 Account dict 字段 | |
| | 统一字段 | cf_temp_email 来源 | maillab 来源 | | |
| | ------------ | ------------------ | --------------------------- | | |
| | `accountId` | `address.id` | `accountId` / `id`(待确认)| | |
| | `email` | `address.name` | `email` | | |
| | `password` | `address.password` | 不返(创建时一次性给) | | |
| | `createTime` | `address.created_at` | `createTime` | | |
| --- | |
| ## 3. 配置切换 | |
| ### 3.1 环境变量 | |
| ```bash | |
| # ---- 选择后端 ---- | |
| MAIL_PROVIDER=cf_temp_email # 或 maillab;缺省=cf_temp_email(保持现状) | |
| # ---- cf_temp_email(现 CLOUDMAIL_*,保留向后兼容) ---- | |
| CLOUDMAIL_BASE_URL=https://your-domain.com/api # cf_temp_email 必须包含 /api | |
| CLOUDMAIL_PASSWORD=... # 实际是 admin password | |
| CLOUDMAIL_DOMAIN=@example.com | |
| # CLOUDMAIL_EMAIL 标记为 deprecated;setup_wizard 不再强制要求 | |
| # ---- maillab(新增) ---- | |
| MAILLAB_API_URL=https://... | |
| MAILLAB_USERNAME=admin@xxx | |
| MAILLAB_PASSWORD=xxx | |
| MAILLAB_DOMAIN=@xxx # 创建邮箱时用 | |
| ``` | |
| **别名兼容**:当 `MAIL_PROVIDER=cf_temp_email` 且未设置 `CF_TEMP_EMAIL_*` 时, | |
| 工厂回落到读 `CLOUDMAIL_*`(避免逼用户改 .env)。 | |
| ### 3.2 工厂函数 | |
| 新文件 `src/autoteam/mail/__init__.py`: | |
| ```python | |
| import os | |
| from autoteam.mail.base import MailProvider | |
| def get_mail_client() -> MailProvider: | |
| provider = (os.environ.get("MAIL_PROVIDER") or "cf_temp_email").strip().lower() | |
| if provider in ("cf_temp_email", "cloudflare_temp_email"): | |
| from autoteam.mail.cf_temp_email import CfTempEmailClient | |
| return CfTempEmailClient() | |
| if provider == "maillab": | |
| from autoteam.mail.maillab import MaillabClient | |
| return MaillabClient() | |
| raise ValueError(f"未知 MAIL_PROVIDER={provider}(可选: cf_temp_email | maillab)") | |
| # 向后兼容别名 — 现有 31 处 `CloudMailClient()` 调用零改动 | |
| CloudMailClient = get_mail_client | |
| ``` | |
| > `CloudMailClient = get_mail_client` 让 `CloudMailClient()` 调用语法保持原样, | |
| > 实际 dispatch 到具体 provider。 | |
| --- | |
| ## 4. 命名争议解决(采纳方案 A:拆包) | |
| **方案 A(采纳)**:拆 `src/autoteam/mail/` 包 | |
| ``` | |
| src/autoteam/mail/ | |
| ├── __init__.py # 工厂 + CloudMailClient 别名 | |
| ├── base.py # MailProvider ABC + Email/Account dataclass + wait/extract 默认实现 | |
| ├── cf_temp_email.py # 从现 cloudmail.py 拆出,重命名为 CfTempEmailClient | |
| └── maillab.py # 新写 | |
| ``` | |
| `cloudmail.py` 改为 1 行 stub:`from autoteam.mail import CloudMailClient # noqa`, | |
| 保留是为了任何还没迁移的外部脚本可以继续 `from autoteam.cloudmail import CloudMailClient`。 | |
| **理由**(vs 方案 B 单文件多 class): | |
| 1. **关注点分离**:cf_temp_email 现在 ~520 行,再塞一个 maillab provider 单文件会 > 1000 行难维护。 | |
| 2. **测试可读性**:现 `tests/unit/test_cloudmail.py` 测的全是 cf 的 admin 路由;拆包后可 | |
| 并行新增 `test_maillab.py`,测试边界清晰。 | |
| 3. **延迟 import**:工厂用 `if` 内 import,仅按需加载对应 provider 的依赖。 | |
| --- | |
| ## 5. 改造计划 | |
| ### 5.1 新建文件(4 个) | |
| | 文件 | 行数估算 | 意图 | | |
| | ------------------------------------- | -------- | ---------------------------------------- | | |
| | `src/autoteam/mail/__init__.py` | ~30 | 工厂 + 兼容别名 | | |
| | `src/autoteam/mail/base.py` | ~150 | ABC、dataclass、wait/OTP/link 默认实现 | | |
| | `src/autoteam/mail/cf_temp_email.py` | ~430 | 把现 cloudmail.py 业务搬过来,重命名类 | | |
| | `src/autoteam/mail/maillab.py` | ~350 | 新写:login/email/list/delete + 字段映射 | | |
| | `tests/unit/test_maillab.py` | ~200 | 新增:覆盖 maillab provider | | |
| ### 5.2 修改文件(4 个) | |
| | 文件 | 行数估算 | 意图 | | |
| | ------------------------------------- | -------- | ----------------------------------------------------------------- | | |
| | `src/autoteam/cloudmail.py` | -510 | 缩为 1 行 re-export(删除业务代码,保 import 兼容) | | |
| | `src/autoteam/config.py` | +10 | 新增 `MAIL_PROVIDER` / `MAILLAB_*` 读取;标记 `CLOUDMAIL_EMAIL` 废弃 | | |
| | `.env.example` | +6 / -1 | 加 `MAIL_PROVIDER=cf_temp_email` 注释块 + maillab 段 | | |
| | `src/autoteam/setup_wizard.py` | ~10 | 不再强制要求 `CLOUDMAIL_EMAIL`;按 provider 走 if 分支 | | |
| | `tests/unit/test_cloudmail.py` | ~5 | 改 import 路径为 `autoteam.mail.cf_temp_email`,断言不变 | | |
| | `docs/configuration.md` | +20 | 加 mail provider 章节 | | |
| | `README.md` | +5 | 修正"cloudmail 不等于 maillab/cloud-mail"的脚注 | | |
| **业务调用方零改动**(31 个调用点全部保持 `from autoteam.cloudmail import CloudMailClient` + | |
| `CloudMailClient()` 语法不变)。 | |
| ### 5.3 风险点 | |
| | 风险 | 缓解 | | |
| | ------------------------------------------------------------------ | ------------------------------------------------------------------ | | |
| | `CloudMailClient = get_mail_client`(class 实例化变函数调用)有副作用 | 工厂函数返回的对象就是 provider 实例;语法 `CloudMailClient()` 完全兼容 | | |
| | `tests/unit/test_cloudmail.py` 直接 patch `cloudmail.requests` | cf_temp_email.py 内的 `requests` 路径变了,需要把 patch target 改名 | | |
| | 旧 `cloudmail.py` 留 stub 期间,`from autoteam.cloudmail import _parse_mime` 等私有函数可能有人引用 | grep 全仓 `_parse_mime / _decode_jwt_payload`:仅 cloudmail.py 内部用,**确认安全**(已查) | | |
| | `MAIL_PROVIDER` 拼错(如 `MAILAB`)→ 启动崩 ValueError | 错误信息列出可选值;setup_wizard 加交互式选择 | | |
| | maillab 后端字段差异未 100% 摸清(见 §6) | 保留 `extra` / `raw` dict 兜底,先实现 80% 路径,发布前小步对齐 | | |
| ### 5.4 调用点统计(用 Grep 数实数) | |
| | 调用方 | `CloudMailClient()` 实例化 | `mail_client.<method>` 调用 | | |
| | --------------------- | -------------------------- | --------------------------- | | |
| | `manager.py` | 11 | 7 | | |
| | `api.py` | 5 | - | | |
| | `invite.py` | 1 | 4 | | |
| | `codex_auth.py` | 0(接收外部传入) | 9 | | |
| | `account_ops.py` | 1 | 2 | | |
| | `setup_wizard.py` | 1 | 0 | | |
| | `tests/unit/` | 6 | - | | |
| | **合计** | **25** | **22** | | |
| → 总 47 处对 `CloudMailClient` 名称的引用,全部走"别名 → 工厂"零改动路径。 | |
| --- | |
| ## 6. cloud-mail 实现的未知项 — close-out | |
| implementer 开工前的 5 项现场验证已经全部 ✅ 完成,结论引用 `maillab/cloud-mail` 上游源码: | |
| 1. **HTML 正文字段名** ✅ — 字段为 `content`(对照 [`mail-vue/src/views/home/index.vue` 的 v-html 绑定](https://github.com/maillab/cloud-mail/blob/main/mail-vue/src/views/home/index.vue))。`text` 为去标签后的纯文本,`message` 仅在子站发送邮件回显时出现。 | |
| 2. **鉴权 header** ✅ — 业务接口统一走 `Authorization: Bearer <token>`,token 由 `/login` 返 JSON Web Token,payload 含 `userId/email/userType`(`userType==1` 为 admin)。验证文件 [`mail-worker/src/middleware/auth.js`](https://github.com/maillab/cloud-mail/blob/main/mail-worker/src/middleware/auth.js)。 | |
| 3. **`createTime` 单位** ✅ — epoch **毫秒**(13 位),IR 转秒时需 ÷1000。验证 [`mail-worker/src/service/email-service.js`](https://github.com/maillab/cloud-mail/blob/main/mail-worker/src/service/email-service.js) 中 `Date.now()` 写入逻辑。 | |
| 4. **创建邮箱端点** ✅ — `POST /account/add` (admin) / `POST /account/register` (普通用户)。验证 [`mail-worker/src/api/account.js`](https://github.com/maillab/cloud-mail/blob/main/mail-worker/src/api/account.js)。 | |
| 5. **`accountId` 类型** ✅ — 数字主键(int auto-increment),`/account/delete?accountId={int}`。 | |
| > 所有未知项已 close,`MaillabClient` 无需再做现场摸索。 | |
| --- | |
| ## 7. skymail.ink API 全表(maillab/cloud-mail) | |
| 由 issue-1-cloudmail.md §B 整理,所有路由均要求 `Authorization: Bearer <token>`(除显式标注的免登录端点)。 | |
| | 端点 | 方法 | 用途 | 关键字段 | | |
| |---|---|---|---| | |
| | `/login` | POST | 管理员/普通用户登录 | `email` / `password` → `data.token` (JWT) | | |
| | `/setting/websiteConfig` | GET | 站点配置(免登录) | `domainList[]` / `addVerify` / `register` | | |
| | `/account/add` | POST | 创建临时邮箱(admin) | body `{email, name?, domain}` → `data.accountId` | | |
| | `/account/register` | POST | 创建临时邮箱(普通用户) | 同上,但受 `register` 开关控制 | | |
| | `/account/delete` | DELETE | 删除邮箱 | query `?accountId={int}` | | |
| | `/account/list` | GET | 列出邮箱 | query `?size={n}&cursor={int}` 翻页 | | |
| | `/email/list` | GET | 列邮件 | query `?accountId={int}&size={n}&cursor={int}` | | |
| | `/email/info` | GET | 单封邮件详情 | query `?emailId={int}` | | |
| | `/email/delete` | DELETE | 删邮件(单封 / 批量) | query `?emailId={int}` 或 `?accountId={int}&all=1` | | |
| | `/auto-reply/setting` | GET/POST | 自动回复(本项目暂不用) | — | | |
| | `/audit/list` | GET | 审计(admin) | — | | |
| JWT payload 结构(`autoteam.mail.base.decode_jwt_payload` 解析): | |
| ```json | |
| { "userId": 1, "email": "admin@x.com", "userType": 1, "exp": 1234567890 } | |
| ``` | |
| `userType==1` 为 admin,probe `step=credentials` 检查该字段以决定是否标记 `is_admin=true`。 | |
| --- | |
| ## 8. 401 自愈策略(SPEC-1 §3.3) | |
| `MaillabClient` 的 `_get/_post/_delete/_put` 全部被 `_with_login_retry` 装饰器包裹: | |
| - 任何业务方法收 `code:401` → 清 token,调 `login()` 重新登录,重试一次原方法。 | |
| - **双 guard** 防递归: | |
| - `_LOGIN_GUARD.in_login`:`login()` 内部 `/login` 自身回 401 时,装饰器立即透传,不再触发自愈(避免 token 错的死循环)。 | |
| - `_LOGIN_GUARD.retried`:已重试一次后再 401 → 抛 `MaillabAuthFailed("重 login 后仍 401")`,日志含明确关键字,便于排查"凭据过期但仍能登录"等灰色场景。 | |
| - 线程局部:guard 通过 `threading.local()` 实现,API 端 + 后台任务并发不会互相影响。 | |
| - 日志关键字: | |
| - `[maillab] {path} 收到 code:401,自愈中...` — 触发自愈 | |
| - `[maillab] {path} token 已自愈` — 自愈成功 | |
| - 自愈失败抛 `MaillabAuthFailed`,堆栈定位调用点 | |
| 测试覆盖见 `tests/unit/test_mail_maillab_self_heal.py`(3 个场景)。 | |
| --- | |
| ## 附录 A:现 `cloudmail.py` 公开方法清单(确保抽象层 100% 覆盖) | |
| ``` | |
| login() | |
| create_temp_email(prefix=None, domain=None) -> (id, email) | |
| list_accounts(size=200) -> list[dict] | |
| delete_account(account_id) -> dict | |
| search_emails_by_recipient(to_email, size=10, account_id=None) -> list[dict] | |
| list_emails(account_id, size=10) -> list[dict] | |
| get_latest_emails(account_id, email_id=0, all_receive=0) -> list[dict] | |
| delete_emails_for(to_email) -> int | |
| wait_for_email(to_email, timeout=None, sender_keyword=None) -> dict | |
| extract_verification_code(email_data) -> str | None | |
| extract_invite_link(email_data) -> str | None | |
| ``` | |
| 11 个公开方法 — §1 抽象类一一对应,无遗漏。 | |
| --- | |
| ## 附录 B:实施顺序建议 | |
| 1. 拉新分支 `feat/mail-provider-abstraction`。 | |
| 2. 建 `src/autoteam/mail/` 目录骨架(base.py + 工厂 + stub cf/maillab)。 | |
| 3. 把 cloudmail.py 业务搬到 `mail/cf_temp_email.py`,跑 `tests/unit/test_cloudmail.py` 全绿。 | |
| 4. 改 `cloudmail.py` 为 1 行 re-export,再跑测试。 | |
| 5. 实现 `mail/maillab.py`,按 §6 清单先做现场验证。 | |
| 6. 写 `tests/unit/test_maillab.py`。 | |
| 7. 更新 `setup_wizard.py` 走 provider 分支,更新 `.env.example` / `docs/configuration.md`。 | |
| 8. 全量 e2e:在两个 provider 各跑一遍 manager 的注册流程。 | |