Files
MailUI4Agents/gateway/internal/notify/mail.go
JianFeeeee c297468819 修 platform_session_id 无差别下发导致抄送方邮件静默消失 + homeagent 补投漏去重
## platform_session_id 只发给归属方(Gateway)

`notify.Recipients` 原来对所有参与方推同一个 `platform_session_id`,
而那是**会话级**的一个值。生产实测:会话 16845133 接管了 pi 的平台会话
`01a05a5e-…`,那封邮件抄送了 dsh@/home/program/agentmail.new。DSH 收到
同一个 id,在 ~/.dsh/sessions/ 里查不到(那是 /root/.pi/agent/sessions/
下的文件),于是走进「平台侧会话已删」那道防线抛错。

那道防线本身是对的(N-8:不能退回新建,否则人在界面上看不到这封邮件带来
的对话),它拦下的却是「别人的会话」。异常被 ctx.logger.error 吞掉,而
DSH 的 logger 不进 journalctl —— 邮件静默消失,日志里一个字都没有。

- 新增 `repo.PlatformSessionFor` 一并返回归属 Agent:以镜像
  `agent_platform_sessions.agent_name` 为准,镜像整表替换后退回
  `sessions.from_agent`(AdoptPlatformSession 写在那里)
- `PlatformIDOf` 变薄封装,保留原签名
- `notify.Recipients` 加 `platformFor(forName)`:归属方以外一律空串;
  归属抽不到时(owner 空)也不下发 —— 宁可退回当普通会话处理,
  也不让一个抽不到归属的 id 把邮件弄丢
- 归属与收件角色无关:归属方在抄送位上同样拿到

## homeagent catchUp 漏 deliveredMails 去重

`go p.catchUp(…)` 与 `go p.sseLoop()` 是两个并发 goroutine,重启时窗口
重叠:SSE 推一次 + 补投拉一次 = 同一封邮件注入两遍。homeagent 的回信正文
印证了这一点(「之前的对话时序中已经收到并确认过多次了」)。另三个插件的
catchUp 都有这层双查,只有这里漏了。

去重放在循环内逐封查而不是拉完一批再筛:InjectInputSync 一封要跑几十秒,
那期间 SSE 完全可能已经投过后面那几封。

## DSH 接管失败改用 console.error

DSH 的 ctx.logger 不进 journalctl,投递失败是「发件人等不到回信」的唯一
线索。接管失败点与 SSE 分发的 catch 都改走 console.error,并带上 mail_id
与发件人。

## 前端 ccAddress 移除(收尾上一轮未提交的改动)

cc_list 里的 `.new` 是**原始意图**,不该被替换成主收件人的别名:每个抄送
方的 `.new` 是独立的 —— pi@/x.new 给 pi 开一条、dsh@/x.new 给 dsh 开另一
条,各有自己的别名。数据库存的就是原文。删掉 ccAddress,MailView /
ThreadView 直接显示 c.raw。

## 测试

- `internal/notify/notify_test.go` +3 例:挂真实 SSE 客户端读帧,验
  归属方拿到 / 抄送方为空 / 归属方在抄送位也拿到 / 普通会话全空。
  负向对照跑过:platformFor 无条件返回时两条用例失败
- `internal/repo/platform_owner_test.go` +3 例:镜像取归属、普通会话、
  镜像被清后退回 from_agent
- 修好 web/test/components/replyTarget.test.tsx(上一轮遗留的语法损坏),
  三条 .new 用例改成断言原样保留
- gateway 7 包全绿;web 176 例 + 主题 26;dsh 219 / pi 250 / opencode 201

## 生产验证

- 抄送验证:jianf → pi(接管会话)cc dsh。DSH 正常建会话并回信「收到」,
  pi 走接管续谈 —— 两封回信都落在同一条线索上(此前 DSH 那封不存在)
- homeagent 去重:连发两轮,其中一轮在邮件未处理完时重启 homeagent 造出
  SSE/catchUp 并发窗口,两轮都只产生一封 Re:
