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,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
}