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 }