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 时转发本轮总结)
129 lines
4.1 KiB
Go
129 lines
4.1 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/agentmail/gateway/internal/repo"
|
|
)
|
|
|
|
// JSON 写入 JSON 响应
|
|
func JSON(w http.ResponseWriter, status int, data interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(data)
|
|
}
|
|
|
|
// Error 写入错误响应
|
|
func Error(w http.ResponseWriter, status int, msg string) {
|
|
JSON(w, status, map[string]string{"error": msg})
|
|
}
|
|
|
|
// Decode 从请求体解析 JSON
|
|
func Decode(r *http.Request, v interface{}) error {
|
|
return json.NewDecoder(r.Body).Decode(v)
|
|
}
|
|
|
|
// httpError 携带 HTTP 状态码的错误
|
|
type httpError struct {
|
|
status int
|
|
msg string
|
|
}
|
|
|
|
func (e httpError) Error() string { return e.msg }
|
|
|
|
func errBadRequest(msg string) error { return httpError{http.StatusBadRequest, msg} }
|
|
func errNotFound(msg string) error { return httpError{http.StatusNotFound, msg} }
|
|
func errConflict(msg string) error { return httpError{http.StatusConflict, msg} }
|
|
|
|
// writeKeyErr 把 repo 层的密钥错误映射成 HTTP 响应。
|
|
// 「已使用 / 已过期」与「无效」分开报,便于运维判断是重签还是查配置。
|
|
func writeKeyErr(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, repo.ErrKeyUsed):
|
|
Error(w, http.StatusUnauthorized, "密钥已使用(一次性密钥只能用一次)")
|
|
case errors.Is(err, repo.ErrKeyExpired):
|
|
Error(w, http.StatusUnauthorized, "密钥已过期")
|
|
case errors.Is(err, repo.ErrKeyNotFound):
|
|
Error(w, http.StatusUnauthorized, "密钥无效")
|
|
case errors.Is(err, repo.ErrKeyTypeInvalid):
|
|
Error(w, http.StatusBadRequest, "密钥类型非法,应为 permanent / one_time / timed")
|
|
case errors.Is(err, repo.ErrKeyNeedsExpiry):
|
|
Error(w, http.StatusBadRequest, "timed 密钥必须给出正的 expires_hours")
|
|
case errors.Is(err, repo.ErrKeyTooShort):
|
|
Error(w, http.StatusBadRequest, "密钥太短(至少 32 位)")
|
|
case errors.Is(err, repo.ErrKeyTokenTaken):
|
|
Error(w, http.StatusConflict, "该密钥已登记过")
|
|
default:
|
|
Error(w, http.StatusInternalServerError, "密钥操作失败")
|
|
}
|
|
}
|
|
|
|
// validateSessionAlias 校验会话别名是否可安全出现在三维地址 name@path.<alias> 的末段。
|
|
// "new" 是寻址保留字;含 . 会让 path/session 切分歧义;含 @ 与空白同理。
|
|
func validateSessionAlias(alias string) error {
|
|
if alias == "new" {
|
|
return errBadRequest(`会话别名不可为 "new":该词已作为寻址保留字`)
|
|
}
|
|
if strings.ContainsAny(alias, ". \t/@") {
|
|
return errBadRequest("会话别名不可含 . 空白 / 或 @(会与三维地址解析冲突)")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// normalizeAlias 把 Agent 平台侧的 slug/标题改写为合法的寻址别名。
|
|
//
|
|
// 平台侧命名不一定遵守本侧的寻址约束(可能含 . / @ 空白),直接入库会让
|
|
// name@path.session 切分歧义,因此非法字符统一换成 -,并压缩连续的 -。
|
|
// 保留字 "new" 加前缀避开;全部不可用时返回空串交由调用方报错。
|
|
func normalizeAlias(s string) string {
|
|
var b strings.Builder
|
|
lastDash := false
|
|
for _, r := range s {
|
|
switch {
|
|
case r == '.' || r == '/' || r == '@' || r == ' ' || r == '\t' || r == '\n' || r == '\r':
|
|
if !lastDash && b.Len() > 0 {
|
|
b.WriteByte('-')
|
|
lastDash = true
|
|
}
|
|
default:
|
|
b.WriteRune(r)
|
|
lastDash = false
|
|
}
|
|
}
|
|
out := strings.Trim(b.String(), "-")
|
|
if out == "new" {
|
|
return "session-new"
|
|
}
|
|
// VARCHAR(128) 上限,按字节截断时不能切坏多字节字符
|
|
const maxBytes = 128
|
|
if len(out) > maxBytes {
|
|
cut := out[:maxBytes]
|
|
for len(cut) > 0 && !utf8.ValidString(cut) {
|
|
cut = cut[:len(cut)-1]
|
|
}
|
|
out = strings.Trim(cut, "-")
|
|
}
|
|
return out
|
|
}
|
|
|
|
// writeErr 将 httpError 按其状态码写出,其余错误统一 500 + fallback 文案
|
|
func writeErr(w http.ResponseWriter, err error, fallback string) {
|
|
if he, ok := err.(httpError); ok {
|
|
Error(w, he.status, he.msg)
|
|
return
|
|
}
|
|
Error(w, http.StatusInternalServerError, fallback)
|
|
}
|
|
|
|
// emptySlice 把 nil slice 转为空 JSON 数组 []
|
|
func emptySlice[T any](s []T) []T {
|
|
if s == nil {
|
|
return []T{}
|
|
}
|
|
return s
|
|
}
|