Files
MailUI4Agents/gateway/internal/handler/me.go
JianFeeeee a44fd6949b feat: 权限档位体系(三档 plan/workspace/full + 四桥 from_session_id)
L2 核心改动:sessions 表补 permission_mode / permission_enforcement 两列
(sqlite + pg 同步),三桥 lib/permission-mode.js 翻译档位到平台原生配置,
homeagent advisory 模式提示词告知模型实际强制力。四桥全部携带 from_session_id
供 relay 去重与会话回溯。

FromHuman / ToHuman 判据已加入心跳 payload 与 notify/mail.go。
2026-09-06 15:16:49 +08:00

381 lines
13 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"
"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.DefaultPermissionModeworkspace
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))
}
// 人类侧不产生改名提议(人直接有改名按钮,用不着向自己提议),
// 但仍然剥掉标记:粘贴进正文时它会被渲染成一行可见的转义文本。
_, 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
}