Files
MailUI4Agents/server/internal/repo/admin_mail.go
JianFeeeee 7b0207b621 feat(线索树)★★: 卡片视图接线 + C 层 admin 全量邮件
## ① 卡片视图(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 设备判据的红)。
2026-10-04 12:03:58 +08:00

102 lines
4.2 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
/*
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
}