Files
MailUI4Agents/server/internal/handler/push.go
JianFeeeee 46fa7fa729 feat(push): 可选、配置式、多厂商的推送通道(HMS 为首个实现)
用户要求:推送密钥必须是可选项(自部署后端不能写死推送方式),且要支持
多厂商配置式接入 —— 每个用户各自部署服务器、自己选厂商、自己配凭证。
所以落地成:

· internal/push:通道抽象 + 工厂表(RegisterType),加厂商不改配置层与端点形状;
  HMS 只是第一个实现(internal/push/hms.go)
· 配置在 PUSH_CONFIG(默认 <AGENTMAIL_DATA_DIR>/push.json),一项一个厂商,
  凭证走文件(app_secret_file / files.*,建议 600);环境变量只是可选覆盖
· 没配 = 整条推送路径连一次查库都不发生(shouldDispatch 早退);
  单项配错(未知类型/密钥读不到/enabled:false)只跳过那一条,不影响启动
· push_tokens 表带 provider 维度 + 三个 /me/devices/push-token 端点;
  没配推送时端点照存并回 enabled:false(登记成功 != 服务端开了推送)
· notify.Recipients 末尾异步挂钩:收件人名单直接用 SSE 那份 seen(两条通道
  共用同一份"谁该收到"的判据);失败只记日志,绝不拖住收信

HMS 的形状是拿真凭证打线上接口问出来的(v1 + message.token[] + testMessage;
payload/target 形状 v1 不认、v2 要服务账号 JWT)。未上架应用必须 test_message=true,
单批 ≤10 token(MaxTokensPerRequest 声明)、每日 1000 条兜底(项目级额度)。
实测:App ID + App Secret 能换到 access_token(3600s);形状被线上服务接受。

判据:repo 6 条 + push 12 条 + handler 3 组,全部做过**变异验证** ——
过程中抓出两条假判据(异步分发与 t.Cleanup 赛跑而假绿;密钥文件优先级没被覆盖)
并补掉。Go 全量测试与 go vet 干净。

★ 未验:端到端真机送达(需要真机 token + 客户端按 com.jianf.agentmail 重编并签名,
签名指纹还要在 AGC 登记)—— 从未真正发出过一条能到达设备的推送。
详见 docs/HMS-PUSH-PLAN.md 的「实现状态」一节。
2026-09-15 11:21:00 +08:00

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