Files
MailUI4Agents/gateway/internal/handler/helpers.go
JianFeeeee 79c4171c9d feat: L0 线协议冻结 + 附件链路修复 + 人/Agent 区分
L0 核心:
- 严格解码 Decode(DisallowUnknownFields) 全覆盖 29 个 DecodeBody 调用点
- DecodeLenient 心跳专用:容忍新字段但回报 unknown_fields
- 400 消息列出本端点接受的全部字段(jsonFieldNames 反射 tag)
- 日历 status 校验(create 补字段 + update 拦非法值)
- 新增 strictdecode_test.go 10 例 + blob/list_test.go 6 例

A-4 附件挂载回滚:checkAttachable 在 CreateMail 前校验,失败按
解挂→释放 relay→删邮件→退预算回滚,幽灵邮件这条路堵住了

A-5 反向 GC:blob.Store.List() 枚举磁盘(跳 .upload-*),
SweepUnreferencedBlobs 按 attachments + calendar_attachments 反查,
48h 年龄下限兜上传窗口。已接进每小时 sweep 循环

C 人/Agent 区分:四个读路径 + threadCols 补 from_human / to_human
(EXISTS users 判定),models.Mail 加 ToHuman。前端判据从
workspace 启发式改成显式布尔,mailCounterpart/sessionCounterpart
从 session_workspace 取 path(修 dsh@dsh 拼接 bug)

契约文档:SSE new_mail 补 4 字段(in_reply_to/from_human/
permission_mode/permission_enforcement),B-5 加 B-5.6
(Agent→Agent 不转发),B-3.4 MUST 改条件式,心跳补 mode_enforcement
+ unknown_fields,demo 死链修复 + from_human 检查
验收清单加 Agent→Agent 负向对照项
2026-09-06 15:18:06 +08:00

411 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

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