chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
454
server/internal/handler/agent_calendar.go
Normal file
454
server/internal/handler/agent_calendar.go
Normal file
@ -0,0 +1,454 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
// Agent 侧的日历能力。
|
||||
//
|
||||
// # 为什么 Agent 需要建日程
|
||||
//
|
||||
// 在这之前日历是纯人类功能:`/calendar/*` 全挂在 `middleware.UserAuth` 后面,
|
||||
// Agent 密钥一律 401。于是「明天九点提醒我看 CI 结果」这件事,Agent 只能
|
||||
// 在自己进程里 setTimeout —— 而它的进程随时会重启,定时器一并消失,
|
||||
// 那条提醒静默不见,没有任何地方留下痕迹。
|
||||
//
|
||||
// 把日程放进 Gateway 之后,它由数据库与调度器保证:插件重启、Agent 换机器、
|
||||
// 甚至换平台,提醒照样按时到达。
|
||||
//
|
||||
// # 与人类端点的三处差异
|
||||
//
|
||||
// 1. **只能看自己建的**(`ListCalendarEventsCreatedBy`)。别人的日程里
|
||||
// 可能有它无权知道的会议与地址。
|
||||
// 2. **只能改自己建的**。人建的提醒不该被 Agent 悄悄改时间或删掉 ——
|
||||
// 那等于让它绕过人的安排。
|
||||
// 3. **有速率与总量双重上限**。见下面 `guardAgentQuota` 的说明。
|
||||
//
|
||||
// # 为什么允许发给别人
|
||||
//
|
||||
// 「让 pi 提醒 dsh 明天交周报」是真实需求:跨 Agent 的任务交接本来就是
|
||||
// 这个平台的主题。收件人一律走完整三维寻址,与 send_mail 同一套解析,
|
||||
// 因此 Agent 能设的目标不会超出它本来就能发信的范围。
|
||||
//
|
||||
// **但不能设给人类**:见 `rejectHumanRecipients`。
|
||||
|
||||
// ─── 配额守卫 ───
|
||||
|
||||
// maxActiveEventsPerAgent 是单个 Agent 同时生效的事件总量上限。
|
||||
//
|
||||
// 为什么速率限制不够:`calendar:` 桶压住的是「一小时内建几条」,
|
||||
// 压不住「每小时建 19 条、连建一周」。而日历事件是**长效**的 ——
|
||||
// 一条每日重复提醒会一直发下去直到有人删它。攒下 300 条之后,
|
||||
// 即使 Agent 早已停止建新的,每天仍有 300 封提醒邮件涌出来。
|
||||
//
|
||||
// 50 条:正常用法下一个 Agent 手上的长期日程是个位数;
|
||||
// 撞到 50 说明它在无意义地攒任务,此时报错比继续接受更有用。
|
||||
const maxActiveEventsPerAgent = 50
|
||||
|
||||
// guardAgentQuota 检查速率与总量两道闸。
|
||||
//
|
||||
// 返回 false 时已经写好响应,调用方直接 return。
|
||||
// 第二个返回值是「本次已记账」,创建失败时调用方要 Release 归还。
|
||||
func guardAgentQuota(w http.ResponseWriter, r *http.Request, agentName string) (ok bool, charged bool) {
|
||||
// 总量先查:它不消耗速率名额,撞上限时不该顺手扣一次
|
||||
active, err := repo.CountActiveEventsBy(r.Context(), agentName)
|
||||
if err == nil && active >= maxActiveEventsPerAgent {
|
||||
Error(w, http.StatusTooManyRequests,
|
||||
"你当前已有 "+strconv.Itoa(active)+" 条生效中的日程(上限 "+strconv.Itoa(maxActiveEventsPerAgent)+
|
||||
")。请先删掉不需要的,或把多条合并成一条重复日程。")
|
||||
return false, false
|
||||
}
|
||||
|
||||
if allowed, retry := repo.AllowAgentCalendarEvent(r.Context(), agentName); !allowed {
|
||||
Error(w, http.StatusTooManyRequests,
|
||||
"建日程过于频繁(1 小时内已建 "+strconv.Itoa(repo.CalendarRateLimit())+" 条)。"+
|
||||
strconv.Itoa(retry)+" 秒后再试;如果只是想改时间,请用 PUT 改已有那条而不是新建。")
|
||||
return false, false
|
||||
}
|
||||
return true, true
|
||||
}
|
||||
|
||||
// rejectHumanRecipients 拒绝把人类放进收件人列表。
|
||||
//
|
||||
// 理由是**投递通道不对等**。Agent 之间的提醒是任务信号:收到就干活,
|
||||
// 干完回信,人不在环里也能推进。而发给人的提醒是打扰 —— 它会进人的收件箱、
|
||||
// 触发未读徽标,而人无法「回信让它停下」(提醒是日历实体,不是对话)。
|
||||
//
|
||||
// 一个 Agent 建一条「每 10 分钟提醒 jianf 检查进度」的日程,人就只能去
|
||||
// WebUI 里找出那条事件删掉。给 Agent 这个能力,收益(它其实可以直接发邮件)
|
||||
// 远小于代价。
|
||||
//
|
||||
// 人自己在界面上给自己设提醒不受此限 —— 那是人类端点的事。
|
||||
func rejectHumanRecipients(w http.ResponseWriter, r *http.Request, recipients []string) bool {
|
||||
for _, raw := range recipients {
|
||||
addr, err := models.ParseAddress(raw)
|
||||
if err != nil {
|
||||
continue // 地址合法性由 normalizeRecipients 负责报错
|
||||
}
|
||||
human, hErr := repo.IsHumanUser(r.Context(), addr.Name)
|
||||
if hErr != nil {
|
||||
// 查不动就放行:这道闸是防滥用,不该因为 DB 抖动而挡住正常请求
|
||||
continue
|
||||
}
|
||||
if human {
|
||||
Error(w, http.StatusForbidden,
|
||||
"不能把日程提醒设给人类用户("+addr.Name+")。"+
|
||||
"要通知人请直接 send_mail —— 那样他能回信,而定时提醒他只能去界面上删。")
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// requireOwnEvent 读出事件并确认它是本 Agent 建的。
|
||||
//
|
||||
// 返回 nil 时已写好响应。刻意对「不存在」与「不属于我」都回 404 ——
|
||||
// 回 403 会泄漏「这个 id 存在」,让 Agent 能枚举出别人有多少条日程。
|
||||
func requireOwnEvent(w http.ResponseWriter, r *http.Request, agentName string) *models.CalendarEvent {
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if id == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing event id")
|
||||
return nil
|
||||
}
|
||||
e, err := repo.GetCalendarEvent(r.Context(), id)
|
||||
if errors.Is(err, repo.ErrEventNotFound) {
|
||||
Error(w, http.StatusNotFound, "日程不存在")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "读取日程失败")
|
||||
return nil
|
||||
}
|
||||
if e.CreatedBy != agentName {
|
||||
Error(w, http.StatusNotFound, "日程不存在")
|
||||
return nil
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// ─── 端点 ───
|
||||
|
||||
// POST /api/v1/agent/calendar/events
|
||||
func AgentCreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ReminderText string `json:"reminder_text"`
|
||||
Recipients []string `json:"recipients"`
|
||||
DeliveryMode string `json:"delivery_mode"`
|
||||
EventTime time.Time `json:"event_time"`
|
||||
RemindBefore int `json:"remind_before"`
|
||||
Recurrence string `json:"recurrence"`
|
||||
RecurrenceEnd *time.Time `json:"recurrence_end"`
|
||||
}
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Title) == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing title")
|
||||
return
|
||||
}
|
||||
if req.EventTime.IsZero() {
|
||||
Error(w, http.StatusBadRequest, "Missing event_time(RFC3339,例如 2026-09-10T09:00:00+08:00)")
|
||||
return
|
||||
}
|
||||
if req.Recurrence == "" {
|
||||
req.Recurrence = models.RecurNone
|
||||
}
|
||||
if !validRecurrence(req.Recurrence) {
|
||||
Error(w, http.StatusBadRequest,
|
||||
"recurrence 必须是 none/daily/weekly/monthly/yearly/lunar_monthly/lunar_yearly 之一")
|
||||
return
|
||||
}
|
||||
|
||||
// 收件人默认是自己:「提醒我明天看 CI」是最常见的用法,
|
||||
// 每次都要求写出自己的名字只会让模型忘记然后拿到 400。
|
||||
recipients, badAddr := normalizeRecipients(req.Recipients)
|
||||
if badAddr != "" {
|
||||
Error(w, http.StatusBadRequest, "收件地址无法解析:"+badAddr)
|
||||
return
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
recipients = []string{agentName}
|
||||
}
|
||||
if !rejectHumanRecipients(w, r, recipients) {
|
||||
return
|
||||
}
|
||||
|
||||
// 一次性事件设在过去毫无意义:调度器下一轮就会立刻发出去,
|
||||
// 而模型的意图显然是「未来某时」。这几乎总是时区或年份写错。
|
||||
// 重复事件不拦:一条「每天 9 点」的规则从昨天开始是合理的写法,
|
||||
// AdvanceRecurrence 会把它推到下一个未来时刻。
|
||||
if req.Recurrence == models.RecurNone && req.EventTime.Before(time.Now()) {
|
||||
Error(w, http.StatusBadRequest,
|
||||
"event_time 在过去("+req.EventTime.Format(time.RFC3339)+
|
||||
")。一次性日程会立刻触发 —— 请检查时区与年份是否写对。")
|
||||
return
|
||||
}
|
||||
|
||||
okQuota, charged := guardAgentQuota(w, r, agentName)
|
||||
if !okQuota {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.ReminderText) == "" {
|
||||
req.ReminderText = defaultReminderTemplate
|
||||
}
|
||||
if req.DeliveryMode == "" {
|
||||
req.DeliveryMode = models.DeliverSeparate
|
||||
}
|
||||
|
||||
e := &models.CalendarEvent{
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
Description: req.Description,
|
||||
ReminderText: req.ReminderText,
|
||||
Recipients: recipients,
|
||||
DeliveryMode: req.DeliveryMode,
|
||||
ToAddress: recipients[0],
|
||||
EventTime: req.EventTime,
|
||||
RemindBefore: req.RemindBefore,
|
||||
Recurrence: req.Recurrence,
|
||||
RecurrenceEnd: req.RecurrenceEnd,
|
||||
Status: "active",
|
||||
// created_by 记 Agent 名。它与 users.username 共用命名空间,
|
||||
// 因此不会与人类创建者混淆。
|
||||
CreatedBy: agentName,
|
||||
}
|
||||
out, err := repo.CreateCalendarEvent(r.Context(), e)
|
||||
if err != nil {
|
||||
if charged {
|
||||
// 那次创建实际没有发生,名额还回去
|
||||
repo.ReleaseAgentCalendarEvent(r.Context(), agentName)
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "创建日程失败")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// GET /api/v1/agent/calendar/events
|
||||
//
|
||||
// 只返回本 Agent 建的事件。默认区间是「现在往后 90 天」——
|
||||
// Agent 关心的是「接下来要发生什么」,不是翻历史。
|
||||
func AgentListCalendarEvents(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
from := time.Now().Add(-24 * time.Hour)
|
||||
to := time.Now().AddDate(0, 3, 0)
|
||||
if v := r.URL.Query().Get("from"); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
from = t
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("to"); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
to = t
|
||||
}
|
||||
}
|
||||
status := r.URL.Query().Get("status")
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
if status == "all" {
|
||||
status = "" // repo 里空串 = 不过滤
|
||||
}
|
||||
|
||||
events, err := repo.ListCalendarEventsCreatedBy(r.Context(), agentName, from, to, status)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "读取日程失败")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"events": events,
|
||||
// 把上限一并回传:模型看到 42/50 才知道该清理了,
|
||||
// 只在撞墙时才用报文告知等于让它一直蒙在鼓里。
|
||||
"active_limit": maxActiveEventsPerAgent,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/agent/calendar/events/{id}
|
||||
func AgentGetCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
e := requireOwnEvent(w, r, agentName)
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, e)
|
||||
}
|
||||
|
||||
// PUT /api/v1/agent/calendar/events/{id}
|
||||
//
|
||||
// 部分更新:省略的字段保持原值。
|
||||
//
|
||||
// 与人类端点(整体替换)不同,因为调用方是模型 —— 要求它每次都回传全部
|
||||
// 字段,漏一个就会把提醒正文或收件人清空,而那种破坏没有任何报错。
|
||||
func AgentUpdateCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
cur := requireOwnEvent(w, r, agentName)
|
||||
if cur == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 全部用指针:nil = 没传 = 保持原值。
|
||||
// 用值类型的话「传了空字符串想清空说明」与「没传」无法区分。
|
||||
var req struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
ReminderText *string `json:"reminder_text"`
|
||||
Recipients *[]string `json:"recipients"`
|
||||
DeliveryMode *string `json:"delivery_mode"`
|
||||
EventTime *time.Time `json:"event_time"`
|
||||
RemindBefore *int `json:"remind_before"`
|
||||
Recurrence *string `json:"recurrence"`
|
||||
RecurrenceEnd *time.Time `json:"recurrence_end"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
next := *cur
|
||||
if req.Title != nil {
|
||||
if strings.TrimSpace(*req.Title) == "" {
|
||||
Error(w, http.StatusBadRequest, "title 不能为空")
|
||||
return
|
||||
}
|
||||
next.Title = strings.TrimSpace(*req.Title)
|
||||
}
|
||||
if req.Description != nil {
|
||||
next.Description = *req.Description
|
||||
}
|
||||
if req.ReminderText != nil {
|
||||
next.ReminderText = *req.ReminderText
|
||||
if strings.TrimSpace(next.ReminderText) == "" {
|
||||
next.ReminderText = defaultReminderTemplate
|
||||
}
|
||||
}
|
||||
if req.Recipients != nil {
|
||||
recipients, badAddr := normalizeRecipients(*req.Recipients)
|
||||
if badAddr != "" {
|
||||
Error(w, http.StatusBadRequest, "收件地址无法解析:"+badAddr)
|
||||
return
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
Error(w, http.StatusBadRequest, "recipients 不能改成空 —— 那样日程永远不会提醒任何人")
|
||||
return
|
||||
}
|
||||
if !rejectHumanRecipients(w, r, recipients) {
|
||||
return
|
||||
}
|
||||
next.Recipients = recipients
|
||||
next.ToAddress = recipients[0]
|
||||
}
|
||||
if req.DeliveryMode != nil {
|
||||
next.DeliveryMode = *req.DeliveryMode
|
||||
}
|
||||
if req.EventTime != nil {
|
||||
if req.EventTime.IsZero() {
|
||||
Error(w, http.StatusBadRequest, "event_time 无效")
|
||||
return
|
||||
}
|
||||
next.EventTime = *req.EventTime
|
||||
}
|
||||
if req.RemindBefore != nil {
|
||||
if *req.RemindBefore < 0 {
|
||||
Error(w, http.StatusBadRequest, "remind_before 不能为负")
|
||||
return
|
||||
}
|
||||
next.RemindBefore = *req.RemindBefore
|
||||
}
|
||||
if req.Recurrence != nil {
|
||||
if !validRecurrence(*req.Recurrence) {
|
||||
Error(w, http.StatusBadRequest,
|
||||
"recurrence 必须是 none/daily/weekly/monthly/yearly/lunar_monthly/lunar_yearly 之一")
|
||||
return
|
||||
}
|
||||
next.Recurrence = *req.Recurrence
|
||||
}
|
||||
if req.RecurrenceEnd != nil {
|
||||
next.RecurrenceEnd = req.RecurrenceEnd
|
||||
}
|
||||
if req.Status != nil {
|
||||
switch *req.Status {
|
||||
case "active", "paused", "cancelled":
|
||||
next.Status = *req.Status
|
||||
default:
|
||||
Error(w, http.StatusBadRequest, "status 必须是 active/paused/cancelled")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 改了时间就允许重新触发。
|
||||
//
|
||||
// 不清 fired_for 的后果:把一条已触发的事件时间往后挪,
|
||||
// DueEvents 的判据 `fired_for <> event_time` 恰好又成立了 —— 这是对的;
|
||||
// 但把时间挪成**原值**(比如只改标题时前端回传了同一个时间)不该重发。
|
||||
// 因此这里不主动清,靠 occurrence 相等自然判断即可。
|
||||
if err := repo.UpdateCalendarEvent(r.Context(), next.EventID, &next); err != nil {
|
||||
if errors.Is(err, repo.ErrEventNotFound) {
|
||||
Error(w, http.StatusNotFound, "日程不存在")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "更新日程失败")
|
||||
return
|
||||
}
|
||||
out, err := repo.GetCalendarEvent(r.Context(), next.EventID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "更新成功但读回失败")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/agent/calendar/events/{id}
|
||||
func AgentDeleteCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
e := requireOwnEvent(w, r, agentName)
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
if err := repo.DeleteCalendarEvent(r.Context(), e.EventID); err != nil {
|
||||
if errors.Is(err, repo.ErrEventNotFound) {
|
||||
Error(w, http.StatusNotFound, "日程不存在")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "删除日程失败")
|
||||
return
|
||||
}
|
||||
// 附件随事件一起清:ON DELETE CASCADE 在 SQLite 下需要 foreign_keys=ON,
|
||||
// 而那个 pragma 默认是关的,不显式删会留下孤儿记录。
|
||||
_ = repo.DeleteCalendarAttachments(r.Context(), e.EventID)
|
||||
JSON(w, http.StatusOK, map[string]any{"status": "deleted", "event_id": e.EventID})
|
||||
}
|
||||
330
server/internal/handler/agent_discovery.go
Normal file
330
server/internal/handler/agent_discovery.go
Normal file
@ -0,0 +1,330 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Agent 侧的寻址发现与线索读取。
|
||||
//
|
||||
// # 为什么需要这一组端点
|
||||
//
|
||||
// 在这之前,Agent 能读的只有自己的收件箱。`/agents`、`/contacts`、
|
||||
// `/contacts/suggest`、`/mail/{id}/thread`、`/sessions/{id}` 全部挂在
|
||||
// `middleware.UserAuth` 后面,Agent 密钥一律 401。后果是 `send_mail` 的 `to`
|
||||
// 成了一个**只能靠记忆拼写的自由文本字段**:
|
||||
//
|
||||
// - 想回给抄送方,只能从收件箱渲染出的 `抄送: opencode@/home.new` 里抄一段,
|
||||
// 而 `.new` 是一次性的,抄过去只会再建一条会话;
|
||||
// - 想知道对方接受哪个工作目录,无从查询,只能猜。生产上真实发生过一次:
|
||||
// dsh 猜了 `opencode@/home`,地址解析通过、投递成功,但 `/home` 不是
|
||||
// opencode 的工作目录 —— **猜错比报错更糟,它会静默变成新会话的 workspace**。
|
||||
//
|
||||
// 人类侧从来没有这个问题:`AddressInput` 三段式逐段查 `/contacts/suggest`,
|
||||
// name / path / session 每一段都从活数据里选。这一组端点就是把同一份能力
|
||||
// 给 Agent。
|
||||
//
|
||||
// # 为什么不直接给 Agent 复用人类那几条路由
|
||||
//
|
||||
// 两条理由:
|
||||
//
|
||||
// 1. **作用域不同。** 人类侧 `ListContactsFor(scope=username)` 的 scope 是
|
||||
// 「我参与过的会话」,管理员还能 `?all=true` 看全部。Agent 没有管理员概念,
|
||||
// 也不该看到自己没参与过的线索。把 AgentAuth 加进人类路由组,等于让
|
||||
// `middleware.GetUser` 返回 nil 的请求走进一堆假定 user 非空的 handler。
|
||||
// 2. **审计与演进。** Agent 能读什么是插件契约的一部分(PLUGIN-CONTRACT 的
|
||||
// 能力矩阵),独立成组才能在一处看全。
|
||||
//
|
||||
// # 一律只读
|
||||
//
|
||||
// 这里没有任何写端点。归档、改别名、决策权限都仍然只有人能做 ——
|
||||
// Agent 可以「看见并寻址」,但不能替人整理邮箱。
|
||||
|
||||
// GET /api/v1/agent/contacts
|
||||
//
|
||||
// 本 Agent 参与过的全部会话,每条给出可直接投递的 `address`。
|
||||
// 与人类侧 `/contacts` 同源(`repo.ListContactsFor`),scope 固定为自己。
|
||||
func AgentListContacts(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
archived := r.URL.Query().Get("archived") == "true"
|
||||
contacts, err := repo.ListContactsFor(r.Context(), agentName, archived)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list contacts")
|
||||
return
|
||||
}
|
||||
|
||||
// 联系人条目里的 agent_name 是「会话对面那个人」,但 ListContactsFor 取的是
|
||||
// 会话首封邮件的 to_name(人类侧视角:对面是 Agent)。Agent 自己调用时,
|
||||
// 首封邮件的 to_name 往往就是自己,对面反而是 from_name。
|
||||
// 因此这里补一个 peer 字段明确「该跟谁说话」,不改原字段以免动到前端。
|
||||
out := make([]map[string]any, 0, len(contacts))
|
||||
for _, c := range contacts {
|
||||
peer := c.AgentName
|
||||
if peer == agentName {
|
||||
peer = c.LastFrom
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"session_id": c.SessionID,
|
||||
"session_alias": c.SessionAlias,
|
||||
"subject": c.Subject,
|
||||
"path": c.Path,
|
||||
"status": c.Status,
|
||||
"mail_count": c.MailCount,
|
||||
"unread_count": c.UnreadCount,
|
||||
"last_activity": c.LastActivity,
|
||||
"last_from": c.LastFrom,
|
||||
"max_rounds": c.MaxRounds,
|
||||
"used_rounds": c.UsedRounds,
|
||||
// peer 是这条会话里可与之通信的另一方
|
||||
"peer": peer,
|
||||
// address 是投回这条会话的现成地址。别名为空的老会话给不出可寻址的
|
||||
// 形式,此时置空而不是拼一个 `.new` —— 那会开新线索而不是续谈。
|
||||
"address": addressForSession(peer, c.Path, c.SessionAlias),
|
||||
})
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"contacts": out})
|
||||
}
|
||||
|
||||
// addressForSession 拼「投回这条会话」的地址;无别名时返回空串。
|
||||
//
|
||||
// 刻意不退化成 `name@path`(默认会话):默认会话是「该 name@path 当前最活跃的
|
||||
// 那条」,与调用方想回的那条不一定是同一条。给一个看着能用其实指向别处的地址,
|
||||
// 比给空串危险。
|
||||
func addressForSession(name, path, alias string) string {
|
||||
if alias == "" {
|
||||
return ""
|
||||
}
|
||||
return models.FormatAddress(name, path, alias)
|
||||
}
|
||||
|
||||
// GET /api/v1/agent/contacts/suggest?name=&path=
|
||||
//
|
||||
// 三段式寻址补全,与人类侧 `/contacts/suggest` 同一套语义:
|
||||
//
|
||||
// 不带 name → 候选收件人名(在线 Agent + 活跃用户,去掉自己)
|
||||
// 带 name 不带 path → 该 name 用过的工作目录
|
||||
// name + path 都带 → 该 name@path 下可续谈的会话别名,`new` 永远在最后
|
||||
//
|
||||
// **这是「精准发信」的关键一环**:模型不再拼地址,而是逐段选。
|
||||
func AgentSuggestAddress(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(r.URL.Query().Get("name"))
|
||||
path := strings.TrimSpace(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 {
|
||||
if a.Name == agentName {
|
||||
continue // 不建议给自己发信
|
||||
}
|
||||
names = append(names, a.Name)
|
||||
}
|
||||
names = append(names, users...)
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"kind": "name",
|
||||
"suggestions": emptySlice(names),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
paths, _ := repo.SuggestPaths(r.Context(), name)
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"kind": "path",
|
||||
"suggestions": emptySlice(paths),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 可见性传自己的名字:只提示自己参与过的会话。
|
||||
// 传空会把别人的私下线索也列出来,那是越权。
|
||||
sessions, err := repo.SuggestSessionCandidates(r.Context(), agentName, name, path)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to suggest sessions")
|
||||
return
|
||||
}
|
||||
|
||||
aliases := make([]string, 0, len(sessions)+1)
|
||||
addresses := make([]string, 0, len(sessions)+1)
|
||||
for _, c := range sessions {
|
||||
aliases = append(aliases, c.Alias)
|
||||
addresses = append(addresses, models.FormatAddress(name, path, c.Alias))
|
||||
}
|
||||
// new 总在最后:它不是一条已存在的会话。排在前面会让模型在想续谈时
|
||||
// 顺手开出一条新线索 —— 生产上已经发生过。
|
||||
aliases = append(aliases, "new")
|
||||
addresses = append(addresses, models.FormatAddress(name, path, "new"))
|
||||
sessions = append(sessions, repo.SessionCandidate{
|
||||
Alias: "new", Source: "new", Title: "新建会话",
|
||||
})
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"kind": "session",
|
||||
"suggestions": emptySlice(aliases),
|
||||
// addresses 与 suggestions 同序,可直接塞进 send_mail 的 to
|
||||
"addresses": emptySlice(addresses),
|
||||
"candidates": emptySlice(sessions),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/agent/mail/{id}/thread
|
||||
//
|
||||
// 与人类侧 `/mail/{id}/thread` 同一份实现,可见性判据换成
|
||||
// 「本 Agent 参与过该会话」。抄送协作要靠它回答「谁已经回了、谁还没回」。
|
||||
func AgentGetMailThread(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
serveMailThread(w, r, func(sid uuid.UUID) (bool, error) {
|
||||
return repo.AgentCanAccessSession(r.Context(), agentName, sid)
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/agent/mail/{id}
|
||||
//
|
||||
// 读单封邮件全文(含抄送清单与附件)。收件箱只给摘要,
|
||||
// 而要回给抄送方就必须先看清这封信到底发给了谁。
|
||||
func AgentGetMail(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
|
||||
}
|
||||
|
||||
mail, err := repo.GetMailByID(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Mail not found")
|
||||
return
|
||||
}
|
||||
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, mail.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该邮件")
|
||||
return
|
||||
}
|
||||
|
||||
fillAttachments(r, mail)
|
||||
|
||||
alias := repo.SessionAliasOf(r.Context(), mail.SessionID)
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"mail": mail,
|
||||
"session_alias": alias,
|
||||
// 回信地址与「我这个身份」都给现成的,省得插件自己拼。
|
||||
// mail.ToWorkspace 是收件方那个地址的 path 位。
|
||||
"reply_address": models.FormatAddress(mail.FromName, "", alias),
|
||||
"self_address": models.FormatAddress(agentName, mail.ToWorkspace, alias),
|
||||
"participants": participantsOf(mail, alias),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/agent/sessions/{id}/participants
|
||||
//
|
||||
// 列出该会话的全部参与方及各自的可投递地址。
|
||||
//
|
||||
// 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、以及**用什么地址找到他**。
|
||||
// 逐封邮件扫收件人与抄送,因为参与方是随往来变化的(一封转发就多一个人)。
|
||||
func AgentSessionParticipants(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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
parts, err := repo.SessionParticipants(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list participants")
|
||||
return
|
||||
}
|
||||
|
||||
alias := repo.SessionAliasOf(r.Context(), sessionID)
|
||||
out := make([]map[string]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
out = append(out, map[string]any{
|
||||
"name": p.Name,
|
||||
"path": p.Path,
|
||||
"roles": p.Roles, // from / to / cc 的并集
|
||||
"is_self": p.Name == agentName,
|
||||
"mail_count": p.MailCount,
|
||||
// address 用**该参与方自己的 path**,不是调用方的:
|
||||
// 抄送给 opencode@/a 与主发给 dsh@/b 是两个工作区,
|
||||
// 用错 path 会让对方在别人的目录里开会话。
|
||||
"address": addressForSession(p.Name, p.Path, alias),
|
||||
})
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"session_id": sessionID,
|
||||
"session_alias": alias,
|
||||
"participants": out,
|
||||
})
|
||||
}
|
||||
|
||||
// participantsOf 从单封邮件里摘出参与方地址,供 AgentGetMail 直接返回。
|
||||
// 与 SessionParticipants 的区别:这里只看这一封(发件人 + 收件人 + 抄送),
|
||||
// 用于「回这封信时该带上谁」;那里看整条会话。
|
||||
func participantsOf(m *models.Mail, alias string) []map[string]any {
|
||||
out := []map[string]any{}
|
||||
add := func(role, name, path string) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"role": role,
|
||||
"name": name,
|
||||
"path": path,
|
||||
"address": addressForSession(name, path, alias),
|
||||
})
|
||||
}
|
||||
// from_workspace 对 Agent 存的是 Agent 名而非路径(历史遗留),
|
||||
// 拿它当 path 会拼出错地址,所以发件人一侧留空 path 走默认。
|
||||
add("from", m.FromName, "")
|
||||
add("to", m.ToName, m.ToWorkspace)
|
||||
for _, c := range m.CCList {
|
||||
add("cc", c.Name, c.Path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
359
server/internal/handler/agents.go
Normal file
359
server/internal/handler/agents.go
Normal file
@ -0,0 +1,359 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ---------- Agent ----------
|
||||
|
||||
type registerRequest struct {
|
||||
Name string `json:"name"`
|
||||
Secret string `json:"secret"`
|
||||
Workspaces []models.Workspace `json:"workspaces"`
|
||||
Platform string `json:"platform"`
|
||||
}
|
||||
|
||||
// heartbeatRequest 是心跳可选带的上报体。
|
||||
//
|
||||
// 字段全可省:旧插件发空心跳,不能因为新增了上报就把它们报错。
|
||||
type heartbeatRequest struct {
|
||||
// PlatformSessions 是平台侧当前的会话快照(按最近活跃排序)。
|
||||
//
|
||||
// 为什么让插件上报而不是 Gateway 反向拉取:当前架构是单向的
|
||||
// (Agent 持密钥主动连 Gateway,Gateway 从不外呼)。反向拉取需要 Gateway
|
||||
// 保存各平台的地址与凭证,那是另一套信任模型。
|
||||
//
|
||||
// nil 与空数组语义不同:nil = 本次不上报(保留现有镜像),
|
||||
// 空数组 = 平台侧确实一条会话都没有(清空镜像)。
|
||||
// 拿不到会话列表的插件应当省略该字段,而不是传空数组把镜像抹掉。
|
||||
PlatformSessions []repo.PlatformSession `json:"platform_sessions"`
|
||||
|
||||
// Models 是平台当前看得见的模型目录,供配置页勾选。
|
||||
//
|
||||
// 随心跳上报而不是只在注册时上报:模型清单会在运行中变
|
||||
// (换 provider 配置、上游上下线、换了 API key)。只在注册时报一次的话,
|
||||
// 目录会静静变陈,而管理员在配置页上看到的是上次重启时的快照 ——
|
||||
// 选中一个平台已经调不到的模型,失败要到真发邮件时才暴露。
|
||||
//
|
||||
// 与 PlatformSessions 同一约定:nil = 本次不上报(保留现有目录),
|
||||
// 空数组 = 平台确实一个模型都拿不到。拿不到目录时必须省略:
|
||||
// 清空目录会让配置页变成空白,管理员以为该平台没有任何可用模型。
|
||||
Models []repo.CatalogModel `json:"models"`
|
||||
|
||||
// ModeEnforcement 是插件自报的权限档位强制能力:native / advisory。
|
||||
//
|
||||
// 为什么走心跳而不是注册:能力会在运行中变。DSH 的沙箱模式被改成
|
||||
// danger-full-access 时,它就从 native 退化成了 advisory(实测:
|
||||
// approval:"never" 会在 waterfall 之前短路,approval/request 根本不触发)。
|
||||
// 只在注册时报一次的话,发件人看到的是上次重启时的能力快照。
|
||||
//
|
||||
// 与模型目录同一条通道(I-1:平台自己说的才算)。
|
||||
// 省略 = 本次不上报,保留现有值(与 PlatformSessions / Models 同约定)。
|
||||
ModeEnforcement string `json:"mode_enforcement"`
|
||||
}
|
||||
|
||||
// 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 !DecodeBody(w, r, &req) {
|
||||
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
|
||||
}
|
||||
|
||||
// 已退役的名字不可重建 —— 历史邮件的署名由此不会被冒用
|
||||
if retired, err := repo.IsRetiredAgentName(r.Context(), req.Name); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check agent name")
|
||||
return
|
||||
} else if retired {
|
||||
Error(w, http.StatusConflict, "该名字已退役,不可重建(历史邮件署名保护)")
|
||||
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 {
|
||||
// 已停用的 Agent 不得靠重新注册复活。回 403 而不是 500:
|
||||
// 这是一个明确的策略拒绝,插件应当停止重试并把原因打出来。
|
||||
if errors.Is(err, repo.ErrAgentDisabled) {
|
||||
Error(w, http.StatusForbidden,
|
||||
"Agent \""+req.Name+"\" 已被管理员停用,无法注册。"+
|
||||
"如需重新启用,请在管理页「默认预算」里恢复它。")
|
||||
return
|
||||
}
|
||||
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 判成离线。
|
||||
//
|
||||
// 但**未知字段必须回报**(resp["unknown_fields"]):这是全站唯一一处宽容
|
||||
// 解码的端点,若还静默忽略,插件把 `models` 拼成 `modles` 就永远没人知道 ——
|
||||
// 而那与 `attachments` vs `attachment_ids` 是同一种事故形状。
|
||||
var req heartbeatRequest
|
||||
var unknownFields []string
|
||||
if r.ContentLength > 0 {
|
||||
unknownFields, _ = DecodeLenient(r, &req)
|
||||
}
|
||||
syncedSessions := -1 // -1 = 本次未上报
|
||||
if req.PlatformSessions != nil {
|
||||
if err := repo.ReplacePlatformSessions(r.Context(), agentName, req.PlatformSessions); err != nil {
|
||||
// 镜像写失败只影响候选补全,不影响投递,因此不报错
|
||||
syncedSessions = -1
|
||||
} else {
|
||||
syncedSessions = len(req.PlatformSessions)
|
||||
}
|
||||
}
|
||||
|
||||
// 模型目录同理:写失败只让配置页看到的目录陈一轮,下一次心跳会补上。
|
||||
syncedModels := -1
|
||||
if req.Models != nil {
|
||||
if err := repo.ReplaceModelCatalog(r.Context(), agentName, req.Models); err == nil {
|
||||
syncedModels = len(req.Models)
|
||||
}
|
||||
}
|
||||
|
||||
// 档位强制能力:省略时不动(保留现有值)。
|
||||
// 写失败不影响心跳本身 —— 心跳的主职责是「我还活着」。
|
||||
if req.ModeEnforcement != "" {
|
||||
_ = repo.SetAgentModeEnforcement(r.Context(), agentName, req.ModeEnforcement)
|
||||
}
|
||||
|
||||
// 心跳回传该 Agent 的累计统计与新任务默认预算。
|
||||
//
|
||||
// 不再回传「剩余额度」:额度属于具体任务(会话)而不属于 Agent,
|
||||
// 剩余往返随每次发信响应(budget_remaining)回传,在那里才有意义。
|
||||
stats, sErr := repo.GetAgentStats(r.Context(), agentName)
|
||||
if sErr != nil {
|
||||
// 统计读不到不影响心跳本身
|
||||
stats = repo.AgentStats{AgentName: agentName}
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"status": "ok",
|
||||
"pending_mails": pending,
|
||||
"stats": stats,
|
||||
}
|
||||
if syncedSessions >= 0 {
|
||||
resp["platform_sessions_synced"] = syncedSessions
|
||||
}
|
||||
if syncedModels >= 0 {
|
||||
resp["models_synced"] = syncedModels
|
||||
}
|
||||
// 回传当前生效的模型范围,插件无需另起一个请求去读。
|
||||
//
|
||||
// 随心跳回传而不是让插件自己轮询:管理员在配置页改了范围后,
|
||||
// 插件最多一个心跳周期(30 秒)就能看到新值,不需要重启。
|
||||
if allowed, aErr := repo.ListAllowedModels(r.Context(), agentName); aErr == nil {
|
||||
resp["allowed_models"] = allowed
|
||||
resp["models_unrestricted"] = len(allowed) == 0
|
||||
}
|
||||
// 未知字段回报:只有真的出现时才带这一项,正常心跳的响应不多一个空数组。
|
||||
// 插件看到它就知道自己上报的某个字段服务端根本没收。
|
||||
if len(unknownFields) > 0 {
|
||||
resp["unknown_fields"] = unknownFields
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// 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),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 停用 / 恢复 ----------
|
||||
|
||||
type setAgentStatusRequest struct {
|
||||
// Disabled true = 停用,false = 恢复
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
// PUT /api/v1/admin/agents/{name}/status —— 停用或恢复一个 Agent
|
||||
//
|
||||
// 停用是可逆的「归档」,比 DELETE 轻一档:
|
||||
// - 邮件、会话、权限记录、转发幂等键全部保留(往来里有一半是人自己写的)
|
||||
// - 从地址补全、GET /agents、可授权范围里消失
|
||||
// - 全部密钥被撤销,插件拿不到新任务也发不出信
|
||||
// - 重新注册会被拒(否则插件下次启动就把它复活了)
|
||||
// - 别人发信给它得到 409(见 repo.RecipientDeliverable)
|
||||
//
|
||||
// 与 DELETE 的分工:停用留着运行态随时可恢复,删除清掉运行态且名字退役。
|
||||
func AdminSetAgentStatus(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 setAgentStatusRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
revoked, err := repo.SetAgentDisabled(r.Context(), name, req.Disabled)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
Error(w, http.StatusNotFound, "Agent 不存在: "+name)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to update agent status")
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"agent_name": name,
|
||||
"disabled": req.Disabled,
|
||||
}
|
||||
if req.Disabled {
|
||||
resp["keys_revoked"] = revoked
|
||||
resp["detail"] = "已停用。邮件与会话保留;该 Agent 的密钥已全部撤销," +
|
||||
"恢复后需要重新签发。停用期间别人发信给它会收到 409。"
|
||||
} else {
|
||||
resp["needs_new_key"] = true
|
||||
resp["detail"] = "已恢复为离线状态。停用时撤销的密钥不会自动回来 —— " +
|
||||
"必须在「密钥」面板重新签发一把并写进该插件的配置," +
|
||||
"否则它会一直拿旧密钥重试并被拒(401)。"
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/admin/agents/{name}
|
||||
//
|
||||
// 删除 Agent 的全部运行态,保留邮件历史。
|
||||
//
|
||||
// 取舍(见 PLUGIN-CONTRACT.md):
|
||||
// - 邮件与会话不删(是审计凭据,且往来里有一半是人自己写的)
|
||||
// - 它建的日历事件置 cancelled(留着会由调度器一直触发,发信人却已不存在)
|
||||
// - 名字立即不可重建(Agent 名与人类用户名共用命名空间,
|
||||
// 否则下一个同名注册者会看起来像是历史邮件的发信人)
|
||||
func AdminDeleteAgent(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimSpace(chi.URLParam(r, "name"))
|
||||
if name == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing agent name")
|
||||
return
|
||||
}
|
||||
// 人类账号不走这个端点。硬编码某个用户名会在换管理员时失效,
|
||||
// 所以按「是不是人类用户」判定。
|
||||
if isHuman, hErr := repo.IsHumanUser(r.Context(), name); hErr != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check recipient")
|
||||
return
|
||||
} else if isHuman {
|
||||
Error(w, http.StatusForbidden,
|
||||
"\""+name+"\" 是人类用户,不能用这个端点删除(请去用户管理)")
|
||||
return
|
||||
}
|
||||
|
||||
keysRevoked, err := repo.DeleteAgent(r.Context(), name)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
Error(w, http.StatusNotFound, "Agent 不存在: "+name)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to delete agent")
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"agent_name": name,
|
||||
"keys_revoked": keysRevoked,
|
||||
"detail": "已删除。邮件与会话保留(审计凭据);" +
|
||||
"密钥、平台会话镜像、模型范围已清除;它建的日历事件已置为取消。" +
|
||||
"此名字今后不可再注册(历史邮件的署名由此不会被冒用)。",
|
||||
})
|
||||
}
|
||||
69
server/internal/handler/alias_test.go
Normal file
69
server/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)
|
||||
}
|
||||
}
|
||||
}
|
||||
419
server/internal/handler/attachments.go
Normal file
419
server/internal/handler/attachments.go
Normal file
@ -0,0 +1,419 @@
|
||||
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
|
||||
}
|
||||
return writeAttachErr(w, repo.AttachToMail(r.Context(), mailID, ids, uploader))
|
||||
}
|
||||
|
||||
// checkAttachable 在**产生任何副作用之前**校验附件可不可挂。
|
||||
//
|
||||
// 返回 false 表示已写出错误响应,调用方应立即返回。
|
||||
//
|
||||
// # 为什么不能只靠 attachAll
|
||||
//
|
||||
// attachAll 在 CreateMail **之后**调用,于是附件不合法时请求返回 403/409,
|
||||
// 但那封邮件**已经入库、已经通知了收件人、已经扣掉了会话预算**。
|
||||
// 生产实测:两封探针邮件(一封 403「只能附加自己上传的附件」、一封 409
|
||||
// 「附件已随其他邮件发出」)都躺在 mails 表里,used_rounds 也涨了。
|
||||
// 发件方看到 4xx 会重试,收件方于是收到两封。
|
||||
//
|
||||
// 纯输入校验必须在副作用之前做完 —— 与「400 之后会话已建好」是同一个教训。
|
||||
//
|
||||
// 它**不取代** attachAll:两次调用之间仍有竞态窗口(另一个请求把同一个附件
|
||||
// 挂走了),那一次由 attachAll 的原子 UPDATE 拦下、并由调用方回滚。
|
||||
// 双层分工:这里挡住绝大多数(拼错 id、拿别人的附件、重复挂),
|
||||
// attachAll 挡住真正的并发。
|
||||
func checkAttachable(w http.ResponseWriter, r *http.Request, ids []uuid.UUID, uploader string) bool {
|
||||
if len(ids) == 0 {
|
||||
return true
|
||||
}
|
||||
return writeAttachErr(w, repo.EnsureAttachable(r.Context(), ids, uploader))
|
||||
}
|
||||
|
||||
// writeAttachErr 把 repo 层的附件错误映射成 HTTP 响应。
|
||||
//
|
||||
// checkAttachable 与 attachAll 共用一份:同一种错误在两条路径上必须给出同一个
|
||||
// 状态码与同一句话 —— 分开写早晚会分叉,而调用方无法区分自己碰上的是哪一层。
|
||||
func writeAttachErr(w http.ResponseWriter, err error) bool {
|
||||
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 给邮件填充附件列表(读取单封/线程时用)。
|
||||
// 读附件失败不该让整封邮件打不开,因此吞错只留空列表。
|
||||
//
|
||||
// 一批邮件走一次查询:逐封调 ListAttachmentsFor 是 N+1,
|
||||
// 一个 200 封的会话打开一次要打 200 次库。
|
||||
func fillAttachments(r *http.Request, mails ...*models.Mail) {
|
||||
ids := make([]uuid.UUID, 0, len(mails))
|
||||
for _, m := range mails {
|
||||
if m != nil {
|
||||
ids = append(ids, m.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
byMail, err := repo.ListAttachmentsForMails(r.Context(), ids)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, m := range mails {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
// 没有附件的邮件保持 nil:Attachments 带 omitempty,
|
||||
// 填空切片只会给每封邮件的 JSON 加一个 "attachments":[]
|
||||
if as := byMail[m.ID]; len(as) > 0 {
|
||||
m.Attachments = as
|
||||
}
|
||||
}
|
||||
}
|
||||
138
server/internal/handler/attachments_test.go
Normal file
138
server/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)
|
||||
}
|
||||
}
|
||||
408
server/internal/handler/auth.go
Normal file
408
server/internal/handler/auth.go
Normal file
@ -0,0 +1,408 @@
|
||||
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 !DecodeBody(w, r, &req) {
|
||||
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 !DecodeBody(w, r, &req) {
|
||||
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(r.Context(), 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(r.Context(), name)
|
||||
Error(w, http.StatusUnauthorized, "用户名或密码错误")
|
||||
case errors.Is(err, repo.ErrUserDisabled):
|
||||
Error(w, http.StatusForbidden, "账号已被禁用")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "登录失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
limiter.Reset(r.Context(), 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 !DecodeBody(w, r, &req) {
|
||||
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 !DecodeBody(w, r, &req) {
|
||||
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 !DecodeBody(w, r, &req) {
|
||||
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 !DecodeBody(w, r, &req) {
|
||||
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
|
||||
}
|
||||
806
server/internal/handler/calendar.go
Normal file
806
server/internal/handler/calendar.go
Normal file
@ -0,0 +1,806 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// defaultReminderTemplate 是提醒正文的默认模板。
|
||||
//
|
||||
// 存变量而非字面值:{title}/{time}/{description} 在触发时由
|
||||
// scheduler.RenderReminder 替换。与前端 CalendarEventEditor 的
|
||||
// DEFAULT_TEMPLATE 必须逐字一致 —— 前端用它作 placeholder 与预览,
|
||||
// 两边不同会让人看到的预览与 Agent 实收的正文不是一回事。
|
||||
const defaultReminderTemplate = "日程提醒:{title}\n时间:{time}\n{description}"
|
||||
|
||||
// ─── Calendar Events ───
|
||||
|
||||
// POST /api/v1/calendar/events
|
||||
func CreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ReminderText string `json:"reminder_text"`
|
||||
AgentName string `json:"agent_name"`
|
||||
ToAddress string `json:"to_address"`
|
||||
Recipients []string `json:"recipients"`
|
||||
DeliveryMode string `json:"delivery_mode"`
|
||||
EventTime time.Time `json:"event_time"`
|
||||
RemindBefore int `json:"remind_before"`
|
||||
Recurrence string `json:"recurrence"`
|
||||
RecurrenceEnd *time.Time `json:"recurrence_end"`
|
||||
// Status 在创建时存在只为与更新端点同形:前端的 CalendarEventInput 是
|
||||
// **一份**类型,新建与编辑发的是同一个对象。不接这个字段的后果在
|
||||
// 严格解码下是新建日程直接 400。
|
||||
//
|
||||
// 新建时它只能是 active(新建一个已取消的提醒没有意义),
|
||||
// 但传 paused/cancelled 也不报错 —— 照字面履行比推回去更有用。
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Title == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing title")
|
||||
return
|
||||
}
|
||||
if req.Status == "" {
|
||||
req.Status = models.EventActive
|
||||
}
|
||||
if !validEventStatus(req.Status) {
|
||||
Error(w, http.StatusBadRequest,
|
||||
"status 必须是 active/paused/cancelled 之一")
|
||||
return
|
||||
}
|
||||
if req.EventTime.IsZero() {
|
||||
Error(w, http.StatusBadRequest, "Missing event_time")
|
||||
return
|
||||
}
|
||||
if req.ReminderText == "" {
|
||||
// 默认模板必须存**变量形式**而不是把值烤进去。
|
||||
//
|
||||
// 原来这里 Sprintf 出一份含字面时间的正文。对重复事件是错的:
|
||||
// AdvanceRecurrence 只推进 event_time,reminder_text 保持不动 ——
|
||||
// 于是「每天 9 点」的提醒从第二天起永远写着第一天的日期,
|
||||
// Agent 收到的信里时间与实际触发时刻越差越远。
|
||||
//
|
||||
// 变量形式由 scheduler.RenderReminder 在**触发时**替换,
|
||||
// 每一次触发都拿当时的 event_time。前端的 DEFAULT_TEMPLATE
|
||||
// 也是这一份(client/electron/src/components/CalendarEventEditor.tsx),
|
||||
// 两处必须一致,否则预览与实发不符。
|
||||
req.ReminderText = defaultReminderTemplate
|
||||
}
|
||||
if req.Recurrence == "" {
|
||||
req.Recurrence = models.RecurNone
|
||||
}
|
||||
if !validRecurrence(req.Recurrence) {
|
||||
Error(w, http.StatusBadRequest,
|
||||
"recurrence 必须是 none/daily/weekly/monthly/lunar_monthly/lunar_yearly 之一")
|
||||
return
|
||||
}
|
||||
|
||||
recipients, badAddr := normalizeRecipients(req.Recipients)
|
||||
if badAddr != "" {
|
||||
// 地址在这里就校验而不是等到触发时:建事件时报错人能立刻改,
|
||||
// 而触发时报错只会进 journalctl —— 人以为提醒设好了,实际永远发不出去。
|
||||
Error(w, http.StatusBadRequest, "收件地址无法解析:"+badAddr)
|
||||
return
|
||||
}
|
||||
// 收件人一个都没有时事件永远发不出去,这不该静默通过
|
||||
if len(recipients) == 0 && strings.TrimSpace(req.ToAddress) == "" &&
|
||||
strings.TrimSpace(req.AgentName) == "" {
|
||||
Error(w, http.StatusBadRequest, "至少要有一个收件人(recipients / to_address / agent_name)")
|
||||
return
|
||||
}
|
||||
if req.DeliveryMode == "" {
|
||||
req.DeliveryMode = models.DeliverSeparate
|
||||
}
|
||||
|
||||
e := &models.CalendarEvent{
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
ReminderText: req.ReminderText,
|
||||
AgentName: req.AgentName,
|
||||
ToAddress: req.ToAddress,
|
||||
Recipients: recipients,
|
||||
DeliveryMode: req.DeliveryMode,
|
||||
EventTime: req.EventTime,
|
||||
RemindBefore: req.RemindBefore,
|
||||
Recurrence: req.Recurrence,
|
||||
RecurrenceEnd: req.RecurrenceEnd,
|
||||
Status: req.Status,
|
||||
CreatedBy: user.Username,
|
||||
}
|
||||
|
||||
if _, err := repo.CreateCalendarEvent(r.Context(), e); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create event")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, e)
|
||||
}
|
||||
|
||||
// GET /api/v1/calendar/events?from=...&to=...
|
||||
func ListCalendarEvents(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
fromStr := r.URL.Query().Get("from")
|
||||
toStr := r.URL.Query().Get("to")
|
||||
status := r.URL.Query().Get("status")
|
||||
|
||||
var from, to time.Time
|
||||
if fromStr != "" {
|
||||
from, _ = time.Parse(time.RFC3339, fromStr)
|
||||
}
|
||||
if toStr != "" {
|
||||
to, _ = time.Parse(time.RFC3339, toStr)
|
||||
}
|
||||
if to.IsZero() {
|
||||
to = time.Now().AddDate(0, 1, 0) // 默认往后一个月
|
||||
}
|
||||
if from.IsZero() {
|
||||
from = time.Now().AddDate(0, -1, 0) // 默认往前一个月
|
||||
}
|
||||
|
||||
events, err := repo.ListCalendarEvents(r.Context(), from, to, status)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list events")
|
||||
return
|
||||
}
|
||||
if events == nil {
|
||||
events = []models.CalendarEvent{}
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"events": events,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/calendar/events/{id}
|
||||
func GetCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := pathUUID(w, r, "id"); !ok {
|
||||
return
|
||||
}
|
||||
eventID := chi.URLParam(r, "id")
|
||||
e, err := repo.GetCalendarEvent(r.Context(), eventID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repo.ErrEventNotFound) {
|
||||
Error(w, http.StatusNotFound, "Event not found")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to get event")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, e)
|
||||
}
|
||||
|
||||
// PUT /api/v1/calendar/events/{id}
|
||||
func UpdateCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
eventID := chi.URLParam(r, "id")
|
||||
if _, ok := pathUUID(w, r, "id"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ReminderText string `json:"reminder_text"`
|
||||
AgentName string `json:"agent_name"`
|
||||
ToAddress string `json:"to_address"`
|
||||
Recipients []string `json:"recipients"`
|
||||
DeliveryMode string `json:"delivery_mode"`
|
||||
EventTime time.Time `json:"event_time"`
|
||||
RemindBefore int `json:"remind_before"`
|
||||
Recurrence string `json:"recurrence"`
|
||||
RecurrenceEnd *time.Time `json:"recurrence_end"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Recurrence != "" && !validRecurrence(req.Recurrence) {
|
||||
Error(w, http.StatusBadRequest,
|
||||
"recurrence 必须是 none/daily/weekly/monthly/lunar_monthly/lunar_yearly 之一")
|
||||
return
|
||||
}
|
||||
// status 直接写进库,所以必须先校验:一个拼错的值(比如 "pause")会变成
|
||||
// 调度器不认识的状态 —— DueEvents 只查 active,那条提醒于是静默失效,
|
||||
// 而界面下拉框里没有这个选项,人再也改不回来。
|
||||
if req.Status == "" {
|
||||
req.Status = models.EventActive
|
||||
}
|
||||
if !validEventStatus(req.Status) {
|
||||
Error(w, http.StatusBadRequest, "status 必须是 active/paused/cancelled 之一")
|
||||
return
|
||||
}
|
||||
recipients, badAddr := normalizeRecipients(req.Recipients)
|
||||
if badAddr != "" {
|
||||
Error(w, http.StatusBadRequest, "收件地址无法解析:"+badAddr)
|
||||
return
|
||||
}
|
||||
|
||||
e := &models.CalendarEvent{
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
ReminderText: req.ReminderText,
|
||||
AgentName: req.AgentName,
|
||||
ToAddress: req.ToAddress,
|
||||
Recipients: recipients,
|
||||
DeliveryMode: req.DeliveryMode,
|
||||
EventTime: req.EventTime,
|
||||
RemindBefore: req.RemindBefore,
|
||||
Recurrence: req.Recurrence,
|
||||
RecurrenceEnd: req.RecurrenceEnd,
|
||||
Status: req.Status,
|
||||
}
|
||||
if err := repo.UpdateCalendarEvent(r.Context(), eventID, e); err != nil {
|
||||
if errors.Is(err, repo.ErrEventNotFound) {
|
||||
Error(w, http.StatusNotFound, "Event not found")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to update event")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, e)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/calendar/events/{id}
|
||||
func DeleteCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
eventID := chi.URLParam(r, "id")
|
||||
if err := repo.DeleteCalendarEvent(r.Context(), eventID); err != nil {
|
||||
if errors.Is(err, repo.ErrEventNotFound) {
|
||||
Error(w, http.StatusNotFound, "Event not found")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to delete event")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// ─── Calendar Attachments ───
|
||||
|
||||
// POST /api/v1/calendar/events/{id}/attachments
|
||||
func UploadCalendarAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
if Blobs == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "附件存储未初始化")
|
||||
return
|
||||
}
|
||||
eventID := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if eventID == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing event id")
|
||||
return
|
||||
}
|
||||
// 事件必须存在:否则会攒下一堆孤儿附件记录,而 ON DELETE CASCADE
|
||||
// 永远清不掉它们(没有对应的父行可删)。
|
||||
if _, err := repo.GetCalendarEvent(r.Context(), eventID); err != nil {
|
||||
Error(w, http.StatusNotFound, "事件不存在")
|
||||
return
|
||||
}
|
||||
|
||||
max := config.C.MaxAttachmentBytes
|
||||
// 与邮件附件同一套双层限制:外层卡整个请求体(含 multipart 边界),
|
||||
// blob.Put 的 max 卡单个文件内容。少了外层,超大 multipart 头能拖死内存。
|
||||
r.Body = http.MaxBytesReader(w, r.Body, max+1<<20)
|
||||
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()
|
||||
|
||||
// 原来这里是 `data := make([]byte, header.Size); file.Read(data)` ——
|
||||
// 两处错:单次 Read 不保证填满缓冲(大文件必然短读,sha256 因此算的是
|
||||
// 半截内容),而且**文件内容从未落盘**,只往库里写了一条元数据。
|
||||
// 结果是附件"上传成功"、清单里看得见、发提醒时取不到任何字节。
|
||||
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
|
||||
}
|
||||
|
||||
att := &models.CalendarAttachment{
|
||||
EventID: eventID,
|
||||
Filename: sanitizeFilename(header.Filename),
|
||||
SizeBytes: size,
|
||||
SHA256: sum,
|
||||
}
|
||||
if err := repo.AddCalendarAttachment(r.Context(), att); err != nil {
|
||||
// 落盘成功但入库失败:孤立文件由 GC 回收,不影响正确性
|
||||
Error(w, http.StatusInternalServerError, "登记附件失败")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, att)
|
||||
}
|
||||
|
||||
// GET /api/v1/calendar/events/{id}/attachments
|
||||
func ListCalendarAttachments(w http.ResponseWriter, r *http.Request) {
|
||||
eventID := chi.URLParam(r, "id")
|
||||
atts, err := repo.ListCalendarAttachments(r.Context(), eventID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list attachments")
|
||||
return
|
||||
}
|
||||
if atts == nil {
|
||||
atts = []models.CalendarAttachment{}
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"attachments": atts})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/calendar/events/{id}/attachments/{aid}
|
||||
func DeleteCalendarAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
attID := strings.TrimSpace(chi.URLParam(r, "attachmentID"))
|
||||
if attID == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing attachment id")
|
||||
return
|
||||
}
|
||||
// 原来这里返回 501 并让人「删整个事件来清附件」—— 那要求人为了撤掉
|
||||
// 一个错传的文件把整条日程连提醒配置一起重建。
|
||||
//
|
||||
// 磁盘上的 blob 不在这里删:内容寻址下同一个 sha256 可能被别的附件
|
||||
// (甚至别的邮件)引用着,删文件会让那些引用一起坏掉。孤立 blob 归 GC。
|
||||
ok, err := repo.DeleteCalendarAttachment(r.Context(), attID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "删除附件失败")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
Error(w, http.StatusNotFound, "附件不存在")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"status": "deleted", "attachment_id": attID})
|
||||
}
|
||||
|
||||
// validRecurrence 白名单校验重复规则。
|
||||
//
|
||||
// 必须白名单而不是「未知值当 none」:把 `lunar_montly`(拼错)静默当成
|
||||
// 不重复,用户设的每月提醒只会响一次,而没有任何地方报错。
|
||||
// validEventStatus 校验日历事件状态(包装 models.ValidEventStatus,与
|
||||
// validRecurrence 保持同一种调用形状)。
|
||||
func validEventStatus(s string) bool {
|
||||
return models.ValidEventStatus(s)
|
||||
}
|
||||
|
||||
func validRecurrence(r string) bool {
|
||||
switch r {
|
||||
case models.RecurNone, models.RecurDaily, models.RecurWeekly, models.RecurMonthly,
|
||||
models.RecurYearly, models.RecurLunarMonthly, models.RecurLunarYearly:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeRecipients 清洗收件人列表:去空白、去重、校验地址可解析。
|
||||
//
|
||||
// 返回第二个值非空表示有地址解析失败(值即那个地址),调用方回 400。
|
||||
// 在建事件时校验而不是等触发:建事件时报错人能立刻改,
|
||||
// 触发时报错只会进 journalctl —— 人以为设好了,实际永远发不出去。
|
||||
//
|
||||
// 去重是必要的:together 模式下同一个 Agent 既是主收件人又在抄送里,
|
||||
// 会让它收到两条一模一样的 SSE,插件可能因此起两轮。
|
||||
func normalizeRecipients(in []string) ([]string, string) {
|
||||
seen := make(map[string]bool, len(in))
|
||||
out := make([]string, 0, len(in))
|
||||
for _, raw := range in {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := models.ParseAddress(raw); err != nil {
|
||||
return nil, raw
|
||||
}
|
||||
if seen[raw] {
|
||||
continue
|
||||
}
|
||||
seen[raw] = true
|
||||
out = append(out, raw)
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
|
||||
// ─── iCal 导入导出 ───
|
||||
|
||||
// GET /api/v1/calendar/export.ics
|
||||
func ExportCalendarICS(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
// 区间取自查询参数:前端导出的是「当前正在看的那段」,
|
||||
// 写死 ±1 年会让人点导出后得到一堆与屏幕上不符的事件。
|
||||
from := time.Now().AddDate(-1, 0, 0)
|
||||
to := time.Now().AddDate(1, 0, 0)
|
||||
if v := r.URL.Query().Get("from"); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
from = t
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("to"); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
to = t
|
||||
}
|
||||
}
|
||||
events, err := repo.ListCalendarEvents(r.Context(), from, to, "active")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list events")
|
||||
return
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("BEGIN:VCALENDAR\r\n")
|
||||
sb.WriteString("VERSION:2.0\r\n")
|
||||
sb.WriteString("PRODID:-//AgentMail//Calendar//EN\r\n")
|
||||
|
||||
for _, e := range events {
|
||||
sb.WriteString("BEGIN:VEVENT\r\n")
|
||||
fmt.Fprintf(&sb, "UID:%s@agentmail\r\n", e.EventID)
|
||||
fmt.Fprintf(&sb, "DTSTAMP:%s\r\n", e.EventTime.UTC().Format("20060102T150405Z"))
|
||||
fmt.Fprintf(&sb, "DTSTART:%s\r\n", e.EventTime.UTC().Format("20060102T150405Z"))
|
||||
// 默认 1 小时持续时间
|
||||
fmt.Fprintf(&sb, "DTEND:%s\r\n", e.EventTime.Add(time.Hour).UTC().Format("20060102T150405Z"))
|
||||
// 转义换行
|
||||
summary := strings.ReplaceAll(e.Title, "\n", "\\n")
|
||||
fmt.Fprintf(&sb, "SUMMARY:%s\r\n", summary)
|
||||
if e.Description != "" {
|
||||
desc := strings.ReplaceAll(e.Description, "\n", "\\n")
|
||||
fmt.Fprintf(&sb, "DESCRIPTION:%s\r\n", desc)
|
||||
}
|
||||
if e.Recurrence != "none" {
|
||||
var freq string
|
||||
switch e.Recurrence {
|
||||
case "daily":
|
||||
freq = "DAILY"
|
||||
case "weekly":
|
||||
freq = "WEEKLY"
|
||||
case "monthly":
|
||||
freq = "MONTHLY"
|
||||
case "yearly":
|
||||
freq = "YEARLY"
|
||||
}
|
||||
if freq != "" {
|
||||
fmt.Fprintf(&sb, "RRULE:FREQ=%s\r\n", freq)
|
||||
}
|
||||
// 农历规则 RFC 5545 表达不了(RRULE 只有公历频率)。
|
||||
//
|
||||
// 折中:用 X- 扩展属性记下真实规则,并把它降级成最接近的公历
|
||||
// 近似(lunar_monthly → MONTHLY、lunar_yearly → YEARLY)。
|
||||
// 别的客户端至少能看到一个大致对的重复;导回本系统时
|
||||
// X- 属性会把精确规则还原。
|
||||
//
|
||||
// 不写近似 RRULE 的后果更糟:外部客户端会把它当一次性事件,
|
||||
// 用户以为导出的日历里有「每年农历生日」,实际只有一条。
|
||||
if models.IsLunarRecurrence(e.Recurrence) {
|
||||
fmt.Fprintf(&sb, "X-AGENTMAIL-RECURRENCE:%s\r\n", e.Recurrence)
|
||||
if e.Recurrence == models.RecurLunarMonthly {
|
||||
sb.WriteString("RRULE:FREQ=MONTHLY\r\n")
|
||||
} else {
|
||||
sb.WriteString("RRULE:FREQ=YEARLY\r\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
// VALARM 的 TRIGGER 必须写成 `-PT<n>M`。
|
||||
//
|
||||
// 两个坑:iCal 的 duration 里 `M` **在 T 之前是月、在 T 之后才是分钟** ——
|
||||
// 原来写的 `-P15M` 在任何合规日历客户端里都是「提前 15 个月」。
|
||||
// 而且原来用 maxInt(RemindBefore, 15) 兜底,把用户明确设的
|
||||
// 「到点提醒」(0) 悄悄改成提前 15 分钟;导出不该修改语义。
|
||||
// 收件人与投递模式同样没有标准字段可放。
|
||||
// 不导出的后果:往返一圈后事件变成「没有收件人」,永远不会提醒。
|
||||
if rs := e.EffectiveRecipients(); len(rs) > 0 {
|
||||
fmt.Fprintf(&sb, "X-AGENTMAIL-RECIPIENTS:%s\r\n", strings.Join(rs, ","))
|
||||
fmt.Fprintf(&sb, "X-AGENTMAIL-DELIVERY:%s\r\n", e.EffectiveDeliveryMode())
|
||||
}
|
||||
fmt.Fprintf(&sb, "BEGIN:VALARM\r\n")
|
||||
fmt.Fprintf(&sb, "TRIGGER:-PT%dM\r\n", e.RemindBefore)
|
||||
fmt.Fprintf(&sb, "ACTION:DISPLAY\r\n")
|
||||
fmt.Fprintf(&sb, "DESCRIPTION:%s\r\n", summary)
|
||||
fmt.Fprintf(&sb, "END:VALARM\r\n")
|
||||
|
||||
sb.WriteString("END:VEVENT\r\n")
|
||||
}
|
||||
|
||||
sb.WriteString("END:VCALENDAR\r\n")
|
||||
|
||||
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="agentmail-calendar.ics"`)
|
||||
w.Write([]byte(sb.String()))
|
||||
}
|
||||
|
||||
// POST /api/v1/calendar/import.ics
|
||||
func ImportCalendarICS(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
// 两种上传形态都接受。
|
||||
//
|
||||
// multipart 是浏览器 <input type=file> 的天然形态;raw text/calendar 是
|
||||
// 脚本与 Agent 的天然形态(curl --data-binary @x.ics)。只支持前者会让
|
||||
// 命令行调用者收到含糊的「Missing file field」,只支持后者则要求前端
|
||||
// 先把文件读成字符串再发 —— 两边各让一步不如两边都收。
|
||||
var body []byte
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(ct, "multipart/") {
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Failed to parse multipart: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Missing file field")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
body, err = io.ReadAll(file)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Failed to read file")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
body, err = io.ReadAll(http.MaxBytesReader(w, r.Body, 10<<20))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Failed to read body")
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(body) == 0 {
|
||||
Error(w, http.StatusBadRequest, "Empty .ics payload")
|
||||
return
|
||||
}
|
||||
|
||||
events := parseICS(body)
|
||||
imported := 0
|
||||
for _, e := range events {
|
||||
e.CreatedBy = user.Username
|
||||
e.Status = "active"
|
||||
if _, err := repo.CreateCalendarEvent(r.Context(), &e); err == nil {
|
||||
imported++
|
||||
}
|
||||
}
|
||||
|
||||
// skipped 单独给出而不是让前端自己减:insert 失败(撞名、约束冲突)
|
||||
// 与「解析出来但没入库」是同一回事,前端只关心「有几个没进来」。
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"imported": imported,
|
||||
"skipped": len(events) - imported,
|
||||
"total": len(events),
|
||||
})
|
||||
}
|
||||
|
||||
// ─── iCal 解析 ───
|
||||
|
||||
func parseICS(data []byte) []models.CalendarEvent {
|
||||
var events []models.CalendarEvent
|
||||
var current *models.CalendarEvent
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, raw := range lines {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理折叠行(iCal 的续行以空格开头)
|
||||
if strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t") {
|
||||
if current != nil && len(events) > 0 {
|
||||
// 简单续行处理:追加到最后一个字段
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
colon := strings.Index(line, ":")
|
||||
if colon < 0 {
|
||||
continue
|
||||
}
|
||||
key := line[:colon]
|
||||
value := line[colon+1:]
|
||||
|
||||
// 去掉参数部分(如 DTSTART;TZID=...:value)
|
||||
if semi := strings.Index(key, ";"); semi >= 0 {
|
||||
key = key[:semi]
|
||||
}
|
||||
// 键名大小写不敏感(RFC 5545 §3.1)。X- 扩展属性尤其容易被
|
||||
// 其他客户端改写大小写,不归一化会让往返丢掉农历规则。
|
||||
key = strings.ToUpper(key)
|
||||
|
||||
switch key {
|
||||
case "BEGIN":
|
||||
if value == "VEVENT" {
|
||||
current = &models.CalendarEvent{Recurrence: "none"}
|
||||
}
|
||||
case "END":
|
||||
if value == "VEVENT" && current != nil {
|
||||
if !current.EventTime.IsZero() {
|
||||
events = append(events, *current)
|
||||
}
|
||||
current = nil
|
||||
}
|
||||
case "SUMMARY":
|
||||
if current != nil {
|
||||
current.Title = strings.ReplaceAll(value, "\\n", "\n")
|
||||
}
|
||||
case "DESCRIPTION":
|
||||
if current != nil {
|
||||
current.Description = strings.ReplaceAll(value, "\\n", "\n")
|
||||
}
|
||||
case "DTSTART":
|
||||
if current != nil {
|
||||
if t, err := time.Parse("20060102T150405Z", value); err == nil {
|
||||
current.EventTime = t
|
||||
} else if t, err := time.ParseInLocation("20060102T150405", value, time.Local); err == nil {
|
||||
current.EventTime = t
|
||||
} else if t, err := time.Parse("20060102", value); err == nil {
|
||||
current.EventTime = t
|
||||
}
|
||||
}
|
||||
case "RRULE":
|
||||
// **不覆盖已经从 X-AGENTMAIL-RECURRENCE 读到的农历规则。**
|
||||
//
|
||||
// 导出时农历事件同时写了 X- 精确值与一条公历近似 RRULE
|
||||
// (给别的客户端看)。X- 出现在 RRULE 之前时,
|
||||
// 若这里无条件赋值就会把精确的 lunar_monthly 打回 monthly
|
||||
// —— 往返一圈农历规则悄悄退化成公历,用户要过一个月才发现
|
||||
// 提醒日子不对。
|
||||
if current != nil && !models.IsLunarRecurrence(current.Recurrence) {
|
||||
v := strings.ToUpper(value)
|
||||
switch {
|
||||
case strings.Contains(v, "FREQ=DAILY"):
|
||||
current.Recurrence = models.RecurDaily
|
||||
case strings.Contains(v, "FREQ=WEEKLY"):
|
||||
current.Recurrence = models.RecurWeekly
|
||||
case strings.Contains(v, "FREQ=MONTHLY"):
|
||||
current.Recurrence = models.RecurMonthly
|
||||
case strings.Contains(v, "FREQ=YEARLY"):
|
||||
current.Recurrence = models.RecurYearly
|
||||
}
|
||||
}
|
||||
case "TRIGGER":
|
||||
if current != nil {
|
||||
if mins, ok := parseTriggerMinutes(value); ok {
|
||||
current.RemindBefore = mins
|
||||
}
|
||||
}
|
||||
case "X-AGENTMAIL-RECURRENCE":
|
||||
// 精确规则覆盖上面从 RRULE 猜出来的近似值。
|
||||
// 顺序无关:X- 属性只在值合法时才生效。
|
||||
if current != nil && validRecurrence(value) {
|
||||
current.Recurrence = value
|
||||
}
|
||||
case "X-AGENTMAIL-RECIPIENTS":
|
||||
if current != nil {
|
||||
list, bad := normalizeRecipients(strings.Split(value, ","))
|
||||
// 单个地址坏掉不该让整份导入失败:其余收件人仍有效。
|
||||
// 全坏时 list 为空,事件会在 Create 时被收件人校验拦下。
|
||||
if bad == "" {
|
||||
current.Recipients = list
|
||||
}
|
||||
}
|
||||
case "X-AGENTMAIL-DELIVERY":
|
||||
if current != nil && (value == models.DeliverSeparate || value == models.DeliverTogether) {
|
||||
current.DeliveryMode = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
// ─── 辅助 ───
|
||||
|
||||
func blobSha256(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return fmt.Sprintf("%x", sum[:])
|
||||
}
|
||||
|
||||
// parseTriggerMinutes 把 VALARM 的 TRIGGER duration 解析成「提前多少分钟」。
|
||||
//
|
||||
// 接受 `-PT30M` / `-PT1H` / `-PT1H30M` / `-P1D` / `-P1DT2H` 这些形态。
|
||||
// 关键规则:`M` 出现在 `T` **之后**才是分钟,之前是月 —— 按月的 trigger
|
||||
// 无法映射到 remind_before(那是个分钟数),直接忽略比乱换算好。
|
||||
//
|
||||
// 正号(事件之后提醒)也忽略:remind_before 语义上只能提前。
|
||||
// 返回 ok=false 表示「这条 TRIGGER 用不上」,调用方保持原值不动。
|
||||
func parseTriggerMinutes(v string) (int, bool) {
|
||||
v = strings.TrimSpace(strings.ToUpper(v))
|
||||
if !strings.HasPrefix(v, "-P") {
|
||||
return 0, false
|
||||
}
|
||||
rest := v[2:]
|
||||
|
||||
// 切成 T 前后两段:前面是日期部分(Y/M/W/D),后面是时间部分(H/M/S)
|
||||
datePart, timePart := rest, ""
|
||||
if i := strings.Index(rest, "T"); i >= 0 {
|
||||
datePart, timePart = rest[:i], rest[i+1:]
|
||||
}
|
||||
|
||||
total := 0
|
||||
// 日期部分只认 W/D。Y/M 是可变长度(月有 28~31 天),换算成分钟只能靠猜。
|
||||
if n, ok := durationField(datePart, 'W'); ok {
|
||||
total += n * 7 * 24 * 60
|
||||
}
|
||||
if n, ok := durationField(datePart, 'D'); ok {
|
||||
total += n * 24 * 60
|
||||
}
|
||||
if n, ok := durationField(timePart, 'H'); ok {
|
||||
total += n * 60
|
||||
}
|
||||
if n, ok := durationField(timePart, 'M'); ok {
|
||||
total += n
|
||||
}
|
||||
// 秒不进 remind_before:它的粒度是分钟,30 秒会被截成 0 而看不出区别
|
||||
if total <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return total, true
|
||||
}
|
||||
|
||||
// durationField 从 `1H30M` 这样的串里取出紧接在 unit 之前的整数。
|
||||
func durationField(s string, unit byte) (int, bool) {
|
||||
idx := strings.IndexByte(s, unit)
|
||||
if idx < 0 {
|
||||
return 0, false
|
||||
}
|
||||
start := idx
|
||||
for start > 0 && s[start-1] >= '0' && s[start-1] <= '9' {
|
||||
start--
|
||||
}
|
||||
if start == idx {
|
||||
return 0, false
|
||||
}
|
||||
n := 0
|
||||
for i := start; i < idx; i++ {
|
||||
n = n*10 + int(s[i]-'0')
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
221
server/internal/handler/contacts.go
Normal file
221
server/internal/handler/contacts.go
Normal file
@ -0,0 +1,221 @@
|
||||
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 !DecodeBody(w, r, &req) {
|
||||
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, err := repo.SuggestSessionCandidates(r.Context(), user.Username, name, path)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to suggest sessions")
|
||||
return
|
||||
}
|
||||
// suggestions 保留纯字符串形式:已部署的前端与第三方客户端只认这一个字段。
|
||||
// 带标题与来源的完整形式另放 candidates,两个字段同序。
|
||||
aliases := make([]string, 0, len(sessions)+1)
|
||||
for _, c := range sessions {
|
||||
aliases = append(aliases, c.Alias)
|
||||
}
|
||||
// new 总是可选且永远在最后:它不是一条已存在的会话,
|
||||
// 排在前面会让人在想续谈时随手回车开出一条新线索。
|
||||
aliases = append(aliases, "new")
|
||||
sessions = append(sessions, repo.SessionCandidate{Alias: "new", Source: "new", Title: "新建会话"})
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"kind": "session",
|
||||
"suggestions": aliases,
|
||||
"candidates": 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
|
||||
}
|
||||
138
server/internal/handler/decode_test.go
Normal file
138
server/internal/handler/decode_test.go
Normal file
@ -0,0 +1,138 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
)
|
||||
|
||||
// 400 的信息必须指向具体字段。
|
||||
//
|
||||
// 起因:写跨主机验证脚本时把 workspaces 传成了字符串数组,
|
||||
// 服务端回的是一句固定的 "Invalid JSON" —— 只能靠翻服务端结构体才发现是哪个字段。
|
||||
// 第三方客户端没有这个条件。
|
||||
func TestDecodeBodyErrorNamesTheField(t *testing.T) {
|
||||
type body struct {
|
||||
Name string `json:"name"`
|
||||
Workspaces []models.Workspace `json:"workspaces"`
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
payload string
|
||||
wantHas []string
|
||||
wantMiss []string
|
||||
}{
|
||||
{
|
||||
name: "字段类型不对要说出字段名与期望类型",
|
||||
payload: `{"name":"bot","workspaces":["/tmp/ws"]}`,
|
||||
// 期望能看出:是 workspaces,要的是 object 数组,给的是 string
|
||||
wantHas: []string{"workspaces", "object", "string"},
|
||||
// 不该把 Go 类型名漏出去
|
||||
wantMiss: []string{"models.Workspace", "[]models"},
|
||||
},
|
||||
{
|
||||
name: "整个体的类型不对",
|
||||
payload: `["not","an","object"]`,
|
||||
wantHas: []string{"object"},
|
||||
},
|
||||
{
|
||||
// 截断的 JSON 走的是 io.ErrUnexpectedEOF,不是 json.SyntaxError
|
||||
name: "被截断的体要说明是截断",
|
||||
payload: `{"name":`,
|
||||
wantHas: []string{"语法", "结束"},
|
||||
},
|
||||
{
|
||||
name: "非法字符要给出位置",
|
||||
payload: `{"name":1x}`,
|
||||
wantHas: []string{"语法", "字节"},
|
||||
},
|
||||
{
|
||||
name: "空体单独说明",
|
||||
payload: ``,
|
||||
wantHas: []string{"为空"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/x", strings.NewReader(c.payload))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var v body
|
||||
if DecodeBody(w, r, &v) {
|
||||
t.Fatal("这个体应当解析失败")
|
||||
}
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("状态码应为 400,实际 %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("响应不是 JSON: %v", err)
|
||||
}
|
||||
msg := resp["error"]
|
||||
if msg == "" {
|
||||
t.Fatal("error 字段为空")
|
||||
}
|
||||
for _, want := range c.wantHas {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("信息里应含 %q,实际 %q", want, msg)
|
||||
}
|
||||
}
|
||||
for _, miss := range c.wantMiss {
|
||||
if strings.Contains(msg, miss) {
|
||||
t.Errorf("信息里不该含 Go 类型名 %q:%q", miss, msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 合法请求体不该被拦,也不该写任何响应 ——
|
||||
// 写了的话调用方接着写自己的响应就成了两次 WriteHeader。
|
||||
func TestDecodeBodyPassesValidPayload(t *testing.T) {
|
||||
type body struct {
|
||||
Name string `json:"name"`
|
||||
Workspaces []models.Workspace `json:"workspaces"`
|
||||
}
|
||||
|
||||
payload := `{"name":"bot","workspaces":[{"name":"demo","path":"/tmp/ws"}]}`
|
||||
r := httptest.NewRequest(http.MethodPost, "/x", strings.NewReader(payload))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var v body
|
||||
if !DecodeBody(w, r, &v) {
|
||||
t.Fatalf("合法体被拒:%s", w.Body.String())
|
||||
}
|
||||
if w.Body.Len() != 0 {
|
||||
t.Errorf("成功时不该写响应体,实际写了 %q", w.Body.String())
|
||||
}
|
||||
if v.Name != "bot" || len(v.Workspaces) != 1 || v.Workspaces[0].Path != "/tmp/ws" {
|
||||
t.Errorf("解析结果不对:%+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// 空数组是合法的 —— 两个正式插件注册时都传 workspaces: []。
|
||||
func TestDecodeBodyAcceptsEmptyWorkspaces(t *testing.T) {
|
||||
type body struct {
|
||||
Name string `json:"name"`
|
||||
Workspaces []models.Workspace `json:"workspaces"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/x",
|
||||
strings.NewReader(`{"name":"opencode","workspaces":[]}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var v body
|
||||
if !DecodeBody(w, r, &v) {
|
||||
t.Fatalf("空 workspaces 被拒:%s", w.Body.String())
|
||||
}
|
||||
if len(v.Workspaces) != 0 {
|
||||
t.Errorf("应为空数组,实际 %+v", v.Workspaces)
|
||||
}
|
||||
}
|
||||
91
server/internal/handler/events.go
Normal file
91
server/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, r, 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(),
|
||||
})
|
||||
}
|
||||
292
server/internal/handler/forward.go
Normal file
292
server/internal/handler/forward.go
Normal file
@ -0,0 +1,292 @@
|
||||
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 !DecodeBody(w, r, &req) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 可达性:转发目标必须存在且未停用。人与 Agent 两条转发路径共用这道检查。
|
||||
if !checkDeliverable(w, r, append([]models.Address{to}, ccList...)) {
|
||||
return
|
||||
}
|
||||
|
||||
subject := forwardSubject(req.Subject, src.Subject)
|
||||
|
||||
// 转发按目标地址寻址,不带 reply_to:它是一条新线索,不该并进原会话
|
||||
sessionID, _, created, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias, agentLimiterKey(isAgent, actor))
|
||||
if err != nil {
|
||||
writeErr(w, err, "Failed to resolve session")
|
||||
return
|
||||
}
|
||||
|
||||
// 权限档位继承自源会话(plan 档派不出 full 档子任务,约束沿链条传递)。
|
||||
// 只在【新建】目标会话时设:复用既有会话时不改写对方正在遵守的规则。
|
||||
if created {
|
||||
mode := repo.InheritedMode(r.Context(), &src.SessionID, models.DefaultPermissionMode)
|
||||
if _, err := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to set permission mode")
|
||||
return
|
||||
}
|
||||
_ = repo.SetSessionEnforcement(r.Context(), sessionID,
|
||||
repo.AgentModeEnforcement(r.Context(), to.Name))
|
||||
}
|
||||
|
||||
if isAgent {
|
||||
// 转发也是一次主动发信,扣【目标会话】的往返预算。
|
||||
// 扣目标而不是源:转发开启的是一条新线索,消耗的是新线索的额度。
|
||||
budget, bErr := repo.ConsumeSessionBudget(r.Context(), sessionID)
|
||||
if errors.Is(bErr, repo.ErrSessionBudgetExhausted) {
|
||||
Error(w, http.StatusForbidden, fmt.Sprintf(
|
||||
"目标会话的往返预算已用尽(%d/%d)。请让人在对话页调高该会话的预算。",
|
||||
budget.Used, budget.Max))
|
||||
return
|
||||
}
|
||||
if bErr != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check session budget")
|
||||
return
|
||||
}
|
||||
repo.BumpSentCount(r.Context(), actor)
|
||||
} 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
|
||||
}
|
||||
|
||||
// 转发在数据上 parent 指向原邮件(用于回溯来源),但对**收件方**而言这是一封
|
||||
// 全新的信:那封原邮件不是它写的,也不在它的线索里。
|
||||
// 因此 in_reply_to 传空串 —— 提示词该说「有人转了一封信给你」而不是
|
||||
// 「你上封信的回复到了」。
|
||||
notifyRecipients(r.Context(), 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)
|
||||
}
|
||||
|
||||
// ---------- Agent 默认预算与统计(管理员) ----------
|
||||
|
||||
// GET /api/v1/admin/quotas
|
||||
//
|
||||
// 路径沿用 quotas(兼容已部署的前端),但语义已变:
|
||||
// 返回的是【新任务默认预算 + 累计统计】,而不是会拦请求的终身额度。
|
||||
// 真正的额度在每条会话上(GET /sessions/{id}/budget)。
|
||||
func AdminListQuotas(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := repo.ListAgentStats(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list agent stats")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"quotas": stats})
|
||||
}
|
||||
|
||||
type setQuotaRequest struct {
|
||||
// DefaultRounds 派给该 Agent 的新任务默认多少个来回(0 = 不限)
|
||||
DefaultRounds *int `json:"default_rounds"`
|
||||
// MaxRounds 是 DefaultRounds 的旧字段名,保留兼容:
|
||||
// 已部署的前端与脚本不应该因为改名就难以察觉地失效。
|
||||
MaxRounds *int `json:"max_rounds"`
|
||||
}
|
||||
|
||||
// PUT /api/v1/admin/quotas/{name}
|
||||
//
|
||||
// 只能改【新任务默认预算】。不再接受 reset:
|
||||
// 累计发信数是观测数据,不拦任何请求,归零它只会销毁历史。
|
||||
// 要给某个卡住的任务加额度,去那条会话的对话页改预算。
|
||||
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 !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
n := req.DefaultRounds
|
||||
if n == nil {
|
||||
n = req.MaxRounds // 兼容旧字段名
|
||||
}
|
||||
if n == nil {
|
||||
Error(w, http.StatusBadRequest, "需要 default_rounds")
|
||||
return
|
||||
}
|
||||
if *n < 0 {
|
||||
Error(w, http.StatusBadRequest, "default_rounds 不能为负")
|
||||
return
|
||||
}
|
||||
|
||||
st, err := repo.SetDefaultRounds(r.Context(), name, *n)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"quota": st})
|
||||
}
|
||||
77
server/internal/handler/forward_test.go
Normal file
77
server/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("无抄送时不应出现「抄送」行")
|
||||
}
|
||||
}
|
||||
410
server/internal/handler/helpers.go
Normal file
410
server/internal/handler/helpers.go
Normal file
@ -0,0 +1,410 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"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。**拒绝未知字段。**
|
||||
//
|
||||
// # 为什么必须严格
|
||||
//
|
||||
// 宽容解码把「字段名写错」变成一种**静默成功**:请求返回 200,服务端却什么都
|
||||
// 没收到。生产实测过最坏的一种形状 —— homeagent 插件的 send_mail 传的是
|
||||
// `attachments: [{"attachment_id": …}]`,而服务端要的是 `attachment_ids: ["…"]`:
|
||||
//
|
||||
// $ curl -X POST /mail/send -d '{…,"attachments":[{"attachment_id":"598f100e…"}]}'
|
||||
// HTTP 200 {"mail_id":"2a64fdc8…", …}
|
||||
// $ sqlite3 "SELECT COUNT(*) FROM attachments WHERE mail_id='2a64fdc8…'"
|
||||
// 0
|
||||
//
|
||||
// 邮件发出去了、附件一个都没带、没有任何一层报错。那个 bug 在库里活了很久 ——
|
||||
// **正因为没人会去核对一个返回 200 的请求**。
|
||||
//
|
||||
// 严格解码把它变成一个当场可见的 400。这是 `I-5`(失败必须当场可见)在
|
||||
// 请求解析层的落点:宁可让调用方收到一句「字段 X 不认识」,
|
||||
// 也不要让它以为自己传的东西生效了。
|
||||
//
|
||||
// 需要宽容的地方只有一处(心跳,见 DecodeLenient),且必须显式说明理由。
|
||||
func Decode(r *http.Request, v interface{}) error {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(v)
|
||||
}
|
||||
|
||||
// DecodeLenient 解析请求体但**容忍未知字段**,同时把认不出的字段名报回来。
|
||||
//
|
||||
// 只给心跳用,理由是那条路径的职责是「我还活着」:插件比服务端新、多带了一个
|
||||
// 服务端还不认识的字段时,代价不该是整个心跳体(含会话快照与模型目录)被丢掉。
|
||||
//
|
||||
// 但**容忍不等于咽下去**。返回的 unknown 列表必须被调用方回报给插件
|
||||
// (心跳响应里的 `unknown_fields`),否则又变成一次静默忽略 —— 那正是
|
||||
// `attachments` vs `attachment_ids` 能拖那么久的原因。
|
||||
//
|
||||
// 实现上要解两遍(宽容一遍取值、严格一遍找未知字段),所以先把 body 读进内存。
|
||||
func DecodeLenient(r *http.Request, v interface{}) (unknown []string, err error) {
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, maxLenientBodyBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 取值这一遍必须宽容:未知字段不能让整个心跳体作废。
|
||||
if uErr := json.Unmarshal(raw, v); uErr != nil {
|
||||
return nil, uErr
|
||||
}
|
||||
|
||||
// 再严格解一遍**只为找出未知字段**。json 每遇到一个未知字段就立即返回,
|
||||
// 所以要循环剥:不循环的话「多带了三个字段」只会报出第一个。
|
||||
probeType := reflect.TypeOf(v)
|
||||
for probeType != nil && probeType.Kind() == reflect.Ptr {
|
||||
probeType = probeType.Elem()
|
||||
}
|
||||
if probeType == nil {
|
||||
return nil, nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < maxUnknownFieldsReported; i++ {
|
||||
probe := reflect.New(probeType).Interface()
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
dErr := dec.Decode(probe)
|
||||
if dErr == nil {
|
||||
break
|
||||
}
|
||||
name := unknownFieldName(dErr)
|
||||
// 不是未知字段错误(宽容那遍已经成功,所以这里本不应出现其他错),
|
||||
// 或者同一个名字又出现一次 —— 都说明剥不下去了,停。
|
||||
if name == "" || seen[name] {
|
||||
break
|
||||
}
|
||||
seen[name] = true
|
||||
unknown = append(unknown, name)
|
||||
stripped, sErr := stripTopLevelKey(raw, name)
|
||||
if sErr != nil {
|
||||
break
|
||||
}
|
||||
raw = stripped
|
||||
}
|
||||
return unknown, nil
|
||||
}
|
||||
|
||||
const (
|
||||
// maxLenientBodyBytes 是心跳体的读取上限。会话快照 200 条 + 模型目录 300 条,
|
||||
// 每条百来字节,2MB 有充足余量;超出的部分被截断后 json 解析会报错,
|
||||
// 那正是我们想要的(一个畸形巨大的心跳体不该被当成有效上报)。
|
||||
maxLenientBodyBytes = 2 << 20
|
||||
// maxUnknownFieldsReported 是回报的未知字段数上限。
|
||||
// 报头几个足够定位问题,无上限循环会让一个塞满垃圾键的请求变成 CPU 消耗。
|
||||
maxUnknownFieldsReported = 8
|
||||
)
|
||||
|
||||
// stripTopLevelKey 从一个 JSON 对象里删掉一个顶层键。
|
||||
//
|
||||
// 只动顶层:未知字段错误报的就是顶层键名。嵌套结构里的未知字段报的名字
|
||||
// 在顶层找不到,这里返回错误,循环随即停下 —— 那个字段仍会被报出来。
|
||||
func stripTopLevelKey(raw []byte, key string) ([]byte, error) {
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := m[key]; !ok {
|
||||
return nil, errors.New("key not at top level")
|
||||
}
|
||||
delete(m, key)
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
// unknownFieldName 从 encoding/json 的未知字段错误里取出那个字段名。
|
||||
//
|
||||
// json 包没有为这种错误定义类型(返回的是 *errors.errorString),
|
||||
// 只能按文本匹配 `json: unknown field "xxx"`。
|
||||
// 匹配不上时返回空串,调用方回落到笼统文案。
|
||||
func unknownFieldName(err error) string {
|
||||
const prefix = `json: unknown field "`
|
||||
msg := err.Error()
|
||||
i := strings.Index(msg, prefix)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := msg[i+len(prefix):]
|
||||
j := strings.IndexByte(rest, '"')
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
return rest[:j]
|
||||
}
|
||||
|
||||
// jsonFieldNames 反射列出一个请求结构体接受的 JSON 键。
|
||||
//
|
||||
// 用途是把「字段 X 不认识」补成「应为 a / b / c 之一」——
|
||||
// 少了这半句,调用方只知道自己错了,仍要去翻服务端源码才知道对的是什么。
|
||||
// 那正是 `attachments` vs `attachment_ids` 当初拖了那么久的原因。
|
||||
func jsonFieldNames(v interface{}) []string {
|
||||
t := reflect.TypeOf(v)
|
||||
for t != nil && t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t == nil || t.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, t.NumField())
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
f := t.Field(i)
|
||||
if f.PkgPath != "" {
|
||||
continue // 非导出字段不参与 JSON
|
||||
}
|
||||
name := f.Tag.Get("json")
|
||||
if idx := strings.IndexByte(name, ','); idx >= 0 {
|
||||
name = name[:idx]
|
||||
}
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = f.Name
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DecodeBody 解析请求体,失败时直接写 400 并返回 false。
|
||||
//
|
||||
// 与直接用 Decode 的区别是错误信息**指向具体字段**。原先 22 处调用点
|
||||
// 一律回一句固定的 "Invalid JSON",客户端只知道「有问题」却不知道哪里有问题 ——
|
||||
// 实测踩过一次:`workspaces` 要的是 `[{name, path}]`,传字符串数组得到的
|
||||
// 就是那句固定文案,只能靠翻服务端结构体才发现。第三方客户端没有这个条件。
|
||||
func DecodeBody(w http.ResponseWriter, r *http.Request, v interface{}) bool {
|
||||
if err := Decode(r, v); err != nil {
|
||||
Error(w, http.StatusBadRequest, decodeErrMsg(err, v))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// decodeErrMsg 把 json 解码错误翻成一句能照着改的话。
|
||||
//
|
||||
// 刻意不回显 json 包的原文:它带 Go 的类型名(如 models.Workspace),
|
||||
// 那是本侧的实现细节,对调用方没有意义,也不该出现在公开 API 的响应里。
|
||||
func decodeErrMsg(err error, target interface{}) string {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return "请求体为空"
|
||||
}
|
||||
|
||||
// 未知字段:把认识的键一并列出来。只说「不认识 x」的话,调用方还得去翻
|
||||
// 服务端源码才知道对的拼法 —— 而拼错字段名恰恰是最容易犯、最难自查的错
|
||||
//(宽容解码时它连报错都没有,见 Decode 的注释)。
|
||||
if bad := unknownFieldName(err); bad != "" {
|
||||
msg := "不认识的字段 \"" + bad + "\""
|
||||
if names := jsonFieldNames(target); len(names) > 0 {
|
||||
msg += ";本端点接受:" + strings.Join(names, " / ")
|
||||
}
|
||||
return msg
|
||||
}
|
||||
// 截断的 JSON 走的不是 SyntaxError 而是 ErrUnexpectedEOF ——
|
||||
// 不单独处理的话会落到最后那句笼统的兜底文案里
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return "JSON 语法错误:请求体在解析完成前就结束了(可能被截断)"
|
||||
}
|
||||
|
||||
var typeErr *json.UnmarshalTypeError
|
||||
if errors.As(err, &typeErr) {
|
||||
if typeErr.Field != "" {
|
||||
return "字段 \"" + typeErr.Field + "\" 类型不对:期望 " +
|
||||
jsonKindName(typeErr.Type) + ",收到 " + typeErr.Value
|
||||
}
|
||||
return "请求体类型不对:期望 " + jsonKindName(typeErr.Type) + ",收到 " + typeErr.Value
|
||||
}
|
||||
|
||||
var syntaxErr *json.SyntaxError
|
||||
if errors.As(err, &syntaxErr) {
|
||||
return "JSON 语法错误(第 " + strconv.FormatInt(syntaxErr.Offset, 10) + " 字节处)"
|
||||
}
|
||||
|
||||
return "请求体不是合法 JSON"
|
||||
}
|
||||
|
||||
// jsonKindName 把 Go 类型说成 JSON 的说法。
|
||||
// 调用方写的是 JSON,用 []models.Workspace 去解释它要的是什么毫无帮助。
|
||||
func jsonKindName(t reflect.Type) string {
|
||||
if t == nil {
|
||||
return "未知类型"
|
||||
}
|
||||
switch t.Kind() {
|
||||
case reflect.Slice, reflect.Array:
|
||||
return jsonKindName(t.Elem()) + " 数组"
|
||||
case reflect.Map, reflect.Struct:
|
||||
return "object"
|
||||
case reflect.String:
|
||||
return "string"
|
||||
case reflect.Bool:
|
||||
return "boolean"
|
||||
case reflect.Ptr:
|
||||
return jsonKindName(t.Elem())
|
||||
default:
|
||||
if k := t.Kind(); k >= reflect.Int && k <= reflect.Float64 {
|
||||
return "number"
|
||||
}
|
||||
return t.Kind().String()
|
||||
}
|
||||
}
|
||||
|
||||
// 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} }
|
||||
|
||||
// errRateLimited 用于新建会话速率限制。用 429 而不是 403:
|
||||
// 前者表示「稍后再来」,后者表示「你没这个权限」——语义完全不同,
|
||||
// 客户端据此决定是重试还是放弃。
|
||||
func errRateLimited(msg string) error { return httpError{http.StatusTooManyRequests, 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:
|
||||
if strings.Contains(err.Error(), "已退役") {
|
||||
Error(w, http.StatusConflict, err.Error())
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
// agentLimiterKey 把「这是不是 Agent 发起的」翻译成速率限制的键。
|
||||
// 人类返回空串 = 不限速(手工操作的频率天然受限)。
|
||||
func agentLimiterKey(isAgent bool, actor string) string {
|
||||
if isAgent {
|
||||
return actor
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// validPermissionModeInput 校验人显式指定的权限档位。
|
||||
//
|
||||
// 与 repo 层的 Normalize 分工不同:**人显式传了一个认不出的档位时必须报错**,
|
||||
// 不能静默用默认档。他以为自己给了 plan,实际拿到 workspace —— 那是比报错
|
||||
// 更坏的结果(他会以为自己收紧了)。
|
||||
//
|
||||
// 而 repo 层的 Normalize 面向的是「库里的历史脏数据」与「省略该字段」,
|
||||
// 那两种情形下静默回落到默认档才是对的。
|
||||
func validPermissionModeInput(w http.ResponseWriter, mode string) bool {
|
||||
if mode == "" || models.ValidPermissionMode(mode) {
|
||||
return true
|
||||
}
|
||||
Error(w, http.StatusBadRequest,
|
||||
"permission_mode 非法:"+mode+"(应为 plan / workspace / full)")
|
||||
return false
|
||||
}
|
||||
423
server/internal/handler/ics_test.go
Normal file
423
server/internal/handler/ics_test.go
Normal file
@ -0,0 +1,423 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
)
|
||||
|
||||
// parseICS 是导入的唯一入口,解析错了不会报错 —— 事件只是安静地不出现,
|
||||
// 或者出现在错误的时间。这些测试锁住 iCal 的形态约定。
|
||||
|
||||
func TestParseICSBasicEvent(t *testing.T) {
|
||||
ics := "BEGIN:VCALENDAR\r\n" +
|
||||
"VERSION:2.0\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"SUMMARY:发布评审\r\n" +
|
||||
"DESCRIPTION:看 llmsproxy 的部署脚本\r\n" +
|
||||
"DTSTART:20260903T063000Z\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("期望 1 个事件,得到 %d", len(events))
|
||||
}
|
||||
e := events[0]
|
||||
if e.Title != "发布评审" {
|
||||
t.Errorf("Title = %q,期望 发布评审", e.Title)
|
||||
}
|
||||
if e.Description != "看 llmsproxy 的部署脚本" {
|
||||
t.Errorf("Description = %q", e.Description)
|
||||
}
|
||||
if !e.EventTime.Equal(time.Date(2026, 9, 3, 6, 30, 0, 0, time.UTC)) {
|
||||
t.Errorf("EventTime = %v,期望 2026-09-03T06:30:00Z", e.EventTime)
|
||||
}
|
||||
if e.Recurrence != "none" {
|
||||
t.Errorf("Recurrence = %q,期望 none", e.Recurrence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICSMultipleEvents(t *testing.T) {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("BEGIN:VCALENDAR\r\n")
|
||||
for i, title := range []string{"甲", "乙", "丙"} {
|
||||
sb.WriteString("BEGIN:VEVENT\r\n")
|
||||
sb.WriteString("SUMMARY:" + title + "\r\n")
|
||||
sb.WriteString("DTSTART:2026090" + string(rune('1'+i)) + "T020000Z\r\n")
|
||||
sb.WriteString("END:VEVENT\r\n")
|
||||
}
|
||||
sb.WriteString("END:VCALENDAR\r\n")
|
||||
|
||||
events := parseICS([]byte(sb.String()))
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("期望 3 个事件,得到 %d", len(events))
|
||||
}
|
||||
for i, want := range []string{"甲", "乙", "丙"} {
|
||||
if events[i].Title != want {
|
||||
t.Errorf("第 %d 个 Title = %q,期望 %q", i, events[i].Title, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 没有 DTSTART 的 VEVENT 必须被丢弃:让它进库会得到一个 zero time 事件,
|
||||
// 调度器认为它「早就该触发了」,于是立刻发一封莫名其妙的提醒。
|
||||
func TestParseICSDropsEventWithoutStart(t *testing.T) {
|
||||
ics := "BEGIN:VCALENDAR\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"SUMMARY:没有时间\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
|
||||
if events := parseICS([]byte(ics)); len(events) != 0 {
|
||||
t.Fatalf("无 DTSTART 的事件应被丢弃,却得到 %d 个", len(events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICSRecurrence(t *testing.T) {
|
||||
cases := []struct {
|
||||
rrule string
|
||||
want string
|
||||
}{
|
||||
{"FREQ=DAILY", "daily"},
|
||||
{"FREQ=WEEKLY;BYDAY=MO", "weekly"},
|
||||
{"FREQ=MONTHLY;BYMONTHDAY=1", "monthly"},
|
||||
{"FREQ=YEARLY", "yearly"},
|
||||
// 不支持的频率退回 none 而不是乱猜:把 HOURLY 当 daily
|
||||
// 会让提醒少发 23 次且没有任何报错。
|
||||
{"FREQ=HOURLY", "none"},
|
||||
{"FREQ=SECONDLY", "none"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" +
|
||||
"RRULE:" + c.rrule + "\r\nEND:VEVENT\r\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("%s: 期望 1 个事件", c.rrule)
|
||||
}
|
||||
if events[0].Recurrence != c.want {
|
||||
t.Errorf("%s: Recurrence = %q,期望 %q", c.rrule, events[0].Recurrence, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICSTriggerToRemindBefore(t *testing.T) {
|
||||
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" +
|
||||
"TRIGGER:-PT30M\r\nEND:VEVENT\r\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Fatal("期望 1 个事件")
|
||||
}
|
||||
if events[0].RemindBefore != 30 {
|
||||
t.Errorf("RemindBefore = %d,期望 30", events[0].RemindBefore)
|
||||
}
|
||||
}
|
||||
|
||||
// DTSTART 有三种合法形态,都得认。只认 UTC 那种会让本地时间的 .ics
|
||||
// 整份导入失败(每个事件都缺 DTSTART → 全被丢弃 → 「导入 0 个」且无提示)。
|
||||
func TestParseICSDateFormats(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"UTC", "20260903T063000Z"},
|
||||
{"本地时间", "20260903T143000"},
|
||||
{"仅日期", "20260903"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:" + c.value + "\r\nEND:VEVENT\r\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Errorf("%s (%s): 期望 1 个事件,得到 %d", c.name, c.value, len(events))
|
||||
continue
|
||||
}
|
||||
if events[0].EventTime.IsZero() {
|
||||
t.Errorf("%s (%s): EventTime 为零值", c.name, c.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DTSTART;TZID=Asia/Shanghai:... 这种带参数的键必须归一化到 DTSTART,
|
||||
// 否则 switch 落空 → 无 EventTime → 事件被丢。
|
||||
func TestParseICSStripsKeyParameters(t *testing.T) {
|
||||
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\n" +
|
||||
"DTSTART;TZID=Asia/Shanghai:20260903T143000\r\nEND:VEVENT\r\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("带 TZID 参数的 DTSTART 应被识别,得到 %d 个事件", len(events))
|
||||
}
|
||||
if events[0].EventTime.IsZero() {
|
||||
t.Error("EventTime 为零值")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICSEscapedNewlines(t *testing.T) {
|
||||
ics := "BEGIN:VEVENT\r\nSUMMARY:第一行\\n第二行\r\n" +
|
||||
"DTSTART:20260903T020000Z\r\nEND:VEVENT\r\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Fatal("期望 1 个事件")
|
||||
}
|
||||
if !strings.Contains(events[0].Title, "\n") {
|
||||
t.Errorf("转义的 \\n 应还原成真换行,得到 %q", events[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICSEmptyAndGarbage(t *testing.T) {
|
||||
for _, in := range []string{"", "不是 ics", "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"} {
|
||||
if events := parseICS([]byte(in)); len(events) != 0 {
|
||||
t.Errorf("输入 %q 应给 0 个事件,得到 %d", in, len(events))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LF 换行(非 CRLF)的 .ics 也要能解析:很多工具导出的是 LF。
|
||||
func TestParseICSAcceptsLFLineEndings(t *testing.T) {
|
||||
ics := "BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:LF 换行\n" +
|
||||
"DTSTART:20260903T020000Z\nEND:VEVENT\nEND:VCALENDAR\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("LF 换行应能解析,得到 %d 个事件", len(events))
|
||||
}
|
||||
if events[0].Title != "LF 换行" {
|
||||
t.Errorf("Title = %q", events[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
// TRIGGER duration 解析。
|
||||
//
|
||||
// 原实现是 fmt.Sscanf(value, "-PT%dM", &mins),只认一种形态;导出端又写的是
|
||||
// `-P15M`(T 之前的 M 在 iCal 里是**月**)—— 于是自己导出的文件自己都读不回来。
|
||||
func TestParseTriggerMinutes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{"-PT30M", 30, true},
|
||||
{"-PT1H", 60, true},
|
||||
{"-PT1H30M", 90, true},
|
||||
{"-P1D", 1440, true},
|
||||
{"-P1DT2H", 1560, true},
|
||||
{"-P1W", 10080, true},
|
||||
{"-pt45m", 45, true}, // 大小写不敏感
|
||||
|
||||
// T 之前的 M 是月,映射不到分钟数,忽略比乱换算好
|
||||
{"-P3M", 0, false},
|
||||
// 正号 = 事件之后提醒,remind_before 表达不了
|
||||
{"PT30M", 0, false},
|
||||
// 零时长与垃圾输入
|
||||
{"-PT0M", 0, false},
|
||||
{"", 0, false},
|
||||
{"垃圾", 0, false},
|
||||
{"-P", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := parseTriggerMinutes(c.in)
|
||||
if ok != c.ok || got != c.want {
|
||||
t.Errorf("parseTriggerMinutes(%q) = (%d, %v),期望 (%d, %v)",
|
||||
c.in, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出写的 TRIGGER 必须能被自己的导入解析回同一个分钟数。
|
||||
// 这条往返曾经是断的:导出 -P15M、导入找 -PT%dM。
|
||||
func TestTriggerRoundtrip(t *testing.T) {
|
||||
for _, mins := range []int{5, 15, 30, 60, 120, 1440} {
|
||||
// 导出端的写法(与 ExportCalendarICS 里那行一致)
|
||||
trigger := fmt.Sprintf("-PT%dM", mins)
|
||||
got, ok := parseTriggerMinutes(trigger)
|
||||
if !ok {
|
||||
t.Errorf("%d 分钟导出成 %q 后无法解析", mins, trigger)
|
||||
continue
|
||||
}
|
||||
if got != mins {
|
||||
t.Errorf("%d 分钟往返后变成 %d(trigger=%q)", mins, got, trigger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 整份 .ics 的往返:TRIGGER 经过 parseICS 后落到 RemindBefore 上。
|
||||
func TestParseICSTriggerVariants(t *testing.T) {
|
||||
cases := []struct {
|
||||
trigger string
|
||||
want int
|
||||
}{
|
||||
{"-PT15M", 15},
|
||||
{"-PT2H", 120},
|
||||
{"-P1D", 1440},
|
||||
}
|
||||
for _, c := range cases {
|
||||
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" +
|
||||
"TRIGGER:" + c.trigger + "\r\nEND:VEVENT\r\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Errorf("%s: 期望 1 个事件", c.trigger)
|
||||
continue
|
||||
}
|
||||
if events[0].RemindBefore != c.want {
|
||||
t.Errorf("%s: RemindBefore = %d,期望 %d", c.trigger, events[0].RemindBefore, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默认提醒模板必须是**变量形式**,不能把当时的时间烤成字面值。
|
||||
//
|
||||
// 重复事件上这个区别是致命的:AdvanceRecurrence 只推进 event_time,
|
||||
// reminder_text 保持不动 —— 字面值会让「每天 9 点」的提醒从第二天起
|
||||
// 永远写着第一天的日期,且不报任何错。
|
||||
//
|
||||
// 同时锁住「与前端 DEFAULT_TEMPLATE 逐字一致」:
|
||||
// client/electron/src/components/CalendarEventEditor.tsx 用它作预览,
|
||||
// 两边不同会让人看到的预览与 Agent 实收的正文不是一回事。
|
||||
func TestDefaultReminderTemplateUsesVariables(t *testing.T) {
|
||||
for _, v := range []string{"{title}", "{time}", "{description}"} {
|
||||
if !strings.Contains(defaultReminderTemplate, v) {
|
||||
t.Errorf("默认模板缺变量 %s:%q", v, defaultReminderTemplate)
|
||||
}
|
||||
}
|
||||
// 前端那份的字面内容(保持同步)
|
||||
const frontend = "日程提醒:{title}\n时间:{time}\n{description}"
|
||||
if defaultReminderTemplate != frontend {
|
||||
t.Errorf("后端默认模板与前端 DEFAULT_TEMPLATE 不一致:\n后端 %q\n前端 %q",
|
||||
defaultReminderTemplate, frontend)
|
||||
}
|
||||
// 不该含任何形如年份的字面数字 —— 那是「把值烤进模板」的迹象
|
||||
for _, digit := range []string{"2026", "20:", ":00"} {
|
||||
if strings.Contains(defaultReminderTemplate, digit) {
|
||||
t.Errorf("默认模板含字面时间片段 %q:%q", digit, defaultReminderTemplate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 农历与多收件人的 iCal 往返 ───
|
||||
|
||||
// 农历规则 RFC 5545 表达不了。折中方案:X- 扩展属性记精确规则 +
|
||||
// 降级成最接近的公历 RRULE。别的客户端至少能看到一个大致对的重复,
|
||||
// 导回本系统时 X- 属性还原精确规则。
|
||||
func TestParseICSLunarRecurrenceExtension(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"X- 属性覆盖 RRULE 近似值",
|
||||
"RRULE:FREQ=MONTHLY\r\nX-AGENTMAIL-RECURRENCE:lunar_monthly\r\n",
|
||||
"lunar_monthly",
|
||||
},
|
||||
{
|
||||
"农历年",
|
||||
"RRULE:FREQ=YEARLY\r\nX-AGENTMAIL-RECURRENCE:lunar_yearly\r\n",
|
||||
"lunar_yearly",
|
||||
},
|
||||
{
|
||||
"X- 在 RRULE 之前也生效(顺序无关)",
|
||||
"X-AGENTMAIL-RECURRENCE:lunar_monthly\r\nRRULE:FREQ=MONTHLY\r\n",
|
||||
"lunar_monthly",
|
||||
},
|
||||
{
|
||||
"非法 X- 值被忽略,保留 RRULE 的近似值",
|
||||
"RRULE:FREQ=MONTHLY\r\nX-AGENTMAIL-RECURRENCE:lunar_montly\r\n",
|
||||
"monthly",
|
||||
},
|
||||
{
|
||||
"键名小写也认(RFC 5545 §3.1 大小写不敏感)",
|
||||
"x-agentmail-recurrence:lunar_yearly\r\n",
|
||||
"lunar_yearly",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" +
|
||||
c.body + "END:VEVENT\r\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Errorf("%s: 期望 1 个事件,得到 %d", c.name, len(events))
|
||||
continue
|
||||
}
|
||||
if events[0].Recurrence != c.want {
|
||||
t.Errorf("%s: Recurrence = %q,期望 %q", c.name, events[0].Recurrence, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICSRecipientsExtension(t *testing.T) {
|
||||
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" +
|
||||
"X-AGENTMAIL-RECIPIENTS:pi@/home/x,dsh,opencode@/tmp.alias\r\n" +
|
||||
"X-AGENTMAIL-DELIVERY:together\r\nEND:VEVENT\r\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("期望 1 个事件,得到 %d", len(events))
|
||||
}
|
||||
e := events[0]
|
||||
if len(e.Recipients) != 3 {
|
||||
t.Fatalf("收件人应有 3 个,得到 %d:%v", len(e.Recipients), e.Recipients)
|
||||
}
|
||||
if e.Recipients[0] != "pi@/home/x" || e.Recipients[2] != "opencode@/tmp.alias" {
|
||||
t.Errorf("收件人内容或顺序不符:%v", e.Recipients)
|
||||
}
|
||||
if e.DeliveryMode != "together" {
|
||||
t.Errorf("DeliveryMode = %q,期望 together", e.DeliveryMode)
|
||||
}
|
||||
}
|
||||
|
||||
// 未知投递模式必须被忽略(留空 → EffectiveDeliveryMode 给 separate),
|
||||
// 而不是原样写进库里。
|
||||
func TestParseICSRejectsBadDeliveryMode(t *testing.T) {
|
||||
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" +
|
||||
"X-AGENTMAIL-DELIVERY:随便写的\r\nEND:VEVENT\r\n"
|
||||
events := parseICS([]byte(ics))
|
||||
if len(events) != 1 {
|
||||
t.Fatal("期望 1 个事件")
|
||||
}
|
||||
if events[0].DeliveryMode != "" {
|
||||
t.Errorf("非法投递模式应被忽略,得到 %q", events[0].DeliveryMode)
|
||||
}
|
||||
if events[0].EffectiveDeliveryMode() != models.DeliverSeparate {
|
||||
t.Error("兜底应是 separate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidRecurrence(t *testing.T) {
|
||||
for _, ok := range []string{"none", "daily", "weekly", "monthly", "yearly", "lunar_monthly", "lunar_yearly"} {
|
||||
if !validRecurrence(ok) {
|
||||
t.Errorf("%q 应合法", ok)
|
||||
}
|
||||
}
|
||||
// 拼错必须被拒而不是静默当 none —— 后者会让每月提醒只响一次且无报错
|
||||
for _, bad := range []string{"", "lunar_montly", "LUNAR_MONTHLY", "每月", "lunar_weekly"} {
|
||||
if validRecurrence(bad) {
|
||||
t.Errorf("%q 应非法", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRecipients(t *testing.T) {
|
||||
got, bad := normalizeRecipients([]string{" pi ", "", "dsh", "pi", " "})
|
||||
if bad != "" {
|
||||
t.Fatalf("不该报错,得到 %q", bad)
|
||||
}
|
||||
// 去空白 + 去重,保留首次出现的顺序
|
||||
if len(got) != 2 || got[0] != "pi" || got[1] != "dsh" {
|
||||
t.Errorf("清洗结果 %v,期望 [pi dsh]", got)
|
||||
}
|
||||
|
||||
// 去重是必要的:together 模式下同一 Agent 既主收又抄送会收到两条 SSE
|
||||
if dup, _ := normalizeRecipients([]string{"pi@/x", "pi@/x"}); len(dup) != 1 {
|
||||
t.Errorf("重复地址应去重,得到 %v", dup)
|
||||
}
|
||||
|
||||
// 非法地址回报具体是哪一个
|
||||
if _, bad := normalizeRecipients([]string{"pi", "@@@bad@@@"}); bad == "" {
|
||||
t.Error("非法地址应被报出")
|
||||
}
|
||||
|
||||
// nil / 空输入给空数组而不是 nil(避免序列化成 null)
|
||||
if out, _ := normalizeRecipients(nil); out == nil {
|
||||
t.Error("nil 输入应给空数组")
|
||||
}
|
||||
}
|
||||
176
server/internal/handler/keys.go
Normal file
176
server/internal/handler/keys.go
Normal file
@ -0,0 +1,176 @@
|
||||
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 !DecodeBody(w, r, &req) {
|
||||
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 !DecodeBody(w, r, &req) {
|
||||
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 !DecodeBody(w, r, &req) {
|
||||
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"})
|
||||
}
|
||||
|
||||
692
server/internal/handler/mail.go
Normal file
692
server/internal/handler/mail.go
Normal file
@ -0,0 +1,692 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/notify"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"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 非空时必填
|
||||
|
||||
// FromSessionID 是发信时模型所处的邮件会话 id(即「这活是谁派给我的」)。
|
||||
//
|
||||
// **只用于权限档位继承**:Agent 新开一条会话时,新会话不得比它所在
|
||||
// 的那条会话更宽松。注意这里**没有** permission_mode 字段 —— 那是有意的:
|
||||
// 让 Agent 自己指定档位等于发一封 mode=full 的信就能提权。
|
||||
//
|
||||
// 省略时回落到默认档(不是 full)。插件担不担得起传这个值不影响安全下限:
|
||||
// 没传 = 拿默认档,不会因此拿到更大的权限。
|
||||
FromSessionID string `json:"from_session_id"`
|
||||
}
|
||||
|
||||
// resolveTarget 根据三维地址 name@path.session 决定投递的会话。
|
||||
//
|
||||
// session 位三态语义(设计文档):
|
||||
// - 省略(pi@root) → 投递到 name@path 的默认会话;从未通信则建立
|
||||
// - new(pi@root.new) → 强制新建一个会话
|
||||
// - 具体别名(pi@root.fix-leak)→ 必须已存在且该收件人参与过,否则 404 无法送达
|
||||
//
|
||||
// alias 为新建会话命名(仅新建时生效),使其之后可被 name@path.<alias> 寻址。
|
||||
// reply_to 优先于地址:显式回复某封邮件时沿用该邮件的会话。
|
||||
//
|
||||
// byAgent 非空时表示这是 Agent 发起的投递,新建会话要过速率限制:
|
||||
// 往返预算按会话计,Agent 用 .new 开一串会话就等于绕过预算。
|
||||
// 人类不受此限(手工点「新建邮件」的频率天然受限,加限制只会在批量派活时误伤)。
|
||||
// resolveTarget 依据地址的 session 位定位(或新建)会话。
|
||||
//
|
||||
// 返回值:会话 id / 父邮件 id(仅 reply_to 路径非 nil)/ **created** / 错误。
|
||||
//
|
||||
// created 为真**仅**表示这次调用真的新建了一条会话。它存在的理由是:
|
||||
// `parentMailID == nil` 曾被当作「新建会话」的判据,而那是错的 ——
|
||||
// 省略 session 位复用默认会话时 parentMailID 也是 nil。实测后果:
|
||||
// 第一封信 `max_rounds=7`,第二封信省略该字段,会话预算被静默改成 20。
|
||||
// 「只在新建时生效」的字段(往返预算、权限档位)必须靠这个返回值判断,
|
||||
// 否则每封新信都在改写对方正在遵守的规则。
|
||||
func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, subject, alias string, byAgent string) (uuid.UUID, *uuid.UUID, bool, error) {
|
||||
if replyTo != "" {
|
||||
replyID, err := uuid.Parse(replyTo)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, false, errBadRequest("Invalid reply_to UUID")
|
||||
}
|
||||
mail, err := repo.GetMailByID(r.Context(), replyID)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, false, errNotFound("Parent mail not found")
|
||||
}
|
||||
repo.TouchSession(r.Context(), mail.SessionID)
|
||||
return mail.SessionID, &replyID, false, 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, false, err
|
||||
}
|
||||
if _, err := repo.FindSessionByAlias(r.Context(), a); err == nil {
|
||||
return uuid.Nil, nil, false, errConflict(fmt.Sprintf(
|
||||
"会话别名 %q 已被占用;若要接着该会话谈请用 %s@%s.%s", a, addr.Name, addr.Path, a))
|
||||
}
|
||||
aliasPtr = &a
|
||||
}
|
||||
// Agent 主动开新线索要过速率限制
|
||||
if ok, retry := repo.AllowNewSession(r.Context(), byAgent); !ok {
|
||||
return uuid.Nil, nil, false, errRateLimited(fmt.Sprintf(
|
||||
"新建会话过于频繁(1 小时内已开 %d 条)。请在已有会话里继续,或 %d 秒后再试。",
|
||||
repo.SessionRateLimit(), retry))
|
||||
}
|
||||
// 带上 addr.Path:会话属于哪个工作区是会话自己的属性,
|
||||
// 不存下来的话「这个工作区下有哪些会话」就只能从 mails 反推。
|
||||
id, err := repo.CreateSession(r.Context(), aliasPtr, fromAgent, subject, addr.Path)
|
||||
if err != nil {
|
||||
// 建失败要把名额还回去:那次新建实际上没有发生
|
||||
repo.ReleaseNewSession(r.Context(), byAgent)
|
||||
return id, nil, false, err
|
||||
}
|
||||
// `.new` 是一次性动作:它建完会话就用完了,之后要再投进这条会话只能靠
|
||||
// `name@path.<别名>`。未命名会话既查不到(FindNamedSessionFor 的
|
||||
// `session_alias = $1` 对 NULL 不成立)也补全不出来,收件方与抄送方
|
||||
// 除了回复那一封之外再也无法寻址到它 —— 再发一次 `.new` 只会建第三条会话。
|
||||
// 因此这里立刻给一个别名,平台随后仍可用 SyncSessionAlias 改写它。
|
||||
if aliasPtr == nil {
|
||||
// 命名失败不该让发信失败:邮件本身能送达,代价只是这条会话暂时
|
||||
// 只能用 reply_to 续谈,比整封退回轻。
|
||||
_, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject))
|
||||
}
|
||||
return id, nil, true, nil
|
||||
|
||||
case models.SessionDefault:
|
||||
// 默认会话「从未通信则建立」也会产生新会话,但一个 name@path 只有一条,
|
||||
// 不构成暴开的手段,因此不计入速率限制。
|
||||
//
|
||||
// created 必须区分「这次建了」与「复用了既有的那条」:两者在这里都返回
|
||||
// parentMailID == nil,靠它判断会把续谈误当新建(预算与档位被静默改写)。
|
||||
id, created, err := repo.FindOrCreateDefaultSessionCreated(r.Context(), addr.Name, addr.Path, fromAgent, subject)
|
||||
if err != nil {
|
||||
return id, nil, false, err
|
||||
}
|
||||
// 默认会话同样需要可寻址的别名:省略 session 位能投进来,但要**指名**
|
||||
// 投进这一条(而不是「该 name@path 当前的默认会话」)仍然只能靠别名。
|
||||
// 已有别名时 EnsureSessionAlias 直接返回,复用旧会话不会被改名。
|
||||
_, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject))
|
||||
return id, nil, created, nil
|
||||
|
||||
default: // models.SessionNamed
|
||||
id, err := repo.FindNamedSessionFor(r.Context(), addr.Name, addr.Path, addr.Session)
|
||||
if err == nil {
|
||||
repo.TouchSession(r.Context(), id)
|
||||
return id, nil, false, nil
|
||||
}
|
||||
if !errors.Is(err, repo.ErrSessionNotFound) {
|
||||
return uuid.Nil, nil, false, err
|
||||
}
|
||||
|
||||
// 本侧没有这条别名 —— 再看平台会话镜像。
|
||||
//
|
||||
// TUI 与邮箱是同一个 Agent 的两个入口,人在平台界面上开的会话
|
||||
// 早就被补全列为候选(agent_platform_sessions),此前投递侧却没有
|
||||
// 这一跳,选中后只能得到 404 —— 候选列表在承诺一件做不到的事。
|
||||
//
|
||||
// 命中就**接管**它:本侧建一条会话并绑定 platform_id,插件收到投递
|
||||
// 事件时据此 resume 那条平台会话而不是新建。
|
||||
// 接管**是**新建本侧会话(绑定了 platform_id 的那条),
|
||||
// 所以 created 为真:它此前没有档位与预算,需要按这次投递定下来。
|
||||
if adopted, aErr := adoptFromPlatform(r, addr, fromAgent, subject, byAgent); aErr == nil {
|
||||
return adopted, nil, true, nil
|
||||
} else if !errors.Is(aErr, repo.ErrSessionNotFound) {
|
||||
return uuid.Nil, nil, false, aErr
|
||||
}
|
||||
|
||||
return uuid.Nil, nil, false, errNotFound(fmt.Sprintf(
|
||||
"无法送达:会话 %q 不存在于 %s@%s。若要新建会话请用 %s@%s.new,投递默认会话请省略 session 位",
|
||||
addr.Session, addr.Name, addr.Path, addr.Name, addr.Path))
|
||||
}
|
||||
}
|
||||
|
||||
// adoptFromPlatform 把地址里的 session 位当作**平台会话的 slug** 来解析,
|
||||
// 命中则接管那条会话。
|
||||
//
|
||||
// 返回 repo.ErrSessionNotFound 表示镜像里也没有,调用方据此回 404。
|
||||
//
|
||||
// # 为什么接管而不是直接投
|
||||
//
|
||||
// 平台会话在本侧没有身份:没有 session_id、没有预算、没法归档,
|
||||
// 也无处记录「谁往里投过什么」。接管一次之后它就是一条正常的本侧会话,
|
||||
// 只是多带一个 platform_id 告诉插件「别新建,去 resume 那条」。
|
||||
//
|
||||
// # 为什么一条平台会话只能被接管一次
|
||||
//
|
||||
// 第二次投递必须复用第一次建的本侧会话。否则同一条 TUI 对话会在邮箱里
|
||||
// 裂成多条互不相干的线索 —— 人看到三个同名会话,而回信只落在其中一条上。
|
||||
func adoptFromPlatform(r *http.Request, addr models.Address, fromAgent, subject, byAgent string) (uuid.UUID, error) {
|
||||
platformID, realWorkspace, title, err := repo.FindPlatformSession(
|
||||
r.Context(), addr.Name, addr.Session, addr.Path)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
// 已被接管过 → 复用,不再建新的
|
||||
if existing, fErr := repo.FindSessionByPlatformID(r.Context(), addr.Name, platformID); fErr == nil {
|
||||
repo.TouchSession(r.Context(), existing)
|
||||
return existing, nil
|
||||
} else if !errors.Is(fErr, repo.ErrSessionNotFound) {
|
||||
return uuid.Nil, fErr
|
||||
}
|
||||
|
||||
// 接管等于新开一条本侧线索,计入速率限制 —— 否则它成了绕过
|
||||
// AllowNewSession 的后门(镜像里有几百条 slug 可选)。
|
||||
if ok, retry := repo.AllowNewSession(r.Context(), byAgent); !ok {
|
||||
return uuid.Nil, errRateLimited(fmt.Sprintf(
|
||||
"新建会话过于频繁(1 小时内已开 %d 条)。请在已有会话里继续,或 %d 秒后再试。",
|
||||
repo.SessionRateLimit(), retry))
|
||||
}
|
||||
|
||||
// 主题优先用平台侧标题:它是那条对话在谈什么,比这封邮件的主题更能
|
||||
// 代表整条会话。人在补全里看到的也是这个标题。
|
||||
sub := strings.TrimSpace(title)
|
||||
if sub == "" {
|
||||
sub = subject
|
||||
}
|
||||
id, err := repo.AdoptPlatformSession(
|
||||
r.Context(), addr.Name, platformID, addr.Session, realWorkspace, sub)
|
||||
if err != nil {
|
||||
repo.ReleaseNewSession(r.Context(), byAgent)
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return id, 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 !DecodeBody(w, r, &req) {
|
||||
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
|
||||
}
|
||||
|
||||
// 附件可挂性必须在**建邮件之前**校验。
|
||||
//
|
||||
// 原先只在 CreateMail 之后调 attachAll,于是附件不合法时返回 403/409,
|
||||
// 但那封邮件已入库、已通知收件人、已扣预算(生产实测两封探针邮件均如此)。
|
||||
// 发件方看到 4xx 会重试,收件方于是收到两封。
|
||||
if !checkAttachable(w, r, attachIDs, agentName) {
|
||||
return
|
||||
}
|
||||
|
||||
// 可达性:收件人必须存在且未停用。Agent 侧同样要查 ——
|
||||
// 模型拿到 200 就会当作「话已传到」并停手等对方,而那封信永远不会有人读。
|
||||
if !checkDeliverable(w, r, append([]models.Address{to}, ccList...)) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, parentMailID, created, err := resolveTarget(r, to, req.ReplyTo, agentName, req.Subject, req.SessionAlias, agentName)
|
||||
if err != nil {
|
||||
writeErr(w, err, "Failed to resolve session")
|
||||
return
|
||||
}
|
||||
|
||||
// Agent 新开的会话继承权限档位,**不得自行抬档**。
|
||||
//
|
||||
// req 里根本没有 permission_mode 字段 —— 这是有意的:Agent 能指定档位
|
||||
// 就等于发一封 mode=full 的信给自己提权。档位由发信方当前所处的会话
|
||||
// (也就是「这活是谁派给我的」)推导,且只能同档或更严。
|
||||
//
|
||||
// 这保证 plan 档的任务派不出 full 档的子任务 —— 与 hop_limit 防自激同形:
|
||||
// 约束必须沿着链条传递下去,否则一跳之后就失效了。
|
||||
// 判据是 `created`:省略 session 位复用默认会话时 parentMailID 也是 nil,
|
||||
// 用后者会让每一封续谈的信重新“继承”一次 —— 而那条会话的档位可能已经
|
||||
// 被人在对话页里改过,重继承等于把人的修改静默回滚。
|
||||
if created {
|
||||
// 发信方自己那条会话的档位是上限。插件没传 from_session_id 时
|
||||
// 回落到默认档 —— 不会因为没传而拿到更大的权限。
|
||||
var parent *uuid.UUID
|
||||
if req.FromSessionID != "" {
|
||||
if pid, pErr := uuid.Parse(req.FromSessionID); pErr == nil {
|
||||
parent = &pid
|
||||
}
|
||||
}
|
||||
mode := repo.InheritedMode(r.Context(), parent, models.DefaultPermissionMode)
|
||||
if _, sErr := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); sErr != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to set permission mode")
|
||||
return
|
||||
}
|
||||
_ = repo.SetSessionEnforcement(r.Context(), sessionID,
|
||||
repo.AgentModeEnforcement(r.Context(), to.Name))
|
||||
}
|
||||
|
||||
// 配额在建邮件之前扣:否则邮件已入库再报 403,收件方会看到一封发件方以为发失败的邮件。
|
||||
// 只限制主动发信,不限制收信(卡住收信只会让邮件凭空消失)。
|
||||
//
|
||||
// 插件代劳转发(relay)走免配额通道:配额约束的是模型的自主发信,
|
||||
// 不是 harness 把平台原生的权限询问与最终总结搬到邮件里。
|
||||
relay, relayKey, err := parseRelay(req.Relay, req.RelayKey)
|
||||
if err != nil {
|
||||
writeErr(w, err, "Invalid relay")
|
||||
return
|
||||
}
|
||||
|
||||
var budget repo.SessionBudget
|
||||
// relayFree 表示本次 relay 走免配额通道。
|
||||
//
|
||||
// **免配额只给发往人类的 relay。**
|
||||
//
|
||||
// 豁免的理由是「harness 把平台原生的权限询问与最终总结搬进邮件,
|
||||
// 不该算模型的自主发信」—— 而那是**假定收件方是人**写的。
|
||||
// 收件方是另一个同样会自动转发的 Agent 时,双方都不在做决定,
|
||||
// 整个回路里没有任何一处在计数 —— 生产上跑出过 41 封且间隔从
|
||||
// 15 分钟缩到 5 秒的无穷循环(会话 f3d824ce)。
|
||||
//
|
||||
// 因此 Agent→Agent 的 relay 照样扣会话预算,max_rounds 就能截断它。
|
||||
relayFree := false
|
||||
if relay != "" {
|
||||
human, hErr := repo.IsHumanUser(r.Context(), to.Name)
|
||||
if hErr != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to resolve recipient")
|
||||
return
|
||||
}
|
||||
relayFree = human
|
||||
}
|
||||
|
||||
if relay != "" {
|
||||
// 硬上限:一条会话里**连续**的 relay 邮件不得超过上限。
|
||||
//
|
||||
// 与预算无关的第二道防线:预算给得大(比如 200)时,两个 Agent 仍能
|
||||
// 烧掉 200 个来回;而故障报告这类**必须**走 relay 的邮件也需要受约束。
|
||||
//
|
||||
// 「连续」是关键:中间只要有一封自主发信或人类插话,计数就归零。
|
||||
hops, hopErr := repo.CountTrailingRelayHops(r.Context(), sessionID)
|
||||
if hopErr == nil && hops >= repo.MaxRelayHops() {
|
||||
Error(w, http.StatusForbidden, fmt.Sprintf(
|
||||
"本会话已连续 %d 封自动转发(上限 %d)。这通常意味着两个 Agent 在互相"+
|
||||
"唤醒而无人决策。若确实需要继续,请由模型主动调 send_mail(不带 relay),"+
|
||||
"或由人类在会话里插一句话。",
|
||||
hops, repo.MaxRelayHops()))
|
||||
return
|
||||
}
|
||||
|
||||
// 先占幂等键。重复则说明这条上游消息已经转过,
|
||||
// 这是插件重试 / 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
|
||||
}
|
||||
}
|
||||
|
||||
if relayFree {
|
||||
// 只读快照用于回传,不扣预算
|
||||
budget, _ = repo.GetSessionBudget(r.Context(), sessionID)
|
||||
} else {
|
||||
// 额度只看【本任务】的往返预算。
|
||||
//
|
||||
// 不再叠一层 Agent 终身额度:那种额度跑满后要管理员手工重置才能再干活,
|
||||
// 而 Agent 是长期在线的。防止 Agent 用 .new 开一串新会话绕过预算,
|
||||
// 靠的是新建会话速率限制(resolveTarget 里)。
|
||||
budget, err = repo.ConsumeSessionBudget(r.Context(), sessionID)
|
||||
if errors.Is(err, repo.ErrSessionBudgetExhausted) {
|
||||
// 预算耗尽时要把幂等键还回去:否则那条上游消息永远转不出来了,
|
||||
// 之后管理员加了额度也无法重发。
|
||||
if relay != "" {
|
||||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||||
}
|
||||
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
|
||||
}
|
||||
// 纯统计,不拦请求;写失败也不该让邮件发不出去
|
||||
repo.BumpSentCount(r.Context(), agentName)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 走到这里说明碰上了 checkAttachable 之后的竞态窗口(另一个请求把同一个
|
||||
// 附件挂走了)。必须回滚已产生的副作用,否则收件方会拿到一封没有附件的
|
||||
// 邮件,而发件方以为整次请求失败了。
|
||||
//
|
||||
// 三件事都要退:邮件本身、本次往返预算、relay 幂等键。
|
||||
// 错误均忽略:响应已由 attachAll 写出,回滚失败只能记日志。
|
||||
_ = repo.DeleteMailByID(r.Context(), mailID)
|
||||
if !relayFree {
|
||||
repo.RefundSessionBudget(r.Context(), sessionID)
|
||||
}
|
||||
if relay != "" {
|
||||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
notifyRecipients(r.Context(), to, ccList, sessionID, mailID, agentName, req.Subject, parentIDString(parentMailID))
|
||||
|
||||
// 回传会话别名与本任务剩余往返,让发件方知道后续用什么地址续谈、还能发几封
|
||||
resp := map[string]any{
|
||||
"mail_id": mailID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
"session_alias": repo.SessionAliasOf(r.Context(), sessionID),
|
||||
}
|
||||
// 预算属于【本任务】,不限时不回传 —— 多给一个 -1 只会让插件去判断哪个值是哨兵
|
||||
if !budget.Unlimited {
|
||||
resp["budget_remaining"] = budget.Remaining
|
||||
resp["budget_used"] = budget.Used
|
||||
resp["budget_max"] = budget.Max
|
||||
}
|
||||
if relay != "" {
|
||||
// 告知本次未扣预算,否则插件看到 budget_remaining 没变会以为数据错了
|
||||
resp["relay"] = relay
|
||||
resp["budget_charged"] = false
|
||||
}
|
||||
if proposal != nil {
|
||||
// 回传规范化后的别名:Agent 提的名字可能含非法字符被改写过,
|
||||
// 让它知道最终会拿什么去问用户
|
||||
resp["rename_proposed"] = proposal.Alias
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// notifyRecipients 是 notify.Recipients 的薄封装,保留旧签名减少调用点改动。
|
||||
//
|
||||
// 实现只有一份,在 internal/notify 里 —— 此前 handler 与 scheduler 各写一份,
|
||||
// 加字段时漏改一处直接造成生产事故(详见那个包的注释)。
|
||||
//
|
||||
// parentMailID 为空字串表示这不是回信。
|
||||
func notifyRecipients(ctx context.Context, to models.Address, cc []models.Address, sessionID, mailID uuid.UUID, from, subject, parentMailID string) {
|
||||
notify.Recipients(ctx, notify.Mail{
|
||||
SessionID: sessionID,
|
||||
MailID: mailID,
|
||||
From: from,
|
||||
To: to,
|
||||
CC: cc,
|
||||
Subject: subject,
|
||||
ParentMailID: parentMailID,
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
ptrs := make([]*models.Mail, len(mails))
|
||||
for i := range mails {
|
||||
ptrs[i] = &mails[i]
|
||||
}
|
||||
fillAttachments(r, ptrs...)
|
||||
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
|
||||
}
|
||||
|
||||
type markReadRequest struct {
|
||||
// MailIDs 要标记为已读的邮件;省略/为空 = 把收件箱里全部未读标掉。
|
||||
MailIDs []string `json:"mail_ids"`
|
||||
}
|
||||
|
||||
// POST /api/v1/mail/read —— Agent 侧批量标记已读
|
||||
//
|
||||
// 为什么需要它:Agent 读完 read_inbox 后没有任何办法把邮件标掉,
|
||||
// 于是每次拉收件箱都把同一批旧邮件重新捞出来 —— 处理过的信和新来的信混在一起,
|
||||
// 模型分不清哪封该回。心跳里的未读数也永远只增不减。
|
||||
//
|
||||
// 鉴权写进 UPDATE 的 WHERE 而不是先查后改:不是发给自己的邮件根本改不动,
|
||||
// 既省一次查询,也没有「查完到改之间邮件被转走」的时间窗。
|
||||
func MarkInboxRead(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req markReadRequest
|
||||
// 允许空 body:`POST /mail/read` 不带任何内容 = 全部标掉
|
||||
if r.ContentLength > 0 {
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 不给 id 就把收件箱里全部未读标掉。
|
||||
// 这是 Agent 最常见的用法:一轮处理完,剩下的都不必再看。
|
||||
if len(req.MailIDs) == 0 {
|
||||
n, err := repo.MarkAllInboxReadFor(r.Context(), agentName)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to mark read")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"status": "read", "marked": n, "scope": "all"})
|
||||
return
|
||||
}
|
||||
|
||||
const maxBatch = 200
|
||||
if len(req.MailIDs) > maxBatch {
|
||||
Error(w, http.StatusBadRequest, fmt.Sprintf("一次最多标记 %d 封", maxBatch))
|
||||
return
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(req.MailIDs))
|
||||
for _, s := range req.MailIDs {
|
||||
id, err := uuid.Parse(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "非法的 mail_id: "+s)
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
n, err := repo.MarkMailsReadFor(r.Context(), agentName, ids)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to mark read")
|
||||
return
|
||||
}
|
||||
// 不因为「有些 id 不是发给你的」而报错:那些 id 只是没被标掉。
|
||||
// 报错会让整批失败,而 Agent 通常是把上一轮列出的 id 原样传回来,
|
||||
// 其中可能混着已读的(幂等)——那不该是错误。
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "read",
|
||||
"marked": n,
|
||||
"requested": len(ids),
|
||||
})
|
||||
}
|
||||
|
||||
// parentIDString 把可空的父邮件 id 转成字符串(nil → 空串)。
|
||||
//
|
||||
// 空串在 SSE payload 里的语义是「这不是回信」—— 插件据此选提示词。
|
||||
func parentIDString(id *uuid.UUID) string {
|
||||
if id == nil {
|
||||
return ""
|
||||
}
|
||||
return id.String()
|
||||
}
|
||||
392
server/internal/handler/me.go
Normal file
392
server/internal/handler/me.go
Normal file
@ -0,0 +1,392 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"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"`
|
||||
|
||||
// PermissionMode 声明本任务允许 Agent 动手到什么程度:plan / workspace / full。
|
||||
//
|
||||
// 与 MaxRounds 同理,**仅在本次投递【新建】会话时生效**:续谈已有会话若也接受
|
||||
// 这个字段,每封新信都会悄悄改掉对方正在遵守的规则 —— 而 plan 档的会话里
|
||||
// 模型已经被告知「只许看」,第二封信把它改成 full 是在一段已有上下文里换规则。
|
||||
// 续谈请用 PUT /sessions/{id}/permission(对话页里可随时改)。
|
||||
//
|
||||
// 省略时用 models.DefaultPermissionMode(workspace)。
|
||||
PermissionMode string `json:"permission_mode"`
|
||||
}
|
||||
|
||||
// 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 !DecodeBody(w, r, &req) {
|
||||
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
|
||||
}
|
||||
|
||||
// 附件可挂性必须在**建邮件之前**校验(与 Agent 侧同理,见 mail.go)。
|
||||
if !checkAttachable(w, r, attachIDs, user.Username) {
|
||||
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
|
||||
}
|
||||
|
||||
// 可达性:收件人必须存在且未停用,否则邮件进黑洞
|
||||
if !checkDeliverable(w, r, append([]models.Address{to}, ccList...)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 纯输入校验必须在建会话【之前】做完。
|
||||
//
|
||||
// 原来两项校验都在 resolveTarget 之后:请求返回 400,但 `.new` 已经建好了
|
||||
// 会话、占掉了新建速率名额、并留下一条谁也不会再用的空线索。实测发 5 封
|
||||
// 非法请求就攒下 5 条垃圾会话。校验不依赖会话,本来就该先做。
|
||||
rounds := -1
|
||||
if req.MaxRounds != nil {
|
||||
if *req.MaxRounds < 0 {
|
||||
Error(w, http.StatusBadRequest, "max_rounds 不能为负")
|
||||
return
|
||||
}
|
||||
rounds = *req.MaxRounds
|
||||
}
|
||||
if !validPermissionModeInput(w, req.PermissionMode) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, parentMailID, created, 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)
|
||||
|
||||
// 新建会话时定往返预算。只在新建时设:续谈已有会话若也接受这个字段,
|
||||
// 每封新信都会悄悄改掉对方正在遵守的预算,人却不一定意识到自己改了。
|
||||
//
|
||||
// 没显式给就用【收件 Agent 的默认值】。默认值挂在 Agent 上而不是全站一个数:
|
||||
// 跑测试的小工具与重构整个模块的 Agent,合理来回数差一个量级。
|
||||
// 判据是 `created` 而不是 `parentMailID == nil`:后者在「省略 session 位复用
|
||||
// 默认会话」时也成立,于是第二封信会把对方正在遵守的预算改写成默认值
|
||||
//(实测:max_rounds=7 的会话被第二封省略该字段的信改成 20)。
|
||||
if created {
|
||||
if rounds < 0 {
|
||||
rounds = repo.DefaultRoundsFor(r.Context(), to.Name)
|
||||
}
|
||||
if _, err := repo.SetSessionBudget(r.Context(), sessionID, rounds); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to set session budget")
|
||||
return
|
||||
}
|
||||
|
||||
// 权限档位同样只在新建时定。人可以直接指定(不继承)—— 人就是权限的源头,
|
||||
// 而 Agent 侧的 SendMail 走 InheritedMode 不得自行抬档。
|
||||
mode := models.NormalizePermissionMode(req.PermissionMode)
|
||||
if _, err := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to set permission mode")
|
||||
return
|
||||
}
|
||||
// 强制力是事实快照:按收件 Agent 当下自报的能力定死。
|
||||
// 收件方是人类时也走这里 —— AgentModeEnforcement 查不到就返回 advisory,
|
||||
// 而人的收件箱本来不执行任何档位,这个值对他无意义也无害。
|
||||
_ = repo.SetSessionEnforcement(r.Context(), sessionID,
|
||||
repo.AgentModeEnforcement(r.Context(), to.Name))
|
||||
}
|
||||
|
||||
// 续谈已有会话时,人也可以显式改档位。人是权限的源头,
|
||||
// 可以任改三档——与 Agent 不同,人没有「只能同档或更严」的约束。
|
||||
if !created && req.PermissionMode != "" {
|
||||
mode := models.NormalizePermissionMode(req.PermissionMode)
|
||||
if _, err := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to update permission mode")
|
||||
return
|
||||
}
|
||||
_ = repo.SetSessionEnforcement(r.Context(), sessionID,
|
||||
repo.AgentModeEnforcement(r.Context(), to.Name))
|
||||
}
|
||||
|
||||
// 人类侧不产生改名提议(人直接有改名按钮,用不着向自己提议),
|
||||
// 但仍然剥掉标记:粘贴进正文时它会被渲染成一行可见的转义文本。
|
||||
_, 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) {
|
||||
// 竞态窗口(见 mail.go 同位置):回滚那封已入库的邮件。
|
||||
// 人类发信不扣会话预算、也不走 relay,所以只需退邮件本身。
|
||||
_ = repo.DeleteMailByID(r.Context(), mailID)
|
||||
return
|
||||
}
|
||||
|
||||
notifyRecipients(r.Context(), to, ccList, sessionID, mailID, user.Username, req.Subject, parentIDString(parentMailID))
|
||||
|
||||
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
|
||||
}
|
||||
// 列表页要显示附件图标与下载入口
|
||||
ptrs := make([]*models.Mail, len(mails))
|
||||
for i := range mails {
|
||||
ptrs[i] = &mails[i]
|
||||
}
|
||||
fillAttachments(r, ptrs...)
|
||||
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 {
|
||||
ptrs := make([]*models.Mail, len(mails))
|
||||
for i := range mails {
|
||||
ptrs[i] = &mails[i]
|
||||
}
|
||||
fillAttachments(r, ptrs...)
|
||||
}
|
||||
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"`
|
||||
// 往返预算随列表一并返回:预算是【任务】的属性,
|
||||
// 工作列表上就应当看得见哪些任务快跑满了,
|
||||
// 而不是点进去一个一个查。
|
||||
MaxRounds int `json:"max_rounds"`
|
||||
UsedRounds int `json:"used_rounds"`
|
||||
}
|
||||
|
||||
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,
|
||||
MaxRounds: s.MaxRounds,
|
||||
UsedRounds: s.UsedRounds,
|
||||
})
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
|
||||
// checkDeliverable 校验每个收件人(含拄送)当前能不能收信,写好响应并返回 false 表示已拒绝。
|
||||
//
|
||||
// 拄送位同样要查:不查的话 cc 就成了绕过口 —— 把已删除的 Agent 放到 cc 位
|
||||
// 依旧能把邮件送进黑洞,而且因为不是主收件人更不容易被发现。
|
||||
func checkDeliverable(w http.ResponseWriter, r *http.Request, addrs []models.Address) bool {
|
||||
for _, a := range addrs {
|
||||
err := repo.RecipientDeliverable(r.Context(), a.Name)
|
||||
switch {
|
||||
case err == nil:
|
||||
continue
|
||||
case errors.Is(err, repo.ErrRecipientUnknown):
|
||||
Error(w, http.StatusNotFound,
|
||||
"收件人不存在:"+a.Name+"。它既不是人类用户也不是已注册的 Agent(可能已被删除)。")
|
||||
return false
|
||||
case errors.Is(err, repo.ErrRecipientDisabled):
|
||||
Error(w, http.StatusConflict,
|
||||
"Agent \""+a.Name+"\" 已被管理员停用,现在不接收新任务。请先在管理页恢复它。")
|
||||
return false
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "无法校验收件人状态")
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
116
server/internal/handler/models_scope.go
Normal file
116
server/internal/handler/models_scope.go
Normal file
@ -0,0 +1,116 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// ---------- 邮件场景下的可用模型 ----------
|
||||
//
|
||||
// GET /agent/models/allowed 读取被允许的模型(Agent 凭证)
|
||||
// GET /admin/agents/{name}/models 管理员读目录 + 已选
|
||||
// PUT /admin/agents/{name}/models 管理员保存选择与优先级
|
||||
//
|
||||
// **目录上报走心跳**(见 agents.go 的 heartbeatRequest.Models),不另设端点:
|
||||
// 模型清单会在运行中变(换 provider 配置、上游上下线、换 API key),
|
||||
// 心跳本来就是 30 秒一次的现成通道。另设一个 POST 等于给「目录是谁写的」
|
||||
// 这个问题留两个答案,排查时要同时看两处。
|
||||
//
|
||||
// 生效的模型范围同样随心跳响应回传(allowed_models),因此插件通常不需要调
|
||||
// 下面这个 GET —— 它是给非插件的第三方客户端(没有心跳循环)与排查用的。
|
||||
|
||||
// GET /api/v1/agent/models/allowed —— 插件读取被允许的模型
|
||||
//
|
||||
// 返回按优先级排序的列表。空列表表示**不限定**,插件应回退到平台自己的默认模型
|
||||
// —— 与「一个都不许用」不同,后者等于让 Agent 彻底哑掉,不该是一次误配的后果。
|
||||
func GetAllowedModels(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
models, err := repo.ListAllowedModels(r.Context(), agentName)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list allowed models")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"models": models,
|
||||
// unrestricted 明确表达「没配 = 不限」,省得插件自己去判断空数组的含义
|
||||
"unrestricted": len(models) == 0,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/agents/{name}/models —— 管理员读目录(带已选标记)
|
||||
func AdminListAgentModels(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimSpace(chi.URLParam(r, "name"))
|
||||
if name == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing agent name")
|
||||
return
|
||||
}
|
||||
catalog, err := repo.ListModelCatalog(r.Context(), name)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list model catalog")
|
||||
return
|
||||
}
|
||||
// 已选但已不在目录里的模型要单独给出来:平台可能临时下线了某个模型,
|
||||
// 界面上不显示的话管理员会以为自己没选过它,而它其实还在被插件尝试。
|
||||
stale, err := repo.ListStaleAllowedModels(r.Context(), name)
|
||||
if err != nil {
|
||||
stale = []repo.ModelRef{}
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"agent_name": name,
|
||||
"catalog": catalog,
|
||||
"stale": stale,
|
||||
})
|
||||
}
|
||||
|
||||
// PUT /api/v1/admin/agents/{name}/models —— 管理员保存选择
|
||||
//
|
||||
// 入参顺序即优先级(rank)。插件按这个顺序逐个尝试,全部失败才回一封失败邮件。
|
||||
func AdminSetAgentModels(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 struct {
|
||||
Models []repo.ModelRef `json:"models"`
|
||||
}
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if len(req.Models) > maxAllowedModels {
|
||||
Error(w, http.StatusBadRequest,
|
||||
"选定的模型过多(上限 "+strconv.Itoa(maxAllowedModels)+" 个)")
|
||||
return
|
||||
}
|
||||
|
||||
if err := repo.SetAllowedModels(r.Context(), name, req.Models); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to save allowed models")
|
||||
return
|
||||
}
|
||||
// 回传保存后的实际结果而不是回显入参:repo 层会跳过重复项与空字段,
|
||||
// 回显入参会让前端以为那些也存下来了。
|
||||
saved, err := repo.ListAllowedModels(r.Context(), name)
|
||||
if err != nil {
|
||||
saved = []repo.ModelRef{}
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "saved",
|
||||
"models": saved,
|
||||
})
|
||||
}
|
||||
|
||||
// maxAllowedModels 限制管理员能选多少个模型。
|
||||
//
|
||||
// 降级尝试是串行的:选 50 个意味着最坏情况下一封邮件要等 50 次模型调用超时。
|
||||
// 十个已经足够表达「主力 + 几个备选」。
|
||||
const maxAllowedModels = 10
|
||||
383
server/internal/handler/permission.go
Normal file
383
server/internal/handler/permission.go
Normal file
@ -0,0 +1,383 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ---------- 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"`
|
||||
// Kind 区分待办类型:"permission"(危险工具审批,默认)或 "question"
|
||||
// (Agent 主动询问)。主动询问不套权限档位判定 —— plan/full 档也可能需要
|
||||
// 补充信息,审批档不能拦它。
|
||||
Kind string `json:"kind"`
|
||||
// MultiSelect 仅 question 使用:ask_user_question 的多选语义。
|
||||
MultiSelect bool `json:"multi_select"`
|
||||
}
|
||||
|
||||
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 !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Question == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing question")
|
||||
return
|
||||
}
|
||||
|
||||
// 校验请求类型。空串按 permission 处理(历史客户端不传也不会被拒)。
|
||||
kind := strings.TrimSpace(req.Kind)
|
||||
if kind == "" {
|
||||
kind = "permission"
|
||||
}
|
||||
if kind != "permission" && kind != "question" {
|
||||
Error(w, http.StatusBadRequest, `kind 只能是 ""、"permission" 或 "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 {
|
||||
// workspace 空串:权限询问不经三维寻址,没有 path 位可归属。
|
||||
id, err := repo.CreateSession(r.Context(), nil, agentName, "权限请求: "+req.Question, "")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create session")
|
||||
return
|
||||
}
|
||||
sessionID = id
|
||||
}
|
||||
|
||||
// 权限档位决定审批型询问该不该存在。**主动询问(question)不受此约束**:
|
||||
// 无论 plan/full,模型都可能需要向人补充信息,拦住就是阻塞整个任务。
|
||||
//
|
||||
// 审批型只有 workspace 档需要人:
|
||||
// - plan 档 → 409。该档的语义就是「这轮不动手」,没什么可问人的,
|
||||
// 模型该做的是把方案写在回信里。
|
||||
// - full 档 → 409。已经声明全权,再问一遍只是噪音;插件本不该发这封信,
|
||||
// 发了说明它没按档位翻译,报错比静默接受好。
|
||||
//
|
||||
// 这也是为什么下面不再有「退回第一个管理员」的兜底:
|
||||
// 既然只有一档需要人,那一档里找不到人就是 409,没有中间形态。
|
||||
mode := repo.SessionPermissionMode(r.Context(), sessionID)
|
||||
needHuman := kind != "question" && !models.ModeNeedsHuman(mode)
|
||||
if needHuman {
|
||||
if relayKey != "" {
|
||||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||||
}
|
||||
detail := "本会话的权限档位是 " + mode + ",不产生权限询问。"
|
||||
suggestion := ""
|
||||
if mode == models.ModePlan {
|
||||
suggestion = "plan 档只允许读与查。请不要尝试写入或执行命令," +
|
||||
"把方案、需要人工执行的步骤写在回信里。如需动手,请请发件人把档位改成 workspace。"
|
||||
} else {
|
||||
suggestion = "full 档下工具调用无需审批,插件不应该转发权限询问。" +
|
||||
"这通常意味着插件没按会话档位配置平台的审批策略。"
|
||||
}
|
||||
JSON(w, http.StatusConflict, map[string]interface{}{
|
||||
"error": "本会话不接受权限询问(档位 " + mode + ")",
|
||||
"detail": detail,
|
||||
"suggestion": suggestion,
|
||||
"permission_mode": mode,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 决策人:显式指定优先,否则取会话 owner,再否则沿线索找最近的人类。
|
||||
//
|
||||
// **不再退回第一个管理员**。那段兜底让下面的 409 分支永远不可达:
|
||||
// decider 空 → 填上管理员 → IsHumanUser 通过 → NearestHumanInThread 根本不会被调用。
|
||||
// 实测:pi 给自己新开会话派活跑 bash,权限邮件 to_name=jianf,而那条链上
|
||||
// 没有任何人类参与过。而且那段 409 自己的注释就在论证兜底是错的:
|
||||
// 「管理员对这条 Agent 链的上下文一无所知」。两条策略互相矛盾,
|
||||
// 先执行的那条把后写的那条变成了死代码。
|
||||
decider := req.To
|
||||
if decider == "" || decider == "human" {
|
||||
owner, err := repo.SessionOwnerUsername(r.Context(), sessionID)
|
||||
if err == nil && owner != "" {
|
||||
decider = owner
|
||||
}
|
||||
}
|
||||
|
||||
// 关键防线:decider 必须是人类用户。
|
||||
//
|
||||
// Agent 无法通过 Web UI 决策权限 —— SendToUser 投递到不存在的用户通道,
|
||||
// 而桥的 await Promise 永不 resolve,会话永久阻塞。这在 Agent 给自己发信时
|
||||
// 必然发生:pi 分配任务给自己的另一个会话 → 该会话触发权限询问 → 邮件发给 pi
|
||||
// → pi 不是人类用户 → 整条会话卡死。
|
||||
//
|
||||
// 修复:沿会话树上溯找最近的人类节点 —— 权限应追溯到最初分配任务的人。
|
||||
if isHuman, _ := repo.IsHumanUser(r.Context(), decider); !isHuman {
|
||||
human, err := repo.NearestHumanInThread(r.Context(), sessionID, decider)
|
||||
if err == nil && human != "" {
|
||||
decider = human
|
||||
} else {
|
||||
// 整条任务链上没有人类:Agent → Agent → Agent,中间没有任何人介入。
|
||||
//
|
||||
// 这条分支曾经**永远不可达**:上游有一段「退回第一个管理员」的兜底,
|
||||
// 把 decider 填成 admin,IsHumanUser 于是通过,这里根本不会被调用。
|
||||
// 实测:pi 给自己新开会话派活跑 bash → 权限邮件 to_name=jianf。
|
||||
// 那段兜底已删(参见上面的档位判定)。
|
||||
//
|
||||
// 为什么不该转给管理员:管理员对这条 Agent 链的上下文一无所知,
|
||||
// 既不知道这个 bash 命令在做什么,也不知道拒绝后 Agent 该怎么绕过去。
|
||||
//
|
||||
// 正确做法:直接拒绝,让 Agent 收到明确的错误信息,由它自己决定下一步:
|
||||
// 换用不需要权限的方式(subprocess、文件操作等),或在邮件里说明情况让上游转给人类。
|
||||
if relayKey != "" {
|
||||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||||
}
|
||||
JSON(w, http.StatusConflict, map[string]interface{}{
|
||||
"error": "权限询问无法送达:该任务链上没有人类用户",
|
||||
"detail": "整条任务都是 Agent 之间的邮件往来,没有人类参与决策。请换用不需要权限的方式完成此操作,或在回复中说明情况让上游转达给人类。",
|
||||
"suggestion": "考虑用 subprocess/file 工具替代需要权限的工具,或通过邮件向上游请求人类协助。",
|
||||
"decider_was": decider,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
body := req.Context
|
||||
if body == "" {
|
||||
body = req.Question
|
||||
}
|
||||
mailID, err := repo.CreatePermissionMail(r.Context(), sessionID, agentName, decider, req.Question, body, options, kind, req.MultiSelect)
|
||||
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, kind, req.MultiSelect); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create permission request")
|
||||
return
|
||||
}
|
||||
|
||||
// 只推给该决策人。
|
||||
//
|
||||
// 这一处不走 notify.Recipients:那个函数推给「三维地址解析出的参与方」,
|
||||
// 而权限询问的投递对象是逐会话树找出来的人类决策人(NearestHumanInThread),
|
||||
// 不是一个地址 —— 抄送也不应当收到它(权限是待办,不是广播)。
|
||||
//
|
||||
// 但 payload 必须带足字段:前端的授权页靠 session_alias + 会话 workspace
|
||||
// 拼出「哪个 Agent、在哪个目录、哪条线索」。只给 from_name 的话人
|
||||
// 看到的只是一个光秃的 Agent 名,无法判断该不该批。
|
||||
alias := repo.SessionAliasOf(r.Context(), sessionID)
|
||||
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",
|
||||
"session_alias": alias,
|
||||
})
|
||||
|
||||
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 !DecodeBody(w, r, &req) {
|
||||
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
|
||||
}
|
||||
|
||||
// 决策选项必须在候选内 —— 仅限审批型。主动询问允许自由文本回答,
|
||||
// 多选时 decision 是多个原始标签(前端用换行分隔),同样不套暂时选项表。
|
||||
if perm.Kind != "question" && !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,
|
||||
"kind": perm.Kind,
|
||||
"multi_select": perm.MultiSelect,
|
||||
// 会话 id:插件重启丢了待决映射时,会退化成「把决策当一封通知投进会话」,
|
||||
// 那条路径要靠这个字段找到原会话,否则会凭空另开一个。
|
||||
"session_id": perm.SessionID.String(),
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
75
server/internal/handler/ratelimit.go
Normal file
75
server/internal/handler/ratelimit.go
Normal file
@ -0,0 +1,75 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
)
|
||||
|
||||
// 登录失败限速:同一用户名连续 N 次失败后锁定一段时间。
|
||||
// 用 DB 而非进程内内存计数器,多实例部署时各实例共享同一份计数。
|
||||
const (
|
||||
maxLoginFailures = 5
|
||||
lockoutDuration = 5 * time.Minute
|
||||
failureWindow = 15 * time.Minute
|
||||
)
|
||||
|
||||
// LoginLimiter 通过 DB 实现的登录失败限速器。
|
||||
type LoginLimiter struct{}
|
||||
|
||||
var limiter = &LoginLimiter{}
|
||||
|
||||
// Locked 返回该用户名是否处于锁定期,以及剩余秒数。
|
||||
// 不记账,只读。
|
||||
func (l *LoginLimiter) Locked(ctx context.Context, name string) (bool, int) {
|
||||
bucket := "login:" + name
|
||||
cutoff := time.Now().Add(-failureWindow)
|
||||
|
||||
var count int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM rate_limits WHERE bucket = $1 AND ts >= $2`,
|
||||
bucket, cutoff).Scan(&count)
|
||||
if err != nil || count < maxLoginFailures {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// 找到最早那条记录 + lockoutDuration = 解锁时间
|
||||
var earliest time.Time
|
||||
err = db.DB.QueryRowContext(ctx,
|
||||
`SELECT MIN(ts) FROM rate_limits WHERE bucket = $1 AND ts >= $2`,
|
||||
bucket, cutoff).Scan(&earliest)
|
||||
if err != nil || earliest.IsZero() {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
unlockAt := earliest.Add(lockoutDuration)
|
||||
now := time.Now()
|
||||
if now.Before(unlockAt) {
|
||||
remain := int(unlockAt.Sub(now).Seconds()) + 1
|
||||
if remain < 1 {
|
||||
remain = 1
|
||||
}
|
||||
return true, remain
|
||||
}
|
||||
|
||||
// 锁定期已过,清理旧记录
|
||||
db.DB.ExecContext(ctx, `DELETE FROM rate_limits WHERE bucket = $1 AND ts < $2`,
|
||||
bucket, unlockAt)
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// Fail 记录一次登录失败。达到阈值时不额外标记 ——
|
||||
// Locked() 用 COUNT >= maxLoginFailures 自然判定锁定。
|
||||
func (l *LoginLimiter) Fail(ctx context.Context, name string) {
|
||||
bucket := "login:" + name
|
||||
db.DB.ExecContext(ctx,
|
||||
`INSERT INTO rate_limits (bucket, ts) VALUES ($1, $2)`,
|
||||
bucket, time.Now())
|
||||
}
|
||||
|
||||
// Reset 登录成功后清除失败计数。
|
||||
func (l *LoginLimiter) Reset(ctx context.Context, name string) {
|
||||
bucket := "login:" + name
|
||||
db.DB.ExecContext(ctx, `DELETE FROM rate_limits WHERE bucket = $1`, bucket)
|
||||
}
|
||||
85
server/internal/handler/relay_test.go
Normal file
85
server/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
server/internal/handler/rename_proposal.go
Normal file
141
server/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
server/internal/handler/rename_proposal_test.go
Normal file
143
server/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("截断产生了替换符,说明切在多字节字符中间")
|
||||
}
|
||||
}
|
||||
}
|
||||
397
server/internal/handler/sessions.go
Normal file
397
server/internal/handler/sessions.go
Normal file
@ -0,0 +1,397 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// ---------- 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
|
||||
}
|
||||
// 会话线程要展示附件,逐封填充。
|
||||
//
|
||||
// 这里漏掉过:前端的会话视图走的是本端点(GET /sessions/{id}),
|
||||
// 而不是下面那个 /sessions/{id}/mails —— 后者填了附件但没人调用,
|
||||
// 于是 Agent 回信里的附件在 UI 上完全不存在。
|
||||
ptrs := make([]*models.Mail, len(mails))
|
||||
for i := range mails {
|
||||
ptrs[i] = &mails[i]
|
||||
}
|
||||
fillAttachments(r, ptrs...)
|
||||
|
||||
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
|
||||
}
|
||||
// 会话线程要展示附件,逐封填充
|
||||
ptrs := make([]*models.Mail, len(mails))
|
||||
for i := range mails {
|
||||
ptrs[i] = &mails[i]
|
||||
}
|
||||
fillAttachments(r, ptrs...)
|
||||
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 !DecodeBody(w, r, &req) {
|
||||
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 !DecodeBody(w, r, &req) {
|
||||
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"`
|
||||
}
|
||||
|
||||
type updatePermissionRequest struct {
|
||||
// PermissionMode 三档 plan / workspace / full。
|
||||
//
|
||||
// 对话页里人可随时改,改了即时生效(不继承、不限「只能同档或更严」——
|
||||
// 那是 Agent 主动派子任务时的约束;人改档是对一条已有会话的明示意愿,
|
||||
// 可以从 plan 直接调到 full)。脏值 fail-closed 到默认档而不是 full。
|
||||
PermissionMode string `json:"permission_mode"`
|
||||
}
|
||||
|
||||
// PUT /api/v1/sessions/{id}/permission
|
||||
//
|
||||
// 对话页里随时调档位。与 budget 同位置编辑:两者都是任务的属性,
|
||||
// 人看着往来内容才知道「这件事现在该收紧还是放开」。
|
||||
//
|
||||
// 人类可以任改三档(包括从 plan 提到 full —— 人是权限的源头);
|
||||
// Agent 不经此端点(Agent 改档须走发信继承路径,不得自行提权)。
|
||||
func UpdateSessionPermission(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req updatePermissionRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
mode := models.NormalizePermissionMode(req.PermissionMode)
|
||||
if mode == "" {
|
||||
mode = models.DefaultPermissionMode
|
||||
}
|
||||
perm, err := repo.SetSessionPermissionMode(r.Context(), sessionID, mode)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to update permission mode")
|
||||
return
|
||||
}
|
||||
|
||||
// 强制力不在此刷新:它是「平台能力」的事实快照,在会话建立时定死
|
||||
// (见 repo.SetSessionEnforcement 的注释)。人改档位不改变平台的能力,
|
||||
// 插件升级才改变 —— 那要等新投递/新会话才会反映。
|
||||
|
||||
sse.Default.Broadcast("session_update", map[string]interface{}{
|
||||
"session_id": sessionID.String(),
|
||||
"permission_mode": perm.Mode,
|
||||
"permission_enforcement": perm.Enforcement,
|
||||
})
|
||||
JSON(w, http.StatusOK, perm)
|
||||
}
|
||||
|
||||
// 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 !DecodeBody(w, r, &req) {
|
||||
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)
|
||||
}
|
||||
247
server/internal/handler/strictdecode_test.go
Normal file
247
server/internal/handler/strictdecode_test.go
Normal file
@ -0,0 +1,247 @@
|
||||
package handler
|
||||
|
||||
// 严格解码的回归测试。
|
||||
//
|
||||
// 事故背景(生产实测):homeagent 插件的 send_mail 传的是
|
||||
// `attachments: [{"attachment_id": …}]`,而服务端要的是 `attachment_ids: ["…"]`。
|
||||
// 宽容解码让这变成一种**静默成功**:
|
||||
//
|
||||
// POST /mail/send {…,"attachments":[{"attachment_id":"598f100e…"}]}
|
||||
// → HTTP 200 {"mail_id":"2a64fdc8…"}
|
||||
// → SELECT COUNT(*) FROM attachments WHERE mail_id='2a64fdc8…' → 0
|
||||
//
|
||||
// 邮件发出去了、附件一个都没带、没有任何一层报错。那个 bug 活了很久,
|
||||
// 正因为没人会去核对一个返回 200 的请求。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 这就是那次事故的最小复现:把 attachment_ids 写成 attachments。
|
||||
func TestStrictDecodeRejectsMisspelledField(t *testing.T) {
|
||||
type sendReq struct {
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/mail/send", strings.NewReader(
|
||||
`{"to":"jianf","subject":"x","attachments":[{"attachment_id":"598f100e"}]}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var req sendReq
|
||||
if DecodeBody(w, r, &req) {
|
||||
t.Fatal("拼错的字段名必须被拒绝 —— 否则又是一次静默成功")
|
||||
}
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("状态码应为 400,实际 %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("响应不是 JSON: %v", err)
|
||||
}
|
||||
msg := resp["error"]
|
||||
|
||||
// 报出错的字段名
|
||||
if !strings.Contains(msg, "attachments") {
|
||||
t.Errorf("信息里应指出 attachments,实际 %q", msg)
|
||||
}
|
||||
// **并且**列出对的拼法 —— 少了这半句,调用方仍要去翻服务端源码,
|
||||
// 而拼错字段名恰恰是最容易犯、最难自查的错
|
||||
if !strings.Contains(msg, "attachment_ids") {
|
||||
t.Errorf("信息里应列出正确字段 attachment_ids,实际 %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 负向对照:合法字段必须原样通过,不能被严格解码误伤。
|
||||
func TestStrictDecodeAcceptsCorrectField(t *testing.T) {
|
||||
type sendReq struct {
|
||||
To string `json:"to"`
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/mail/send", strings.NewReader(
|
||||
`{"to":"jianf","attachment_ids":["a","b"]}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var req sendReq
|
||||
if !DecodeBody(w, r, &req) {
|
||||
t.Fatalf("合法请求体被拒了:%s", w.Body.String())
|
||||
}
|
||||
if w.Code != http.StatusOK { // recorder 默认 200,即「没写过响应」
|
||||
t.Fatalf("不该写任何响应,实际状态码 %d", w.Code)
|
||||
}
|
||||
if len(req.AttachmentIDs) != 2 {
|
||||
t.Fatalf("附件 id 应解出 2 个,实际 %#v", req.AttachmentIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// 省略可选字段仍然合法 —— 严格针对的是「多」而不是「少」。
|
||||
func TestStrictDecodeAllowsOmittedFields(t *testing.T) {
|
||||
type sendReq struct {
|
||||
To string `json:"to"`
|
||||
CC string `json:"cc"`
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/mail/send", strings.NewReader(`{"to":"jianf"}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var req sendReq
|
||||
if !DecodeBody(w, r, &req) {
|
||||
t.Fatalf("省略可选字段被拒了:%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DecodeLenient(心跳唯一的例外)───
|
||||
|
||||
func TestDecodeLenientKeepsKnownFieldsAndReportsUnknown(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
ModeEnforcement string `json:"mode_enforcement"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(
|
||||
`{"models":["a"],"mode_enforcement":"native","futureField":1}`))
|
||||
|
||||
var req hb
|
||||
unknown, err := DecodeLenient(r, &req)
|
||||
if err != nil {
|
||||
t.Fatalf("心跳体不该整体作废: %v", err)
|
||||
}
|
||||
// 已知字段必须照常取到 —— 这正是心跳要宽容的理由:
|
||||
// 插件比服务端新时,代价不该是会话快照与模型目录一起丢掉
|
||||
if len(req.Models) != 1 || req.ModeEnforcement != "native" {
|
||||
t.Fatalf("已知字段应正常解析,实际 %#v", req)
|
||||
}
|
||||
// 但**必须报出来**,否则又是一次静默忽略
|
||||
if len(unknown) != 1 || unknown[0] != "futureField" {
|
||||
t.Fatalf("未知字段应报 [futureField],实际 %#v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// 多个未知字段要全报出来。
|
||||
//
|
||||
// json 每遇到一个未知字段就立刻返回,所以实现里必须循环剥 ——
|
||||
// 不循环的话「多带了三个字段」只会报出第一个,而人改完那一个又撞上下一个。
|
||||
func TestDecodeLenientReportsAllUnknownFields(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(
|
||||
`{"models":[],"aaa":1,"bbb":2,"ccc":3}`))
|
||||
|
||||
var req hb
|
||||
unknown, err := DecodeLenient(r, &req)
|
||||
if err != nil {
|
||||
t.Fatalf("不该报错: %v", err)
|
||||
}
|
||||
if len(unknown) != 3 {
|
||||
t.Fatalf("应报出 3 个未知字段,实际 %#v", unknown)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, u := range unknown {
|
||||
got[u] = true
|
||||
}
|
||||
for _, want := range []string{"aaa", "bbb", "ccc"} {
|
||||
if !got[want] {
|
||||
t.Errorf("未报出 %q(实际 %#v)", want, unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeLenientCleanBodyReportsNothing(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(`{"models":["a"]}`))
|
||||
var req hb
|
||||
unknown, err := DecodeLenient(r, &req)
|
||||
if err != nil {
|
||||
t.Fatalf("不该报错: %v", err)
|
||||
}
|
||||
// 正常心跳的响应里不该多一个空数组 —— 调用方据此决定是否带 unknown_fields
|
||||
if len(unknown) != 0 {
|
||||
t.Fatalf("干净的体不该报未知字段,实际 %#v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeLenientEmptyBody(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(``))
|
||||
var req hb
|
||||
unknown, err := DecodeLenient(r, &req)
|
||||
if err != nil {
|
||||
t.Fatalf("空体应静默通过(心跳可以不带 body): %v", err)
|
||||
}
|
||||
if len(unknown) != 0 {
|
||||
t.Fatalf("空体不该报未知字段,实际 %#v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// 语法错误仍然要报 —— 宽容的是「多字段」,不是「烂 JSON」。
|
||||
func TestDecodeLenientStillRejectsMalformedJSON(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(`{"models":`))
|
||||
var req hb
|
||||
if _, err := DecodeLenient(r, &req); err == nil {
|
||||
t.Fatal("截断的 JSON 必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
// 类型不对也要报:`models` 要的是数组,给字符串说明插件写错了结构,
|
||||
// 那不是「服务端还不认识的新字段」。
|
||||
func TestDecodeLenientStillRejectsWrongType(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(`{"models":"oops"}`))
|
||||
var req hb
|
||||
if _, err := DecodeLenient(r, &req); err == nil {
|
||||
t.Fatal("类型不匹配必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── jsonFieldNames ───
|
||||
|
||||
func TestJSONFieldNamesListsAcceptedKeys(t *testing.T) {
|
||||
type req struct {
|
||||
To string `json:"to"`
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
Skipped string `json:"-"`
|
||||
NoTag string
|
||||
unexported string //nolint:unused // 刻意保留:验证非导出字段不进清单
|
||||
}
|
||||
|
||||
names := jsonFieldNames(&req{})
|
||||
joined := strings.Join(names, ",")
|
||||
|
||||
for _, want := range []string{"to", "attachment_ids"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("应含 %q,实际 %q", want, joined)
|
||||
}
|
||||
}
|
||||
// json:"-" 的字段不该出现在「本端点接受」的清单里 —— 它确实不接受
|
||||
if strings.Contains(joined, "Skipped") || strings.Contains(joined, "-") {
|
||||
t.Errorf("json:\"-\" 的字段不该列出,实际 %q", joined)
|
||||
}
|
||||
// 无 tag 时用字段名(json 包也是这么匹配的)
|
||||
if !strings.Contains(joined, "NoTag") {
|
||||
t.Errorf("无 tag 字段应按字段名列出,实际 %q", joined)
|
||||
}
|
||||
// 非导出字段 json 根本不看
|
||||
if strings.Contains(joined, "unexported") {
|
||||
t.Errorf("非导出字段不该列出,实际 %q", joined)
|
||||
}
|
||||
}
|
||||
212
server/internal/handler/thread.go
Normal file
212
server/internal/handler/thread.go
Normal file
@ -0,0 +1,212 @@
|
||||
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 = 60
|
||||
threadMaxLimit = 200
|
||||
// anchorPathBudget 是「补齐根到锚点这条路径」时最多回填的层数。
|
||||
// 只在锚点没落进 BFS 首页时才用得上(几百封的巨型线索)。
|
||||
anchorPathBudget = 60
|
||||
)
|
||||
|
||||
// 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
|
||||
//
|
||||
// 以给定邮件所在**线索的根**为起点,BFS 展开整棵树:
|
||||
//
|
||||
// ?offset=0(默认) 从根开始的第一块
|
||||
// ?offset=N 继续往后取(下滑加载)
|
||||
//
|
||||
// 曾经的实现是「锚点的祖先链 + 锚点的子树」两个方向各自分页,问题是
|
||||
// **兄弟节点整条分支都在盲区里**:一封抄送给两个 Agent 的邮件会收到两个回复,
|
||||
// 它们互为兄弟;从其中一个回复看树,另一个回复既不是它的祖先也不是它的子孙,
|
||||
// 于是永远不显示。挂在原件上的转发分支同理。改成从根整树 BFS 后,
|
||||
// 兄弟、抄送产生的平行回复、转发分支都是根的子孙,一次覆盖。
|
||||
//
|
||||
// 树可跨会话(转发是新线索但仍指向原件),因此**逐个会话鉴权**,
|
||||
// 只返回当前用户有权访问的节点。被过滤掉的计入 hidden。
|
||||
func GetMailThread(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
serveMailThread(w, r, func(sid uuid.UUID) (bool, error) {
|
||||
return repo.UserCanAccessSession(r.Context(), user, sid)
|
||||
})
|
||||
}
|
||||
|
||||
// serveMailThread 是人类与 Agent 两条对话树路径的公共实现。
|
||||
//
|
||||
// 差别只在**会话可见性判据**:人类走 UserCanAccessSession(管理员全可见、
|
||||
// 其余看参与过的会话),Agent 走 AgentCanAccessSession(只看自己参与过的)。
|
||||
// 其余全部逻辑——上溯线索根、BFS 分页、锚点路径回填、detached 标记——两侧必须
|
||||
// 完全一致:让 Agent 看到一棵与人类不同形状的树,只会让双方对「谁回了谁」
|
||||
// 产生分歧,而这正是抄送协作要靠对话树解决的问题。
|
||||
func serveMailThread(w http.ResponseWriter, r *http.Request, canAccess func(uuid.UUID) (bool, error)) {
|
||||
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 := canAccess(mail.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该邮件")
|
||||
return
|
||||
}
|
||||
|
||||
limit := intQuery(r, "limit", threadDefaultLimit, 1, threadMaxLimit)
|
||||
offset := intQuery(r, "offset", 0, 0, 1<<20)
|
||||
|
||||
// 上溯到线索根:整棵树都是它的子孙。
|
||||
rootID, anchorDepth, err := repo.ThreadRootOf(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to locate thread root")
|
||||
return
|
||||
}
|
||||
|
||||
raw, hasMore, err := repo.DescendantsRaw(r.Context(), rootID, offset, limit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to load thread")
|
||||
return
|
||||
}
|
||||
|
||||
// 锚点必须可见 —— 用户点开的就是它。巨型线索里 BFS 首页可能还没到锚点那一层,
|
||||
// 此时单独把「根 → 锚点」这条路径补进来,否则用户点开一封邮件却在树里找不到它。
|
||||
if offset == 0 && anchorDepth > 0 && !containsMail(raw, mailID) {
|
||||
path, _, pErr := repo.AncestorsRaw(r.Context(), mailID, 0, anchorPathBudget)
|
||||
if pErr == nil {
|
||||
// AncestorsRaw 给的是相对锚点的负 depth,换算成距根的绝对深度
|
||||
for i := range path {
|
||||
path[i].Depth += anchorDepth
|
||||
}
|
||||
raw = append(raw, path...)
|
||||
}
|
||||
// 锚点自己(AncestorsRaw 从父开始,不含锚点)
|
||||
if anchor, aErr := repo.TreeMailByID(r.Context(), mailID, anchorDepth); aErr == nil {
|
||||
raw = append(raw, *anchor)
|
||||
}
|
||||
}
|
||||
|
||||
// 会话鉴权结果按会话缓存:一条线索里同一会话通常有多封,逐封查是浪费
|
||||
seen := map[uuid.UUID]bool{}
|
||||
canSee := func(sid uuid.UUID) bool {
|
||||
if v, ok := seen[sid]; ok {
|
||||
return v
|
||||
}
|
||||
v, err := canAccess(sid)
|
||||
if err != nil {
|
||||
v = false // 查不出来就当看不到:宁可少给,不可多给
|
||||
}
|
||||
seen[sid] = v
|
||||
return v
|
||||
}
|
||||
|
||||
// 可见性过滤。父节点是否在**本次返回集**里决定 detached;
|
||||
// 父存在却不在集里,再判断是「无权看」还是「没加载」。
|
||||
visible := map[uuid.UUID]bool{}
|
||||
present := map[uuid.UUID]bool{}
|
||||
for _, m := range raw {
|
||||
present[m.ID] = true
|
||||
if canSee(m.SessionID) {
|
||||
visible[m.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
nodes := []threadNode{}
|
||||
emitted := map[uuid.UUID]bool{}
|
||||
for _, m := range raw {
|
||||
if !visible[m.ID] || emitted[m.ID] {
|
||||
// 补齐锚点路径时可能与 BFS 结果重叠,去重
|
||||
continue
|
||||
}
|
||||
emitted[m.ID] = true
|
||||
n := threadNode{TreeMail: m}
|
||||
if m.ParentMailID != nil && !visible[*m.ParentMailID] {
|
||||
n.Detached = true
|
||||
// 父邮件在本次结果里出现过但被过滤掉 = 确实无权查看;
|
||||
// 完全没出现过 = 只是还没加载到,往下翻会补上
|
||||
n.ParentHidden = present[*m.ParentMailID]
|
||||
}
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"anchor_mail_id": mailID,
|
||||
"root_mail_id": rootID,
|
||||
"anchor_depth": anchorDepth,
|
||||
"nodes": nodes,
|
||||
"total": len(nodes),
|
||||
"hidden": len(raw) - len(nodes),
|
||||
"has_more": hasMore,
|
||||
// 下一页的 offset。前端把它原样回传即可,不必自己算已加载数量。
|
||||
"next_offset": offset + limit,
|
||||
})
|
||||
}
|
||||
|
||||
// containsMail 判断某封邮件是否已在结果集里。
|
||||
func containsMail(list []repo.TreeMail, id uuid.UUID) bool {
|
||||
for i := range list {
|
||||
if list[i].ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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
server/internal/handler/thread_test.go
Normal file
26
server/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