## 别名替换(让 .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),未手工拼写
331 lines
12 KiB
Go
331 lines
12 KiB
Go
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
|
||
}
|