Files
MailUI4Agents/server/internal/handler/push.go
JianFeeeee 7e5b11392f fix(push): session_id 非空时必须是合法 UUID —— 它此前静默接受任意字符串,真 bug 就是这么活下来的
dsh(f07daac1 之后的 5fab8734)报:客户端一度把这个字段填成**服务器地址**
(`AccountInfo.server` = `https://…/api/v1`),而这里只 TrimSpace、不看格式 ⇒
不 400、不影响收信、不进日志;而**投递路径也不看它**(通知里的 session_id 来自
邮件自己,见 notify/mail.go;dispatch 只用 token 的 Provider/Token)。
两条路都不看 ⇒ 存了假值没有任何机制会报警,可它存在的唯一目的就是
「点通知回到那条会话」。

改动只有一条校验(非空才校验,空串仍合法 —— 契约允许"客户端还没进任何会话"),
400 文案自带药方(写明"要么 UUID、要么留空")。

判据 `TestPushTokenSessionIDMustBeUUID` 四个用例:服务器地址顶替 / 随便一个词 /
空串 / 真 UUID。**变体验证两个方向**:
  · 去掉校验(连 import 一起去)⇒ 「非法」两条当场红(实际 200);
  · 把判断写反(`err == nil` 才报错)⇒ 合法的真 UUID 也红 ⇒ 证明它不是"恒 400"。
恢复后 `go test ./...` 全绿、`go vet` 干净。

取舍:老客户端带旧值上报会吃 400,按契约 400 归静默 ⇒ 不打扰用户、只是那次不上报。
`push_tokens` 现为 0 行,**没有历史脏值要清**。
2026-09-17 19:42:41 +08:00

