Files
MailUI4Agents/gateway/internal/repo/thread.go
JianFeeeee 0e754617a4 feat: AgentMail —— 以邮件为统一范式的多智能体协作平台
Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是
「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制,
数据库默认内置 SQLite,systemd 托管。

核心设计
- 三维寻址 name@path.session,按最后一个 . 切分;session 位三态:
  省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达)
- 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的
  标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖
- 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构,
  再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载
- 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重,
  且路径与用户 filename 无关,杜绝 ../ 穿越
- 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与
  最终总结走免配额通道,靠上游消息 id 做幂等键而非计数
- 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过

后端 gateway/
- models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL)
  共用一份 repo 层 SQL,差异集中在 internal/db
- 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界)
- 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文
  只从客户端流向服务器一次
- 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、
  附件挂载),并发下不会刷穿

前端 web/
- 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件
- 全站纯 SVG 图标,不使用 emoji
- api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts

插件 plugins/opencode-mail-bridge/
- 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、
  session.idle 时转发本轮总结)
2026-09-02 10:29:26 +08:00

189 lines
6.8 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"
"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 }