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 时转发本轮总结)
This commit is contained in:
2026-09-02 10:29:26 +08:00
commit 0e754617a4
95 changed files with 23219 additions and 0 deletions

View File

@ -0,0 +1,149 @@
// Package blob 提供附件文件的内容寻址存储。
//
// 设计取舍:文件内容存磁盘、数据库只存元数据。
// 不把附件塞进 SQLite 的 BLOB —— 附件是「写一次读多次」的冷数据,
// 塞进库会让 .db 膨胀、WAL 变大、备份变慢,而这些代价换不来任何好处。
//
// 路径由内容的 sha256 派生ab/cdef...),因此:
// - 相同内容天然去重,重复上传不占额外空间
// - 路径与用户提供的 filename 完全无关,杜绝 ../ 穿越
// - 两级目录前缀避免单目录塞进十万个文件
package blob
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
)
// Store 是附件的磁盘存储。
type Store struct {
root string
}
// ErrTooLarge 表示写入的数据超过了给定上限。
var ErrTooLarge = errors.New("attachment too large")
var sha256Re = regexp.MustCompile(`^[0-9a-f]{64}$`)
// New 打开(必要时创建)一个位于 root 的附件库。
func New(root string) (*Store, error) {
if root == "" {
return nil, errors.New("blob: root 不能为空")
}
if err := os.MkdirAll(root, 0o700); err != nil {
return nil, fmt.Errorf("blob: 创建 %s: %w", root, err)
}
return &Store{root: root}, nil
}
// Root 返回存储根目录(用于日志与运维排查)。
func (s *Store) Root() string { return s.root }
// pathFor 由 sha256 推出磁盘路径。
// 调用前必须确认 sum 是合法的 64 位十六进制,否则可能被拼出库外路径。
func (s *Store) pathFor(sum string) (string, error) {
if !sha256Re.MatchString(sum) {
return "", fmt.Errorf("blob: 非法的 sha256 %q", sum)
}
return filepath.Join(s.root, sum[:2], sum[2:4], sum), nil
}
// Put 把 r 的内容写入存储,返回内容的 sha256 与字节数。
//
// maxBytes > 0 时超限即中止并清理临时文件(不会留下半个文件)。
// 先写临时文件再按内容哈希 rename写入过程中崩溃不会产生一个「哈希对不上内容」的文件。
func (s *Store) Put(r io.Reader, maxBytes int64) (string, int64, error) {
tmp, err := os.CreateTemp(s.root, ".upload-*")
if err != nil {
return "", 0, fmt.Errorf("blob: 创建临时文件: %w", err)
}
tmpName := tmp.Name()
// 失败路径统一清理;成功时 rename 之后这个 Remove 是无害的 no-op
defer func() {
tmp.Close()
os.Remove(tmpName)
}()
h := sha256.New()
src := r
if maxBytes > 0 {
// 多读 1 字节用于判断是否超限LimitReader 到达上限时只会 EOF
// 无法区分「刚好等于上限」和「超过上限」。
src = io.LimitReader(r, maxBytes+1)
}
n, err := io.Copy(io.MultiWriter(tmp, h), src)
if err != nil {
return "", 0, fmt.Errorf("blob: 写入: %w", err)
}
if maxBytes > 0 && n > maxBytes {
return "", 0, ErrTooLarge
}
if err := tmp.Sync(); err != nil {
return "", 0, fmt.Errorf("blob: sync: %w", err)
}
if err := tmp.Close(); err != nil {
return "", 0, fmt.Errorf("blob: close: %w", err)
}
sum := hex.EncodeToString(h.Sum(nil))
dst, err := s.pathFor(sum)
if err != nil {
return "", 0, err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
return "", 0, fmt.Errorf("blob: 创建目录: %w", err)
}
// 已存在同内容文件:内容寻址下这就是同一个文件,直接复用
if _, statErr := os.Stat(dst); statErr == nil {
return sum, n, nil
}
if err := os.Rename(tmpName, dst); err != nil {
return "", 0, fmt.Errorf("blob: rename: %w", err)
}
if err := os.Chmod(dst, 0o600); err != nil {
return "", 0, fmt.Errorf("blob: chmod: %w", err)
}
return sum, n, nil
}
// Open 打开某个内容的读取句柄。调用方负责 Close。
func (s *Store) Open(sum string) (*os.File, error) {
p, err := s.pathFor(sum)
if err != nil {
return nil, err
}
return os.Open(p)
}
// Exists 判断某内容是否已在库中。
func (s *Store) Exists(sum string) bool {
p, err := s.pathFor(sum)
if err != nil {
return false
}
_, err = os.Stat(p)
return err == nil
}
// Remove 删除某内容。
//
// 注意:内容寻址意味着多条附件记录可能指向同一个文件,
// 因此调用方必须先确认没有其他记录引用该 sha256 才能删。
func (s *Store) Remove(sum string) error {
p, err := s.pathFor(sum)
if err != nil {
return err
}
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

View File

@ -0,0 +1,171 @@
package blob
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func newStore(t *testing.T) *Store {
t.Helper()
s, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
return s
}
func TestPutAndOpenRoundTrip(t *testing.T) {
s := newStore(t)
data := []byte("附件内容 with bytes \x00\x01")
sum, n, err := s.Put(bytes.NewReader(data), 0)
if err != nil {
t.Fatal(err)
}
if n != int64(len(data)) {
t.Errorf("写入 %d 字节,报告 %d", len(data), n)
}
h := sha256.Sum256(data)
if sum != hex.EncodeToString(h[:]) {
t.Errorf("sha256 = %s与内容不符", sum)
}
f, err := s.Open(sum)
if err != nil {
t.Fatal(err)
}
defer f.Close()
got, _ := io.ReadAll(f)
if !bytes.Equal(got, data) {
t.Error("读回的内容与写入不一致")
}
}
// 相同内容重复上传必须复用同一个文件,不占额外空间。
func TestPutDeduplicates(t *testing.T) {
s := newStore(t)
data := []byte("same content")
sum1, _, err := s.Put(bytes.NewReader(data), 0)
if err != nil {
t.Fatal(err)
}
sum2, _, err := s.Put(bytes.NewReader(data), 0)
if err != nil {
t.Fatal(err)
}
if sum1 != sum2 {
t.Fatalf("同内容得到不同哈希: %s vs %s", sum1, sum2)
}
// 目录里应当只有一个内容文件(外加两级目录)
var files int
filepath.Walk(s.Root(), func(_ string, info os.FileInfo, _ error) error {
if info != nil && !info.IsDir() {
files++
}
return nil
})
if files != 1 {
t.Errorf("去重后应只剩 1 个文件,实际 %d", files)
}
}
// 超限必须拒绝,且不能留下半个临时文件。
func TestPutTooLargeLeavesNoGarbage(t *testing.T) {
s := newStore(t)
data := bytes.Repeat([]byte("x"), 1024)
_, _, err := s.Put(bytes.NewReader(data), 512)
if !errors.Is(err, ErrTooLarge) {
t.Fatalf("期望 ErrTooLarge得到 %v", err)
}
entries, _ := os.ReadDir(s.Root())
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".upload-") {
t.Errorf("超限后残留临时文件 %s", e.Name())
}
}
}
// 恰好等于上限应当通过 —— 边界不能误杀。
func TestPutExactlyAtLimit(t *testing.T) {
s := newStore(t)
data := bytes.Repeat([]byte("y"), 512)
if _, n, err := s.Put(bytes.NewReader(data), 512); err != nil {
t.Fatalf("恰好等于上限被拒: %v", err)
} else if n != 512 {
t.Errorf("字节数 = %dwant 512", n)
}
}
// 路径完全由 sha256 派生,任何非法 sum 都不能落到库外。
func TestPathTraversalRejected(t *testing.T) {
s := newStore(t)
for _, bad := range []string{
"../../etc/passwd",
"..",
"/etc/passwd",
"ABCDEF", // 大写非法
strings.Repeat("g", 64), // 非十六进制
strings.Repeat("a", 63), // 长度不足
"",
} {
if _, err := s.pathFor(bad); err == nil {
t.Errorf("pathFor(%q) 应报错", bad)
}
if _, err := s.Open(bad); err == nil {
t.Errorf("Open(%q) 应报错", bad)
}
if s.Exists(bad) {
t.Errorf("Exists(%q) 应为 false", bad)
}
}
}
// 生成的路径必须落在库根目录之内。
func TestPathStaysInsideRoot(t *testing.T) {
s := newStore(t)
sum := strings.Repeat("ab", 32)
p, err := s.pathFor(sum)
if err != nil {
t.Fatal(err)
}
rel, err := filepath.Rel(s.Root(), p)
if err != nil || strings.HasPrefix(rel, "..") {
t.Errorf("路径逃出库根: %s", p)
}
}
func TestRemove(t *testing.T) {
s := newStore(t)
sum, _, err := s.Put(bytes.NewReader([]byte("z")), 0)
if err != nil {
t.Fatal(err)
}
if !s.Exists(sum) {
t.Fatal("写入后应存在")
}
if err := s.Remove(sum); err != nil {
t.Fatal(err)
}
if s.Exists(sum) {
t.Error("删除后仍存在")
}
// 重复删除应当幂等,不报错
if err := s.Remove(sum); err != nil {
t.Errorf("重复删除报错: %v", err)
}
}

View File

@ -0,0 +1,94 @@
package config
import (
"os"
"path/filepath"
"strconv"
"strings"
)
type Config struct {
Port string
DatabaseURL string
CORSOrigins []string
// 首次启动时创建的默认管理员
AdminUser string
AdminPassword string
// Cookie 是否要求 HTTPS生产环境置 true
SecureCookie bool
CookieName string
// 附件:文件内容存盘,默认与 SQLite 同目录下的 attachments/
AttachmentDir string
// 单个附件上限(字节)。默认 25MB与常见邮箱附件限额一致
MaxAttachmentBytes int64
}
var C *Config
func Load() *Config {
C = &Config{
Port: getEnv("PORT", "8180"),
// 空值 = 用内置 SQLitedata/agentmail.db可用 AGENTMAIL_DATA_DIR 改目录)。
// 想接外部库就给 postgres://…;也接受 sqlite:///path/x.db 与裸路径。
DatabaseURL: getEnv("DATABASE_URL", ""),
CORSOrigins: splitEnv("CORS_ORIGINS", []string{
"http://localhost:5173",
"http://127.0.0.1:5173",
}),
AdminUser: strings.ToLower(getEnv("ADMIN_USER", "admin")),
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
SecureCookie: getEnvBool("SECURE_COOKIE", false),
CookieName: getEnv("COOKIE_NAME", "am_session"),
AttachmentDir: getEnv("AGENTMAIL_ATTACHMENT_DIR",
filepath.Join(getEnv("AGENTMAIL_DATA_DIR", "data"), "attachments")),
MaxAttachmentBytes: getEnvInt64("AGENTMAIL_MAX_ATTACHMENT_BYTES", 25<<20),
}
return C
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func getEnvBool(key string, fallback bool) bool {
if v := os.Getenv(key); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return fallback
}
func getEnvInt64(key string, fallback int64) int64 {
if v := os.Getenv(key); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
return n
}
}
return fallback
}
func splitEnv(key string, fallback []string) []string {
v := os.Getenv(key)
if v == "" {
return fallback
}
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
}
}
if len(out) == 0 {
return fallback
}
return out
}

193
gateway/internal/db/db.go Normal file
View File

@ -0,0 +1,193 @@
// Package db 提供数据库连接与方言适配。
//
// AgentMail 默认用 SQLite零依赖、单文件配合 go:embed 的前端就是「一个二进制 + 一个 .db」
// 用户显式给出 DATABASE_URL 时切换到外部 PostgreSQL。
//
// 两种方言的差异集中在本包处理repo 层只写一份 SQL
// - 占位符SQLite 也支持 $1/$2无需改写
// - NOW() / gen_random_uuid()SQLite 侧注册同名函数补齐
// - JSONB 包含判断:走 CCHas/CCArg 辅助函数(唯一必须分支的查询)
// - 唯一约束冲突IsUniqueViolation 统一识别
package db
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
_ "github.com/jackc/pgx/v5/stdlib" // database/sql 驱动pgx
sqlite "modernc.org/sqlite"
)
type Dialect string
const (
Postgres Dialect = "postgres"
SQLite Dialect = "sqlite"
)
var (
DB *sql.DB
// D 是当前生效的方言repo 层据此选择 SQL 片段
D Dialect
)
func init() {
// SQLite 没有 NOW() 与 gen_random_uuid(),注册同名函数使 repo 层 SQL 与 PG 保持一致。
// 函数名在 SQLite 中大小写不敏感,注册小写即可匹配 SQL 里的 NOW()。
sqlite.MustRegisterDeterministicScalarFunction("gen_random_uuid", 0,
func(*sqlite.FunctionContext, []driver.Value) (driver.Value, error) {
return uuid.NewString(), nil
})
// NOW() 必须非确定性:同一语句内多次调用要各自取当前时刻,
// 且格式与 SQLite 的 CURRENT_TIMESTAMP 一致,才能统一扫进 time.Time。
sqlite.MustRegisterScalarFunction("now", 0,
func(*sqlite.FunctionContext, []driver.Value) (driver.Value, error) {
return time.Now().UTC().Format("2006-01-02 15:04:05"), nil
})
}
// Connect 依据 DATABASE_URL 建立连接。空值时落到 SQLite。
func Connect(ctx context.Context, dsn string) error {
driverName, connStr, dialect, err := resolve(dsn)
if err != nil {
return err
}
pool, err := sql.Open(driverName, connStr)
if err != nil {
return fmt.Errorf("open %s: %w", dialect, err)
}
switch dialect {
case Postgres:
pool.SetMaxOpenConns(20)
pool.SetMaxIdleConns(4)
pool.SetConnMaxLifetime(30 * time.Minute)
pool.SetConnMaxIdleTime(5 * time.Minute)
case SQLite:
// SQLite 单写者:并发写靠 WAL + busy_timeout 排队,连接数放大只会加剧锁竞争。
pool.SetMaxOpenConns(1)
pool.SetMaxIdleConns(1)
pool.SetConnMaxLifetime(0)
}
if err := pool.PingContext(ctx); err != nil {
pool.Close()
return fmt.Errorf("ping %s: %w", dialect, err)
}
DB = pool
D = dialect
return nil
}
// resolve 把 DATABASE_URL 解析为 (驱动名, 连接串, 方言)。
func resolve(dsn string) (string, string, Dialect, error) {
dsn = strings.TrimSpace(dsn)
if dsn == "" {
return "sqlite", sqliteDSN(defaultDBPath()), SQLite, nil
}
switch {
case strings.HasPrefix(dsn, "postgres://"), strings.HasPrefix(dsn, "postgresql://"):
return "pgx", dsn, Postgres, nil
case strings.HasPrefix(dsn, "sqlite://"):
return "sqlite", sqliteDSN(strings.TrimPrefix(dsn, "sqlite://")), SQLite, nil
case strings.HasPrefix(dsn, "sqlite:"):
return "sqlite", sqliteDSN(strings.TrimPrefix(dsn, "sqlite:")), SQLite, nil
case strings.HasPrefix(dsn, "file:"):
// 已是 SQLite URI原样透传调用方自带 pragma
return "sqlite", dsn, SQLite, nil
case strings.HasSuffix(dsn, ".db"), strings.HasSuffix(dsn, ".sqlite"), strings.HasSuffix(dsn, ".sqlite3"):
return "sqlite", sqliteDSN(dsn), SQLite, nil
}
return "", "", "", fmt.Errorf("无法识别的 DATABASE_URL %q期望 postgres://…、sqlite:///path/x.db 或 /path/x.db", dsn)
}
// defaultDBPath 返回默认 SQLite 文件位置AGENTMAIL_DATA_DIR 可覆盖)。
func defaultDBPath() string {
dir := os.Getenv("AGENTMAIL_DATA_DIR")
if dir == "" {
dir = "data"
}
return filepath.Join(dir, "agentmail.db")
}
// sqliteDSN 把文件路径包装为带 pragma 的 SQLite URI并确保父目录存在。
//
// - journal_mode=WAL读写不互斥SSE 长连接查询不会被写入阻塞
// - busy_timeout=5000并发写时排队 5s 而不是立刻 SQLITE_BUSY
// - foreign_keys=1SQLite 默认不校验外键,必须显式打开
func sqliteDSN(path string) string {
if dir := filepath.Dir(path); dir != "" && dir != "." {
os.MkdirAll(dir, 0o755)
}
return "file:" + path +
"?_pragma=journal_mode(WAL)" +
"&_pragma=busy_timeout(5000)" +
"&_pragma=foreign_keys(1)"
}
func Close() {
if DB != nil {
DB.Close()
}
}
// ---------- 方言差异 ----------
// CCHas 返回「cc_list 是否抄送了某人」的 SQL 片段argN 是该人名对应的占位符序号。
//
// 两个方言的实参都是【纯人名字符串】,不是 JSON 探针——因为多处查询把同一个
// 占位符同时用于 from_name/to_name 比较和抄送判断,两种实参约定必然出错。
// PG 侧在 SQL 里用 jsonb_build_* 现场构造探针SQLite 侧用 json_each 展开逐项比对。
func CCHas(col string, argN int) string {
if D == Postgres {
return fmt.Sprintf("%s @> jsonb_build_array(jsonb_build_object('name', $%d::text))", col, argN)
}
return fmt.Sprintf(
"EXISTS (SELECT 1 FROM json_each(%s) WHERE json_extract(value, '$.name') = $%d)",
col, argN)
}
// JSONCast 返回把占位符转成 JSONB 的后缀PG 需要 ::jsonbSQLite 存 TEXT 无需转换)。
func JSONCast() string {
if D == Postgres {
return "::jsonb"
}
return ""
}
// IsUniqueViolation 判断错误是否为唯一约束冲突(用于别名撞名重试)。
func IsUniqueViolation(err error) bool {
if err == nil {
return false
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "23505"
}
var liteErr *sqlite.Error
if errors.As(err, &liteErr) {
// SQLITE_CONSTRAINT_UNIQUE = 2067、SQLITE_CONSTRAINT_PRIMARYKEY = 1555
code := liteErr.Code()
return code == 2067 || code == 1555
}
return false
}

View File

