Files
MailUI4Agents/gateway/internal/handler/mail.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

693 lines
28 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 (
"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.<alias> 续谈。命中已有会话时该字段被忽略。
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"` // 上游消息 idrelay 非空时必填
// FromSessionID 是发信时模型所处的邮件会话 id即「这活是谁派给我的」
//
// **只用于权限档位继承**Agent 新开一条会话时,新会话不得比它所在
// 的那条会话更宽松。注意这里**没有** permission_mode 字段 —— 那是有意的:
// 让 Agent 自己指定档位等于发一封 mode=full 的信就能提权。
//
// 省略时回落到默认档(不是 full。插件担不担得起传这个值不影响安全下限
// 没传 = 拿默认档,不会因此拿到更大的权限。
FromSessionID string `json:"from_session_id"`
}
// resolveTarget 根据三维地址 name@path.session 决定投递的会话。
//
// session 位三态语义(设计文档):
// - 省略pi@root → 投递到 name@path 的默认会话;从未通信则建立
// - newpi@root.new → 强制新建一个会话
// - 具体别名pi@root.fix-leak→ 必须已存在且该收件人参与过,否则 404 无法送达
//
// alias 为新建会话命名(仅新建时生效),使其之后可被 name@path.<alias> 寻址。
// 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.<alias> 续谈。
// 别名全局唯一(负责寻址),已被占用时报 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 新开的会话继承权限档位,**不得自行抬档**。
//
// 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 可以在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
// 标记从入库正文里剥掉:它是给系统看的元数据,不该出现在人读的正文里
// 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
}
}
mails, err := repo.ListInbox(r.Context(), agentName, status, limit)
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
}
if err := repo.MarkMailRead(r.Context(), mailID); 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 {
// 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 {
n, err := repo.MarkAllInboxReadFor(r.Context(), agentName)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to mark read")
return
}
JSON(w, http.StatusOK, map[string]any{"status": "read", "marked": n, "scope": "all"})
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()
}