- homeagent SSE:换新 plugin.bin 后连续 89 分钟零断连(此前 2 小时 102 次
  deadline exceeded 自激振荡)
2026-09-04 19:06:36 +08:00

184 lines
7.9 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 notify 是「一封邮件落库之后要通知谁、推什么」的**唯一实现**。
//
// # 为什么单独成包
//
// 在此之前有两份几乎相同的推送代码:`handler.notifyRecipients`(人发信、
// Agent 发信、转发都走它)和 `scheduler` 里日历提醒自己拼的那一份。
//
// 两份代码的代价在生产上兑现过一次,而且症状离原因很远:给 `new_mail` 加
// `platform_session_id` 字段时只改了 handler 那份,调度器那份仍是旧的。
// 于是日历提醒投进一条**接管会话**时,插件收不到 `platform_session_id`
// 把它当成新会话另开了一条平台会话;那条新会话的名字随后经命名同步回写,
// **把接管会话的别名冲掉了** —— 人在补全里选中的「项目定位」变成了
// 「日程提醒:…」,同一条会话因此在候选列表里出现两次,而另一条真实会话
// 被按别名字符串去重吃掉了。
//
// 链条上每一环都不报错。根因只是「同一件事写了两遍」。
//
// 因此这个包对外只暴露一个入口:新增字段时不存在「另一处忘了改」的可能。
package notify
import (
"context"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/agentmail/gateway/internal/sse"
"github.com/google/uuid"
)
// Mail 描述一封刚落库的邮件需要推给谁。
type Mail struct {
SessionID uuid.UUID
MailID uuid.UUID
// From 是发件方名字。人类用户名与 Agent 名共享命名空间,这里不区分。
From string
// To 是主收件方地址(三维寻址已解析)。
To models.Address
// CC 是抄送方地址列表。
CC []models.Address
// Subject 是邮件主题。
Subject string
// MailType 默认 "normal";权限请求等特殊类型由调用方指定。
MailType string
// Origin 标记这封信的来源,供插件与 UI 区分「定时提醒」与「有人在找它」。
// 空串表示普通邮件。
Origin string
// ReplyToName 是「把回信发回这条会话」时该写的收件人名。
//
// 默认取 From。日历提醒必须覆盖它发件人是 `calendar`,而那不是一个
// 收得到信的账号 —— 回给它的信投不出去。此时应当填主收件方自己的名字,
// 让模型把结果回报到同一条线索上。
ReplyToName string
}
// Recipients 把一封邮件推给主收件人、所有抄送方,并刷新发件方的会话列表。
//
// # 每个收件方拿到的是**自己那个地址**
//
// 三维地址 `name@path.session` 的 path 就是工作目录,插件靠它建会话。
// 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个不同的工作区,共用一份
// payload 会让抄送方在别人的目录里开会话。`reply_address` / `self_address`
// 同理,且 session 位已经把 `.new` 换成真实别名 —— `.new` 建完会话就失效了,
// 把原文那个 `x@/p.new` 送给参与方只会让它下一次又建一条新会话。
//
// # 抄送方必须单独推
//
// 漏掉的后果很隐蔽:邮件的 cc_list 里有他们、他们**查**收件箱能看到这封信,
// 但没有任何事件推给他们 —— 插件不会唤起会话Agent 直到下一次补拉
// (重启时)才发现。对「知情方」而言等于没通知。
func Recipients(ctx context.Context, m Mail) {
// 别名此时应已由会话解析路径保证存在(`.new` 与默认会话都过
// EnsureSessionAlias。仍可能为空的情形命名写入失败已吐日志
// 此时退回省略 session 位,而不是把 "new" 写进去 —— 后者会让参与方
// 反复建新会话。
alias := repo.SessionAliasOf(ctx, m.SessionID)
// 这条会话是否接管了一条平台侧已存在的会话(人在 TUI/GUI 里开的那种),
// 以及那条平台会话属于哪个 Agent。插件据此决定 resume 还是新建;
// 空串就是过去的行为。
//
// **owner 必须参与分发判据**platform_id 是会话级的一个值,而一封邮件
// 可以有多个参与方。无差别下发会让抄送方拿一个属于别的平台的会话 id
// 去自己磁盘上找文件,找不到就抛「平台侧会话已删」—— 邮件静默消失。
// 生产实测过pi 的会话 `01a05a5e-…` 被推给了抄送方 dsh。
platformID, platformOwner := repo.PlatformSessionFor(ctx, m.SessionID)
mailType := m.MailType
if mailType == "" {
mailType = "normal"
}
replyTo := m.ReplyToName
if replyTo == "" {
replyTo = m.From
}
// platformFor 只把 platform_session_id 给归属方。
//
// owner 为空镜像里没这条、sessions.from_agent 也空)时一律不下发:
// 宁可退回「当普通会话处理」(插件新建一条,人在界面上看不到),
// 也不能让一个抽不到归属的 id 把邮件弄丢。
platformFor := func(forName string) string {
if platformID == "" || platformOwner == "" || forName != platformOwner {
return ""
}
return platformID
}
payload := func(role, workspace, forName string) map[string]interface{} {
p := map[string]interface{}{
"mail_id": m.MailID.String(),
"session_id": m.SessionID.String(),
"from_name": m.From,
"subject": m.Subject,
"mail_type": mailType,
"role": role, // to / cc
// to_workspace 是收件方地址的 path 位,即希望它在哪个工作目录干活。
// 不带这一项的后果:插件只能自己拼一个临时目录,于是每封邮件都落在
// 不同的空目录里DSH / opencode 按 cwd 分组时全进「未分组」。
"to_workspace": workspace,
// session_alias 是这条会话今后的寻址名。没有它的话,收到 `.new`
// 邮件的一方只持有一个 send_mail 不接受的 session_id。
"session_alias": alias,
// reply_address 是「把回信发回这条会话」的现成地址。
// 插件不必自己拼(拼错了就是静默开新会话)。
"reply_address": models.FormatAddress(replyTo, "", alias),
// self_address 是对方应当用来称呼自己的地址,供转发/报告时引用。
"self_address": models.FormatAddress(forName, workspace, alias),
// platform_session_id 非空时,这封邮件要投进**平台侧已经存在的
// 那条会话**TUI 与邮箱是同一个 Agent 的两个入口)。
//
// 插件必须 resume 而不是新建:新建会让人在 TUI 里看不到这封邮件
// 带来的对话,而那正是接管这条会话的目的。
//
// **只发给归属方**:其余参与方拿到它只会去自己磁盘上找一个
// 不存在的会话文件,然后按 N-8 报错丢掉这封邮件。
"platform_session_id": platformFor(forName),
}
if m.Origin != "" {
p["origin"] = m.Origin
}
return p
}
update := map[string]interface{}{
"session_id": m.SessionID.String(),
"status": "active",
}
// 参与方去重:收件人 + 所有抄送 + 发件方自己(刷新他的发件箱)
seen := map[string]bool{}
sse.Default.SendToRecipient(m.To.Name, "new_mail", payload("to", m.To.Path, m.To.Name))
sse.Default.SendToRecipient(m.To.Name, "session_update", update)
seen[m.To.Name] = true
for _, c := range m.CC {
if seen[c.Name] {
continue
}
seen[c.Name] = true
sse.Default.SendToRecipient(c.Name, "new_mail", payload("cc", c.Path, c.Name))
sse.Default.SendToRecipient(c.Name, "session_update", update)
}
if !seen[m.From] {
sse.Default.SendToRecipient(m.From, "session_update", update)
}
}
// SessionActive 只刷新某一方的会话列表,不推 new_mail。
//
// 用于「日历事件的创建者该知道提醒发出去了」这类场景:他不是收件方,
// 不该收到一封信的通知,但需要看到那条会话活跃起来 —— 否则
// 「我设的提醒到底触发了没有」只能去翻 journalctl。
func SessionActive(name string, sessionID uuid.UUID) {
if name == "" {
return
}
sse.Default.SendToRecipient(name, "session_update", map[string]interface{}{
"session_id": sessionID.String(),
"status": "active",
})
}