@ -0,0 +1,118 @@
package db
import (
"context"
_ "embed"
"fmt"
"strings"
)
//go:embed migrations/init.sql
var initSQLPostgres string
//go:embed migrations/init_sqlite.sql
var initSQLSQLite string
// Migrate 建表建索引。两种方言各有一份 schema语义保持一致。
func Migrate(ctx context.Context) error {
switch D {
case Postgres:
// PG 侧含 DO $$ … $$ 迁移块,必须整体提交
if _, err := DB.ExecContext(ctx, initSQLPostgres); err != nil {
return fmt.Errorf("migrate postgres: %w", err)
}
case SQLite:
// modernc.org/sqlite 的 Exec 不接受多语句,逐条执行
for i, stmt := range splitStatements(initSQLSQLite) {
if _, err := DB.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("migrate sqlite (语句 #%d: %.60s): %w", i+1, stmt, err)
}
}
// CREATE TABLE IF NOT EXISTS 不会给**已存在**的表补列,而 SQLite 又没有
// ADD COLUMN IF NOT EXISTS。已部署的库靠这一步补齐新列。
if err := addMissingColumns(ctx); err != nil {
return err
}
default:
return fmt.Errorf("migrate: 未初始化的方言")
}
fmt.Printf("数据库迁移完成(%s\n", D)
return nil
}
// splitStatements 按分号切分 SQL 脚本并剔除注释行。
// 本项目的 SQLite schema 只有 CREATE 语句,不含字符串字面量里的分号,
// 因此按分号朴素切分是安全的;若将来加入含分号的字面量需改用真正的词法切分。
func splitStatements(script string) []string {
var out []string
for _, raw := range strings.Split(script, ";") {
var lines []string
for _, line := range strings.Split(raw, "\n") {
if t := strings.TrimSpace(line); t == "" || strings.HasPrefix(t, "--") {
continue
}
lines = append(lines, line)
}
if stmt := strings.TrimSpace(strings.Join(lines, "\n")); stmt != "" {
out = append(out, stmt)
}
}
return out
}
// sqliteAddColumns 声明 SQLite 侧需要在已存在的表上补齐的列。
//
// 新库由 init_sqlite.sql 的 CREATE TABLE 一次建全,这里只服务**已部署的库**。
// PG 侧用 ALTER TABLE ... ADD COLUMN IF NOT EXISTS 就够SQLite 没有这个语法,
// 只能先查 pragma 再决定加不加。
//
// 新增列时同时改两处init_sqlite.sql 的 CREATE TABLE给新库与这张表给老库
var sqliteAddColumns = []struct{ table, column, ddl string }{
{"mails", "rename_alias", "ALTER TABLE mails ADD COLUMN rename_alias TEXT"},
{"mails", "rename_reason", "ALTER TABLE mails ADD COLUMN rename_reason TEXT"},
{"sessions", "rename_dismissed", "ALTER TABLE sessions ADD COLUMN rename_dismissed TEXT"},
{"sessions", "alias_source", "ALTER TABLE sessions ADD COLUMN alias_source TEXT NOT NULL DEFAULT 'platform'"},
// 会话级往返预算0 = 不限)。旧库默认 0引入预算不应该把已在进行的会话卡死。
{"sessions", "max_rounds", "ALTER TABLE sessions ADD COLUMN max_rounds INTEGER NOT NULL DEFAULT 0"},
{"sessions", "used_rounds", "ALTER TABLE sessions ADD COLUMN used_rounds INTEGER NOT NULL DEFAULT 0"},
}
// sqliteAddIndexes 是建表后才能建的索引(依赖上面补的列)。
// CREATE INDEX IF NOT EXISTS 天然幂等,直接执行即可。
var sqliteAddIndexes = []string{
// 人类决策后要按 mail_id 反查上游 permission id
"CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id)",
}
func addMissingColumns(ctx context.Context) error {
for _, c := range sqliteAddColumns {
has, err := columnExists(ctx, c.table, c.column)
if err != nil {
return fmt.Errorf("migrate sqlite: 检查 %s.%s: %w", c.table, c.column, err)
}
if has {
continue
}
if _, err := DB.ExecContext(ctx, c.ddl); err != nil {
return fmt.Errorf("migrate sqlite: 补列 %s.%s: %w", c.table, c.column, err)
}
fmt.Printf("补列 %s.%s\n", c.table, c.column)
}
for _, ddl := range sqliteAddIndexes {
if _, err := DB.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("migrate sqlite: 建索引 %.60s: %w", ddl, err)
}
}
return nil
}
func columnExists(ctx context.Context, table, column string) (bool, error) {
// pragma_table_info 是表函数形式的 PRAGMA可以直接当表查比解析 PRAGMA 输出干净)。
// table 与 column 都来自上面的硬编码常量表,不存在注入面。
var n int
err := DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`,
table, column).Scan(&n)
return n > 0, err
}

View File

@ -0,0 +1,297 @@
-- AgentMail MVP Schema
-- PostgreSQL 14+
-- Users table人类多用户username 与 agents.agent_name 共用命名空间)
CREATE TABLE IF NOT EXISTS users (
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(64) NOT NULL UNIQUE,
display_name VARCHAR(128) NOT NULL DEFAULT '',
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'user',
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT NOW(),
last_login TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS user_sessions (
token VARCHAR(64) PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
user_agent VARCHAR(256) DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_exp ON user_sessions(expires_at);
-- 用户权限边界:可调用的 Agent 与可访问的工作区目录
-- allowed_agents["deepseekharness","pi"],空数组 = 不限(继承系统默认)
-- allowed_paths ["/program","/home/x"],空数组 = 不限;按前缀匹配
-- agent_aliases {"大龙":"deepseekharness","小派":"pi"},发信时自动解析真实名
ALTER TABLE users ADD COLUMN IF NOT EXISTS allowed_agents JSONB NOT NULL DEFAULT '[]';
ALTER TABLE users ADD COLUMN IF NOT EXISTS allowed_paths JSONB NOT NULL DEFAULT '[]';
ALTER TABLE users ADD COLUMN IF NOT EXISTS agent_aliases JSONB NOT NULL DEFAULT '{}';
-- Agents table
CREATE TABLE IF NOT EXISTS agents (
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_name VARCHAR(64) NOT NULL UNIQUE,
secret VARCHAR(128) NOT NULL,
host_url VARCHAR(256) NOT NULL DEFAULT '',
workspaces JSONB NOT NULL DEFAULT '[]',
platform VARCHAR(32) NOT NULL DEFAULT 'pi',
status VARCHAR(16) NOT NULL DEFAULT 'offline',
max_rounds INT NOT NULL DEFAULT 10,
used_rounds INT NOT NULL DEFAULT 0,
last_seen TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Sessions table
CREATE TABLE IF NOT EXISTS sessions (
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_alias VARCHAR(128),
from_agent VARCHAR(64) NOT NULL,
subject VARCHAR(512) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'active',
owner_user_id UUID REFERENCES users(user_id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(session_alias);
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
-- 会话别名负责寻址name@path.<alias>),必须全局唯一。
-- 部分唯一索引未命名会话NULL不受约束可以有任意多个。
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_alias_uniq
ON sessions(session_alias) WHERE session_alias IS NOT NULL;
-- 已存在的库补列(必须先于依赖该列的索引)
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS owner_user_id UUID REFERENCES users(user_id);
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS rename_dismissed TEXT;
-- 别名是谁定的:'platform'Agent 平台自动同步,可被后续同步覆盖)
-- 或 'manual'(人显式指定,平台同步不得覆盖)。
-- 没有这个标记,平台的下一次 session.updated 会把人刚接受的名字冲掉。
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS alias_source TEXT NOT NULL DEFAULT 'platform';
-- 本次任务的往返预算0 = 本会话不限,仅受 Agent 全局配额约束)。
--
-- 配额的真实语义是「这件事值得多少个来回」,那是任务的属性而不是 Agent 的属性:
-- 只有 agents.max_rounds 一个全局计数器时,两个并行任务会互相抢额度,
-- 且 used_rounds 单调递增,一旦跑满就得管理员手工重置才能再干活。
-- 因此预算下沉到会话,由人在写信时给、在对话页里随时调。
--
-- Agent 全局配额仍然生效(两者都要过):否则 Agent 自己 .new 开一串会话,
-- 每条都是全新预算,全局上限就形同虚设。
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS max_rounds INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS used_rounds INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id);
-- Mails table
CREATE TABLE IF NOT EXISTS mails (
mail_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(session_id),
parent_mail_id UUID REFERENCES mails(mail_id),
from_name VARCHAR(64) NOT NULL,
from_workspace VARCHAR(128) DEFAULT '',
to_name VARCHAR(64) NOT NULL,
to_workspace VARCHAR(128) DEFAULT '',
subject VARCHAR(512) NOT NULL,
body TEXT NOT NULL,
-- 拄送列表:[{"name":"pi","path":"root","session":"new","raw":"pi@root.new"}]
cc_list JSONB NOT NULL DEFAULT '[]',
mail_type VARCHAR(32) NOT NULL DEFAULT 'normal',
permission_options JSONB,
permission_result VARCHAR(32),
status VARCHAR(16) NOT NULL DEFAULT 'unread',
created_at TIMESTAMPTZ DEFAULT NOW(),
hop_limit INT DEFAULT 5
);
CREATE INDEX IF NOT EXISTS idx_mails_session ON mails(session_id);
CREATE INDEX IF NOT EXISTS idx_mails_to ON mails(to_name, status);
CREATE INDEX IF NOT EXISTS idx_mails_parent ON mails(parent_mail_id);
-- 已存在的库补列(重复运行安全)
ALTER TABLE mails ADD COLUMN IF NOT EXISTS cc_list JSONB NOT NULL DEFAULT '[]';
-- Agent 在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
-- 存在邮件上而非会话上:邮件是不可篡改的历史记录,「谁在哪一封里提了什么」应当留痕。
ALTER TABLE mails ADD COLUMN IF NOT EXISTS rename_alias TEXT;
ALTER TABLE mails ADD COLUMN IF NOT EXISTS rename_reason TEXT;
-- 抄送检索cc_list @> '[{"name":"pi"}]' 走 GIN
CREATE INDEX IF NOT EXISTS idx_mails_cc_list ON mails USING GIN (cc_list jsonb_path_ops);
-- 历史数据规整早期写入过首字母大写的键Raw/Name/Path/Session统一成小写
UPDATE mails
SET cc_list = (
SELECT COALESCE(jsonb_agg(
jsonb_strip_nulls(jsonb_build_object(
'name', COALESCE(e->>'name', e->>'Name'),
'path', COALESCE(e->>'path', e->>'Path'),
'session', COALESCE(e->>'session', e->>'Session'),
'raw', COALESCE(e->>'raw', e->>'Raw')
))
), '[]'::jsonb)
FROM jsonb_array_elements(cc_list) AS e
)
WHERE cc_list @? '$[*].Name';
-- Permission requests table
CREATE TABLE IF NOT EXISTS permission_requests (
request_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mail_id UUID NOT NULL REFERENCES mails(mail_id),
session_id UUID NOT NULL REFERENCES sessions(session_id),
agent_name VARCHAR(64) NOT NULL,
question TEXT NOT NULL,
options JSONB NOT NULL DEFAULT '["同意", "拒绝"]',
context TEXT DEFAULT '',
result VARCHAR(32),
decided_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_perm_agent ON permission_requests(agent_name, result);
CREATE INDEX IF NOT EXISTS idx_perm_pending ON permission_requests(result) WHERE result IS NULL;
-- 历史邮件里的字面量 'human' 迁移到默认管理员账号
-- (管理员由 Go 侧 EnsureAdminUser 首启创建,此处只做数据重写)
DO $$
DECLARE
admin_name TEXT;
BEGIN
SELECT username INTO admin_name
FROM users WHERE role = 'admin' AND status = 'active'
ORDER BY created_at ASC LIMIT 1;
IF admin_name IS NULL THEN
RETURN; -- 还没有管理员,下次迁移再试
END IF;
UPDATE mails SET from_name = admin_name WHERE from_name = 'human';
UPDATE mails SET to_name = admin_name WHERE to_name = 'human';
UPDATE sessions SET from_agent = admin_name WHERE from_agent = 'human';
-- 拄送列表里的 human 一并重写
UPDATE mails
SET cc_list = (
SELECT COALESCE(jsonb_agg(
CASE WHEN e->>'name' = 'human'
THEN jsonb_set(
jsonb_set(e, '{name}', to_jsonb(admin_name)),
'{raw}',
to_jsonb(admin_name || '@' || COALESCE(e->>'path','') ||
CASE WHEN COALESCE(e->>'session','') = '' THEN ''
ELSE '.' || (e->>'session') END))
ELSE e END
), '[]'::jsonb)
FROM jsonb_array_elements(cc_list) AS e
)
WHERE cc_list @> '[{"name":"human"}]';
-- 人类发起的会话补上 owner
UPDATE sessions s
SET owner_user_id = u.user_id
FROM users u
WHERE u.username = admin_name
AND s.owner_user_id IS NULL
AND s.from_agent = admin_name;
END $$;
-- ---------- 密钥认证体系 ----------
--
-- 两类密钥,共享一个全局唯一的 token 命名空间(验证时先查 agent_keys 再查 user_keys
-- agent_keys管理员签发用于 Agent 注册/心跳/SSE
-- user_keys :用户自助签发,仅用于 /me/* 人类邮箱接口,不可注册 Agent
--
-- key_type
-- permanent — 永不过期,可重复使用
-- one_time — 首次验证后写 used_at再用即拒
-- timed — expires_at 之后失效
CREATE TABLE IF NOT EXISTS agent_keys (
key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key_token VARCHAR(128) NOT NULL UNIQUE,
agent_name VARCHAR(64), -- NULL = 待绑定
key_type VARCHAR(16) NOT NULL DEFAULT 'permanent',
label VARCHAR(128) NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ,
used_at TIMESTAMPTZ,
created_by UUID REFERENCES users(user_id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_agent_keys_token ON agent_keys(key_token);
CREATE INDEX IF NOT EXISTS idx_agent_keys_agent ON agent_keys(agent_name);
CREATE TABLE IF NOT EXISTS user_keys (
key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key_token VARCHAR(128) NOT NULL UNIQUE,
user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
label VARCHAR(128) NOT NULL DEFAULT '',
key_type VARCHAR(16) NOT NULL DEFAULT 'permanent',
expires_at TIMESTAMPTZ,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_keys_user ON user_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_user_keys_token ON user_keys(key_token);
-- ---------- 附件 ----------
--
-- 文件内容存磁盘(内容寻址:路径由 sha256 派生),数据库只存元数据。
--
-- mail_id 为 NULL 表示「已上传但还没挂到邮件上」的待用附件:
-- 上传与发信是两步Agent 工具是 JSON 接口,没法在发信时带 multipart
-- 中间态必须允许存在;超时未挂载的由 GC 清掉。
-- 插件自动转发的邮件登记表。
--
-- **配额约束的是模型的自主发信,不是 harness 的转发**(基本原则):
-- 配额存在的意义是防止 Agent 无限自我循环。而「把平台原生的权限询问转给人」
-- 与「把本轮的最终总结转给人」都是插件代劳的搬运,不是模型自己决定要发的信 ——
-- 对它们收费会导致配额用尽时 Agent 连交代都做不了。
--
-- relay_key 是上游那条消息的稳定标识opencode 的 permission id / assistant message id
-- 唯一约束把「同一条上游消息只转一次」变成一条 INSERT 的成败:
-- * 插件重试、SSE 重连后重放都不会产生第二封
-- * 也顺带给免配额通道加了结构性上限 —— 想多转就得拿出不同的上游消息 id
CREATE TABLE IF NOT EXISTS relayed_mails (
agent_name VARCHAR(64) NOT NULL,
relay_key VARCHAR(160) NOT NULL,
mail_id UUID REFERENCES mails(mail_id),
kind VARCHAR(32) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (agent_name, relay_key)
);
-- 人类决策后要按 mail_id 反查上游 permission id
CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id);
CREATE TABLE IF NOT EXISTS attachments (
attachment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mail_id UUID REFERENCES mails(mail_id) ON DELETE CASCADE,
uploader VARCHAR(64) NOT NULL,
filename VARCHAR(512) NOT NULL,
content_type VARCHAR(128) NOT NULL DEFAULT 'application/octet-stream',
size_bytes BIGINT NOT NULL,
sha256 CHAR(64) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_attachments_mail ON attachments(mail_id);
CREATE INDEX IF NOT EXISTS idx_attachments_sha ON attachments(sha256);
CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE mail_id IS NULL;

View File

@ -0,0 +1,241 @@
-- AgentMail Schema — SQLite默认后端
--
-- 与 init.sqlPostgreSQL保持同一套表结构与语义差异仅在方言
-- UUID → TEXTGo 侧 uuid 或 gen_random_uuid() 注册函数生成)
-- TIMESTAMPTZ → DATETIME必须写 DATETIMEdatabase/sql 才能扫进 time.Time
-- JSONB → TEXT存 JSON 字符串,用 json_each/json_extract 检索)
-- VARCHAR(n) → TEXTSQLite 不强制长度,长度约束由应用层负责)
-- NOW() → 由 internal/db 注册的同名函数提供,与 PG 侧 SQL 一致
--
-- 本文件只建表建索引不含数据迁移SQLite 是新引入的默认后端,不存在历史库。
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
-- 权限边界:空数组 = 不限
allowed_agents TEXT NOT NULL DEFAULT '[]',
allowed_paths TEXT NOT NULL DEFAULT '[]',
agent_aliases TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS user_sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
user_agent TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_exp ON user_sessions(expires_at);
CREATE TABLE IF NOT EXISTS agents (
agent_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
agent_name TEXT NOT NULL UNIQUE,
secret TEXT NOT NULL,
host_url TEXT NOT NULL DEFAULT '',
workspaces TEXT NOT NULL DEFAULT '[]',
platform TEXT NOT NULL DEFAULT 'pi',
status TEXT NOT NULL DEFAULT 'offline',
max_rounds INTEGER NOT NULL DEFAULT 10,
used_rounds INTEGER NOT NULL DEFAULT 0,
last_seen DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
session_alias TEXT,
from_agent TEXT NOT NULL,
subject TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
owner_user_id TEXT REFERENCES users(user_id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
rename_dismissed TEXT,
-- 别名是谁定的:'platform'Agent 平台自动同步,可被后续同步覆盖)
-- 或 'manual'(人显式指定,平台同步不得覆盖)。
-- 没有这个标记,平台的下一次 session.updated 会把人刚接受的名字冲掉,
-- 人上一秒记住的寻址地址下一秒失效。
alias_source TEXT NOT NULL DEFAULT 'platform',
-- 本次任务的往返预算0 = 本会话不限,仅受 Agent 全局配额约束)。
--
-- 配额的真实语义是「这件事值得多少个来回」,那是任务的属性而不是 Agent 的属性:
-- 只有 agents.max_rounds 一个全局计数器时,两个并行任务会互相抢额度,
-- 且 used_rounds 单调递增,一旦跑满就得管理员手工重置才能再干活。
-- 因此预算下沉到会话,由人在写信时给、在对话页里随时调。
--
-- Agent 全局配额仍然生效(两者都要过):否则 Agent 自己 .new 开一串会话,
-- 每条都是全新预算,全局上限就形同虚设。
max_rounds INTEGER NOT NULL DEFAULT 0,
used_rounds INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(session_alias);
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id);
-- 会话别名负责寻址name@path.<alias>),必须全局唯一。
-- 部分唯一索引未命名会话NULL不受约束可以有任意多个。
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_alias_uniq
ON sessions(session_alias) WHERE session_alias IS NOT NULL;
CREATE TABLE IF NOT EXISTS mails (
mail_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
session_id TEXT NOT NULL REFERENCES sessions(session_id),
parent_mail_id TEXT REFERENCES mails(mail_id),
from_name TEXT NOT NULL,
from_workspace TEXT DEFAULT '',
to_name TEXT NOT NULL,
to_workspace TEXT DEFAULT '',
subject TEXT NOT NULL,
body TEXT NOT NULL,
-- 抄送列表:[{"name":"pi","path":"root","session":"new","raw":"pi@root.new"}]
cc_list TEXT NOT NULL DEFAULT '[]',
mail_type TEXT NOT NULL DEFAULT 'normal',
permission_options TEXT,
permission_result TEXT,
status TEXT NOT NULL DEFAULT 'unread',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
hop_limit INTEGER DEFAULT 5,
-- Agent 在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
-- 存在邮件上而非会话上:邮件是不可篡改的历史记录,
-- 「谁在哪一封里提了什么」应当留痕。
rename_alias TEXT,
rename_reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_mails_session ON mails(session_id);
CREATE INDEX IF NOT EXISTS idx_mails_to ON mails(to_name, status);
CREATE INDEX IF NOT EXISTS idx_mails_parent ON mails(parent_mail_id);
CREATE INDEX IF NOT EXISTS idx_mails_created ON mails(created_at);
-- 抄送检索无对应索引SQLite 侧走 json_each 展开。
-- 单机邮件量级(数千至数万)下全表展开是毫秒级,不值得为此加物化列。
CREATE TABLE IF NOT EXISTS permission_requests (
request_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
mail_id TEXT NOT NULL REFERENCES mails(mail_id),
session_id TEXT NOT NULL REFERENCES sessions(session_id),
agent_name TEXT NOT NULL,
question TEXT NOT NULL,
options TEXT NOT NULL DEFAULT '["同意","拒绝"]',
context TEXT DEFAULT '',
result TEXT,
decided_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_perm_agent ON permission_requests(agent_name, result);
CREATE INDEX IF NOT EXISTS idx_perm_pending ON permission_requests(result) WHERE result IS NULL;
-- ---------- 密钥认证体系 ----------
--
-- 两类密钥,共享一个全局唯一的 token 命名空间(验证时先查 agent_keys 再查 user_keys
-- agent_keys管理员签发用于 Agent 注册/心跳/SSE
-- user_keys :用户自助签发,仅用于 /me/* 人类邮箱接口,不可注册 Agent
--
-- key_type
-- permanent — 永不过期,可重复使用
-- one_time — 首次验证后写 used_at再用即拒
-- timed — expires_at 之后失效
CREATE TABLE IF NOT EXISTS agent_keys (
key_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
key_token TEXT NOT NULL UNIQUE,
agent_name TEXT, -- NULL = 待绑定
key_type TEXT NOT NULL DEFAULT 'permanent',
label TEXT NOT NULL DEFAULT '',
expires_at DATETIME,
used_at DATETIME,
created_by TEXT REFERENCES users(user_id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_agent_keys_token ON agent_keys(key_token);
CREATE INDEX IF NOT EXISTS idx_agent_keys_agent ON agent_keys(agent_name);
CREATE TABLE IF NOT EXISTS user_keys (
key_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
key_token TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
label TEXT NOT NULL DEFAULT '',
key_type TEXT NOT NULL DEFAULT 'permanent',
expires_at DATETIME,
used_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_keys_user ON user_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_user_keys_token ON user_keys(key_token);
-- ---------- 附件 ----------
--
-- 文件内容存磁盘(内容寻址:路径由 sha256 派生),数据库只存元数据。
-- 不塞 BLOBSQLite 的 BLOB 会让 .db 文件膨胀并拖慢 WAL而附件是只写一次多次读的冷数据。
--
-- mail_id 为 NULL 表示「已上传但还没挂到邮件上」的待用附件:
-- 上传与发信是两步Agent 工具是 JSON 接口,没法在发信时带 multipart
-- 中间态必须允许存在;超时未挂载的由 GC 清掉。
-- 插件自动转发的邮件登记表。
--
-- **配额约束的是模型的自主发信,不是 harness 的转发**(基本原则):
-- 配额存在的意义是防止 Agent 无限自我循环。而「把平台原生的权限询问转给人」
-- 与「把本轮的最终总结转给人」都是插件代劳的搬运,不是模型自己决定要发的信 ——
-- 对它们收费会导致配额用尽时 Agent 连交代都做不了。
--
-- relay_key 是上游那条消息的稳定标识opencode 的 permission id / assistant message id
-- 唯一约束把「同一条上游消息只转一次」变成一条 INSERT 的成败:
-- * 插件重试、SSE 重连后重放都不会产生第二封
-- * 也顺带给免配额通道加了结构性上限 —— 想多转就得拿出不同的上游消息 id
CREATE TABLE IF NOT EXISTS relayed_mails (
agent_name TEXT NOT NULL,
relay_key TEXT NOT NULL,
mail_id TEXT REFERENCES mails(mail_id),
kind TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (agent_name, relay_key)
);
-- 人类决策后要按 mail_id 反查上游 permission id
CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id);
CREATE TABLE IF NOT EXISTS attachments (
attachment_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
mail_id TEXT REFERENCES mails(mail_id) ON DELETE CASCADE,
-- 上传者Agent 名或用户名),用于「只能挂自己上传的附件」校验
uploader TEXT NOT NULL,
filename TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size_bytes INTEGER NOT NULL,
-- sha256 既是去重依据也是磁盘路径来源,绝不用用户给的 filename 拼路径
sha256 TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_attachments_mail ON attachments(mail_id);
CREATE INDEX IF NOT EXISTS idx_attachments_sha ON attachments(sha256);
-- GC 扫描待挂载附件用
CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE mail_id IS NULL;

View File

@ -0,0 +1,142 @@
package handler
import (
"net/http"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
)
// ---------- Agent ----------
type registerRequest struct {
Name string `json:"name"`
Secret string `json:"secret"`
Workspaces []models.Workspace `json:"workspaces"`
Platform string `json:"platform"`
}
// POST /api/v1/agent/register
//
// 两种认证方式:
// 1. Authorization: Bearer <agent_key_token> —— 密钥认证(推荐)。
// 密钥未绑定时用本请求的 name 落定;已绑定时 name 必须与之一致,
// 否则等于拿别人的密钥冒充新身份。
// 2. body 里带 secret —— 旧方式,兼容保留。
func RegisterAgent(w http.ResponseWriter, r *http.Request) {
var req registerRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
if req.Name == "" {
Error(w, http.StatusBadRequest, "Missing name")
return
}
keyToken := middleware.BearerToken(r)
if keyToken == "" && req.Secret == "" {
Error(w, http.StatusBadRequest, "需要 Authorization: Bearer <密钥> 或 body 里的 secret")
return
}
if keyToken != "" {
bound, err := repo.VerifyAgentKey(r.Context(), keyToken)
if err != nil {
writeKeyErr(w, err)
return
}
if bound != "" && bound != req.Name {
Error(w, http.StatusForbidden,
"该密钥已绑定到 Agent \""+bound+"\",不能用于注册 \""+req.Name+"\"")
return
}
}
if req.Platform == "" {
req.Platform = "pi"
}
// 三维地址的 name 位与人类用户名共用命名空间,不得重名
if ok, err := repo.AgentNameAvailable(r.Context(), req.Name); err != nil {
Error(w, http.StatusInternalServerError, "Failed to validate agent name")
return
} else if !ok {
Error(w, http.StatusConflict, "该名称已被人类用户占用")
return
}
if req.Name == "human" {
Error(w, http.StatusBadRequest, "human 是保留别名,不能作为 Agent 名")
return
}
// 密钥认证时不需要 secret但 agents.secret 非空约束仍在;
// 存密钥本身作占位,旧的 name/secret 路径不受影响。
secret := req.Secret
if secret == "" {
secret = keyToken
}
if err := repo.CreateOrUpdateAgent(r.Context(), req.Name, secret, req.Platform, req.Workspaces); err != nil {
Error(w, http.StatusInternalServerError, "Failed to register agent")
return
}
// 待绑定密钥在首次注册成功后落定到该 Agent
if keyToken != "" {
if err := repo.ClaimAgentKey(r.Context(), keyToken, req.Name); err != nil {
Error(w, http.StatusInternalServerError, "Failed to bind key")
return
}
}
JSON(w, http.StatusOK, map[string]string{
"status": "registered",
"agent_name": req.Name,
})
}
// POST /api/v1/agent/heartbeat
func HeartbeatAgent(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
pending, err := repo.HeartbeatAgent(r.Context(), agentName)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to heartbeat")
return
}
// 心跳回传配额:插件据此把剩余次数注入 Agent 上下文,
// 让它在配额耗尽前主动发总结,而不是撞到 403 才发现。
quota, qErr := repo.GetQuota(r.Context(), agentName)
if qErr != nil {
// 配额读不到不影响心跳本身,降级为不限额
quota = repo.Quota{AgentName: agentName, Unlimited: true, Remaining: -1}
}
JSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"pending_mails": pending,
"quota": quota,
})
}
// GET /api/v1/agents
func ListAgents(w http.ResponseWriter, r *http.Request) {
statusFilter := r.URL.Query().Get("status")
agents, err := repo.ListAgents(r.Context(), statusFilter)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list agents")
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"agents": emptySlice(agents),
})
}

View File

@ -0,0 +1,69 @@
package handler
import "testing"
// 平台侧 slug/标题不受本侧寻址约束normalizeAlias 必须把它改写成
// 能安全出现在 name@path.<alias> 末段的形式。
func TestNormalizeAlias(t *testing.T) {
cases := []struct{ in, want string }{
// opencode 风格 slug 原样通过
{"jolly-cactus", "jolly-cactus"},
{"fix-memory-leak", "fix-memory-leak"},
// 非法字符统一换 -,连续的压缩成一个
{"fix.memory.leak", "fix-memory-leak"},
{"修复 登录态 丢失", "修复-登录态-丢失"},
{"a//b..c", "a-b-c"},
{"user@host", "user-host"},
// 首尾的分隔符要去掉
{".leading", "leading"},
{"trailing.", "trailing"},
{" spaced ", "spaced"},
// 保留字必须避开,否则会被寻址当成「新建会话」
{"new", "session-new"},
// 全是非法字符 → 空串,交由调用方报错
{"...", ""},
{"", ""},
}
for _, c := range cases {
if got := normalizeAlias(c.in); got != c.want {
t.Errorf("normalizeAlias(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// 规范化后的别名必须能通过寻址校验,否则同步会写进一个自己都拒绝的别名。
func TestNormalizeAliasPassesValidation(t *testing.T) {
for _, in := range []string{
"jolly-cactus", "fix.memory.leak", "修复 登录态 丢失", "new", "user@host/path",
} {
norm := normalizeAlias(in)
if norm == "" {
continue
}
if err := validateSessionAlias(norm); err != nil {
t.Errorf("normalizeAlias(%q) = %q但未通过 validateSessionAlias: %v", in, norm, err)
}
}
}
// 截断长别名时不能切坏多字节字符session_alias 是 VARCHAR(128))。
func TestNormalizeAliasTruncatesOnValidUTF8(t *testing.T) {
long := ""
for i := 0; i < 100; i++ {
long += "修" // 每个 3 字节,共 300 字节
}
got := normalizeAlias(long)
if len(got) > 128 {
t.Errorf("normalizeAlias 截断后 %d 字节,超过 128", len(got))
}
for _, r := range got {
if r == '\uFFFD' {
t.Fatalf("normalizeAlias 截断产生了非法 UTF-8: %q", got)
}
}
}

View File

@ -0,0 +1,368 @@
package handler
import (
"errors"
"fmt"
"mime"
"net/http"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/agentmail/gateway/internal/blob"
"github.com/agentmail/gateway/internal/config"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/google/uuid"
)
// ---------- 附件 ----------
//
// 上传与发信是两步:
// 1. POST /attachments multipart→ 拿到 attachment_id
// 2. 发信时把 id 放进 attachment_ids
// 之所以不合成一步Agent 侧的工具接口是 JSON没法带 multipart
// 而人类侧若只支持一步,就无法在写信过程中先传文件再改正文。
//
// 未挂载的附件是合法中间态,超时由 GC 清理repo.SweepOrphanAttachments
// Blobs 是附件内容存储,由 main 在启动时注入。
var Blobs *blob.Store
// sanitizeFilename 清理用户提供的文件名。
//
// 文件名只用于展示与下载时的 Content-Disposition磁盘路径完全由 sha256 派生,
// 因此这里的目的不是防路径穿越(那已由内容寻址杜绝),而是:
// - 去掉目录成分,避免下载时浏览器按 "a/b/c.txt" 解释
// - 去掉控制字符与换行,避免污染 HTTP 响应头
// - 限长,避免超出数据库列宽
func sanitizeFilename(name string) string {
name = strings.TrimSpace(name)
// 同时处理 / 与 \:上传方可能是 Windows 客户端
if i := strings.LastIndexAny(name, `/\`); i >= 0 {
name = name[i+1:]
}
var b strings.Builder
for _, r := range name {
if r < 0x20 || r == 0x7f {
continue // 控制字符一律丢弃
}
b.WriteRune(r)
}
name = strings.TrimSpace(b.String())
// "." 与 ".." 作为文件名毫无意义,且容易在各层被特殊解释
if name == "" || name == "." || name == ".." {
return "unnamed"
}
const maxBytes = 255
if len(name) > maxBytes {
cut := name[:maxBytes]
for len(cut) > 0 && !utf8.ValidString(cut) {
cut = cut[:len(cut)-1]
}
name = cut
}
return name
}
// detectContentType 优先用客户端声明的类型,缺失时按扩展名猜,兜底 octet-stream。
// 无论如何都不回显未经处理的客户端值到响应头(下载时统一用 octet-stream见 DownloadAttachment
func detectContentType(declared, filename string) string {
if ct := strings.TrimSpace(declared); ct != "" && ct != "application/octet-stream" {
if parsed, _, err := mime.ParseMediaType(ct); err == nil {
return parsed
}
}
if ext := filepath.Ext(filename); ext != "" {
if byExt := mime.TypeByExtension(ext); byExt != "" {
if parsed, _, err := mime.ParseMediaType(byExt); err == nil {
return parsed
}
}
}
return "application/octet-stream"
}
// uploadAttachment 是 Agent 与人类两条上传路径的公共实现。
func uploadAttachment(w http.ResponseWriter, r *http.Request, uploader string) {
if Blobs == nil {
Error(w, http.StatusServiceUnavailable, "附件存储未初始化")
return
}
max := config.C.MaxAttachmentBytes
// 双层限制MaxBytesReader 卡整个请求体(含 multipart 边界与其他字段),
// blob.Put 的 max 卡单个文件内容。少了外层,攻击者可以用超大 multipart 头拖死内存。
r.Body = http.MaxBytesReader(w, r.Body, max+1<<20)
// 32MB 内存缓冲上限,超出部分 multipart 会自动落临时文件
if err := r.ParseMultipartForm(32 << 20); err != nil {
Error(w, http.StatusBadRequest, "解析 multipart 失败(是否超过大小上限?)")
return
}
defer func() {
if r.MultipartForm != nil {
r.MultipartForm.RemoveAll()
}
}()
file, header, err := r.FormFile("file")
if err != nil {
Error(w, http.StatusBadRequest, "缺少 file 字段")
return
}
defer file.Close()
name := sanitizeFilename(header.Filename)
ctype := detectContentType(header.Header.Get("Content-Type"), name)
// 先落盘再入库:反过来会出现「库里有记录、磁盘没文件」的下载 500
sum, size, err := Blobs.Put(file, max)
if errors.Is(err, blob.ErrTooLarge) {
Error(w, http.StatusRequestEntityTooLarge,
fmt.Sprintf("附件超过上限 %.1f MB", float64(max)/(1<<20)))
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "保存附件失败")
return
}
a, err := repo.CreateAttachment(r.Context(), uploader, name, ctype, size, sum)
if err != nil {
// 落盘成功但入库失败:留下的孤立文件由 GC 回收,不影响正确性
Error(w, http.StatusInternalServerError, "登记附件失败")
return
}
JSON(w, http.StatusOK, map[string]any{"attachment": a})
}
// POST /api/v1/attachments —— Agent 侧上传
func UploadAttachment(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
uploadAttachment(w, r, agentName)
}
// POST /api/v1/me/attachments —— 人类侧上传
func MeUploadAttachment(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
uploadAttachment(w, r, user.Username)
}
// downloadAttachment 是 Agent 与人类两条下载路径的公共实现。
func downloadAttachment(w http.ResponseWriter, r *http.Request, viewer string) {
if Blobs == nil {
Error(w, http.StatusServiceUnavailable, "附件存储未初始化")
return
}
id, ok := pathUUID(w, r, "id")
if !ok {
return
}
a, err := repo.GetAttachment(r.Context(), id)
if errors.Is(err, repo.ErrAttachmentNotFound) {
Error(w, http.StatusNotFound, "附件不存在")
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "读取附件失败")
return
}
allowed, err := repo.AttachmentAccessible(r.Context(), a, viewer)
if err != nil {
Error(w, http.StatusInternalServerError, "校验权限失败")
return
}
if !allowed {
Error(w, http.StatusForbidden, "无权访问该附件")
return
}
f, err := Blobs.Open(a.SHA256)
if err != nil {
// 元数据在库但文件不在盘:说明存储被外部改动过,这是运维问题而非用户输入问题
Error(w, http.StatusInternalServerError, "附件内容缺失")
return
}
defer f.Close()
// 一律 octet-stream + attachment绝不按声明的 MIME 内联渲染。
// 否则一个上传的 .html/.svg 就能在本站域下执行脚本,等于自带 XSS。
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", fmt.Sprintf("%d", a.SizeBytes))
w.Header().Set("Content-Disposition", contentDisposition(a.Filename))
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
}
// contentDisposition 构造下载头。
// filename* 用 RFC 5987 编码承载非 ASCII 名字filename= 给只认 ASCII 的老客户端兜底;
// 兜底值里的引号与反斜杠必须去掉,否则能截断响应头。
func contentDisposition(name string) string {
var ascii strings.Builder
for _, r := range name {
switch {
case r == '"' || r == '\\':
ascii.WriteByte('_')
case r < 0x20 || r > 0x7e:
ascii.WriteByte('_')
default:
ascii.WriteRune(r)
}
}
fallback := ascii.String()
if fallback == "" {
fallback = "attachment"
}
return fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`,
fallback, urlEncodeRFC5987(name))
}
// urlEncodeRFC5987 按 RFC 5987 的 attr-char 集合做百分号编码。
func urlEncodeRFC5987(s string) string {
const safe = "!#$&+-.^_`|~" // attr-char 中除字母数字外允许的字符
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
isAlnum := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
if isAlnum || strings.IndexByte(safe, c) >= 0 {
b.WriteByte(c)
} else {
fmt.Fprintf(&b, "%%%02X", c)
}
}
return b.String()
}
// GET /api/v1/attachments/{id} —— Agent 侧下载
func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
downloadAttachment(w, r, agentName)
}
// GET /api/v1/me/attachments/{id} —— 人类侧下载
func MeDownloadAttachment(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
downloadAttachment(w, r, user.Username)
}
// DELETE /api/v1/me/attachments/{id} —— 删除自己上传且尚未挂载的附件
//
// 已挂载的不允许删:邮件是不可篡改的历史记录,附件是它的一部分。
func MeDeleteAttachment(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
id, ok := pathUUID(w, r, "id")
if !ok {
return
}
a, err := repo.GetAttachment(r.Context(), id)
if errors.Is(err, repo.ErrAttachmentNotFound) {
Error(w, http.StatusNotFound, "附件不存在")
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "读取附件失败")
return
}
if a.Uploader != user.Username {
Error(w, http.StatusForbidden, "只能删除自己上传的附件")
return
}
if a.MailID != nil {
Error(w, http.StatusConflict, "附件已随邮件发出,不能删除")
return
}
sum, orphaned, err := repo.DeleteAttachment(r.Context(), id)
if err != nil {
Error(w, http.StatusInternalServerError, "删除附件失败")
return
}
// 内容寻址下多条记录可能共享同一文件,只有最后一条引用消失才删磁盘
if orphaned && Blobs != nil {
_ = Blobs.Remove(sum)
}
JSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// parseAttachmentIDs 把请求里的附件 id 列表解析为 UUID。
func parseAttachmentIDs(raw []string) ([]uuid.UUID, error) {
out := make([]uuid.UUID, 0, len(raw))
for _, s := range raw {
s = strings.TrimSpace(s)
if s == "" {
continue
}
id, err := uuid.Parse(s)
if err != nil {
return nil, fmt.Errorf("非法的 attachment_id %q", s)
}
out = append(out, id)
}
return out, nil
}
// attachAll 把附件挂到刚创建的邮件上,并把错误翻译成 HTTP 响应。
// 返回 false 表示已写出错误响应,调用方应立即返回。
func attachAll(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, ids []uuid.UUID, uploader string) bool {
if len(ids) == 0 {
return true
}
err := repo.AttachToMail(r.Context(), mailID, ids, uploader)
switch {
case err == nil:
return true
case errors.Is(err, repo.ErrAttachmentNotFound):
Error(w, http.StatusNotFound, "附件不存在")
case errors.Is(err, repo.ErrAttachmentNotOwned):
Error(w, http.StatusForbidden, "只能附加自己上传的附件")
case errors.Is(err, repo.ErrAttachmentAlreadyAttached):
Error(w, http.StatusConflict, "附件已随其他邮件发出")
default:
Error(w, http.StatusInternalServerError, "附加附件失败")
}
return false
}
// fillAttachments 给邮件填充附件列表(读取单封/线程时用)。
// 读附件失败不该让整封邮件打不开,因此吞错只留空列表。
func fillAttachments(r *http.Request, mails ...*models.Mail) {
for _, m := range mails {
if m == nil {
continue
}
if as, err := repo.ListAttachmentsFor(r.Context(), m.ID); err == nil {
m.Attachments = as
}
}
}

