## ① 卡片视图(WorkCard)
上一轮只接了列表视图 —— 但用户原话是「形成/展示为树结构」,
只在一个视图里成立不算:**切一下视图,线索树就没了**。
缩进比列表更紧(8px/级、上限 3 级):卡片本来就有三块内容
(发件人行 / 主题 / 摘要 + 预算条),400px 侧栏里每级 12px
挤掉的是**摘要本身** —— 摘要没了,卡片就只剩一个标题。
判据加一格专门盯「两个视图都接了」,否则这格缺口会一直没人看。
## ② C 层:admin 全量邮件(scope=all)
GET /api/v1/me/mail/inbox?scope=all admin 才有效
GET /api/v1/me/mail/inbox 默认,仍是自己收件箱
三条边界,两个变异都转红:
| | 行为 | 变异后 |
|---|---|---|
| 非 admin 要 scope=all | **403** | 静默降级 200 ⇒ 转红 |
| 默认(无 scope) | 自己的,一封不多 | 身份隐式全给 ⇒ 转红 |
| 非法 scope | 400 | — |
**静默降级是最危险的那个**:调用方会以为拿到了全量(实际没有)——
「看起来能用的错答案」,比报错难查得多。
**默认不给 admin 全量**:全看必须显式要求,不能靠身份隐式获得。
### 为什么单独写 ListAllMails,没给 ListInboxScoped 加参数
`ListInboxScoped` 的第一个参数 `agentName` 兼任两职:
① SQL 里的 reader 过滤
② `readStateFor("$1")` 算 status(已读/未读是**按读者**记的)
两者都必须有值 —— `requireReader` 就是为此存在。
若把「全量」做成「reader 传空」,那个非法状态看起来就合法了;
一旦放进去,**status 会静默变成未读** —— 一个没人会注意到、
却让「已读/未读」全面失真的坑。
同理,admin 全量视图里 `status=unread` **明确报错**而不是返回全部 ——
后者会让前端把整箱染成"未读"。admin 不是任何一封信的读者。
### scanMailRows 提取(repo.go +16/-0,纯新增)
第二份手写扫描副本的第一个分叉点必然是「admin 视图少算一个派生字段」,
而那在前端表现为某个徽标不见了,极难归因。⇒ 两处共用一份,
`ListInboxScoped` 行为一行未改。
## ★ 这不触碰 Agent 侧的收窄
`ListInboxScoped` 里 `to_name = reader OR cc` 那条是 **AgentAuth** 用的,
是 15e4fe9 / 095213b 修出来的越权防护。`scope=all` 是**人类登录态**下
admin 的显式全量视图,两条通道互不相干 —— 语义不同,不要混谈。
## 判据自己错了一次
`TestAdminScopeAllSeesEverything` 红在 500,报
`Scan: invalid UUID length: 7` —— 看着像 SQL/扫描代码坏了,
其实是判据自己的数据不对(`mail_id` 是 UUID,我塞了 `"m-admin"`)。
**判据数据错了会伪装成被测代码坏了。**
14 包全绿;Electron 带改动 fail 9 / 基线 fail 12(减少 3 红、无新红,
剩下的是其他会话 harmony 设备判据的红)。
442 lines
16 KiB
Go
442 lines
16 KiB
Go
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
|
||
}
|
||
}
|
||
|
||
// scope=all:admin 全量视图(用户 2026-10-04 定的分层可见性)。
|
||
//
|
||
// ★ 为什么是「同一个 URL 加参数」而不是新端点:
|
||
// 可见性收窄集中在一处判断,少一个端点就少一处将来忘了收窄的地方。
|
||
//
|
||
// ★ 普通用户显式请求 scope=all 必须 **403**,不能静默降级回自己的收件箱 ——
|
||
// 静默降级会让调用方以为拿到了全量(实际没有),
|
||
// 那是「看起来能用的错答案」,比报错难查得多。
|
||
scope := r.URL.Query().Get("scope")
|
||
mails, err := func() ([]models.Mail, error) {
|
||
if scope == "" || scope == "me" {
|
||
return repo.ListInbox(r.Context(), user.Username, status, "", limit)
|
||
}
|
||
if scope != "all" {
|
||
Error(w, http.StatusBadRequest, "scope 只能是 me 或 all")
|
||
return nil, errAlreadyAnswered
|
||
}
|
||
if !user.IsAdmin() {
|
||
// ★ fail-closed:非 admin 要全量 ⇒ 拒绝,**不回退**到自己的收件箱。
|
||
// 静默降级会让调用方以为拿到了全量(实际没有)——
|
||
// 那是「看起来能用的错答案」,比报错难查得多。
|
||
Error(w, http.StatusForbidden, "scope=all 需要 admin 权限")
|
||
return nil, errAlreadyAnswered
|
||
}
|
||
return repo.ListAllMails(r.Context(), status, limit)
|
||
}()
|
||
if err != nil {
|
||
if errors.Is(err, errAlreadyAnswered) {
|
||
return // 4xx 已写,不再覆写成 500
|
||
}
|
||
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,
|
||
})
|
||
}
|
||
|
||
// errAlreadyAnswered 表示「响应已经写完了,别再覆写成 500」。
|
||
var errAlreadyAnswered = errors.New("already answered")
|
||
|
||
// 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
|
||
}
|