Files
MailUI4Agents/gateway/internal/handler/mail.go
JianFeeeee 344f970353 修死信黑洞:发信前校验收件人可达性
## 事故

发给已彻底删除的 Agent 返回 200:邮件入库、分配 20 个来回预算、
建好会话,而那一端永远不会有人读。发件人看到 200 和一个 session_id,
以为送出去了。

实测(修复前):
  POST /me/mail/send to=remotebot@/tmp  → 200
  mail_id 53e4c9ea… session dc8a41c3… budget_max 20

remotebot 的 agents 行在本会话早前已被 DELETE /admin/agents 删掉。

根因:三个发信入口的检查链只有「地址语法 / 调用权限 / 会话别名」,
从不问「这个名字存在吗」。`AgentDisabled` 那个函数只在注册路径被调用,
发信路径压根不查——它的注释甚至写着「Agent 不存在时返回 false」。

静默丢件比报错严重:报错能立刻改,静默丢件要等对方追问才发现。
这与之前修过的「relay_key 400 被当暂时失败导致静默挂死」同类。

## 修法

`repo.RecipientDeliverable(ctx, name)` 作为唯一判据:
- 人类用户 → 放行(人的收件箱一直在,不受 Agent 停用影响)
- Agent 在册且未停用 → 放行
- Agent 不存在 → ErrRecipientUnknown → 404
- Agent 已停用 → ErrRecipientDisabled → 409

`handler.checkDeliverable` 把它接到三个入口,**收件人与抄送位一起查**:
不查 cc 的话抄送位就成了绕过口,而且因为不是主收件人更不容易被发现。

- me.go   MeSendMail   (人类发信)
- mail.go SendMail     (Agent 发信)
- forward.go ForwardMail(人与 Agent 两条转发路径共用)

停用选择「当场拒收」而非「入库等恢复后补投」:停用的语义就是这个
Agent 现在不干活,让发件人以为信已送达更坏——它会照常等回信。

## 测试

`internal/repo/deliverable_test.go` 8 例:人类 / 在线 Agent / 不存在 /
删除后不可达 / 停用 409 / 恢复后重新可达 / 空名放行 /
同名人类优先于已停用 Agent。

负向对照:让 RecipientDeliverable 无条件 return nil(还原事故前行为),
UnknownName、AfterDelete、Disabled 三例如期失败。

## 线上验证

  发给已删除 remotebot        → 404「收件人不存在」
  发给在线 pi                → 200,pi 回信「可达性 OK」
  cc 位放已删除 remotebot     → 404(绕过口已封)
  停用 pi 后发信             → 409「已被管理员停用」
  恢复 pi 后发信             → 200

## 顺带

- 部署改用 sqlite3 .backup + install -m 0755(原子 rename,不写坏
  运行中进程镜像),来自 git-release-discipline skill 的运维纪律
- 清理本会话测试残留:误登记的 opencode 密钥、remotebot 两把残留密钥、
  死信测试邮件与会话
2026-09-05 09:58:36 +08:00

617 lines
23 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 非空时必填
}
// 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 开一串会话就等于绕过预算。
// 人类不受此限(手工点「新建邮件」的频率天然受限,加限制只会在批量派活时误伤)。
func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, subject, alias string, byAgent string) (uuid.UUID, *uuid.UUID, error) {
if replyTo != "" {
replyID, err := uuid.Parse(replyTo)
if err != nil {
return uuid.Nil, nil, errBadRequest("Invalid reply_to UUID")
}
mail, err := repo.GetMailByID(r.Context(), replyID)
if err != nil {
return uuid.Nil, nil, errNotFound("Parent mail not found")
}
repo.TouchSession(r.Context(), mail.SessionID)
return mail.SessionID, &replyID, 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, err
}
if _, err := repo.FindSessionByAlias(r.Context(), a); err == nil {
return uuid.Nil, nil, 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, 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, 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, nil
case models.SessionDefault:
// 默认会话「从未通信则建立」也会产生新会话,但一个 name@path 只有一条,
// 不构成暴开的手段,因此不计入速率限制。
id, err := repo.FindOrCreateDefaultSession(r.Context(), addr.Name, addr.Path, fromAgent, subject)
if err != nil {
return id, nil, err
}
// 默认会话同样需要可寻址的别名:省略 session 位能投进来,但要**指名**
// 投进这一条(而不是「该 name@path 当前的默认会话」)仍然只能靠别名。
// 已有别名时 EnsureSessionAlias 直接返回,复用旧会话不会被改名。
_, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject))
return id, nil, 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, nil
}
if !errors.Is(err, repo.ErrSessionNotFound) {
return uuid.Nil, nil, err
}
// 本侧没有这条别名 —— 再看平台会话镜像。
//
// TUI 与邮箱是同一个 Agent 的两个入口,人在平台界面上开的会话
// 早就被补全列为候选agent_platform_sessions此前投递侧却没有
// 这一跳,选中后只能得到 404 —— 候选列表在承诺一件做不到的事。
//
// 命中就**接管**它:本侧建一条会话并绑定 platform_id插件收到投递
// 事件时据此 resume 那条平台会话而不是新建。
if adopted, aErr := adoptFromPlatform(r, addr, fromAgent, subject, byAgent); aErr == nil {
return adopted, nil, nil
} else if !errors.Is(aErr, repo.ErrSessionNotFound) {
return uuid.Nil, nil, aErr
}
return uuid.Nil, nil, 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
}
// 可达性收件人必须存在且未停用。Agent 侧同样要查 ——
// 模型拿到 200 就会当作「话已传到」并停手等对方,而那封信永远不会有人读。
if !checkDeliverable(w, r, append([]models.Address{to}, ccList...)) {
return
}
sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, agentName, req.Subject, req.SessionAlias, agentName)
if err != nil {
writeErr(w, err, "Failed to resolve session")
return
}
// 配额在建邮件之前扣:否则邮件已入库再报 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) {
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()
}