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 !DecodeBody(w, r, &req) { 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 } } // 可达性:转发目标必须存在且未停用。人与 Agent 两条转发路径共用这道检查。 if !checkDeliverable(w, r, append([]models.Address{to}, ccList...)) { return } subject := forwardSubject(req.Subject, src.Subject) // 转发按目标地址寻址,不带 reply_to:它是一条新线索,不该并进原会话 sessionID, _, created, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias, agentLimiterKey(isAgent, actor)) if err != nil { writeErr(w, err, "Failed to resolve session") return } // 权限档位继承自源会话(plan 档派不出 full 档子任务,约束沿链条传递)。 // 只在【新建】目标会话时设:复用既有会话时不改写对方正在遵守的规则。 if created { mode := repo.InheritedMode(r.Context(), &src.SessionID, models.DefaultPermissionMode) if _, err := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); err != nil { Error(w, http.StatusInternalServerError, "Failed to set permission mode") return } _ = repo.SetSessionEnforcement(r.Context(), sessionID, repo.AgentModeEnforcement(r.Context(), to.Name)) } if isAgent { // 转发也是一次主动发信,扣【目标会话】的往返预算。 // 扣目标而不是源:转发开启的是一条新线索,消耗的是新线索的额度。 budget, bErr := repo.ConsumeSessionBudget(r.Context(), sessionID) if errors.Is(bErr, repo.ErrSessionBudgetExhausted) { Error(w, http.StatusForbidden, fmt.Sprintf( "目标会话的往返预算已用尽(%d/%d)。请让人在对话页调高该会话的预算。", budget.Used, budget.Max)) return } if bErr != nil { Error(w, http.StatusInternalServerError, "Failed to check session budget") return } repo.BumpSentCount(r.Context(), actor) } 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 } // 转发在数据上 parent 指向原邮件(用于回溯来源),但对**收件方**而言这是一封 // 全新的信:那封原邮件不是它写的,也不在它的线索里。 // 因此 in_reply_to 传空串 —— 提示词该说「有人转了一封信给你」而不是 // 「你上封信的回复到了」。 notifyRecipients(r.Context(), 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) } // ---------- Agent 默认预算与统计(管理员) ---------- // GET /api/v1/admin/quotas // // 路径沿用 quotas(兼容已部署的前端),但语义已变: // 返回的是【新任务默认预算 + 累计统计】,而不是会拦请求的终身额度。 // 真正的额度在每条会话上(GET /sessions/{id}/budget)。 func AdminListQuotas(w http.ResponseWriter, r *http.Request) { stats, err := repo.ListAgentStats(r.Context()) if err != nil { Error(w, http.StatusInternalServerError, "Failed to list agent stats") return } JSON(w, http.StatusOK, map[string]any{"quotas": stats}) } type setQuotaRequest struct { // DefaultRounds 派给该 Agent 的新任务默认多少个来回(0 = 不限) DefaultRounds *int `json:"default_rounds"` // MaxRounds 是 DefaultRounds 的旧字段名,保留兼容: // 已部署的前端与脚本不应该因为改名就难以察觉地失效。 MaxRounds *int `json:"max_rounds"` } // PUT /api/v1/admin/quotas/{name} // // 只能改【新任务默认预算】。不再接受 reset: // 累计发信数是观测数据,不拦任何请求,归零它只会销毁历史。 // 要给某个卡住的任务加额度,去那条会话的对话页改预算。 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 !DecodeBody(w, r, &req) { return } n := req.DefaultRounds if n == nil { n = req.MaxRounds // 兼容旧字段名 } if n == nil { Error(w, http.StatusBadRequest, "需要 default_rounds") return } if *n < 0 { Error(w, http.StatusBadRequest, "default_rounds 不能为负") return } st, err := repo.SetDefaultRounds(r.Context(), name, *n) if err != nil { Error(w, http.StatusNotFound, err.Error()) return } JSON(w, http.StatusOK, map[string]any{"quota": st}) }