// 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 里开的那种)。 // 插件据此决定 resume 还是新建;空串就是过去的行为。 platformID := repo.PlatformIDOf(ctx, m.SessionID) mailType := m.MailType if mailType == "" { mailType = "normal" } replyTo := m.ReplyToName if replyTo == "" { replyTo = m.From } 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 里看不到这封邮件 // 带来的对话,而那正是接管这条会话的目的。 "platform_session_id": platformID, } 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", }) }