Files
MailUI4Agents/server/internal/repo/readstate_test.go
JianFeeeee 1619399470 fix(gateway): 已读改为**按读者**记录 —— 修掉"别人读掉,我就看不到"
用户报的那句 dsh 自述("收件箱列表未展示它,直接按 mail_id 读取成功")不是插件问题,
是网关的已读模型:`mails.status` 是**邮件级**的一个列,任何收件人读掉,对所有收件人
(含抄送)都变成已读 —— 全库没有任何按人记录已读的表,我查过 schema 与迁移文件。

实测复现(两个人类用户、一封共享邮件,排除 Agent 干扰):
  gui-lab 读掉 → gui-lab 未读清空(应当)→ **jianf 的未读也没了**(错误)
  而 jianf 的 `status=all` 里仍在 ⇒ 是已读语义问题,不是送达问题。
线上那封信正是这个形状:`jianf → dsh` 抄送 pi/opencode/zcode/homeagent,**pi 最先
回复(= 它读过了)** ⇒ 这封对 dsh 也变成 read ⇒ dsh 的 `read_inbox`(默认 unread)
返回空 ⇒ 它只能按提示词里的 mail_id 兜。

三个受害面:① Agent 的 `read_inbox` 拿不到信(换一个不兜的模型就变成"正文是空的");
② 人类的未读被抄送的 Agent 读掉;③ ★ 桥的补投判据 `pending_mails = CountUnread` 归零
⇒ SSE 漏过或进程重启时那封信**不再补投**(静默丢信)。

改动:
- 新表 `mail_reads(mail_id, reader_name, read_at)`,未读 = 这张表里没有该读者的行。
- 判据收敛到一处(repo 的 `unreadFor` / `readStateFor`),六处读写点全部改用它:
  单封已读、批量标已读、权限决策(只记**决策人**)、`ListInbox`(过滤 + 返回的
  status 都按读者算)、`CountUnread`、`CountUnreadInSession`、会话列表未读计数。
- 一次性回填补历史:`mails.status='read'` 记到**主收件人**名下(唯一可用的推断),
  用 `app_meta` 里的标记守住 —— 不能每次启动都跑,那会把"某抄送方读过"按主收件人
  写成已读,正是这次要修的错。实测:`done rows=207`。
- `mails.status` 保留为"有人读过 / 已归档"的冗余列,**不再是判据**。

★ 顺带挖出并修掉一个真 bug:`CountUnreadInSession` 用的是 PG 专有语法
(`cc_list @> $3::jsonb`),而线上是 SQLite ⇒ 那条 SQL **语法错误**
(`unrecognized token: "@"`),调用点又是 `unread, _ :=`(吞错)⇒
**会话列表的未读数一直是 0**。现已改用仓库既有的方言助手 `db.CCHas`。
实测:happy-pixel 会话现在 `unread_count=5`(修复前恒 0)。

判据:新增 `internal/repo/readstate_test.go`(5 条:按读者未读、会话内计数、
批量标已读、归档对所有人可见性、权限决策只记决策人)。
**扰动验证**:把 `unreadFor` 退回旧语义 → 4 条判据全红;恢复 → 绿。
全量 server 10 包全绿。文档同步:API.md 的「标记已读」段 + PLUGIN-CONTRACT 的 T-1.4。

线上复验:同一受控实验 —— gui-lab 读掉后,**jianf 的未读仍在且 status=unread** 
2026-09-13 14:25:44 +08:00

