## 症状
单封邮件的元信息三行都不对(生产实测那封 12:12:12):
发件 jianf.邮件驱动·多智能体协作平台-完整设计文档-一、项目概述-11-项目定位
收件 pi@/home/program/agentmail
抄送 pi@/home/program/agentmail.new
人指定的是「投进 pi 的那条会话」,而界面把会话别名拼给了**发件人**。
## 三处错
**1. 别名拼错了一方。** `name@path.session` 三段才唯一确定「哪个 Agent、
在哪个目录、哪条线索」—— 别名必须跟 Agent 走。拼给发件人之后收件人变成
`pi@/home/program/agentmail`,那指向**默认会话**而不是人指定的那条。
**2. 人不该有目录和会话位。** 人没有工作目录,发给人就是进收件箱。
`jianf.某会话` 是把 Agent 的三维语义硬套在人身上,而且因为 from_workspace
为空,拼出来的形态连 ParseAddress 都还原不了 —— 没有 `@` 时整串被当成
**名字**(实测 name="jianf.某会话别名"),投递必然 404。
**3. 抄送残留 `.new`。** 它是一次性动作,建完会话就失效;留着会让人以为
再发一次还能投进同一条会话,实际会开出第三条。
## 修法
`identityAddress` → `participantAddress(name, workspace, alias)`,
判据是有没有 workspace:
Agent → pi@/home/program/agentmail.日程提醒:… 三段齐全
人 → jianf 裸名字
`ccAddress` 按同一判据分流;`.new` 换成当前会话别名。
六处手工拼接(MailView / MailList ×2 / ThreadView)统一走这两个函数。
## 顺带修掉 `dsh@dsh`
改的时候实测发现:**`mails.from_workspace` 对 Agent 存的是 Agent 名而不是
路径**(历史遗留,见 db/migrate.go 里 sessions.workspace 的注释)。
拿它当路径拼,Agent 发来的信显示成 `dsh@dsh`。
会话的 workspace 才是权威来源 → `models.Mail` 新增 `SessionWorkspace`,
六处查询补 `s.workspace`:GetMailByID / ListInbox / GetSessionMails /
GetSessionMailByID / ListSentBy / threadCols。
## formatAddress 与后端对齐
第一版我改成「path 为空时舍弃 session 返回裸名字」,对着后端 ParseAddress
跑了一遍才发现搞反了 —— **正确形态是保留 `@`**:
jianf@.任务 → name=jianf path="" session=任务 ✓
jianf.任务 → name="jianf.任务" ✗
现在两端六个 case 逐例一致(这个分支只在内部逻辑上用得到;
展示一律走 participantAddress,人根本不带会话位)。
## 取舍
列表行与对话树节点**不带会话位**:列表的分组头已单独显示别名,
树的每个节点都在同一条线索上 —— 重复无信息量,而 92 字节的别名会把那行挤没。
## homeagent 日程工具的两个修复(同批)
**查询串手拼吃掉了时区。** RFC3339 的 `+08:00` 里那个 `+` 在查询串里正是
空格的转义形式,服务端 ParseQuery 还原成空格 → time.Parse 失败 →
AgentListCalendarEvents **静默退回默认区间**(不报错)。表现为「明明有日程
却说一条都没有」。改走 url.Values.Encode()。
**默认窗口 3 个月太窄。** yearly / lunar_yearly 的下一次触发随时落在窗口外,
模型问「我建过什么」得到空结果,然后照着空结果再建一条重复的。改成 14 个月。
空结果的话术也从「你还没有建过日程」改成说出实际查询区间 —— 前者在窗口外
有事件时是假话。
## 验收
- web 182 例(replyTarget 24 → 46);tsc 无错;Gateway 7 包全过
- 新增 test/manual/addr-verify.mjs:真渲染两个方向都验过
人 → Agent:jianf / pi@/home/program/agentmail.日程提醒:…
Agent → 人:dsh@/home/program/agentmail.查看工程与插件适配指南 / jianf
判据含「Agent 的 path 必须是真路径而不是 Agent 名」(锁 dsh@dsh 那个 bug)
244 lines
8.9 KiB
Go
244 lines
8.9 KiB
Go
package repo
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
"github.com/agentmail/gateway/internal/models"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// 对话树。
|
||
//
|
||
// **不另建 tree_nodes 表**:`mails.parent_mail_id` 已经完整编码了树结构 ——
|
||
// 回复指向来信,转发指向被转发的原件。再维护一张 tree_nodes 就是第二份真相,
|
||
// 两处不一致时无法判断谁对。这里直接用递归 CTE 在 mails 上查。
|
||
//
|
||
// 树可以跨会话:转发把线索引到新会话,但 parent 仍指向原件。这正是「对话树」比
|
||
// 「会话内平铺」更有价值的地方 —— 能看出一条线索分叉去了哪里。
|
||
// 也正因如此,读取时必须按会话逐个鉴权(见 handler):
|
||
// A 转发给 B 之后,B 与 C 在新会话里的往来不能回流给 A。
|
||
//
|
||
// **从根展开,而不是从锚点展开**:曾经的实现是「锚点的祖先链 + 锚点的子树」,
|
||
// 于是兄弟节点整条分支都在盲区里 —— 一封抄送给两个 Agent 的邮件,两个回复
|
||
// 互为兄弟,从其中一个看树看不到另一个;挂在原件上的转发同理。
|
||
// 兄弟既不是锚点的祖先也不是它的子孙,只有先上溯到根、再整棵 BFS 才能覆盖。
|
||
//
|
||
// **分块加载而非截断**:线索可以有几百封,一次全取要把几 MB 预览塞给前端。
|
||
// 从根 BFS 后只剩一个方向,游标就是「已取到的节点数」。
|
||
|
||
// TreeMail 是树里的一个节点。正文只带预览:整棵线索带全文可能几百 KB,
|
||
// 前端点开某封时再单取全文与附件清单。
|
||
type TreeMail struct {
|
||
models.Mail
|
||
// Depth 是**距线索根**的层级:0 = 根,1 = 它的直接回复。
|
||
// 从根展开后根一定在结果里,绝对深度因此总是可知的(早先按相对锚点算,
|
||
// 是因为那时根可能还没取到)。
|
||
Depth int `json:"depth"`
|
||
AttachmentCount int `json:"attachment_count"`
|
||
}
|
||
|
||
// descendantDepthCap 只是数据损坏时的兜底。
|
||
//
|
||
// parent_mail_id 正常不成环(新邮件只能指向已存在的旧邮件),但一旦被外部工具改坏,
|
||
// 无上限的递归 CTE 会把进程拖死。取得足够大,正常数据碰不到。
|
||
const descendantDepthCap = 10000
|
||
|
||
const threadCols = `m.mail_id, m.session_id, m.parent_mail_id,
|
||
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
|
||
m.cc_list, m.subject, m.body, m.mail_type,
|
||
COALESCE(m.permission_result,'') AS permission_result,
|
||
m.status, m.created_at, s.session_alias, s.workspace,
|
||
(SELECT COUNT(*) FROM attachments a WHERE a.mail_id = m.mail_id) AS attach_count`
|
||
|
||
// ThreadRootOf 沿 parent_mail_id 上溯到线索的根,返回根的 mail_id 与锚点到根的层数。
|
||
//
|
||
// 「根」= 链条最上面那封:parent_mail_id 为 NULL,或者指向一封已被删掉的邮件
|
||
// (JOIN 断掉,递归自然停在这一层)。锚点自己没有父时返回它自己、depth 0。
|
||
//
|
||
// **不做可见性过滤**:不可见的中间段必须能穿过 —— 转发把线索引进别人的会话,
|
||
// 再往上却可能仍是自己参与的往来。只返回 id 与层数,不泄露任何内容。
|
||
func ThreadRootOf(ctx context.Context, anchorID uuid.UUID) (uuid.UUID, int, error) {
|
||
var rootID uuid.UUID
|
||
var lvl int
|
||
err := db.DB.QueryRowContext(ctx, `
|
||
WITH RECURSIVE up(mail_id, parent_mail_id, lvl) AS (
|
||
SELECT mail_id, parent_mail_id, 0 FROM mails WHERE mail_id = $1
|
||
UNION ALL
|
||
SELECT m.mail_id, m.parent_mail_id, up.lvl + 1
|
||
FROM mails m JOIN up ON m.mail_id = up.parent_mail_id
|
||
WHERE up.lvl < $2
|
||
)
|
||
SELECT mail_id, lvl FROM up ORDER BY lvl DESC LIMIT 1
|
||
`, anchorID, descendantDepthCap).Scan(&rootID, &lvl)
|
||
if err != nil {
|
||
return uuid.Nil, 0, err
|
||
}
|
||
return rootID, lvl, nil
|
||
}
|
||
|
||
// AncestorsRaw 沿 parent_mail_id 上溯,取第 offset+1 .. offset+limit 层的祖先。
|
||
// 层号 1 = 父,2 = 祖父;返回的 Depth 为负数(相对锚点)。
|
||
//
|
||
// 从根 BFS 之后这个函数只在一处还有用:巨型线索里锚点没落在 BFS 首页时,
|
||
// 用它把「根到锚点」这条路径单独补齐,保证点开的那封一定看得见。
|
||
// 调用方需要自己把负 depth 换算成绝对深度(锚点绝对深度由 ThreadRootOf 给出)。
|
||
//
|
||
// **不做可见性过滤**,理由同 ThreadRootOf。过滤放在 handler 层(那里知道调用者是谁)。
|
||
//
|
||
// 第二个返回值表示 offset+limit 层之上还有节点。
|
||
func AncestorsRaw(ctx context.Context, anchorID uuid.UUID, offset, limit int) ([]TreeMail, bool, error) {
|
||
rows, err := db.DB.QueryContext(ctx, `
|
||
WITH RECURSIVE up(mail_id, parent_mail_id, lvl) AS (
|
||
SELECT mail_id, parent_mail_id, 0 FROM mails WHERE mail_id = $1
|
||
UNION ALL
|
||
SELECT m.mail_id, m.parent_mail_id, up.lvl + 1
|
||
FROM mails m JOIN up ON m.mail_id = up.parent_mail_id
|
||
WHERE up.lvl < $2
|
||
)
|
||
SELECT `+threadCols+`, u.lvl
|
||
FROM up u
|
||
JOIN mails m ON m.mail_id = u.mail_id
|
||
JOIN sessions s ON m.session_id = s.session_id
|
||
WHERE u.lvl > $3
|
||
ORDER BY u.lvl ASC
|
||
`, anchorID, offset+limit+1, offset)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
// 多取一层用来判断「上面还有没有」,不返回给调用方
|
||
out, err := scanTreeRows(rows, true)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
hasMore := len(out) > limit
|
||
if hasMore {
|
||
out = out[:limit]
|
||
}
|
||
return out, hasMore, nil
|
||
}
|
||
|
||
// DescendantsRaw 取给定节点及其全部子孙,BFS 顺序(同层按时间),按节点数分页。
|
||
//
|
||
// 传线索的根(见 ThreadRootOf)就能覆盖整棵树:兄弟、抄送产生的平行回复、
|
||
// 挂在原件上的转发分支,全都是根的子孙。offset = 0 时结果第一个是起点自己(Depth 0)。
|
||
//
|
||
// 同样不做可见性过滤:不可见的子节点下面可能挂着可见的孙节点
|
||
// (别人把线索转走又转回来给我)。
|
||
//
|
||
// 注意 CTE 每次都会走完整棵子树,LIMIT 只截断输出。一条邮件线索通常几十封,
|
||
// 这个代价可以接受;真出现巨型线索时再加物化。
|
||
func DescendantsRaw(ctx context.Context, anchorID uuid.UUID, offset, limit int) ([]TreeMail, bool, error) {
|
||
rows, err := db.DB.QueryContext(ctx, `
|
||
WITH RECURSIVE down(mail_id, lvl) AS (
|
||
SELECT mail_id, 0 FROM mails WHERE mail_id = $1
|
||
UNION ALL
|
||
SELECT m.mail_id, down.lvl + 1
|
||
FROM mails m JOIN down ON m.parent_mail_id = down.mail_id
|
||
WHERE down.lvl < $2
|
||
)
|
||
SELECT `+threadCols+`, d.lvl
|
||
FROM down d
|
||
JOIN mails m ON m.mail_id = d.mail_id
|
||
JOIN sessions s ON m.session_id = s.session_id
|
||
ORDER BY d.lvl ASC, m.created_at ASC, m.mail_id ASC
|
||
LIMIT $3 OFFSET $4
|
||
`, anchorID, descendantDepthCap, limit+1, offset)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
out, err := scanTreeRows(rows, false)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
hasMore := len(out) > limit
|
||
if hasMore {
|
||
out = out[:limit]
|
||
}
|
||
return out, hasMore, nil
|
||
}
|
||
|
||
// TreeMailByID 取单封邮件的树节点形式,深度由调用方给定。
|
||
//
|
||
// 补齐「根 → 锚点」路径时用得上:AncestorsRaw 从父开始,不含锚点自己。
|
||
// 同样不做可见性过滤,由 handler 负责。
|
||
func TreeMailByID(ctx context.Context, id uuid.UUID, depth int) (*TreeMail, error) {
|
||
rows, err := db.DB.QueryContext(ctx, `
|
||
SELECT `+threadCols+`, $2
|
||
FROM mails m
|
||
JOIN sessions s ON m.session_id = s.session_id
|
||
WHERE m.mail_id = $1
|
||
`, id, depth)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out, err := scanTreeRows(rows, false)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(out) == 0 {
|
||
return nil, sql.ErrNoRows
|
||
}
|
||
return &out[0], nil
|
||
}
|
||
|
||
// scanTreeRows 读出节点。negate 为真时把层号取负(祖先方向)。
|
||
func scanTreeRows(rows interface {
|
||
Next() bool
|
||
Scan(...interface{}) error
|
||
Err() error
|
||
Close() error
|
||
}, negate bool) ([]TreeMail, error) {
|
||
defer rows.Close()
|
||
|
||
out := []TreeMail{}
|
||
for rows.Next() {
|
||
var t TreeMail
|
||
var alias *string
|
||
var ccJSON []byte
|
||
var lvl int
|
||
if err := rows.Scan(&t.ID, &t.SessionID, &t.ParentMailID,
|
||
&t.FromName, &t.FromWorkspace, &t.ToName, &t.ToWorkspace,
|
||
&ccJSON, &t.Subject, &t.Body, &t.MailType, &t.PermResult,
|
||
&t.Status, &t.CreatedAt, &alias, &t.SessionWorkspace, &t.AttachmentCount, &lvl); err != nil {
|
||
return nil, err
|
||
}
|
||
if len(ccJSON) > 0 {
|
||
json.Unmarshal(ccJSON, &t.CCList)
|
||
}
|
||
if t.CCList == nil {
|
||
t.CCList = []models.Address{}
|
||
}
|
||
if alias != nil {
|
||
t.SessionAlias = *alias
|
||
}
|
||
if negate {
|
||
t.Depth = -lvl
|
||
} else {
|
||
t.Depth = lvl
|
||
}
|
||
t.BodyPreview = preview(t.Body, 240)
|
||
t.Body = "" // 树视图只要预览,全文按需单取
|
||
out = append(out, t)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// preview 按 UTF-8 边界截断正文。
|
||
// 直接切字节会把多字节字符切成半个,前端渲染出 U+FFFD 替换符。
|
||
func preview(s string, max int) string {
|
||
if len(s) <= max {
|
||
return s
|
||
}
|
||
cut := max
|
||
for cut > 0 && !utf8Start(s[cut]) {
|
||
cut--
|
||
}
|
||
return s[:cut] + "..."
|
||
}
|
||
|
||
// utf8Start 判断某字节是否为一个 UTF-8 序列的首字节
|
||
func utf8Start(b byte) bool { return b&0xC0 != 0x80 }
|