修死信黑洞:发信前校验收件人可达性
## 事故 发给已彻底删除的 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 两把残留密钥、 死信测试邮件与会话
This commit is contained in:
@ -119,6 +119,11 @@ func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor,
|
||||
}
|
||||
}
|
||||
|
||||
// 可达性:转发目标必须存在且未停用。人与 Agent 两条转发路径共用这道检查。
|
||||
if !checkDeliverable(w, r, append([]models.Address{to}, ccList...)) {
|
||||
return
|
||||
}
|
||||
|
||||
subject := forwardSubject(req.Subject, src.Subject)
|
||||
|
||||
// 转发按目标地址寻址,不带 reply_to:它是一条新线索,不该并进原会话
|
||||
|
||||
@ -236,6 +236,12 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
|
||||
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")
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@ -76,6 +77,11 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 可达性:收件人必须存在且未停用,否则邮件进黑洞
|
||||
if !checkDeliverable(w, r, append([]models.Address{to}, ccList...)) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, user.Username, req.Subject, req.SessionAlias, "")
|
||||
if err != nil {
|
||||
writeErr(w, err, "Failed to resolve session")
|
||||
@ -302,3 +308,29 @@ func checkScope(r *http.Request, user *models.User, addrs []models.Address) stri
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user