213 lines
6.7 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"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
/*
已读语义:**按读者**记录,而不是邮件行上的一个全局列。
2026-09-13 线上实测:`jianf → dsh` 那封打了招呼、抄送 pi/opencode/zcode/homeagent 的
邮件里pi 最先回复 ⇒ 它读过了 ⇒ 这封对 dsh 也变成 `read` ⇒ dsh 的
`read_inbox`(默认 unread返回空它只能按提示词里的 mail_id 直接读,并在回信里
写下"收件箱列表未展示它,直接按 mail_id 读取成功"。
同一封邮件**对每个读者各自算未读**,是这一组判据要钉住的不变量。
*/
// unreadList 返回 ListInbox 在 unread 过滤下给出的邮件 id。
func unreadList(t *testing.T, reader string) []uuid.UUID {
t.Helper()
mails, err := ListInbox(context.Background(), reader, "unread", 50)
if err != nil {
t.Fatal(err)
}
out := []uuid.UUID{}
for _, m := range mails {
out = append(out, m.ID)
}
return out
}
func hasID(list []uuid.UUID, id uuid.UUID) bool {
for _, x := range list {
if x == id {
return true
}
}
return false
}
// statusSeenBy 返回 ListInbox 在 all 过滤下、某个读者看到的那封邮件的 status。
func statusSeenBy(t *testing.T, reader string, id uuid.UUID) string {
t.Helper()
mails, err := ListInbox(context.Background(), reader, "all", 50)
if err != nil {
t.Fatal(err)
}
for _, m := range mails {
if m.ID == id {
return m.Status
}
}
t.Fatalf("读者 %s 的收件箱里没有 %s", reader, id)
return ""
}
func TestReadStateIsPerRecipient(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 一封主收件人 alice、抄送 bob 的邮件 —— 正是线上那封信的形状
id := seedMailTo(t, "alice", "bob")
// 初始:两边都未读
if n, _ := CountUnread(ctx, "alice"); n != 1 {
t.Fatalf("alice 初始未读 = %d期望 1", n)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
t.Fatalf("bob 初始未读 = %d期望 1", n)
}
// alice 读掉它
if err := MarkMailRead(ctx, id, "alice"); err != nil {
t.Fatal(err)
}
// ★ 核心判据alice 的未读清了bob 的**必须还在**
if n, _ := CountUnread(ctx, "alice"); n != 0 {
t.Fatalf("alice 读过后未读 = %d期望 0", n)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
t.Fatalf("★ bob 没读,未读却变成了 %d —— 这正是要修的缺陷(任一收件人读掉,所有人变已读)", n)
}
if !hasID(unreadList(t, "bob"), id) {
t.Fatal("★ bob 的 unread 收件箱里必须还能看到这封信")
}
if hasID(unreadList(t, "alice"), id) {
t.Fatal("alice 自己读过了unread 收件箱不该再有它")
}
// 同一封邮件,两个读者看到的状态不同 —— 这就是"按读者"的直接证据
if got := statusSeenBy(t, "alice", id); got != "read" {
t.Fatalf("alice 看到的 status = %q期望 read", got)
}
if got := statusSeenBy(t, "bob", id); got != "unread" {
t.Fatalf("★ bob 看到的 status = %q期望 unread同一封邮件按读者算", got)
}
// 反向对照bob 也读掉 → 两边都清
if err := MarkMailRead(ctx, id, "bob"); err != nil {
t.Fatal(err)
}
if n, _ := CountUnread(ctx, "bob"); n != 0 {
t.Fatalf("bob 读过后未读 = %d期望 0", n)
}
}
// CountUnreadInSession 是会话列表徽标用的,同样必须按读者算。
func TestCountUnreadInSessionIsPerRecipient(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
id := seedMailTo(t, "alice", "bob")
var sid uuid.UUID
if err := db.DB.QueryRowContext(ctx, `SELECT session_id FROM mails WHERE mail_id = $1`, id).Scan(&sid); err != nil {
t.Fatal(err)
}
if err := MarkMailRead(ctx, id, "alice"); err != nil {
t.Fatal(err)
}
if n, _ := CountUnreadInSession(ctx, "alice", sid); n != 0 {
t.Fatalf("alice 会话内未读 = %d期望 0", n)
}
if n, _ := CountUnreadInSession(ctx, "bob", sid); n != 1 {
t.Fatalf("★ bob 会话内未读 = %d期望 1徽标不能因为别人读过就归零", n)
}
}
// 批量标已读Agent 的 /mail/read同样只影响调用者自己。
func TestBulkMarkReadOnlyAffectsCaller(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
id := seedMailTo(t, "alice", "bob")
if _, err := MarkMailsReadFor(ctx, "alice", []uuid.UUID{id}); err != nil {
t.Fatal(err)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
t.Fatalf("★ 批量标已读后 bob 未读 = %d期望 1", n)
}
// 全部标已读(不带 id也只能标自己收件箱里的
if _, err := MarkAllInboxReadFor(ctx, "alice"); err != nil {
t.Fatal(err)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
t.Fatalf("★ 全标已读后 bob 未读 = %d期望 1", n)
}
}
// 归档是**会话级**的全局状态归档后对所有人都不可见unread 与 all 都不给),
// 而且不能因为"没人读过"就把它算成未读。
func TestArchivedStaysArchivedForEveryone(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
id := seedMailTo(t, "alice", "bob")
var sid uuid.UUID
if err := db.DB.QueryRowContext(ctx, `SELECT session_id FROM mails WHERE mail_id = $1`, id).Scan(&sid); err != nil {
t.Fatal(err)
}
if err := ArchiveSession(ctx, sid); err != nil {
t.Fatal(err)
}
if n, _ := CountUnread(ctx, "bob"); n != 0 {
t.Fatalf("归档会话里的邮件不该计入未读,实际 %d", n)
}
if hasID(unreadList(t, "bob"), id) {
t.Fatal("归档邮件的 status 是 archived不该出现在 unread 收件箱")
}
// ListInbox 会整体排除归档会话(`s.status <> 'archived'`),所以它在
// `all` 里也不出现 —— 归档是"从收件箱拿走",不是"标成已读"。
mails, err := ListInbox(ctx, "bob", "all", 50)
if err != nil {
t.Fatal(err)
}
for _, m := range mails {
if m.ID == id {
t.Fatal("归档会话里的邮件不该出现在收件箱列表all 也不该有)")
}
}
}
// 权限决策把邮件记成**决策人**已读:同一条线索上其他收件人不受影响。
func TestPermissionDecisionMarksOnlyDecider(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 一封 alice 收、bob 抄送的权限邮件
id := seedMailTo(t, "alice", "bob")
if _, err := db.DB.ExecContext(ctx,
`INSERT INTO permission_requests (mail_id, session_id, agent_name, question, options, kind)
SELECT mail_id, session_id, 'bot', 'q', '["同意","拒绝"]', 'permission' FROM mails WHERE mail_id = $1`,
id); err != nil {
t.Fatal(err)
}
if _, err := DecidePermission(ctx, id, "alice", "同意"); err != nil {
t.Fatal(err)
}
if n, _ := CountUnread(ctx, "alice"); n != 0 {
t.Fatalf("决策人 alice 未读 = %d期望 0", n)
}
if n, _ := CountUnread(ctx, "bob"); n != 1 {
t.Fatalf("★ bob 未读 = %d期望 1决策是 alice 做的,不该替他标记已读)", n)
}
}