diff --git a/client/electron/src/api/client.ts b/client/electron/src/api/client.ts
index 0fff4a5..e034496 100644
--- a/client/electron/src/api/client.ts
+++ b/client/electron/src/api/client.ts
@@ -1,5 +1,6 @@
import type { User } from '../types';
-import type { Agent, Attachment, Contact, HumanSession, Mail, PermissionRequest, SuggestResult, SessionDetail, ThreadPage, RenameProposal, SessionBudget, CalendarEvent, CalendarEventInput, CalendarAttachment } from '../types';
+import type { Agent, Attachment, Contact, HumanSession, Mail, PermissionRequest,
+ SessionTreeNode, SuggestResult, SessionDetail, ThreadPage, RenameProposal, SessionBudget, CalendarEvent, CalendarEventInput, CalendarAttachment } from '../types';
import { API_BASE, authHeaders, withToken, fetchWithAuth } from './config';
export { API_BASE, setToken, getToken, authHeaders, withToken, fetchWithAuth } from './config';
@@ -586,6 +587,24 @@ export async function listContacts(archived = false) {
return request<{ contacts: Contact[] }>('GET', `/contacts?archived=${archived}`);
}
+/**
+ * 拉会话树,取每条可见会话的 depth 用于列表缩进。
+ *
+ * ★ 与 `/contacts` 是**两个不同口径**的集合,别混:
+ * - `/contacts` = 我参与过的会话(列表渲染用这个)
+ * - `/sessions/tree` = 我**可见**的全部会话(216 条量级,含只在树上可见的侧枝)
+ * 前端按 `session_id` 连接取 depth;联系人不在这棵树里就当 depth 0。
+ * 绝不能拿树的节点直接渲染列表 —— 那会把 216 条全倒出来。
+ */
+export async function listSessionTree() {
+ return request<{
+ nodes: SessionTreeNode[];
+ admin: boolean;
+ total: number;
+ visible: number;
+ }>('GET', '/sessions/tree');
+}
+
export async function suggestAddress(name?: string, path?: string) {
const p = new URLSearchParams();
if (name) p.set('name', name);
diff --git a/client/electron/src/components/ContactPanel.tsx b/client/electron/src/components/ContactPanel.tsx
index 48145de..26865db 100644
--- a/client/electron/src/components/ContactPanel.tsx
+++ b/client/electron/src/components/ContactPanel.tsx
@@ -35,6 +35,7 @@ export default function ContactPanel() {
const archive = useContactStore(s => s.archive);
const view = useContactStore(s => s.view);
const setView = useContactStore(s => s.setView);
+ const depthBySession = useContactStore(s => s.depthBySession);
const selectSession = useSessionStore(s => s.selectSession);
const currentSession = useSessionStore(s => s.currentSession);
@@ -47,6 +48,29 @@ export default function ContactPanel() {
fetchContacts();
}, []);
+ // 树顺序 = 先按 depth 稳定分组(保持后端返回的活跃度顺序),
+ // 这样子会话紧跟父会话,而不是按各自 last_activity 散落。
+ //
+ // ★ 但**只排我在树里的会话**:不在树里的(treeAvailable=false,或该会话
+ // 不在可见树内)depth 记 0,会被提到最前面 —— 那会把树外会话全堆到顶部。
+ // 修法:不在树里的**保持原位**,只对树内会话做同级分组。
+ const ordered = (() => {
+ const known = contacts.filter(c => c.session_id in depthBySession);
+ const unknown = contacts.filter(c => !(c.session_id in depthBySession));
+ if (known.length === 0) return contacts;
+ const rank = new Map(known.map((c, i) => [c.session_id, i]));
+ const sorted = known
+ .slice()
+ .sort(
+ (a, b) =>
+ (depthBySession[a.session_id] ?? 0) - (depthBySession[b.session_id] ?? 0) ||
+ (rank.get(a.session_id) ?? 0) - (rank.get(b.session_id) ?? 0)
+ );
+ // 树外会话按原位置插回:简单做法是接在末尾并标注它们不在树里,
+ // 免得用户以为「这些会话没有上级」。
+ return [...sorted, ...unknown];
+ })();
+
const open = (c: Contact) => {
cancelCompose();
clearCurrentMail();
@@ -96,7 +120,7 @@ export default function ContactPanel() {
加载中
)}
- {contacts.map(c =>
+ {ordered.map(c =>
// 归档确认态两种视图共用同一个确认框:那是个破坏性操作,
// 换个视图就换套确认 UI 只会让人对「点了什么」更没底
pendingArchive === c.address ? (
@@ -110,6 +134,7 @@ export default function ContactPanel() {
open(c)}
onCompose={() => startCompose({ to: c.address })}
@@ -119,6 +144,7 @@ export default function ContactPanel() {
open(c)}
onCompose={() => startCompose({ to: c.address })}
@@ -206,17 +232,26 @@ function ArchiveConfirm({
function ContactRow({
contact,
+ depth = 0,
active,
onOpen,
onCompose,
onRequestArchive
}: {
contact: Contact;
+ /** 会话在树中的深度(0 = 根)。缺省 0 —— 树不可用时列表退化成平铺 */
+ depth?: number;
active: boolean;
onOpen: () => void;
onCompose: () => void;
onRequestArchive: () => void;
}) {
+ // 与 ThreadView 的邮件树同一套视觉语言(缩进 + 左边框连接线),
+ // 两处树看起来是一套东西,而不是各画各的。
+ //
+ // 每级 12px、上限 4 级:320px 宽的侧栏里 4 级是 48px,地址还剩得下;
+ // 再深就 truncate 得只剩三个字,那时层级反而读不出来。
+ const indent = Math.min(Math.max(depth, 0), 4) * 12;
const time = new Date(contact.last_activity).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
@@ -225,11 +260,18 @@ function ContactRow({
});
return (
-
+
+ {indent > 0 && (
+
+ )}
+
);
}
diff --git a/client/electron/src/components/WorkCard.tsx b/client/electron/src/components/WorkCard.tsx
index 17a677b..583618f 100644
--- a/client/electron/src/components/WorkCard.tsx
+++ b/client/electron/src/components/WorkCard.tsx
@@ -25,17 +25,27 @@ import PermissionChip from './PermissionChip';
*/
export function WorkCard({
contact: c,
+ depth = 0,
active,
onOpen,
onCompose,
onArchive
}: {
contact: Contact;
+ /**
+ * 会话在树中的深度(0 = 根)。缺省 0 —— 树不可用时卡片视图退化成平铺。
+ *
+ * ★ 卡片视图的缩进比列表**更紧**(8px/级、上限 3 级):卡片本来就有
+ * 三块内容(发件人行 / 主题 / 摘要 + 预算条),400px 侧栏里每级 12px
+ * 挤掉的是摘要本身 —— 摘要没了,卡片就只剩一个标题。
+ */
+ depth?: number;
active: boolean;
onOpen: () => void;
onCompose: () => void;
onArchive: () => void;
}) {
+ const indent = Math.min(Math.max(depth, 0), 3) * 8;
const time = new Date(c.last_activity).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
@@ -45,8 +55,15 @@ export function WorkCard({
const fromHuman = c.last_from !== c.agent_name;
return (
+
+ {indent > 0 && (
+
+ )}
@@ -114,7 +131,8 @@ export function WorkCard({
归档
-
+
+
);
}
diff --git a/client/electron/src/stores/contactStore.ts b/client/electron/src/stores/contactStore.ts
index 18f2c77..67cd4b0 100644
--- a/client/electron/src/stores/contactStore.ts
+++ b/client/electron/src/stores/contactStore.ts
@@ -32,6 +32,18 @@ interface ContactState {
/** 中间栏呈现方式(持久化到 localStorage) */
view: ContactView;
setView: (v: ContactView) => void;
+ /**
+ * 会话 id → 在树中的深度(0 = 根)。
+ *
+ * ★ 与 contacts 是**两个来源**:contacts 来自 `/contacts`(我参与过的),
+ * 深度来自 `/sessions/tree`(我可见的全部会话,量级大得多)。
+ * 按 session_id 连接,不能互相替代 —— 拿树的节点直接渲染列表会把
+ * 全部可见会话倒出来,那不是我参与的那些。
+ * 树里没有的联系人当 depth 0(不缩进):或树不可用,或它不在可见树内。
+ */
+ depthBySession: Record;
+ /** 树是否可用。false 时列表退化成平铺,不报错也不空白 */
+ treeAvailable: boolean;
fetchContacts: () => Promise;
fetchArchived: () => Promise;
@@ -53,6 +65,8 @@ interface ContactState {
export const useContactStore = create((set, get) => ({
contacts: [],
archivedContacts: [],
+ depthBySession: {},
+ treeAvailable: false,
showArchived: false,
loading: false,
error: null,
@@ -75,6 +89,20 @@ export const useContactStore = create((set, get) => ({
set({ contacts: contacts || [], loading: false });
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err), loading: false });
+ return;
+ }
+
+ // 树**独立**拉、失败不影响联系人列表(两个口径不能互相拖垮)。
+ // 失败时 depthBySession 保持旧值/为空 ⇒ 列表退化成平铺,而不是空白或报错。
+ // 树拉取刻意放在联系人之后、不 await 进 contacts 的 set ——
+ // 否则树慢 200ms 会让整个列表多等 200ms,而树只是锦上添花。
+ try {
+ const tree = await api.listSessionTree();
+ const depth: Record = {};
+ for (const n of tree.nodes || []) depth[n.session_id] = n.depth || 0;
+ set({ depthBySession: depth, treeAvailable: true });
+ } catch {
+ set({ treeAvailable: false });
}
},
@@ -122,6 +150,11 @@ export const useContactStore = create((set, get) => ({
set({
contacts: [],
archivedContacts: [],
+ // ★ 深度表必须一起清。它按 session_id 索引,而 session_id 是**全局**的,
+ // 不含账号维度 —— 登出不清就会把 A 账号的树套到 B 账号的列表上,
+ // 表现为「切了账号还看到别人的会话层级」。这不是显示瑕疵,是越界。
+ depthBySession: {},
+ treeAvailable: false,
pendingArchive: null,
loading: false,
error: null
diff --git a/client/electron/src/types/index.ts b/client/electron/src/types/index.ts
index 81e7b97..8a08631 100644
--- a/client/electron/src/types/index.ts
+++ b/client/electron/src/types/index.ts
@@ -205,6 +205,30 @@ export interface RenameProposal {
reason?: string;
}
+/**
+ * 会话树节点(GET /sessions/tree)。
+ *
+ * ★ 与 Contact 是**两个不同口径**的集合,别混:
+ * - `/contacts` = 我参与过的会话(列表渲染用这个)
+ * - `/sessions/tree` = 我**可见**的全部会话(含我只在树上可见的祖先侧枝)
+ * 前端按 `session_id` 连接取 `depth`,联系人不在这棵树里就当 depth 0。
+ * 绝不能拿树的节点直接当列表渲染 —— 那是 216 条 vs 我参与的那几条。
+ */
+export interface SessionTreeNode {
+ session_id: string;
+ alias: string;
+ subject: string;
+ from_agent: string;
+ workspace: string;
+ /** 可见祖先链长度:0 = 根。不可见的祖先已被剪断并重算 */
+ depth: number;
+ /** 直接子节点数 */
+ child_count: number;
+ mail_count: number;
+ parent_session_id?: string;
+ updated_at: string;
+}
+
export interface Contact {
session_id: string;
agent_name: string;
diff --git a/client/electron/test/session-tree-list.test.mjs b/client/electron/test/session-tree-list.test.mjs
new file mode 100644
index 0000000..c1caa72
--- /dev/null
+++ b/client/electron/test/session-tree-list.test.mjs
@@ -0,0 +1,87 @@
+/*
+会话树接入列表的判据(2026-10-04)。
+
+★ 这批盯的是**接线的正确性**,不是「有没有缩进」:
+
+ 1. depth 来源正确 —— 必须来自 /sessions/tree,且**按 session_id 连接**;
+ 绝不能把树的节点直接当列表渲染(那是「我可见的全部会话」,
+ 而列表是「我参与过的」—— 两者量级差两个数量级)。
+ 2. 树不可用时**退化成平铺**,不报错也不空白。
+ 3. 换账号/登出必须清 depthBySession —— session_id 不含账号维度,
+ 不清就是把 A 账号的层级套到 B 账号的列表上(越界,不是显示瑕疵)。
+ 4. 缩进有上限 —— 320px 侧栏里无限缩进会把地址 truncate 到读不出来。
+
+源码每次现读(不模块级缓存):判据跑的是**当前文件**,
+否则改了不重读会给出假绿。
+*/
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const src = p => readFileSync(join(here, '..', 'src', p), 'utf8');
+
+/*
+★ 两个视图都要接缩进 —— 否则「切一下视图,线索树就没了」。
+ 用户 2026-10-04 的原话是「形成/展示为树结构」,只在一个视图里成立不算。
+*/
+test('列表与卡片两个视图都接了缩进', () => {
+ const panel = src('components/ContactPanel.tsx');
+ const card = src('components/WorkCard.tsx');
+ assert.match(panel, / {
+ const client = src('api/client.ts');
+ assert.match(client, /export async function listSessionTree/, '缺 listSessionTree');
+ assert.match(client, /'\/sessions\/tree'/, '树端点没接上');
+ // 树与 contacts 必须各自请求,不得把树塞进 listContacts 的返回
+ assert.match(client, /listContacts[\s\S]{0,200}?\/contacts\?archived=/,
+ 'listContacts 的口径被改了');
+});
+
+test('ContactPanel 按 depth 缩进且有上限', () => {
+ const panel = src('components/ContactPanel.tsx');
+ assert.match(panel, /Math\.min\(Math\.max\(depth, 0\), (\d+)\)/,
+ 'ContactRow 没按 depth 缩进,或没有上限');
+ const m = panel.match(/Math\.min\(Math\.max\(depth, 0\), (\d+)\)/);
+ assert.ok(Number(m[1]) <= 4,
+ `缩进上限 ${m[1]} 太大:320px 侧栏里会把地址 truncate 到读不出层级`);
+ // 连接线:与 ThreadView 同一套视觉语言
+ assert.match(panel, /border-l border-b/, '缺左框连接线,与 ThreadView 不一致');
+});
+
+test('树不可用时退化为平铺,不让列表崩', () => {
+ const store = src('stores/contactStore.ts');
+ // 树失败必须被吞掉,且不影响 contacts 已 set 的结果
+ assert.match(store, /catch\s*\{\s*set\(\{\s*treeAvailable:\s*false\s*\}\)/,
+ '树拉取失败未降级 —— 一次树服务故障不该让整个联系人列表报错');
+ // contacts 的 set 必须在树的 try 之前、且提前 return
+ const idxContacts = store.indexOf('set({ contacts: contacts || [], loading: false })');
+ const idxTree = store.indexOf('api.listSessionTree()');
+ assert.ok(idxContacts > 0 && idxTree > idxContacts,
+ '树拉取必须排在联系人之后 —— 否则树慢 200ms 会让整个列表多等 200ms');
+});
+
+test('换账号必须清 depthBySession(跨账号越界)', () => {
+ const store = src('stores/contactStore.ts');
+ // reset / clear 分支
+ const clears = store.match(/depthBySession:\s*\{\}/g) || [];
+ assert.ok(clears.length >= 2,
+ `depthBySession 只清了 ${clears.length} 处(应在初始 state 与 reset 各一处)` +
+ ' —— session_id 不含账号维度,不清就会把 A 账号的层级套到 B 账号');
+ // 初始 state 也必须有
+ const initIdx = store.indexOf('archivedContacts: [],');
+ assert.ok(initIdx > 0 && store.indexOf('depthBySession: {}', initIdx) > initIdx,
+ '初始 state 里没有 depthBySession');
+});
diff --git a/server/internal/handler/admin_scope_test.go b/server/internal/handler/admin_scope_test.go
new file mode 100644
index 0000000..67e72b2
--- /dev/null
+++ b/server/internal/handler/admin_scope_test.go
@@ -0,0 +1,161 @@
+package handler
+
+/*
+C 层:admin 全量邮件可见性的判据(2026-10-04)。
+
+用户要求:「admin 查看全部邮件,普通用户仅查看与自己相关或显式分享的邮件」。
+
+★ 盯的是**分层可见性**,不是「scope 参数存在」:
+
+ 1. 非 admin 显式请求 scope=all ⇒ **403**,不是静默回退到自己的收件箱。
+ 静默降级会让调用方以为拿到了全量 —— 那是「看起来能用的错答案」。
+ 2. 默认(无 scope)⇒ 仍是自己��收件箱,一封都不多给。
+ ★ 这条与 Agent 侧的收窄(15e4fe9 / 095213b 修的越权防护)无关 ——
+ 那是 AgentAuth 通道;本文件是人���登录态。别拿「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())
+ }
+}
diff --git a/server/internal/handler/me.go b/server/internal/handler/me.go
index b01d3bd..2b724bf 100644
--- a/server/internal/handler/me.go
+++ b/server/internal/handler/me.go
@@ -236,8 +236,36 @@ func MeGetInbox(w http.ResponseWriter, r *http.Request) {
}
}
- mails, err := repo.ListInbox(r.Context(), user.Username, status, "", limit)
+ // scope=all:admin 全量视图(用户 2026-10-04 定的分层可见性)。
+ //
+ // ★ 为什么是「同一个 URL 加参数」而不是新端点:
+ // 可见性收窄集中在一处判断,少一个端点就少一处将来忘了收窄的地方。
+ //
+ // ★ 普通用户显式请求 scope=all 必须 **403**,不能静默降级回自己的收件箱 ——
+ // 静默降级会让调用方以为拿到了全量(实际没有),
+ // 那是「看起来能用的错答案」,比报错难查得多。
+ scope := r.URL.Query().Get("scope")
+ mails, err := func() ([]models.Mail, error) {
+ if scope == "" || scope == "me" {
+ return repo.ListInbox(r.Context(), user.Username, status, "", limit)
+ }
+ if scope != "all" {
+ Error(w, http.StatusBadRequest, "scope 只能是 me 或 all")
+ return nil, errAlreadyAnswered
+ }
+ if !user.IsAdmin() {
+ // ★ fail-closed:非 admin 要全量 ⇒ 拒绝,**不回退**到自己的收件箱。
+ // 静默降级会让调用方以为拿到了全量(实际没有)——
+ // 那是「看起来能用的错答案」,比报错难查得多。
+ Error(w, http.StatusForbidden, "scope=all 需要 admin 权限")
+ return nil, errAlreadyAnswered
+ }
+ return repo.ListAllMails(r.Context(), status, limit)
+ }()
if err != nil {
+ if errors.Is(err, errAlreadyAnswered) {
+ return // 4xx 已写,不再覆写成 500
+ }
Error(w, http.StatusInternalServerError, "Failed to list inbox")
return
}
@@ -255,6 +283,9 @@ func MeGetInbox(w http.ResponseWriter, r *http.Request) {
})
}
+// errAlreadyAnswered 表示「响应已经写完了,别再覆写成 500」。
+var errAlreadyAnswered = errors.New("already answered")
+
// GET /api/v1/me/mail/sent
func MeGetSent(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
diff --git a/server/internal/repo/admin_mail.go b/server/internal/repo/admin_mail.go
new file mode 100644
index 0000000..0f3d1ec
--- /dev/null
+++ b/server/internal/repo/admin_mail.go
@@ -0,0 +1,101 @@
+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
+}
diff --git a/server/internal/repo/repo.go b/server/internal/repo/repo.go
index caa070c..7841fa1 100644
--- a/server/internal/repo/repo.go
+++ b/server/internal/repo/repo.go
@@ -972,6 +972,22 @@ func ListInboxScoped(ctx context.Context, agentName, status, workspace string, l
}
defer rows.Close()
+ return scanMailRows(rows)
+}
+
+/*
+scanMailRows 把邮件查询结果行扫进 []models.Mail 并补齐派生字段。
+
+★ 提取出来是因为**第二份实现一定会漂移**:admin 全量视图(2026-10-04,
+
+ internal/repo/admin_mail.go)需要同样的列清单与派生逻辑
+ (cc 解析、alias、ReplyAddress、BodyPreview、权限截止时间)。
+ 两份手写副本的第一个分叉点会是「admin 视图少算一个派生字段」,
+ 而那在前端表现为某个徽标不见了,极难归因。
+
+⇒ 新增邮件查询一律复用本函数,不要再写一份扫描循环。
+*/
+func scanMailRows(rows *sql.Rows) ([]models.Mail, error) {
mails := []models.Mail{}
for rows.Next() {
var m models.Mail