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:
264
gateway/internal/handler/forward.go
Normal file
264
gateway/internal/handler/forward.go
Normal file
@ -0,0 +1,264 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 转发 ----------
|
||||
//
|
||||
// 转发 = 引用原文 + 新收件人。与「回复」的区别:
|
||||
// 回复(reply_to)落回原会话,收件人是原发件人;
|
||||
// 转发按目标地址的 session 位另行定位会话,收件人是新指定的人。
|
||||
// 因此转发不复用 reply_to,而是走完整的三维寻址。
|
||||
|
||||
type forwardRequest struct {
|
||||
// To 新收件人,完整三维地址
|
||||
To string `json:"to"`
|
||||
// CC 可选抄送
|
||||
CC string `json:"cc"`
|
||||
// Comment 转发者附加的说明,置于引用原文之前
|
||||
Comment string `json:"comment"`
|
||||
// Subject 可选;留空时自动加 "Fwd: " 前缀
|
||||
Subject string `json:"subject"`
|
||||
// SessionAlias 仅在目标地址以 .new 结尾时生效
|
||||
SessionAlias string `json:"session_alias"`
|
||||
}
|
||||
|
||||
// quoteBody 把原文渲染为 Markdown 引用块。
|
||||
// 逐行加 "> " 而不是整段包裹:原文本身可能含代码块与列表,
|
||||
// 只有逐行前缀才能在任何 Markdown 渲染器里保持引用语义。
|
||||
func quoteBody(m *models.Mail) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("---\n\n")
|
||||
b.WriteString(fmt.Sprintf("> **转发自** %s", m.FromName))
|
||||
if m.FromWorkspace != "" {
|
||||
b.WriteString("@" + m.FromWorkspace)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf("> **主题** %s\n", m.Subject))
|
||||
b.WriteString(fmt.Sprintf("> **时间** %s\n", m.CreatedAt.Format("2006-01-02 15:04:05")))
|
||||
if len(m.CCList) > 0 {
|
||||
names := make([]string, 0, len(m.CCList))
|
||||
for _, c := range m.CCList {
|
||||
names = append(names, c.Raw)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("> **抄送** %s\n", strings.Join(names, ", ")))
|
||||
}
|
||||
b.WriteString(">\n")
|
||||
for _, line := range strings.Split(m.Body, "\n") {
|
||||
b.WriteString("> " + line + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// forwardSubject 生成转发主题,避免 "Fwd: Fwd: Fwd:" 无限叠加。
|
||||
func forwardSubject(custom, original string) string {
|
||||
if s := strings.TrimSpace(custom); s != "" {
|
||||
return s
|
||||
}
|
||||
if strings.HasPrefix(original, "Fwd: ") {
|
||||
return original
|
||||
}
|
||||
return "Fwd: " + original
|
||||
}
|
||||
|
||||
// doForward 是 Agent 与人类两条转发路径的公共实现。
|
||||
// actor 是转发者名(Agent 名或用户名),fromWorkspace 仅 Agent 有。
|
||||
func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor, fromWorkspace string, isAgent bool) {
|
||||
var req forwardRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.To) == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing to")
|
||||
return
|
||||
}
|
||||
|
||||
src, err := repo.LoadForwardSource(r.Context(), mailID, actor)
|
||||
switch {
|
||||
case errors.Is(err, repo.ErrMailNotFound):
|
||||
Error(w, http.StatusNotFound, "待转发的邮件不存在")
|
||||
return
|
||||
case errors.Is(err, repo.ErrForwardNotAllowed):
|
||||
Error(w, http.StatusForbidden, "只能转发自己参与过的邮件")
|
||||
return
|
||||
case err != nil:
|
||||
Error(w, http.StatusInternalServerError, "Failed to load mail")
|
||||
return
|
||||
}
|
||||
|
||||
to, err := models.ParseAddress(req.To)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid to address: "+err.Error())
|
||||
return
|
||||
}
|
||||
ccList, err := models.ParseAddressList(req.CC)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid cc address: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
if !isAgent && user != nil {
|
||||
to = resolveHumanAlias(to, user.Username)
|
||||
for i := range ccList {
|
||||
ccList[i] = resolveHumanAlias(ccList[i], user.Username)
|
||||
}
|
||||
if msg := checkScope(r, user, append([]models.Address{to}, ccList...)); msg != "" {
|
||||
Error(w, http.StatusForbidden, msg)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
subject := forwardSubject(req.Subject, src.Subject)
|
||||
|
||||
// 转发按目标地址寻址,不带 reply_to:它是一条新线索,不该并进原会话
|
||||
sessionID, _, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias)
|
||||
if err != nil {
|
||||
writeErr(w, err, "Failed to resolve session")
|
||||
return
|
||||
}
|
||||
|
||||
if isAgent {
|
||||
quota, qErr := repo.ConsumeQuota(r.Context(), actor)
|
||||
if errors.Is(qErr, repo.ErrQuotaExhausted) {
|
||||
Error(w, http.StatusForbidden, fmt.Sprintf(
|
||||
"发信配额已用尽(%d/%d)。请先向人类发送最终总结,或联系管理员重置配额。",
|
||||
quota.Used, quota.Max))
|
||||
return
|
||||
}
|
||||
if qErr != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check quota")
|
||||
return
|
||||
}
|
||||
} else if user != nil {
|
||||
_ = repo.SetSessionOwner(r.Context(), sessionID, user.ID)
|
||||
}
|
||||
|
||||
body := quoteBody(src)
|
||||
if c := strings.TrimSpace(req.Comment); c != "" {
|
||||
body = c + "\n\n" + body
|
||||
}
|
||||
attachedCount := 0
|
||||
|
||||
// parent_mail_id 指向原邮件:即便落在新会话里,也能回溯这封转发从何而来
|
||||
newID, err := repo.CreateMail(r.Context(), sessionID, &src.ID,
|
||||
actor, fromWorkspace, to.Name, to.Path, subject, body, ccList)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create mail")
|
||||
return
|
||||
}
|
||||
|
||||
// 附件随转发一同带过去——只引用正文而丢掉附件,收件人拿到的是一封残缺的邮件。
|
||||
// 内容寻址下这只是新增元数据,不拷磁盘文件。
|
||||
if n, err := repo.CopyAttachmentsTo(r.Context(), src.ID, newID, actor); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "复制附件失败")
|
||||
return
|
||||
} else {
|
||||
attachedCount = n
|
||||
}
|
||||
|
||||
notifyRecipients(to, ccList, sessionID, newID, actor, subject)
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"mail_id": newID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
"session_alias": repo.SessionAliasOf(r.Context(), sessionID),
|
||||
"forwarded_from": src.ID.String(),
|
||||
"attachments": attachedCount,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/mail/{id}/forward —— Agent 侧转发
|
||||
func ForwardMail(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
doForward(w, r, mailID, agentName, agentName, true)
|
||||
}
|
||||
|
||||
// POST /api/v1/me/mail/{id}/forward —— 人类侧转发
|
||||
func MeForwardMail(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
doForward(w, r, mailID, user.Username, "", false)
|
||||
}
|
||||
|
||||
// ---------- 配额管理(管理员) ----------
|
||||
|
||||
// GET /api/v1/admin/quotas
|
||||
func AdminListQuotas(w http.ResponseWriter, r *http.Request) {
|
||||
quotas, err := repo.ListQuotas(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list quotas")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"quotas": quotas})
|
||||
}
|
||||
|
||||
type setQuotaRequest struct {
|
||||
// MaxRounds 发信配额上限;0 = 不限
|
||||
MaxRounds *int `json:"max_rounds"`
|
||||
// Reset 为 true 时把已用次数归零
|
||||
Reset bool `json:"reset"`
|
||||
}
|
||||
|
||||
// PUT /api/v1/admin/quotas/{name}
|
||||
func AdminSetQuota(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimSpace(chi.URLParam(r, "name"))
|
||||
if name == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing agent name")
|
||||
return
|
||||
}
|
||||
|
||||
var req setQuotaRequest
|
||||
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
|
||||
}
|
||||
|
||||
var (
|
||||
q repo.Quota
|
||||
err error
|
||||
)
|
||||
if req.MaxRounds != nil {
|
||||
if q, err = repo.SetQuota(r.Context(), name, *req.MaxRounds); err != nil {
|
||||
Error(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Reset {
|
||||
if q, err = repo.ResetQuota(r.Context(), name); err != nil {
|
||||
Error(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"quota": q})
|
||||
}
|
||||
Reference in New Issue
Block a user