chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

View File

@ -0,0 +1,292 @@
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})
}