View File

@ -0,0 +1,138 @@
package handler
import (
"strings"
"testing"
)
// 文件名只用于展示与下载头;磁盘路径由 sha256 派生,
// 因此这里守的是「不污染 HTTP 头、不被当成目录」而非路径穿越。
func TestSanitizeFilename(t *testing.T) {
cases := []struct{ in, want string }{
{"report.pdf", "report.pdf"},
{"中文 文件名.txt", "中文 文件名.txt"},
// 目录成分必须剥掉(含 Windows 风格)
{"../../etc/passwd", "passwd"},
{"/abs/path/x.log", "x.log"},
{`C:\Users\me\a.txt`, "a.txt"},
{"a/b/c.txt", "c.txt"},
// 控制字符会污染 Content-Disposition
{"bad\r\nname.txt", "badname.txt"},
{"tab\there.txt", "tabhere.txt"},
// 无意义的名字兜底
{"", "unnamed"},
{" ", "unnamed"},
{".", "unnamed"},
{"..", "unnamed"},
{"/", "unnamed"},
}
for _, c := range cases {
if got := sanitizeFilename(c.in); got != c.want {
t.Errorf("sanitizeFilename(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// 超长名字按 UTF-8 边界截断,不产生非法序列。
func TestSanitizeFilenameTruncates(t *testing.T) {
long := strings.Repeat("中", 200) + ".txt" // 每字 3 字节,共 600+
got := sanitizeFilename(long)
if len(got) > 255 {
t.Errorf("截断后 %d 字节,超过 255", len(got))
}
for _, r := range got {
if r == '\uFFFD' {
t.Fatalf("截断产生非法 UTF-8: %q", got)
}
}
}
func TestDetectContentType(t *testing.T) {
cases := []struct{ declared, filename, want string }{
{"application/pdf", "x.pdf", "application/pdf"},
// 客户端没给类型时按扩展名猜
{"", "notes.txt", "text/plain"},
{"application/octet-stream", "data.json", "application/json"},
// 带参数的声明要剥掉参数
{"text/plain; charset=utf-8", "a.txt", "text/plain"},
// 认不出就兜底
{"", "blob.unknownext", "application/octet-stream"},
{"garbage//not-a-type", "blob.unknownext", "application/octet-stream"},
}
for _, c := range cases {
got := detectContentType(c.declared, c.filename)
// mime.TypeByExtension 在不同系统上可能返回带参数的值,只比主类型
if !strings.HasPrefix(got, c.want) {
t.Errorf("detectContentType(%q, %q) = %q, want prefix %q",
c.declared, c.filename, got, c.want)
}
}
}
// Content-Disposition 必须双写filename* 承载 UTF-8filename= 给老客户端兜底。
// 兜底值里的引号/反斜杠/非 ASCII 一律换成下划线,否则能截断响应头。
func TestContentDisposition(t *testing.T) {
got := contentDisposition("报告 v2.pdf")
if !strings.HasPrefix(got, "attachment; ") {
t.Errorf("必须以 attachment 开头: %s", got)
}
if !strings.Contains(got, "filename*=UTF-8''") {
t.Errorf("缺少 RFC 5987 编码: %s", got)
}
// 非 ASCII 不能出现在 filename= 的兜底值里
ascii := got[:strings.Index(got, "filename*=")]
for _, r := range ascii {
if r > 0x7e {
t.Errorf("兜底 filename 含非 ASCII 字符 %q: %s", r, ascii)
}
}
// 引号注入不能逃出引号
evil := contentDisposition(`a"; x="y`)
if strings.Contains(evil[:strings.Index(evil, "filename*=")], `"; x=`) {
t.Errorf("引号未转义,可截断响应头: %s", evil)
}
// 控制字符(若绕过 sanitize 直达此处)也不能出现
ctl := contentDisposition("a\r\nb.txt")
if strings.ContainsAny(ctl, "\r\n") {
t.Errorf("响应头含换行: %q", ctl)
}
}
func TestURLEncodeRFC5987(t *testing.T) {
cases := []struct{ in, want string }{
{"abc.txt", "abc.txt"},
{"a b", "a%20b"},
{"中", "%E4%B8%AD"},
{`a"b`, "a%22b"},
}
for _, c := range cases {
if got := urlEncodeRFC5987(c.in); got != c.want {
t.Errorf("urlEncodeRFC5987(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestParseAttachmentIDs(t *testing.T) {
valid := "3f2504e0-4f89-11d3-9a0c-0305e82c3301"
got, err := parseAttachmentIDs([]string{valid, " ", ""})
if err != nil {
t.Fatalf("合法输入报错: %v", err)
}
if len(got) != 1 {
t.Errorf("空白项应被忽略,得到 %d 个", len(got))
}
if _, err := parseAttachmentIDs([]string{"not-a-uuid"}); err == nil {
t.Error("非法 UUID 应报错")
}
if got, err := parseAttachmentIDs(nil); err != nil || len(got) != 0 {
t.Errorf("nil 应返回空列表,得到 %v, %v", got, err)
}
}

View File

@ -0,0 +1,414 @@
package handler
import (
"errors"
"net/http"
"strings"
"time"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// ---------- 登录 / 登出 / 自身信息 ----------
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type userOut struct {
UserID string `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Status string `json:"status"`
AllowedAgents []string `json:"allowed_agents"`
AllowedPaths []string `json:"allowed_paths"`
LastLogin string `json:"last_login,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
func toUserOut(u *models.User) userOut {
o := userOut{
UserID: u.ID.String(),
Username: u.Username,
DisplayName: u.DisplayName,
Role: u.Role,
Status: u.Status,
AllowedAgents: emptySlice(u.AllowedAgents),
AllowedPaths: emptySlice(u.AllowedPaths),
CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"),
}
if u.LastLogin != nil {
o.LastLogin = u.LastLogin.Format("2006-01-02 15:04:05")
}
return o
}
// ---------- 首次初始化 ----------
// GET /api/v1/setup/status —— 公开:前端据此判断是否展示初始化向导
func SetupStatus(w http.ResponseWriter, r *http.Request) {
needs, err := repo.NeedsSetup(r.Context())
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to check setup status")
return
}
JSON(w, http.StatusOK, map[string]bool{"needs_setup": needs})
}
type setupRequest struct {
Username string `json:"username"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
}
// POST /api/v1/setup/admin —— 公开,但仅在系统无任何用户时可用
func SetupAdmin(w http.ResponseWriter, r *http.Request) {
var req setupRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
if len(req.Password) < 8 {
Error(w, http.StatusBadRequest, "密码自少 8 位")
return
}
u, err := repo.SetupFirstAdmin(r.Context(), req.Username, req.Password, req.DisplayName)
if err != nil {
switch {
case errors.Is(err, repo.ErrAlreadySetup):
Error(w, http.StatusConflict, "系统已初始化,请直接登录")
case errors.Is(err, repo.ErrInvalidUsername):
Error(w, http.StatusBadRequest, "用户名只能是 2-64 位的小写字母、数字、点、下划线、连字符,且不能为 human")
case errors.Is(err, repo.ErrNameTaken):
Error(w, http.StatusConflict, "该名称已被 Agent 占用")
default:
Error(w, http.StatusInternalServerError, "初始化失败")
}
return
}
// 初始化后直接登录
token, expires, err := repo.CreateUserSession(r.Context(), u.ID, r.UserAgent())
if err == nil {
maxAge := int(time.Until(expires).Seconds())
if maxAge < 0 {
maxAge = 0
}
middleware.SetSessionCookie(w, token, maxAge)
}
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
}
// POST /api/v1/auth/login
func Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
name := strings.ToLower(strings.TrimSpace(req.Username))
if name == "" || req.Password == "" {
Error(w, http.StatusBadRequest, "Missing username or password")
return
}
if locked, remain := limiter.Locked(name); locked {
JSON(w, http.StatusTooManyRequests, map[string]interface{}{
"error": "尝试过于频繁,请稍后再试",
"retry_after": remain,
})
return
}
u, err := repo.Authenticate(r.Context(), name, req.Password)
if err != nil {
switch {
case errors.Is(err, repo.ErrBadCredentials):
limiter.Fail(name)
Error(w, http.StatusUnauthorized, "用户名或密码错误")
case errors.Is(err, repo.ErrUserDisabled):
Error(w, http.StatusForbidden, "账号已被禁用")
default:
Error(w, http.StatusInternalServerError, "登录失败")
}
return
}
limiter.Reset(name)
token, expires, err := repo.CreateUserSession(r.Context(), u.ID, r.UserAgent())
if err != nil {
Error(w, http.StatusInternalServerError, "无法创建会话")
return
}
maxAge := int(time.Until(expires).Seconds())
if maxAge < 0 {
maxAge = 0
}
middleware.SetSessionCookie(w, token, maxAge)
JSON(w, http.StatusOK, map[string]interface{}{
"user": toUserOut(u),
})
}
// POST /api/v1/auth/logout
func Logout(w http.ResponseWriter, r *http.Request) {
if token := middleware.SessionToken(r); token != "" {
_ = repo.DeleteUserSession(r.Context(), token)
}
middleware.ClearSessionCookie(w)
JSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
}
// GET /api/v1/auth/me
func Me(w http.ResponseWriter, r *http.Request) {
u := middleware.GetUser(r)
if u == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
}
type changePasswordRequest struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
// POST /api/v1/auth/password
func ChangePassword(w http.ResponseWriter, r *http.Request) {
u := middleware.GetUser(r)
if u == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
var req changePasswordRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
if len(req.NewPassword) < 8 {
Error(w, http.StatusBadRequest, "新密码至少 8 位")
return
}
if _, err := repo.Authenticate(r.Context(), u.Username, req.OldPassword); err != nil {
Error(w, http.StatusUnauthorized, "原密码错误")
return
}
if err := repo.SetPassword(r.Context(), u.ID, req.NewPassword); err != nil {
Error(w, http.StatusInternalServerError, "修改密码失败")
return
}
middleware.ClearSessionCookie(w)
JSON(w, http.StatusOK, map[string]string{"status": "password_changed"})
}
// ---------- 管理员:用户管理 ----------
// GET /api/v1/admin/users
func AdminListUsers(w http.ResponseWriter, r *http.Request) {
users, err := repo.ListUsers(r.Context())
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list users")
return
}
out := make([]userOut, 0, len(users))
for i := range users {
out = append(out, toUserOut(&users[i]))
}
JSON(w, http.StatusOK, map[string]interface{}{"users": out})
}
type createUserRequest struct {
Username string `json:"username"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
AllowedAgents []string `json:"allowed_agents"`
AllowedPaths []string `json:"allowed_paths"`
}
// POST /api/v1/admin/users
func AdminCreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
if len(req.Password) < 8 {
Error(w, http.StatusBadRequest, "密码至少 8 位")
return
}
u, err := repo.CreateUser(r.Context(), req.Username, req.Password, req.DisplayName, req.Role,
req.AllowedAgents, req.AllowedPaths)
if err != nil {
switch {
case errors.Is(err, repo.ErrNameTaken):
Error(w, http.StatusConflict, "该名称已被用户或 Agent 占用")
case errors.Is(err, repo.ErrInvalidUsername):
Error(w, http.StatusBadRequest, "用户名只能是 2-64 位的小写字母、数字、点、下划线、连字符,且不能为 human")
default:
Error(w, http.StatusInternalServerError, "创建用户失败")
}
return
}
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
}
type updateUserRequest struct {
DisplayName *string `json:"display_name"`
Role *string `json:"role"`
Status *string `json:"status"`
AllowedAgents *[]string `json:"allowed_agents"`
AllowedPaths *[]string `json:"allowed_paths"`
}
// PUT /api/v1/admin/users/{id}
func AdminUpdateUser(w http.ResponseWriter, r *http.Request) {
id, ok := pathUUID(w, r, "id")
if !ok {
return
}
var req updateUserRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
// 不允许把最后一个管理员降级或禁用
if err := guardLastAdmin(r, id, req.Role, req.Status); err != nil {
Error(w, http.StatusConflict, err.Error())
return
}
u, err := repo.UpdateUser(r.Context(), id, repo.UserUpdate{
DisplayName: req.DisplayName,
Role: req.Role,
Status: req.Status,
AllowedAgents: req.AllowedAgents,
AllowedPaths: req.AllowedPaths,
})
if err != nil {
if errors.Is(err, repo.ErrUserNotFound) {
Error(w, http.StatusNotFound, "用户不存在")
return
}
Error(w, http.StatusInternalServerError, "更新用户失败")
return
}
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
}
// GET /api/v1/admin/scopes —— 可授权的 Agent 与目录候选
func AdminListScopes(w http.ResponseWriter, r *http.Request) {
agents, err := repo.ListAgents(r.Context(), "")
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list agents")
return
}
names := make([]string, 0, len(agents))
for _, a := range agents {
names = append(names, a.Name)
}
paths, _ := repo.AllWorkspaceNames(r.Context())
JSON(w, http.StatusOK, map[string]interface{}{
"agents": emptySlice(names),
"paths": emptySlice(paths),
})
}
// DELETE /api/v1/admin/users/{id} —— 禁用而非物理删除,保留邮件历史
func AdminDisableUser(w http.ResponseWriter, r *http.Request) {
id, ok := pathUUID(w, r, "id")
if !ok {
return
}
disabled := "disabled"
if err := guardLastAdmin(r, id, nil, &disabled); err != nil {
Error(w, http.StatusConflict, err.Error())
return
}
if err := repo.DisableUser(r.Context(), id); err != nil {
if errors.Is(err, repo.ErrUserNotFound) {
Error(w, http.StatusNotFound, "用户不存在")
return
}
Error(w, http.StatusInternalServerError, "禁用用户失败")
return
}
JSON(w, http.StatusOK, map[string]string{"status": "disabled"})
}
type resetPasswordRequest struct {
NewPassword string `json:"new_password"`
}
// POST /api/v1/admin/users/{id}/reset
func AdminResetPassword(w http.ResponseWriter, r *http.Request) {
id, ok := pathUUID(w, r, "id")
if !ok {
return
}
var req resetPasswordRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
if len(req.NewPassword) < 8 {
Error(w, http.StatusBadRequest, "密码至少 8 位")
return
}
if err := repo.SetPassword(r.Context(), id, req.NewPassword); err != nil {
if errors.Is(err, repo.ErrUserNotFound) {
Error(w, http.StatusNotFound, "用户不存在")
return
}
Error(w, http.StatusInternalServerError, "重置密码失败")
return
}
JSON(w, http.StatusOK, map[string]string{"status": "password_reset"})
}
// ---------- 辅助 ----------
func pathUUID(w http.ResponseWriter, r *http.Request, key string) (uuid.UUID, bool) {
id, err := uuid.Parse(chi.URLParam(r, key))
if err != nil {
Error(w, http.StatusBadRequest, "Invalid "+key)
return uuid.Nil, false
}
return id, true
}
// guardLastAdmin 阻止把系统里最后一个可用管理员降级或禁用
func guardLastAdmin(r *http.Request, id uuid.UUID, role, status *string) error {
demoting := role != nil && *role != "admin"
disabling := status != nil && *status != "active"
if !demoting && !disabling {
return nil
}
target, err := repo.GetUserByID(r.Context(), id)
if err != nil || !target.IsAdmin() || target.Status != "active" {
return nil
}
n, err := repo.CountAdmins(r.Context())
if err != nil {
return nil
}
if n <= 1 {
return errors.New("系统至少需要保留一个可用管理员")
}
return nil
}

View File

@ -0,0 +1,207 @@
package handler
import (
"net/http"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/agentmail/gateway/internal/sse"
"github.com/google/uuid"
)
// ---------- Contacts左侧联系人界面按登录用户隔离 ----------
// GET /api/v1/contacts?archived=false
func ListContacts(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
archived := r.URL.Query().Get("archived") == "true"
// 管理员可用 ?all=true 查看全部
scope := user.Username
if user.IsAdmin() && r.URL.Query().Get("all") == "true" {
scope = ""
}
contacts, err := repo.ListContactsFor(r.Context(), scope, archived)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list contacts")
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"contacts": emptySlice(contacts),
})
}
type archiveRequest struct {
Address string `json:"address"`
SessionID string `json:"session_id"`
}
// POST /api/v1/contacts/archive
// 归档指定 name@path.sessionAgent 侧会话归档 + 邮箱界面移除
func ArchiveContact(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
var req archiveRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
var sessionID uuid.UUID
switch {
case req.SessionID != "":
id, err := uuid.Parse(req.SessionID)
if err != nil {
Error(w, http.StatusBadRequest, "Invalid session_id")
return
}
sessionID = id
case req.Address != "":
addr, err := models.ParseAddress(req.Address)
if err != nil {
Error(w, http.StatusBadRequest, "Invalid address: "+err.Error())
return
}
id, err := repo.FindSessionByAddress(r.Context(), addr.Name, addr.Path, addr.Session)
if err != nil {
Error(w, http.StatusNotFound, "No session matches "+req.Address)
return
}
sessionID = id
default:
Error(w, http.StatusBadRequest, "Provide address or session_id")
return
}
// 鉴权:只能归档自己参与的会话(管理员不限)
allowed, err := repo.UserCanAccessSession(r.Context(), user, sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to check permission")
return
}
if !allowed {
Error(w, http.StatusForbidden, "无权归档他人的会话")
return
}
session, err := repo.GetSessionByID(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusNotFound, "Session not found")
return
}
mails, _ := repo.GetSessionMails(r.Context(), sessionID)
if err := repo.ArchiveSession(r.Context(), sessionID); err != nil {
Error(w, http.StatusInternalServerError, "Failed to archive session")
return
}
alias := ""
if session.Alias != nil {
alias = *session.Alias
}
payload := map[string]interface{}{
"session_id": sessionID.String(),
"session_alias": alias,
"archived_by": user.Username,
}
// 通知会话内所有参与方Agent 与人类),各自归档/移除
notified := map[string]bool{}
for _, m := range mails {
names := append([]string{m.FromName, m.ToName}, ccNames(m.CCList)...)
for _, name := range names {
if name == "" || notified[name] {
continue
}
notified[name] = true
sse.Default.SendToRecipient(name, "session_archived", payload)
}
}
if !notified[user.Username] {
sse.Default.SendToUser(user.Username, "session_archived", payload)
}
JSON(w, http.StatusOK, map[string]interface{}{
"status": "archived",
"session_id": sessionID.String(),
"session_alias": alias,
})
}
// GET /api/v1/contacts/suggest?name=xxx&path=yyy
// 三段式补全:无 name 给 Agent+人类用户名;有 name 给工作区;两者都有给会话别名(含 new)
func SuggestAddress(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
name := r.URL.Query().Get("name")
path := r.URL.Query().Get("path")
if name == "" {
agents, err := repo.ListAgents(r.Context(), "")
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list agents")
return
}
users, _ := repo.ListActiveUsernames(r.Context())
names := make([]string, 0, len(agents)+len(users))
for _, a := range agents {
names = append(names, a.Name)
}
for _, u := range users {
if u == user.Username {
continue // 不建议给自己发信
}
names = append(names, u)
}
JSON(w, http.StatusOK, map[string]interface{}{
"kind": "name",
"suggestions": emptySlice(names),
})
return
}
if path == "" {
// 人类用户没有工作区,直接给空列表(前端会继续走 session 段)
paths, _ := repo.SuggestPaths(r.Context(), name)
JSON(w, http.StatusOK, map[string]interface{}{
"kind": "path",
"suggestions": emptySlice(paths),
})
return
}
sessions, _ := repo.SuggestSessionsFor(r.Context(), user.Username, name, path)
sessions = append(sessions, "new")
JSON(w, http.StatusOK, map[string]interface{}{
"kind": "session",
"suggestions": sessions,
})
}
func ccNames(list []models.Address) []string {
out := make([]string, 0, len(list))
for _, a := range list {
if a.Name != "" {
out = append(out, a.Name)
}
}
return out
}

View File

@ -0,0 +1,91 @@
package handler
import (
"net/http"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/repo"
"github.com/agentmail/gateway/internal/sse"
)
// GET /api/v1/events/stream
//
// 四种凭证,都必须真正验证过身份才能订阅:
// Authorization: Bearer <agent_key_token> → Agent 通道(密钥认证)
// X-Agent-Name + X-Agent-Secret → Agent 通道(旧方式,兼容)
// 登录 Cookie 或 Bearer <user_key_token> → 人类用户通道
// ?access_token=<token> → 浏览器 EventSource 专用回退
//
// 注意不能只凭 X-Agent-Name 就分流:那等于任何人报个名字就能读走别人的新邮件通知。
// query 令牌仅本端点接受EventSource 无法带自定义头),其余接口一律要求请求头,
// 因为 URL 里的令牌会进访问日志与 Referer。
func SSEStream(w http.ResponseWriter, r *http.Request) {
agentName, ok := resolveStreamAgent(r)
if !ok {
Error(w, http.StatusUnauthorized, "凭证无效")
return
}
userName := ""
if agentName == "" {
u := middleware.OptionalUserWithQuery(r)
if u == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
userName = u.Username
}
client := sse.Default.AddClient(w, agentName, userName)
if client == nil {
Error(w, http.StatusInternalServerError, "SSE not supported")
return
}
<-r.Context().Done()
sse.Default.RemoveClient(client.ID)
}
// resolveStreamAgent 校验 Agent 侧凭证。
// 返回 ("", true) 表示这不是 Agent 请求,交给人类用户分支;
// 返回 ("", false) 表示带了 Agent 凭证但验证失败。
func resolveStreamAgent(r *http.Request) (string, bool) {
// 密钥认证Bearer 令牌可能是 Agent 密钥,也可能是用户密钥。
// 先按 Agent 密钥试,失败就落到人类分支(那里会再按用户密钥试)。
token := middleware.BearerToken(r)
if token == "" {
token = middleware.QueryToken(r) // EventSource 回退
}
if token != "" {
name, err := repo.VerifyAgentKey(r.Context(), token)
if err == nil && name != "" {
return name, true
}
return "", true
}
name := r.Header.Get("X-Agent-Name")
if name == "" {
name = r.URL.Query().Get("agent_name")
}
if name == "" {
return "", true // 非 Agent 请求
}
secret := r.Header.Get("X-Agent-Secret")
if secret == "" {
return "", false // 报了名字却没给凭证
}
agent, err := repo.VerifyAgent(r.Context(), name, secret)
if err != nil {
return "", false
}
return agent.Name, true
}
// GET /api/v1/events/status
func SSEStatus(w http.ResponseWriter, r *http.Request) {
JSON(w, http.StatusOK, map[string]interface{}{
"connected_clients": sse.Default.ClientCount(),
})
}

View File

@ -0,0 +1,264 @@
package handler
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// ---------- 转发 ----------
//
// 转发 = 引用原文 + 新收件人。与「回复」的区别:
// 回复reply_to落回原会话收件人是原发件人
// 转发按目标地址的 session 位另行定位会话,收件人是新指定的人。
// 因此转发不复用 reply_to而是走完整的三维寻址。
type forwardRequest struct {
// To 新收件人,完整三维地址
To string `json:"to"`
// CC 可选抄送
CC string `json:"cc"`
// Comment 转发者附加的说明,置于引用原文之前
Comment string `json:"comment"`
// Subject 可选;留空时自动加 "Fwd: " 前缀
Subject string `json:"subject"`
// SessionAlias 仅在目标地址以 .new 结尾时生效
SessionAlias string `json:"session_alias"`
}
// quoteBody 把原文渲染为 Markdown 引用块。
// 逐行加 "> " 而不是整段包裹:原文本身可能含代码块与列表,
// 只有逐行前缀才能在任何 Markdown 渲染器里保持引用语义。
func quoteBody(m *models.Mail) string {
var b strings.Builder
b.WriteString("---\n\n")
b.WriteString(fmt.Sprintf("> **转发自** %s", m.FromName))
if m.FromWorkspace != "" {
b.WriteString("@" + m.FromWorkspace)
}
b.WriteString("\n")
b.WriteString(fmt.Sprintf("> **主题** %s\n", m.Subject))
b.WriteString(fmt.Sprintf("> **时间** %s\n", m.CreatedAt.Format("2006-01-02 15:04:05")))
if len(m.CCList) > 0 {
names := make([]string, 0, len(m.CCList))
for _, c := range m.CCList {
names = append(names, c.Raw)
}
b.WriteString(fmt.Sprintf("> **抄送** %s\n", strings.Join(names, ", ")))
}
b.WriteString(">\n")
for _, line := range strings.Split(m.Body, "\n") {
b.WriteString("> " + line + "\n")
}
return b.String()
}
// forwardSubject 生成转发主题,避免 "Fwd: Fwd: Fwd:" 无限叠加。
func forwardSubject(custom, original string) string {
if s := strings.TrimSpace(custom); s != "" {
return s
}
if strings.HasPrefix(original, "Fwd: ") {
return original
}
return "Fwd: " + original
}
// doForward 是 Agent 与人类两条转发路径的公共实现。
// actor 是转发者名Agent 名或用户名fromWorkspace 仅 Agent 有。
func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor, fromWorkspace string, isAgent bool) {
var req forwardRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
if strings.TrimSpace(req.To) == "" {
Error(w, http.StatusBadRequest, "Missing to")
return
}
src, err := repo.LoadForwardSource(r.Context(), mailID, actor)
switch {
case errors.Is(err, repo.ErrMailNotFound):
Error(w, http.StatusNotFound, "待转发的邮件不存在")
return
case errors.Is(err, repo.ErrForwardNotAllowed):
Error(w, http.StatusForbidden, "只能转发自己参与过的邮件")
return
case err != nil:
Error(w, http.StatusInternalServerError, "Failed to load mail")
return
}
to, err := models.ParseAddress(req.To)
if err != nil {
Error(w, http.StatusBadRequest, "Invalid to address: "+err.Error())
return
}
ccList, err := models.ParseAddressList(req.CC)
if err != nil {
Error(w, http.StatusBadRequest, "Invalid cc address: "+err.Error())
return
}
user := middleware.GetUser(r)
if !isAgent && user != nil {
to = resolveHumanAlias(to, user.Username)
for i := range ccList {
ccList[i] = resolveHumanAlias(ccList[i], user.Username)
}
if msg := checkScope(r, user, append([]models.Address{to}, ccList...)); msg != "" {
Error(w, http.StatusForbidden, msg)
return
}
}
subject := forwardSubject(req.Subject, src.Subject)
// 转发按目标地址寻址,不带 reply_to它是一条新线索不该并进原会话
sessionID, _, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias)
if err != nil {
writeErr(w, err, "Failed to resolve session")
return
}
if isAgent {
quota, qErr := repo.ConsumeQuota(r.Context(), actor)
if errors.Is(qErr, repo.ErrQuotaExhausted) {
Error(w, http.StatusForbidden, fmt.Sprintf(
"发信配额已用尽(%d/%d。请先向人类发送最终总结或联系管理员重置配额。",
quota.Used, quota.Max))
return
}
if qErr != nil {
Error(w, http.StatusInternalServerError, "Failed to check quota")
return
}
} else if user != nil {
_ = repo.SetSessionOwner(r.Context(), sessionID, user.ID)
}
body := quoteBody(src)
if c := strings.TrimSpace(req.Comment); c != "" {
body = c + "\n\n" + body
}
attachedCount := 0
// parent_mail_id 指向原邮件:即便落在新会话里,也能回溯这封转发从何而来
newID, err := repo.CreateMail(r.Context(), sessionID, &src.ID,
actor, fromWorkspace, to.Name, to.Path, subject, body, ccList)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to create mail")
return
}
// 附件随转发一同带过去——只引用正文而丢掉附件,收件人拿到的是一封残缺的邮件。
// 内容寻址下这只是新增元数据,不拷磁盘文件。
if n, err := repo.CopyAttachmentsTo(r.Context(), src.ID, newID, actor); err != nil {
Error(w, http.StatusInternalServerError, "复制附件失败")
return
} else {
attachedCount = n
}
notifyRecipients(to, ccList, sessionID, newID, actor, subject)
JSON(w, http.StatusOK, map[string]any{
"mail_id": newID.String(),
"session_id": sessionID.String(),
"session_alias": repo.SessionAliasOf(r.Context(), sessionID),
"forwarded_from": src.ID.String(),
"attachments": attachedCount,
})
}
// POST /api/v1/mail/{id}/forward —— Agent 侧转发
func ForwardMail(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
mailID, ok := pathUUID(w, r, "id")
if !ok {
return
}
doForward(w, r, mailID, agentName, agentName, true)
}
// POST /api/v1/me/mail/{id}/forward —— 人类侧转发
func MeForwardMail(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
}
doForward(w, r, mailID, user.Username, "", false)
}
// ---------- 配额管理(管理员) ----------
// GET /api/v1/admin/quotas
func AdminListQuotas(w http.ResponseWriter, r *http.Request) {
quotas, err := repo.ListQuotas(r.Context())
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list quotas")
return
}
JSON(w, http.StatusOK, map[string]any{"quotas": quotas})
}
type setQuotaRequest struct {
// MaxRounds 发信配额上限0 = 不限
MaxRounds *int `json:"max_rounds"`
// Reset 为 true 时把已用次数归零
Reset bool `json:"reset"`
}
// PUT /api/v1/admin/quotas/{name}
func AdminSetQuota(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(chi.URLParam(r, "name"))
if name == "" {
Error(w, http.StatusBadRequest, "Missing agent name")
return
}
var req setQuotaRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
if req.MaxRounds == nil && !req.Reset {
Error(w, http.StatusBadRequest, "需要 max_rounds 或 reset 之一")
return
}
var (
q repo.Quota
err error
)
if req.MaxRounds != nil {
if q, err = repo.SetQuota(r.Context(), name, *req.MaxRounds); err != nil {
Error(w, http.StatusNotFound, err.Error())
return
}
}
if req.Reset {
if q, err = repo.ResetQuota(r.Context(), name); err != nil {
Error(w, http.StatusNotFound, err.Error())
return
}
}
JSON(w, http.StatusOK, map[string]any{"quota": q})
}

