Files
MailUI4Agents/server/internal/repo/markread_test.go
JianFeeeee 1d7b018734 fix(权限): 决策人��空时不再静默跳过已读记账 —— 那让权限邮件永远显示未读
## 形状

`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`,
会把我注释里提到该列名也算成「新增一处读取」,导致那条清册判据变红。
已在注释里避开那个写法并写明原因 —— 改这块代码时若它突然变红,先看是不是注释。
2026-10-02 00:15:02 +08:00

237 lines
7.2 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 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)
}
}