## ① 卡片视图(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 设备判据的红)。
102 lines
4.2 KiB
Go
102 lines
4.2 KiB
Go
package repo
|
||
|
||
/*
|
||
admin 全量邮件视图(2026-10-04,用户 2026-10-04 定的分层可见性)。
|
||
|
||
# 与 Agent 侧那条边界无关
|
||
|
||
`ListInboxScoped` 里的收窄(`m.to_name = reader OR cc 里有 reader`)
|
||
是 **AgentAuth** 用的:Agent 只能看自己收发的。这是 15e4fe9 / 095213b
|
||
修出来的越权防护,**不得**因为「admin 要看全部」而放宽它。
|
||
|
||
本文件是**人类登录态**下 admin 的显式全量视图 —— 走 `UserAuth` +
|
||
`IsAdmin()`,与 Agent 通道互不相干。两者语义不同,不要混谈。
|
||
|
||
# 为什么单独一个函数而不是给 ListInboxScoped 加参数
|
||
|
||
`ListInboxScoped` 的第一个参数 `agentName` 兼任两职:
|
||
① SQL 里的 reader 过滤条件
|
||
② `readStateFor("$1")` 算 status(已读/未读是**按读者**记的)
|
||
两者都必须有值 —— `requireReader` 就是为此存在(reader 为空会抛错,
|
||
因为那样 status 无从算起)。
|
||
|
||
⇒ 「全量」不是「reader 传空」,而是**去掉 reader 过滤这一段**。
|
||
把两件事塞进一个函数会让「空 reader」这种非法状态看起来合法,
|
||
而它一旦被放进去,status 会静默变成未读 —— 一个没人会注意到、却
|
||
让「已读/未读」全面失真的坑。
|
||
|
||
# status 在这里怎么算
|
||
|
||
admin 不是任何一封信的读者,没有「他读没读过」这回事。
|
||
故 `status` 恒为 `'all'`(而不是伪造一个 'read'),与 handler 的
|
||
`status=all` 语义一致 —— 前端不会因为它把邮件染成"已读"。
|
||
*/
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
"github.com/agentmail/gateway/internal/models"
|
||
)
|
||
|
||
// ListAllMails 列出**全部**邮件(不限收件人),仅供 admin 视图。
|
||
//
|
||
// ★ 调用方必须已经校验过 IsAdmin() —— 这个函数本身不做鉴权,
|
||
//
|
||
// 与 repo 层其它函数一致(鉴权在 handler/middleware)。
|
||
func ListAllMails(ctx context.Context, status string, limit int) ([]models.Mail, error) {
|
||
if limit <= 0 {
|
||
limit = 50
|
||
}
|
||
|
||
// 复用 ListInboxScoped 的列清单(含附件/权限/发件人是否人类等派生列),
|
||
// 只把 WHERE 换掉:没有 reader 过滤,status 直接取参数。
|
||
// ★ 不用 'read'/'unread' 的 readStateFor —— admin 不是读者。
|
||
q := `SELECT m.mail_id, m.session_id, m.parent_mail_id,
|
||
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
|
||
m.cc_list, m.subject, m.body, m.mail_type,
|
||
COALESCE(m.permission_result,'') AS permission_result,
|
||
COALESCE(m.permission_kind,'') AS permission_kind,
|
||
COALESCE(m.permission_multi_select,0) AS permission_multi_select,
|
||
$1 AS status, m.created_at, s.session_alias, s.workspace,
|
||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
|
||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human,
|
||
COALESCE(NULLIF(s.permission_mode, ''), 'workspace') AS permission_mode,
|
||
COALESCE(NULLIF(s.permission_enforcement, ''), 'advisory') AS permission_enforcement
|
||
FROM mails m
|
||
JOIN sessions s ON m.session_id = s.session_id
|
||
WHERE s.status <> 'archived'`
|
||
|
||
args := []any{statusOrAll(status)}
|
||
|
||
switch args[0] {
|
||
case "unread", "read":
|
||
// admin 全量视图里「未读」没有 reader 维度可依 ⇒ 明确不支持,
|
||
// 而不是悄悄返回全部(那会让前端把整箱染成"未读")。
|
||
return nil, fmt.Errorf("admin 全量视图不支持 status=%s(未读是按读者记的,admin 不是任何一封信的读者)", args[0])
|
||
}
|
||
|
||
// limit 直接内联(与 ListInboxScoped 同做法:不进参数位)——
|
||
// 它是 int 且已在上面夹到 >0,不是注入面。
|
||
q += fmt.Sprintf(` ORDER BY m.created_at DESC, m.mail_id DESC LIMIT %d`, limit)
|
||
|
||
rows, err := db.DB.QueryContext(ctx, q, args...)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("列全部邮件: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
// ★ 复用 ListInboxScoped 的扫描(scanMailRows):列清单与派生字段
|
||
// 必须与普通收件箱**完全一致**,否则 admin 视图会少算一个字段
|
||
// (cc 解析 / ReplyAddress / BodyPreview / 权限截止时间)。
|
||
return scanMailRows(rows)
|
||
}
|
||
|
||
func statusOrAll(s string) string {
|
||
if s == "" {
|
||
return "all"
|
||
}
|
||
return s
|
||
}
|