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

@ -22,6 +22,7 @@ import (
"context"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/push"
"github.com/agentmail/gateway/internal/repo"
"github.com/agentmail/gateway/internal/sse"
"github.com/google/uuid"
@ -224,6 +225,26 @@ func Recipients(ctx context.Context, m Mail) {
if !seen[m.From] {
sse.Default.SendToRecipient(m.From, "session_update", update)
}
// 第二条送达通道:设备推送(可选,没配凭证时这里是空操作)。
//
// 放在 SSE 之后且**异步**:推送慢不拖住收信(一封邮件的送达不能被
// 一个卡住的 HTTP 请求拖住),失败也只记日志。收件人名单直接用上面
// 的 seen —— 两条通道必须共用同一份「谁该收到这封信」的判据,
// 各算一套的话抄送方总有一边收不到(SSE 那边已经因为这个踩过一次)。
pushRecipients := make([]string, 0, len(seen))
for name := range seen {
if name != "" {
pushRecipients = append(pushRecipients, name)
}
}
push.NotifyNewMail(ctx, push.NewMail{
MailID: m.MailID.String(),
SessionID: m.SessionID.String(),
From: m.From,
Subject: m.Subject,
Recipients: pushRecipients,
})
}
// SessionActive 只刷新某一方的会话列表,不推 new_mail。