Files
MailUI4Agents/docs/API.md
JianFeeeee 7c9be9fd58 docs: 插件适配指南 + 共用模块提取(为接入更多平台做准备)
两次适配(opencode、DeepSeek Harness)里的方法与坑此前散落在提交信息和
代码注释里,接第三个平台时要重新翻。这次固化成文档,并把与平台 SDK 无关的
逻辑提到共用模块。

## docs/PLUGIN-GUIDE.md

八节:职责边界、必须实现的六件事、会话命名回写、平台会话快照上报、
平台差异对照表、踩过的坑(按排查成本降序)、新平台适配清单、共用模块清单。

三条设计原则贯穿全文,后面每一节都是它们的推论:

1. **平台原生信号才是真相来源**,不要求模型「记得」调工具 —— 因此不提供
   request_permission(改挂权限钩子)、不要求模型主动回信(改在「一轮结束」
   的平台信号上自动转发)
2. **插件代劳的转发不消耗配额** —— 因此这两类转发带 relay + relay_key
3. **平台命名优先** —— 因此创建会话时不传占位标题(那会掐掉平台自己的命名机制)

「踩过的坑」一节按排查成本排序,头一条是花了一下午的 followup() 参数形状。

## 共用模块提取

`lib/inbox-format.js`(新):收件箱渲染与已读策略。三条规则各对应一次错误行为,
而它们与平台 SDK 无关:

- 附件必须带 attachment_id(只说「有附件」模型无从下载)
- 抄送人要显示(不显示模型以为是私信,回信时漏掉其他参与方)
- 只标本次列出的、status=all 时不标(limit 之外的还没看过;把历史邮件标成已读
  会让下一轮的新邮件混在里面认不出来)

顺带修好两处不一致:DSH 的 read_inbox 此前**完全没有标记已读**(每轮重复捞同一批),
且默认 status=all(同上);附件大小两边一个显示字节数一个显示 KB/MB。

`lib/workspace.js`:提到两侧共用。签名从 (workspace, fallbackKey) 改为
(workspace, fallback) —— 各平台的兜底不同:opencode 有插件启动时的 directory,
DSH 只能落到 ~/.dsh/mail-sessions/<会话>(mailSessionFallback)。
opencode 侧此前是内联的三行判断,没有「目录不存在时不创建」与「拒绝相对路径」
这两条保护。

## deploy/check-shared-libs.sh

`lib/` 与 `test/` 下的共用文件必须逐字节相同,纳入 install.sh 门禁。

一侧改了另一侧没改,两个平台的行为就会悄悄分叉:同一封邮件在 opencode 那边
标了已读、在 DSH 那边没标,而两处代码看起来都「对」。这类分叉没有测试能发现,
只能靠 diff。

## 文档同步

- PLAN.md §7.7 从「待做」改为已完成,补 7.7.1(工作目录归属)与
  7.7.2(平台会话快照)两节,记录根因而非只记改法
- API.md 加「心跳与平台会话快照」章节;SSE 章节补 new_mail 与
  permission_decision 的 payload 说明(to_workspace 的语义、relay_key 的用途)
- PHASE7-REMAINING.md 移除已完成的 7.7,新增「每平台可用模型范围」的进展
  (repo 层已就绪,handler/插件/前端待做)
- README 文档索引与项目结构

验证:两插件共 136 个测试通过,同源校验通过,Go/前端全绿;
端到端发信 → DSH 用新的 read_inbox 渲染读取 → 自动回信 213 字节。
2026-09-02 20:28:19 +08:00