View File

@ -0,0 +1,77 @@
package handler
import (
"strings"
"testing"
"time"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
)
// 转发主题不能无限叠加 Fwd: 前缀,否则转发几轮后主题栏全是前缀。
func TestForwardSubject(t *testing.T) {
cases := []struct{ custom, original, want string }{
{"", "修复登录态", "Fwd: 修复登录态"},
{"", "Fwd: 修复登录态", "Fwd: 修复登录态"}, // 已有前缀不再叠加
{"自定义主题", "修复登录态", "自定义主题"},
{" ", "修复登录态", "Fwd: 修复登录态"}, // 全空白视为未指定
}
for _, c := range cases {
if got := forwardSubject(c.custom, c.original); got != c.want {
t.Errorf("forwardSubject(%q, %q) = %q, want %q", c.custom, c.original, got, c.want)
}
}
}
// 引用块必须逐行加 "> ":原文含代码块或列表时,
// 只有逐行前缀才能在任何 Markdown 渲染器里保持引用语义。
func TestQuoteBodyPrefixesEveryLine(t *testing.T) {
m := &models.Mail{
ID: uuid.New(),
FromName: "opencode",
FromWorkspace: "/root",
Subject: "巡检结果",
Body: "第一行\n\n```go\nfmt.Println(1)\n```\n- 列表项",
CreatedAt: time.Date(2026, 9, 2, 10, 30, 0, 0, time.UTC),
CCList: []models.Address{
{Name: "pi", Path: "root", Raw: "pi@root.new"},
},
}
out := quoteBody(m)
for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
if line == "---" || line == "" {
continue
}
if !strings.HasPrefix(line, ">") {
t.Errorf("引用块出现未加前缀的行: %q", line)
}
}
// 元信息必须齐全,否则收件人不知道这封转发的来路
for _, want := range []string{"opencode@/root", "巡检结果", "2026-09-02 10:30:00", "pi@root.new"} {
if !strings.Contains(out, want) {
t.Errorf("引用块缺少 %q\n%s", want, out)
}
}
// 原文正文本身要在引用里
if !strings.Contains(out, "> fmt.Println(1)") {
t.Errorf("原文代码行未被引用:\n%s", out)
}
}
// 无抄送时不该渲染出空的「抄送」行。
func TestQuoteBodyOmitsEmptyCC(t *testing.T) {
m := &models.Mail{
FromName: "admin",
Subject: "x",
Body: "y",
CreatedAt: time.Now(),
}
if strings.Contains(quoteBody(m), "抄送") {
t.Error("无抄送时不应出现「抄送」行")
}
}

View File

@ -0,0 +1,128 @@
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
}

View File

@ -0,0 +1,179 @@
package handler
import (
"net/http"
"strings"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
)
// ---------- 密钥管理 ----------
//
// 两套接口,权限边界不同:
// /admin/agent-keys —— 管理员签发 Agent 接入密钥
// /me/keys —— 用户自助签发客户端连接密钥(不能注册 Agent
//
// 密钥全文只在创建响应里出现一次,列表接口只给前 8 位 hint。
type createKeyRequest struct {
// AgentName 仅 Agent 密钥使用;留空表示「待绑定」,首次注册时按注册请求的 name 落定
AgentName string `json:"agent_name"`
// Label 人类可读备注如「我的笔记本」「CI 机器」)
Label string `json:"label"`
// KeyType permanent / one_time / timed
KeyType string `json:"key_type"`
// ExpiresHours 仅 timed 使用,必须为正
ExpiresHours int `json:"expires_hours"`
// KeyToken 仅 Agent 密钥使用:登记一把客户端已在本地生成的密钥。
// 插件首次安装时自己生成密钥并打印出来,管理员把它填到这里完成登记,
// 密钥全文因此不需要从服务器往客户端传。留空则由服务器生成。
KeyToken string `json:"key_token"`
}
// normalizeKeyType 默认给 permanent避免调用方漏填时落到非法值
func normalizeKeyType(t string) string {
t = strings.TrimSpace(t)
if t == "" {
return models.KeyPermanent
}
return t
}
// POST /api/v1/admin/agent-keys
func CreateAgentKey(w http.ResponseWriter, r *http.Request) {
admin := middleware.GetUser(r)
if admin == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
var req createKeyRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
key, err := repo.CreateAgentKey(r.Context(),
strings.TrimSpace(req.AgentName), normalizeKeyType(req.KeyType),
strings.TrimSpace(req.Label), req.ExpiresHours, admin.ID,
strings.TrimSpace(req.KeyToken))
if err != nil {
writeKeyErr(w, err)
return
}
// 唯一一次回传全文
JSON(w, http.StatusOK, map[string]any{"key": key})
}
// GET /api/v1/admin/agent-keys?agent_name=xxx
func ListAgentKeys(w http.ResponseWriter, r *http.Request) {
keys, err := repo.ListAgentKeys(r.Context(), r.URL.Query().Get("agent_name"))
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list keys")
return
}
JSON(w, http.StatusOK, map[string]any{"keys": keys})
}
// DELETE /api/v1/admin/agent-keys/{id}
func DeleteAgentKey(w http.ResponseWriter, r *http.Request) {
id, ok := pathUUID(w, r, "id")
if !ok {
return
}
if err := repo.DeleteAgentKey(r.Context(), id); err != nil {
writeKeyErr(w, err)
return
}
JSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
type bindKeyRequest struct {
AgentName string `json:"agent_name"`
}
// POST /api/v1/admin/agent-keys/{id}/bind
func BindAgentKey(w http.ResponseWriter, r *http.Request) {
id, ok := pathUUID(w, r, "id")
if !ok {
return
}
var req bindKeyRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
name := strings.TrimSpace(req.AgentName)
if name == "" {
Error(w, http.StatusBadRequest, "Missing agent_name")
return
}
if err := repo.BindAgentKey(r.Context(), id, name); err != nil {
writeKeyErr(w, err)
return
}
JSON(w, http.StatusOK, map[string]string{"status": "bound", "agent_name": name})
}
// ---------- 用户连接密钥 ----------
// POST /api/v1/me/keys
func CreateMyKey(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
var req createKeyRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
key, err := repo.CreateUserKey(r.Context(), user.ID,
strings.TrimSpace(req.Label), normalizeKeyType(req.KeyType), req.ExpiresHours)
if err != nil {
writeKeyErr(w, err)
return
}
JSON(w, http.StatusOK, map[string]any{"key": key})
}
// GET /api/v1/me/keys
func ListMyKeys(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
keys, err := repo.ListUserKeys(r.Context(), user.ID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list keys")
return
}
JSON(w, http.StatusOK, map[string]any{"keys": keys})
}
// DELETE /api/v1/me/keys/{id}
func DeleteMyKey(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
id, ok := pathUUID(w, r, "id")
if !ok {
return
}
// repo 层带 user_id 条件,删不到就是不属于自己或不存在,统一 404
if err := repo.DeleteUserKey(r.Context(), user.ID, id); err != nil {
writeKeyErr(w, err)
return
}
JSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}

View File

@ -0,0 +1,421 @@
package handler
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/agentmail/gateway/internal/sse"
"github.com/google/uuid"
)
// ---------- Mail ----------
type sendMailRequest struct {
To string `json:"to"` // name@path.session省略 session=默认会话new=新建,别名=必须已存在)
CC string `json:"cc"` // 逗号/分号/空格分隔的多个 name@path.session
Subject string `json:"subject"`
Body string `json:"body"`
ReplyTo string `json:"reply_to"`
// SessionAlias 仅在本次投递【新建】会话时生效,为新会话命名,
// 之后即可用 name@path.<alias> 续谈。命中已有会话时该字段被忽略。
SessionAlias string `json:"session_alias"`
// AttachmentIDs 先用 POST /attachments 上传拿到的 id只能附加自己上传且未挂载的
AttachmentIDs []string `json:"attachment_ids"`
// Relay 标识本次发信是【插件代劳转发】而不是模型自主发信。
//
// 基本原则:**配额约束的是模型的自主发信,不是 harness 的转发**。
// 平台原生的权限询问与本轮的最终总结都是插件搬运的,不计配额。
//
// RelayKey 必須是上游那条消息的稳定标识permission id / assistant message id
// 它由平台生成,模型伪造不出,而唯一约束保证同一条上游消息只能免费转一次。
Relay string `json:"relay"` // "" | "permission" | "summary"
RelayKey string `json:"relay_key"` // 上游消息 idrelay 非空时必填
}
// resolveTarget 根据三维地址 name@path.session 决定投递的会话。
//
// session 位三态语义(设计文档):
// - 省略pi@root → 投递到 name@path 的默认会话;从未通信则建立
// - newpi@root.new → 强制新建一个会话
// - 具体别名pi@root.fix-leak→ 必须已存在且该收件人参与过,否则 404 无法送达
//
// alias 为新建会话命名(仅新建时生效),使其之后可被 name@path.<alias> 寻址。
// reply_to 优先于地址:显式回复某封邮件时沿用该邮件的会话。
func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, subject, alias string) (uuid.UUID, *uuid.UUID, error) {
if replyTo != "" {
replyID, err := uuid.Parse(replyTo)
if err != nil {
return uuid.Nil, nil, errBadRequest("Invalid reply_to UUID")
}
mail, err := repo.GetMailByID(r.Context(), replyID)
if err != nil {
return uuid.Nil, nil, errNotFound("Parent mail not found")
}
repo.TouchSession(r.Context(), mail.SessionID)
return mail.SessionID, &replyID, nil
}
switch addr.Mode() {
case models.SessionNew:
// 新建会话:若调用方给了别名,当场命名,之后即可用 name@path.<alias> 续谈。
// 别名全局唯一(负责寻址),已被占用时报 409 而不是静默吐出重名会话。
var aliasPtr *string
if a := strings.TrimSpace(alias); a != "" {
if err := validateSessionAlias(a); err != nil {
return uuid.Nil, nil, err
}
if _, err := repo.FindSessionByAlias(r.Context(), a); err == nil {
return uuid.Nil, nil, errConflict(fmt.Sprintf(
"会话别名 %q 已被占用;若要接着该会话谈请用 %s@%s.%s", a, addr.Name, addr.Path, a))
}
aliasPtr = &a
}
id, err := repo.CreateSession(r.Context(), aliasPtr, fromAgent, subject)
return id, nil, err
case models.SessionDefault:
id, err := repo.FindOrCreateDefaultSession(r.Context(), addr.Name, addr.Path, fromAgent, subject)
return id, nil, err
default: // models.SessionNamed
id, err := repo.FindNamedSessionFor(r.Context(), addr.Name, addr.Path, addr.Session)
if errors.Is(err, repo.ErrSessionNotFound) {
return uuid.Nil, nil, errNotFound(fmt.Sprintf(
"无法送达:会话 %q 不存在于 %s@%s。若要新建会话请用 %s@%s.new投递默认会话请省略 session 位",
addr.Session, addr.Name, addr.Path, addr.Name, addr.Path))
}
if err != nil {
return uuid.Nil, nil, err
}
repo.TouchSession(r.Context(), id)
return id, nil, nil
}
}
// POST /api/v1/mail/send
func SendMail(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
var req sendMailRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
if req.To == "" || req.Subject == "" || req.Body == "" {
Error(w, http.StatusBadRequest, "Missing to, subject, or body")
return
}
to, err := models.ParseAddress(req.To)
if err != nil {
Error(w, http.StatusBadRequest, "Invalid to address: "+err.Error())
return
}
ccList, err := models.ParseAddressList(req.CC)
if err != nil {
Error(w, http.StatusBadRequest, "Invalid cc address: "+err.Error())
return
}
attachIDs, err := parseAttachmentIDs(req.AttachmentIDs)
if err != nil {
Error(w, http.StatusBadRequest, err.Error())
return
}
sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, agentName, req.Subject, req.SessionAlias)
if err != nil {
writeErr(w, err, "Failed to resolve session")
return
}
// 配额在建邮件之前扣:否则邮件已入库再报 403收件方会看到一封发件方以为发失败的邮件。
// 只限制主动发信,不限制收信(卡住收信只会让邮件凭空消失)。
//
// 插件代劳转发relay走免配额通道配额约束的是模型的自主发信
// 不是 harness 把平台原生的权限询问与最终总结搬到邮件里。
relay, relayKey, err := parseRelay(req.Relay, req.RelayKey)
if err != nil {
writeErr(w, err, "Invalid relay")
return
}
var quota repo.Quota
var budget repo.SessionBudget
if relay != "" {
// 先占幂等键。重复则说明这条上游消息已经转过,
// 这是插件重试 / SSE 重放的正常结果,不是故障 —— 幂等地返回成功。
if cErr := repo.ClaimRelay(r.Context(), agentName, relayKey, relay); cErr != nil {
if errors.Is(cErr, repo.ErrRelayDuplicate) {
JSON(w, http.StatusOK, map[string]any{
"status": "duplicate_relay",
"relay": relay,
"relay_key": relayKey,
"detail": "该上游消息已转发过,本次调用未产生新邮件",
})
return
}
Error(w, http.StatusInternalServerError, "Failed to claim relay")
return
}
// 仅读快照用于回传,不扣任何一层
quota, _ = repo.GetQuota(r.Context(), agentName)
budget, _ = repo.GetSessionBudget(r.Context(), sessionID)
} else {
// 两层都要过:会话预算管「这件事值得多少个来回」,
// Agent 全局配额管「这个 Agent 总共能发多少」。
// 先扣会话、后扣全局;全局拦下时把会话那次退回去 ——
// 那次往返实际上没有发生,不能白掉一格。
budget, err = repo.ConsumeSessionBudget(r.Context(), sessionID)
if errors.Is(err, repo.ErrSessionBudgetExhausted) {
Error(w, http.StatusForbidden, fmt.Sprintf(
"本会话的往返预算已用尽(%d/%d。自动转发的总结与权限询问不占预算"+
"若需继续主动发信,请让人在对话页调高本会话的预算。",
budget.Used, budget.Max))
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to check session budget")
return
}
quota, err = repo.ConsumeQuota(r.Context(), agentName)
if errors.Is(err, repo.ErrQuotaExhausted) {
repo.RefundSessionBudget(r.Context(), sessionID)
Error(w, http.StatusForbidden, fmt.Sprintf(
"Agent 全局发信配额已用尽(%d/%d。插件代劳转发的权限询问与最终总结不占配额"+
"若需继续主动发信请联系管理员重置配额。",
quota.Used, quota.Max))
return
}
if err != nil {
repo.RefundSessionBudget(r.Context(), sessionID)
Error(w, http.StatusInternalServerError, "Failed to check quota")
return
}
}
// Agent 可以在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
// 标记从入库正文里剥掉:它是给系统看的元数据,不该出现在人读的正文里
// react-markdown 会把 HTML 注释转义成可见文本,不会自动吞掉)。
//
// 提议只是提议 —— 别名是人的寻址入口Agent 干到一半自己改掉会让人
// 上一秒记住的地址下一秒失效。真正改名要等用户在前端点「接受」。
proposal, body := extractRenameProposal(req.Body)
mailID, err := repo.CreateMail(r.Context(), sessionID, parentMailID,
agentName, agentName, to.Name, to.Path, req.Subject, body, ccList)
if err != nil {
// 建邮件失败时必须把幂等键还回去,否则这条上游消息永远转不出来了
if relay != "" {
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
}
Error(w, http.StatusInternalServerError, "Failed to create mail")
return
}
if relay != "" {
// 关联失败不影响功能,只是少一条审计记录
_ = repo.BindRelayMail(r.Context(), agentName, relayKey, mailID)
}
if proposal != nil {
// 记不上提议不该让发信失败:邮件本身已经入库,提议是旁支信息
_ = repo.SetMailRenameProposal(r.Context(), mailID, proposal.Alias, proposal.Reason)
}
if !attachAll(w, r, mailID, attachIDs, agentName) {
return
}
notifyRecipients(to, ccList, sessionID, mailID, agentName, req.Subject)
// 回传会话别名与剩余配额,让发件方知道后续用什么地址续谈、还能发几封
resp := map[string]any{
"mail_id": mailID.String(),
"session_id": sessionID.String(),
"session_alias": repo.SessionAliasOf(r.Context(), sessionID),
}
if !quota.Unlimited {
resp["quota_remaining"] = quota.Remaining
resp["quota_used"] = quota.Used
resp["quota_max"] = quota.Max
}
// 会话预算是【本任务】的剩余往返Agent 更应该看这个而不是全局配额
if !budget.Unlimited {
resp["budget_remaining"] = budget.Remaining
resp["budget_used"] = budget.Used
resp["budget_max"] = budget.Max
}
if relay != "" {
// 告知本次未扣配额,否则插件看到 quota_remaining 没变会以为数据错了
resp["relay"] = relay
resp["quota_charged"] = false
}
if proposal != nil {
// 回传规范化后的别名Agent 提的名字可能含非法字符被改写过,
// 让它知道最终会拿什么去问用户
resp["rename_proposed"] = proposal.Alias
}
JSON(w, http.StatusOK, resp)
}
// notifyRecipients 向主收件人与抄送方推送 new_mail并刷新相关方的会话列表。
// 收件人可能是 Agent 也可能是人类用户(三维地址 name 位共享命名空间),
// 因此统一用 SendToRecipient 同时试 Agent 通道与用户通道。
func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID uuid.UUID, from, subject string) {
payload := func(role string) map[string]interface{} {
return map[string]interface{}{
"mail_id": mailID.String(),
"session_id": sessionID.String(),
"from_name": from,
"subject": subject,
"mail_type": "normal",
"role": role, // to / cc
}
}
update := map[string]interface{}{
"session_id": sessionID.String(),
"status": "active",
}
// 参与方去重:收件人 + 所有抄送 + 发件人自己(刷新他的发件箱)
seen := map[string]bool{}
sse.Default.SendToRecipient(to.Name, "new_mail", payload("to"))
sse.Default.SendToRecipient(to.Name, "session_update", update)
seen[to.Name] = true
for _, c := range cc {
if seen[c.Name] {
continue
}
seen[c.Name] = true
sse.Default.SendToRecipient(c.Name, "new_mail", payload("cc"))
sse.Default.SendToRecipient(c.Name, "session_update", update)
}
if !seen[from] {
sse.Default.SendToRecipient(from, "session_update", update)
}
}
// GET /api/v1/mail/inbox
func GetInbox(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
status := r.URL.Query().Get("status")
if status == "" {
status = "unread"
}
limit := 10
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := parseInt(l); err == nil && n > 0 {
limit = n
}
}
mails, err := repo.ListInbox(r.Context(), agentName, status, limit)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list inbox")
return
}
// Agent 靠收件箱列表得知有哪些附件可下载,否则它不知道该调 attachment_id
for i := range mails {
fillAttachments(r, &mails[i])
}
total, _ := repo.CountUnread(r.Context(), agentName)
JSON(w, http.StatusOK, map[string]interface{}{
"mails": emptySlice(mails),
"total": total,
})
}
// GET /api/v1/mail/{id} —— 需登录,且需对所属会话有权限
func GetMail(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, 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
}
fillAttachments(r, mail)
JSON(w, http.StatusOK, mail)
}
// POST /api/v1/mail/{id}/read —— 需登录,只能标记自己可见的邮件
func MarkMailRead(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, 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
}
if err := repo.MarkMailRead(r.Context(), mailID); err != nil {
Error(w, http.StatusInternalServerError, "Failed to mark read")
return
}
JSON(w, http.StatusOK, map[string]string{"status": "read"})
}
func parseInt(s string) (int, error) {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0, nil
}
n = n*10 + int(c-'0')
}
return n, nil
}

View File

