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),未手工拼写
This commit is contained in:
2026-09-03 12:09:12 +08:00
parent 22ddb1b89c
commit e6fd2fafdc
81 changed files with 11355 additions and 122 deletions

View File

@ -0,0 +1,135 @@
package repo
import (
"context"
"encoding/json"
"sort"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
)
// Participant 是一条会话里的一个参与方。
//
// Path 是该参与方**自己那个地址的 path 位**,不是别人的:一封主发给 dsh@/b、
// 抄送给 opencode@/a 的邮件里,两人的工作目录不同,混用会让对方在别人的目录里
// 开会话(生产上已发生过一次,见 PLUGIN-CONTRACT 9.3)。
type Participant struct {
Name string `json:"name"`
Path string `json:"path"`
// Roles 是该参与方在这条会话里出现过的全部身份,from / to / cc 的并集。
// 用集合而非单值:同一个人常常既发过信也被抄送过,只留最后一个身份会让
// 「谁是这件事的负责人」这个判断出错。
Roles []string `json:"roles"`
// MailCount 是该参与方作为发件人的邮件数。用来回答「谁还没回」——
// 参与方列表里 from 计数为 0 的那个就是还没开口的人。
MailCount int `json:"mail_count"`
}
// SessionParticipants 列出会话的全部参与方及各自的地址素材。
//
// 为什么要逐封扫而不是看 sessions 表:**参与方是随往来增长的**。会话建立时
// 只有发件人与收件人,一封抄送、一次转发都会带进新的人。sessions 表里只有
// from_agent 一个名字,回答不了「这条线索上现在有谁」。
//
// 排序:按首次出现顺序(created_at)。这让主收件人稳定排在抄送方之前,
// 模型据此判断「谁是负责人、谁是配合方」——按名字排序会丢掉这个信息。
func SessionParticipants(ctx context.Context, sessionID uuid.UUID) ([]Participant, error) {
rows, err := db.DB.QueryContext(ctx, `
SELECT from_name, COALESCE(from_workspace,''),
to_name, COALESCE(to_workspace,''),
cc_list
FROM mails
WHERE session_id = $1
ORDER BY created_at ASC, mail_id ASC
`, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
type acc struct {
p Participant
roles map[string]bool
order int
}
seen := map[string]*acc{}
next := 0
// note 记录一次「某人以某身份出现」。
//
// path 只在**当前为空且新值非空**时补写:同一个人可能在不同邮件里带不同
// path(先被抄送到 /a,后被主发到 /b)。保留首个非空值而不是最后一个,
// 与排序口径一致(首次出现顺序),也避免一封转发把地址改指到别处。
note := func(name, path, role string, isSender bool) {
if name == "" {
return
}
a, ok := seen[name]
if !ok {
a = &acc{
p: Participant{Name: name, Path: path},
roles: map[string]bool{},
order: next,
}
next++
seen[name] = a
}
if a.p.Path == "" && path != "" {
a.p.Path = path
}
a.roles[role] = true
if isSender {
a.p.MailCount++
}
}
for rows.Next() {
var fromName, fromWS, toName, toWS string
var ccRaw []byte
if err := rows.Scan(&fromName, &fromWS, &toName, &toWS, &ccRaw); err != nil {
return nil, err
}
// **发件人一侧不取 from_workspace 当 path。** Agent 回信时那一列存的是
// Agent 名而不是路径(历史遗留,FindOrCreateDefaultSession 的注释里也提到
// 同一个坑)。拿它拼地址会得到 `dsh@dsh.alias` 这种投不出去的东西。
note(fromName, "", "from", true)
note(toName, toWS, "to", false)
if len(ccRaw) > 0 {
var cc []models.Address
if json.Unmarshal(ccRaw, &cc) == nil {
for _, c := range cc {
note(c.Name, c.Path, "cc", false)
}
}
}
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]Participant, 0, len(seen))
for _, a := range seen {
a.p.Roles = sortedKeys(a.roles)
out = append(out, a.p)
}
sort.Slice(out, func(i, j int) bool {
return seen[out[i].Name].order < seen[out[j].Name].order
})
return out, nil
}
// sortedKeys 给出稳定顺序的角色列表。
// map 迭代顺序随机,不排序的话同一条会话每次返回的 roles 顺序都不同,
// 插件侧做 diff 或缓存时会误判为「参与方变了」。
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}