## ① 卡片视图(WorkCard)
上一轮只接了列表视图 —— 但用户原话是「形成/展示为树结构」,
只在一个视图里成立不算:**切一下视图,线索树就没了**。
缩进比列表更紧(8px/级、上限 3 级):卡片本来就有三块内容
(发件人行 / 主题 / 摘要 + 预算条),400px 侧栏里每级 12px
挤掉的是**摘要本身** —— 摘要没了,卡片就只剩一个标题。
判据加一格专门盯「两个视图都接了」,否则这格缺口会一直没人看。
## ② C 层:admin 全量邮件(scope=all)
GET /api/v1/me/mail/inbox?scope=all admin 才有效
GET /api/v1/me/mail/inbox 默认,仍是自己收件箱
三条边界,两个变异都转红:
| | 行为 | 变异后 |
|---|---|---|
| 非 admin 要 scope=all | **403** | 静默降级 200 ⇒ 转红 |
| 默认(无 scope) | 自己的,一封不多 | 身份隐式全给 ⇒ 转红 |
| 非法 scope | 400 | — |
**静默降级是最危险的那个**:调用方会以为拿到了全量(实际没有)——
「看起来能用的错答案」,比报错难查得多。
**默认不给 admin 全量**:全看必须显式要求,不能靠身份隐式获得。
### 为什么单独写 ListAllMails,没给 ListInboxScoped 加参数
`ListInboxScoped` 的第一个参数 `agentName` 兼任两职:
① SQL 里的 reader 过滤
② `readStateFor("$1")` 算 status(已读/未读是**按读者**记的)
两者都必须有值 —— `requireReader` 就是为此存在。
若把「全量」做成「reader 传空」,那个非法状态看起来就合法了;
一旦放进去,**status 会静默变成未读** —— 一个没人会注意到、
却让「已读/未读」全面失真的坑。
同理,admin 全量视图里 `status=unread` **明确报错**而不是返回全部 ——
后者会让前端把整箱染成"未读"。admin 不是任何一封信的读者。
### scanMailRows 提取(repo.go +16/-0,纯新增)
第二份手写扫描副本的第一个分叉点必然是「admin 视图少算一个派生字段」,
而那在前端表现为某个徽标不见了,极难归因。⇒ 两处共用一份,
`ListInboxScoped` 行为一行未改。
## ★ 这不触碰 Agent 侧的收窄
`ListInboxScoped` 里 `to_name = reader OR cc` 那条是 **AgentAuth** 用的,
是 15e4fe9 / 095213b 修出来的越权防护。`scope=all` 是**人类登录态**下
admin 的显式全量视图,两条通道互不相干 —— 语义不同,不要混谈。
## 判据自己错了一次
`TestAdminScopeAllSeesEverything` 红在 500,报
`Scan: invalid UUID length: 7` —— 看着像 SQL/扫描代码坏了,
其实是判据自己的数据不对(`mail_id` 是 UUID,我塞了 `"m-admin"`)。
**判据数据错了会伪装成被测代码坏了。**
14 包全绿;Electron 带改动 fail 9 / 基线 fail 12(减少 3 红、无新红,
剩下的是其他会话 harmony 设备判据的红)。
162 lines
5.7 KiB
Go
162 lines
5.7 KiB
Go
package handler
|
||
|
||
/*
|
||
C 层:admin 全量邮件可见性的判据(2026-10-04)。
|
||
|
||
用户要求:「admin 查看全部邮件,普通用户仅查看与自己相关或显式分享的邮件」。
|
||
|
||
★ 盯的是**分层可见性**,不是「scope 参数存在」:
|
||
|
||
1. 非 admin 显式请求 scope=all ⇒ **403**,不是静默回退到自己的收件箱。
|
||
静默降级会让调用方以为拿到了全量 —— 那是「看起来能用的错答案」。
|
||
2. 默认(无 scope)⇒ 仍是自己<E887AA><E5B7B1>收件箱,一封都不多给。
|
||
★ 这条与 Agent 侧的收窄(15e4fe9 / 095213b 修的越权防护)无关 ——
|
||
那是 AgentAuth 通道;本文件是人<E698AF><E4BABA><EFBFBD>登录态。别拿「admin 要全看」
|
||
当理由去放宽 ListInboxScoped。
|
||
3. 非法 scope ⇒ 400,不是当成没给。
|
||
*/
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
"github.com/agentmail/gateway/internal/middleware"
|
||
"github.com/agentmail/gateway/internal/models"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
func setupAdminScopeDB(t *testing.T) {
|
||
t.Helper()
|
||
db.Close()
|
||
path := filepath.Join(t.TempDir(), "admin-scope.db")
|
||
if err := db.Connect(context.Background(), "sqlite://"+path); err != nil {
|
||
t.Fatalf("连接测试库: %v", err)
|
||
}
|
||
if err := db.Migrate(context.Background()); err != nil {
|
||
t.Fatalf("迁移: %v", err)
|
||
}
|
||
t.Cleanup(db.Close)
|
||
}
|
||
|
||
// seed 两封信:一封给 admin,一封只给普通用户。
|
||
func seedScopeMails(t *testing.T) (adminMailID, userMailID string) {
|
||
t.Helper()
|
||
ctx := context.Background()
|
||
for _, u := range []string{"boss", "intern"} {
|
||
if _, err := db.DB.ExecContext(ctx,
|
||
`INSERT INTO users (username, password_hash, role) VALUES ($1,'x',$2)`,
|
||
u, map[string]string{"boss": "admin", "intern": "user"}[u]); err != nil {
|
||
t.Fatalf("建用户 %s: %v", u, err)
|
||
}
|
||
}
|
||
var sid string
|
||
if err := db.DB.QueryRowContext(ctx,
|
||
`INSERT INTO sessions (session_alias, subject, status, workspace, from_agent)
|
||
VALUES ('s','x','active','/tmp','pi') RETURNING session_id`).Scan(&sid); err != nil {
|
||
t.Fatalf("建会话: %v", err)
|
||
}
|
||
// ★ mail_id 是 **UUID**:初版这里塞了 "m-admin" 这种字符串,
|
||
// 报出来是 `Scan: invalid UUID length: 7` —— 看着像 SQL/扫描代码坏了,
|
||
// 其实是判据自己的数据不对。判据数据错了会伪装成被测代码坏了。
|
||
for _, spec := range []struct{ tag, to, subj, at string }{
|
||
{"admin", "boss", "给管理员的信", "2026-01-02T00:00:00Z"},
|
||
{"intern", "intern", "只给普通用户的信", "2026-01-01T00:00:00Z"},
|
||
} {
|
||
mid := uuid.New()
|
||
if _, err := db.DB.ExecContext(ctx,
|
||
`INSERT INTO mails (mail_id, session_id, from_name, from_workspace,
|
||
to_name, to_workspace, subject, body, mail_type, created_at)
|
||
VALUES ($1,$2,'pi','/tmp',$3,'/tmp',$4,'b','normal',$5)`,
|
||
mid, sid, spec.to, spec.subj, spec.at,
|
||
); err != nil {
|
||
t.Fatalf("建邮件 %s: %v", spec.tag, err)
|
||
}
|
||
if spec.tag == "admin" {
|
||
adminMailID = mid.String()
|
||
} else {
|
||
userMailID = mid.String()
|
||
}
|
||
}
|
||
return adminMailID, userMailID
|
||
}
|
||
|
||
func reqAsUser(t *testing.T, u models.User, url string) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
rec := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||
req = req.WithContext(context.WithValue(req.Context(),
|
||
middleware.UserKey, &u))
|
||
MeGetInbox(rec, req)
|
||
return rec
|
||
}
|
||
|
||
func TestAdminScopeAllSeesEverything(t *testing.T) {
|
||
setupAdminScopeDB(t)
|
||
_, userMailID := seedScopeMails(t)
|
||
|
||
admin := models.User{Username: "boss", Role: "admin"}
|
||
rec := reqAsUser(t, admin, "/api/v1/me/mail/inbox?scope=all")
|
||
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("admin scope=all 应 200,实际 %d:%s", rec.Code, rec.Body.String())
|
||
}
|
||
if !strings.Contains(rec.Body.String(), "只给普通用户的信") {
|
||
t.Errorf("★ admin scope=all 应看到别人的信,实际:%s", rec.Body.String())
|
||
}
|
||
_ = userMailID
|
||
}
|
||
|
||
func TestNonAdminScopeAllIs403NotSilentlyNarrowed(t *testing.T) {
|
||
setupAdminScopeDB(t)
|
||
adminMailID, _ := seedScopeMails(t)
|
||
|
||
intern := models.User{Username: "intern", Role: "user"}
|
||
rec := reqAsUser(t, intern, "/api/v1/me/mail/inbox?scope=all")
|
||
|
||
// ★ 403 而不是 200:静默降级会让调用方以为拿到了全量
|
||
if rec.Code != http.StatusForbidden {
|
||
t.Fatalf("★ 普通用户 scope=all 应 403,实际 %d:%s", rec.Code, rec.Body.String())
|
||
}
|
||
if strings.Contains(rec.Body.String(), "给管理员的信") {
|
||
t.Errorf("★ 403 的响应体里不该带任何邮件内容:%s", rec.Body.String())
|
||
}
|
||
_ = adminMailID
|
||
}
|
||
|
||
func TestDefaultScopeStaysOwnInbox(t *testing.T) {
|
||
setupAdminScopeDB(t)
|
||
adminMailID, _ := seedScopeMails(t)
|
||
|
||
// ★ 连 admin 在**默认**(无 scope)下也只能看自己的 ——
|
||
// 「admin 全看」必须显式要求,不能靠身份隐式获得。
|
||
admin := models.User{Username: "boss", Role: "admin"}
|
||
rec := reqAsUser(t, admin, "/api/v1/me/mail/inbox")
|
||
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("默认应 200,实际 %d:%s", rec.Code, rec.Body.String())
|
||
}
|
||
if strings.Contains(rec.Body.String(), "只给普通用户的信") {
|
||
t.Errorf("★ 默认 scope 下 admin 也不该看到别人的信(须显式 scope=all):%s",
|
||
rec.Body.String())
|
||
}
|
||
if !strings.Contains(rec.Body.String(), "给管理员的信") {
|
||
t.Errorf("自己的信反而没了:%s", rec.Body.String())
|
||
}
|
||
_ = adminMailID
|
||
}
|
||
|
||
func TestBadScopeIs400(t *testing.T) {
|
||
setupAdminScopeDB(t)
|
||
seedScopeMails(t)
|
||
admin := models.User{Username: "boss", Role: "admin"}
|
||
rec := reqAsUser(t, admin, "/api/v1/me/mail/inbox?scope=bogus")
|
||
if rec.Code != http.StatusBadRequest {
|
||
t.Errorf("非法 scope 应 400(不是当成没给),实际 %d:%s", rec.Code, rec.Body.String())
|
||
}
|
||
}
|