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 时转发本轮总结)
This commit is contained in:
2026-09-02 10:29:26 +08:00
commit 0e754617a4
95 changed files with 23219 additions and 0 deletions

View File

@ -0,0 +1,87 @@
package repo
import (
"context"
"errors"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
// ---------- 插件自动转发(免配额通道) ----------
//
// **基本原则:配额约束的是模型的自主发信,不是 harness 的转发。**
//
// 配额存在的意义是防止 Agent 无限自我循环。而插件代劳搬运的两类消息不属于此列:
// 1. 平台原生的权限询问(opencode 的 permission.updated)—— 不转给人,人就看不到,
// Agent 卡在那里等一个永远不会来的回答
// 2. 本轮的最终总结(session.idle 时最后一条 assistant 消息)—— 模型已经把话说完了,
// 插件只是把它搬到邮件里;对它收费会导致配额用尽时 Agent 连交代都做不了
//
// 防滥用不靠计数,靠**幂等键**:relay_key 是上游那条消息的稳定标识
// (permission id / assistant message id)。唯一约束让同一条上游消息只能转一次,
// 于是插件重试与 SSE 重放不会产生第二封,想多转就得拿出不同的上游消息 id ——
// 而那些 id 由平台生成,模型伪造不出来。
// ErrRelayDuplicate 表示这条上游消息已经转发过了。
var ErrRelayDuplicate = errors.New("relay already recorded")
// ClaimRelay 占用一次免配额转发名额。
//
// 判断与占用在同一条 INSERT 里(靠主键唯一约束),并发重试下只有一个能成功 ——
// 分成「先查有没有、再插入」两步的话,插件的两次重试会双双通过检查各插一条。
//
// 返回 ErrRelayDuplicate 表示重复,调用方应当据此跳过发信而不是报错:
// 重复转发是插件重试的正常结果,不是故障。
func ClaimRelay(ctx context.Context, agentName, relayKey, kind string) error {
_, err := db.DB.ExecContext(ctx,
`INSERT INTO relayed_mails (agent_name, relay_key, kind) VALUES ($1, $2, $3)`,
agentName, relayKey, kind)
if err != nil {
if db.IsUniqueViolation(err) {
return ErrRelayDuplicate
}
return err
}
return nil
}
// BindRelayMail 把已占用的名额关联到真正发出的邮件,便于事后审计
// 「这封免配额的信是从哪条上游消息来的」。
//
// 关联失败不该让发信失败:邮件已经入库,缺一条审计关联不影响功能。
func BindRelayMail(ctx context.Context, agentName, relayKey string, mailID uuid.UUID) error {
_, err := db.DB.ExecContext(ctx,
`UPDATE relayed_mails SET mail_id = $1 WHERE agent_name = $2 AND relay_key = $3`,
mailID, agentName, relayKey)
return err
}
// ReleaseRelay 撤销名额占用。
//
// 占用成功但发信失败时必须还回去,否则那条上游消息永远转不出来了 ——
// 幂等键会一直认为它已经转过。
func ReleaseRelay(ctx context.Context, agentName, relayKey string) error {
_, err := db.DB.ExecContext(ctx,
`DELETE FROM relayed_mails WHERE agent_name = $1 AND relay_key = $2 AND mail_id IS NULL`,
agentName, relayKey)
return err
}
// RelayKeyForMail 反查某封邮件对应的上游消息 id。
//
// 人类决策一条权限请求后,插件需要知道该回复 opencode 的哪个 permission ——
// 光有 AgentMail 的 mail_id 是不够的,两边的 id 空间不同。
// 插件重启后内存映射会丢,所以这个映射必须在服务端持久化。
//
// 无记录时返回空串(例如旧数据,或压根没走 relay 通道的请求)。
func RelayKeyForMail(ctx context.Context, mailID uuid.UUID) (string, string) {
var key, kind string
err := db.DB.QueryRowContext(ctx,
`SELECT relay_key, kind FROM relayed_mails WHERE mail_id = $1 LIMIT 1`,
mailID).Scan(&key, &kind)
if err != nil {
return "", ""
}
return key, kind
}