@ -0,0 +1,285 @@
package handler
import (
"net/http"
"time"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/google/uuid"
)
// ---------- /me当前登录人类用户的邮箱全部路由需 UserAuth ----------
type meSendMailRequest struct {
To string `json:"to"` // name@path.session
CC string `json:"cc"` // 多个 name@path.session
Subject string `json:"subject"`
Body string `json:"body"`
ReplyTo string `json:"reply_to"`
// SessionAlias 仅在本次投递【新建】会话时生效,为新会话命名
SessionAlias string `json:"session_alias"`
// AttachmentIDs 先用 POST /me/attachments 上传拿到的 id
AttachmentIDs []string `json:"attachment_ids"`
// MaxRounds 是本次任务的往返预算0/省略 = 不限)。
//
// 配额的真实语义是「这件事值得多少个来回」——那是任务的属性,
// 所以在派活的这一刻给,而不是事后到管理员页面去调某个 Agent 的全局配额。
// 仅在本次投递【新建】会话时生效;续谈已有会话请用
// PUT /sessions/{id}/budget对话页里可随时改
MaxRounds *int `json:"max_rounds"`
}
// POST /api/v1/me/mail/send
func MeSendMail(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
var req meSendMailRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
if req.To == "" || req.Subject == "" || req.Body == "" {
Error(w, http.StatusBadRequest, "Missing to, subject, or body")
return
}
to, err := models.ParseAddress(req.To)
if err != nil {
Error(w, http.StatusBadRequest, "Invalid to address: "+err.Error())
return
}
ccList, err := models.ParseAddressList(req.CC)
if err != nil {
Error(w, http.StatusBadRequest, "Invalid cc address: "+err.Error())
return
}
attachIDs, err := parseAttachmentIDs(req.AttachmentIDs)
if err != nil {
Error(w, http.StatusBadRequest, err.Error())
return
}
// human@ 是兼容别名,人类发信时解析为自己
to = resolveHumanAlias(to, user.Username)
for i := range ccList {
ccList[i] = resolveHumanAlias(ccList[i], user.Username)
}
// 权限边界:校验可调用的 Agent 与可访问的目录
if msg := checkScope(r, user, append([]models.Address{to}, ccList...)); msg != "" {
Error(w, http.StatusForbidden, msg)
return
}
sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, user.Username, req.Subject, req.SessionAlias)
if err != nil {
writeErr(w, err, "Failed to resolve session")
return
}
// 人类发起的会话归属于该用户
_ = repo.SetSessionOwner(r.Context(), sessionID, user.ID)
// 新建会话时接受往返预算。只在新建时设:续谈已有会话若也接受这个字段,
// 每封新信都会悄悄改掉对方正在遵守的预算,人却不一定意识到自己改了。
if req.MaxRounds != nil && parentMailID == nil {
if *req.MaxRounds < 0 {
Error(w, http.StatusBadRequest, "max_rounds 不能为负")
return
}
if _, err := repo.SetSessionBudget(r.Context(), sessionID, *req.MaxRounds); err != nil {
Error(w, http.StatusInternalServerError, "Failed to set session budget")
return
}
}
// 人类侧不产生改名提议(人直接有改名按钮,用不着向自己提议),
// 但仍然剥掉标记:粘贴进正文时它会被渲染成一行可见的转义文本。
_, body := extractRenameProposal(req.Body)
mailID, err := repo.CreateMail(r.Context(), sessionID, parentMailID,
user.Username, "", to.Name, to.Path, req.Subject, body, ccList)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to create mail")
return
}
if !attachAll(w, r, mailID, attachIDs, user.Username) {
return
}
notifyRecipients(to, ccList, sessionID, mailID, user.Username, req.Subject)
resp := map[string]any{
"mail_id": mailID.String(),
"session_id": sessionID.String(),
"session_alias": repo.SessionAliasOf(r.Context(), sessionID),
}
// 回传预算,让前端不必再单独查一次就能显示「本任务还剩几个来回」
if b, err := repo.GetSessionBudget(r.Context(), sessionID); err == nil && !b.Unlimited {
resp["budget_max"] = b.Max
resp["budget_used"] = b.Used
resp["budget_remaining"] = b.Remaining
}
JSON(w, http.StatusOK, resp)
}
// GET /api/v1/me/mail/inbox
func MeGetInbox(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
status := r.URL.Query().Get("status")
if status == "" {
status = "all"
}
limit := 50
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := parseInt(l); err == nil && n > 0 {
limit = n
}
}
mails, err := repo.ListInbox(r.Context(), user.Username, status, limit)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list inbox")
return
}
// 列表页要显示附件图标与下载入口
for i := range mails {
fillAttachments(r, &mails[i])
}
total, _ := repo.CountUnread(r.Context(), user.Username)
JSON(w, http.StatusOK, map[string]interface{}{
"mails": emptySlice(mails),
"total": total,
})
}
// GET /api/v1/me/mail/sent
func MeGetSent(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
limit := 50
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := parseInt(l); err == nil && n > 0 {
limit = n
}
}
mails, err := repo.ListSentBy(r.Context(), user.Username, limit)
if err == nil {
for i := range mails {
fillAttachments(r, &mails[i])
}
}
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list sent")
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"mails": emptySlice(mails),
})
}
// GET /api/v1/me/sessions
func MeGetSessions(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
scope := user.Username
if user.IsAdmin() && r.URL.Query().Get("all") == "true" {
scope = ""
}
sessions, err := repo.ListSessionsFor(r.Context(), scope, 50)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list sessions")
return
}
type SessionOut struct {
SessionID uuid.UUID `json:"session_id"`
SessionAlias *string `json:"session_alias"`
FromAgent string `json:"from_agent"`
Subject string `json:"subject"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MailCount int `json:"mail_count"`
UnreadCount int `json:"unread_count"`
}
result := make([]SessionOut, 0, len(sessions))
for _, s := range sessions {
unread, _ := repo.CountUnreadInSession(r.Context(), user.Username, s.ID)
result = append(result, SessionOut{
SessionID: s.ID,
SessionAlias: s.Alias,
FromAgent: s.FromAgent,
Subject: s.Subject,
Status: s.Status,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
MailCount: s.MailCount,
UnreadCount: unread,
})
}
JSON(w, http.StatusOK, map[string]interface{}{
"sessions": result,
})
}
// resolveHumanAlias 把兼容别名 human 解析为具体用户名
func resolveHumanAlias(a models.Address, username string) models.Address {
if a.Name != "human" {
return a
}
a.Name = username
a.Raw = username + "@" + a.Path
if a.Session != "" {
a.Raw += "." + a.Session
}
return a
}
// checkScope 校验用户的 Agent 白名单与目录白名单;返回空串表示通过。
// 收件方是人类用户时不受 Agent 白名单约束(人与人通信始终允许)。
func checkScope(r *http.Request, user *models.User, addrs []models.Address) string {
if user.IsAdmin() {
return ""
}
for _, a := range addrs {
if a.Name == "" || a.Name == user.Username {
continue
}
isHuman, err := repo.IsHumanUser(r.Context(), a.Name)
if err != nil {
return "无法校验收件人权限"
}
if !isHuman && !user.CanUseAgent(a.Name) {
return "无权调用 Agent: " + a.Name
}
if !user.CanUsePath(a.Path) {
return "无权访问目录: " + a.Path
}
}
return ""
}

View File

@ -0,0 +1,279 @@
package handler
import (
"errors"
"net/http"
"strings"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/repo"
"github.com/agentmail/gateway/internal/sse"
"github.com/google/uuid"
)
// ---------- Permission ----------
type permissionRequestRequest struct {
Question string `json:"question"`
Options []string `json:"options"`
Context string `json:"context"`
SessionID *string `json:"session_id"`
// 可选:显式指定决策人(人类用户名)。省略时由会话 owner 决定。
To string `json:"to"`
// RelayKey 是上游那条权限询问的稳定 idopencode 的 permission.id
//
// 权限请求本来就不扣配额(人不点头 Agent 就动不了,收费等于收「求人费」),
// 这里要的只是**幂等**permission.updated 事件会重复触发,插件也会重连重放,
// 没有幂等键就会给同一次询问生成好几封邮件。
RelayKey string `json:"relay_key"`
}
type permissionDecideRequest struct {
MailID string `json:"mail_id"`
Decision string `json:"decision"`
Note string `json:"note"`
}
// POST /api/v1/permission/request
func RequestPermission(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
var req permissionRequestRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
if req.Question == "" {
Error(w, http.StatusBadRequest, "Missing question")
return
}
options := req.Options
if len(options) == 0 {
options = []string{"同意", "拒绝"}
}
// 幂等:同一条上游询问只生成一封邮件。
// 重复不是故障(插件重试/事件重放的正常结果),因此幂等地返回已存在的结论而非报错。
relayKey := strings.TrimSpace(req.RelayKey)
if relayKey != "" {
if len(relayKey) > 160 {
Error(w, http.StatusBadRequest, "relay_key 过长(上限 160 字节)")
return
}
if err := repo.ClaimRelay(r.Context(), agentName, relayKey, "permission"); err != nil {
if errors.Is(err, repo.ErrRelayDuplicate) {
JSON(w, http.StatusOK, map[string]any{
"status": "duplicate_relay",
"relay_key": relayKey,
"detail": "该权限询问已转发过,本次调用未产生新邮件",
})
return
}
Error(w, http.StatusInternalServerError, "Failed to claim relay")
return
}
}
// 确定 session
var sessionID uuid.UUID
if req.SessionID != nil && *req.SessionID != "" {
id, err := uuid.Parse(*req.SessionID)
if err != nil {
Error(w, http.StatusBadRequest, "Invalid session_id")
return
}
sessionID = id
repo.TouchSession(r.Context(), sessionID)
} else {
id, err := repo.CreateSession(r.Context(), nil, agentName, "权限请求: "+req.Question)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to create session")
return
}
sessionID = id
}
// 决策人:显式指定优先,否则取会话 owner
decider := req.To
if decider == "" || decider == "human" {
owner, err := repo.SessionOwnerUsername(r.Context(), sessionID)
if err == nil && owner != "" {
decider = owner
}
}
if decider == "" {
// 会话无归属Agent 自发起)时退回默认管理员
admin, err := repo.FirstAdminUsername(r.Context())
if err != nil || admin == "" {
Error(w, http.StatusConflict, "无法确定决策人,请在请求中指定 to")
return
}
decider = admin
}
body := req.Context
if body == "" {
body = req.Question
}
mailID, err := repo.CreatePermissionMail(r.Context(), sessionID, agentName, decider, req.Question, body, options)
if err != nil {
// 归还幂等键,否则这次询问永远转不出来了
if relayKey != "" {
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
}
Error(w, http.StatusInternalServerError, "Failed to create permission mail")
return
}
if relayKey != "" {
_ = repo.BindRelayMail(r.Context(), agentName, relayKey, mailID)
}
if err := repo.CreatePermissionRequest(r.Context(), mailID, sessionID, agentName, req.Question, options, req.Context); err != nil {
Error(w, http.StatusInternalServerError, "Failed to create permission request")
return
}
// 只推给该决策人
sse.Default.SendToUser(decider, "new_mail", map[string]interface{}{
"mail_id": mailID.String(),
"session_id": sessionID.String(),
"from_name": agentName,
"subject": "权限请求: " + req.Question,
"mail_type": "permission_request",
"role": "to",
})
JSON(w, http.StatusOK, map[string]string{
"mail_id": mailID.String(),
"session_id": sessionID.String(),
"permission_mail_id": mailID.String(),
"decider": decider,
})
}
// POST /api/v1/permission/decide —— 需登录;只有该权限请求的收件人或管理员可决策
func DecidePermission(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
var req permissionDecideRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
if req.MailID == "" || req.Decision == "" {
Error(w, http.StatusBadRequest, "Missing mail_id or decision")
return
}
mailID, err := uuid.Parse(req.MailID)
if err != nil {
Error(w, http.StatusBadRequest, "Invalid mail_id UUID")
return
}
perm, err := repo.GetPermissionByMailID(r.Context(), mailID)
if err != nil {
Error(w, http.StatusNotFound, "Permission request not found")
return
}
if perm.Result != nil && *perm.Result != "" {
Error(w, http.StatusConflict, "该请求已被处理")
return
}
// 鉴权:必须是这封权限邮件的收件人,或管理员
mail, err := repo.GetMailByID(r.Context(), mailID)
if err != nil {
Error(w, http.StatusNotFound, "Mail not found")
return
}
if !user.IsAdmin() && mail.ToName != user.Username {
Error(w, http.StatusForbidden, "无权决策他人的权限请求")
return
}
// 决策选项必须在候选内
if !contains(perm.Options, req.Decision) {
Error(w, http.StatusBadRequest, "决策必须是候选项之一")
return
}
if _, err := repo.DecidePermission(r.Context(), mailID, req.Decision); err != nil {
Error(w, http.StatusInternalServerError, "Failed to decide permission")
return
}
decisionMailID, err := repo.CreateDecisionMail(
r.Context(), perm.SessionID, mailID, user.Username, perm.AgentName, req.Decision, req.Note)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to create decision mail")
return
}
// 通知发起 Agent 恢复执行
// 带上上游 permission id插件要拿它回复 opencode 的原生权限询问。
// 两边 id 空间不同,光给 AgentMail 的 mail_id 插件对不上;
// 而插件重启后内存映射会丢,所以这个映射由服务端持久化并在此回传。
payload := map[string]interface{}{
"mail_id": mailID.String(),
"decision_mail_id": decisionMailID.String(),
"decision": req.Decision,
"note": req.Note,
"decided_by": user.Username,
}
if key, kind := repo.RelayKeyForMail(r.Context(), mailID); key != "" {
payload["relay_key"] = key
payload["relay_kind"] = kind
}
sse.Default.SendToAgent(perm.AgentName, "permission_decision", payload)
// 只刷新决策人自己的界面
sse.Default.SendToUser(user.Username, "session_update", map[string]interface{}{
"session_id": perm.SessionID.String(),
"status": "active",
})
JSON(w, http.StatusOK, map[string]string{
"status": "decided",
"decision_mail_id": decisionMailID.String(),
})
}
// GET /api/v1/permission/pending —— 需登录;普通用户只看发给自己的
func ListPendingPermissions(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
forUser := user.Username
if user.IsAdmin() && r.URL.Query().Get("all") == "true" {
forUser = ""
}
reqs, err := repo.ListPendingPermissionsFor(r.Context(), forUser)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list pending permissions")
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"requests": emptySlice(reqs),
})
}
func contains(list []string, v string) bool {
for _, s := range list {
if s == v {
return true
}
}
return false
}

View File

@ -0,0 +1,87 @@
package handler
import (
"sync"
"time"
)
// 登录失败限速:同一用户名连续 N 次失败后锁定一段时间
const (
maxLoginFailures = 5
lockoutDuration = 5 * time.Minute
failureWindow = 15 * time.Minute
)
type failureRecord struct {
count int
firstSeen time.Time
lockedAt time.Time
}
type loginLimiter struct {
mu sync.Mutex
recs map[string]*failureRecord
}
var limiter = &loginLimiter{recs: make(map[string]*failureRecord)}
// Locked 返回该用户名是否处于锁定期,以及剩余秒数
func (l *loginLimiter) Locked(name string) (bool, int) {
l.mu.Lock()
defer l.mu.Unlock()
r, ok := l.recs[name]
if !ok || r.lockedAt.IsZero() {
return false, 0
}
elapsed := time.Since(r.lockedAt)
if elapsed >= lockoutDuration {
delete(l.recs, name)
return false, 0
}
return true, int((lockoutDuration - elapsed).Seconds())
}
// Fail 记录一次失败,达到阈值则锁定
func (l *loginLimiter) Fail(name string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
r, ok := l.recs[name]
if !ok || now.Sub(r.firstSeen) > failureWindow {
l.recs[name] = &failureRecord{count: 1, firstSeen: now}
return
}
r.count++
if r.count >= maxLoginFailures {
r.lockedAt = now
}
}
// Reset 登录成功后清除失败计数
func (l *loginLimiter) Reset(name string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.recs, name)
}
// 定期清理过期记录,避免 map 无限增长
func init() {
go func() {
t := time.NewTicker(10 * time.Minute)
defer t.Stop()
for range t.C {
limiter.mu.Lock()
now := time.Now()
for k, r := range limiter.recs {
stale := now.Sub(r.firstSeen) > failureWindow &&
(r.lockedAt.IsZero() || now.Sub(r.lockedAt) > lockoutDuration)
if stale {
delete(limiter.recs, k)
}
}
limiter.mu.Unlock()
}
}()
}

View File

@ -0,0 +1,85 @@
package handler
import (
"errors"
"testing"
)
// parseRelay 是免配额通道的入口校验。白名单 + 强制幂等键这两条必须守住:
// 前者防止 relay 变成任意字符串的后门,后者是「同一条上游消息只转一次」的基础。
func TestParseRelay(t *testing.T) {
cases := []struct {
name string
kind, key string
wantKind string
wantKey string
wantErr bool
}{
{name: "都为空 = 普通自主发信,正常扣配额", kind: "", key: "", wantKind: "", wantKey: ""},
{name: "总结转发", kind: "summary", key: "msg_1", wantKind: "summary", wantKey: "msg_1"},
{name: "权限转发", kind: "permission", key: "per_1", wantKind: "permission", wantKey: "per_1"},
{name: "两端空白被裁掉", kind: " summary ", key: " msg_2 ", wantKind: "summary", wantKey: "msg_2"},
// 白名单外的类型必须拒:否则 relay:"anything" 就绕过了配额
{name: "未知类型", kind: "whatever", key: "k", wantErr: true},
// 没有幂等键就无法阻止同一条上游消息反复转发
{name: "缺幂等键", kind: "summary", key: "", wantErr: true},
{name: "只给了键没给类型", kind: "", key: "k", wantErr: true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
kind, key, err := parseRelay(c.kind, c.key)
if c.wantErr {
if err == nil {
t.Fatalf("期望报错实际通过kind=%q key=%q", kind, key)
}
return
}
if err != nil {
t.Fatalf("意外报错: %v", err)
}
if kind != c.wantKind || key != c.wantKey {
t.Fatalf("得到 (%q, %q),期望 (%q, %q)", kind, key, c.wantKind, c.wantKey)
}
})
}
}
func TestParseRelayRejectsOverlongKey(t *testing.T) {
long := make([]byte, 161)
for i := range long {
long[i] = 'k'
}
if _, _, err := parseRelay("summary", string(long)); err == nil {
t.Fatal("超长 relay_key 应被拒绝(列宽 160")
}
}
// 免配额类型是白名单,不是黑名单。新增一种转发时必须同时更新这里,
// 免得悄悄多出一条不受审视的免费通道。
func TestRelayKindsIsExactlyTwo(t *testing.T) {
want := map[string]bool{"permission": true, "summary": true}
if len(relayKinds) != len(want) {
t.Fatalf("免配额类型数量变了:%v。新增前请确认它确实是 harness 代劳而非模型自主发信", relayKinds)
}
for k := range want {
if !relayKinds[k] {
t.Fatalf("缺少免配额类型 %q", k)
}
}
}
// 报错必须是 400 而不是 500这些都是调用方参数问题
func TestParseRelayErrorsAreBadRequest(t *testing.T) {
for _, c := range [][2]string{{"whatever", "k"}, {"summary", ""}, {"", "k"}} {
_, _, err := parseRelay(c[0], c[1])
if err == nil {
t.Fatalf("(%q,%q) 应报错", c[0], c[1])
}
var he httpError
if !errors.As(err, &he) || he.status != 400 {
t.Fatalf("(%q,%q) 的错误不是 400: %#v", c[0], c[1], err)
}
}
}

View File

@ -0,0 +1,141 @@
package handler
import (
"regexp"
"strings"
)
// ---------- Agent 在正文里提议改会话别名 ----------
//
// 与「平台命名自动同步」POST /sessions/:id/sync互补
// 自动同步 = 平台起的名字,后台静默生效,不打扰人
// 正文提议 = Agent 干完活后觉得该换个更贴切的名字,需要人点头
//
// 为什么走正文而不是让 Agent 直接调 PUT alias
// 别名是**人**的寻址入口。Agent 干到一半自己改掉,人上一秒记住的地址下一秒失效。
// 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
//
// 载体选 HTML 注释:
// - react-markdown 默认不解析 raw HTML注释在页面上不可见实测渲染为转义文本节点
// 不是节点丢失 —— 所以必须从原始正文里剥掉,不能指望渲染器吞掉它)
// - 纯文本邮件客户端里它是一行不碍事的注释,不像自造标记那样显眼
// - 不与 Markdown 语法冲突,不会被格式化工具改写
// renameProposalRe 匹配 Agent 提议改名的标记。
//
// 形如:<!-- agentmail:rename-session alias="fix-login-leak" reason="定位到是登录态泄漏" -->
// reason 可选。alias 用双引号包裹,因此别名本身不能含双引号 —— 但合法别名连
// 空白和 . / @ 都不许有,双引号自然也在禁止之列,不构成限制。
//
// 用正则而不是完整 HTML 解析:这是一个格式固定的单行标记,正则足够且不引依赖。
var renameProposalRe = regexp.MustCompile(
`(?s)<!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->`)
// RenameProposal 是从正文里解析出的一条改名提议。
type RenameProposal struct {
// Alias 已经过 normalizeAlias 规范化,可直接用于 PUT /sessions/:id/alias
Alias string `json:"alias"`
// Reason 是 Agent 给出的理由,可为空
Reason string `json:"reason,omitempty"`
}
// extractRenameProposal 从正文里取出改名提议,并返回剥掉标记后的正文。
//
// 只认**最后一条**Agent 在长回复里可能反复修正措辞,最后写下的才是它的结论。
// 标记一律从正文里剥掉 —— 它是给系统看的元数据,不该出现在人读的正文里
// react-markdown 会把 HTML 注释转义成可见文本)。
//
// 非法别名(规范化后为空或不合法)视为无提议,但标记仍然剥掉:
// 与其在正文里留一行乱码,不如当它没提。
func extractRenameProposal(body string) (*RenameProposal, string) {
matches := renameProposalRe.FindAllStringSubmatch(body, -1)
cleaned := stripProposalMarkers(body)
if len(matches) == 0 {
return nil, cleaned
}
last := matches[len(matches)-1]
alias := normalizeAlias(strings.TrimSpace(last[1]))
if alias == "" {
return nil, cleaned
}
if err := validateSessionAlias(alias); err != nil {
return nil, cleaned
}
reason := ""
if len(last) > 2 {
reason = strings.TrimSpace(last[2])
}
// 理由是展示给人看的一句话,过长会把提示条撑破
const maxReason = 200
if len(reason) > maxReason {
reason = preview(reason, maxReason)
}
return &RenameProposal{Alias: alias, Reason: reason}, cleaned
}
// stripProposalMarkers 移除全部提议标记,并把因此产生的多余空行压回一个。
func stripProposalMarkers(body string) string {
out := renameProposalRe.ReplaceAllString(body, "")
// 标记独占一行时会留下连续空行压成一个空行Markdown 的段落分隔)
for strings.Contains(out, "\n\n\n") {
out = strings.ReplaceAll(out, "\n\n\n", "\n\n")
}
return strings.TrimSpace(out)
}
// preview 按 UTF-8 边界截断。与 repo.preview 同逻辑,这里为避免 handler → repo
// 的反向依赖而复制一份(两处都是 5 行,抽公共包不值当)。
func preview(s string, max int) string {
if len(s) <= max {
return s
}
cut := max
for cut > 0 && s[cut]&0xC0 == 0x80 {
cut--
}
return s[:cut] + "..."
}
// ---------- 插件代劳转发(免配额通道) ----------
// relayKinds 是允许免配额的转发类型。
//
// 白名单而不是任意字符串:免配额通道必须有明确边界,
// 否则 `relay: "whatever"` 就成了绕过配额的后门。
//
// permission —— 平台原生的权限询问opencode 的 permission.updated
// 不转给人人就看不到Agent 卡在那里等一个永远不会来的回答。
// summary —— 本轮的最终总结session.idle 时最后一条 assistant 消息)。
// 模型已经把话说完了,插件只是搬运;对它收费会导致配额用尽时
// Agent 连交代都做不了。
var relayKinds = map[string]bool{
"permission": true,
"summary": true,
}
// parseRelay 校验免配额转发参数,返回规范化后的 (kind, key)。
// 两者都为空表示这是普通的自主发信,正常扣配额。
func parseRelay(kind, key string) (string, string, error) {
kind = strings.TrimSpace(kind)
key = strings.TrimSpace(key)
if kind == "" {
if key != "" {
return "", "", errBadRequest("给了 relay_key 却没给 relay 类型")
}
return "", "", nil
}
if !relayKinds[kind] {
return "", "", errBadRequest(`relay 只能是 "permission" 或 "summary"`)
}
// 幂等键是免配额通道的唯一约束基础,不能省:
// 没有它就无法阻止同一条上游消息被反复转发。
if key == "" {
return "", "", errBadRequest("relay 转发必须带 relay_key上游消息的稳定 id")
}
if len(key) > 160 {
return "", "", errBadRequest("relay_key 过长(上限 160 字节)")
}
return kind, key, nil
}

View File

@ -0,0 +1,143 @@
package handler
import "testing"
func TestExtractRenameProposal(t *testing.T) {
cases := []struct {
name string
body string
wantAlias string
wantReason string
wantBody string
}{
{
name: "无标记时原样返回",
body: "普通正文。",
wantAlias: "",
wantBody: "普通正文。",
},
{
name: "带理由",
body: "已定位问题。\n\n<!-- agentmail:rename-session alias=\"fix-login-leak\" reason=\"是登录态泄漏\" -->",
wantAlias: "fix-login-leak",
wantReason: "是登录态泄漏",
wantBody: "已定位问题。",
},
{
name: "无理由",
body: "<!-- agentmail:rename-session alias=\"cache-eval\" -->\n\n正文在后。",
wantAlias: "cache-eval",
wantBody: "正文在后。",
},
{
// Agent 在长回复里反复修正措辞,最后写下的才是它的结论
name: "多条只取最后一条",
body: "<!-- agentmail:rename-session alias=\"first\" -->\n中间\n<!-- agentmail:rename-session alias=\"second\" -->",
wantAlias: "second",
wantBody: "中间",
},
{
// 别名含 . / @ 会让三维地址切分歧义normalizeAlias 改写为 -
name: "非法字符被规范化",
body: "<!-- agentmail:rename-session alias=\"fix login.leak/now\" -->",
wantAlias: "fix-login-leak-now",
wantBody: "",
},
{
// "new" 是寻址保留字
name: "保留字被改写",
body: "<!-- agentmail:rename-session alias=\"new\" -->",
wantAlias: "session-new",
wantBody: "",
},
{
// 规范化后为空 → 视为无提议,但标记仍要剥掉
name: "空别名视为无提议且剥掉标记",
body: "正文\n<!-- agentmail:rename-session alias=\"\" -->",
wantAlias: "",
wantBody: "正文",
},
{
name: "多余空格容错",
body: "<!-- agentmail:rename-session alias=\"ok-name\" -->",
wantAlias: "ok-name",
wantBody: "",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
p, body := extractRenameProposal(c.body)
gotAlias := ""
gotReason := ""
if p != nil {
gotAlias, gotReason = p.Alias, p.Reason
}
if gotAlias != c.wantAlias {
t.Errorf("alias = %q期望 %q", gotAlias, c.wantAlias)
}
if gotReason != c.wantReason {
t.Errorf("reason = %q期望 %q", gotReason, c.wantReason)
}
if body != c.wantBody {
t.Errorf("剥标记后正文 = %q期望 %q", body, c.wantBody)
}
})
}
}
// 标记必须从入库正文里彻底消失react-markdown 不解析 raw HTML
// 留着会被转义成一行可见的乱码文本,而不是被渲染器吞掉。
func TestProposalMarkerNeverSurvivesInBody(t *testing.T) {
bodies := []string{
"<!-- agentmail:rename-session alias=\"a\" -->",
"前\n<!-- agentmail:rename-session alias=\"a\" reason=\"r\" -->\n后",
"<!-- agentmail:rename-session alias=\"\" -->", // 无效提议也要剥
}
for _, b := range bodies {
_, out := extractRenameProposal(b)
if renameProposalRe.MatchString(out) {
t.Errorf("正文里仍残留标记:%q", out)
}
}
}
// 提议出来的别名必须能直接通过 PUT alias 的校验,
// 否则前端点「接受」时会拿到 400 —— 系统自己造出了自己拒绝的值。
func TestProposedAliasPassesValidation(t *testing.T) {
inputs := []string{
"fix login.leak",
"new",
"a@b/c",
" spaced name ",
"正常中文别名",
}
for _, in := range inputs {
p, _ := extractRenameProposal("<!-- agentmail:rename-session alias=\"" + in + "\" -->")
if p == nil {
continue // 规范化后为空,已按无提议处理
}
if err := validateSessionAlias(p.Alias); err != nil {
t.Errorf("提议 %q → %q 未通过 validateSessionAlias: %v", in, p.Alias, err)
}
}
}
func TestReasonTruncatedOnUTF8Boundary(t *testing.T) {
long := ""
for i := 0; i < 100; i++ {
long += "很长的理由"
}
p, _ := extractRenameProposal("<!-- agentmail:rename-session alias=\"x\" reason=\"" + long + "\" -->")
if p == nil {
t.Fatal("应当解析出提议")
}
if len(p.Reason) > 210 { // 200 + "..."
t.Errorf("理由未截断:%d 字节", len(p.Reason))
}
for _, r := range p.Reason {
if r == 0xFFFD {
t.Fatal("截断产生了替换符,说明切在多字节字符中间")
}
}
}

View File

@ -0,0 +1,340 @@
package handler
import (
"net/http"
"strings"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/repo"
"github.com/agentmail/gateway/internal/sse"
"github.com/google/uuid"
)
// ---------- Session均需登录且做会话级鉴权 ----------
// requireSessionAccess 解析路径中的会话 ID 并校验当前用户有权访问
func requireSessionAccess(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return uuid.Nil, false
}
sessionID, ok := pathUUID(w, r, "id")
if !ok {
return uuid.Nil, false
}
allowed, err := repo.UserCanAccessSession(r.Context(), user, sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to check permission")
return uuid.Nil, false
}
if !allowed {
Error(w, http.StatusForbidden, "无权访问该会话")
return uuid.Nil, false
}
return sessionID, true
}
// GET /api/v1/sessions/{id}
func GetSession(w http.ResponseWriter, r *http.Request) {
sessionID, ok := requireSessionAccess(w, r)
if !ok {
return
}
session, err := repo.GetSessionByID(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusNotFound, "Session not found")
return
}
mails, err := repo.GetSessionMails(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to get session mails")
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"session": session,
"mails": emptySlice(mails),
})
}
// GET /api/v1/sessions/{id}/mails
func GetSessionMails(w http.ResponseWriter, r *http.Request) {
sessionID, ok := requireSessionAccess(w, r)
if !ok {
return
}
mails, err := repo.GetSessionMails(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to get mails")
return
}
// 会话线程要展示附件,逐封填充
for i := range mails {
fillAttachments(r, &mails[i])
}
JSON(w, http.StatusOK, map[string]interface{}{
"mails": emptySlice(mails),
})
}
type updateAliasRequest struct {
Alias string `json:"alias"`
}
// PUT /api/v1/sessions/{id}/alias
//
// 会话别名负责三维寻址name@path.<alias>),因此必须全局唯一,
// 且不能叫 "new"(那是寻址保留字)。
func UpdateSessionAlias(w http.ResponseWriter, r *http.Request) {
sessionID, ok := requireSessionAccess(w, r)
if !ok {
return
}
var req updateAliasRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
alias := strings.TrimSpace(req.Alias)
if alias == "" {
Error(w, http.StatusBadRequest, "Missing alias")
return
}
if err := validateSessionAlias(alias); err != nil {
writeErr(w, err, "Invalid alias")
return
}
// 被其他会话占用时报 409而不是默默造出两个同名可寻址会话
if s, err := repo.FindSessionByAlias(r.Context(), alias); err == nil && s.ID != sessionID {
Error(w, http.StatusConflict, "会话别名 \""+alias+"\" 已被其他会话占用")
return
}
if err := repo.UpdateSessionAlias(r.Context(), sessionID, alias); err != nil {
Error(w, http.StatusInternalServerError, "Failed to update alias")
return
}
JSON(w, http.StatusOK, map[string]string{
"status": "updated",
"alias": alias,
})
}
// syncSessionRequest 是 Agent 平台回传自己那侧的会话标识。
//
// 各 Agent 平台opencode / Claude Code / DSH…都会由模型为会话生成一个摘要标题
// 并配一个短 slug。不在本侧另造一套命名平台那边叫什么本侧就叫什么。
type syncSessionRequest struct {
// Alias 是平台侧的短标识(如 opencode 的 slug "jolly-cactus"),写入本侧 session_alias 供寻址。
Alias string `json:"alias"`
// Title 是平台侧模型生成的摘要标题(如「修复登录态丢失」),写入本侧 subject 供展示。
Title string `json:"title"`
}
// POST /api/v1/sessions/{id}/sync
//
// Agent 侧端点:把平台生成的会话标题与 slug 同步到本侧。
// alias 撞名时自动追加 -2/-3 后缀(本侧别名负责寻址必须唯一,而平台 slug 不保证全局唯一),
// 因此本接口不会因撞名失败,响应里回传最终落库的别名。
func SyncSession(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
sessionID, ok := pathUUID(w, r, "id")
if !ok {
return
}
// Agent 只能同步自己参与过的会话
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to check permission")
return
}
if !allowed {
Error(w, http.StatusForbidden, "无权修改未参与的会话")
return
}
var req syncSessionRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
resp := map[string]string{"status": "synced"}
if title := strings.TrimSpace(req.Title); title != "" {
if err := repo.SyncSessionTitle(r.Context(), sessionID, title); err != nil {
Error(w, http.StatusInternalServerError, "Failed to sync title")
return
}
resp["title"] = title
}
if alias := strings.TrimSpace(req.Alias); alias != "" {
// 平台 slug 可能带非法字符,落库前按本侧寻址规则规范化
norm := normalizeAlias(alias)
if norm == "" {
Error(w, http.StatusBadRequest, "alias 规范化后为空,无法作为寻址别名")
return
}
final, err := repo.SyncSessionAlias(r.Context(), sessionID, norm)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to sync alias")
return
}
resp["alias"] = final
}
// 让参与方前端立即看到新标题/别名
sse.Default.Broadcast("session_update", map[string]interface{}{
"session_id": sessionID.String(),
"alias": resp["alias"],
"title": resp["title"],
})
JSON(w, http.StatusOK, resp)
}
// GET /api/v1/sessions/{id}/rename-proposal
//
// 返回该会话里最新一条尚未处理的改名提议Agent 在正文里提的)。
// 「尚未处理」= 既不是当前别名(已接受),也不在驳回记录里。
// 无提议时返回 {"proposal": null},前端据此决定要不要显示提示条。
func GetRenameProposal(w http.ResponseWriter, r *http.Request) {
sessionID, ok := requireSessionAccess(w, r)
if !ok {
return
}
alias, reason, err := repo.PendingRenameProposal(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to load proposal")
return
}
if alias == "" {
JSON(w, http.StatusOK, map[string]interface{}{"proposal": nil})
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"proposal": map[string]string{"alias": alias, "reason": reason},
})
}
// POST /api/v1/sessions/{id}/rename-proposal/dismiss
//
// 用户驳回当前提议。记下被驳回的别名,好让提示条不再反复弹同一个建议 ——
// 否则每次打开会话都要重新点一次「忽略」。
//
// 接受提议走已有的 PUT /sessions/{id}/alias不另开端点
// 那条路径已经有唯一性校验与 409 处理,复制一遍只会多一个出错的地方。
func DismissRenameProposal(w http.ResponseWriter, r *http.Request) {
sessionID, ok := requireSessionAccess(w, r)
if !ok {
return
}
alias, _, err := repo.PendingRenameProposal(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to load proposal")
return
}
if alias == "" {
// 已经没有待处理提议(可能是另一个标签页刚处理过),当作成功
JSON(w, http.StatusOK, map[string]string{"status": "no_pending"})
return
}
if err := repo.DismissRenameProposal(r.Context(), sessionID, alias); err != nil {
Error(w, http.StatusInternalServerError, "Failed to dismiss proposal")
return
}
JSON(w, http.StatusOK, map[string]string{
"status": "dismissed",
"dismissed": alias,
})
}
type sessionBudgetRequest struct {
// MaxRounds 是本会话的往返预算上限0 = 不限)。
MaxRounds *int `json:"max_rounds"`
// Reset 把已用次数归零(上限不变)。可与 MaxRounds 同时给:
// 「加到 20 并从头算」是一次很自然的操作,拆成两个请求只会让前端多一次往返。
Reset bool `json:"reset"`
}
// GET /api/v1/sessions/{id}/budget
//
// 本会话的往返预算。与 Agent 全局配额是两层,都要过:
// 会话预算管「这件事值得多少个来回」,全局配额管「这个 Agent 总共能发多少」。
func GetSessionBudgetHandler(w http.ResponseWriter, r *http.Request) {
sessionID, ok := requireSessionAccess(w, r)
if !ok {
return
}
b, err := repo.GetSessionBudget(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusNotFound, "Session not found")
return
}
JSON(w, http.StatusOK, b)
}
// PUT /api/v1/sessions/{id}/budget
//
// 在对话页里随时调本任务的预算 —— 这是配额最该被编辑的地方:
// 人看着往来内容才知道这件事还值不值得再来几个回合。
func UpdateSessionBudget(w http.ResponseWriter, r *http.Request) {
sessionID, ok := requireSessionAccess(w, r)
if !ok {
return
}
var req sessionBudgetRequest
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
if req.MaxRounds == nil && !req.Reset {
Error(w, http.StatusBadRequest, "需要给出 max_rounds 或 reset")
return
}
b, err := repo.GetSessionBudget(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusNotFound, "Session not found")
return
}
if req.MaxRounds != nil {
if *req.MaxRounds < 0 {
Error(w, http.StatusBadRequest, "max_rounds 不能为负")
return
}
// 允许调到低于已用次数:那表示「就到这里为止」,是人的合法意图。
// 此时剩余为 0Agent 下次发信即被拦。
b, err = repo.SetSessionBudget(r.Context(), sessionID, *req.MaxRounds)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to set budget")
return
}
}
if req.Reset {
b, err = repo.ResetSessionBudget(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to reset budget")
return
}
}
// 让会话的其他参与方(含 Agent 侧界面)立刻看到新预算
sse.Default.Broadcast("session_update", map[string]interface{}{
"session_id": sessionID.String(),
"budget_max": b.Max,
"budget_used": b.Used,
"budget_remaining": b.Remaining,
})
JSON(w, http.StatusOK, b)
}

View File

@ -0,0 +1,209 @@
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
}

View File

@ -0,0 +1,26 @@
package handler
import (
"net/http/httptest"
"testing"
)
func TestIntQueryClampsAndFallsBack(t *testing.T) {
cases := []struct {
q string
want int
}{
{"", 40}, // 缺省
{"limit=10", 10}, // 正常
{"limit=0", 1}, // 低于下限 → 夹到下限
{"limit=999", 200}, // 高于上限 → 夹到上限
{"limit=abc", 40}, // 非法 → 回落默认值,而不是让整个请求 400
{"limit=-5", 1},
}
for _, c := range cases {
r := httptest.NewRequest("GET", "/x?"+c.q, nil)
if got := intQuery(r, "limit", 40, 1, 200); got != c.want {
t.Fatalf("intQuery(%q) = %d期望 %d", c.q, got, c.want)
}
}
}

View File

@ -0,0 +1,98 @@
package middleware
import (
"context"
"errors"
"net/http"
"strings"
"github.com/agentmail/gateway/internal/repo"
)
type contextKey string
const AgentNameKey contextKey = "agent_name"
// bearerToken 从 Authorization: Bearer <token> 取出令牌,缺失时返回空串。
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if h == "" {
return ""
}
const p = "Bearer "
if len(h) > len(p) && strings.EqualFold(h[:len(p)], p) {
return strings.TrimSpace(h[len(p):])
}
return ""
}
// BearerToken 导出给 handler 层用(注册接口不过中间件,需要自己取密钥)。
func BearerToken(r *http.Request) string { return bearerToken(r) }
// keyAuthError 把密钥校验错误翻译成对外文案。
// 「不存在」与「已使用/已过期」区分开:前两者是拿错了密钥,后者是密钥生命周期到了,
// 运维需要据此判断该重新签发还是该检查配置。
func keyAuthError(err error) string {
switch {
case errors.Is(err, repo.ErrKeyUsed):
return `{"error":"密钥已使用(一次性密钥只能用一次)"}`
case errors.Is(err, repo.ErrKeyExpired):
return `{"error":"密钥已过期"}`
default:
return `{"error":"密钥无效"}`
}
}
// AgentAuth 验证 Agent 身份,支持两种凭证:
//
// Authorization: Bearer <agent_key_token> —— 密钥认证(推荐)
// X-Agent-Name + X-Agent-Secret —— 旧的 name/secret 方式(兼容保留)
//
// 用户密钥user_keys不接受两类密钥共享 token 命名空间但走各自的验证表,
// 因此用用户密钥调 Agent 接口只会得到「密钥无效」。
func AgentAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if token := bearerToken(r); token != "" {
agentName, err := repo.VerifyAgentKey(r.Context(), token)
if err != nil {
http.Error(w, keyAuthError(err), http.StatusUnauthorized)
return
}
if agentName == "" {
// 密钥有效但尚未绑定 Agent注册接口会用请求里的 name 落定它,
// 其余接口无法确定调用者身份,只能拒。
http.Error(w, `{"error":"密钥尚未绑定 Agent请先调用 /agent/register 完成注册"}`, http.StatusForbidden)
return
}
repo.HeartbeatAgent(r.Context(), agentName)
ctx := context.WithValue(r.Context(), AgentNameKey, agentName)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
agentName := r.Header.Get("X-Agent-Name")
agentSecret := r.Header.Get("X-Agent-Secret")
if agentName == "" || agentSecret == "" {
http.Error(w, `{"error":"Missing Authorization: Bearer <key> or X-Agent-Name/X-Agent-Secret header"}`, http.StatusUnauthorized)
return
}
agent, err := repo.VerifyAgent(r.Context(), agentName, agentSecret)
if err != nil {
http.Error(w, `{"error":"Invalid credentials"}`, http.StatusUnauthorized)
return
}
repo.HeartbeatAgent(r.Context(), agent.Name)
ctx := context.WithValue(r.Context(), AgentNameKey, agent.Name)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// GetAgentName 从 context 中获取 agent_name
func GetAgentName(r *http.Request) string {
if v := r.Context().Value(AgentNameKey); v != nil {
return v.(string)
}
return ""
}

View File

@ -0,0 +1,161 @@
package middleware
import (
"context"
"net/http"
"strings"
"github.com/agentmail/gateway/internal/config"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
)
const UserKey contextKey = "auth_user"
// SetSessionCookie 写入登录 Cookie
func SetSessionCookie(w http.ResponseWriter, token string, maxAge int) {
http.SetCookie(w, &http.Cookie{
Name: config.C.CookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: config.C.SecureCookie,
SameSite: http.SameSiteLaxMode,
MaxAge: maxAge,
})
}
// ClearSessionCookie 清除登录 Cookie
func ClearSessionCookie(w http.ResponseWriter) {
SetSessionCookie(w, "", -1)
}
// SessionToken 从请求中取出登录令牌
func SessionToken(r *http.Request) string {
c, err := r.Cookie(config.C.CookieName)
if err != nil {
return ""
}
return c.Value
}
// QueryToken 从 ?access_token= 取出令牌。
//
// 仅为浏览器 EventSource 存在:它不支持自定义请求头,因此订阅 SSE 时
// 除了 Cookie 就只剩 query 一条路。代价是令牌会进访问日志,
// 所以只在 SSE 端点启用,其余接口一律要求 Authorization 头。
func QueryToken(r *http.Request) string {
return strings.TrimSpace(r.URL.Query().Get("access_token"))
}
// UserAuth 校验人类用户登录态,把 *models.User 注入 context。
// 支持两种凭证:浏览器 Cookie或 Authorization: Bearer <user_key_token>(第三方客户端)。
func UserAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, err := resolve(r)
if err != nil {
// 只有 Cookie 路径才清 Cookie密钥认证失败不应频带浏览器会话
if bearerToken(r) == "" {
ClearSessionCookie(w)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"not authenticated"}`))
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), UserKey, u)))
})
}
// AdminOnly 叠在 UserAuth 之后,要求 role = admin
func AdminOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := GetUser(r)
if u == nil || !u.IsAdmin() {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"error":"admin only"}`))
return
}
next.ServeHTTP(w, r)
})
}
// UserAuthAllowQueryToken 与 UserAuth 相同,但额外接受 ?access_token=。
//
// 只给那些【由浏览器直接发起、无法设置请求头】的端点用(附件下载的 <a download>)。
// URL 里的令牌会进访问日志与 Referer所以不能全局开启。
func UserAuthAllowQueryToken(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, err := resolve(r)
if err != nil {
if token := QueryToken(r); token != "" {
if ku, kErr := repo.VerifyUserKey(r.Context(), token); kErr == nil {
u, err = ku, nil
}
}
}
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"not authenticated"}`))
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), UserKey, u)))
})
}
// GetUser 从 context 取登录用户;未登录返回 nil
func GetUser(r *http.Request) *models.User {
if v := r.Context().Value(UserKey); v != nil {
if u, ok := v.(*models.User); ok {
return u
}
}
return nil
}
// GetUserName 便捷取登录用户名
func GetUserName(r *http.Request) string {
if u := GetUser(r); u != nil {
return u.Username
}
return ""
}
// OptionalUser 解析登录态但不拦截SSE 等需要区分匿名/登录的场景)
func OptionalUser(r *http.Request) *models.User {
u, err := resolve(r)
if err != nil {
return nil
}
return u
}
// OptionalUserWithQuery 在 resolve 的三种凭证之外额外接受 ?access_token=。
// 仅 SSE 用:浏览器 EventSource 无法带自定义头。
func OptionalUserWithQuery(r *http.Request) *models.User {
if u := OptionalUser(r); u != nil {
return u
}
if token := QueryToken(r); token != "" {
if u, err := repo.VerifyUserKey(r.Context(), token); err == nil {
return u
}
}
return nil
}
// resolve 解析调用者身份Cookie 优先,其次 Bearer 用户密钥。
//
// 用户密钥只能走到这里(/me/* 与会话级接口Agent 密钥只能走 AgentAuth
// 两者各自查自己的表,因此拿 Agent 密钥读人类邮箱会得到 not authenticated。
func resolve(r *http.Request) (*models.User, error) {
if token := SessionToken(r); token != "" {
return repo.ResolveUserSession(r.Context(), token)
}
if token := bearerToken(r); token != "" {
return repo.VerifyUserKey(r.Context(), token)
}
return nil, repo.ErrSessionInvalid
}

