diff --git a/server/internal/repo/markread_authcolumn_test.go b/server/internal/repo/markread_authcolumn_test.go new file mode 100644 index 0000000..28ab03c --- /dev/null +++ b/server/internal/repo/markread_authcolumn_test.go @@ -0,0 +1,133 @@ +package repo + +import ( + "context" + "testing" + + "github.com/agentmail/gateway/internal/db" + "github.com/google/uuid" +) + +/* + * ★★ 权威列 `mail_reads` 的回归判据(2026-09-20)。 + * + * 背景:`markReadFor` 原来把 reader **前置**成 `$1`,而调用方 `where` 早已把 `$1` + * 用成 recipient ⇒ 整张绑定表右移一格 ⇒ `INSERT ... SELECT` 的 `IN` 子句拿到 + * `(recipient, id1 … idN-1)`,**最后一封永远插不进**;只传 1 封时一封都不插。 + * + * ★ 为什么长期零痕迹(这条比 bug 本身重要): + * 调用方返回的"标了几封"来自**随后那条 `UPDATE mails`**,它用的是**没被前置**的 + * args ⇒ 计数正确、冗余列 `mails.status` 正确,**只有权威列 `mail_reads` 静默少行**。 + * 而未读判据是 `mail_reads`(见 `unreadFor`)⇒ 邮件「看起来已读、实际仍算未读」 + * ⇒ 重启补投(`catchUp`)时被当新信**重投**。 + * + * ★ 为什么原有 4 个测试(`markread_test.go`)全都漏掉它: + * 它们断言的是 `statusOf()` —— 读的正是那列**冗余** `mails.status`。 + * 判据读错了列,于是守着"看起来对"的那个值,而不是**决定行为**的那个值。 + * 本文件补的就是"断言权威列"。 + * + * ⚠️ 这四条对**顺序**敏感:off-by-one 只丢「最后一个」,所以**必须一次传多个** + * 才有区分力 —— 单封用例在旧实现下也插不进去,多封用例才能看出"丢了尾巴"。 + */ + +// readRows 读**权威列**:这个读者对某封邮件有几行已读记录。 +func readRows(t *testing.T, id uuid.UUID, reader string) int { + t.Helper() + var n int + if err := db.DB.QueryRowContext(context.Background(), + `SELECT count(*) FROM mail_reads WHERE mail_id = $1 AND reader_name = $2`, + id, reader).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +// 单封:旧实现下 `IN ($2)` 拿到的是 recipient ⇒ 一行都插不进。 +func TestAuthColumnSingleMailMarked(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + id := seedMailTo(t, "bot", "") + + if _, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{id}); err != nil { + t.Fatal(err) + } + if got := readRows(t, id, "bot"); got != 1 { + t.Fatalf("权威列 mail_reads 行数 = %d,期望 1 —— "+ + "`mails.status` 会说 read,但未读判据读的是 mail_reads ⇒ 这封会被当新信重投", got) + } +} + +// ★ 核心:一次传多封时,**每一封**都要有行 —— off-by-one 只会丢最后一封。 +func TestAuthColumnAllMailsMarkedNotJustFirstN(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + ids := make([]uuid.UUID, 5) + for i := range ids { + ids[i] = seedMailTo(t, "bot", "") + } + n, err := MarkMailsReadFor(ctx, "bot", ids) + if err != nil { + t.Fatal(err) + } + if n != len(ids) { + t.Fatalf("冗余列影响行数 = %d,期望 %d", n, len(ids)) + } + + // 逐封断言,并指名是**哪一封**没标上(尾巴那一封最容易漏)。 + var missing []int + for i, id := range ids { + if readRows(t, id, "bot") != 1 { + missing = append(missing, i+1) + } + } + if len(missing) > 0 { + t.Fatalf("★ 权威列漏标第 %v 封(共 %d 封)—— "+ + "`n=%d` 与 `mails.status` 都会是「对的」,只有 mail_reads 少行;"+ + "未读判据读 mail_reads ⇒ 这几封会被重投", missing, len(ids), n) + } +} + +// 抄送路径同样要写权威列(它的 where 也把 $1 用成了 recipient)。 +func TestAuthColumnCoversCC(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + id := seedMailTo(t, "other", "bot") // 主收件人 other,bot 被抄送 + + if _, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{id}); err != nil { + t.Fatal(err) + } + if got := readRows(t, id, "bot"); got != 1 { + t.Fatalf("被抄送的邮件在权威列 mail_reads 行数 = %d,期望 1", got) + } +} + +// 幂等:重复标记不产生第二行(NOT EXISTS 那半也要在**正确的 reader**上生效)。 +func TestAuthColumnIdempotent(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + id := seedMailTo(t, "bot", "") + + for i := 0; i < 3; i++ { + if _, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{id}); err != nil { + t.Fatal(err) + } + } + if got := readRows(t, id, "bot"); got != 1 { + t.Fatalf("重复标记后权威列行数 = %d,期望 1(幂等)", got) + } +} + +// 别人的邮件不该在权威列留下行(鉴权在 WHERE 里,前置错位时这条也可能被绕过)。 +func TestAuthColumnNotOwnMailUntouched(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + others := seedMailTo(t, "other", "") + + if _, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{others}); err != nil { + t.Fatal(err) + } + if got := readRows(t, others, "bot"); got != 0 { + t.Fatalf("别人的邮件竟在权威列留下了 %d 行 —— 鉴权失效", got) + } +} diff --git a/server/internal/repo/repo.go b/server/internal/repo/repo.go index f0afd10..59f0f40 100644 --- a/server/internal/repo/repo.go +++ b/server/internal/repo/repo.go @@ -503,13 +503,36 @@ func readStateFor(arg string) string { // 调用方各自负责随后刷新 mails.status 那列冗余(那个语句没有 `m` 别名)。 // 之前我把同一个 where 复用到 UPDATE 上,直接 SQL 报 "no such column: m.mail_id" // (测试当场抓到)。 +// +// ★★ reader 的占位符**必须排在调用方实参之后**(2026-09-20 修,一个静默少行的真 bug)。 +// +// 原实现把 reader **前置**(`append([]any{reader}, args...)`)当 `$1`,而两个调用方 +// 的 `where` 早就把 `$1` 用成了 recipient: +// +// MarkMailsReadFor : $1=recipient,$2..$(N+1)=ids,args=[recipient, ids...] +// MarkAllInboxReadForSession : $1=recipient,$2=sessionID,args=[recipient, …] +// +// 前置之后整张绑定表**右移一格** ⇒ `$2` 拿到的是 recipient 而不是 `id1`: +// - `IN ($2 …, $(N+1))` 实际是 `(recipient, id1 … idN-1)` ⇒ **最后一封永远不插**; +// 只传 1 封时 `$2=recipient` ⇒ **一封都不插**。 +// - `MarkAllInboxReadForSession` 带 session 时 `$2=recipient` 被当成 `session_id` ⇒ 同样一行不插。 +// +// **为什么长期零痕迹**:调用方返回的"标了几封"来自**随后那条 `UPDATE mails`**, +// 它用的是没被前置的 args ⇒ 计数正确、那列冗余值也正确, +// 只有权威列 `mail_reads` 静默少行。而未读判据是 `mail_reads`(见 `unreadFor`), +// 于是邮件**看起来已读、实际仍是未读** ⇒ 重启补投时被当新信重投。 +// +// 现在把 reader 放在**最后一个**占位符,两个调用方的 `$1` 语义各自保持不变。 func markReadFor(ctx context.Context, reader string, where string, args ...any) error { + // reader 的编号紧跟在调用方实参之后,避免与它们已占用的 $N 相撞。 + n := len(args) + 1 + ph := fmt.Sprintf("$%d", n) _, err := db.DB.ExecContext(ctx, `INSERT INTO mail_reads (mail_id, reader_name) - SELECT m.mail_id, $1 FROM mails m + SELECT m.mail_id, `+ph+` FROM mails m WHERE `+where+` - AND NOT EXISTS (SELECT 1 FROM mail_reads r WHERE r.mail_id = m.mail_id AND r.reader_name = $1)`, - append([]any{reader}, args...)...) + AND NOT EXISTS (SELECT 1 FROM mail_reads r WHERE r.mail_id = m.mail_id AND r.reader_name = `+ph+`)`, + append(append([]any{}, args...), reader)...) return err }