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:
142
gateway/internal/handler/agents.go
Normal file
142
gateway/internal/handler/agents.go
Normal 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),
|
||||
})
|
||||
}
|
||||
69
gateway/internal/handler/alias_test.go
Normal file
69
gateway/internal/handler/alias_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
368
gateway/internal/handler/attachments.go
Normal file
368
gateway/internal/handler/attachments.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
138
gateway/internal/handler/attachments_test.go
Normal file
138
gateway/internal/handler/attachments_test.go
Normal 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-8,filename= 给老客户端兜底。
|
||||
// 兜底值里的引号/反斜杠/非 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)
|
||||
}
|
||||
}
|
||||
414
gateway/internal/handler/auth.go
Normal file
414
gateway/internal/handler/auth.go
Normal 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
|
||||
}
|
||||
207
gateway/internal/handler/contacts.go
Normal file
207
gateway/internal/handler/contacts.go
Normal 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.session:Agent 侧会话归档 + 邮箱界面移除
|
||||
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
|
||||
}
|
||||
91
gateway/internal/handler/events.go
Normal file
91
gateway/internal/handler/events.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
264
gateway/internal/handler/forward.go
Normal file
264
gateway/internal/handler/forward.go
Normal 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})
|
||||
}
|
||||
77
gateway/internal/handler/forward_test.go
Normal file
77
gateway/internal/handler/forward_test.go
Normal 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("无抄送时不应出现「抄送」行")
|
||||
}
|
||||
}
|
||||
128
gateway/internal/handler/helpers.go
Normal file
128
gateway/internal/handler/helpers.go
Normal 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
|
||||
}
|
||||
179
gateway/internal/handler/keys.go
Normal file
179
gateway/internal/handler/keys.go
Normal 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"})
|
||||
}
|
||||
|
||||
421
gateway/internal/handler/mail.go
Normal file
421
gateway/internal/handler/mail.go
Normal 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"` // 上游消息 id;relay 非空时必填
|
||||
}
|
||||
|
||||
// resolveTarget 根据三维地址 name@path.session 决定投递的会话。
|
||||
//
|
||||
// session 位三态语义(设计文档):
|
||||
// - 省略(pi@root) → 投递到 name@path 的默认会话;从未通信则建立
|
||||
// - new(pi@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
|
||||
}
|
||||
285
gateway/internal/handler/me.go
Normal file
285
gateway/internal/handler/me.go
Normal 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 ""
|
||||
}
|
||||
279
gateway/internal/handler/permission.go
Normal file
279
gateway/internal/handler/permission.go
Normal 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 是上游那条权限询问的稳定 id(opencode 的 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
|
||||
}
|
||||
87
gateway/internal/handler/ratelimit.go
Normal file
87
gateway/internal/handler/ratelimit.go
Normal 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()
|
||||
}
|
||||
}()
|
||||
}
|
||||
85
gateway/internal/handler/relay_test.go
Normal file
85
gateway/internal/handler/relay_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
141
gateway/internal/handler/rename_proposal.go
Normal file
141
gateway/internal/handler/rename_proposal.go
Normal 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
|
||||
}
|
||||
143
gateway/internal/handler/rename_proposal_test.go
Normal file
143
gateway/internal/handler/rename_proposal_test.go
Normal 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("截断产生了替换符,说明切在多字节字符中间")
|
||||
}
|
||||
}
|
||||
}
|
||||
340
gateway/internal/handler/sessions.go
Normal file
340
gateway/internal/handler/sessions.go
Normal 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
|
||||
}
|
||||
// 允许调到低于已用次数:那表示「就到这里为止」,是人的合法意图。
|
||||
// 此时剩余为 0,Agent 下次发信即被拦。
|
||||
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)
|
||||
}
|
||||
209
gateway/internal/handler/thread.go
Normal file
209
gateway/internal/handler/thread.go
Normal 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
|
||||
}
|
||||
26
gateway/internal/handler/thread_test.go
Normal file
26
gateway/internal/handler/thread_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user