Spaces:
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 现有公开方法,不增不减。
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/Accountdataclass 仅作内部 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 环境变量
# ---- 选择后端 ----
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:
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):
- 关注点分离:cf_temp_email 现在 ~520 行,再塞一个 maillab provider 单文件会 > 1000 行难维护。
- 测试可读性:现
tests/unit/test_cloudmail.py测的全是 cf 的 admin 路由;拆包后可 并行新增test_maillab.py,测试边界清晰。 - 延迟 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 上游源码:
- HTML 正文字段名 ✅ — 字段为
content(对照mail-vue/src/views/home/index.vue的 v-html 绑定)。text为去标签后的纯文本,message仅在子站发送邮件回显时出现。 - 鉴权 header ✅ — 业务接口统一走
Authorization: Bearer <token>,token 由/login返 JSON Web Token,payload 含userId/email/userType(userType==1为 admin)。验证文件mail-worker/src/middleware/auth.js。 createTime单位 ✅ — epoch 毫秒(13 位),IR 转秒时需 ÷1000。验证mail-worker/src/service/email-service.js中Date.now()写入逻辑。- 创建邮箱端点 ✅ —
POST /account/add(admin) /POST /account/register(普通用户)。验证mail-worker/src/api/account.js。 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 解析):
{ "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:实施顺序建议
- 拉新分支
feat/mail-provider-abstraction。 - 建
src/autoteam/mail/目录骨架(base.py + 工厂 + stub cf/maillab)。 - 把 cloudmail.py 业务搬到
mail/cf_temp_email.py,跑tests/unit/test_cloudmail.py全绿。 - 改
cloudmail.py为 1 行 re-export,再跑测试。 - 实现
mail/maillab.py,按 §6 清单先做现场验证。 - 写
tests/unit/test_maillab.py。 - 更新
setup_wizard.py走 provider 分支,更新.env.example/docs/configuration.md。 - 全量 e2e:在两个 provider 各跑一遍 manager 的注册流程。