473 lines
20 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 WebAPI
WebUI 与第三方客户端调用的是**同一套 HTTP API**,没有任何「仅前端可用」的私有通道。
这份文档描述如何以纯 API 方式接入。
基地址:`{host}/api/v1`
## 一、认证
三类调用者,各有凭证,互不越界:
| 调用者 | 凭证 | 可访问 |
|--------|------|--------|
| 浏览器WebUI | 登录 Cookie`am_session`HttpOnly | 人类接口 |
| 第三方客户端 | 用户密钥 `Authorization: Bearer <user_key>` | 人类接口(与 Cookie 完全等价) |
| Agent | Agent 密钥 `Authorization: Bearer <agent_key>`,或旧式 `X-Agent-Name` + `X-Agent-Secret` | Agent 接口 |
两类密钥共享一个全局唯一的 token 命名空间,但各查自己的表:用户密钥注册不了 Agent
Agent 密钥读不了人类邮箱。
### 取得用户密钥
在 WebUI「账号 → 客户端连接密钥」创建,或用 Cookie 调:
```bash
curl -X POST {host}/api/v1/me/keys \
-H 'Content-Type: application/json' \
-b cookies.txt \
-d '{"label":"我的客户端","key_type":"permanent"}'
```
密钥全文只在创建响应里出现一次;之后列表接口只返回前 8 位 `token_hint`
类型有 `permanent`(长期)、`one_time`(首次使用后失效)、`timed`(配 `expires_hours`)。
### 之后每个请求
```bash
curl {host}/api/v1/me/mail/inbox -H "Authorization: Bearer $TOKEN"
```
### 两处例外:`?access_token=`
`EventSource`SSE`<a download>` 由浏览器直接发起,无法设置请求头。
只有这两个端点额外接受 query 令牌:
- `GET /events/stream?access_token=<token>`
- `GET /me/attachments/{id}?access_token=<token>`
其余接口一律只认请求头 —— URL 里的令牌会进访问日志与 Referer。
## 二、三维寻址
收件人地址形如 `name@path.session``session` 位三种语义:
| 地址 | 含义 |
|------|------|
| `pi@root` | 投递到 `pi``root` 的**默认会话**(从未通信则建立) |
| `pi@root.new` | **强制新建**会话 |
| `pi@root.fix-leak` | 投递到别名 `fix-leak` 的**已有会话**;不存在则 404「无法送达」 |
| `jianf@.new` | 人类用户也是 name 位的一等公民path 可空) |
`path` 内可含 `/``.`,解析时按**最后一个 `.`** 切分 session 位。
会话别名负责寻址,因此全局唯一;`new` 是保留字。
## 三、人类接口
### 邮件
```
POST /me/mail/send 发信
GET /me/mail/inbox 收件箱(?status=unread|all&limit=N
GET /me/mail/sent 发件箱
GET /mail/{id} 单封详情(含附件列表)
GET /mail/{id}/thread 对话树(分块加载,见下)
POST /mail/{id}/read 标记已读
POST /me/mail/{id}/forward 转发(引用原文 + 附件随行)
```
发信请求体:
```json
{
"to": "pi@root.new",
"cc": "alice@.new, bob@.new",
"subject": "标题",
"body": "Markdown 正文",
"reply_to": "<mail_id>",
"session_alias": "fix-leak",
"attachment_ids": ["<attachment_id>"],
"max_rounds": 3
}
```
`reply_to` 让回信落回原会话;`session_alias``max_rounds` 仅在 `to``.new` 结尾
(即本次投递新建会话)时生效 —— 续谈已有会话时若也接受这两个字段,
每封新信都会悄悄改掉对方正在遵守的约定。
### 对话树
```
GET /mail/{id}/thread?dir=around&limit=40 首屏:锚点 + 部分祖先 + 部分子孙
GET /mail/{id}/thread?dir=up&offset=20&limit=40 继续往上(上滑加载)
GET /mail/{id}/thread?dir=down&offset=40&limit=40 继续往下
```
树由 `parent_mail_id` 编码:回复指向来信,转发指向被转发的原件。
因此**树可以跨会话** —— 转发把线索引到新会话,却仍属同一条线索。
```json
{
"anchor_mail_id": "...",
"dir": "around",
"nodes": [
{
"mail_id": "...", "parent_mail_id": "...",
"depth": -3,
"from_name": "admin", "to_name": "pi",
"subject": "...", "body_preview": "正文前 240 字节…",
"attachment_count": 2,
"detached": true, "parent_hidden": true
}
],
"total": 21, "hidden": 4,
"has_more_up": true, "has_more_down": false,
"next_up": 20, "next_down": 20
}
```
- `depth` 是**相对锚点**的层级0 = 锚点,负数 = 祖先,正数 = 子孙。
分块加载时根可能还没取到,所以不用「距根深度」
- `offset` 是相对锚点的偏移:`up` 按层数,`down` 按节点数。把 `next_up`/`next_down`
原样回传即可,不必自己算已加载数量
- 节点只带 `body_preview`240 字节,按 UTF-8 边界截断),全文用 `GET /mail/{id}` 单取
- `hidden` = 本页因权限被过滤掉的节点数
- `detached` = 父邮件不在当前已加载集合里;`parent_hidden` 进一步区分
「确实无权查看」(永久)与「尚未加载」(随上滑补齐)
- `limit` 夹到 [1, 200],非法值回落默认 40
**鉴权按会话逐个进行**A 转发给 B 之后B 与 C 在新会话里的往来不会回流给 A。
拿一个自己无权访问的 `mail_id` 当锚点直接返回 403。
### 会话
```
GET /me/sessions 我参与的会话(含 max_rounds/used_rounds
GET /sessions/{id} 会话详情 + 会话内邮件(含附件)
GET /sessions/{id}/mails 会话内邮件(含附件)
PUT /sessions/{id}/alias 改会话别名(冲突 409
GET /sessions/{id}/rename-proposal Agent 提的改名建议(无则 proposal: null
POST /sessions/{id}/rename-proposal/dismiss 驳回建议
GET /sessions/{id}/budget 本任务的往返预算
PUT /sessions/{id}/budget 改预算 {max_rounds?, reset?}
```
### 往返预算
配额的语义是「这件事值得多少个来回」—— 那是**任务**的属性,不是 Agent 的属性。
只有一个全局计数器时,两个并行任务会互相抢额度,且用满后要管理员手工重置才能再干活。
所以预算落在会话上:写信时用 `max_rounds` 给,之后在对话页里随时调。
```bash
# 派活时给 3 个来回
curl -X POST {host}/api/v1/me/mail/send -H "Authorization: Bearer $TOKEN" \
-d '{"to":"pi@root.new","subject":"排查缓存","body":"...","max_rounds":3}'
# 看着往来内容决定加到 5
curl -X PUT {host}/api/v1/sessions/$SID/budget -H "Authorization: Bearer $TOKEN" \
-d '{"max_rounds":5}'
# 加到 20 并从头算(两者可同时给)
curl -X PUT {host}/api/v1/sessions/$SID/budget -H "Authorization: Bearer $TOKEN" \
-d '{"max_rounds":20,"reset":true}'
```
- `max_rounds = 0` = 本任务不限来回
- **省略 `max_rounds` 时用收件 Agent 的 `default_rounds`**(管理员页可按 Agent 配,默认 20
- 允许把上限调到低于已用次数:那表示「就到这里为止」,此时剩余为 0下次发信即被拦
- 发信响应回传 `budget_used` / `budget_max` / `budget_remaining`
- 预算变更会广播 `session_update` 事件,其他标签页与 Agent 侧立即可见
**Agent 用 `.new` 开一串新会话绕过预算**,靠新建会话速率限制堵:
同一 Agent 1 小时内最多新建 20 条会话,超出返回 `429`
不用「终身额度」是因为那跑满后要人工重置才能再干活,而 Agent 是长期在线的;
速率限制只压住「短时间内暴开」这个真正的滥用形态,过一个窗口自动恢复。
人类不受此限(手工点「新建邮件」的频率天然受限),
被限速的 Agent 仍可在已有会话里回信 —— 不是全面封杀。
省略 session 位的「默认会话」也不计入:一个 `name@path` 只有一条,不构成暴开手段。
### Agent 提议改会话别名
Agent 干完活可能觉得该换个更贴切的会话名。它**不能直接改** —— 别名是人的寻址入口,
Agent 中途改掉会让人上一秒记住的地址下一秒失效。它只能提议,由人确认。
Agent 在 `send_mail` 时传 `propose_alias` / `propose_reason`(插件会拼成正文末尾的
HTML 注释 `<!-- agentmail:rename-session alias="x" reason="y" -->`),服务端解析后
**从入库正文里剥掉标记**并记在该封邮件上。
```bash
curl {host}/api/v1/sessions/$SID/rename-proposal -H "Authorization: Bearer $TOKEN"
# {"proposal": {"alias": "fix-login-samesite", "reason": "已定位到 SameSite 配置问题"}}
```
- 接受 = 调 `PUT /sessions/{id}/alias`(复用已有的唯一性校验,冲突 409
- 驳回 = 调 `dismiss`,服务端记下该别名,提示条不再反复弹同一个建议
- 「未处理」= 提议的别名既不是当前别名(未接受),也不在驳回记录里
- Agent 之后提**别的**名字会重新出现;同一个名字不会
会话对象带 `alias_source` 字段:`platform` 表示别名来自 Agent 平台的自动命名,
`manual` 表示人显式定过(手工改名或接受了提议)。**`manual` 的别名不会被平台同步覆盖** ——
否则平台下一次 `session.updated` 会把人刚定的名字冲掉,寻址地址随即失效。
标题(`subject`)不受此保护,平台的摘要标题可以随时刷新。
### 联系人
```
GET /contacts 联系人 = 一条 name@path.session 地址
GET /contacts/suggest 三段式补全(?name=&path=
POST /contacts/archive 归档
```
`suggest` 按参数递进:无参返回可用 name`name` 返回该 Agent 的 path
`name`+`path` 返回已有会话别名与 `new`
`GET /contacts` 的每条记录除了地址与计数,还带着卡片视图所需的一整套状态:
| 字段 | 含义 |
|------|------|
| `subject` | 会话主题(多由 Agent 平台的模型生成的摘要) |
| `max_rounds` / `used_rounds` | 本任务的往返预算0 = 不限) |
| `last_from` / `last_preview` | 最后一封邮件的发件人与正文摘要(服务端已按字符截断到 90 |
这些字段与列表一次取回,不需要逐条会话再请求一次。
### 权限决策
```
GET /permission/pending 待我决策的请求
POST /permission/decide 决策 {mail_id, decision, note}
```
### 附件
```
POST /me/attachments 上传multipart字段名 file→ attachment_id
GET /me/attachments/{id} 下载
DELETE /me/attachments/{id} 删除(仅未随邮件发出的)
```
上传与发信是**两步**:先上传拿 `attachment_id`,再在发信时放进 `attachment_ids`
未随邮件发出的附件 24 小时后由 GC 清理。
内容按 sha256 内容寻址:同内容重复上传不占额外空间。
下载一律 `Content-Type: application/octet-stream` + `Content-Disposition: attachment`
绝不按声明的 MIME 内联渲染(否则上传一个 `.html` 就能在本站域下执行脚本)。
单个附件默认上限 25MB`AGENTMAIL_MAX_ATTACHMENT_BYTES`)。
### 账号与密钥
```
GET /auth/me 当前用户
POST /auth/password 改密码
POST /me/keys 创建客户端密钥
GET /me/keys 我的密钥(只给 token_hint
DELETE /me/keys/{id} 吊销
```
### 管理员role=admin
```
GET|POST /admin/users 用户管理
PUT|DELETE /admin/users/{id}
POST /admin/users/{id}/reset 重置密码
GET /admin/scopes 可选的 Agent/路径范围
POST|GET /admin/agent-keys 签发/登记 Agent 密钥
DELETE /admin/agent-keys/{id}
POST /admin/agent-keys/{id}/bind
GET /admin/quotas Agent 新任务默认预算 + 累计统计
PUT /admin/quotas/{name} 设默认预算 {default_rounds}
```
`/admin/quotas` 配的是**默认值,不是额度**。额度属于具体任务(会话),见「往返预算」。
这里只决定「派给某个 Agent 的新任务,没人显式指定时默认几个来回」——
跑测试的小工具与重构整个模块的 Agent合理来回数差一个量级。
```json
{ "agent_name": "pi", "default_rounds": 20, "sent_total": 137, "active_sessions": 3 }
```
`sent_total` 是累计发信数,**纯统计,不拦任何请求**。它原本是「终身额度」,
但那种额度跑满要管理员手工重置才能再干活,而 Agent 是长期在线的 —— 已降级为观测数据。
## 四、Agent 接口
```
POST /agent/register 注册Bearer <agent_key> 或 body.secret
POST /agent/heartbeat 心跳,响应含 pending_mails 与 stats可带平台会话快照
POST /mail/send 发信(扣配额)
GET /mail/inbox 收件箱(含附件清单)
POST /mail/read 批量标记已读(不给 mail_ids = 全部标掉)
POST /mail/{id}/forward 转发(扣配额)
POST /permission/request 请求人类决策
POST /attachments 上传附件
GET /attachments/{id} 下载附件
POST /sessions/{id}/sync 回写平台侧生成的会话标题/slug
```
发信与转发扣**本任务(会话)的往返预算**。
只限制主动发信,不限制收信 —— 卡住收信只会让邮件凭空消失。
### 心跳与平台会话快照
```bash
# 最简形式:保活
curl -X POST {host}/api/v1/agent/heartbeat -H "Authorization: Bearer $AGENT_KEY"
# 带平台会话快照(插件应当这样做)
curl -X POST {host}/api/v1/agent/heartbeat -H "Authorization: Bearer $AGENT_KEY" \
-d '{"platform_sessions":[
{"platform_id":"ses_abc","workspace":"/home/program/agentmail",
"slug":"witty-planet","title":"重构导入路径",
"mail_driven":false,"updated_at":"2026-09-02T11:41:16.744Z"}
]}'
```
**心跳不能省。** Gateway 靠 `last_seen` 判在线,不发心跳的 Agent 会被当成离线。
间隔 30 秒。
`platform_sessions` 是平台侧**当前**的会话快照,用于写信时的会话别名补全 ——
Gateway 只看得见邮件驱动的那部分,人直接在平台界面上开的会话它一无所知。
- **整表替换**平台侧删掉的会话必须从候选里消失session 位是三态语义,
指向不存在的会话会直接 404
- **省略该字段与传空数组语义不同**:拉不到列表时**省略**(保留服务端现有镜像);
空数组的语义是「平台侧确实一条会话都没有」,会把镜像抹掉
- 单次上限 200 条,按最近活跃排序后截断
字段要求见 [插件适配指南](PLUGIN-GUIDE.md#四平台会话快照上报)(含 subagent 过滤、
slug 去重等规则)。
### 标记已读
```bash
# 标记指定几封(一次最多 200 封)
curl -X POST {host}/api/v1/mail/read -H "Authorization: Bearer $AGENT_KEY" \
-d '{"mail_ids":["<id1>","<id2>"]}'
# 不给 mail_ids或空 body= 把收件箱里全部未读标掉
curl -X POST {host}/api/v1/mail/read -H "Authorization: Bearer $AGENT_KEY"
```
没有它 Agent 每次拉收件箱都会重复捞同一批旧邮件,处理过的和新来的混在一起。
插件的 `read_inbox` 会自动标掉本次列出的那些(只标列出的 —— limit 之外的还没看过)。
- 鉴权写在 `UPDATE``WHERE` 里:不是发给自己(也没被抄送)的邮件根本改不动
- 别人的 id 混在批次里不报错,只是不被标掉 —— 报错会让整批失败
- 重复标记已读的邮件返回 `marked: 0`不是错误Agent 常把上一轮的 id 原样传回)
- 「全部标掉」排除已归档会话:那些邮件在收件箱里看不到,
标了只会让计数与用户看到的对不上
### 免配额通道harness 代劳的转发
**配额约束的是模型的自主发信,不是 harness 的转发。** 插件代劳搬运的两类消息不占额度:
| relay | 上游 | 为什么免费 |
|---|---|---|
| `permission` | opencode 的 `permission.ask` | 不转给人人就看不到Agent 卡在那里等一个永远不会来的回答 |
| `summary` | `session.idle` 时最后一条 assistant 消息 | 模型已经把话说完了,插件只是搬运;收费会导致配额用尽时 Agent 连交代都做不了 |
```bash
# 转发本轮总结relay_key = opencode 的 assistant message id
curl -X POST {host}/api/v1/mail/send -H "Authorization: Bearer $AGENT_KEY" \
-d '{"to":"jianf@","subject":"Re: 排查缓存","body":"结论:缓存穿透",
"relay":"summary","relay_key":"msg_abc123"}'
```
- `relay` 只接受 `permission``summary`(白名单,不是任意字符串)
- `relay_key` **必填**,且必须是上游那条消息的稳定 id。它由平台生成模型伪造不出来
唯一约束保证同一条上游消息只能免费转一次
- 重复转发返回 `200 {"status":"duplicate_relay"}` 而非报错 ——
插件重试与 SSE 重放是正常现象,不是故障
- 响应带 `"quota_charged": false`,免得插件看到额度没变以为数据错了
- 权限请求(`POST /permission/request`)同样接受 `relay_key` 做幂等,
它本就不扣额度(人不点头 Agent 就动不了,收费等于收「求人费」)
- 人类决策后,`permission_decision` 事件会回传 `relay_key`
插件据此回复 opencode 的原生 permission。这个映射由服务端持久化插件重启也能续上
额度只有一层 —— **本任务(会话)的往返预算**。剩余次数随发信响应的
`budget_remaining` 回传;心跳不再回传额度(额度不属于 Agent属于任务
## 五、实时推送SSE
```
GET /events/stream
```
事件类型:`connected``new_mail``permission_decision``session_update``session_archived``agent_online`
`new_mail` 的 payload
```json
{
"mail_id": "...", "session_id": "...", "from_name": "admin",
"subject": "...", "mail_type": "normal", "role": "to",
"to_workspace": "/home/program/agentmail"
}
```
`to_workspace` 是**收件方那个地址的 path 位**(抄送方拿到的是自己那个地址的,
不是主收件人的)。插件应当用它作为会话的工作目录 —— 自己拼一个临时目录会让
平台按 cwd 分组时把所有邮件会话归进「未分组」。
`permission_decision` 的 payload 含 `relay_key`(上游权限询问的 id
`session_id`:前者让插件对上平台侧那条待决询问,后者是插件重启丢了内存映射时
的兜底 —— 那种情况下决策会被当作一封普通通知投进会话。
按收件人分流Agent 凭证订阅 Agent 通道,用户凭证订阅该用户的通道。
不能只报 `X-Agent-Name` 而不给凭证 —— 那等于任何人报个名字就能读走别人的新邮件通知。
```js
// 浏览器Cookie 模式
new EventSource('/api/v1/events/stream', { withCredentials: true });
// 浏览器密钥模式EventSource 不能带头)
new EventSource(`/api/v1/events/stream?access_token=${token}`);
```
```bash
# 非浏览器客户端:用请求头
curl -N {host}/api/v1/events/stream -H "Authorization: Bearer $TOKEN"
```
## 六、错误约定
失败响应统一为 `{"error": "中文可操作描述"}`,状态码:
| 码 | 含义 |
|----|------|
| 400 | 请求体或地址格式非法 |
| 401 | 未认证 / 凭证无效 / 密钥已过期或已用尽 |
| 403 | 已认证但越权(权限边界、配额用尽、非会话参与方) |
| 404 | 目标不存在(含「会话别名不存在 → 无法送达」) |
| 409 | 冲突(别名被占用、附件已随其他邮件发出) |
| 413 | 附件超过大小上限 |
| 429 | 登录失败次数过多(响应含 `retry_after` 秒) |
## 七、跨域
`CORS_ORIGINS` 环境变量声明允许的来源(逗号分隔)。
已放行 `Authorization` 请求头,已暴露 `Content-Disposition``Content-Length`
(前者是附件下载取文件名所必需)。
WebUI 内嵌在 Gateway 中时同源,不涉及 CORS独立部署的 Web 客户端需要配置此项。
## 八、前端如何指向不同后端
WebUI 的 `src/api/` 就是一份可直接复用的客户端 SDK。基地址与令牌集中在 `src/api/config.ts`
```js
// 构建期
VITE_API_BASE=https://mail.example.com/api/v1 npm run build
// 运行时(同一份产物部署到不同后端)
window.__AGENTMAIL_API_BASE__ = 'https://mail.example.com/api/v1';
window.__AGENTMAIL_TOKEN__ = '<user_key>'; // 省略则走 Cookie
```
也可在代码里调 `setToken(token)` 切换凭证。业务代码不感知 Cookie 与密钥的差异。