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

265 lines
7.8 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 (
"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})
}