View File

@ -0,0 +1,138 @@
package models
import (
"fmt"
"strings"
)
// Address 是三维寻址 name@path.session 的解析结果
type Address struct {
Name string `json:"name"` // Agent 实例名(或人类用户名)
Path string `json:"path"` // 工作区路径(可含 /,可为空)
Session string `json:"session"` // 会话别名;"new" = 新建;"" = 默认会话
Raw string `json:"raw"` // 原始字符串
}
// SessionMode 是 session 位的三种语义
type SessionMode int
const (
// SessionDefaultsession 位省略 → 投递到 name@path 的默认会话(不存在则建立)
SessionDefault SessionMode = iota
// SessionNewsession 位为 new → 强制新建一个会话
SessionNew
// SessionNamedsession 位为具体别名 → 必须已存在,否则无法送达
SessionNamed
)
// Mode 返回该地址 session 位的语义
func (a Address) Mode() SessionMode {
switch a.Session {
case "":
return SessionDefault
case "new":
return SessionNew
default:
return SessionNamed
}
}
// IsNewSession 表示该地址要求新建会话(仅 session == "new")。
// 注意session 位省略不等于 new那是「默认会话」见 Mode()。
func (a Address) IsNewSession() bool {
return a.Mode() == SessionNew
}
// IsDefaultSession 表示该地址省略了 session 位,走默认会话
func (a Address) IsDefaultSession() bool {
return a.Mode() == SessionDefault
}
func (a Address) String() string {
return a.Raw
}
// ParseAddress 解析 name@path.session 三维地址。
//
// 支持形态:
//
// deepseekharness@/program.updatefeature → name=deepseekharness path=/program session=updatefeature
// pi@root.new → name=pi path=root session=new新建
// builder@ModelRouter.fix-leak → name=builder path=ModelRouter session=fix-leak
// human@.new → name=human path="" session=new
// human → name=human path="" session=""(默认会话)
//
// 规则:
// - 第一个 @ 之前是 name必填
// - @ 之后按【最后一个 .】切成 path 与 session因此 path 内可以包含 . 与 /
// - 没有 . 时,整段视为 pathsession 为空(默认会话)
//
// session 位三态语义见 Address.Mode():省略=默认会话new=新建,其他=必须已存在。
func ParseAddress(s string) (Address, error) {
raw := strings.TrimSpace(s)
if raw == "" {
return Address{}, fmt.Errorf("empty address")
}
// 只有形如 "@name@path.session" 时才剥掉前导 @
// "@ModelRouter.new" 缺少 name应当报错而不是被当成名字。
trimmed := raw
if strings.HasPrefix(raw, "@") && strings.Contains(raw[1:], "@") {
trimmed = raw[1:]
}
at := strings.Index(trimmed, "@")
if at < 0 {
// 只有名字human / builder
name := strings.TrimSpace(trimmed)
if name == "" {
return Address{}, fmt.Errorf("missing agent name in %q", raw)
}
return Address{Name: name, Raw: raw}, nil
}
name := strings.TrimSpace(trimmed[:at])
if name == "" {
return Address{}, fmt.Errorf("missing agent name in %q", raw)
}
rest := trimmed[at+1:]
// 按最后一个 . 切 path / sessionpath 内允许 / 与 .
var path, session string
if dot := strings.LastIndex(rest, "."); dot >= 0 {
path = rest[:dot]
session = rest[dot+1:]
} else {
path = rest
}
return Address{
Name: name,
Path: strings.TrimSpace(path),
Session: strings.TrimSpace(session),
Raw: raw,
}, nil
}
// ParseAddressList 解析逗号/分号/空白分隔的多个地址(用于 CC
func ParseAddressList(s string) ([]Address, error) {
raw := strings.TrimSpace(s)
if raw == "" {
return nil, nil
}
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == ';' || r == '\n' || r == '\t' || r == ' '
})
out := make([]Address, 0, len(fields))
for _, f := range fields {
addr, err := ParseAddress(f)
if err != nil {
return nil, err
}
out = append(out, addr)
}
return out, nil
}

View File

@ -0,0 +1,94 @@
package models
import "testing"
func TestParseAddress(t *testing.T) {
cases := []struct {
in string
name string
path string
session string
mode SessionMode
}{
// 用户给出的两个例子
{"deepseekharness@/program.upadtefeature", "deepseekharness", "/program", "upadtefeature", SessionNamed},
{"pi@root.new", "pi", "root", "new", SessionNew},
// 常规形态
{"builder@ModelRouter.fix-memory-leak", "builder", "ModelRouter", "fix-memory-leak", SessionNamed},
{"@builder@ModelRouter.new", "builder", "ModelRouter", "new", SessionNew},
{"human@.new", "human", "", "new", SessionNew},
// 省略 session 位 = 默认会话(不等于 new
{"human", "human", "", "", SessionDefault},
{"ops@prod", "ops", "prod", "", SessionDefault},
// path 内含 . 与 /(按最后一个 . 切)
{"agent@/home/a.b/c.deploy", "agent", "/home/a.b/c", "deploy", SessionNamed},
}
for _, c := range cases {
got, err := ParseAddress(c.in)
if err != nil {
t.Fatalf("ParseAddress(%q) unexpected error: %v", c.in, err)
}
if got.Name != c.name || got.Path != c.path || got.Session != c.session {
t.Errorf("ParseAddress(%q) = {name:%q path:%q session:%q}, want {name:%q path:%q session:%q}",
c.in, got.Name, got.Path, got.Session, c.name, c.path, c.session)
}
if got.Mode() != c.mode {
t.Errorf("ParseAddress(%q).Mode() = %v, want %v", c.in, got.Mode(), c.mode)
}
}
}
// session 位省略与 new 必须是两种不同语义:
// 省略 → 默认会话new → 强制新建;其他 → 必须已存在。
func TestSessionModeSemantics(t *testing.T) {
def, _ := ParseAddress("pi@root")
if def.Mode() != SessionDefault || def.IsNewSession() || !def.IsDefaultSession() {
t.Errorf("pi@root 应为默认会话,得到 mode=%v isNew=%v", def.Mode(), def.IsNewSession())
}
new_, _ := ParseAddress("pi@root.new")
if new_.Mode() != SessionNew || !new_.IsNewSession() || new_.IsDefaultSession() {
t.Errorf("pi@root.new 应为新建,得到 mode=%v", new_.Mode())
}
named, _ := ParseAddress("pi@root.fix-leak")
if named.Mode() != SessionNamed || named.IsNewSession() || named.IsDefaultSession() {
t.Errorf("pi@root.fix-leak 应为具名会话,得到 mode=%v", named.Mode())
}
}
func TestParseAddressErrors(t *testing.T) {
for _, in := range []string{"", " ", "@", "@ModelRouter.new"} {
if _, err := ParseAddress(in); err == nil {
t.Errorf("ParseAddress(%q) expected error, got nil", in)
}
}
}
func TestParseAddressList(t *testing.T) {
list, err := ParseAddressList("pi@root.new, deepseekharness@/program.upadtefeature;ops@prod")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(list) != 3 {
t.Fatalf("got %d addresses, want 3", len(list))
}
if list[0].Name != "pi" || !list[0].IsNewSession() {
t.Errorf("addr[0] = %+v", list[0])
}
if list[1].Path != "/program" || list[1].Session != "upadtefeature" {
t.Errorf("addr[1] = %+v", list[1])
}
if list[2].Name != "ops" || list[2].Path != "prod" || list[2].Mode() != SessionDefault {
t.Errorf("addr[2] = %+v", list[2])
}
empty, err := ParseAddressList(" ")
if err != nil || empty != nil {
t.Errorf("empty list = %v, %v; want nil, nil", empty, err)
}
}

View File

@ -0,0 +1,242 @@
package models
import (
"strings"
"time"
"github.com/google/uuid"
)
// Agent 代表一个已注册的 Agent 实例
type Agent struct {
ID uuid.UUID `json:"agent_id"`
Name string `json:"agent_name"`
Secret string `json:"-"`
HostURL string `json:"host_url"`
Workspaces []Workspace `json:"workspaces"`
Platform string `json:"platform"`
Status string `json:"status"`
MaxRounds int `json:"max_rounds"`
UsedRounds int `json:"used_rounds"`
LastSeen *time.Time `json:"last_seen"`
CreatedAt time.Time `json:"created_at"`
}
// Workspace 是 Agent 管理的项目工作区
type Workspace struct {
Name string `json:"name"`
Path string `json:"path"`
}
// Session 是有明确边界的任务会话
type Session struct {
ID uuid.UUID `json:"session_id"`
Alias *string `json:"session_alias"`
FromAgent string `json:"from_agent"`
Subject string `json:"subject"`
Status string `json:"status"`
OwnerUserID *uuid.UUID `json:"owner_user_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MailCount int `json:"mail_count,omitempty"`
// RenameDismissed 是用户驳回过的改名提议。
// 记下来才能让提示条不再反复弹同一个建议。
RenameDismissed string `json:"rename_dismissed,omitempty"`
// AliasSource 记录别名是谁定的:
// platform = Agent 平台自动同步来的,后续同步可以覆盖
// manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖
// 没有这个区分,平台下一次 session.updated 会把人刚定的名字冲掉。
AliasSource string `json:"alias_source,omitempty"`
// MaxRounds/UsedRounds 是本任务的往返预算0 = 本会话不限)。
// 配额的语义是「这件事值得多少个来回」,那是任务的属性而非 Agent 的属性,
// 所以在写信时给、在对话页里随时调。
MaxRounds int `json:"max_rounds"`
UsedRounds int `json:"used_rounds"`
}
// User 是人类用户(多用户账号体系)
type User struct {
ID uuid.UUID `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
PasswordHash string `json:"-"`
Role string `json:"role"` // admin / user
Status string `json:"status"` // active / disabled
CreatedAt time.Time `json:"created_at"`
LastLogin *time.Time `json:"last_login"`
// 权限边界:空切片 = 不限
AllowedAgents []string `json:"allowed_agents"`
AllowedPaths []string `json:"allowed_paths"`
}
// IsAdmin 判断是否管理员
func (u User) IsAdmin() bool { return u.Role == "admin" }
// CanUseAgent 判断用户是否可向指定 Agent 发信
// 空白名单(或为空) = 不限;管理员不受限;收件方是人类用户时不走此限制
func (u User) CanUseAgent(agentName string) bool {
if u.IsAdmin() || len(u.AllowedAgents) == 0 {
return true
}
for _, a := range u.AllowedAgents {
if a == agentName {
return true
}
}
return false
}
// CanUsePath 判断用户是否可访问指定工作区。
// 空白名单 = 不限;管理员不受限;空 path人类地址总是允许。
// 匹配规则:完全相等,或白名单项作为目录前缀(/program 允许 /program/sub
func (u User) CanUsePath(path string) bool {
if u.IsAdmin() || len(u.AllowedPaths) == 0 || path == "" {
return true
}
for _, p := range u.AllowedPaths {
if p == "" {
continue
}
if path == p {
return true
}
prefix := p
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
if strings.HasPrefix(path, prefix) {
return true
}
}
return false
}
// Mail 是会话中的一封邮件
type Mail struct {
ID uuid.UUID `json:"mail_id"`
SessionID uuid.UUID `json:"session_id"`
ParentMailID *uuid.UUID `json:"parent_mail_id"`
FromName string `json:"from_name"`
FromWorkspace string `json:"from_workspace"`
ToName string `json:"to_name"`
ToWorkspace string `json:"to_workspace"`
CCList []Address `json:"cc_list"`
Subject string `json:"subject"`
Body string `json:"body"`
MailType string `json:"mail_type"`
PermOptions []string `json:"permission_options,omitempty"`
PermResult string `json:"permission_result,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
HopLimit int `json:"hop_limit"`
SessionAlias string `json:"session_alias,omitempty"`
BodyPreview string `json:"body_preview,omitempty"`
// Attachments 仅在读取单封邮件/会话线程时填充;列表接口为省带宽留空
Attachments []Attachment `json:"attachments,omitempty"`
// RenameAlias / RenameReason 是 Agent 在本封正文里提议的新会话别名。
// 存在邮件上而非会话上:邮件是不可篡改的历史记录,
// 「谁在哪一封里提了什么」应当留痕。
RenameAlias string `json:"rename_alias,omitempty"`
RenameReason string `json:"rename_reason,omitempty"`
}
// PermissionRequest 是 Agent 向人类发起的权限请求
type PermissionRequest struct {
ID uuid.UUID `json:"request_id"`
MailID uuid.UUID `json:"mail_id"`
SessionID uuid.UUID `json:"session_id"`
AgentName string `json:"agent_name"`
Question string `json:"question"`
Options []string `json:"options"`
Context string `json:"context"`
Result *string `json:"result"`
DecidedAt *time.Time `json:"decided_at"`
CreatedAt time.Time `json:"created_at"`
}
// SSE 事件类型
const (
EventNewMail = "new_mail"
EventPermissionDecision = "permission_decision"
EventSessionUpdate = "session_update"
EventAgentOnline = "agent_online"
)
// ---------- 密钥认证 ----------
// 密钥类型:签发时决定其生命周期
const (
// KeyPermanent 永不过期,可重复使用(正式部署的 Agent 用这个)
KeyPermanent = "permanent"
// KeyOneTime 首次验证后即失效(用于把 Agent 首次接入的窗口压到最小)
KeyOneTime = "one_time"
// KeyTimed 到 ExpiresAt 之后失效
KeyTimed = "timed"
)
// ValidKeyType 判断密钥类型是否受支持
func ValidKeyType(t string) bool {
return t == KeyPermanent || t == KeyOneTime || t == KeyTimed
}
// AgentKey 是管理员签发的 Agent 接入密钥。
// AgentName 为空表示「待绑定」——密钥有效但还没指定属于哪个 Agent
// 首次注册时由注册请求里的 name 落定。
type AgentKey struct {
ID uuid.UUID `json:"key_id"`
Token string `json:"key_token,omitempty"` // 仅创建时回显一次
TokenHint string `json:"token_hint"` // 前 8 位 + 省略号,用于列表展示
AgentName *string `json:"agent_name"`
KeyType string `json:"key_type"`
Label string `json:"label"`
ExpiresAt *time.Time `json:"expires_at"`
UsedAt *time.Time `json:"used_at"`
CreatedBy *uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
}
// UserKey 是用户自助签发的客户端连接密钥,只能用于 /me/* 人类邮箱接口。
type UserKey struct {
ID uuid.UUID `json:"key_id"`
Token string `json:"key_token,omitempty"` // 仅创建时回显一次
TokenHint string `json:"token_hint"`
UserID uuid.UUID `json:"user_id"`
Label string `json:"label"`
KeyType string `json:"key_type"`
ExpiresAt *time.Time `json:"expires_at"`
UsedAt *time.Time `json:"used_at"`
CreatedAt time.Time `json:"created_at"`
}
// TokenHint 返回密钥的展示形式:只露前 8 位。
// 密钥全文仅在创建响应里出现一次,之后任何列表接口都只给 hint。
func TokenHint(token string) string {
if len(token) <= 8 {
return token
}
return token[:8] + "…"
}
// ---------- 附件 ----------
// Attachment 是一封邮件的附件元数据。文件内容存磁盘,按 sha256 内容寻址。
//
// MailID 为空表示「已上传、尚未挂到邮件上」:上传与发信是两步操作
//Agent 侧工具走 JSON无法在发信请求里带 multipart中间态必须允许存在。
type Attachment struct {
ID uuid.UUID `json:"attachment_id"`
MailID *uuid.UUID `json:"mail_id"`
Uploader string `json:"uploader"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
SHA256 string `json:"sha256"`
CreatedAt time.Time `json:"created_at"`
}

View File

