Files
MailUI4Agents/gateway/internal/handler/me.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

305 lines
8.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 handler
import (
"net/http"
"time"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/google/uuid"
)
// ---------- /me当前登录人类用户的邮箱全部路由需 UserAuth ----------
type meSendMailRequest struct {
To string `json:"to"` // name@path.session
CC string `json:"cc"` // 多个 name@path.session
Subject string `json:"subject"`
Body string `json:"body"`
ReplyTo string `json:"reply_to"`
// SessionAlias 仅在本次投递【新建】会话时生效,为新会话命名
SessionAlias string `json:"session_alias"`
// AttachmentIDs 先用 POST /me/attachments 上传拿到的 id
AttachmentIDs []string `json:"attachment_ids"`
// MaxRounds 是本次任务的往返预算0/省略 = 不限)。
//
// 配额的真实语义是「这件事值得多少个来回」——那是任务的属性,
// 所以在派活的这一刻给,而不是事后到管理员页面去调某个 Agent 的全局配额。
// 仅在本次投递【新建】会话时生效;续谈已有会话请用
// PUT /sessions/{id}/budget对话页里可随时改
MaxRounds *int `json:"max_rounds"`
}
// POST /api/v1/me/mail/send
func MeSendMail(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
var req meSendMailRequest
if !DecodeBody(w, r, &req) {
return
}
if req.To == "" || req.Subject == "" || req.Body == "" {
Error(w, http.StatusBadRequest, "Missing to, subject, or body")
return
}
to, err := models.ParseAddress(req.To)
if err != nil {
Error(w, http.StatusBadRequest, "Invalid to address: "+err.Error())
return
}
ccList, err := models.ParseAddressList(req.CC)
if err != nil {
Error(w, http.StatusBadRequest, "Invalid cc address: "+err.Error())
return
}
attachIDs, err := parseAttachmentIDs(req.AttachmentIDs)
if err != nil {
Error(w, http.StatusBadRequest, err.Error())
return
}
// human@ 是兼容别名,人类发信时解析为自己
to = resolveHumanAlias(to, user.Username)
for i := range ccList {
ccList[i] = resolveHumanAlias(ccList[i], user.Username)
}
// 权限边界:校验可调用的 Agent 与可访问的目录
if msg := checkScope(r, user, append([]models.Address{to}, ccList...)); msg != "" {
Error(w, http.StatusForbidden, msg)
return
}
sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, user.Username, req.Subject, req.SessionAlias, "")
if err != nil {
writeErr(w, err, "Failed to resolve session")
return
}
// 人类发起的会话归属于该用户
_ = repo.SetSessionOwner(r.Context(), sessionID, user.ID)
// 新建会话时定往返预算。只在新建时设:续谈已有会话若也接受这个字段,
// 每封新信都会悄悄改掉对方正在遵守的预算,人却不一定意识到自己改了。
//
// 没显式给就用【收件 Agent 的默认值】。默认值挂在 Agent 上而不是全站一个数:
// 跑测试的小工具与重构整个模块的 Agent合理来回数差一个量级。
if parentMailID == nil {
rounds := 0
if req.MaxRounds != nil {
if *req.MaxRounds < 0 {
Error(w, http.StatusBadRequest, "max_rounds 不能为负")
return
}
rounds = *req.MaxRounds
} else {
rounds = repo.DefaultRoundsFor(r.Context(), to.Name)
}
if _, err := repo.SetSessionBudget(r.Context(), sessionID, rounds); err != nil {
Error(w, http.StatusInternalServerError, "Failed to set session budget")
return
}
}
// 人类侧不产生改名提议(人直接有改名按钮,用不着向自己提议),
// 但仍然剥掉标记:粘贴进正文时它会被渲染成一行可见的转义文本。
_, body := extractRenameProposal(req.Body)
mailID, err := repo.CreateMail(r.Context(), sessionID, parentMailID,
user.Username, "", to.Name, to.Path, req.Subject, body, ccList)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to create mail")
return
}
if !attachAll(w, r, mailID, attachIDs, user.Username) {
return
}
notifyRecipients(r.Context(), to, ccList, sessionID, mailID, user.Username, req.Subject)
resp := map[string]any{
"mail_id": mailID.String(),
"session_id": sessionID.String(),
"session_alias": repo.SessionAliasOf(r.Context(), sessionID),
}
// 回传预算,让前端不必再单独查一次就能显示「本任务还剩几个来回」
if b, err := repo.GetSessionBudget(r.Context(), sessionID); err == nil && !b.Unlimited {
resp["budget_max"] = b.Max
resp["budget_used"] = b.Used
resp["budget_remaining"] = b.Remaining
}
JSON(w, http.StatusOK, resp)
}
// GET /api/v1/me/mail/inbox
func MeGetInbox(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
status := r.URL.Query().Get("status")
if status == "" {
status = "all"
}
limit := 50
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := parseInt(l); err == nil && n > 0 {
limit = n
}
}
mails, err := repo.ListInbox(r.Context(), user.Username, status, limit)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list inbox")
return
}
// 列表页要显示附件图标与下载入口
ptrs := make([]*models.Mail, len(mails))
for i := range mails {
ptrs[i] = &mails[i]
}
fillAttachments(r, ptrs...)
total, _ := repo.CountUnread(r.Context(), user.Username)
JSON(w, http.StatusOK, map[string]interface{}{
"mails": emptySlice(mails),
"total": total,
})
}
// GET /api/v1/me/mail/sent
func MeGetSent(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
limit := 50
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := parseInt(l); err == nil && n > 0 {
limit = n
}
}
mails, err := repo.ListSentBy(r.Context(), user.Username, limit)
if err == nil {
ptrs := make([]*models.Mail, len(mails))
for i := range mails {
ptrs[i] = &mails[i]
}
fillAttachments(r, ptrs...)
}
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list sent")
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"mails": emptySlice(mails),
})
}
// GET /api/v1/me/sessions
func MeGetSessions(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
scope := user.Username
if user.IsAdmin() && r.URL.Query().Get("all") == "true" {
scope = ""
}
sessions, err := repo.ListSessionsFor(r.Context(), scope, 50)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list sessions")
return
}
type SessionOut struct {
SessionID uuid.UUID `json:"session_id"`
SessionAlias *string `json:"session_alias"`
FromAgent string `json:"from_agent"`
Subject string `json:"subject"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MailCount int `json:"mail_count"`
UnreadCount int `json:"unread_count"`
// 往返预算随列表一并返回:预算是【任务】的属性,
// 工作列表上就应当看得见哪些任务快跑满了,
// 而不是点进去一个一个查。
MaxRounds int `json:"max_rounds"`
UsedRounds int `json:"used_rounds"`
}
result := make([]SessionOut, 0, len(sessions))
for _, s := range sessions {
unread, _ := repo.CountUnreadInSession(r.Context(), user.Username, s.ID)
result = append(result, SessionOut{
SessionID: s.ID,
SessionAlias: s.Alias,
FromAgent: s.FromAgent,
Subject: s.Subject,
Status: s.Status,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
MailCount: s.MailCount,
UnreadCount: unread,
MaxRounds: s.MaxRounds,
UsedRounds: s.UsedRounds,
})
}
JSON(w, http.StatusOK, map[string]interface{}{
"sessions": result,
})
}
// resolveHumanAlias 把兼容别名 human 解析为具体用户名
func resolveHumanAlias(a models.Address, username string) models.Address {
if a.Name != "human" {
return a
}
a.Name = username
a.Raw = username + "@" + a.Path
if a.Session != "" {
a.Raw += "." + a.Session
}
return a
}
// checkScope 校验用户的 Agent 白名单与目录白名单;返回空串表示通过。
// 收件方是人类用户时不受 Agent 白名单约束(人与人通信始终允许)。
func checkScope(r *http.Request, user *models.User, addrs []models.Address) string {
if user.IsAdmin() {
return ""
}
for _, a := range addrs {
if a.Name == "" || a.Name == user.Username {
continue
}
isHuman, err := repo.IsHumanUser(r.Context(), a.Name)
if err != nil {
return "无法校验收件人权限"
}
if !isHuman && !user.CanUseAgent(a.Name) {
return "无权调用 Agent: " + a.Name
}
if !user.CanUsePath(a.Path) {
return "无权访问目录: " + a.Path
}
}
return ""
}