Files
MailUI4Agents/gateway/internal/handler/agent_discovery.go
JianFeeeee e6fd2fafdc feat: agent 邮件寻址能力全面补齐 + .new 别名替换
## 别名替换(让 .new 邮件可寻址)

repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调

notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new

FormatAddress(name,path,session) 空 path 也必须留 @ 与 .

## Agent 侧寻址发现(五个只读端点)

handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做

repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)

repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空

## 共用模块(三插件逐字节相同)

lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
                  renderParticipants/renderContacts/renderThread

lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
  - selfName 参数(兼容旧调用不传的情况)

check-shared-libs.sh 纳入 addressing + discovery

## 插件侧

opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)

dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)

## 测试

repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
  → dsh 用 session_participants 取到地址 → send_mail 给 opencode
  → 地址取自工具返回值(.crisp-planet),未手工拼写
2026-09-03 12:09:12 +08:00

331 lines
12 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 handler
import (
"net/http"
"strings"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/google/uuid"
)
// Agent 侧的寻址发现与线索读取。
//
// # 为什么需要这一组端点
//
// 在这之前Agent 能读的只有自己的收件箱。`/agents`、`/contacts`、
// `/contacts/suggest`、`/mail/{id}/thread`、`/sessions/{id}` 全部挂在
// `middleware.UserAuth` 后面Agent 密钥一律 401。后果是 `send_mail` 的 `to`
// 成了一个**只能靠记忆拼写的自由文本字段**
//
// - 想回给抄送方,只能从收件箱渲染出的 `抄送: opencode@/home.new` 里抄一段,
// 而 `.new` 是一次性的,抄过去只会再建一条会话;
// - 想知道对方接受哪个工作目录,无从查询,只能猜。生产上真实发生过一次:
// dsh 猜了 `opencode@/home`,地址解析通过、投递成功,但 `/home` 不是
// opencode 的工作目录 —— **猜错比报错更糟,它会静默变成新会话的 workspace**。
//
// 人类侧从来没有这个问题:`AddressInput` 三段式逐段查 `/contacts/suggest`
// name / path / session 每一段都从活数据里选。这一组端点就是把同一份能力
// 给 Agent。
//
// # 为什么不直接给 Agent 复用人类那几条路由
//
// 两条理由:
//
// 1. **作用域不同。** 人类侧 `ListContactsFor(scope=username)` 的 scope 是
// 「我参与过的会话」,管理员还能 `?all=true` 看全部。Agent 没有管理员概念,
// 也不该看到自己没参与过的线索。把 AgentAuth 加进人类路由组,等于让
// `middleware.GetUser` 返回 nil 的请求走进一堆假定 user 非空的 handler。
// 2. **审计与演进。** Agent 能读什么是插件契约的一部分PLUGIN-CONTRACT 的
// 能力矩阵),独立成组才能在一处看全。
//
// # 一律只读
//
// 这里没有任何写端点。归档、改别名、决策权限都仍然只有人能做 ——
// Agent 可以「看见并寻址」,但不能替人整理邮箱。
// GET /api/v1/agent/contacts
//
// 本 Agent 参与过的全部会话,每条给出可直接投递的 `address`。
// 与人类侧 `/contacts` 同源(`repo.ListContactsFor`scope 固定为自己。
func AgentListContacts(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
archived := r.URL.Query().Get("archived") == "true"
contacts, err := repo.ListContactsFor(r.Context(), agentName, archived)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list contacts")
return
}
// 联系人条目里的 agent_name 是「会话对面那个人」,但 ListContactsFor 取的是
// 会话首封邮件的 to_name人类侧视角对面是 Agent。Agent 自己调用时,
// 首封邮件的 to_name 往往就是自己,对面反而是 from_name。
// 因此这里补一个 peer 字段明确「该跟谁说话」,不改原字段以免动到前端。
out := make([]map[string]any, 0, len(contacts))
for _, c := range contacts {
peer := c.AgentName
if peer == agentName {
peer = c.LastFrom
}
out = append(out, map[string]any{
"session_id": c.SessionID,
"session_alias": c.SessionAlias,
"subject": c.Subject,
"path": c.Path,
"status": c.Status,
"mail_count": c.MailCount,
"unread_count": c.UnreadCount,
"last_activity": c.LastActivity,
"last_from": c.LastFrom,
"max_rounds": c.MaxRounds,
"used_rounds": c.UsedRounds,
// peer 是这条会话里可与之通信的另一方
"peer": peer,
// address 是投回这条会话的现成地址。别名为空的老会话给不出可寻址的
// 形式,此时置空而不是拼一个 `.new` —— 那会开新线索而不是续谈。
"address": addressForSession(peer, c.Path, c.SessionAlias),
})
}
JSON(w, http.StatusOK, map[string]any{"contacts": out})
}
// addressForSession 拼「投回这条会话」的地址;无别名时返回空串。
//
// 刻意不退化成 `name@path`(默认会话):默认会话是「该 name@path 当前最活跃的
// 那条」,与调用方想回的那条不一定是同一条。给一个看着能用其实指向别处的地址,
// 比给空串危险。
func addressForSession(name, path, alias string) string {
if alias == "" {
return ""
}
return models.FormatAddress(name, path, alias)
}
// GET /api/v1/agent/contacts/suggest?name=&path=
//
// 三段式寻址补全,与人类侧 `/contacts/suggest` 同一套语义:
//
// 不带 name → 候选收件人名(在线 Agent + 活跃用户,去掉自己)
// 带 name 不带 path → 该 name 用过的工作目录
// name + path 都带 → 该 name@path 下可续谈的会话别名,`new` 永远在最后
//
// **这是「精准发信」的关键一环**:模型不再拼地址,而是逐段选。
func AgentSuggestAddress(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
name := strings.TrimSpace(r.URL.Query().Get("name"))
path := strings.TrimSpace(r.URL.Query().Get("path"))
if name == "" {
agents, err := repo.ListAgents(r.Context(), "")
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list agents")
return
}
users, _ := repo.ListActiveUsernames(r.Context())
names := make([]string, 0, len(agents)+len(users))
for _, a := range agents {
if a.Name == agentName {
continue // 不建议给自己发信
}
names = append(names, a.Name)
}
names = append(names, users...)
JSON(w, http.StatusOK, map[string]any{
"kind": "name",
"suggestions": emptySlice(names),
})
return
}
if path == "" {
paths, _ := repo.SuggestPaths(r.Context(), name)
JSON(w, http.StatusOK, map[string]any{
"kind": "path",
"suggestions": emptySlice(paths),
})
return
}
// 可见性传自己的名字:只提示自己参与过的会话。
// 传空会把别人的私下线索也列出来,那是越权。
sessions, err := repo.SuggestSessionCandidates(r.Context(), agentName, name, path)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to suggest sessions")
return
}
aliases := make([]string, 0, len(sessions)+1)
addresses := make([]string, 0, len(sessions)+1)
for _, c := range sessions {
aliases = append(aliases, c.Alias)
addresses = append(addresses, models.FormatAddress(name, path, c.Alias))
}
// new 总在最后:它不是一条已存在的会话。排在前面会让模型在想续谈时
// 顺手开出一条新线索 —— 生产上已经发生过。
aliases = append(aliases, "new")
addresses = append(addresses, models.FormatAddress(name, path, "new"))
sessions = append(sessions, repo.SessionCandidate{
Alias: "new", Source: "new", Title: "新建会话",
})
JSON(w, http.StatusOK, map[string]any{
"kind": "session",
"suggestions": emptySlice(aliases),
// addresses 与 suggestions 同序,可直接塞进 send_mail 的 to
"addresses": emptySlice(addresses),
"candidates": emptySlice(sessions),
})
}
// GET /api/v1/agent/mail/{id}/thread
//
// 与人类侧 `/mail/{id}/thread` 同一份实现,可见性判据换成
// 「本 Agent 参与过该会话」。抄送协作要靠它回答「谁已经回了、谁还没回」。
func AgentGetMailThread(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
serveMailThread(w, r, func(sid uuid.UUID) (bool, error) {
return repo.AgentCanAccessSession(r.Context(), agentName, sid)
})
}
// GET /api/v1/agent/mail/{id}
//
// 读单封邮件全文(含抄送清单与附件)。收件箱只给摘要,
// 而要回给抄送方就必须先看清这封信到底发给了谁。
func AgentGetMail(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
mailID, ok := pathUUID(w, r, "id")
if !ok {
return
}
mail, err := repo.GetMailByID(r.Context(), mailID)
if err != nil {
Error(w, http.StatusNotFound, "Mail not found")
return
}
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, mail.SessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to check permission")
return
}
if !allowed {
Error(w, http.StatusForbidden, "无权访问该邮件")
return
}
fillAttachments(r, mail)
alias := repo.SessionAliasOf(r.Context(), mail.SessionID)
JSON(w, http.StatusOK, map[string]any{
"mail": mail,
"session_alias": alias,
// 回信地址与「我这个身份」都给现成的,省得插件自己拼。
// mail.ToWorkspace 是收件方那个地址的 path 位。
"reply_address": models.FormatAddress(mail.FromName, "", alias),
"self_address": models.FormatAddress(agentName, mail.ToWorkspace, alias),
"participants": participantsOf(mail, alias),
})
}
// GET /api/v1/agent/sessions/{id}/participants
//
// 列出该会话的全部参与方及各自的可投递地址。
//
// 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、以及**用什么地址找到他**。
// 逐封邮件扫收件人与抄送,因为参与方是随往来变化的(一封转发就多一个人)。
func AgentSessionParticipants(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
sessionID, ok := pathUUID(w, r, "id")
if !ok {
return
}
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to check permission")
return
}
if !allowed {
Error(w, http.StatusForbidden, "无权访问该会话")
return
}
parts, err := repo.SessionParticipants(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list participants")
return
}
alias := repo.SessionAliasOf(r.Context(), sessionID)
out := make([]map[string]any, 0, len(parts))
for _, p := range parts {
out = append(out, map[string]any{
"name": p.Name,
"path": p.Path,
"roles": p.Roles, // from / to / cc 的并集
"is_self": p.Name == agentName,
"mail_count": p.MailCount,
// address 用**该参与方自己的 path**,不是调用方的:
// 抄送给 opencode@/a 与主发给 dsh@/b 是两个工作区,
// 用错 path 会让对方在别人的目录里开会话。
"address": addressForSession(p.Name, p.Path, alias),
})
}
JSON(w, http.StatusOK, map[string]any{
"session_id": sessionID,
"session_alias": alias,
"participants": out,
})
}
// participantsOf 从单封邮件里摘出参与方地址,供 AgentGetMail 直接返回。
// 与 SessionParticipants 的区别:这里只看这一封(发件人 + 收件人 + 抄送),
// 用于「回这封信时该带上谁」;那里看整条会话。
func participantsOf(m *models.Mail, alias string) []map[string]any {
out := []map[string]any{}
add := func(role, name, path string) {
if name == "" {
return
}
out = append(out, map[string]any{
"role": role,
"name": name,
"path": path,
"address": addressForSession(name, path, alias),
})
}
// from_workspace 对 Agent 存的是 Agent 名而非路径(历史遗留),
// 拿它当 path 会拼出错地址,所以发件人一侧留空 path 走默认。
add("from", m.FromName, "")
add("to", m.ToName, m.ToWorkspace)
for _, c := range m.CCList {
add("cc", c.Name, c.Path)
}
return out
}