fix(归档): 标已读不得把归档邮件改回 read —— e78888b 的不变量有反向缺口

pi 2026-09-28 复核 e78888b 时指出:EnsureSessionOpen 堵住了「往归档会话里建邮件」,
但 MarkMailRead 能**反向**打破同一个不变量。复核成立。

  POST /api/v1/mail/{id}/read(对归档会话里的一封)
    → GetMailByID(无 status 过滤)→ UserCanAccessSession(只查参与方)
    → UPDATE mails SET status='read' WHERE mail_id=$1   ← 不查 status

实测:mails.status `archived → read`,而 sessions.status 仍是 archived
⇒ 两表分叉,且 readStateFor 返回 'read' 而非 'archived',
**归档语义在行级被抹掉**。已加 TestMarkReadDoesNotUnarchiveMail,
先确认它在修之前转红(不是改完就绿的装饰)。

同族的批量路径 MarkAllInboxRead 本来就有守卫
(`session_id IN (SELECT ... WHERE status <> 'archived')`,markread_test.go:126
断言了它)—— 缺的只有单封这一处,所以这是漏网而非设计如此。修法与批量那条同形。

★ mail_reads 的 INSERT 刻意**不加**守卫:已读是按读者记的事实,
人确实读过,归档不该改写它。行级那列是「全局可见性」的冗余、mail_reads 是
「谁读过」的事实,两者语义不同 —— 一起挡会把事实也丢掉。

读侧清册仍为 repo.go=13:新增的那 1 处命中在注释里(散文里拼了列名字面量),
改写措辞而不改数字 —— 让数字 +1 会给未来新增读取凭空送出 1 格余量。
This commit is contained in:
2026-09-28 11:07:32 +08:00
parent e78888b756
commit 2b77b17e65
2 changed files with 59 additions and 1 deletions

View File

@ -853,7 +853,23 @@ func MarkMailRead(ctx context.Context, id uuid.UUID, reader string) error {
id, reader); err != nil {
return err
}
_, err := db.DB.ExecContext(ctx, `UPDATE mails SET status = 'read' WHERE mail_id = $1`, id)
// 冗余列**不得覆盖 archived**(pi 2026-09-28 复核时指出的反向洞)。
//
// 缺这一格时:对归档会话里的一封调 `POST /api/v1/mail/{id}/read`,
// 会把那行从 archived 改成 read,而会话那列仍是 archived
// ⇒ 两表分叉,且 `readStateFor` 返回 'read' 而非 'archived',
// **归档语义在行级被抹掉**(实测 archived → read)。
//
// 批量那条 `MarkAllInboxRead` 本来就有等价守卫
// (`session_id IN (SELECT ... WHERE status <> 'archived')`)——
// 缺的只有单封这一处,所以这不是设计如此,是漏网。判据:
// session_status_invariant_test.go 的 TestMarkReadDoesNotUnarchiveMail。
//
// 注意上面那条 `INSERT INTO mail_reads` **不加**守卫:已读是按读者记的事实,
// 人确实读过,归档不该改写这个事实(行级那列是"全局可见性"的冗余,
// mail_reads 是"谁读过"的事实,两者语义不同,不要一起挡)。
_, err := db.DB.ExecContext(ctx,
`UPDATE mails SET status = 'read' WHERE mail_id = $1 AND status <> 'archived'`, id)
return err
}

View File

@ -227,3 +227,45 @@ func TestArchiveAfterDeliveryKeepsTablesTogether(t *testing.T) {
t.Fatalf("重复投递把会话状态推成了 %q,期望仍是 archived", got)
}
}
// 标已读不得把归档邮件改回 read —— 与 TestTouchSessionDoesNotUnarchive 对称。
//
// ★ 这条是 pi 2026-09-28 复核 e78888b 时指出的**反向**洞:EnsureSessionOpen 堵住了
// 「往归档会话里建邮件」(会话 active?/邮件行不该非 archived),但
// `MarkMailRead`(repo.go:MarkMailRead)那句 `UPDATE mails SET status='read'
// WHERE mail_id=$1` **不查 status**,于是
//
// POST /api/v1/mail/{id}/read(对归档会话里的一封)
// → GetMailByID(无 status 过滤)→ UserCanAccessSession(只查参与方)
// → UPDATE ⇒ mails.status: archived → read,而 sessions.status 仍是 archived
//
// 实测读数:`archived → read`(会话仍 archived)。此时 `readStateFor` 返回
// 'read' 而非 'archived' ⇒ **归档语义在行级被抹掉**,正是本文件要禁止的分叉。
//
// 同族的批量路径 `MarkAllInboxRead` **本来就有**守卫
// (`session_id IN (SELECT ... WHERE status <> 'archived')`,markread_test.go:126
// 断言了它)—— 所以缺的只有单封这一处,这也说明它不是"设计如此"而是漏网。
func TestMarkReadDoesNotUnarchiveMail(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
_, id := seedArchivedSession(t)
if err := MarkMailRead(ctx, id, "bob"); err != nil {
t.Fatalf("MarkMailRead 读数 %v(对归档邮件标已读应当被拒或无害)", err)
}
var mstat string
if err := db.DB.QueryRowContext(ctx,
`SELECT status FROM mails WHERE mail_id = $1`, id).Scan(&mstat); err != nil {
t.Fatal(err)
}
if mstat != "archived" {
t.Fatalf("★ MarkMailRead 把已归档邮件改成了 %q —— 归档是全局属性,标已读不该抹掉它。\n"+
" sessions.status 仍是 archived 而这一行变成 %q,两表分叉(readStateFor 会返回 %q\n"+
" 而不是 'archived',归档语义在行级被抹掉)。\n"+
" 批量那条 MarkAllInboxRead 有 `session_id IN (... status <> 'archived')` 守卫,\n"+
" 单封这条漏了 —— 对称的洞,别只堵一半。", mstat, mstat, mstat)
}
assertNoDivergence(t)
}