Files
MailUI4Agents/gateway/internal/handler/sessions.go
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

341 lines
10 KiB
Go
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.

package handler
import (
"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"
)
// ---------- Session均需登录且做会话级鉴权 ----------
// requireSessionAccess 解析路径中的会话 ID 并校验当前用户有权访问
func requireSessionAccess(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return uuid.Nil, false
}
sessionID, ok := pathUUID(w, r, "id")
if !ok {
return uuid.Nil, false
}
allowed, err := repo.UserCanAccessSession(r.Context(), user, sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to check permission")
return uuid.Nil, false
}
if !allowed {
Error(w, http.StatusForbidden, "无权访问该会话")
return uuid.Nil, false
}
return sessionID, true
}
// GET /api/v1/sessions/{id}
func GetSession(w http.ResponseWriter, r *http.Request) {
sessionID, ok := requireSessionAccess(w, r)
if !ok {
return
}
session, err := repo.GetSessionByID(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusNotFound, "Session not found")
return
}
mails, err := repo.GetSessionMails(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to get session mails")
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"session": session,
"mails": emptySlice(mails),
})
}
// GET /api/v1/sessions/{id}/mails
func GetSessionMails(w http.ResponseWriter, r *http.Request) {
sessionID, ok := requireSessionAccess(w, r)
if !ok {
return
}
mails, err := repo.GetSessionMails(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to get mails")
return
}
// 会话线程要展示附件,逐封填充
for i := range mails {
fillAttachments(r, &mails[i])
}
JSON(w, http.StatusOK, map[string]interface{}{
"mails": emptySlice(mails),
})
}
type updateAliasRequest struct {
Alias string `json:"alias"`
}
// PUT /api/v1/sessions/{id}/alias
//
// 会话别名负责三维寻址name@path.<alias>),因此必须全局唯一,
// 且不能叫 "new"(那是寻址保留字)。
func UpdateSessionAlias(w http.ResponseWriter, r *http.Request) {
sessionID, ok := requireSessionAccess(w, r)
if !ok {
return
}
var req updateAliasRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
alias := strings.TrimSpace(req.Alias)
if alias == "" {
Error(w, http.StatusBadRequest, "Missing alias")
return
}
if err := validateSessionAlias(alias); err != nil {
writeErr(w, err, "Invalid alias")
return
}
// 被其他会话占用时报 409而不是默默造出两个同名可寻址会话
if s, err := repo.FindSessionByAlias(r.Context(), alias); err == nil && s.ID != sessionID {
Error(w, http.StatusConflict, "会话别名 \""+alias+"\" 已被其他会话占用")
return
}
if err := repo.UpdateSessionAlias(r.Context(), sessionID, alias); err != nil {
Error(w, http.StatusInternalServerError, "Failed to update alias")
return
}
JSON(w, http.StatusOK, map[string]string{
"status": "updated",
"alias": alias,
})
}
// syncSessionRequest 是 Agent 平台回传自己那侧的会话标识。
//
// 各 Agent 平台opencode / Claude Code / DSH…都会由模型为会话生成一个摘要标题
// 并配一个短 slug。不在本侧另造一套命名平台那边叫什么本侧就叫什么。
type syncSessionRequest struct {
// Alias 是平台侧的短标识(如 opencode 的 slug "jolly-cactus"),写入本侧 session_alias 供寻址。
Alias string `json:"alias"`
// Title 是平台侧模型生成的摘要标题(如「修复登录态丢失」),写入本侧 subject 供展示。
Title string `json:"title"`
}
// POST /api/v1/sessions/{id}/sync
//
// Agent 侧端点:把平台生成的会话标题与 slug 同步到本侧。
// alias 撞名时自动追加 -2/-3 后缀(本侧别名负责寻址必须唯一,而平台 slug 不保证全局唯一),
// 因此本接口不会因撞名失败,响应里回传最终落库的别名。
func SyncSession(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
sessionID, ok := pathUUID(w, r, "id")
if !ok {
return
}
// Agent 只能同步自己参与过的会话
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to check permission")
return
}
if !allowed {
Error(w, http.StatusForbidden, "无权修改未参与的会话")
return
}
var req syncSessionRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
resp := map[string]string{"status": "synced"}
if title := strings.TrimSpace(req.Title); title != "" {
if err := repo.SyncSessionTitle(r.Context(), sessionID, title); err != nil {
Error(w, http.StatusInternalServerError, "Failed to sync title")
return
}
resp["title"] = title
}
if alias := strings.TrimSpace(req.Alias); alias != "" {
// 平台 slug 可能带非法字符,落库前按本侧寻址规则规范化
norm := normalizeAlias(alias)
if norm == "" {
Error(w, http.StatusBadRequest, "alias 规范化后为空,无法作为寻址别名")
return
}
final, err := repo.SyncSessionAlias(r.Context(), sessionID, norm)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to sync alias")
return
}
resp["alias"] = final
}
// 让参与方前端立即看到新标题/别名
sse.Default.Broadcast("session_update", map[string]interface{}{
"session_id": sessionID.String(),
"alias": resp["alias"],
"title": resp["title"],
})
JSON(w, http.StatusOK, resp)
}
// GET /api/v1/sessions/{id}/rename-proposal
//
// 返回该会话里最新一条尚未处理的改名提议Agent 在正文里提的)。
// 「尚未处理」= 既不是当前别名(已接受),也不在驳回记录里。
// 无提议时返回 {"proposal": null},前端据此决定要不要显示提示条。
func GetRenameProposal(w http.ResponseWriter, r *http.Request) {
sessionID, ok := requireSessionAccess(w, r)
if !ok {
return
}
alias, reason, err := repo.PendingRenameProposal(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to load proposal")
return
}
if alias == "" {
JSON(w, http.StatusOK, map[string]interface{}{"proposal": nil})
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"proposal": map[string]string{"alias": alias, "reason": reason},
})
}
// POST /api/v1/sessions/{id}/rename-proposal/dismiss
//
// 用户驳回当前提议。记下被驳回的别名,好让提示条不再反复弹同一个建议 ——
// 否则每次打开会话都要重新点一次「忽略」。
//
// 接受提议走已有的 PUT /sessions/{id}/alias不另开端点
// 那条路径已经有唯一性校验与 409 处理,复制一遍只会多一个出错的地方。
func DismissRenameProposal(w http.ResponseWriter, r *http.Request) {
sessionID, ok := requireSessionAccess(w, r)
if !ok {
return
}
alias, _, err := repo.PendingRenameProposal(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to load proposal")
return
}
if alias == "" {
// 已经没有待处理提议(可能是另一个标签页刚处理过),当作成功
JSON(w, http.StatusOK, map[string]string{"status": "no_pending"})
return
}
if err := repo.DismissRenameProposal(r.Context(), sessionID, alias); err != nil {
Error(w, http.StatusInternalServerError, "Failed to dismiss proposal")
return
}
JSON(w, http.StatusOK, map[string]string{
"status": "dismissed",
"dismissed": alias,
})
}
type sessionBudgetRequest struct {
// MaxRounds 是本会话的往返预算上限0 = 不限)。
MaxRounds *int `json:"max_rounds"`
// Reset 把已用次数归零(上限不变)。可与 MaxRounds 同时给:
// 「加到 20 并从头算」是一次很自然的操作,拆成两个请求只会让前端多一次往返。
Reset bool `json:"reset"`
}
// GET /api/v1/sessions/{id}/budget
//
// 本会话的往返预算。与 Agent 全局配额是两层,都要过:
// 会话预算管「这件事值得多少个来回」,全局配额管「这个 Agent 总共能发多少」。
func GetSessionBudgetHandler(w http.ResponseWriter, r *http.Request) {
sessionID, ok := requireSessionAccess(w, r)
if !ok {
return
}
b, err := repo.GetSessionBudget(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusNotFound, "Session not found")
return
}
JSON(w, http.StatusOK, b)
}
// PUT /api/v1/sessions/{id}/budget
//
// 在对话页里随时调本任务的预算 —— 这是配额最该被编辑的地方:
// 人看着往来内容才知道这件事还值不值得再来几个回合。
func UpdateSessionBudget(w http.ResponseWriter, r *http.Request) {
sessionID, ok := requireSessionAccess(w, r)
if !ok {
return
}
var req sessionBudgetRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
if req.MaxRounds == nil && !req.Reset {
Error(w, http.StatusBadRequest, "需要给出 max_rounds 或 reset")
return
}
b, err := repo.GetSessionBudget(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusNotFound, "Session not found")
return
}
if req.MaxRounds != nil {
if *req.MaxRounds < 0 {
Error(w, http.StatusBadRequest, "max_rounds 不能为负")
return
}
// 允许调到低于已用次数:那表示「就到这里为止」,是人的合法意图。
// 此时剩余为 0Agent 下次发信即被拦。
b, err = repo.SetSessionBudget(r.Context(), sessionID, *req.MaxRounds)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to set budget")
return
}
}
if req.Reset {
b, err = repo.ResetSessionBudget(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to reset budget")
return
}
}
// 让会话的其他参与方(含 Agent 侧界面)立刻看到新预算
sse.Default.Broadcast("session_update", map[string]interface{}{
"session_id": sessionID.String(),
"budget_max": b.Max,
"budget_used": b.Used,
"budget_remaining": b.Remaining,
})
JSON(w, http.StatusOK, b)
}