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 时转发本轮总结)
15 KiB
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 调:
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)。
之后每个请求
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 转发(引用原文 + 附件随行)
发信请求体:
{
"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 编码:回复指向来信,转发指向被转发的原件。
因此树可以跨会话 —— 转发把线索引到新会话,却仍属同一条线索。
{
"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 我参与的会话
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 给,之后在对话页里随时调。
# 派活时给 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(或省略)= 本会话不限,仅受 Agent 全局配额约束- 两层都要过:会话预算 +
agents.max_rounds全局配额。少了后者, Agent 自己用.new开一串会话每条都是全新预算,全局上限形同虚设 - 会话预算先扣、全局配额后扣;被全局拦下时会话那次会退回去 —— 那次往返实际上没有发生
- 允许把上限调到低于已用次数:那表示「就到这里为止」,此时剩余为 0,下次发信即被拦
- 发信响应回传
budget_used/budget_max/budget_remaining - 预算变更会广播
session_update事件,其他标签页与 Agent 侧立即可见
Agent 提议改会话别名
Agent 干完活可能觉得该换个更贴切的会话名。它不能直接改 —— 别名是人的寻址入口, Agent 中途改掉会让人上一秒记住的地址下一秒失效。它只能提议,由人确认。
Agent 在 send_mail 时传 propose_alias / propose_reason(插件会拼成正文末尾的
HTML 注释 <!-- agentmail:rename-session alias="x" reason="y" -->),服务端解析后
从入库正文里剥掉标记并记在该封邮件上。
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 /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} 设上限 {max_rounds} 或归零 {reset:true}
四、Agent 接口
POST /agent/register 注册(Bearer <agent_key> 或 body.secret)
POST /agent/heartbeat 心跳,响应含 pending_mails 与 quota
POST /mail/send 发信(扣配额)
GET /mail/inbox 收件箱(含附件清单)
POST /mail/{id}/forward 转发(扣配额)
POST /permission/request 请求人类决策
POST /attachments 上传附件
GET /attachments/{id} 下载附件
POST /sessions/{id}/sync 回写平台侧生成的会话标题/slug
发信与转发要过两层额度:会话往返预算 + Agent 全局配额。 只限制主动发信,不限制收信 —— 卡住收信只会让邮件凭空消失。
免配额通道:harness 代劳的转发
配额约束的是模型的自主发信,不是 harness 的转发。 插件代劳搬运的两类消息不占额度:
| relay | 上游 | 为什么免费 |
|---|---|---|
permission |
opencode 的 permission.ask |
不转给人,人就看不到,Agent 卡在那里等一个永远不会来的回答 |
summary |
session.idle 时最后一条 assistant 消息 |
模型已经把话说完了,插件只是搬运;收费会导致配额用尽时 Agent 连交代都做不了 |
# 转发本轮总结(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。这个映射由服务端持久化,插件重启也能续上
max_rounds = 0 表示不限。剩余次数随发信响应与心跳回传。
注意 Agent 全局配额与会话往返预算是两层,都要过。
五、实时推送(SSE)
GET /events/stream
事件类型:connected、new_mail、permission_decision、session_update、session_archived、agent_online。
按收件人分流:Agent 凭证订阅 Agent 通道,用户凭证订阅该用户的通道。
不能只报 X-Agent-Name 而不给凭证 —— 那等于任何人报个名字就能读走别人的新邮件通知。
// 浏览器:Cookie 模式
new EventSource('/api/v1/events/stream', { withCredentials: true });
// 浏览器:密钥模式(EventSource 不能带头)
new EventSource(`/api/v1/events/stream?access_token=${token}`);
# 非浏览器客户端:用请求头
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:
// 构建期
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 与密钥的差异。