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,166 @@
package repo
import (
"context"
"fmt"
"strings"
"time"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
/*
设备推送 token 的读写(可选通道,见 internal/push)。
# 语义是「设备 + 注册者」
同一个 push token 只属于一个注册者:换人登录是**转移**,不是并存。这不是洁癖 ——
同一台设备上如果两个账号各存一份同一个 token,前一个人没退干净时,他的新邮件
会推到后一个人手里的那台设备上。
# 为什么没配推送也要能写
推送是自部署后端的可选项(用户 2026-09-15 明确要求)。所以登记 token 不依赖
「当前是否配了推送渠道」:端点照存,管理员之后把凭证配上就立刻生效,
不需要客户端重新登记一遍(客户端那时可能已经不在前台了)。
*/
// PushToken 是一台设备为某个注册者登记的推送地址。
type PushToken struct {
TokenID string
Provider string
Token string
OwnerName string
SessionID string
DeviceName string
}
// UpsertPushToken 登记/刷新一台设备的推送地址。
//
// 同一个 (provider, token) 重复登记时**改归属**并刷新 session/device_name:
// 客户端每次启动都会登记一次,若这里报错或插重复行,表会随启动次数膨胀。
func UpsertPushToken(ctx context.Context, provider, token, owner, sessionID, deviceName string) error {
_, err := db.DB.ExecContext(ctx, `
INSERT INTO push_tokens (token_id, provider, token, owner_name, session_id, device_name, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (provider, token) DO UPDATE
SET owner_name = $4, session_id = $5, device_name = $6, updated_at = NOW()`,
uuid.New().String(), provider, token, owner, sessionID, deviceName)
return err
}
// DeletePushToken 注销一个 token。只允许**注册者本人**注销:不能凭一个 token
// 字符串把别人的设备推送掐掉。
//
// 返回是否真的删到了行 —— 客户端拿它区分「已注销」与「本来就没登记」,
// 但两者对客户端都不是错误(推送是可选通道,注销失败不该弹提示)。
func DeletePushToken(ctx context.Context, provider, token, owner string) (bool, error) {
res, err := db.DB.ExecContext(ctx, `
DELETE FROM push_tokens WHERE provider = $1 AND token = $2 AND owner_name = $3`,
provider, token, owner)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
if err != nil {
return false, err
}
return n > 0, nil
}
// ListPushTokensOf 列出某个注册者的全部推送地址(各 provider 都有)。
func ListPushTokensOf(ctx context.Context, owner string) ([]PushToken, error) {
rows, err := db.DB.QueryContext(ctx, `
SELECT token_id, provider, token, owner_name, session_id, device_name
FROM push_tokens WHERE owner_name = $1 ORDER BY provider, created_at`, owner)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPushTokens(rows)
}
// ListPushTokensOfOwners 一次取回多个注册者的推送地址。
//
// 为什么批量:一封邮件可能同时推给收件人 + 若干抄送方,逐个查库就是每个参与方
// 一次往返(`new_mail` 的分发已经因为同类原因重排过一次:会话级字段不许每人查一次)。
// 参与方为空时直接返回,不拼 `IN ()` 这种非法 SQL。
func ListPushTokensOfOwners(ctx context.Context, owners []string) ([]PushToken, error) {
if len(owners) == 0 {
return nil, nil
}
ph := make([]string, len(owners))
args := make([]any, 0, len(owners))
for i, o := range owners {
ph[i] = fmt.Sprintf("$%d", i+1)
args = append(args, o)
}
rows, err := db.DB.QueryContext(ctx, `
SELECT token_id, provider, token, owner_name, session_id, device_name
FROM push_tokens WHERE owner_name IN (`+strings.Join(ph, ",")+`)
ORDER BY provider, created_at`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPushTokens(rows)
}
// PruneStalePushTokens 删掉 90 天没刷新过的登记。
//
// 客户端只在启动/换 token 时登记,所以「长期不刷新」就等于「这台设备不再用了」
// (App 卸载、token 轮换、换机)。留着它们的代价是每次发信都向一批死 token 发推送,
// 而华为对测试消息的额度是**项目级**的(1000 条/天,未上架应用),死 token 会白吃额度。
func PruneStalePushTokens(ctx context.Context) (int64, error) {
// 截止时间在 Go 里算,不用 SQL 的日期运算:两种方言的写法不同
// (PG 是 INTERVAL,SQLite 没有),而库里存的就是 NOW() 写的文本
// "YYYY-MM-DD HH:MM:SS.ffffff"(见 db.go 的 now 注册),UTC 字符串比较即正确。
cutoff := time.Now().UTC().Add(-90 * 24 * time.Hour).Format("2006-01-02 15:04:05.000000")
res, err := db.DB.ExecContext(ctx,
`DELETE FROM push_tokens WHERE updated_at < $1`, cutoff)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// DeletePushTokensByValue 按 token 值删除(不看注册者)。
//
// 只给**系统自愈**用:厂商回「这些 token 无效」(设备卸了 App、token 轮换)时,
// 留着它们的结果是每次发信都白吃额度(华为测试消息是**项目级** 1000 条/天),
// 而且用户那边永远收不到。用户主动注销走 DeletePushToken(要带 owner)。
func DeletePushTokensByValue(ctx context.Context, provider string, tokens []string) (int64, error) {
if len(tokens) == 0 {
return 0, nil
}
ph := make([]string, len(tokens))
args := make([]any, 0, len(tokens)+1)
args = append(args, provider)
for i, t := range tokens {
ph[i] = fmt.Sprintf("$%d", i+2)
args = append(args, t)
}
res, err := db.DB.ExecContext(ctx,
`DELETE FROM push_tokens WHERE provider = $1 AND token IN (`+strings.Join(ph, ",")+`)`, args...)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func scanPushTokens(rows interface {
Next() bool
Scan(...any) error
Err() error
}) ([]PushToken, error) {
var out []PushToken
for rows.Next() {
var t PushToken
if err := rows.Scan(&t.TokenID, &t.Provider, &t.Token, &t.OwnerName, &t.SessionID, &t.DeviceName); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}

View File

@ -0,0 +1,216 @@
package repo
import (
"context"
"testing"
"time"
"github.com/agentmail/gateway/internal/db"
)
/*
推送登记的判据(2026-09-15)。
推送是**可选通道**(用户要求「不能写死推送方式…推送密钥应当是可选项」),
所以这里钉住的不是"能不能推",而是登记本身的四个语义:
1. 重复登记是**刷新**,不是插入新行 —— 客户端每次启动都会登记,表不能随之膨胀;
2. 同一个 token 换人登录是**转移**(否则上一任用户的通知推到同一台设备上);
3. 注销要认**注册者**(不能凭一个 token 字符串掐掉别人的设备推送);
4. 无效 token 能按值清理(否则每次发信白吃华为的项目级额度)。
*/
func TestPushTokenUpsertIsRefreshNotInsert(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
for i := 0; i < 3; i++ {
if err := UpsertPushToken(ctx, "hms", "tok-1", "alice", "s-1", "我的手机"); err != nil {
t.Fatalf("第 %d 次登记失败: %v", i+1, err)
}
}
got, err := ListPushTokensOf(ctx, "alice")
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("同一 token 登记 3 次应只有 1 行,实际 %d 行(表会随客户端启动次数膨胀)", len(got))
}
if got[0].Provider != "hms" || got[0].Token != "tok-1" || got[0].DeviceName != "我的手机" {
t.Fatalf("登记内容不对: %+v", got[0])
}
// 刷新要更新 session_id(客户端换了会话,点通知该回到新会话)
if err := UpsertPushToken(ctx, "hms", "tok-1", "alice", "s-2", "我的手机"); err != nil {
t.Fatal(err)
}
got, _ = ListPushTokensOf(ctx, "alice")
if len(got) != 1 || got[0].SessionID != "s-2" {
t.Fatalf("重复登记应刷新 session_id,实际 %+v", got)
}
}
func TestPushTokenIsTransferredOnRelogin(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := UpsertPushToken(ctx, "hms", "shared-device", "alice", "", "同一台手机"); err != nil {
t.Fatal(err)
}
// bob 在同一台设备上登录并登记同一个 token
if err := UpsertPushToken(ctx, "hms", "shared-device", "bob", "", "同一台手机"); err != nil {
t.Fatal(err)
}
aliceTokens, _ := ListPushTokensOf(ctx, "alice")
if len(aliceTokens) != 0 {
t.Fatalf("换人登录后 alice 不该还持有这台设备:%+v(否则 alice 的新邮件会推到 bob 手里的设备上)", aliceTokens)
}
bobTokens, _ := ListPushTokensOf(ctx, "bob")
if len(bobTokens) != 1 {
t.Fatalf("bob 应持有这台设备,实际 %+v", bobTokens)
}
}
func TestPushTokenPerProviderSameValueCoexist(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 不同 provider 的 token 空间是独立的,同一个字符串不该互相覆盖。
if err := UpsertPushToken(ctx, "hms", "same-string", "alice", "", ""); err != nil {
t.Fatal(err)
}
if err := UpsertPushToken(ctx, "webpush", "same-string", "alice", "", ""); err != nil {
t.Fatal(err)
}
got, _ := ListPushTokensOf(ctx, "alice")
if len(got) != 2 {
t.Fatalf("两个 provider 的同名 token 应并存(provider 是维度的一部分),实际 %d 行", len(got))
}
}
func TestDeletePushTokenRequiresOwner(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := UpsertPushToken(ctx, "hms", "tok-1", "alice", "", ""); err != nil {
t.Fatal(err)
}
// bob 拿着同一个 token 字符串来注销:必须无效
deleted, err := DeletePushToken(ctx, "hms", "tok-1", "bob")
if err != nil {
t.Fatal(err)
}
if deleted {
t.Fatal("bob 不该能注销 alice 的设备(token 字符串不是凭证)")
}
if got, _ := ListPushTokensOf(ctx, "alice"); len(got) != 1 {
t.Fatal("alice 的登记被误删了")
}
deleted, err = DeletePushToken(ctx, "hms", "tok-1", "alice")
if err != nil || !deleted {
t.Fatalf("本人注销应成功,deleted=%v err=%v", deleted, err)
}
// 再注销一次:不是错误,只是 deleted=false
deleted, err = DeletePushToken(ctx, "hms", "tok-1", "alice")
if err != nil {
t.Fatal(err)
}
if deleted {
t.Fatal("第二次注销不该报告删到了行")
}
}
func TestListPushTokensOfOwnersBatches(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := UpsertPushToken(ctx, "hms", "t-a", "alice", "", ""); err != nil {
t.Fatal(err)
}
if err := UpsertPushToken(ctx, "hms", "t-b", "bob", "", ""); err != nil {
t.Fatal(err)
}
if err := UpsertPushToken(ctx, "hms", "t-c", "carol", "", ""); err != nil {
t.Fatal(err)
}
got, err := ListPushTokensOfOwners(ctx, []string{"alice", "bob"})
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("应取回 alice+bob 两个 token,实际 %d", len(got))
}
for _, tk := range got {
if tk.OwnerName == "carol" {
t.Fatal("不该取回未请求的 carol 的登记")
}
}
// 空名单不该拼出 `IN ()` 这种非法 SQL
if got, err := ListPushTokensOfOwners(ctx, nil); err != nil || got != nil {
t.Fatalf("空名单应直接返回 nil, nil,实际 %v / %v", got, err)
}
}
func TestDeletePushTokensByValueIgnoresOwner(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
for _, o := range []string{"alice", "bob"} {
if err := UpsertPushToken(ctx, "hms", "dead-"+o, o, "", ""); err != nil {
t.Fatal(err)
}
}
n, err := DeletePushTokensByValue(ctx, "hms", []string{"dead-alice", "dead-bob", "not-there"})
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("应删掉 2 行,实际 %d", n)
}
if left, _ := ListPushTokensOfOwners(ctx, []string{"alice", "bob"}); len(left) != 0 {
t.Fatalf("清理不干净: %+v", left)
}
// 别的 provider 不该被误删
if err := UpsertPushToken(ctx, "webpush", "dead-alice", "alice", "", ""); err != nil {
t.Fatal(err)
}
if n, _ := DeletePushTokensByValue(ctx, "hms", []string{"dead-alice"}); n != 0 {
t.Fatal("按 hms 清理时误删了 webpush 的登记")
}
}
func TestPruneStalePushTokens(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := UpsertPushToken(ctx, "hms", "fresh", "alice", "", ""); err != nil {
t.Fatal(err)
}
if err := UpsertPushToken(ctx, "hms", "stale", "bob", "", ""); err != nil {
t.Fatal(err)
}
// 把 stale 那条改老:UPDATE 直接写 91 天前的时刻(与 db.go 的 NOW() 同格式)
old := time.Now().UTC().Add(-91 * 24 * time.Hour).Format("2006-01-02 15:04:05.000000")
if _, err := db.DB.ExecContext(ctx, `UPDATE push_tokens SET updated_at = $1 WHERE token = 'stale'`, old); err != nil {
t.Fatal(err)
}
n, err := PruneStalePushTokens(ctx)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("应清掉 1 条过期登记,实际 %d", n)
}
left, _ := ListPushTokensOf(ctx, "alice")
if len(left) != 1 {
t.Fatal("新鲜的登记被误删了")
}
if gone, _ := ListPushTokensOf(ctx, "bob"); len(gone) != 0 {
t.Fatal("过期登记没被清掉")
}
}