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) } }