Files
MailUI4Agents/server/internal/handler/me.go
JianFeeeee 2fd18ba134 fix(回路): 人类经 /me/mail/send 插话也要解锁 —— 端到端实测抓到的缺口
上一提交(4d8165f)的 403 文案写着「若要立刻恢复,请由人类在会话里插一句话」,
但**那句话是假的**。

## 实测证据

拿真实用户登录态经 `POST /api/v1/me/mail/send` 在被锁会话里插话:

    HTTP 200  {"mail_id":"68eda0c3-…"}    ← 人类的信进去了(这是对的)
    session_agent_locks 锁行数: 1          ← ★ 锁还在

`MeSendMail` 有自己的 resolve + CreateMail,**根本不经过 Agent 侧那道闸**
(main.go 里 UserAuth 组下单独注册)。所以解锁只写在 `SendMail` 里是不够的:
人类插了话,锁仍要等满 2 小时。

## 为什么单测没抓到

`TestHumanPostClearsCooldownImmediately` 走的是 Agent 侧 handler,
证明了「解锁逻辑本身对」,却没证明「人类实际会走的那条路也解锁」。
判据钉的是 A 路径、生产走的是 B 路径 —— 这个形状本仓已遇到三次
(opencode 的 withScope、homeagent 的 InjectInputSync、这次)。

## 修法

把解锁放在 `SetSessionOwner` 旁边 —— 那是「人类参与这条会话」的权威落点,
比在每个调用点各写一遍可靠(漏一处就又是一句假承诺)。
解锁失败只记日志不阻断:人的来信优先入库,代价只是那把锁到期自消(保守方向)。

判据:新增 `TestHumanSendPathAlsoClearsCooldown`,走真实 `middleware.UserAuth`
+ 真实 user_sessions 行。变异(撤掉解锁)后该格变红。

踩到的两个测试夹具坑(都记在判据注释里):
- 直接调 handler 会 401 —— 生产上这条路由在 UserAuth 中间件后面
- UserAuth → SessionToken 读 config.C.CookieName,而测试里 config.C 是 nil ⇒ panic
2026-10-01 20:19:47 +08:00

