Files
MailUI4Agents/gateway/internal/repo/relay.go
JianFeeeee 0e754617a4 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 时转发本轮总结)
2026-09-02 10:29:26 +08:00

88 lines
3.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}