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 }