186 lines
6.9 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 (
"log"
"net/http"
"regexp"
"strings"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/push"
"github.com/agentmail/gateway/internal/repo"
"github.com/google/uuid"
)
/*
设备推送登记 —— /api/v1/me/devices/push-token
# 为什么这三个端点"没配推送也能用"
推送是自部署后端的**可选项**(用户 2026-09-15 明确要求)。所以:
- 登记/注销**不看服务端有没有配凭证**:照存。管理员之后把 `HMS_APP_ID/SECRET`
配上就立刻生效,客户端不必再登记一次(它那时可能已经不在前台了)。
- 响应里回 `enabled`:客户端据此知道「登记成功了,但服务端现在还没开推送」——
而不是把"没收到通知"当成登记失败去反复重试。
- 查不到 provider 凭证时**不返回错误状态码**:那是正常配置,不是故障。
# 为什么 provider 用字符串而不是枚举
加通道(web push、别的厂商)不该动端点形状、不该动 schema。合法性只做**形状**
校验(字符集与长度),不做白名单 —— 白名单会把「服务端还没实现的那个通道」
变成客户端的 400,而那恰恰是最不该拦的时候。
*/
var pushProviderRe = regexp.MustCompile(`^[a-z0-9_-]{1,32}$`)
// pushTokenRequest 是登记/注销共用的请求体。
type pushTokenRequest struct {
Provider string `json:"provider"`
Token string `json:"token"`
// DeviceName 仅供人辨认(如"我的手机"),服务端不做判据。
DeviceName string `json:"device_name"`
// SessionID 是客户端当前所在的会话:点通知要回到那条会话里的那封信。
// 允许为空(客户端还没进任何会话),此时通知只带 mail_id。
SessionID string `json:"session_id"`
}
// pushEnabled 是三个端点共用的响应片段。
func pushStatus() map[string]interface{} {
return map[string]interface{}{
"enabled": push.Enabled(),
"providers": push.Names(),
}
}
// RegisterPushToken 登记(或刷新)一台设备的推送地址。
//
// POST /api/v1/me/devices/push-token
func RegisterPushToken(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
var req pushTokenRequest
if !DecodeBody(w, r, &req) {
return
}
req.Provider = strings.TrimSpace(req.Provider)
req.Token = strings.TrimSpace(req.Token)
if !pushProviderRe.MatchString(req.Provider) {
Error(w, http.StatusBadRequest, "provider 非法(只允许小写字母、数字、下划线、连字符,最长 32)")
return
}
if req.Token == "" || len(req.Token) > 512 {
Error(w, http.StatusBadRequest, "token 非法(不能为空,最长 512 字符)")
return
}
/*
* session_id 非空时必须是**合法 UUID**(dsh 2026-09-17 报的真 bug)。
*
* 为什么这个校验值得加(那个 bug 无声地活了很久):
* 客户端一度把这个字段填成了**服务器地址**(`https://…/api/v1`),而这里只 TrimSpace、
* 允许任何字符串 ⇒ 不报错、不影响收信、不进日志。而**投递路径不看这个字段**
* (发通知用的是邮件自己的 `m.SessionID`,见 notify/mail.go;dispatch 只用 token 的
* Provider/Token),所以它错了也**没有任何机制会报警**。
* 可这个字段存在的唯一目的就是“点通知回到那条会话”—— 等哪天真按它路由,
* 人会被送到别的会话,而根因在一个看起来“存进去了”的字段上。
*
* 代价与取舍:老客户端(上报过错值的)下次上报会吃 **400**;按契约 400 归静默,
* 所以不打扰用户、只是那一次不上报(下次带对值就恢复)。用一次静默重试换掉
* “静默存错值”,值。
* 注意:这里**只校验非空值**——空串是契约允许的(“客户端还没进任何会话”)。
*/
if sid := strings.TrimSpace(req.SessionID); sid != "" {
if _, err := uuid.Parse(sid); err != nil {
Error(w, http.StatusBadRequest, "session_id 非法(必须是会话 UUID,或留空表示“还没有当前会话”)")
return
}
}
if err := repo.UpsertPushToken(r.Context(), req.Provider, req.Token, user.Username,
strings.TrimSpace(req.SessionID), strings.TrimSpace(req.DeviceName)); err != nil {
Error(w, http.StatusInternalServerError, "登记推送地址失败")
return
}
// 顺手清理长期没刷新的登记(90 天)。放在登记路径上是因为它天然低频
// (每台设备只在启动/token 轮换时来一次),不需要定时任务。
// 失败只记日志:清理失败不该让一次正常登记变成 500。
if _, err := repo.PruneStalePushTokens(r.Context()); err != nil {
log.Printf("[push] 清理过期推送登记失败: %v", err)
}
resp := pushStatus()
resp["ok"] = true
JSON(w, http.StatusOK, resp)
}
// DeletePushToken 注销一台设备的推送地址。
//
// DELETE /api/v1/me/devices/push-token
func DeletePushToken(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
var req pushTokenRequest
if !DecodeBody(w, r, &req) {
return
}
req.Provider = strings.TrimSpace(req.Provider)
req.Token = strings.TrimSpace(req.Token)
if !pushProviderRe.MatchString(req.Provider) || req.Token == "" {
Error(w, http.StatusBadRequest, "provider 或 token 非法")
return
}
deleted, err := repo.DeletePushToken(r.Context(), req.Provider, req.Token, user.Username)
if err != nil {
Error(w, http.StatusInternalServerError, "注销推送地址失败")
return
}
resp := pushStatus()
resp["ok"] = true
// deleted=false 不是错误(本来就没登记),但客户端可据此避免重复注销。
resp["deleted"] = deleted
JSON(w, http.StatusOK, resp)
}
// ListPushTokens 列出自己的推送登记。
//
// GET /api/v1/me/devices/push-token
func ListPushTokens(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
tokens, err := repo.ListPushTokensOf(r.Context(), user.Username)
if err != nil {
Error(w, http.StatusInternalServerError, "读取推送登记失败")
return
}
items := make([]map[string]interface{}, 0, len(tokens))
for _, t := range tokens {
items = append(items, map[string]interface{}{
"id": t.TokenID,
"provider": t.Provider,
// token 只回尾 6 位:客户端不需要全文(它自己刚发过来的),
// 而一个能读到全文的接口等于多一处泄漏面。
"token_tail": tailOf(t.Token, 6),
"device_name": t.DeviceName,
"session_id": t.SessionID,
})
}
resp := pushStatus()
resp["tokens"] = items
JSON(w, http.StatusOK, resp)
}
func tailOf(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[len(r)-n:])
}