Files
MailUI4Agents/gateway/internal/repo/autoalias.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

178 lines
7.1 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 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, "-")
}