@ -0,0 +1,226 @@
package repo
import (
"context"
"database/sql"
"errors"
"time"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
)
// ---------- 附件 ----------
//
// 元数据在库、内容在磁盘internal/blob。两者的一致性由调用顺序保证
// 先落盘再入库 —— 反过来会出现「库里有记录但文件不存在」的下载 500。
// 落盘成功但入库失败时最多留下一个无引用的文件,由 GC 回收,不影响正确性。
var (
// ErrAttachmentNotFound 附件不存在
ErrAttachmentNotFound = errors.New("attachment not found")
// ErrAttachmentNotOwned 附件不属于该上传者
ErrAttachmentNotOwned = errors.New("attachment not owned by uploader")
// ErrAttachmentAlreadyAttached 附件已挂到别的邮件上
ErrAttachmentAlreadyAttached = errors.New("attachment already attached")
)
const attachmentCols = `attachment_id, mail_id, uploader, filename, content_type, size_bytes, sha256, created_at`
func scanAttachment(sc interface{ Scan(...any) error }) (*models.Attachment, error) {
var a models.Attachment
if err := sc.Scan(&a.ID, &a.MailID, &a.Uploader, &a.Filename,
&a.ContentType, &a.SizeBytes, &a.SHA256, &a.CreatedAt); err != nil {
return nil, err
}
return &a, nil
}
// CreateAttachment 登记一条待挂载的附件mail_id 为空)。
func CreateAttachment(ctx context.Context, uploader, filename, contentType string, size int64, sum string) (*models.Attachment, error) {
a := &models.Attachment{
Uploader: uploader,
Filename: filename,
ContentType: contentType,
SizeBytes: size,
SHA256: sum,
}
err := db.DB.QueryRowContext(ctx, `
INSERT INTO attachments (uploader, filename, content_type, size_bytes, sha256)
VALUES ($1, $2, $3, $4, $5)
RETURNING attachment_id, created_at
`, uploader, filename, contentType, size, sum).Scan(&a.ID, &a.CreatedAt)
if err != nil {
return nil, err
}
return a, nil
}
// GetAttachment 读取一条附件元数据。
func GetAttachment(ctx context.Context, id uuid.UUID) (*models.Attachment, error) {
a, err := scanAttachment(db.DB.QueryRowContext(ctx,
`SELECT `+attachmentCols+` FROM attachments WHERE attachment_id = $1`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAttachmentNotFound
}
return a, err
}
// ListAttachmentsFor 列出某封邮件的附件。
func ListAttachmentsFor(ctx context.Context, mailID uuid.UUID) ([]models.Attachment, error) {
rows, err := db.DB.QueryContext(ctx,
`SELECT `+attachmentCols+` FROM attachments WHERE mail_id = $1 ORDER BY created_at`, mailID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Attachment{}
for rows.Next() {
a, err := scanAttachment(rows)
if err != nil {
return nil, err
}
out = append(out, *a)
}
return out, rows.Err()
}
// AttachToMail 把一批待挂载附件绑到某封邮件上。
//
// 每条都要求:存在、属于该上传者、且尚未挂载。
// 用 WHERE mail_id IS NULL AND uploader = ? 一条 UPDATE 完成判断与写入,
// 避免「先查后改」在并发下把同一个附件挂到两封邮件上。
func AttachToMail(ctx context.Context, mailID uuid.UUID, ids []uuid.UUID, uploader string) error {
for _, id := range ids {
tag, err := db.DB.ExecContext(ctx, `
UPDATE attachments SET mail_id = $1
WHERE attachment_id = $2 AND uploader = $3 AND mail_id IS NULL
`, mailID, id, uploader)
if err != nil {
return err
}
if n, _ := tag.RowsAffected(); n > 0 {
continue
}
// 没改到:查明原因,给调用方一个能照着修的错误
a, gErr := GetAttachment(ctx, id)
if gErr != nil {
return gErr
}
if a.Uploader != uploader {
return ErrAttachmentNotOwned
}
return ErrAttachmentAlreadyAttached
}
return nil
}
// CopyAttachmentsTo 把源邮件的附件复制到目标邮件(转发时用)。
//
// 内容寻址下「复制」只是新增一条指向同一 sha256 的元数据,不拷磁盘文件。
// uploader 记为转发人:附件随新邮件重新分发,其可见范围由新邮件的参与方决定,
// 而不是沿用原上传者。返回复制的数量。
func CopyAttachmentsTo(ctx context.Context, srcMailID, dstMailID uuid.UUID, forwarder string) (int, error) {
src, err := ListAttachmentsFor(ctx, srcMailID)
if err != nil {
return 0, err
}
for _, a := range src {
_, err := db.DB.ExecContext(ctx, `
INSERT INTO attachments (mail_id, uploader, filename, content_type, size_bytes, sha256)
VALUES ($1, $2, $3, $4, $5, $6)
`, dstMailID, forwarder, a.Filename, a.ContentType, a.SizeBytes, a.SHA256)
if err != nil {
return 0, err
}
}
return len(src), nil
}
// DeleteAttachment 删除一条附件元数据,返回它的 sha256 以及该内容是否已无人引用。
// 内容寻址下多条记录可能共享同一个文件,只有最后一条引用消失才能删磁盘文件。
func DeleteAttachment(ctx context.Context, id uuid.UUID) (sum string, orphaned bool, err error) {
a, err := GetAttachment(ctx, id)
if err != nil {
return "", false, err
}
if _, err = db.DB.ExecContext(ctx,
`DELETE FROM attachments WHERE attachment_id = $1`, id); err != nil {
return "", false, err
}
var refs int
if err = db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM attachments WHERE sha256 = $1`, a.SHA256).Scan(&refs); err != nil {
return "", false, err
}
return a.SHA256, refs == 0, nil
}
// SweepOrphanAttachments 清理超过 age 仍未挂载到邮件的附件记录,
// 返回可以从磁盘删除的 sha256 列表(已确认无任何记录引用)。
//
// 上传后没走完发信流程用户取消、Agent 崩溃)会留下这类记录,
// 不清理的话磁盘只会单调增长。
func SweepOrphanAttachments(ctx context.Context, age time.Duration) ([]string, error) {
cutoff := time.Now().Add(-age)
rows, err := db.DB.QueryContext(ctx,
`SELECT attachment_id, sha256 FROM attachments
WHERE mail_id IS NULL AND created_at < $1`, cutoff)
if err != nil {
return nil, err
}
type orphan struct {
id uuid.UUID
sum string
}
var found []orphan
for rows.Next() {
var o orphan
if err := rows.Scan(&o.id, &o.sum); err != nil {
rows.Close()
return nil, err
}
found = append(found, o)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
var removable []string
for _, o := range found {
if _, err := db.DB.ExecContext(ctx,
`DELETE FROM attachments WHERE attachment_id = $1`, o.id); err != nil {
return nil, err
}
var refs int
if err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM attachments WHERE sha256 = $1`, o.sum).Scan(&refs); err != nil {
return nil, err
}
if refs == 0 {
removable = append(removable, o.sum)
}
}
return removable, nil
}
// AttachmentAccessible 判断某人是否有权读取某附件:
// 已挂载的看邮件所属会话的参与关系,未挂载的只有上传者本人能看。
func AttachmentAccessible(ctx context.Context, a *models.Attachment, name string) (bool, error) {
if a.MailID == nil {
return a.Uploader == name, nil
}
var n int
err := db.DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM mails m
WHERE m.mail_id = $1
AND (m.from_name = $2 OR m.to_name = $2 OR `+db.CCHas("m.cc_list", 2)+`)
`, *a.MailID, name).Scan(&n)
return n > 0, err
}

View File

@ -0,0 +1,165 @@
package repo
import (
"context"
"database/sql"
"errors"
"sync"
"testing"
"github.com/google/uuid"
)
// setupBudgetDB 复用 quota_test.go 的临时库,再建一个会话。
// 用真实 SQLite 而非 mock预算的正确性核心是「判断与自增在同一条 UPDATE 里」,
// 那正是只有真实数据库才能验证的部分。
func setupBudgetDB(t *testing.T) uuid.UUID {
t.Helper()
setupTestDB(t)
id, err := CreateSession(context.Background(), nil, "bot", "预算测试")
if err != nil {
t.Fatalf("create session: %v", err)
}
return id
}
func TestSessionBudgetZeroMeansUnlimited(t *testing.T) {
id := setupBudgetDB(t)
ctx := context.Background()
// 默认 0 = 不限:引入预算不该把已在进行的会话卡死
b, err := GetSessionBudget(ctx, id)
if err != nil {
t.Fatal(err)
}
if !b.Unlimited || b.Remaining != -1 {
t.Fatalf("默认应为不限:%+v", b)
}
// 不限时反复占用都成功
for i := 0; i < 5; i++ {
if _, err := ConsumeSessionBudget(ctx, id); err != nil {
t.Fatalf("不限额下第 %d 次占用失败: %v", i+1, err)
}
}
}
func TestSessionBudgetExhausts(t *testing.T) {
id := setupBudgetDB(t)
ctx := context.Background()
if _, err := SetSessionBudget(ctx, id, 2); err != nil {
t.Fatal(err)
}
for i := 1; i <= 2; i++ {
b, err := ConsumeSessionBudget(ctx, id)
if err != nil {
t.Fatalf("第 %d 次应成功: %v", i, err)
}
if b.Used != i {
t.Fatalf("第 %d 次后 used = %d", i, b.Used)
}
}
b, err := ConsumeSessionBudget(ctx, id)
if !errors.Is(err, ErrSessionBudgetExhausted) {
t.Fatalf("第 3 次应耗尽,得到 err=%v b=%+v", err, b)
}
if b.Remaining != 0 {
t.Fatalf("耗尽后剩余应为 0%+v", b)
}
}
// 判断与自增必须在同一条 UPDATE 里,否则并发下会把预算刷穿
func TestSessionBudgetConcurrentDoesNotOverdraw(t *testing.T) {
id := setupBudgetDB(t)
ctx := context.Background()
const limit = 10
if _, err := SetSessionBudget(ctx, id, limit); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
var mu sync.Mutex
ok := 0
for i := 0; i < 40; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := ConsumeSessionBudget(ctx, id); err == nil {
mu.Lock()
ok++
mu.Unlock()
}
}()
}
wg.Wait()
if ok != limit {
t.Fatalf("40 并发下成功 %d 次,期望恰好 %d 次(预算被刷穿或误拒)", ok, limit)
}
b, _ := GetSessionBudget(ctx, id)
if b.Used != limit {
t.Fatalf("used = %d期望 %d", b.Used, limit)
}
}
func TestSessionBudgetResetAndLowerBelowUsed(t *testing.T) {
id := setupBudgetDB(t)
ctx := context.Background()
SetSessionBudget(ctx, id, 5)
for i := 0; i < 3; i++ {
ConsumeSessionBudget(ctx, id)
}
// 调到低于已用次数 = 「就到这里为止」,是人的合法意图,不该报错
b, err := SetSessionBudget(ctx, id, 1)
if err != nil {
t.Fatalf("下调预算不该失败: %v", err)
}
if b.Remaining != 0 {
t.Fatalf("已用 3 上限 1 时剩余应为 0%+v", b)
}
if _, err := ConsumeSessionBudget(ctx, id); !errors.Is(err, ErrSessionBudgetExhausted) {
t.Fatal("下调后应立即拦住")
}
// 重置只清已用次数,不动上限
b, err = ResetSessionBudget(ctx, id)
if err != nil {
t.Fatal(err)
}
if b.Used != 0 || b.Max != 1 {
t.Fatalf("重置后应为 0/1%+v", b)
}
}
// 会话预算先扣、全局配额后扣;全局拦下时必须把会话那次退回去,
// 否则那格白掉了 —— 那次往返实际上没有发生
func TestRefundSessionBudget(t *testing.T) {
id := setupBudgetDB(t)
ctx := context.Background()
SetSessionBudget(ctx, id, 3)
ConsumeSessionBudget(ctx, id)
RefundSessionBudget(ctx, id)
b, _ := GetSessionBudget(ctx, id)
if b.Used != 0 {
t.Fatalf("退还后 used 应为 0%+v", b)
}
// 已经是 0 时再退不该变成负数
RefundSessionBudget(ctx, id)
b, _ = GetSessionBudget(ctx, id)
if b.Used != 0 {
t.Fatalf("重复退还把 used 变成了 %d", b.Used)
}
}
func TestGetSessionBudgetMissingSession(t *testing.T) {
setupBudgetDB(t)
if _, err := GetSessionBudget(context.Background(), uuid.New()); err == nil {
t.Fatal("不存在的会话应报错")
} else if errors.Is(err, sql.ErrNoRows) {
t.Fatal("应包装成可读错误而不是裸 sql.ErrNoRows")
}
}

View File

@ -0,0 +1,341 @@
package repo
import (
"context"
"database/sql"
"errors"
"time"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
)
// ---------- 密钥认证 ----------
//
// 两类密钥共享一个全局唯一的 token 命名空间:验证时先查 agent_keys 再查 user_keys。
// 这样一个 token 永远只有一种身份,不会出现「同一串字符既能注册 Agent 又能读人类邮箱」。
var (
// ErrKeyNotFound 密钥不存在
ErrKeyNotFound = errors.New("key not found")
// ErrKeyUsed 一次性密钥已被使用
ErrKeyUsed = errors.New("key already used")
// ErrKeyExpired 定时密钥已过期
ErrKeyExpired = errors.New("key expired")
// ErrKeyTypeInvalid 密钥类型不受支持
ErrKeyTypeInvalid = errors.New("invalid key type")
// ErrKeyNeedsExpiry timed 密钥缺少有效的 expires_hours
ErrKeyNeedsExpiry = errors.New("timed key requires positive expires_hours")
// ErrKeyTooShort 登记的客户端密钥长度不足
ErrKeyTooShort = errors.New("key token too short")
)
// expiryFor 依据密钥类型算出过期时间。
// 只有 timed 需要 expires_atpermanent 与 one_time 都是 NULL
// 各自的失效条件由 key_type 本身表达,不混用 expires_at。
func expiryFor(keyType string, hours int) (*time.Time, error) {
if !models.ValidKeyType(keyType) {
return nil, ErrKeyTypeInvalid
}
if keyType != models.KeyTimed {
return nil, nil
}
if hours <= 0 {
return nil, ErrKeyNeedsExpiry
}
t := time.Now().Add(time.Duration(hours) * time.Hour)
return &t, nil
}
// checkKeyUsable 判断一条密钥记录当前是否可用。
func checkKeyUsable(keyType string, expiresAt, usedAt *time.Time) error {
switch keyType {
case models.KeyOneTime:
if usedAt != nil {
return ErrKeyUsed
}
case models.KeyTimed:
if expiresAt == nil || time.Now().After(*expiresAt) {
return ErrKeyExpired
}
}
return nil
}
// ---------- Agent 密钥(管理员签发) ----------
// ErrKeyTokenTaken 登记的密钥已被占用
var ErrKeyTokenTaken = errors.New("key token already registered")
// CreateAgentKey 签发一条 Agent 接入密钥。agentName 为空表示待绑定。
//
// presetToken 非空时登记客户端已在本地生成的密钥(插件首装场景),
// 这样密钥全文只从客户端往服务器走一次,不需要反方向传递;留空则由服务器生成。
func CreateAgentKey(ctx context.Context, agentName, keyType, label string, expiresHours int, createdBy uuid.UUID, presetToken string) (*models.AgentKey, error) {
expires, err := expiryFor(keyType, expiresHours)
if err != nil {
return nil, err
}
token := presetToken
if token == "" {
if token, err = newToken(); err != nil {
return nil, err
}
} else if len(token) < 32 {
// 太短的客户端密钥不接受,否则等于把弱口令当凭证
return nil, ErrKeyTooShort
}
var namePtr *string
if agentName != "" {
namePtr = &agentName
}
k := &models.AgentKey{
Token: token,
TokenHint: models.TokenHint(token),
AgentName: namePtr,
KeyType: keyType,
Label: label,
ExpiresAt: expires,
CreatedBy: &createdBy,
}
err = db.DB.QueryRowContext(ctx, `
INSERT INTO agent_keys (key_token, agent_name, key_type, label, expires_at, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING key_id, created_at
`, token, namePtr, keyType, label, expires, createdBy).Scan(&k.ID, &k.CreatedAt)
if db.IsUniqueViolation(err) {
return nil, ErrKeyTokenTaken
}
if err != nil {
return nil, err
}
return k, nil
}
// ListAgentKeys 列出 Agent 密钥agentName 非空时按 Agent 过滤。
// 返回值不含 token 全文,只有 hint。
func ListAgentKeys(ctx context.Context, agentName string) ([]models.AgentKey, error) {
q := `SELECT key_id, key_token, agent_name, key_type, label, expires_at, used_at, created_by, created_at
FROM agent_keys`
args := []any{}
if agentName != "" {
q += ` WHERE agent_name = $1`
args = append(args, agentName)
}
q += ` ORDER BY created_at DESC`
rows, err := db.DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
keys := []models.AgentKey{}
for rows.Next() {
var k models.AgentKey
var token string
if err := rows.Scan(&k.ID, &token, &k.AgentName, &k.KeyType, &k.Label,
&k.ExpiresAt, &k.UsedAt, &k.CreatedBy, &k.CreatedAt); err != nil {
return nil, err
}
k.TokenHint = models.TokenHint(token) // 不回传全文
keys = append(keys, k)
}
return keys, rows.Err()
}
// DeleteAgentKey 吊销一条 Agent 密钥。
func DeleteAgentKey(ctx context.Context, id uuid.UUID) error {
tag, err := db.DB.ExecContext(ctx, `DELETE FROM agent_keys WHERE key_id = $1`, id)
if err != nil {
return err
}
if n, _ := tag.RowsAffected(); n == 0 {
return ErrKeyNotFound
}
return nil
}
// BindAgentKey 把一条密钥绑定到指定 Agent 名。
func BindAgentKey(ctx context.Context, id uuid.UUID, agentName string) error {
tag, err := db.DB.ExecContext(ctx,
`UPDATE agent_keys SET agent_name = $2 WHERE key_id = $1`, id, agentName)
if err != nil {
return err
}
if n, _ := tag.RowsAffected(); n == 0 {
return ErrKeyNotFound
}
return nil
}
// VerifyAgentKey 校验 Agent 密钥并返回它绑定的 Agent 名(未绑定时返回空串)。
//
// 一次性密钥在校验通过时立刻写 used_at —— 用 WHERE used_at IS NULL 保证并发下
// 只有一个请求能把它标记掉,避免两个 Agent 拿同一把一次性密钥同时注册成功。
func VerifyAgentKey(ctx context.Context, token string) (string, error) {
var (
id uuid.UUID
agentName *string
keyType string
expiresAt *time.Time
usedAt *time.Time
)
err := db.DB.QueryRowContext(ctx, `
SELECT key_id, agent_name, key_type, expires_at, used_at
FROM agent_keys WHERE key_token = $1
`, token).Scan(&id, &agentName, &keyType, &expiresAt, &usedAt)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrKeyNotFound
}
if err != nil {
return "", err
}
if err := checkKeyUsable(keyType, expiresAt, usedAt); err != nil {
return "", err
}
if keyType == models.KeyOneTime {
tag, err := db.DB.ExecContext(ctx,
`UPDATE agent_keys SET used_at = NOW() WHERE key_id = $1 AND used_at IS NULL`, id)
if err != nil {
return "", err
}
if n, _ := tag.RowsAffected(); n == 0 {
return "", ErrKeyUsed // 并发下被别人抢先用掉了
}
}
if agentName == nil {
return "", nil
}
return *agentName, nil
}
// ClaimAgentKey 在待绑定密钥首次注册时把它落定到该 Agent 名。
// 已绑定的密钥不受影响WHERE agent_name IS NULL因此不能借一把已绑定的密钥改注册别的 Agent。
func ClaimAgentKey(ctx context.Context, token, agentName string) error {
_, err := db.DB.ExecContext(ctx,
`UPDATE agent_keys SET agent_name = $2 WHERE key_token = $1 AND agent_name IS NULL`,
token, agentName)
return err
}
// ---------- 用户密钥(用户自助签发) ----------
// CreateUserKey 为用户签发一条客户端连接密钥。
func CreateUserKey(ctx context.Context, userID uuid.UUID, label, keyType string, expiresHours int) (*models.UserKey, error) {
expires, err := expiryFor(keyType, expiresHours)
if err != nil {
return nil, err
}
token, err := newToken()
if err != nil {
return nil, err
}
k := &models.UserKey{
Token: token,
TokenHint: models.TokenHint(token),
UserID: userID,
Label: label,
KeyType: keyType,
ExpiresAt: expires,
}
err = db.DB.QueryRowContext(ctx, `
INSERT INTO user_keys (key_token, user_id, label, key_type, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING key_id, created_at
`, token, userID, label, keyType, expires).Scan(&k.ID, &k.CreatedAt)
if err != nil {
return nil, err
}
return k, nil
}
// ListUserKeys 列出某用户的连接密钥(不含 token 全文)。
func ListUserKeys(ctx context.Context, userID uuid.UUID) ([]models.UserKey, error) {
rows, err := db.DB.QueryContext(ctx, `
SELECT key_id, key_token, user_id, label, key_type, expires_at, used_at, created_at
FROM user_keys WHERE user_id = $1 ORDER BY created_at DESC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
keys := []models.UserKey{}
for rows.Next() {
var k models.UserKey
var token string
if err := rows.Scan(&k.ID, &token, &k.UserID, &k.Label, &k.KeyType,
&k.ExpiresAt, &k.UsedAt, &k.CreatedAt); err != nil {
return nil, err
}
k.TokenHint = models.TokenHint(token)
keys = append(keys, k)
}
return keys, rows.Err()
}
// DeleteUserKey 删除自己的一条密钥。带 user_id 条件,避免删掉别人的。
func DeleteUserKey(ctx context.Context, userID, keyID uuid.UUID) error {
tag, err := db.DB.ExecContext(ctx,
`DELETE FROM user_keys WHERE key_id = $1 AND user_id = $2`, keyID, userID)
if err != nil {
return err
}
if n, _ := tag.RowsAffected(); n == 0 {
return ErrKeyNotFound
}
return nil
}
// VerifyUserKey 校验用户密钥并返回对应用户。
// 用户必须仍处于 active 状态——禁用账号后其密钥应当立即失效。
func VerifyUserKey(ctx context.Context, token string) (*models.User, error) {
var (
id uuid.UUID
userID uuid.UUID
keyType string
expiresAt *time.Time
usedAt *time.Time
)
err := db.DB.QueryRowContext(ctx, `
SELECT key_id, user_id, key_type, expires_at, used_at
FROM user_keys WHERE key_token = $1
`, token).Scan(&id, &userID, &keyType, &expiresAt, &usedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrKeyNotFound
}
if err != nil {
return nil, err
}
if err := checkKeyUsable(keyType, expiresAt, usedAt); err != nil {
return nil, err
}
if keyType == models.KeyOneTime {
tag, err := db.DB.ExecContext(ctx,
`UPDATE user_keys SET used_at = NOW() WHERE key_id = $1 AND used_at IS NULL`, id)
if err != nil {
return nil, err
}
if n, _ := tag.RowsAffected(); n == 0 {
return nil, ErrKeyUsed
}
}
u, err := GetUserByID(ctx, userID)
if err != nil {
return nil, err
}
if u.Status != "active" {
return nil, ErrKeyNotFound // 账号已禁用,密钥一并失效
}
return u, nil
}

View File

@ -0,0 +1,282 @@
package repo
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
)
// ---------- 配额 ----------
//
// 配额限制的是 Agent「主动发信」的次数不限制收信
// 收信是被动的,卡住收信只会让邮件凭空消失;卡住发信才能真正阻止 Agent 无限自我循环。
//
// agents.max_rounds = 0 表示不限额。used_rounds 单调递增,由管理员显式重置。
// ErrQuotaExhausted 表示 Agent 的发信配额已用尽。
var ErrQuotaExhausted = errors.New("quota exhausted")
// Quota 是一个 Agent 的配额快照。
type Quota struct {
AgentName string `json:"agent_name"`
Max int `json:"max_rounds"` // 0 = 不限
Used int `json:"used_rounds"`
Remaining int `json:"remaining"` // 不限时为 -1
Unlimited bool `json:"unlimited"`
}
func makeQuota(name string, max, used int) Quota {
q := Quota{AgentName: name, Max: max, Used: used, Unlimited: max <= 0}
if q.Unlimited {
q.Remaining = -1
return q
}
if r := max - used; r > 0 {
q.Remaining = r
}
return q
}
// GetQuota 读取某 Agent 的配额状态。
func GetQuota(ctx context.Context, agentName string) (Quota, error) {
var max, used int
err := db.DB.QueryRowContext(ctx,
`SELECT max_rounds, used_rounds FROM agents WHERE agent_name = $1`, agentName).Scan(&max, &used)
if errors.Is(err, sql.ErrNoRows) {
return Quota{}, fmt.Errorf("agent %q 不存在", agentName)
}
if err != nil {
return Quota{}, err
}
return makeQuota(agentName, max, used), nil
}
// ConsumeQuota 原子地占用一次发信配额,返回占用后的快照。
//
// 判断与自增必须在同一条 UPDATE 里完成WHERE used_rounds < max_rounds
// 否则并发发信会双双通过检查再各自 +1把配额刷穿。
// 配额耗尽时返回 ErrQuotaExhausted同时给出快照供调用方生成提示。
func ConsumeQuota(ctx context.Context, agentName string) (Quota, error) {
tag, err := db.DB.ExecContext(ctx, `
UPDATE agents SET used_rounds = used_rounds + 1
WHERE agent_name = $1
AND (max_rounds <= 0 OR used_rounds < max_rounds)
`, agentName)
if err != nil {
return Quota{}, err
}
n, _ := tag.RowsAffected()
if n == 0 {
// 要么 Agent 不存在,要么配额用尽——用快照区分
q, qErr := GetQuota(ctx, agentName)
if qErr != nil {
return Quota{}, qErr
}
return q, ErrQuotaExhausted
}
return GetQuota(ctx, agentName)
}
// SetQuota 设置某 Agent 的配额上限0 = 不限)。
func SetQuota(ctx context.Context, agentName string, max int) (Quota, error) {
if max < 0 {
max = 0
}
tag, err := db.DB.ExecContext(ctx,
`UPDATE agents SET max_rounds = $2 WHERE agent_name = $1`, agentName, max)
if err != nil {
return Quota{}, err
}
if n, _ := tag.RowsAffected(); n == 0 {
return Quota{}, fmt.Errorf("agent %q 不存在", agentName)
}
return GetQuota(ctx, agentName)
}
// ResetQuota 把已用次数归零(配额上限不变)。
func ResetQuota(ctx context.Context, agentName string) (Quota, error) {
tag, err := db.DB.ExecContext(ctx,
`UPDATE agents SET used_rounds = 0 WHERE agent_name = $1`, agentName)
if err != nil {
return Quota{}, err
}
if n, _ := tag.RowsAffected(); n == 0 {
return Quota{}, fmt.Errorf("agent %q 不存在", agentName)
}
return GetQuota(ctx, agentName)
}
// ListQuotas 列出所有 Agent 的配额(管理员视图)。
func ListQuotas(ctx context.Context) ([]Quota, error) {
rows, err := db.DB.QueryContext(ctx,
`SELECT agent_name, max_rounds, used_rounds FROM agents ORDER BY agent_name`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Quota{}
for rows.Next() {
var name string
var max, used int
if err := rows.Scan(&name, &max, &used); err != nil {
return nil, err
}
out = append(out, makeQuota(name, max, used))
}
return out, rows.Err()
}
// ---------- 转发 ----------
// ForwardSource 是被转发邮件的必要信息。
type ForwardSource struct {
Mail *models.Mail
Session uuid.UUID
}
// LoadForwardSource 读取待转发的邮件,并校验转发者确实参与过该邮件
//(收件人、发件人或被抄送方之一)。防止凭 mail_id 转发别人的邮件。
func LoadForwardSource(ctx context.Context, mailID uuid.UUID, actor string) (*models.Mail, error) {
m, err := GetMailByID(ctx, mailID)
if err != nil {
return nil, ErrMailNotFound
}
if m.FromName == actor || m.ToName == actor {
return m, nil
}
for _, cc := range m.CCList {
if cc.Name == actor {
return m, nil
}
}
return nil, ErrForwardNotAllowed
}
var (
// ErrMailNotFound 待转发的邮件不存在
ErrMailNotFound = errors.New("mail not found")
// ErrForwardNotAllowed 转发者未参与该邮件
ErrForwardNotAllowed = errors.New("not a participant of that mail")
)
// ---------- 会话级往返预算 ----------
//
// 配额的真实语义是「这件事值得多少个来回」——那是**任务**的属性,不是 Agent 的属性。
// 只有 agents.max_rounds 一个全局计数器时有两个问题:
// 1. 两个并行任务互相抢额度:给紧急任务留的份被另一条线索吃掉
// 2. used_rounds 单调递增,跑满就得管理员手工重置才能再干活
// 因此预算下沉到会话,由人在写信时给、在对话页里随时调。
//
// **两层都要过**:会话预算 + Agent 全局配额。少了后者Agent 自己 `.new` 开一串会话
// 每条都是全新预算,全局上限形同虚设;少了前者,就回到抢额度的老问题。
// SessionBudget 是一个会话的往返预算快照。
type SessionBudget struct {
SessionID uuid.UUID `json:"session_id"`
Max int `json:"max_rounds"` // 0 = 本会话不限
Used int `json:"used_rounds"`
Remaining int `json:"remaining"` // 不限时为 -1
Unlimited bool `json:"unlimited"`
}
func makeSessionBudget(id uuid.UUID, max, used int) SessionBudget {
b := SessionBudget{SessionID: id, Max: max, Used: used, Unlimited: max <= 0}
if b.Unlimited {
b.Remaining = -1
return b
}
if r := max - used; r > 0 {
b.Remaining = r
}
return b
}
// ErrSessionBudgetExhausted 表示该会话的往返预算已用尽。
var ErrSessionBudgetExhausted = errors.New("session budget exhausted")
// GetSessionBudget 读取会话预算。
func GetSessionBudget(ctx context.Context, id uuid.UUID) (SessionBudget, error) {
var max, used int
err := db.DB.QueryRowContext(ctx,
`SELECT COALESCE(max_rounds,0), COALESCE(used_rounds,0) FROM sessions WHERE session_id = $1`,
id).Scan(&max, &used)
if errors.Is(err, sql.ErrNoRows) {
return SessionBudget{}, fmt.Errorf("会话 %s 不存在", id)
}
if err != nil {
return SessionBudget{}, err
}
return makeSessionBudget(id, max, used), nil
}
// ConsumeSessionBudget 原子地占用会话的一次往返。
//
// 与 ConsumeQuota 同理:判断与自增必须在同一条 UPDATE 里WHERE used_rounds < max_rounds
// 否则并发发信会双双通过检查再各自 +1把预算刷穿。
func ConsumeSessionBudget(ctx context.Context, id uuid.UUID) (SessionBudget, error) {
tag, err := db.DB.ExecContext(ctx, `
UPDATE sessions SET used_rounds = COALESCE(used_rounds,0) + 1
WHERE session_id = $1
AND (COALESCE(max_rounds,0) <= 0 OR COALESCE(used_rounds,0) < max_rounds)
`, id)
if err != nil {
return SessionBudget{}, err
}
if n, _ := tag.RowsAffected(); n == 0 {
b, bErr := GetSessionBudget(ctx, id)
if bErr != nil {
return SessionBudget{}, bErr
}
return b, ErrSessionBudgetExhausted
}
return GetSessionBudget(ctx, id)
}
// SetSessionBudget 设置会话预算上限0 = 不限)。
//
// 允许把上限调到低于已用次数:那表示「就到这里为止」,是人的合法意图,
// 不该因为算不出正的剩余量就拒绝。此时 Remaining 为 0下次发信即被拦。
func SetSessionBudget(ctx context.Context, id uuid.UUID, max int) (SessionBudget, error) {
if max < 0 {
max = 0
}
tag, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET max_rounds = $2, updated_at = NOW() WHERE session_id = $1`, id, max)
if err != nil {
return SessionBudget{}, err
}
if n, _ := tag.RowsAffected(); n == 0 {
return SessionBudget{}, fmt.Errorf("会话 %s 不存在", id)
}
return GetSessionBudget(ctx, id)
}
// ResetSessionBudget 把该会话的已用次数归零(上限不变)。
func ResetSessionBudget(ctx context.Context, id uuid.UUID) (SessionBudget, error) {
tag, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET used_rounds = 0, updated_at = NOW() WHERE session_id = $1`, id)
if err != nil {
return SessionBudget{}, err
}
if n, _ := tag.RowsAffected(); n == 0 {
return SessionBudget{}, fmt.Errorf("会话 %s 不存在", id)
}
return GetSessionBudget(ctx, id)
}
// RefundSessionBudget 退还一次往返。
//
// 会话预算先扣、Agent 全局配额后扣,全局那层拦下时必须把会话这次还回去,
// 否则会话预算白掉一格 —— 那次往返实际上没有发生。
func RefundSessionBudget(ctx context.Context, id uuid.UUID) {
_, _ = db.DB.ExecContext(ctx,
`UPDATE sessions SET used_rounds = COALESCE(used_rounds,0) - 1
WHERE session_id = $1 AND COALESCE(used_rounds,0) > 0`, id)
}

View File

@ -0,0 +1,173 @@
package repo
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"testing"
"github.com/agentmail/gateway/internal/db"
)
// setupTestDB 起一个临时 SQLite 库并建表,供配额测试使用。
// 直接用真实的 SQLite 而非 mock配额的正确性核心在于「判断与自增在同一条 UPDATE 里」,
// 这正是只有真实数据库才能验证的部分。
func setupTestDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
if err := db.Connect(context.Background(), filepath.Join(dir, "test.db")); err != nil {
t.Fatalf("connect: %v", err)
}
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(db.Close)
}
func seedAgent(t *testing.T, name string, max int) {
t.Helper()
_, err := db.DB.ExecContext(context.Background(),
`INSERT INTO agents (agent_name, secret, platform, max_rounds) VALUES ($1, 'x', 'test', $2)`,
name, max)
if err != nil {
t.Fatalf("seed agent: %v", err)
}
}
func TestConsumeQuotaCountsDown(t *testing.T) {
setupTestDB(t)
seedAgent(t, "bot", 3)
ctx := context.Background()
for i := 1; i <= 3; i++ {
q, err := ConsumeQuota(ctx, "bot")
if err != nil {
t.Fatalf("第 %d 次占用失败: %v", i, err)
}
if q.Used != i || q.Remaining != 3-i {
t.Errorf("第 %d 次: used=%d remaining=%d, want used=%d remaining=%d",
i, q.Used, q.Remaining, i, 3-i)
}
}
q, err := ConsumeQuota(ctx, "bot")
if !errors.Is(err, ErrQuotaExhausted) {
t.Fatalf("第 4 次应耗尽,得到 err=%v", err)
}
// 耗尽时仍要给出快照,调用方才能在错误文案里写清 used/max
if q.Used != 3 || q.Max != 3 {
t.Errorf("耗尽时快照不对: %+v", q)
}
}
func TestConsumeQuotaUnlimited(t *testing.T) {
setupTestDB(t)
seedAgent(t, "free", 0) // 0 = 不限
ctx := context.Background()
for i := 0; i < 5; i++ {
q, err := ConsumeQuota(ctx, "free")
if err != nil {
t.Fatalf("不限额时不应失败: %v", err)
}
if !q.Unlimited || q.Remaining != -1 {
t.Errorf("不限额快照不对: %+v", q)
}
}
}
// 配额的核心保证:并发发信不能把额度刷穿。
// 判断与自增若分成两步(先 SELECT 再 UPDATE并发下两个请求会双双通过检查。
func TestConsumeQuotaConcurrentDoesNotOverdraw(t *testing.T) {
setupTestDB(t)
const limit = 10
seedAgent(t, "racer", limit)
ctx := context.Background()
const attempts = 40
var (
wg sync.WaitGroup
mu sync.Mutex
granted int
)
for i := 0; i < attempts; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := ConsumeQuota(ctx, "racer"); err == nil {
mu.Lock()
granted++
mu.Unlock()
}
}()
}
wg.Wait()
if granted != limit {
t.Errorf("并发 %d 次请求在上限 %d 下放行了 %d 次", attempts, limit, granted)
}
q, err := GetQuota(ctx, "racer")
if err != nil {
t.Fatal(err)
}
if q.Used != limit {
t.Errorf("used_rounds = %d应恰好等于上限 %d未刷穿也未少记", q.Used, limit)
}
}
func TestSetAndResetQuota(t *testing.T) {
setupTestDB(t)
seedAgent(t, "bot", 2)
ctx := context.Background()
if _, err := ConsumeQuota(ctx, "bot"); err != nil {
t.Fatal(err)
}
q, err := SetQuota(ctx, "bot", 5)
if err != nil {
t.Fatal(err)
}
// 调上限不应清掉已用次数
if q.Max != 5 || q.Used != 1 || q.Remaining != 4 {
t.Errorf("SetQuota 后 %+vwant max=5 used=1 remaining=4", q)
}
q, err = ResetQuota(ctx, "bot")
if err != nil {
t.Fatal(err)
}
if q.Used != 0 || q.Remaining != 5 {
t.Errorf("ResetQuota 后 %+vwant used=0 remaining=5", q)
}
// 负数上限归一为 0不限而不是造出一个永远发不出信的 Agent
if q, err = SetQuota(ctx, "bot", -3); err != nil {
t.Fatal(err)
} else if !q.Unlimited {
t.Errorf("负数上限应视为不限,得到 %+v", q)
}
}
func TestQuotaUnknownAgent(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if _, err := GetQuota(ctx, "ghost"); err == nil {
t.Error("不存在的 Agent 应报错")
}
if _, err := ConsumeQuota(ctx, "ghost"); err == nil {
t.Error("不存在的 Agent 占用配额应报错")
}
if errors.Is(func() error { _, e := ConsumeQuota(ctx, "ghost"); return e }(), ErrQuotaExhausted) {
t.Error("不存在的 Agent 不该被报成「配额耗尽」")
}
}
func TestMain(m *testing.M) {
os.Exit(m.Run())
}

