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 时转发本轮总结)
189 lines
6.8 KiB
Go
189 lines
6.8 KiB
Go
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 }
|