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,279 @@
package handler
import (
"errors"
"net/http"
"strings"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/repo"
"github.com/agentmail/gateway/internal/sse"
"github.com/google/uuid"
)
// ---------- Permission ----------
type permissionRequestRequest struct {
Question string `json:"question"`
Options []string `json:"options"`
Context string `json:"context"`
SessionID *string `json:"session_id"`
// 可选:显式指定决策人(人类用户名)。省略时由会话 owner 决定。
To string `json:"to"`
// RelayKey 是上游那条权限询问的稳定 id(opencode 的 permission.id)。
//
// 权限请求本来就不扣配额(人不点头 Agent 就动不了,收费等于收「求人费」),
// 这里要的只是**幂等**:permission.updated 事件会重复触发,插件也会重连重放,
// 没有幂等键就会给同一次询问生成好几封邮件。
RelayKey string `json:"relay_key"`
}
type permissionDecideRequest struct {
MailID string `json:"mail_id"`
Decision string `json:"decision"`
Note string `json:"note"`
}
// POST /api/v1/permission/request
func RequestPermission(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
var req permissionRequestRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
if req.Question == "" {
Error(w, http.StatusBadRequest, "Missing question")
return
}
options := req.Options
if len(options) == 0 {
options = []string{"同意", "拒绝"}
}
// 幂等:同一条上游询问只生成一封邮件。
// 重复不是故障(插件重试/事件重放的正常结果),因此幂等地返回已存在的结论而非报错。
relayKey := strings.TrimSpace(req.RelayKey)
if relayKey != "" {
if len(relayKey) > 160 {
Error(w, http.StatusBadRequest, "relay_key 过长(上限 160 字节)")
return
}
if err := repo.ClaimRelay(r.Context(), agentName, relayKey, "permission"); err != nil {
if errors.Is(err, repo.ErrRelayDuplicate) {
JSON(w, http.StatusOK, map[string]any{
"status": "duplicate_relay",
"relay_key": relayKey,
"detail": "该权限询问已转发过,本次调用未产生新邮件",
})
return
}
Error(w, http.StatusInternalServerError, "Failed to claim relay")
return
}
}
// 确定 session
var sessionID uuid.UUID
if req.SessionID != nil && *req.SessionID != "" {
id, err := uuid.Parse(*req.SessionID)
if err != nil {
Error(w, http.StatusBadRequest, "Invalid session_id")
return
}
sessionID = id
repo.TouchSession(r.Context(), sessionID)
} else {
id, err := repo.CreateSession(r.Context(), nil, agentName, "权限请求: "+req.Question)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to create session")
return
}
sessionID = id
}
// 决策人:显式指定优先,否则取会话 owner
decider := req.To
if decider == "" || decider == "human" {
owner, err := repo.SessionOwnerUsername(r.Context(), sessionID)
if err == nil && owner != "" {
decider = owner
}
}
if decider == "" {
// 会话无归属(Agent 自发起)时退回默认管理员
admin, err := repo.FirstAdminUsername(r.Context())
if err != nil || admin == "" {
Error(w, http.StatusConflict, "无法确定决策人,请在请求中指定 to")
return
}
decider = admin
}
body := req.Context
if body == "" {
body = req.Question
}
mailID, err := repo.CreatePermissionMail(r.Context(), sessionID, agentName, decider, req.Question, body, options)
if err != nil {
// 归还幂等键,否则这次询问永远转不出来了
if relayKey != "" {
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
}
Error(w, http.StatusInternalServerError, "Failed to create permission mail")
return
}
if relayKey != "" {
_ = repo.BindRelayMail(r.Context(), agentName, relayKey, mailID)
}
if err := repo.CreatePermissionRequest(r.Context(), mailID, sessionID, agentName, req.Question, options, req.Context); err != nil {
Error(w, http.StatusInternalServerError, "Failed to create permission request")
return
}
// 只推给该决策人
sse.Default.SendToUser(decider, "new_mail", map[string]interface{}{
"mail_id": mailID.String(),
"session_id": sessionID.String(),
"from_name": agentName,
"subject": "权限请求: " + req.Question,
"mail_type": "permission_request",
"role": "to",
})
JSON(w, http.StatusOK, map[string]string{
"mail_id": mailID.String(),
"session_id": sessionID.String(),
"permission_mail_id": mailID.String(),
"decider": decider,
})
}
// POST /api/v1/permission/decide —— 需登录;只有该权限请求的收件人或管理员可决策
func DecidePermission(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
var req permissionDecideRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
if req.MailID == "" || req.Decision == "" {
Error(w, http.StatusBadRequest, "Missing mail_id or decision")
return
}
mailID, err := uuid.Parse(req.MailID)
if err != nil {
Error(w, http.StatusBadRequest, "Invalid mail_id UUID")
return
}
perm, err := repo.GetPermissionByMailID(r.Context(), mailID)
if err != nil {
Error(w, http.StatusNotFound, "Permission request not found")
return
}
if perm.Result != nil && *perm.Result != "" {
Error(w, http.StatusConflict, "该请求已被处理")
return
}
// 鉴权:必须是这封权限邮件的收件人,或管理员
mail, err := repo.GetMailByID(r.Context(), mailID)
if err != nil {
Error(w, http.StatusNotFound, "Mail not found")
return
}
if !user.IsAdmin() && mail.ToName != user.Username {
Error(w, http.StatusForbidden, "无权决策他人的权限请求")
return
}
// 决策选项必须在候选内
if !contains(perm.Options, req.Decision) {
Error(w, http.StatusBadRequest, "决策必须是候选项之一")
return
}
if _, err := repo.DecidePermission(r.Context(), mailID, req.Decision); err != nil {
Error(w, http.StatusInternalServerError, "Failed to decide permission")
return
}
decisionMailID, err := repo.CreateDecisionMail(
r.Context(), perm.SessionID, mailID, user.Username, perm.AgentName, req.Decision, req.Note)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to create decision mail")
return
}
// 通知发起 Agent 恢复执行
// 带上上游 permission id:插件要拿它回复 opencode 的原生权限询问。
// 两边 id 空间不同,光给 AgentMail 的 mail_id 插件对不上;
// 而插件重启后内存映射会丢,所以这个映射由服务端持久化并在此回传。
payload := map[string]interface{}{
"mail_id": mailID.String(),
"decision_mail_id": decisionMailID.String(),
"decision": req.Decision,
"note": req.Note,
"decided_by": user.Username,
}
if key, kind := repo.RelayKeyForMail(r.Context(), mailID); key != "" {
payload["relay_key"] = key
payload["relay_kind"] = kind
}
sse.Default.SendToAgent(perm.AgentName, "permission_decision", payload)
// 只刷新决策人自己的界面
sse.Default.SendToUser(user.Username, "session_update", map[string]interface{}{
"session_id": perm.SessionID.String(),
"status": "active",
})
JSON(w, http.StatusOK, map[string]string{
"status": "decided",
"decision_mail_id": decisionMailID.String(),
})
}
// GET /api/v1/permission/pending —— 需登录;普通用户只看发给自己的
func ListPendingPermissions(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
forUser := user.Username
if user.IsAdmin() && r.URL.Query().Get("all") == "true" {
forUser = ""
}
reqs, err := repo.ListPendingPermissionsFor(r.Context(), forUser)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list pending permissions")
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"requests": emptySlice(reqs),
})
}
func contains(list []string, v string) bool {
for _, s := range list {
if s == v {
return true
}
}
return false
}