chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

View 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_timeRFC3339例如 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})
}

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

View 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 持密钥主动连 GatewayGateway 从不外呼)。反向拉取需要 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": "已删除。邮件与会话保留(审计凭据);" +
"密钥、平台会话镜像、模型范围已清除;它建的日历事件已置为取消。" +
"此名字今后不可再注册(历史邮件的署名由此不会被冒用)。",
})
}

View File

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

View File

@ -0,0 +1,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
}
// 没有附件的邮件保持 nilAttachments 带 omitempty
// 填空切片只会给每封邮件的 JSON 加一个 "attachments":[]
if as := byMail[m.ID]; len(as) > 0 {
m.Attachments = as
}
}
}

View File

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

View File

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

View 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_timereminder_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
}

View 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.sessionAgent 侧会话归档 + 邮箱界面移除
func ArchiveContact(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
var req archiveRequest
if !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
}

View 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)
}
}

View File

@ -0,0 +1,91 @@
package handler
import (
"net/http"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/repo"
"github.com/agentmail/gateway/internal/sse"
)
// GET /api/v1/events/stream
//
// 四种凭证,都必须真正验证过身份才能订阅:
// Authorization: Bearer <agent_key_token> → Agent 通道(密钥认证)
// X-Agent-Name + X-Agent-Secret → Agent 通道(旧方式,兼容)
// 登录 Cookie 或 Bearer <user_key_token> → 人类用户通道
// ?access_token=<token> → 浏览器 EventSource 专用回退
//
// 注意不能只凭 X-Agent-Name 就分流:那等于任何人报个名字就能读走别人的新邮件通知。
// query 令牌仅本端点接受EventSource 无法带自定义头),其余接口一律要求请求头,
// 因为 URL 里的令牌会进访问日志与 Referer。
func SSEStream(w http.ResponseWriter, r *http.Request) {
agentName, ok := resolveStreamAgent(r)
if !ok {
Error(w, http.StatusUnauthorized, "凭证无效")
return
}
userName := ""
if agentName == "" {
u := middleware.OptionalUserWithQuery(r)
if u == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
userName = u.Username
}
client := sse.Default.AddClient(w, 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(),
})
}

View 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})
}

View File

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

View File

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

View 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 分钟往返后变成 %dtrigger=%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 输入应给空数组")
}
}

View 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"})
}

View 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"` // 上游消息 idrelay 非空时必填
// FromSessionID 是发信时模型所处的邮件会话 id即「这活是谁派给我的」
//
// **只用于权限档位继承**Agent 新开一条会话时,新会话不得比它所在
// 的那条会话更宽松。注意这里**没有** permission_mode 字段 —— 那是有意的:
// 让 Agent 自己指定档位等于发一封 mode=full 的信就能提权。
//
// 省略时回落到默认档(不是 full。插件担不担得起传这个值不影响安全下限
// 没传 = 拿默认档,不会因此拿到更大的权限。
FromSessionID string `json:"from_session_id"`
}
// resolveTarget 根据三维地址 name@path.session 决定投递的会话。
//
// session 位三态语义(设计文档):
// - 省略pi@root → 投递到 name@path 的默认会话;从未通信则建立
// - newpi@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()
}

View 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.DefaultPermissionModeworkspace
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
}

View 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

View 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 是上游那条权限询问的稳定 idopencode 的 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 填成 adminIsHumanUser 于是通过,这里根本不会被调用。
// 实测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
}

View 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)
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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
}
// 允许调到低于已用次数:那表示「就到这里为止」,是人的合法意图。
// 此时剩余为 0Agent 下次发信即被拦。
b, err = repo.SetSessionBudget(r.Context(), sessionID, *req.MaxRounds)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to set budget")
return
}
}
if req.Reset {
b, err = repo.ResetSessionBudget(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to reset budget")
return
}
}
// 让会话的其他参与方(含 Agent 侧界面)立刻看到新预算
sse.Default.Broadcast("session_update", map[string]interface{}{
"session_id": sessionID.String(),
"budget_max": b.Max,
"budget_used": b.Used,
"budget_remaining": b.Remaining,
})
JSON(w, http.StatusOK, b)
}

View File

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

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

View File

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