Files
MailUI4Agents/server/internal/repo/thread.go

247 lines
9.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"
"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,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human`
// 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,
&t.FromHuman, &t.ToHuman, &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 }