411 lines
14 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"
"log"
"net/http"
"time"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/google/uuid"
)
// ---------- /me:当前登录人类用户的邮箱(全部路由需 UserAuth) ----------
type meSendMailRequest struct {
To string `json:"to"` // name@path.session
CC string `json:"cc"` // 多个 name@path.session
Subject string `json:"subject"`
Body string `json:"body"`
ReplyTo string `json:"reply_to"`
// SessionAlias 仅在本次投递【新建】会话时生效,为新会话命名
SessionAlias string `json:"session_alias"`
// AttachmentIDs 先用 POST /me/attachments 上传拿到的 id
AttachmentIDs []string `json:"attachment_ids"`
// MaxRounds 是本次任务的往返预算(0/省略 = 不限)。
//
// 配额的真实语义是「这件事值得多少个来回」——那是任务的属性,
// 所以在派活的这一刻给,而不是事后到管理员页面去调某个 Agent 的全局配额。
// 仅在本次投递【新建】会话时生效;续谈已有会话请用
// PUT /sessions/{id}/budget(对话页里可随时改)。
MaxRounds *int `json:"max_rounds"`
// PermissionMode 声明本任务允许 Agent 动手到什么程度:plan / workspace / full。
//
// 与 MaxRounds 同理,**仅在本次投递【新建】会话时生效**:续谈已有会话若也接受
// 这个字段,每封新信都会悄悄改掉对方正在遵守的规则 —— 而 plan 档的会话里
// 模型已经被告知「只许看」,第二封信把它改成 full 是在一段已有上下文里换规则。
// 续谈请用 PUT /sessions/{id}/permission(对话页里可随时改)。
//
// 省略时用 models.DefaultPermissionMode(workspace)。
PermissionMode string `json:"permission_mode"`
}
// POST /api/v1/me/mail/send
func MeSendMail(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
var req meSendMailRequest
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
}
// 附件可挂性必须在**建邮件之前**校验(与 Agent 侧同理,见 mail.go)。
if !checkAttachable(w, r, attachIDs, user.Username) {
return
}
// human@ 是兼容别名,人类发信时解析为自己
to = resolveHumanAlias(to, user.Username)
for i := range ccList {
ccList[i] = resolveHumanAlias(ccList[i], user.Username)
}
// 权限边界:校验可调用的 Agent 与可访问的目录
if msg := checkScope(r, user, append([]models.Address{to}, ccList...)); msg != "" {
Error(w, http.StatusForbidden, msg)
return
}
// 可达性:收件人必须存在且未停用,否则邮件进黑洞
if !checkDeliverable(w, r, append([]models.Address{to}, ccList...)) {
return
}
// 纯输入校验必须在建会话【之前】做完。
//
// 原来两项校验都在 resolveTarget 之后:请求返回 400,但 `.new` 已经建好了
// 会话、占掉了新建速率名额、并留下一条谁也不会再用的空线索。实测发 5 封
// 非法请求就攒下 5 条垃圾会话。校验不依赖会话,本来就该先做。
rounds := -1
if req.MaxRounds != nil {
if *req.MaxRounds < 0 {
Error(w, http.StatusBadRequest, "max_rounds 不能为负")
return
}
rounds = *req.MaxRounds
}
if !validPermissionModeInput(w, req.PermissionMode) {
return
}
sessionID, parentMailID, parentFrom, created, err := resolveTarget(r, to, req.ReplyTo, user.Username, req.Subject, req.SessionAlias, "")
if err != nil {
writeErr(w, err, "Failed to resolve session")
return
}
// 人类发起的会话归属于该用户
_ = repo.SetSessionOwner(r.Context(), sessionID, user.ID)
// ★ 2026-10-01:人类在**这条路径**上插话也要立刻解除 Agent 回路冷静期。
//
// 我第一版只把解锁写在 Agent 侧 `SendMail` 里,而**人类根本不走那条路**
// —— 端到端实测:WebUI 用户经 `/me/mail/send` 插话返回 200(人类的信
// 本来就不该被回路闸拦,这是对的),可 `session_agent_locks` 那行**还在**。
//
// 于是错误文案里那句「若要立刻恢复,请由人类在会话里插一句话」是**假的**:
// 人类插了话,锁却要等满 2 小时。而这条文案正是我自己在 403 里写的。
//
// 放在 SetSessionOwner 旁边:那是「人类参与这条会话」的权威落点,
// 比在每个调用点各写一遍解锁可靠(漏一处就是一句假承诺)。
if _, err := repo.SessionLockTouch(r.Context(), sessionID); err != nil {
// 解锁失败不该拦下人类的邮件 —— 人的来信优先入库。
// 代价只是那把锁要到期才消(保守方向,不放宽任何闸)。
log.Printf("[agent-lock] 人类插话后解锁失败(锁将到期自消): %v", err)
}
// 新建会话时定往返预算。只在新建时设:续谈已有会话若也接受这个字段,
// 每封新信都会悄悄改掉对方正在遵守的预算,人却不一定意识到自己改了。
//
// 没显式给就用【收件 Agent 的默认值】。默认值挂在 Agent 上而不是全站一个数:
// 跑测试的小工具与重构整个模块的 Agent,合理来回数差一个量级。
// 判据是 `created` 而不是 `parentMailID == nil`:后者在「省略 session 位复用
// 默认会话」时也成立,于是第二封信会把对方正在遵守的预算改写成默认值
//(实测:max_rounds=7 的会话被第二封省略该字段的信改成 20)。
if created {
if rounds < 0 {
rounds = repo.DefaultRoundsFor(r.Context(), to.Name)
}
if _, err := repo.SetSessionBudget(r.Context(), sessionID, rounds); err != nil {
Error(w, http.StatusInternalServerError, "Failed to set session budget")
return
}
// 权限档位同样只在新建时定。人可以直接指定(不继承)—— 人就是权限的源头,
// 而 Agent 侧的 SendMail 走 InheritedMode 不得自行抬档。
mode := models.NormalizePermissionMode(req.PermissionMode)
if _, err := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); err != nil {
Error(w, http.StatusInternalServerError, "Failed to set permission mode")
return
}
// 强制力是事实快照:按收件 Agent 当下自报的能力定死。
// 收件方是人类时也走这里 —— AgentModeEnforcement 查不到就返回 advisory,
// 而人的收件箱本来不执行任何档位,这个值对他无意义也无害。
_ = repo.SetSessionEnforcement(r.Context(), sessionID,
repo.AgentModeEnforcement(r.Context(), to.Name))
}
// 续谈已有会话时,人也可以显式改档位。人是权限的源头,
// 可以任改三档——与 Agent 不同,人没有「只能同档或更严」的约束。
if !created && req.PermissionMode != "" {
mode := models.NormalizePermissionMode(req.PermissionMode)
if _, err := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); err != nil {
Error(w, http.StatusInternalServerError, "Failed to update permission mode")
return
}
_ = repo.SetSessionEnforcement(r.Context(), sessionID,
repo.AgentModeEnforcement(r.Context(), to.Name))
}
// 人类侧不产生改名提议(人直接有改名按钮,用不着向自己提议),
// 但仍然剥掉标记:粘贴进正文时它会被渲染成一行可见的转义文本。
_, body := extractRenameProposal(req.Body)
mailID, err := repo.CreateMail(r.Context(), sessionID, parentMailID,
user.Username, "", to.Name, to.Path, req.Subject, body, ccList)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to create mail")
return
}
if !attachAll(w, r, mailID, attachIDs, user.Username) {
// 竞态窗口(见 mail.go 同位置):回滚那封已入库的邮件。
// 人类发信不扣会话预算、也不走 relay,所以只需退邮件本身。
_ = repo.DeleteMailByID(r.Context(), mailID)
return
}
notifyRecipients(r.Context(), to, ccList, sessionID, mailID, user.Username, req.Subject, parentIDString(parentMailID), parentFrom)
resp := map[string]any{
"mail_id": mailID.String(),
"session_id": sessionID.String(),
"session_alias": repo.SessionAliasOf(r.Context(), sessionID),
}
// 回传预算,让前端不必再单独查一次就能显示「本任务还剩几个来回」
if b, err := repo.GetSessionBudget(r.Context(), sessionID); err == nil && !b.Unlimited {
resp["budget_max"] = b.Max
resp["budget_used"] = b.Used
resp["budget_remaining"] = b.Remaining
}
JSON(w, http.StatusOK, resp)
}
// GET /api/v1/me/mail/inbox
func MeGetInbox(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
status := r.URL.Query().Get("status")
if status == "" {
status = "all"
}
limit := 50
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(), user.Username, status, "", limit)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list inbox")
return
}
// 列表页要显示附件图标与下载入口
ptrs := make([]*models.Mail, len(mails))
for i := range mails {
ptrs[i] = &mails[i]
}
fillAttachments(r, ptrs...)
total, _ := repo.CountUnread(r.Context(), user.Username, "")
JSON(w, http.StatusOK, map[string]interface{}{
"mails": emptySlice(mails),
"total": total,
})
}
// GET /api/v1/me/mail/sent
func MeGetSent(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
limit := 50
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := parseInt(l); err == nil && n > 0 {
limit = n
}
}
mails, err := repo.ListSentBy(r.Context(), user.Username, limit)
if err == nil {
ptrs := make([]*models.Mail, len(mails))
for i := range mails {
ptrs[i] = &mails[i]
}
fillAttachments(r, ptrs...)
}
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list sent")
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"mails": emptySlice(mails),
})
}
// GET /api/v1/me/sessions
func MeGetSessions(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
scope := user.Username
if user.IsAdmin() && r.URL.Query().Get("all") == "true" {
scope = ""
}
sessions, err := repo.ListSessionsFor(r.Context(), scope, 50)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list sessions")
return
}
type SessionOut struct {
SessionID uuid.UUID `json:"session_id"`
SessionAlias *string `json:"session_alias"`
FromAgent string `json:"from_agent"`
Subject string `json:"subject"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MailCount int `json:"mail_count"`
UnreadCount int `json:"unread_count"`
// 往返预算随列表一并返回:预算是【任务】的属性,
// 工作列表上就应当看得见哪些任务快跑满了,
// 而不是点进去一个一个查。
MaxRounds int `json:"max_rounds"`
UsedRounds int `json:"used_rounds"`
}
result := make([]SessionOut, 0, len(sessions))
for _, s := range sessions {
unread, _ := repo.CountUnreadInSession(r.Context(), user.Username, s.ID)
result = append(result, SessionOut{
SessionID: s.ID,
SessionAlias: s.Alias,
FromAgent: s.FromAgent,
Subject: s.Subject,
Status: s.Status,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
MailCount: s.MailCount,
UnreadCount: unread,
MaxRounds: s.MaxRounds,
UsedRounds: s.UsedRounds,
})
}
JSON(w, http.StatusOK, map[string]interface{}{
"sessions": result,
})
}
// resolveHumanAlias 把兼容别名 human 解析为具体用户名
func resolveHumanAlias(a models.Address, username string) models.Address {
if a.Name != "human" {
return a
}
a.Name = username
a.Raw = username + "@" + a.Path
if a.Session != "" {
a.Raw += "." + a.Session
}
return a
}
// checkScope 校验用户的 Agent 白名单与目录白名单;返回空串表示通过。
// 收件方是人类用户时不受 Agent 白名单约束(人与人通信始终允许)。
func checkScope(r *http.Request, user *models.User, addrs []models.Address) string {
if user.IsAdmin() {
return ""
}
for _, a := range addrs {
if a.Name == "" || a.Name == user.Username {
continue
}
isHuman, err := repo.IsHumanUser(r.Context(), a.Name)
if err != nil {
return "无法校验收件人权限"
}
if !isHuman && !user.CanUseAgent(a.Name) {
return "无权调用 Agent: " + a.Name
}
if !user.CanUsePath(a.Path) {
return "无权访问目录: " + a.Path
}
}
return ""
}
// checkDeliverable 校验每个收件人(含拄送)当前能不能收信,写好响应并返回 false 表示已拒绝。
//
// 拄送位同样要查:不查的话 cc 就成了绕过口 —— 把已删除的 Agent 放到 cc 位
// 依旧能把邮件送进黑洞,而且因为不是主收件人更不容易被发现。
func checkDeliverable(w http.ResponseWriter, r *http.Request, addrs []models.Address) bool {
for _, a := range addrs {
err := repo.RecipientDeliverable(r.Context(), a.Name)
switch {
case err == nil:
continue
case errors.Is(err, repo.ErrRecipientUnknown):
Error(w, http.StatusNotFound,
"收件人不存在:"+a.Name+"。它既不是人类用户也不是已注册的 Agent(可能已被删除)。")
return false
case errors.Is(err, repo.ErrRecipientDisabled):
Error(w, http.StatusConflict,
"Agent \""+a.Name+"\" 已被管理员停用,现在不接收新任务。请先在管理页恢复它。")
return false
default:
Error(w, http.StatusInternalServerError, "无法校验收件人状态")
return false
}
}
return true
}