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" ) /* 设备推送登记 —— /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 } 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:]) }