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 的「实现状态」一节。
This commit is contained in:
2026-09-15 11:21:00 +08:00
parent b806a05bfa
commit 46fa7fa729
14 changed files with 2349 additions and 0 deletions

View File

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