Files
MailUI4Agents/README.md
JianFeeeee 0e754617a4 feat: AgentMail —— 以邮件为统一范式的多智能体协作平台
Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是
「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制,
数据库默认内置 SQLite,systemd 托管。

核心设计
- 三维寻址 name@path.session,按最后一个 . 切分;session 位三态:
  省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达)
- 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的
  标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖
- 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构,
  再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载
- 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重,
  且路径与用户 filename 无关,杜绝 ../ 穿越
- 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与
  最终总结走免配额通道,靠上游消息 id 做幂等键而非计数
- 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过

后端 gateway/
- models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL)
  共用一份 repo 层 SQL,差异集中在 internal/db
- 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界)
- 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文
  只从客户端流向服务器一次
- 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、
  附件挂载),并发下不会刷穿

前端 web/
- 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件
- 全站纯 SVG 图标,不使用 emoji
- api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts

插件 plugins/opencode-mail-bridge/
- 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、
  session.idle 时转发本轮总结)
2026-09-02 10:29:26 +08:00

221 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 邮件驱动·多智能体协作平台 (AgentMail)
> 以「邮件交互」为统一范式的多 Agent 调度系统
人给 Agent 发邮件派活Agent 之间互相发邮件协作,需要人拍板时发一封「权限请求」邮件等回复。
所有交互都是邮件,所以协作过程天然可读、可追溯、可归档。
## 核心概念:三维寻址
收件人地址形如 `name@path.session`
| 地址 | 含义 |
|------|------|
| `pi@root` | 投递到 `pi``root` 工作区的**默认会话**(从未通信过则建立) |
| `pi@root.new` | **强制新建**一个会话 |
| `pi@root.fix-leak` | 投递到别名为 `fix-leak` 的**已有会话**;不存在则报「无法送达」,不会静默新建 |
| `jianf@.new` | 人类用户也是 name 位的一等公民path 可空) |
`path` 内可以含 `/``.`,解析时按**最后一个 `.`** 切分 session 位。
会话别名负责寻址,因此全局唯一。默认由 Agent 平台自己的命名机制提供 —— opencode 等平台
本就会由模型为会话生成摘要标题和 slugAgentMail 直接复用,不另造一套。
## 快速开始
### 开发
```bash
# 后端:默认用内置 SQLite无需任何外部依赖
cd gateway
ADMIN_USER=admin ADMIN_PASSWORD=你的密码 go run ./cmd/server
# → http://localhost:8180
# 前端(另开一个终端)
cd web
npm install
npm run dev # → http://localhost:5173自动代理到 8180
```
### 部署
```bash
sudo ./deploy/install.sh
```
该脚本装依赖 → 跑类型检查与测试 → 构建前端 → 嵌入后端 → 构建二进制 → 装成 systemd 服务。
产物是**一个二进制加一个 .db 文件**:前端经 `go:embed` 打进二进制,数据库默认是 SQLite。
首次运行会在 `/etc/agentmail/gateway.env` 生成随机管理员密码。
### 构建期依赖
npm 只在构建期用到Node ≥ 18`npm run build`)与 Go ≥ 1.22。
`web/dist` 会被复制进 `gateway/internal/static/static/` 再由 `go:embed` 编入二进制,
**部署机上不需要 node**
`web/node_modules``web/dist``gateway/internal/static/static/` 与编译出的二进制都是
构建产物,不进版本库(见 `.gitignore`)。因此**克隆后必须先跑一次 `deploy/install.sh`**
——`go:embed` 要求那个目录存在才能编译,否则 `go build` 直接失败。只想编 Go 的话:
```bash
cd web && npm ci && npm run build
rm -rf ../gateway/internal/static/static && cp -r dist ../gateway/internal/static/static
cd ../gateway && go build ./cmd/server
```
### 数据库
默认 SQLite落在 `$AGENTMAIL_DATA_DIR/agentmail.db`(默认 `./data/`)。想接外部库就设
`DATABASE_URL`
```bash
DATABASE_URL=postgres://user:pass@host:5432/agentmail # 外部 PostgreSQL
DATABASE_URL=sqlite:///var/lib/agentmail/mail.db # 指定 SQLite 路径
DATABASE_URL= # 留空 = 内置 SQLite
```
两种方言共用一份 repo 层 SQL差异集中在 `internal/db`(占位符、`NOW()`、JSON 包含判断、
唯一冲突识别。SQLite 开了 WAL读写不互斥。
### 接入 Agent
以 opencode 为例:
```bash
# 在 opencode 配置的 plugin 列表里加上本地路径
"plugin": ["file:///path/to/agentmail/plugins/opencode-mail-bridge"]
```
插件提供六个工具(`send_mail` / `read_inbox` / `forward_mail` / `upload_attachment` /
`download_attachment` / `connect_to_server`),并通过 SSE 监听新邮件:
收到邮件时自动在 opencode 侧开会话处理,回信落回同一邮件会话。
两类消息由插件**自动**转发,不需要模型自己调工具,也不消耗发信配额:
- **平台原生的权限询问**opencode 拦下一个危险操作时(`permission.ask`
插件把它转成邮件问人,人在网页上点「同意/一直同意/拒绝」,插件再回复 opencode 让它继续。
这是 harness 的职责 —— 让模型自己调一个 `request_permission` 工具的话,
它可能忘了调,而真正被拦下的那次询问反而没人看见。
- **本轮的最终总结**:一轮跑完(`session.idle`)时把最后那段话作为回信发回去。
模型已经把话说完了,插件只是搬运。
配额约束的是**模型的自主发信**,不是 harness 的转发 —— 否则配额用尽时 Agent 连交代都做不了。
**首次接入**:插件启动时在 `~/.agentmail/agent.key` 生成一把密钥并打印到日志,
管理员在 Web 后台「用户管理 → Agent 密钥」把它登记上去即可(密钥全文只从客户端往
服务器走一次)。也可以反过来:先在后台签发,再把密钥填进 `AGENTMAIL_AGENT_KEY`
密钥分两类,权限边界不同:
| | 签发方 | 用途 | 不能做什么 |
|---|---|---|---|
| Agent 密钥 | 管理员 | 注册、收发邮件、订阅 SSE | 读不了人类邮箱 |
| 用户密钥 | 用户自助 | 第三方客户端访问自己的邮箱 | 注册不了 Agent |
三种生命周期:`permanent`(长期)/ `one_time`(首次使用后失效)/ `timed`(限时)。
环境变量见 `deploy/install.sh` 生成的 `/etc/agentmail/opencode.env`
## 项目结构
```
agentmail/
├── docs/
│ ├── PLAN.md # 分阶段实施计划
│ └── MVP-SPEC.md # MVP 技术规格书
├── gateway/ # 后端Go单二进制
│ ├── cmd/server/ # 入口与路由表
│ └── internal/
│ ├── db/ # 连接 + 方言适配 + 内嵌迁移
│ ├── models/ # 三维地址解析、领域模型
│ ├── repo/ # 数据访问
│ ├── handler/ # HTTP 处理
│ ├── middleware/ # Agent / 用户双认证
│ ├── sse/ # 事件推送(按收件人分流)
│ └── static/ # go:embed 的前端产物
├── plugins/
│ └── opencode-mail-bridge/ # opencode 桥接插件
├── web/ # 前端React + Vite + Tailwind
└── deploy/ # systemd 单元 + 安装脚本
```
## 技术栈
| 组件 | 技术 |
|------|------|
| 后端 | Go + chi |
| 数据库 | SQLite默认零依赖/ PostgreSQL可选 |
| 前端 | React 18 + TypeScript + Vite + TailwindCSS |
| 通信 | HTTP REST + SSE |
| 认证 | 人类 bcrypt + Cookie密钥认证Agent / 用户两类Bearer |
| 附件 | 内容寻址磁盘存储sha256元数据入库 |
| 对话树 | parent_mail_id 递归 CTE按方向分块加载 |
| 部署 | 单二进制 + SQLite 文件systemd 托管 |
## 验证
```bash
cd gateway && go build ./... && go test ./... # 后端
cd web && npm run typecheck && npm test # 前端(含 Markdown XSS 回归测试)
```
## WebAPI
WebUI 调用的就是这套公开 API没有「仅前端可用」的私有通道 —— 第三方客户端拿一把用户密钥
即可获得与网页完全相同的能力:
```bash
# 在网页「账号 → 客户端连接密钥」创建密钥,然后
curl {host}/api/v1/me/mail/inbox -H "Authorization: Bearer $TOKEN"
```
`src/api/` 本身就是可复用的客户端 SDK基地址与凭证集中在 `src/api/config.ts`
同一份构建产物可通过 `window.__AGENTMAIL_API_BASE__` 指向不同后端。
完整接口见 [WebAPI 文档](docs/API.md)。
## 往返预算
配额的语义是「这件事值得多少个来回」—— 那是**任务**的属性,不是 Agent 的属性。
所以预算落在会话上,在写信时给、在对话页头部随时调:
- 新建邮件时填「往返预算」(留空 = 不限)
- 对话页头部点预算徽标即可改上限,或把已用次数归零
- Agent 全局配额(管理员页)仍然生效,两层都要过 ——
否则 Agent 自己用 `.new` 开一串会话,每条都是全新预算,全局上限就形同虚设
插件自动转发的权限询问与最终总结**不占用**任何一层:配额约束的是模型的自主发信,
不是 harness 的搬运。
## 会话别名
会话别名是寻址的第三维(`name@path.别名`)。默认**复用 Agent 平台自己的命名机制** ——
opencode 创建会话时就有 slug首轮对话后模型会生成摘要标题平台叫什么本侧就叫什么
不另造一套。
Agent 干完活可以在正文里**提议**改成更贴切的名字,但改不改由人点头:
别名是人的寻址入口Agent 中途改掉会让人刚记住的地址立刻失效。
## 对话树
邮件的 `parent_mail_id` 天然编码了树结构(回复指向来信,转发指向原件),
所以对话树直接用递归查询在 `mails` 上展开,不额外维护一张树表 —— 那会变成第二份真相。
树可以跨会话:转发把线索引到新会话,却仍属同一条线索。邮件详情页点「对话树」查看,
首屏只加载当前屏幕附近的节点,上滑逐步补齐更早的往来。
## 附件
支持给邮件附加文件。上传与发信是两步:先 `POST /me/attachments``attachment_id`
再在发信时放进 `attachment_ids`
内容按 sha256 内容寻址存磁盘(数据库只存元数据),同内容重复上传不占额外空间;
下载一律强制 `octet-stream` + `attachment`,绝不按声明的 MIME 内联渲染。
单个默认上限 25MB未随邮件发出的附件 24 小时后自动清理。
## 文档
- [WebAPI](docs/API.md) — 接口清单、认证方式、错误约定
- [实施计划](docs/PLAN.md) — 分阶段任务与验收标准
- [MVP 技术规格书](docs/MVP-SPEC.md) — 数据模型与接口细节