## 形状
`DecidePermission` 的函数头承诺「权限邮件对他(决策人)也应当变成已读」,
但那两段记账都被 `if decider != ""` 包着。而**未读判据是 mail_reads**
(`unreadFor` / `readStateFor`),不是行级那列。⇒ decider 为空时:
mails.status = 'read' (全局冗余列改了)
mail_reads —— 没有这一行 (事实表没记)
⇒ 收件人在收件箱里**永远看到这封未读**,尽管它早已决策完。
## 线上实证
13 封这样的历史邮件,收件人均为 jianf,2026-09-08 / 09-13,主题全是
「权限请求: 请求执行 bash」—— 每一次都已被人类决策过(同意/拒绝)。
## 为什么今天 HTTP 路径触发不了
`permission.go` 会沿会话树上溯找人类(`NearestHumanInThread`),
HTTP 层传的也是 `user.Username`。但 repo 层是**所有调用方的门**,
`MarkMailRead` 早就为同一件事挡了(reader 是必填语义),只挡它一个等于漏网。
静默跳过正是 `if decider != ""` 的写法问题:它把「不知道谁读的」记成「不用记」。
## 同族核对(不是只看这一处)
全 internal/ 树扫 `UPDATE mails SET status ... 'read'`,共 4 处,
逐处核对其前置 45 行是否有 mail_reads 记账:
MarkMailRead ✓ / MarkMailsReadFor ✓ / MarkAllInboxReadForSession ✓ / DecidePermission ✗
⇒ 唯一缺口就是这一处。事实表本身干净:无空读者、无孤儿行。
## 判据
新增 2 格:空 decider 报错 / 决策后 mail_reads 必有记录且派生读数为 read。
两个变异都经得起(去掉报错守卫、去掉 mail_reads 写入,各红一格)。
## 顺带记一个判据的坑
`mail_status_readers_test.go` 的 readRe 是**纯文本**匹配 `mails.status`,
会把我注释里提到该列名也算成「新增一处读取」,导致那条清册判据变红。
已在注释里避开那个写法并写明原因 —— 改这块代码时若它突然变红,先看是不是注释。
237 lines
7.2 KiB
Go
237 lines
7.2 KiB
Go
package repo
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// seedMailTo 造一封给 recipient 的未读邮件,可选带抄送。
|
||
func seedMailTo(t *testing.T, recipient string, cc string) uuid.UUID {
|
||
t.Helper()
|
||
ctx := context.Background()
|
||
sid, err := CreateSession(ctx, nil, "sender", "t", "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ccJSON := "[]"
|
||
if cc != "" {
|
||
ccJSON = `[{"name":"` + cc + `","path":"","session":"","raw":"` + cc + `"}]`
|
||
}
|
||
var id uuid.UUID
|
||
err = db.DB.QueryRowContext(ctx,
|
||
`INSERT INTO mails (session_id, from_name, to_name, subject, body, cc_list)
|
||
VALUES ($1, 'sender', $2, 's', 'b', $3) RETURNING mail_id`,
|
||
sid, recipient, ccJSON).Scan(&id)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return id
|
||
}
|
||
|
||
func statusOf(t *testing.T, id uuid.UUID) string {
|
||
t.Helper()
|
||
var s string
|
||
if err := db.DB.QueryRowContext(context.Background(),
|
||
`SELECT status FROM mails WHERE mail_id = $1`, id).Scan(&s); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return s
|
||
}
|
||
|
||
func TestMarkMailsReadForOnlyOwnMail(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
mine := seedMailTo(t, "bot", "")
|
||
others := seedMailTo(t, "other", "")
|
||
|
||
// 一次请求里混着别人的邮件:自己的标掉,别人的动不了。
|
||
// 鉴权写在 UPDATE 的 WHERE 里,所以这不是「先查后拒」而是根本改不动。
|
||
n, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{mine, others})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n != 1 {
|
||
t.Fatalf("影响行数 = %d,期望 1(只有自己那封)", n)
|
||
}
|
||
if statusOf(t, mine) != "read" {
|
||
t.Fatal("自己的邮件没被标记")
|
||
}
|
||
if statusOf(t, others) != "unread" {
|
||
t.Fatal("别人的邮件被标记了 —— 鉴权失效")
|
||
}
|
||
}
|
||
|
||
// 重复标记是幂等的:Agent 通常把上一轮列出的 id 原样传回来,
|
||
// 其中混着已读的不该算错误
|
||
func TestMarkMailsReadForIsIdempotent(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
id := seedMailTo(t, "bot", "")
|
||
|
||
if n, _ := MarkMailsReadFor(ctx, "bot", []uuid.UUID{id}); n != 1 {
|
||
t.Fatalf("首次应标掉 1 封,实际 %d", n)
|
||
}
|
||
n, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{id})
|
||
if err != nil {
|
||
t.Fatalf("重复标记不该报错: %v", err)
|
||
}
|
||
if n != 0 {
|
||
t.Fatalf("重复标记影响行数 = %d,期望 0", n)
|
||
}
|
||
}
|
||
|
||
// 被抄送的邮件也在收件箱里,也该能标掉
|
||
func TestMarkMailsReadForCoversCC(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
id := seedMailTo(t, "other", "bot") // 主收件人是 other,bot 被抄送
|
||
|
||
n, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{id})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n != 1 {
|
||
t.Fatalf("被抄送的邮件应可标记,影响行数 = %d", n)
|
||
}
|
||
}
|
||
|
||
func TestMarkMailsReadForEmptyList(t *testing.T) {
|
||
setupTestDB(t)
|
||
// 空列表直接返回,不该拼出 `IN ()` 这种非法 SQL
|
||
n, err := MarkMailsReadFor(context.Background(), "bot", nil)
|
||
if err != nil {
|
||
t.Fatalf("空列表不该报错: %v", err)
|
||
}
|
||
if n != 0 {
|
||
t.Fatalf("空列表影响行数 = %d", n)
|
||
}
|
||
}
|
||
|
||
func TestMarkAllInboxReadForSkipsArchivedAndOthers(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
a := seedMailTo(t, "bot", "")
|
||
b := seedMailTo(t, "bot", "")
|
||
others := seedMailTo(t, "other", "")
|
||
|
||
// 把 b 所在会话归档:那封在收件箱里根本看不到,
|
||
// 标掉它只会让「标记了 N 封」与用户看到的对不上
|
||
var sid uuid.UUID
|
||
db.DB.QueryRowContext(ctx, `SELECT session_id FROM mails WHERE mail_id = $1`, b).Scan(&sid)
|
||
db.DB.ExecContext(ctx, `UPDATE sessions SET status = 'archived' WHERE session_id = $1`, sid)
|
||
|
||
n, err := MarkAllInboxReadFor(ctx, "bot", "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n != 1 {
|
||
t.Fatalf("影响行数 = %d,期望 1(排除归档会话)", n)
|
||
}
|
||
if statusOf(t, a) != "read" {
|
||
t.Fatal("活跃会话里的未读没被标掉")
|
||
}
|
||
if statusOf(t, b) != "unread" {
|
||
t.Fatal("归档会话里的邮件被标掉了")
|
||
}
|
||
if statusOf(t, others) != "unread" {
|
||
t.Fatal("别人的邮件被标掉了")
|
||
}
|
||
}
|
||
|
||
/*
|
||
★★ 2026-10-01:权限决策的已读记账,decider 为空时**静默失效**。
|
||
|
||
# 形状
|
||
|
||
`DecidePermission` 的函数头承诺「权限邮件对他(决策人)也应当变成已读」。
|
||
但那两段记账都被 `if decider != ""` 包着,而**未读判据是 mail_reads**
|
||
(`unreadFor` / `readStateFor`),不是行级那列。⇒ decider 为空时:
|
||
|
||
mails.status = 'read' (全局冗余列改了)
|
||
mail_reads —— 没有这一行 (事实表没记)
|
||
|
||
⇒ 收件人在收件箱里**永远看到这封未读**,尽管它早已决策完(同意/拒绝)。
|
||
|
||
# 线上实证
|
||
|
||
13 封这样的历史邮件,收件人均为 jianf,2026-09-08 / 09-13,主题全是
|
||
「权限请求: 请求执行 bash」—— 每一次都已被人类决策过。
|
||
|
||
# 为什么 HTTP 路径今天触发不了
|
||
|
||
`permission.go` 会沿会话树上溯找人类(`NearestHumanInThread`),
|
||
且 HTTP 层传的是 `user.Username`。但 repo 层是**所有调用方的门**,
|
||
`MarkMailRead` 早就为同一件事挡了(reader 必填),只挡它一个等于漏网。
|
||
|
||
# 方向
|
||
|
||
保守:报错让调用方看到「决策没落库」;
|
||
静默放行让收件人反复看到一封已决策完的权限询问。
|
||
*/
|
||
|
||
func TestDecidePermissionRejectsEmptyDecider(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
_, err := DecidePermission(ctx, uuid.New(), " ", "同意")
|
||
if err == nil {
|
||
t.Fatal("★ 空 decider 必须报错(否则这封权限邮件在收件人收件箱里永远显示未读)")
|
||
}
|
||
if !strings.Contains(err.Error(), "decider") {
|
||
t.Fatalf("错误文案要点明是 decider 的问题,实际: %v", err)
|
||
}
|
||
}
|
||
|
||
// 回归判据:**改 mails.status 就必须记 mail_reads**,两者不能只改其一。
|
||
// 变异(把 decider 守卫去掉 / 改成静默跳过)后本格变红。
|
||
func TestDecidePermissionAlwaysRecordsReadByDecider(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
sid, err := CreateSession(ctx, nil, "pi", "权限", "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
mailID, err := CreateMail(ctx, sid, nil, "pi", "", "jianf", "",
|
||
"权限请求: 请求执行 bash", "要跑 bash", nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 建一条待决的权限请求
|
||
if _, err := db.DB.ExecContext(ctx,
|
||
`INSERT INTO permission_requests (request_id, mail_id, session_id, agent_name, question, options, context, created_at)
|
||
VALUES ($1,$2,$3,'pi','要跑 bash','[]','{}',NOW())`,
|
||
uuid.New(), mailID, sid); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if _, err := DecidePermission(ctx, mailID, "jianf", "同意"); err != nil {
|
||
t.Fatalf("正常决策不该失败: %v", err)
|
||
}
|
||
|
||
// 判据:mail_reads 必须有记录 —— 它才是未读判据
|
||
var n int
|
||
if err := db.DB.QueryRowContext(ctx,
|
||
`SELECT COUNT(*) FROM mail_reads WHERE mail_id = $1 AND reader_name = 'jianf'`, mailID).Scan(&n); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n == 0 {
|
||
t.Fatal("★ 决策后必须记 mail_reads:没有它这封信在收件箱里永远显示未读")
|
||
}
|
||
// 且派生读数(收件箱口径)必须变成 read
|
||
var state string
|
||
if err := db.DB.QueryRowContext(ctx,
|
||
`SELECT `+readStateFor("'jianf'")+` FROM mails m WHERE m.mail_id = $1`, mailID).Scan(&state); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if state != "read" {
|
||
t.Fatalf("决策后收件箱口径应为 read,实际 %q", state)
|
||
}
|
||
}
|