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 非空时必填 // FromSessionID 是发信时模型所处的邮件会话 id(即「这活是谁派给我的」)。 // // **只用于权限档位继承**:Agent 新开一条会话时,新会话不得比它所在 // 的那条会话更宽松。注意这里**没有** permission_mode 字段 —— 那是有意的: // 让 Agent 自己指定档位等于发一封 mode=full 的信就能提权。 // // 省略时回落到默认档(不是 full)。插件担不担得起传这个值不影响安全下限: // 没传 = 拿默认档,不会因此拿到更大的权限。 FromSessionID string `json:"from_session_id"` } // 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 开一串会话就等于绕过预算。 // 人类不受此限(手工点「新建邮件」的频率天然受限,加限制只会在批量派活时误伤)。 // resolveTarget 依据地址的 session 位定位(或新建)会话。 // // 返回值:会话 id / 父邮件 id(仅 reply_to 路径非 nil)/ **created** / 错误。 // // created 为真**仅**表示这次调用真的新建了一条会话。它存在的理由是: // `parentMailID == nil` 曾被当作「新建会话」的判据,而那是错的 —— // 省略 session 位复用默认会话时 parentMailID 也是 nil。实测后果: // 第一封信 `max_rounds=7`,第二封信省略该字段,会话预算被静默改成 20。 // 「只在新建时生效」的字段(往返预算、权限档位)必须靠这个返回值判断, // 否则每封新信都在改写对方正在遵守的规则。 func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, subject, alias string, byAgent string) (uuid.UUID, *uuid.UUID, bool, error) { if replyTo != "" { replyID, err := uuid.Parse(replyTo) if err != nil { return uuid.Nil, nil, false, errBadRequest("Invalid reply_to UUID") } mail, err := repo.GetMailByID(r.Context(), replyID) if err != nil { return uuid.Nil, nil, false, errNotFound("Parent mail not found") } repo.TouchSession(r.Context(), mail.SessionID) return mail.SessionID, &replyID, false, 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, false, err } if _, err := repo.FindSessionByAlias(r.Context(), a); err == nil { return uuid.Nil, nil, false, 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, false, 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, false, 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, true, nil case models.SessionDefault: // 默认会话「从未通信则建立」也会产生新会话,但一个 name@path 只有一条, // 不构成暴开的手段,因此不计入速率限制。 // // created 必须区分「这次建了」与「复用了既有的那条」:两者在这里都返回 // parentMailID == nil,靠它判断会把续谈误当新建(预算与档位被静默改写)。 id, created, err := repo.FindOrCreateDefaultSessionCreated(r.Context(), addr.Name, addr.Path, fromAgent, subject) if err != nil { return id, nil, false, err } // 默认会话同样需要可寻址的别名:省略 session 位能投进来,但要**指名** // 投进这一条(而不是「该 name@path 当前的默认会话」)仍然只能靠别名。 // 已有别名时 EnsureSessionAlias 直接返回,复用旧会话不会被改名。 _, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject)) return id, nil, created, 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, false, nil } if !errors.Is(err, repo.ErrSessionNotFound) { return uuid.Nil, nil, false, err } // 本侧没有这条别名 —— 再看平台会话镜像。 // // TUI 与邮箱是同一个 Agent 的两个入口,人在平台界面上开的会话 // 早就被补全列为候选(agent_platform_sessions),此前投递侧却没有 // 这一跳,选中后只能得到 404 —— 候选列表在承诺一件做不到的事。 // // 命中就**接管**它:本侧建一条会话并绑定 platform_id,插件收到投递 // 事件时据此 resume 那条平台会话而不是新建。 // 接管**是**新建本侧会话(绑定了 platform_id 的那条), // 所以 created 为真:它此前没有档位与预算,需要按这次投递定下来。 if adopted, aErr := adoptFromPlatform(r, addr, fromAgent, subject, byAgent); aErr == nil { return adopted, nil, true, nil } else if !errors.Is(aErr, repo.ErrSessionNotFound) { return uuid.Nil, nil, false, aErr } return uuid.Nil, nil, false, 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 } // 附件可挂性必须在**建邮件之前**校验。 // // 原先只在 CreateMail 之后调 attachAll,于是附件不合法时返回 403/409, // 但那封邮件已入库、已通知收件人、已扣预算(生产实测两封探针邮件均如此)。 // 发件方看到 4xx 会重试,收件方于是收到两封。 if !checkAttachable(w, r, attachIDs, agentName) { return } // 可达性:收件人必须存在且未停用。Agent 侧同样要查 —— // 模型拿到 200 就会当作「话已传到」并停手等对方,而那封信永远不会有人读。 if !checkDeliverable(w, r, append([]models.Address{to}, ccList...)) { return } sessionID, parentMailID, created, err := resolveTarget(r, to, req.ReplyTo, agentName, req.Subject, req.SessionAlias, agentName) if err != nil { writeErr(w, err, "Failed to resolve session") return } // 收件方是 Agent 且地址里没写 path 位时,用**会话的 workspace** 补上。 // // # 为什么必须补 // // `to_workspace` 是插件唯一能知道「这个任务该在哪个目录干活」的入口。 // 而 Agent 之间的回信、以及人在对话页里点回复,地址里通常没有 path 位 // —— 平台自己下发的 `reply_address` 就是这个形状。空着传下去,插件只能 // 自己拼一个临时目录:**每封邮件一个不同的空目录**,DSH / opencode 按 // cwd 分组,界面上于是「每处理一封邮件就多出一条未分组会话」,而模型在 // 空目录里什么项目文件也看不到。 // // 会话的 workspace 才是权威来源(见 models.SessionWorkspace 的注释): // 回信本来就是回给**那条会话**的,而那条会话知道自己属于哪个项目。 // // # 为什么改 to 而不是只改建库那一行 // // 同一个值还进 `notifyRecipients` 的投递载荷(`to_workspace` / // `self_address`)。改一处另一处不改,就会出现「API 读到的与插件推到的 // 不是同一个目录」—— 那正是本项目一直在治的静默不一致。 sessionWorkspace := repo.SessionWorkspaceOf(r.Context(), sessionID) toIsHuman, _ := repo.IsHumanUser(r.Context(), to.Name) to.Path = resolveToWorkspace(to.Path, sessionWorkspace, toIsHuman) // Agent 新开的会话继承权限档位,**不得自行抬档**。 // // req 里根本没有 permission_mode 字段 —— 这是有意的:Agent 能指定档位 // 就等于发一封 mode=full 的信给自己提权。档位由发信方当前所处的会话 // (也就是「这活是谁派给我的」)推导,且只能同档或更严。 // // 这保证 plan 档的任务派不出 full 档的子任务 —— 与 hop_limit 防自激同形: // 约束必须沿着链条传递下去,否则一跳之后就失效了。 // 判据是 `created`:省略 session 位复用默认会话时 parentMailID 也是 nil, // 用后者会让每一封续谈的信重新“继承”一次 —— 而那条会话的档位可能已经 // 被人在对话页里改过,重继承等于把人的修改静默回滚。 if created { // 发信方自己那条会话的档位是上限。插件没传 from_session_id 时 // 回落到默认档 —— 不会因为没传而拿到更大的权限。 var parent *uuid.UUID if req.FromSessionID != "" { if pid, pErr := uuid.Parse(req.FromSessionID); pErr == nil { parent = &pid } } mode := repo.InheritedMode(r.Context(), parent, models.DefaultPermissionMode) if _, sErr := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); sErr != nil { Error(w, http.StatusInternalServerError, "Failed to set permission mode") return } _ = repo.SetSessionEnforcement(r.Context(), sessionID, repo.AgentModeEnforcement(r.Context(), to.Name)) } // 配额在建邮件之前扣:否则邮件已入库再报 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) { // 走到这里说明碰上了 checkAttachable 之后的竞态窗口(另一个请求把同一个 // 附件挂走了)。必须回滚已产生的副作用,否则收件方会拿到一封没有附件的 // 邮件,而发件方以为整次请求失败了。 // // 三件事都要退:邮件本身、本次往返预算、relay 幂等键。 // 错误均忽略:响应已由 attachAll 写出,回滚失败只能记日志。 _ = repo.DeleteMailByID(r.Context(), mailID) if !relayFree { repo.RefundSessionBudget(r.Context(), sessionID) } if relay != "" { _ = repo.ReleaseRelay(r.Context(), agentName, relayKey) } return } notifyRecipients(r.Context(), to, ccList, sessionID, mailID, agentName, req.Subject, parentIDString(parentMailID)) // 回传会话别名与本任务剩余往返,让发件方知道后续用什么地址续谈、还能发几封 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 各写一份, // 加字段时漏改一处直接造成生产事故(详见那个包的注释)。 // // parentMailID 为空字串表示这不是回信。 func notifyRecipients(ctx context.Context, to models.Address, cc []models.Address, sessionID, mailID uuid.UUID, from, subject, parentMailID string) { notify.Recipients(ctx, notify.Mail{ SessionID: sessionID, MailID: mailID, From: from, To: to, CC: cc, Subject: subject, ParentMailID: parentMailID, }) } // 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 } } // 可选会话收窄:桥的 read_inbox 会带上自己那条会话。 // // 不带 = 整个 Agent 的收件箱(旧语义,浏览器/脚本仍可用);带了就只列这条线索 —— // 否则 A 会话的 worker 会把 B 会话的未读也列出来并标成已读,桥重启后的补投 // 判据(?status=unread)就再也看不到那封信(用户报的"都能看到全部邮件")。 var sessionID uuid.UUID if raw := r.URL.Query().Get("session_id"); raw != "" { id, perr := uuid.Parse(raw) if perr != nil { Error(w, http.StatusBadRequest, "非法的 session_id") return } sessionID = id } mails, err := repo.ListInboxScoped(r.Context(), agentName, status, limit, sessionID) 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 } // 已读是**按读者**记的(见 repo.markReadFor 的说明):这里传当前登录用户, // 而不是把邮件行上的全局 status 一改 —— 那会让抄送方读掉的信从别人未读里消失。 if err := repo.MarkMailRead(r.Context(), mailID, user.Username); 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 { // SessionID 可选:带了就只在这条会话内标(见 GetInbox 里那段说明)。 SessionID string `json:"session_id"` // 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 { var sessionID uuid.UUID scope := "all" if raw := strings.TrimSpace(req.SessionID); raw != "" { id, perr := uuid.Parse(raw) if perr != nil { Error(w, http.StatusBadRequest, "非法的 session_id") return } sessionID, scope = id, "session" } n, err := repo.MarkAllInboxReadForSession(r.Context(), agentName, sessionID) if err != nil { Error(w, http.StatusInternalServerError, "Failed to mark read") return } JSON(w, http.StatusOK, map[string]any{"status": "read", "marked": n, "scope": scope}) 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), }) } // parentIDString 把可空的父邮件 id 转成字符串(nil → 空串)。 // // 空串在 SSE payload 里的语义是「这不是回信」—— 插件据此选提示词。 func parentIDString(id *uuid.UUID) string { if id == nil { return "" } return id.String() }