diff --git a/plugins/pi-mail-bridge/src/tools.mjs b/plugins/pi-mail-bridge/src/tools.mjs index 3e458d8..3fb40c4 100644 --- a/plugins/pi-mail-bridge/src/tools.mjs +++ b/plugins/pi-mail-bridge/src/tools.mjs @@ -61,7 +61,17 @@ const text = (s) => ({ content: [{ type: 'text', text: s }] }); * opencode / dsh / homeagent 三个平台都没写这一行,只有这里写了 —— 它不是 * 「更严格更好」,而是与 pi 的参数传递机制直接冲突。 */ -export function createMailTools({ client, log, agentName = '', onReconnect }) { +export function createMailTools({ client, log, agentName = '', onReconnect, getMailSessionId = () => ''}) { + // 收件箱列表的会话收窄参数。 + // + // ★ 缺了它,这条列表会把**别的会话**的来信一起列出来并按契约标成已读 —— + // 别的会话的 worker 之后按 ?status=unread 补投时就再也看不到那封信。 + // 用户原话:「不同 session 的 agent 都可以看到全部邮件」。 + const inboxScope = () => { + const sid = typeof getMailSessionId === 'function' ? getMailSessionId() : ''; + return sid ? `&session_id=${encodeURIComponent(sid)}` : ''; + }; + const sendMail = { name: 'send_mail', label: 'SendMail', @@ -142,7 +152,7 @@ export function createMailTools({ client, log, agentName = '', onReconnect }) { async execute(_id, params) { const status = params.status || DEFAULT_INBOX_STATUS; const { mails } = await client.get( - `/mail/inbox?status=${encodeURIComponent(status)}&limit=${params.limit || DEFAULT_INBOX_LIMIT}`, + `/mail/inbox?status=${encodeURIComponent(status)}&limit=${params.limit || DEFAULT_INBOX_LIMIT}${inboxScope()}`, ); // 渲染与已读策略走共用模块:与另两个平台必须一致, diff --git a/plugins/pi-mail-bridge/src/worker.mjs b/plugins/pi-mail-bridge/src/worker.mjs index 3c7078e..5490363 100644 --- a/plugins/pi-mail-bridge/src/worker.mjs +++ b/plugins/pi-mail-bridge/src/worker.mjs @@ -96,7 +96,10 @@ let client = null; const decidedExtra = new Map(); let modelRuntime = null; let piSessionId = ''; -let mailContext = { replyTo: '', subject: '', mailID: '', permissionMode: 'workspace' }; +// sessionID = **AgentMail 的邮件会话 id**(不是 pi 的 session id)。 +// read_inbox 要靠它把自己那条会话的邮件与别的会话区分开 —— 少了它,A 会话的 +// worker 会把 B 会话的未读一起列出来并标掉(2026-09-14 用户报的缺陷)。 +let mailContext = { replyTo: '', subject: '', mailID: '', sessionID: '', permissionMode: 'workspace' }; let lastSyncedName = ''; let relayedKey = ''; let finished = false; @@ -478,6 +481,8 @@ async function run() { // connect_to_server 在 worker 里换了坐标要让主进程知道:worker 马上就退了, // 改在自己身上等于没改。主进程收到后重建 SSE 并写进后续 worker 的 job。 const mailTools = createMailTools({ + // read_inbox 用它在服务端把列表收窄到自己这条会话。 + getMailSessionId: () => mailContext.sessionID, client, log, agentName: job.config.agentName, onReconnect: () => send({ type: 'reconfigure', url: client.baseURL, agentKey: client.agentKey, @@ -653,6 +658,7 @@ process.on('message', (msg) => { replyTo: msg.data?.from_name || '', subject: msg.data?.subject || '', mailID: msg.data?.mail_id || '', + sessionID: msg.data?.session_id || '', permissionMode: msg.data?.permission_mode || 'workspace', }; lastSyncedName = msg.lastSyncedName || ''; diff --git a/plugins/pi-mail-bridge/test/inbox-session-scope.test.mjs b/plugins/pi-mail-bridge/test/inbox-session-scope.test.mjs new file mode 100644 index 0000000..618cb0e --- /dev/null +++ b/plugins/pi-mail-bridge/test/inbox-session-scope.test.mjs @@ -0,0 +1,35 @@ +/** + * read_inbox 必须把列表收窄到**自己那条会话**(2026-09-14 用户报的缺陷)。 + * + * 「不同 session 的 agent 都可以看到全部邮件」—— 列表按 Agent 列,还按契约把列出的 + * 都标已读 ⇒ A 会话的 worker 会标掉 B 会话的未读,而 B 之后按 `?status=unread` + * 补投时就再也看不到那封信(静默丢信)。现场实例:另一条会话的来信被我在读自己 + * 收件箱的时刻标成已读(mail_reads 里那行的 reader=pi、时间就是那次读取)。 + * + * 这里只验**接线**(工具把 session_id 传出去了、worker 把邮件会话 id 递进来了); + * 服务端语义(读 A 不动 B)由 server/internal/repo/session_scope_test.go 负责。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const tools = readFileSync(join(HERE, '..', 'src', 'tools.mjs'), 'utf8'); +const worker = readFileSync(join(HERE, '..', 'src', 'worker.mjs'), 'utf8'); + +test('read_inbox 的 URL 带上会话收窄', () => { + assert.match(tools, /mail\/inbox\?status=[^`]*\$\{inboxScope\(\)\}/, 'URL 必须拼上 inboxScope()'); + assert.match(tools, /session_id=\$\{encodeURIComponent\(sid\)\}/, 'inboxScope 要真的带上 session_id'); +}); + +test('worker 把邮件会话 id 递给工具', () => { + assert.match(worker, /getMailSessionId: \(\) => mailContext\.sessionID/, '要传闭包而不是快照'); + assert.match(worker, /sessionID: msg\.data\?\.session_id/, '会话 id 来自 SSE 事件'); +}); + +test('★ 判据自检:拿没带会话收窄的旧写法喂进来必须判红', () => { + const old = '`/mail/inbox?status=${encodeURIComponent(status)}&limit=${params.limit || DEFAULT_INBOX_LIMIT}`'; + assert.equal(/mail\/inbox\?status=[^`]*\$\{inboxScope\(\)\}/.test(old), false); +}); diff --git a/server/internal/handler/mail.go b/server/internal/handler/mail.go index 7181001..7a70742 100644 --- a/server/internal/handler/mail.go +++ b/server/internal/handler/mail.go @@ -539,7 +539,22 @@ func GetInbox(w http.ResponseWriter, r *http.Request) { } } - mails, err := repo.ListInbox(r.Context(), agentName, status, limit) + // 可选会话收窄:桥的 read_inbox 会带上自己那条会话。 + // + // 不带 = 整个 Agent 的收件箱(旧语义,浏览器/脚本仍可用);带了就只列这条线索 —— + // 否则 A 会话的 worker 会把 B 会话的未读也列出来并标成已读,桥重启后的补投 + // 判据(?status=unread)就再也看不到那封信(用户报的"都能看到全部邮件")。 + var sessionID uuid.UUID + if raw := r.URL.Query().Get("session_id"); raw != "" { + id, perr := uuid.Parse(raw) + if perr != nil { + Error(w, http.StatusBadRequest, "非法的 session_id") + return + } + sessionID = id + } + + mails, err := repo.ListInboxScoped(r.Context(), agentName, status, limit, sessionID) if err != nil { Error(w, http.StatusInternalServerError, "Failed to list inbox") return @@ -637,6 +652,8 @@ func parseInt(s string) (int, error) { } type markReadRequest struct { + // SessionID 可选:带了就只在这条会话内标(见 GetInbox 里那段说明)。 + SessionID string `json:"session_id"` // MailIDs 要标记为已读的邮件;省略/为空 = 把收件箱里全部未读标掉。 MailIDs []string `json:"mail_ids"` } @@ -667,12 +684,22 @@ func MarkInboxRead(w http.ResponseWriter, r *http.Request) { // 不给 id 就把收件箱里全部未读标掉。 // 这是 Agent 最常见的用法:一轮处理完,剩下的都不必再看。 if len(req.MailIDs) == 0 { - n, err := repo.MarkAllInboxReadFor(r.Context(), agentName) + var sessionID uuid.UUID + scope := "all" + if raw := strings.TrimSpace(req.SessionID); raw != "" { + id, perr := uuid.Parse(raw) + if perr != nil { + Error(w, http.StatusBadRequest, "非法的 session_id") + return + } + sessionID, scope = id, "session" + } + n, err := repo.MarkAllInboxReadForSession(r.Context(), agentName, sessionID) if err != nil { Error(w, http.StatusInternalServerError, "Failed to mark read") return } - JSON(w, http.StatusOK, map[string]any{"status": "read", "marked": n, "scope": "all"}) + JSON(w, http.StatusOK, map[string]any{"status": "read", "marked": n, "scope": scope}) return } diff --git a/server/internal/repo/repo.go b/server/internal/repo/repo.go index 5a01ee4..0d2ca86 100644 --- a/server/internal/repo/repo.go +++ b/server/internal/repo/repo.go @@ -504,7 +504,26 @@ func MarkMailRead(ctx context.Context, id uuid.UUID, reader string) error { return err } +/* +─── 会话维度(2026-09-14)─────────────────────────────────────────────── + +用户报的缺陷:「不同 session 的 agent 都可以看到全部邮件」。 + +原先 `read_inbox` 是**按 Agent** 的:列的是该 Agent 的全部未读(含别的会话的来信), +并且按契约把列出来的都标成已读 ⇒ A 会话的 worker 会把 B 会话的未读标掉。 +平时看不出来(SSE 事件在途时队列兜着),但桥重启/漏事件后的补投判据是 +`?status=unread` —— 被别人标掉的那封**再也不会补投** ⇒ 静默丢信。 + +修法:列表与"全部标已读"都支持按 `session_id` 收窄,桥把自己的会话传进来。 +原函数保持原语义(不带会话 = 整个 Agent 的收件箱),新增带会话的变体 —— +老调用点一个都不用改。 +*/ func ListInbox(ctx context.Context, agentName, status string, limit int) ([]models.Mail, error) { + return ListInboxScoped(ctx, agentName, status, limit, uuid.Nil) +} + +// ListInboxScoped 与 ListInbox 相同,但 `sessionID` 非零时只列该会话的邮件。 +func ListInboxScoped(ctx context.Context, agentName, status string, limit int, sessionID uuid.UUID) ([]models.Mail, error) { 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, @@ -520,6 +539,12 @@ func ListInbox(ctx context.Context, agentName, status string, limit int) ([]mode WHERE (m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `) AND s.status <> 'archived'` args := []any{agentName} + if sessionID != uuid.Nil { + // 会话收窄:只列这条线索里的邮件(见上面「会话维度」的说明) + args = append(args, sessionID) + q += fmt.Sprintf(` AND m.session_id = $%d`, len(args)) + } + if status != "" && status != "all" { // 未读/已读都按**这个读者**算(原先直接比 m.status,于是被抄送方读掉 // 别人的未读也跟着变 —— 这就是要修的那条) @@ -1564,19 +1589,37 @@ func MarkMailsReadFor(ctx context.Context, recipient string, ids []uuid.UUID) (i // 排除已归档会话:那些邮件在收件箱里根本看不到, // 标掉它们只会让「标记了 N 封」这个数字与用户看到的对不上。 func MarkAllInboxReadFor(ctx context.Context, recipient string) (int, error) { - if err := markReadFor(ctx, recipient, - `(m.to_name = $1 OR `+db.CCHas("m.cc_list", 1)+`) - AND m.session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived')`, - recipient); err != nil { + return MarkAllInboxReadForSession(ctx, recipient, uuid.Nil) +} + +// MarkAllInboxReadForSession 只标掉某条会话里发给 recipient 的未读。 +// +// 为什么需要:Agent 的「不给 mail_ids,全部标掉」在会话驱动的 worker 里会跨会话 +// 误伤(见 ListInboxScoped 上面那段说明)。不带 sessionID(uuid.Nil)时是旧语义。 +func MarkAllInboxReadForSession(ctx context.Context, recipient string, sessionID uuid.UUID) (int, error) { + scope := `(m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `) + AND m.session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived')` + args := []any{recipient} + if sessionID != uuid.Nil { + scope += ` AND m.session_id = $2` + args = append(args, sessionID) + } + if err := markReadFor(ctx, recipient, scope, args...); err != nil { return 0, err } - // 同上:刷新冗余列,未读判据在 mail_reads - res, err := db.DB.ExecContext(ctx, ` - UPDATE mails SET status = 'read' + // 同上:刷新冗余列,未读判据在 mail_reads。 + // ★ 这条 UPDATE 也必须跟着同一个 scope —— 只给上面的 INSERT 收窄、漏掉它, + // 返回的计数与"实际标掉多少"都会跨会话(测试当场抓到:应当 1 封、实际 2 封)。 + upd := `UPDATE mails SET status = 'read' WHERE status = 'unread' - AND (to_name = $1 OR `+db.CCHas("cc_list", 1)+`) - AND session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived') - `, recipient) + AND (to_name = $1 OR ` + db.CCHas("cc_list", 1) + `) + AND session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived')` + updArgs := []any{recipient} + if sessionID != uuid.Nil { + upd += ` AND session_id = $2` + updArgs = append(updArgs, sessionID) + } + res, err := db.DB.ExecContext(ctx, upd, updArgs...) if err != nil { return 0, err } diff --git a/server/internal/repo/session_scope_test.go b/server/internal/repo/session_scope_test.go new file mode 100644 index 0000000..7b02850 --- /dev/null +++ b/server/internal/repo/session_scope_test.go @@ -0,0 +1,152 @@ +package repo + +import ( + "context" + "testing" + + "github.com/agentmail/gateway/internal/db" + "github.com/agentmail/gateway/internal/models" + "github.com/google/uuid" +) + +/* +会话维度:一个 Agent 同时在多条会话里干活时,**读一条不能动另一条**。 + +# 用户报的缺陷 + +「不同 session 的 agent 都可以看到全部邮件」—— 原先 `read_inbox` 按 **Agent** 列, +并且把列出来的都标成已读 ⇒ A 会话的 worker 会把 B 会话的未读标掉。 + +平时看不出来(SSE 事件在途时队列兜着),但桥重启/漏事件之后的补投判据是 +`?status=unread` —— 被别人标掉的那封信**再也不会补投**。这不是"少看一封", +是静默丢信。现场实例:另一条会话的来信被我在读自己收件箱时标成已读 +(`mail_reads` 里那行的 reader=pi、时间就是那次读取的时刻)。 + +# 判据要两侧都验 + +只验"收窄后看不到别人的"是不够的:把列表直接返回空也能过。所以同时验 +"不带会话时两条都看得到"(反向对照),以及"全标已读"两个变体。 +*/ + +// seedMailInSession 在指定会话里造一封给 recipient 的未读邮件。 +func seedMailInSession(t *testing.T, sessionID uuid.UUID, recipient string) uuid.UUID { + t.Helper() + var id uuid.UUID + if err := db.DB.QueryRowContext(context.Background(), + `INSERT INTO mails (session_id, from_name, to_name, subject, body) + VALUES ($1, 'sender', $2, 's', 'b') RETURNING mail_id`, + sessionID, recipient).Scan(&id); err != nil { + t.Fatal(err) + } + return id +} + +func twoSessionsWithUnread(t *testing.T, agent string) (uuid.UUID, uuid.UUID, uuid.UUID, uuid.UUID) { + t.Helper() + ctx := context.Background() + s1, err := CreateSession(ctx, nil, "human", "会话一", "") + if err != nil { + t.Fatal(err) + } + s2, err := CreateSession(ctx, nil, "human", "会话二", "") + if err != nil { + t.Fatal(err) + } + return s1, s2, seedMailInSession(t, s1, agent), seedMailInSession(t, s2, agent) +} + +func TestInboxListIsScopedBySession(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + s1, s2, m1, m2 := twoSessionsWithUnread(t, "bot") + + // 反向对照:不带会话 = 整个 Agent 的收件箱,两条都看得到 + all, err := ListInbox(ctx, "bot", "unread", 50) + if err != nil { + t.Fatal(err) + } + if !hasMail(all, m1) || !hasMail(all, m2) { + t.Fatalf("不带会话时应当两条都列出来(%d 条)", len(all)) + } + + // ★ 收窄到会话一:只有它那条 + scoped, err := ListInboxScoped(ctx, "bot", "unread", 50, s1) + if err != nil { + t.Fatal(err) + } + if !hasMail(scoped, m1) { + t.Fatal("会话一的邮件应当在会话一的列表里") + } + if hasMail(scoped, m2) { + t.Fatal("★ 会话二的邮件不该出现在会话一的列表里(用户报的缺陷)") + } + + // 会话二同理(别只验一侧 —— 方向反了也能"过") + scoped2, err := ListInboxScoped(ctx, "bot", "unread", 50, s2) + if err != nil { + t.Fatal(err) + } + if hasMail(scoped2, m1) || !hasMail(scoped2, m2) { + t.Fatalf("会话二的列表应只含会话二的邮件") + } +} + +func TestMarkAllReadCanBeScopedToSession(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + s1, s2, _, _ := twoSessionsWithUnread(t, "bot") + + // 只标会话一 + n, err := MarkAllInboxReadForSession(ctx, "bot", s1) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("应当只标掉 1 封,实际 %d", n) + } + left, err := ListInboxScoped(ctx, "bot", "unread", 50, s2) + if err != nil { + t.Fatal(err) + } + if len(left) != 1 { + t.Fatalf("★ 会话二的未读必须还在(实际剩 %d 条)—— 被跨会话标掉就是静默丢信", len(left)) + } + + // 反向对照:不带会话时两条都会被标掉 + if _, err := MarkAllInboxReadFor(ctx, "bot"); err != nil { + t.Fatal(err) + } + if again, _ := ListInbox(ctx, "bot", "unread", 50); len(again) != 0 { + t.Fatalf("不带会话的全标应当清空整个收件箱,实际剩 %d", len(again)) + } +} + +func TestScopedCountsMatchScopedList(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + s1, s2, _, _ := twoSessionsWithUnread(t, "bot") + + // 会话内的未读数(会话列表徽标用)与"只列这条会话"的口径必须一致, + // 否则界面上会出现"徽标 2、列表 1"这种自相矛盾。 + n1, err := CountUnreadInSession(ctx, "bot", s1) + if err != nil { + t.Fatal(err) + } + list1, err := ListInboxScoped(ctx, "bot", "unread", 50, s1) + if err != nil { + t.Fatal(err) + } + if n1 != len(list1) || n1 != 1 { + t.Fatalf("会话一:计数 %d、列表 %d,期望都是 1", n1, len(list1)) + } + _ = s2 +} + +func hasMail(list []models.Mail, id uuid.UUID) bool { + for _, m := range list { + if m.ID == id { + return true + } + } + return false +}