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 时转发本轮总结)
210 lines
6.1 KiB
Go
210 lines
6.1 KiB
Go
package handler
|
||
|
||
import (
|
||
"net/http"
|
||
"strconv"
|
||
|
||
"github.com/agentmail/gateway/internal/middleware"
|
||
"github.com/agentmail/gateway/internal/repo"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// ---------- 对话树(按方向分块加载) ----------
|
||
|
||
// 分页参数。上限存在的意义是防止 ?limit=100000 一次把整条线索拉走 ——
|
||
// 那就等于绕过了分块加载。
|
||
const (
|
||
threadDefaultLimit = 40
|
||
threadMaxLimit = 200
|
||
)
|
||
|
||
// threadNode 是返回给前端的树节点。
|
||
//
|
||
// Detached 表示「这封的父邮件当前不在返回集里」,两种原因:
|
||
// - 父邮件不可见(转发把线索引到别处,下游往来不回流给上游参与者)
|
||
// - 父邮件还没加载(分块加载的边界,往上滑会补上)
|
||
//
|
||
// 前端据此画出断点,而不是因为找不到父节点就把它悄悄丢掉。
|
||
// 两种原因用 ParentHidden 区分:不可见是永久的,未加载是暂时的。
|
||
type threadNode struct {
|
||
repo.TreeMail
|
||
Detached bool `json:"detached,omitempty"`
|
||
// ParentHidden 为真表示父邮件确实存在但无权查看(不是尚未加载)
|
||
ParentHidden bool `json:"parent_hidden,omitempty"`
|
||
}
|
||
|
||
// GET /api/v1/mail/{id}/thread
|
||
//
|
||
// 以给定邮件为锚点,按方向分块返回线索:
|
||
//
|
||
// ?dir=around(默认) 锚点 + 一批祖先 + 一批子孙,首屏用
|
||
// ?dir=up&offset=N 继续往上取祖先(上滑加载)
|
||
// ?dir=down&offset=N 继续往下取子孙
|
||
//
|
||
// offset 是**相对锚点**的偏移:up 方向按层数(已取到的祖先数),
|
||
// down 方向按节点数(已取到的子孙数)。锚点本身只在 around/down&offset=0 时返回。
|
||
//
|
||
// 树可跨会话(转发是新线索但仍指向原件),因此**逐个会话鉴权**,
|
||
// 只返回当前用户有权访问的节点。被过滤掉的计入 hidden。
|
||
func GetMailThread(w http.ResponseWriter, r *http.Request) {
|
||
user := middleware.GetUser(r)
|
||
if user == nil {
|
||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||
return
|
||
}
|
||
mailID, ok := pathUUID(w, r, "id")
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
// 先确认调用者确实看得到作为锚点的这封邮件,否则等于给了一个
|
||
// 「随便报 mail_id 就能探测线索存在性」的接口
|
||
mail, err := repo.GetMailByID(r.Context(), mailID)
|
||
if err != nil {
|
||
Error(w, http.StatusNotFound, "Mail not found")
|
||
return
|
||
}
|
||
allowed, err := repo.UserCanAccessSession(r.Context(), user, mail.SessionID)
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||
return
|
||
}
|
||
if !allowed {
|
||
Error(w, http.StatusForbidden, "无权访问该邮件")
|
||
return
|
||
}
|
||
|
||
dir := r.URL.Query().Get("dir")
|
||
if dir == "" {
|
||
dir = "around"
|
||
}
|
||
if dir != "around" && dir != "up" && dir != "down" {
|
||
Error(w, http.StatusBadRequest, "dir 只能是 around、up 或 down")
|
||
return
|
||
}
|
||
limit := intQuery(r, "limit", threadDefaultLimit, 1, threadMaxLimit)
|
||
offset := intQuery(r, "offset", 0, 0, 1<<20)
|
||
|
||
// 会话鉴权结果按会话缓存:一条线索里同一会话通常有多封,逐封查是浪费
|
||
seen := map[uuid.UUID]bool{}
|
||
canSee := func(sid uuid.UUID) bool {
|
||
if v, ok := seen[sid]; ok {
|
||
return v
|
||
}
|
||
v, err := repo.UserCanAccessSession(r.Context(), user, sid)
|
||
if err != nil {
|
||
v = false // 查不出来就当看不到:宁可少给,不可多给
|
||
}
|
||
seen[sid] = v
|
||
return v
|
||
}
|
||
|
||
var (
|
||
raw []repo.TreeMail
|
||
hasMoreUp bool
|
||
hasMoreDn bool
|
||
wantUp = dir == "around" || dir == "up"
|
||
wantDown = dir == "around" || dir == "down"
|
||
upOffset = offset
|
||
downOffset = offset
|
||
)
|
||
|
||
// around 时两个方向各取一半,避免首屏一次要求 2×limit。
|
||
// 两边至少各给 1:否则 limit=1 时会算出 downLimit=0,连锚点自己都不返回。
|
||
upLimit, downLimit := limit, limit
|
||
if dir == "around" {
|
||
upLimit = limit / 2
|
||
if upLimit < 1 {
|
||
upLimit = 1
|
||
}
|
||
downLimit = limit - upLimit
|
||
if downLimit < 1 {
|
||
downLimit = 1
|
||
}
|
||
upOffset, downOffset = 0, 0
|
||
}
|
||
|
||
if wantUp {
|
||
anc, more, err := repo.AncestorsRaw(r.Context(), mailID, upOffset, upLimit)
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to load ancestors")
|
||
return
|
||
}
|
||
raw = append(raw, anc...)
|
||
hasMoreUp = more
|
||
}
|
||
if wantDown {
|
||
// around 与 down&offset=0 会带上锚点自己(Depth 0);
|
||
// up 方向单独请求时不带,前端已经有它了
|
||
desc, more, err := repo.DescendantsRaw(r.Context(), mailID, downOffset, downLimit)
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to load descendants")
|
||
return
|
||
}
|
||
raw = append(raw, desc...)
|
||
hasMoreDn = more
|
||
}
|
||
|
||
// 可见性过滤。父节点是否在**本次返回集**里决定 detached;
|
||
// 父存在却不在集里,再判断是"无权看"还是"没加载"。
|
||
visible := map[uuid.UUID]bool{}
|
||
for _, m := range raw {
|
||
if canSee(m.SessionID) {
|
||
visible[m.ID] = true
|
||
}
|
||
}
|
||
|
||
nodes := []threadNode{}
|
||
for _, m := range raw {
|
||
if !visible[m.ID] {
|
||
continue
|
||
}
|
||
n := threadNode{TreeMail: m}
|
||
if m.ParentMailID != nil && !visible[*m.ParentMailID] {
|
||
n.Detached = true
|
||
// 父邮件在本次结果里出现过但被过滤掉 = 确实无权查看;
|
||
// 完全没出现过 = 只是还没加载到,往上滑会补上
|
||
for _, other := range raw {
|
||
if other.ID == *m.ParentMailID {
|
||
n.ParentHidden = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
nodes = append(nodes, n)
|
||
}
|
||
|
||
JSON(w, http.StatusOK, map[string]interface{}{
|
||
"anchor_mail_id": mailID,
|
||
"dir": dir,
|
||
"nodes": nodes,
|
||
"total": len(nodes),
|
||
"hidden": len(raw) - len(nodes),
|
||
// 下一页的 offset。前端把它原样回传即可,不必自己算已加载数量。
|
||
"has_more_up": hasMoreUp,
|
||
"has_more_down": hasMoreDn,
|
||
"next_up": upOffset + upLimit,
|
||
"next_down": downOffset + downLimit,
|
||
})
|
||
}
|
||
|
||
// intQuery 读取整数 query 参数并夹到 [min, max]。
|
||
// 非法值一律回落到默认值 —— 分页参数不该因为一个笔误就让整个请求失败。
|
||
func intQuery(r *http.Request, key string, def, min, max int) int {
|
||
s := r.URL.Query().Get(key)
|
||
if s == "" {
|
||
return def
|
||
}
|
||
v, err := strconv.Atoi(s)
|
||
if err != nil {
|
||
return def
|
||
}
|
||
if v < min {
|
||
return min
|
||
}
|
||
if v > max {
|
||
return max
|
||
}
|
||
return v
|
||
}
|