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,177 @@
package repo
import (
"context"
"fmt"
"strings"
"unicode"
"unicode/utf8"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
// 自动别名 —— 让 `.new` 建出来的会话立刻可被寻址。
//
// # 为什么必须自动命名
//
// `session` 位三态里 `new` 是**一次性动作**:它建出会话就用完了。之后要再投进
// 同一条会话,只有两条路 —— `reply_to` 某封具体邮件,或者 `name@path.<别名>`。
// 而 `CreateSession(alias=nil)` 建出来的会话别名是 NULL,于是:
//
// - `FindNamedSessionFor` 查不到它(`WHERE session_alias = $1` 对 NULL 不成立)
// - `SuggestSessionCandidates` 跳过它(`session_alias IS NOT NULL AND <> ''`)
// - 参与方拿到的 `new_mail` 里 `session_alias` 是空串
//
// 结果是:被抄送方收到一封 `x@/p.new` 的邮件,**除了回复那一封之外无法再投进这条
// 会话**。再发一次 `x@/p.new` 只会建第三条会话。这不是能力缺失,是寻址断链。
//
// 原先的设计假定平台插件会通过 `POST /sessions/{id}/sync` 把模型生成的标题回写成
// 别名,于是「未命名」只是短暂状态。但两件事让这个假定不成立:
//
// 1. 人类发的邮件根本没有平台侧,永远等不到回写;
// 2. 回写发生在模型跑完第一轮之后,而抄送方**在那之前**就要决定回信地址。
//
// 因此本侧先给一个可用的别名,平台随后仍可用 `SyncSessionAlias` 改写它 ——
// `alias_source` 保持 `platform` 正是为此:自动名不是人定的名,不该挡住平台命名。
//
// # 为什么不复用 SyncSessionAlias
//
// 那个函数假定「会话已存在、现在要改名」,并且会跳过 `manual`。这里的场景是
// 「刚建完、还没有名字」,且必须在**建会话的同一个请求里**完成,否则中间那一瞬
// 发出的 SSE 仍然带空别名。
// aliasMaxBytes 与 normalizeAlias 的截断上限一致(sessions.session_alias 为 VARCHAR(128))。
const aliasMaxBytes = 128
// autoAliasAttempts 是撞名后追加 -2、-3… 的尝试次数上限。
// 与 SyncSessionAlias 取同一个数量级:同一主题在同一天内开几十条会话已属异常,
// 真到了上限说明调用方在刷会话,此时报错比继续找空位更有价值。
const autoAliasAttempts = 50
// AutoAliasFor 依据收件人与主题拼一个候选别名(未做唯一性检查)。
//
// 形如 `dsh-重构导入路径`:前缀用收件方名字,后缀用主题。**两者都要**——
// 只用主题时「服务恢复验证」这类通用主题会在不同 Agent 之间反复撞名,
// 只用名字则同一个 Agent 的所有会话都叫 `dsh-2`、`dsh-3`,看不出在聊什么。
//
// 主题为空(少见但合法)时退回单独的名字,由调用方靠后缀去重。
func AutoAliasFor(toName, subject string) string {
base := sanitizeAliasPart(toName)
topic := sanitizeAliasPart(subject)
switch {
case base == "" && topic == "":
// 两边都拿不出可用字符(例如主题全是标点、名字为空)。
// 返回空串让调用方走随机兜底,不要在这里编造。
return ""
case base == "":
return truncateAlias(topic)
case topic == "":
return truncateAlias(base)
default:
return truncateAlias(base + "-" + topic)
}
}
// EnsureSessionAlias 保证会话拥有一个可寻址的别名,返回最终别名。
//
// 已有别名时原样返回,不做任何写入 —— 这让它可以被无条件调用,
// 包括「默认会话」路径上那条可能是刚建的、也可能是复用的会话。
//
// 撞名时追加 -2、-3… 后缀;`want` 为空或全部被占用时退回
// `session-<uuid 前 8 位>`:一个能寻址的丑名字,远胜于没有名字。
func EnsureSessionAlias(ctx context.Context, id uuid.UUID, want string) (string, error) {
if cur := SessionAliasOf(ctx, id); cur != "" {
return cur, nil
}
cands := make([]string, 0, autoAliasAttempts+1)
if want != "" {
for i := 0; i < autoAliasAttempts; i++ {
if i == 0 {
cands = append(cands, want)
continue
}
cands = append(cands, truncateAlias(fmt.Sprintf("%s-%d", want, i+1)))
}
}
// 兜底:uuid 前 8 位。碰撞概率可忽略,且与 want 无关,
// 因此即便主题里一个可用字符都没有也总能拿到别名。
cands = append(cands, "session-"+id.String()[:8])
for _, c := range cands {
// 条件写入:`session_alias IS NULL OR = ''` 保证并发下只有一方写成功,
// 另一方 RowsAffected=0,随后重读拿到对方写的名字 ——
// 两个请求都返回同一个别名,而不是各自以为自己命名成功。
res, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET session_alias = $1, updated_at = NOW()
WHERE session_id = $2 AND (session_alias IS NULL OR session_alias = '')`,
c, id)
if err != nil {
if db.IsUniqueViolation(err) {
continue // 别名被别的会话占了,试下一个后缀
}
return "", err
}
if n, _ := res.RowsAffected(); n == 0 {
// 期间别人(并发请求或平台同步)已经命名过,尊重那个名字
if cur := SessionAliasOf(ctx, id); cur != "" {
return cur, nil
}
// 写不进去且读不到名字,只可能是会话刚被删
return "", fmt.Errorf("会话 %s 已不存在,无法分配别名", id)
}
return c, nil
}
return "", fmt.Errorf("别名 %q 连同 -2..-%d 后缀与 uuid 兜底均被占用", want, autoAliasAttempts)
}
// sanitizeAliasPart 把任意文本压成别名可用的片段。
//
// 规则与 normalizeAlias 一致(非法字符换 -、压缩连续 -、去首尾 -),
// 另外多做两件事:
//
// - **去掉 Markdown / 标点噪声**:主题里的 `[联调]`、`—`、`:` 变成一串
// 破折号毫无信息量。只保留字母、数字与非标点的 Unicode 字符(中文、日文等)。
// - **压缩空白**:`Re: 服务恢复验证` → `Re-服务恢复验证`,而不是 `Re--服务恢复验证`。
//
// 保留中文是刻意的:本项目的会话主题多为中文,转拼音需要额外依赖,
// 而 `dsh-重构导入路径` 作为地址完全可用(三维寻址只忌 `. / @` 与空白)。
func sanitizeAliasPart(s string) string {
var b strings.Builder
lastDash := false
for _, r := range s {
keep := unicode.IsLetter(r) || unicode.IsDigit(r)
if keep {
b.WriteRune(r)
lastDash = false
continue
}
// 其余一切(空白、标点、符号、寻址保留字符)都折成单个 -
if !lastDash && b.Len() > 0 {
b.WriteByte('-')
lastDash = true
}
}
out := strings.Trim(b.String(), "-")
// "new" 是寻址保留字,作为整体别名时必须避开。
// 加前缀而不是拒绝:调用方给的素材没有错,是这个词恰好被占用。
if out == "new" {
return "session-new"
}
return out
}
// truncateAlias 按字节截断且不切坏多字节字符(中文主题很容易超 128 字节)。
func truncateAlias(s string) string {
if len(s) <= aliasMaxBytes {
return strings.Trim(s, "-")
}
cut := s[:aliasMaxBytes]
for len(cut) > 0 && !utf8.ValidString(cut) {
cut = cut[:len(cut)-1]
}
return strings.Trim(cut, "-")
}