package handler import ( "errors" "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, 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) // 新建会话时定往返预算。只在新建时设:续谈已有会话若也接受这个字段, // 每封新信都会悄悄改掉对方正在遵守的预算,人却不一定意识到自己改了。 // // 没显式给就用【收件 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)) 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 }