View File

@ -0,0 +1,87 @@
package repo
import (
"context"
"errors"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
// ---------- 插件自动转发(免配额通道) ----------
//
// **基本原则:配额约束的是模型的自主发信,不是 harness 的转发。**
//
// 配额存在的意义是防止 Agent 无限自我循环。而插件代劳搬运的两类消息不属于此列:
// 1. 平台原生的权限询问opencode 的 permission.updated—— 不转给人,人就看不到,
// Agent 卡在那里等一个永远不会来的回答
// 2. 本轮的最终总结session.idle 时最后一条 assistant 消息)—— 模型已经把话说完了,
// 插件只是把它搬到邮件里;对它收费会导致配额用尽时 Agent 连交代都做不了
//
// 防滥用不靠计数,靠**幂等键**relay_key 是上游那条消息的稳定标识
// permission id / assistant message id。唯一约束让同一条上游消息只能转一次
// 于是插件重试与 SSE 重放不会产生第二封,想多转就得拿出不同的上游消息 id ——
// 而那些 id 由平台生成,模型伪造不出来。
// ErrRelayDuplicate 表示这条上游消息已经转发过了。
var ErrRelayDuplicate = errors.New("relay already recorded")
// ClaimRelay 占用一次免配额转发名额。
//
// 判断与占用在同一条 INSERT 里(靠主键唯一约束),并发重试下只有一个能成功 ——
// 分成「先查有没有、再插入」两步的话,插件的两次重试会双双通过检查各插一条。
//
// 返回 ErrRelayDuplicate 表示重复,调用方应当据此跳过发信而不是报错:
// 重复转发是插件重试的正常结果,不是故障。
func ClaimRelay(ctx context.Context, agentName, relayKey, kind string) error {
_, err := db.DB.ExecContext(ctx,
`INSERT INTO relayed_mails (agent_name, relay_key, kind) VALUES ($1, $2, $3)`,
agentName, relayKey, kind)
if err != nil {
if db.IsUniqueViolation(err) {
return ErrRelayDuplicate
}
return err
}
return nil
}
// BindRelayMail 把已占用的名额关联到真正发出的邮件,便于事后审计
// 「这封免配额的信是从哪条上游消息来的」。
//
// 关联失败不该让发信失败:邮件已经入库,缺一条审计关联不影响功能。
func BindRelayMail(ctx context.Context, agentName, relayKey string, mailID uuid.UUID) error {
_, err := db.DB.ExecContext(ctx,
`UPDATE relayed_mails SET mail_id = $1 WHERE agent_name = $2 AND relay_key = $3`,
mailID, agentName, relayKey)
return err
}
// ReleaseRelay 撤销名额占用。
//
// 占用成功但发信失败时必须还回去,否则那条上游消息永远转不出来了 ——
// 幂等键会一直认为它已经转过。
func ReleaseRelay(ctx context.Context, agentName, relayKey string) error {
_, err := db.DB.ExecContext(ctx,
`DELETE FROM relayed_mails WHERE agent_name = $1 AND relay_key = $2 AND mail_id IS NULL`,
agentName, relayKey)
return err
}
// RelayKeyForMail 反查某封邮件对应的上游消息 id。
//
// 人类决策一条权限请求后,插件需要知道该回复 opencode 的哪个 permission ——
// 光有 AgentMail 的 mail_id 是不够的,两边的 id 空间不同。
// 插件重启后内存映射会丢,所以这个映射必须在服务端持久化。
//
// 无记录时返回空串(例如旧数据,或压根没走 relay 通道的请求)。
func RelayKeyForMail(ctx context.Context, mailID uuid.UUID) (string, string) {
var key, kind string
err := db.DB.QueryRowContext(ctx,
`SELECT relay_key, kind FROM relayed_mails WHERE mail_id = $1 LIMIT 1`,
mailID).Scan(&key, &kind)
if err != nil {
return "", ""
}
return key, kind
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,188 @@
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 }

View File

@ -0,0 +1,23 @@
package repo
import "testing"
func TestPreviewTruncatesOnUTF8Boundary(t *testing.T) {
// 「巡」是 3 字节;在 max=4 处切会切进第 2 个字符中间
s := "巡检报告"
got := preview(s, 4)
if got != "巡..." {
t.Fatalf("按 UTF-8 边界截断失败:%q", got)
}
for i, r := range got {
if r == 0xFFFD {
t.Fatalf("位置 %d 出现替换符,说明切在了字符中间", i)
}
}
}
func TestPreviewKeepsShortBodyIntact(t *testing.T) {
if got := preview("短正文", 240); got != "短正文" {
t.Fatalf("未超长却被改动:%q", got)
}
}

View File

@ -0,0 +1,512 @@
package repo
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
const (
bcryptCost = 12
sessionTTL = 7 * 24 * time.Hour
userSelectCols = `user_id, username, display_name, password_hash, role, status, created_at, last_login, allowed_agents, allowed_paths`
)
var (
ErrUserNotFound = errors.New("user not found")
ErrBadCredentials = errors.New("invalid username or password")
ErrUserDisabled = errors.New("user disabled")
ErrNameTaken = errors.New("name already taken by an agent or user")
ErrSessionInvalid = errors.New("session invalid or expired")
ErrInvalidUsername = errors.New("username must be 2-64 chars of [a-z0-9._-]")
ErrAlreadySetup = errors.New("system already initialized")
)
// ---------- 命名空间校验 ----------
// 三维地址的 name 位由人类用户与 Agent 共用,因此必须全局唯一
func nameTaken(ctx context.Context, name string) (bool, error) {
var n int
err := db.DB.QueryRowContext(ctx, `
SELECT (SELECT COUNT(*) FROM users WHERE username = $1)
+ (SELECT COUNT(*) FROM agents WHERE agent_name = $1)
`, name).Scan(&n)
return n > 0, err
}
// AgentNameAvailable 供 Agent 注册前校验(不与人类用户重名)
func AgentNameAvailable(ctx context.Context, agentName string) (bool, error) {
var n int
err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM users WHERE username = $1`, agentName).Scan(&n)
return n == 0, err
}
func validUsername(name string) bool {
if len(name) < 2 || len(name) > 64 {
return false
}
for _, r := range name {
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-'
if !ok {
return false
}
}
// 保留字human 是兼容别名,不能被真实用户占用
return name != "human"
}
// ---------- User CRUD ----------
func scanUser(row *sql.Row) (*models.User, error) {
var u models.User
var agentsJSON, pathsJSON []byte
err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash,
&u.Role, &u.Status, &u.CreatedAt, &u.LastLogin, &agentsJSON, &pathsJSON)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, err
}
u.AllowedAgents = decodeStrList(agentsJSON)
u.AllowedPaths = decodeStrList(pathsJSON)
return &u, nil
}
func decodeStrList(raw []byte) []string {
out := []string{}
if len(raw) > 0 {
_ = json.Unmarshal(raw, &out)
}
if out == nil {
out = []string{}
}
return out
}
func CreateUser(ctx context.Context, username, password, displayName, role string,
allowedAgents, allowedPaths []string) (*models.User, error) {
username = strings.ToLower(strings.TrimSpace(username))
if !validUsername(username) {
return nil, ErrInvalidUsername
}
if role != "admin" {
role = "user"
}
if displayName == "" {
displayName = username
}
taken, err := nameTaken(ctx, username)
if err != nil {
return nil, err
}
if taken {
return nil, ErrNameTaken
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return nil, err
}
agentsJSON, _ := json.Marshal(normalizeList(allowedAgents))
pathsJSON, _ := json.Marshal(normalizeList(allowedPaths))
row := db.DB.QueryRowContext(ctx, `
INSERT INTO users (username, display_name, password_hash, role, allowed_agents, allowed_paths)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING `+userSelectCols,
username, displayName, string(hash), role, agentsJSON, pathsJSON)
return scanUser(row)
}
func GetUserByName(ctx context.Context, username string) (*models.User, error) {
return scanUser(db.DB.QueryRowContext(ctx,
`SELECT `+userSelectCols+` FROM users WHERE username = $1`,
strings.ToLower(strings.TrimSpace(username))))
}
func GetUserByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
return scanUser(db.DB.QueryRowContext(ctx,
`SELECT `+userSelectCols+` FROM users WHERE user_id = $1`, id))
}
func ListUsers(ctx context.Context) ([]models.User, error) {
rows, err := db.DB.QueryContext(ctx,
`SELECT `+userSelectCols+` FROM users ORDER BY created_at ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
users := []models.User{}
for rows.Next() {
var u models.User
var agentsJSON, pathsJSON []byte
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash,
&u.Role, &u.Status, &u.CreatedAt, &u.LastLogin, &agentsJSON, &pathsJSON); err != nil {
return nil, err
}
u.AllowedAgents = decodeStrList(agentsJSON)
u.AllowedPaths = decodeStrList(pathsJSON)
users = append(users, u)
}
return users, nil
}
// UserUpdate 描述一次用户更新nil 字段表示不改
type UserUpdate struct {
DisplayName *string
Role *string
Status *string
AllowedAgents *[]string
AllowedPaths *[]string
}
func UpdateUser(ctx context.Context, id uuid.UUID, up UserUpdate) (*models.User, error) {
var agentsJSON, pathsJSON *string
if up.AllowedAgents != nil {
b, _ := json.Marshal(normalizeList(*up.AllowedAgents))
s := string(b)
agentsJSON = &s
}
if up.AllowedPaths != nil {
b, _ := json.Marshal(normalizeList(*up.AllowedPaths))
s := string(b)
pathsJSON = &s
}
row := db.DB.QueryRowContext(ctx, `
UPDATE users SET
display_name = COALESCE($2, display_name),
role = COALESCE($3, role),
status = COALESCE($4, status),
allowed_agents = COALESCE($5`+db.JSONCast()+`, allowed_agents),
allowed_paths = COALESCE($6`+db.JSONCast()+`, allowed_paths)
WHERE user_id = $1
RETURNING `+userSelectCols,
id, up.DisplayName, up.Role, up.Status, agentsJSON, pathsJSON)
return scanUser(row)
}
// normalizeList 去空白、去空项、去重,保持顺序
func normalizeList(in []string) []string {
out := make([]string, 0, len(in))
seen := map[string]bool{}
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
func SetPassword(ctx context.Context, id uuid.UUID, newPassword string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost)
if err != nil {
return err
}
tag, err := db.DB.ExecContext(ctx,
`UPDATE users SET password_hash = $2 WHERE user_id = $1`, id, string(hash))
if err != nil {
return err
}
if n, _ := tag.RowsAffected(); n == 0 {
return ErrUserNotFound
}
// 改密后踢掉该用户所有会话
_, _ = db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE user_id = $1`, id)
return nil
}
func DisableUser(ctx context.Context, id uuid.UUID) error {
tag, err := db.DB.ExecContext(ctx,
`UPDATE users SET status = 'disabled' WHERE user_id = $1`, id)
if err != nil {
return err
}
if n, _ := tag.RowsAffected(); n == 0 {
return ErrUserNotFound
}
_, _ = db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE user_id = $1`, id)
return nil
}
func CountAdmins(ctx context.Context) (int, error) {
var n int
err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM users WHERE role = 'admin' AND status = 'active'`).Scan(&n)
return n, err
}
// EnsureAdminUser 首次启动时创建默认管理员(幂等)
func EnsureAdminUser(ctx context.Context, username, password string) (*models.User, bool, error) {
if n, err := CountAdmins(ctx); err != nil {
return nil, false, err
} else if n > 0 {
u, err := GetUserByName(ctx, username)
if err != nil && !errors.Is(err, ErrUserNotFound) {
return nil, false, err
}
return u, false, nil
}
u, err := CreateUser(ctx, username, password, "管理员", "admin", nil, nil)
if err != nil {
return nil, false, err
}
return u, true, nil
}
// ---------- 登录 / 会话令牌 ----------
func Authenticate(ctx context.Context, username, password string) (*models.User, error) {
u, err := GetUserByName(ctx, username)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
// 统一错误,避免暴露用户是否存在
return nil, ErrBadCredentials
}
return nil, err
}
if u.Status != "active" {
return nil, ErrUserDisabled
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
return nil, ErrBadCredentials
}
_, _ = db.DB.ExecContext(ctx, `UPDATE users SET last_login = NOW() WHERE user_id = $1`, u.ID)
return u, nil
}
func newToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func CreateUserSession(ctx context.Context, userID uuid.UUID, userAgent string) (string, time.Time, error) {
token, err := newToken()
if err != nil {
return "", time.Time{}, err
}
expires := time.Now().Add(sessionTTL)
if len(userAgent) > 256 {
userAgent = userAgent[:256]
}
_, err = db.DB.ExecContext(ctx, `
INSERT INTO user_sessions (token, user_id, expires_at, user_agent)
VALUES ($1, $2, $3, $4)`, token, userID, expires, userAgent)
if err != nil {
return "", time.Time{}, err
}
// 顺手清理过期令牌
_, _ = db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE expires_at < NOW()`)
return token, expires, nil
}
// ResolveUserSession 校验令牌并滑动续期
func ResolveUserSession(ctx context.Context, token string) (*models.User, error) {
if token == "" {
return nil, ErrSessionInvalid
}
var userID uuid.UUID
err := db.DB.QueryRowContext(ctx, `
SELECT user_id FROM user_sessions
WHERE token = $1 AND expires_at > NOW()`, token).Scan(&userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrSessionInvalid
}
return nil, err
}
u, err := GetUserByID(ctx, userID)
if err != nil {
return nil, err
}
if u.Status != "active" {
return nil, ErrUserDisabled
}
_, _ = db.DB.ExecContext(ctx,
`UPDATE user_sessions SET expires_at = $2 WHERE token = $1`,
token, time.Now().Add(sessionTTL))
return u, nil
}
func DeleteUserSession(ctx context.Context, token string) error {
_, err := db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE token = $1`, token)
return err
}
// ---------- 人类用户候选(供地址补全) ----------
func ListActiveUsernames(ctx context.Context) ([]string, error) {
rows, err := db.DB.QueryContext(ctx,
`SELECT username FROM users WHERE status = 'active' ORDER BY username`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var s string
if err := rows.Scan(&s); err == nil {
out = append(out, s)
}
}
return out, nil
}
// ---------- 会话归属 ----------
func SetSessionOwner(ctx context.Context, sessionID, userID uuid.UUID) error {
_, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET owner_user_id = $2 WHERE session_id = $1 AND owner_user_id IS NULL`,
sessionID, userID)
return err
}
// SessionOwnerUsername 返回会话归属人类用户名;无归属时返回空串
func SessionOwnerUsername(ctx context.Context, sessionID uuid.UUID) (string, error) {
var name *string
err := db.DB.QueryRowContext(ctx, `
SELECT u.username
FROM sessions s LEFT JOIN users u ON u.user_id = s.owner_user_id
WHERE s.session_id = $1`, sessionID).Scan(&name)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("session %s not found", sessionID)
}
return "", err
}
if name == nil {
return "", nil
}
return *name, nil
}
// UserCanAccessSession 判断用户能否访问该会话owner、或在邮件收发/抄送中出现,或 admin
func UserCanAccessSession(ctx context.Context, u *models.User, sessionID uuid.UUID) (bool, error) {
if u.IsAdmin() {
return true, nil
}
var n int
err := db.DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM sessions s
WHERE s.session_id = $1
AND (s.owner_user_id = $2
OR EXISTS (
SELECT 1 FROM mails m
WHERE m.session_id = s.session_id
AND (m.from_name = $3 OR m.to_name = $3
OR `+db.CCHas("m.cc_list", 3)+`)
))
`, sessionID, u.ID, u.Username).Scan(&n)
return n > 0, err
}
// FirstAdminUsername 返回最早创建的可用管理员用户名(用于无归属会话的兜底决策人)
func FirstAdminUsername(ctx context.Context) (string, error) {
var name string
err := db.DB.QueryRowContext(ctx, `
SELECT username FROM users
WHERE role = 'admin' AND status = 'active'
ORDER BY created_at ASC LIMIT 1`).Scan(&name)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return "", err
}
return name, nil
}
// RandomPassword 生成一个随机初始密码(首次启动无 ADMIN_PASSWORD 时使用)
func RandomPassword(n int) string {
const charset = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "ChangeMe" + fmt.Sprint(time.Now().Unix())
}
for i := range b {
b[i] = charset[int(b[i])%len(charset)]
}
return string(b)
}
// ---------- Setup首次初始化管理员 ----------
// NeedsSetup 返回系统是否尚未初始化(没有任何用户)
func NeedsSetup(ctx context.Context) (bool, error) {
var n int
err := db.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
return n == 0, err
}
// SetupFirstAdmin 在系统尚无任何用户时创建首个管理员。
// 已初始化时返回 ErrAlreadySetup避免被用作后门。
func SetupFirstAdmin(ctx context.Context, username, password, displayName string) (*models.User, error) {
empty, err := NeedsSetup(ctx)
if err != nil {
return nil, err
}
if !empty {
return nil, ErrAlreadySetup
}
if displayName == "" {
displayName = username
}
return CreateUser(ctx, username, password, displayName, "admin", nil, nil)
}
// ---------- 可选目录候选(供权限设置界面) ----------
// AllWorkspaceNames 汇总所有 Agent 注册过的工作区名,供管理员挑选可访问目录
func AllWorkspaceNames(ctx context.Context) ([]string, error) {
rows, err := db.DB.QueryContext(ctx, `
SELECT DISTINCT ws->>'name' AS name
FROM agents, jsonb_array_elements(workspaces) AS ws
WHERE COALESCE(ws->>'name', '') <> ''
ORDER BY name`)
if err != nil {
return []string{}, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var s string
if err := rows.Scan(&s); err == nil {
out = append(out, s)
}
}
return out, nil
}
// IsHumanUser 判断某个三维地址 name 位是否为人类用户
func IsHumanUser(ctx context.Context, name string) (bool, error) {
var n int
err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM users WHERE username = $1`, name).Scan(&n)
return n > 0, err
}

View File

@ -0,0 +1,173 @@
package sse
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/google/uuid"
)
// Client 是一个 SSE 连接客户端
type Client struct {
ID string
AgentName string // 非空 = Agent 侧连接
UserName string // 非空 = 已登录人类用户的前端连接
Res http.ResponseWriter
Flusher http.Flusher
done chan struct{}
}
// Manager 管理所有 SSE 客户端连接
type Manager struct {
mu sync.RWMutex
clients map[string]*Client
}
var Default = &Manager{
clients: make(map[string]*Client),
}
// AddClient 注册一个新 SSE 客户端agentName 与 userName 二者恰其一)
func (m *Manager) AddClient(res http.ResponseWriter, agentName, userName string) *Client {
flusher, ok := res.(http.Flusher)
if !ok {
return nil
}
id := uuid.New().String()[:8]
client := &Client{
ID: id,
AgentName: agentName,
UserName: userName,
Res: res,
Flusher: flusher,
done: make(chan struct{}),
}
// 设置 SSE 响应头
res.Header().Set("Content-Type", "text/event-stream")
res.Header().Set("Cache-Control", "no-cache")
res.Header().Set("Connection", "keep-alive")
res.Header().Set("X-Accel-Buffering", "no")
m.mu.Lock()
m.clients[id] = client
m.mu.Unlock()
// 发送连接确认
client.Send("connected", map[string]string{"id": id})
// 启动心跳
go m.heartbeat(client)
fmt.Printf("[SSE] Client connected: %s (agent=%q user=%q)\n", id, agentName, userName)
return client
}
// RemoveClient 移除一个客户端
func (m *Manager) RemoveClient(id string) {
m.mu.Lock()
if c, ok := m.clients[id]; ok {
close(c.done)
delete(m.clients, id)
fmt.Printf("[SSE] Client disconnected: %s\n", id)
}
m.mu.Unlock()
}
// SendToAgent 向指定 Agent 名的所有客户端推送事件
func (m *Manager) SendToAgent(agentName, eventType string, data interface{}) {
if agentName == "" {
return
}
m.mu.RLock()
defer m.mu.RUnlock()
for _, c := range m.clients {
if c.AgentName == agentName {
c.Send(eventType, data)
}
}
}
// SendToUser 向指定人类用户的所有前端连接推送事件
func (m *Manager) SendToUser(userName, eventType string, data interface{}) {
if userName == "" {
return
}
m.mu.RLock()
defer m.mu.RUnlock()
for _, c := range m.clients {
if c.UserName == userName {
c.Send(eventType, data)
}
}
}
// SendToRecipient 根据收件人名同时尝试 Agent 通道与人类用户通道
// (三维地址的 name 位共享命名空间,投递时不必先判断对方是人还是 Agent
func (m *Manager) SendToRecipient(name, eventType string, data interface{}) {
if name == "" {
return
}
m.mu.RLock()
defer m.mu.RUnlock()
for _, c := range m.clients {
if c.AgentName == name || c.UserName == name {
c.Send(eventType, data)
}
}
}
// Broadcast 向所有客户端广播事件
func (m *Manager) Broadcast(eventType string, data interface{}) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, c := range m.clients {
c.Send(eventType, data)
}
}
// ClientCount 返回当前连接数
func (m *Manager) ClientCount() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.clients)
}
// Send 向单个客户端发送事件
func (c *Client) Send(eventType string, data interface{}) {
defer func() { recover() }() // 防止向已关闭的连接写入 panic
jsonData, err := json.Marshal(data)
if err != nil {
return
}
fmt.Fprintf(c.Res, "event: %s\ndata: %s\n\n", eventType, jsonData)
c.Flusher.Flush()
}
// heartbeat 定期发送心跳保活
func (m *Manager) heartbeat(client *Client) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-client.done:
return
case <-ticker.C:
// SSE 注释行作为心跳
defer func() { recover() }()
fmt.Fprintf(client.Res, ": heartbeat\n\n")
client.Flusher.Flush()
}
}
}

View File

@ -0,0 +1,29 @@
package static
import (
"embed"
"io/fs"
"net/http"
"sync"
)
//go:embed static
var staticFS embed.FS
var (
indexHTML []byte
once sync.Once
)
func GetIndex() []byte {
once.Do(func() {
data, _ := fs.ReadFile(staticFS, "static/index.html")
indexHTML = data
})
return indexHTML
}
func Handler() http.Handler {
sub, _ := fs.Sub(staticFS, "static")
return http.FileServer(http.FS(sub))
}