package handler import ( "context" "errors" "fmt" "net/http" "strings" "github.com/agentmail/gateway/internal/middleware" "github.com/agentmail/gateway/internal/models" "github.com/agentmail/gateway/internal/notify" "github.com/agentmail/gateway/internal/repo" "github.com/google/uuid" ) // ---------- Mail ---------- type sendMailRequest struct { To string `json:"to"` // name@path.session(省略 session=默认会话,new=新建,别名=必须已存在) CC string `json:"cc"` // 逗号/分号/空格分隔的多个 name@path.session Subject string `json:"subject"` Body string `json:"body"` ReplyTo string `json:"reply_to"` // SessionAlias 仅在本次投递【新建】会话时生效,为新会话命名, // 之后即可用 name@path. 续谈。命中已有会话时该字段被忽略。 SessionAlias string `json:"session_alias"` // AttachmentIDs 先用 POST /attachments 上传拿到的 id;只能附加自己上传且未挂载的 AttachmentIDs []string `json:"attachment_ids"` // Relay 标识本次发信是【插件代劳转发】而不是模型自主发信。 // // 基本原则:**配额约束的是模型的自主发信,不是 harness 的转发**。 // 平台原生的权限询问与本轮的最终总结都是插件搬运的,不计配额。 // // RelayKey 必須是上游那条消息的稳定标识(permission id / assistant message id): // 它由平台生成,模型伪造不出,而唯一约束保证同一条上游消息只能免费转一次。 Relay string `json:"relay"` // "" | "permission" | "summary" RelayKey string `json:"relay_key"` // 上游消息 id;relay 非空时必填 } // resolveTarget 根据三维地址 name@path.session 决定投递的会话。 // // session 位三态语义(设计文档): // - 省略(pi@root) → 投递到 name@path 的默认会话;从未通信则建立 // - new(pi@root.new) → 强制新建一个会话 // - 具体别名(pi@root.fix-leak)→ 必须已存在且该收件人参与过,否则 404 无法送达 // // alias 为新建会话命名(仅新建时生效),使其之后可被 name@path. 寻址。 // reply_to 优先于地址:显式回复某封邮件时沿用该邮件的会话。 // // byAgent 非空时表示这是 Agent 发起的投递,新建会话要过速率限制: // 往返预算按会话计,Agent 用 .new 开一串会话就等于绕过预算。 // 人类不受此限(手工点「新建邮件」的频率天然受限,加限制只会在批量派活时误伤)。 func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, subject, alias string, byAgent string) (uuid.UUID, *uuid.UUID, error) { if replyTo != "" { replyID, err := uuid.Parse(replyTo) if err != nil { return uuid.Nil, nil, errBadRequest("Invalid reply_to UUID") } mail, err := repo.GetMailByID(r.Context(), replyID) if err != nil { return uuid.Nil, nil, errNotFound("Parent mail not found") } repo.TouchSession(r.Context(), mail.SessionID) return mail.SessionID, &replyID, nil } switch addr.Mode() { case models.SessionNew: // 新建会话:若调用方给了别名,当场命名,之后即可用 name@path. 续谈。 // 别名全局唯一(负责寻址),已被占用时报 409 而不是静默吐出重名会话。 var aliasPtr *string if a := strings.TrimSpace(alias); a != "" { if err := validateSessionAlias(a); err != nil { return uuid.Nil, nil, err } if _, err := repo.FindSessionByAlias(r.Context(), a); err == nil { return uuid.Nil, nil, errConflict(fmt.Sprintf( "会话别名 %q 已被占用;若要接着该会话谈请用 %s@%s.%s", a, addr.Name, addr.Path, a)) } aliasPtr = &a } // Agent 主动开新线索要过速率限制 if ok, retry := repo.AllowNewSession(r.Context(), byAgent); !ok { return uuid.Nil, nil, errRateLimited(fmt.Sprintf( "新建会话过于频繁(1 小时内已开 %d 条)。请在已有会话里继续,或 %d 秒后再试。", repo.SessionRateLimit(), retry)) } // 带上 addr.Path:会话属于哪个工作区是会话自己的属性, // 不存下来的话「这个工作区下有哪些会话」就只能从 mails 反推。 id, err := repo.CreateSession(r.Context(), aliasPtr, fromAgent, subject, addr.Path) if err != nil { // 建失败要把名额还回去:那次新建实际上没有发生 repo.ReleaseNewSession(r.Context(), byAgent) return id, nil, err } // `.new` 是一次性动作:它建完会话就用完了,之后要再投进这条会话只能靠 // `name@path.<别名>`。未命名会话既查不到(FindNamedSessionFor 的 // `session_alias = $1` 对 NULL 不成立)也补全不出来,收件方与抄送方 // 除了回复那一封之外再也无法寻址到它 —— 再发一次 `.new` 只会建第三条会话。 // 因此这里立刻给一个别名,平台随后仍可用 SyncSessionAlias 改写它。 if aliasPtr == nil { // 命名失败不该让发信失败:邮件本身能送达,代价只是这条会话暂时 // 只能用 reply_to 续谈,比整封退回轻。 _, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject)) } return id, nil, nil case models.SessionDefault: // 默认会话「从未通信则建立」也会产生新会话,但一个 name@path 只有一条, // 不构成暴开的手段,因此不计入速率限制。 id, err := repo.FindOrCreateDefaultSession(r.Context(), addr.Name, addr.Path, fromAgent, subject) if err != nil { return id, nil, err } // 默认会话同样需要可寻址的别名:省略 session 位能投进来,但要**指名** // 投进这一条(而不是「该 name@path 当前的默认会话」)仍然只能靠别名。 // 已有别名时 EnsureSessionAlias 直接返回,复用旧会话不会被改名。 _, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject)) return id, nil, nil default: // models.SessionNamed id, err := repo.FindNamedSessionFor(r.Context(), addr.Name, addr.Path, addr.Session) if err == nil { repo.TouchSession(r.Context(), id) return id, nil, nil } if !errors.Is(err, repo.ErrSessionNotFound) { return uuid.Nil, nil, err } // 本侧没有这条别名 —— 再看平台会话镜像。 // // TUI 与邮箱是同一个 Agent 的两个入口,人在平台界面上开的会话 // 早就被补全列为候选(agent_platform_sessions),此前投递侧却没有 // 这一跳,选中后只能得到 404 —— 候选列表在承诺一件做不到的事。 // // 命中就**接管**它:本侧建一条会话并绑定 platform_id,插件收到投递 // 事件时据此 resume 那条平台会话而不是新建。 if adopted, aErr := adoptFromPlatform(r, addr, fromAgent, subject, byAgent); aErr == nil { return adopted, nil, nil } else if !errors.Is(aErr, repo.ErrSessionNotFound) { return uuid.Nil, nil, aErr } return uuid.Nil, nil, errNotFound(fmt.Sprintf( "无法送达:会话 %q 不存在于 %s@%s。若要新建会话请用 %s@%s.new,投递默认会话请省略 session 位", addr.Session, addr.Name, addr.Path, addr.Name, addr.Path)) } } // adoptFromPlatform 把地址里的 session 位当作**平台会话的 slug** 来解析, // 命中则接管那条会话。 // // 返回 repo.ErrSessionNotFound 表示镜像里也没有,调用方据此回 404。 // // # 为什么接管而不是直接投 // // 平台会话在本侧没有身份:没有 session_id、没有预算、没法归档, // 也无处记录「谁往里投过什么」。接管一次之后它就是一条正常的本侧会话, // 只是多带一个 platform_id 告诉插件「别新建,去 resume 那条」。 // // # 为什么一条平台会话只能被接管一次 // // 第二次投递必须复用第一次建的本侧会话。否则同一条 TUI 对话会在邮箱里 // 裂成多条互不相干的线索 —— 人看到三个同名会话,而回信只落在其中一条上。 func adoptFromPlatform(r *http.Request, addr models.Address, fromAgent, subject, byAgent string) (uuid.UUID, error) { platformID, realWorkspace, title, err := repo.FindPlatformSession( r.Context(), addr.Name, addr.Session, addr.Path) if err != nil { return uuid.Nil, err } // 已被接管过 → 复用,不再建新的 if existing, fErr := repo.FindSessionByPlatformID(r.Context(), addr.Name, platformID); fErr == nil { repo.TouchSession(r.Context(), existing) return existing, nil } else if !errors.Is(fErr, repo.ErrSessionNotFound) { return uuid.Nil, fErr } // 接管等于新开一条本侧线索,计入速率限制 —— 否则它成了绕过 // AllowNewSession 的后门(镜像里有几百条 slug 可选)。 if ok, retry := repo.AllowNewSession(r.Context(), byAgent); !ok { return uuid.Nil, errRateLimited(fmt.Sprintf( "新建会话过于频繁(1 小时内已开 %d 条)。请在已有会话里继续,或 %d 秒后再试。", repo.SessionRateLimit(), retry)) } // 主题优先用平台侧标题:它是那条对话在谈什么,比这封邮件的主题更能 // 代表整条会话。人在补全里看到的也是这个标题。 sub := strings.TrimSpace(title) if sub == "" { sub = subject } id, err := repo.AdoptPlatformSession( r.Context(), addr.Name, platformID, addr.Session, realWorkspace, sub) if err != nil { repo.ReleaseNewSession(r.Context(), byAgent) return uuid.Nil, err } return id, nil } // POST /api/v1/mail/send func SendMail(w http.ResponseWriter, r *http.Request) { agentName := middleware.GetAgentName(r) if agentName == "" { Error(w, http.StatusUnauthorized, "Unauthorized") return } var req sendMailRequest if !DecodeBody(w, r, &req) { return } if req.To == "" || req.Subject == "" || req.Body == "" { Error(w, http.StatusBadRequest, "Missing to, subject, or body") 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 } attachIDs, err := parseAttachmentIDs(req.AttachmentIDs) if err != nil { Error(w, http.StatusBadRequest, err.Error()) return } sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, agentName, req.Subject, req.SessionAlias, agentName) if err != nil { writeErr(w, err, "Failed to resolve session") return } // 配额在建邮件之前扣:否则邮件已入库再报 403,收件方会看到一封发件方以为发失败的邮件。 // 只限制主动发信,不限制收信(卡住收信只会让邮件凭空消失)。 // // 插件代劳转发(relay)走免配额通道:配额约束的是模型的自主发信, // 不是 harness 把平台原生的权限询问与最终总结搬到邮件里。 relay, relayKey, err := parseRelay(req.Relay, req.RelayKey) if err != nil { writeErr(w, err, "Invalid relay") return } var budget repo.SessionBudget // relayFree 表示本次 relay 走免配额通道。 // // **免配额只给发往人类的 relay。** // // 豁免的理由是「harness 把平台原生的权限询问与最终总结搬进邮件, // 不该算模型的自主发信」—— 而那是**假定收件方是人**写的。 // 收件方是另一个同样会自动转发的 Agent 时,双方都不在做决定, // 整个回路里没有任何一处在计数 —— 生产上跑出过 41 封且间隔从 // 15 分钟缩到 5 秒的无穷循环(会话 f3d824ce)。 // // 因此 Agent→Agent 的 relay 照样扣会话预算,max_rounds 就能截断它。 relayFree := false if relay != "" { human, hErr := repo.IsHumanUser(r.Context(), to.Name) if hErr != nil { Error(w, http.StatusInternalServerError, "Failed to resolve recipient") return } relayFree = human } if relay != "" { // 硬上限:一条会话里**连续**的 relay 邮件不得超过上限。 // // 与预算无关的第二道防线:预算给得大(比如 200)时,两个 Agent 仍能 // 烧掉 200 个来回;而故障报告这类**必须**走 relay 的邮件也需要受约束。 // // 「连续」是关键:中间只要有一封自主发信或人类插话,计数就归零。 hops, hopErr := repo.CountTrailingRelayHops(r.Context(), sessionID) if hopErr == nil && hops >= repo.MaxRelayHops() { Error(w, http.StatusForbidden, fmt.Sprintf( "本会话已连续 %d 封自动转发(上限 %d)。这通常意味着两个 Agent 在互相"+ "唤醒而无人决策。若确实需要继续,请由模型主动调 send_mail(不带 relay),"+ "或由人类在会话里插一句话。", hops, repo.MaxRelayHops())) return } // 先占幂等键。重复则说明这条上游消息已经转过, // 这是插件重试 / SSE 重放的正常结果,不是故障 —— 幂等地返回成功。 if cErr := repo.ClaimRelay(r.Context(), agentName, relayKey, relay); cErr != nil { if errors.Is(cErr, repo.ErrRelayDuplicate) { JSON(w, http.StatusOK, map[string]any{ "status": "duplicate_relay", "relay": relay, "relay_key": relayKey, "detail": "该上游消息已转发过,本次调用未产生新邮件", }) return } Error(w, http.StatusInternalServerError, "Failed to claim relay") return } } if relayFree { // 只读快照用于回传,不扣预算 budget, _ = repo.GetSessionBudget(r.Context(), sessionID) } else { // 额度只看【本任务】的往返预算。 // // 不再叠一层 Agent 终身额度:那种额度跑满后要管理员手工重置才能再干活, // 而 Agent 是长期在线的。防止 Agent 用 .new 开一串新会话绕过预算, // 靠的是新建会话速率限制(resolveTarget 里)。 budget, err = repo.ConsumeSessionBudget(r.Context(), sessionID) if errors.Is(err, repo.ErrSessionBudgetExhausted) { // 预算耗尽时要把幂等键还回去:否则那条上游消息永远转不出来了, // 之后管理员加了额度也无法重发。 if relay != "" { _ = repo.ReleaseRelay(r.Context(), agentName, relayKey) } Error(w, http.StatusForbidden, fmt.Sprintf( "本任务的往返预算已用尽(%d/%d)。自动转发的总结与权限询问不占预算;"+ "若需继续主动发信,请让人在对话页调高本任务的预算。", budget.Used, budget.Max)) return } if err != nil { Error(w, http.StatusInternalServerError, "Failed to check session budget") return } // 纯统计,不拦请求;写失败也不该让邮件发不出去 repo.BumpSentCount(r.Context(), agentName) } // Agent 可以在正文里提议改会话别名()。 // 标记从入库正文里剥掉:它是给系统看的元数据,不该出现在人读的正文里 // (react-markdown 会把 HTML 注释转义成可见文本,不会自动吞掉)。 // // 提议只是提议 —— 别名是人的寻址入口,Agent 干到一半自己改掉会让人 // 上一秒记住的地址下一秒失效。真正改名要等用户在前端点「接受」。 proposal, body := extractRenameProposal(req.Body) mailID, err := repo.CreateMail(r.Context(), sessionID, parentMailID, agentName, agentName, to.Name, to.Path, req.Subject, body, ccList) if err != nil { // 建邮件失败时必须把幂等键还回去,否则这条上游消息永远转不出来了 if relay != "" { _ = repo.ReleaseRelay(r.Context(), agentName, relayKey) } Error(w, http.StatusInternalServerError, "Failed to create mail") return } if relay != "" { // 关联失败不影响功能,只是少一条审计记录 _ = repo.BindRelayMail(r.Context(), agentName, relayKey, mailID) } if proposal != nil { // 记不上提议不该让发信失败:邮件本身已经入库,提议是旁支信息 _ = repo.SetMailRenameProposal(r.Context(), mailID, proposal.Alias, proposal.Reason) } if !attachAll(w, r, mailID, attachIDs, agentName) { return } notifyRecipients(r.Context(), to, ccList, sessionID, mailID, agentName, req.Subject) // 回传会话别名与本任务剩余往返,让发件方知道后续用什么地址续谈、还能发几封 resp := map[string]any{ "mail_id": mailID.String(), "session_id": sessionID.String(), "session_alias": repo.SessionAliasOf(r.Context(), sessionID), } // 预算属于【本任务】,不限时不回传 —— 多给一个 -1 只会让插件去判断哪个值是哨兵 if !budget.Unlimited { resp["budget_remaining"] = budget.Remaining resp["budget_used"] = budget.Used resp["budget_max"] = budget.Max } if relay != "" { // 告知本次未扣预算,否则插件看到 budget_remaining 没变会以为数据错了 resp["relay"] = relay resp["budget_charged"] = false } if proposal != nil { // 回传规范化后的别名:Agent 提的名字可能含非法字符被改写过, // 让它知道最终会拿什么去问用户 resp["rename_proposed"] = proposal.Alias } JSON(w, http.StatusOK, resp) } // notifyRecipients 是 notify.Recipients 的薄封装,保留旧签名减少调用点改动。 // // 实现只有一份,在 internal/notify 里 —— 此前 handler 与 scheduler 各写一份, // 加字段时漏改一处直接造成生产事故(详见那个包的注释)。 func notifyRecipients(ctx context.Context, to models.Address, cc []models.Address, sessionID, mailID uuid.UUID, from, subject string) { notify.Recipients(ctx, notify.Mail{ SessionID: sessionID, MailID: mailID, From: from, To: to, CC: cc, Subject: subject, }) } // GET /api/v1/mail/inbox func GetInbox(w http.ResponseWriter, r *http.Request) { agentName := middleware.GetAgentName(r) if agentName == "" { Error(w, http.StatusUnauthorized, "Unauthorized") return } status := r.URL.Query().Get("status") if status == "" { status = "unread" } limit := 10 if l := r.URL.Query().Get("limit"); l != "" { if n, err := parseInt(l); err == nil && n > 0 { limit = n } } mails, err := repo.ListInbox(r.Context(), agentName, status, limit) if err != nil { Error(w, http.StatusInternalServerError, "Failed to list inbox") return } // Agent 靠收件箱列表得知有哪些附件可下载,否则它不知道该调 attachment_id ptrs := make([]*models.Mail, len(mails)) for i := range mails { ptrs[i] = &mails[i] } fillAttachments(r, ptrs...) total, _ := repo.CountUnread(r.Context(), agentName) JSON(w, http.StatusOK, map[string]interface{}{ "mails": emptySlice(mails), "total": total, }) } // GET /api/v1/mail/{id} —— 需登录,且需对所属会话有权限 func GetMail(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 } mail, err := repo.GetMailByID(r.Context(), mailID) if err != nil { Error(w, http.StatusNotFound, "Mail not found") return } allowed, err := repo.UserCanAccessSession(r.Context(), user, mail.SessionID) if err != nil { Error(w, http.StatusInternalServerError, "Failed to check permission") return } if !allowed { Error(w, http.StatusForbidden, "无权访问该邮件") return } fillAttachments(r, mail) JSON(w, http.StatusOK, mail) } // POST /api/v1/mail/{id}/read —— 需登录,只能标记自己可见的邮件 func MarkMailRead(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 } mail, err := repo.GetMailByID(r.Context(), mailID) if err != nil { Error(w, http.StatusNotFound, "Mail not found") return } allowed, err := repo.UserCanAccessSession(r.Context(), user, mail.SessionID) if err != nil { Error(w, http.StatusInternalServerError, "Failed to check permission") return } if !allowed { Error(w, http.StatusForbidden, "无权操作该邮件") return } if err := repo.MarkMailRead(r.Context(), mailID); err != nil { Error(w, http.StatusInternalServerError, "Failed to mark read") return } JSON(w, http.StatusOK, map[string]string{"status": "read"}) } func parseInt(s string) (int, error) { n := 0 for _, c := range s { if c < '0' || c > '9' { return 0, nil } n = n*10 + int(c-'0') } return n, nil } type markReadRequest struct { // MailIDs 要标记为已读的邮件;省略/为空 = 把收件箱里全部未读标掉。 MailIDs []string `json:"mail_ids"` } // POST /api/v1/mail/read —— Agent 侧批量标记已读 // // 为什么需要它:Agent 读完 read_inbox 后没有任何办法把邮件标掉, // 于是每次拉收件箱都把同一批旧邮件重新捞出来 —— 处理过的信和新来的信混在一起, // 模型分不清哪封该回。心跳里的未读数也永远只增不减。 // // 鉴权写进 UPDATE 的 WHERE 而不是先查后改:不是发给自己的邮件根本改不动, // 既省一次查询,也没有「查完到改之间邮件被转走」的时间窗。 func MarkInboxRead(w http.ResponseWriter, r *http.Request) { agentName := middleware.GetAgentName(r) if agentName == "" { Error(w, http.StatusUnauthorized, "Unauthorized") return } var req markReadRequest // 允许空 body:`POST /mail/read` 不带任何内容 = 全部标掉 if r.ContentLength > 0 { if !DecodeBody(w, r, &req) { return } } // 不给 id 就把收件箱里全部未读标掉。 // 这是 Agent 最常见的用法:一轮处理完,剩下的都不必再看。 if len(req.MailIDs) == 0 { n, err := repo.MarkAllInboxReadFor(r.Context(), agentName) if err != nil { Error(w, http.StatusInternalServerError, "Failed to mark read") return } JSON(w, http.StatusOK, map[string]any{"status": "read", "marked": n, "scope": "all"}) return } const maxBatch = 200 if len(req.MailIDs) > maxBatch { Error(w, http.StatusBadRequest, fmt.Sprintf("一次最多标记 %d 封", maxBatch)) return } ids := make([]uuid.UUID, 0, len(req.MailIDs)) for _, s := range req.MailIDs { id, err := uuid.Parse(strings.TrimSpace(s)) if err != nil { Error(w, http.StatusBadRequest, "非法的 mail_id: "+s) return } ids = append(ids, id) } n, err := repo.MarkMailsReadFor(r.Context(), agentName, ids) if err != nil { Error(w, http.StatusInternalServerError, "Failed to mark read") return } // 不因为「有些 id 不是发给你的」而报错:那些 id 只是没被标掉。 // 报错会让整批失败,而 Agent 通常是把上一轮列出的 id 原样传回来, // 其中可能混着已读的(幂等)——那不该是错误。 JSON(w, http.StatusOK, map[string]any{ "status": "read", "marked": n, "requested": len(ids), }) }