package repo import ( "context" "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。 // // **分块加载而非截断**:线索可以有几百封,一次全取要把几 MB 预览塞给前端。 // 按方向分页 —— 祖先向上、子孙向下,各自带游标。 // // 游标用「相对锚点的原始层号偏移」而不是 mail_id: // - 偏移量每次从锚点重走一遍,无状态、不可伪造,也不需要额外证明 // 「这个 cursor 真的在这条线索上」 // - 用 mail_id 做游标就必须允许传入**不可见**的邮件(不可见的中间段要穿过去), // 那就得单独校验它确实是锚点的祖先,反而更绕 // - 祖先方向的层号天然稳定:新邮件只会追加成叶子,不会插进已有链条中间 // TreeMail 是树里的一个节点。正文只带预览:整棵线索带全文可能几百 KB, // 前端点开某封时再单取全文与附件清单。 type TreeMail struct { models.Mail // Depth 是**相对锚点**的层级:0 = 锚点,-1 = 父,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, (SELECT COUNT(*) FROM attachments a WHERE a.mail_id = m.mail_id) AS attach_count` // AncestorsRaw 沿 parent_mail_id 上溯,取第 offset+1 .. offset+limit 层的祖先。 // 层号 1 = 父,2 = 祖父;返回的 Depth 为负数。 // // **不做可见性过滤** —— 不可见的中间段必须能穿过:转发把线索引进别人的会话, // 再往上却可能仍是自己参与的往来。过滤放在 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 顺序(同层按时间),按节点数分页。 // // offset = 0 时结果的第一个是锚点自己(Depth 0)。 // 同样不做可见性过滤,理由同 AncestorsRaw:不可见的子节点下面可能挂着可见的孙节点 // (别人把线索转走又转回来给我)。 // // 注意 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 } // 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.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 }