Files
MailUI4Agents/server/internal/push/config.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

305 lines
11 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 push
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
)
/*
推送通道的**配置式**接入(2026-09-15 用户的第二条要求)。
原话:「我们要支持多厂商配置式接入统一推送服务,推送密钥(文件)应当是配置项,
用户可为不同的推送服务配置对应的凭证,因为我们的设计中是每个用户各自部署服务器」。
所以契约是这样定的:
- **多厂商**:配置里是一张表,每一项是一个厂商(`type`)。一个实例可以同时接
HMS 和小米/Web Push/任何东西 —— 只要那个厂商在 factories 里注册过实现。
- **配置式**:加厂商不改代码路径,只加一个 `Factory` 实现 + 一行 `RegisterType`;
用户侧只改配置文件,不重编译。
- **凭证/密钥文件是配置项**:`app_secret_file` 指向密钥文件(推荐),也接受内联
`app_secret`(图省事/做实验);`client_config_file` 指向厂商给的客户端配置
(如华为的 agconnect-services.json)—— 这类文件属于**部署物料**,由部署者提供。
- **谁部署谁配**:每个用户自己部署服务端、自己选厂商、自己填凭证。没配 = 推送
不可用,但服务端一切照常(这就是「可选」的落地)。
配置文件位置:`PUSH_CONFIG` 指定;默认 `<AGENTMAIL_DATA_DIR>/push.json`。
文件不存在**不是错误**(自部署实例默认就没配推送)。
# 单项配错不能拖垮整个服务
某一条配置写坏(类型未知、密钥文件读不到、JSON 写错)时:**只跳过那一条**并打印
一条明确的日志,其余条目照常启用,网关照常启动。理由很直接:推送是可选旁路,
它不该有能力让整个邮件服务起不来 —— 那是把"锦上添花"变成了"单点故障"。
*/
// ProviderConfig 是配置里的一项:一个推送厂商 + 它自己的凭证。
type ProviderConfig struct {
// Type 是厂商实现名("hms"、"webpush"…),必须已 RegisterType。
Type string `json:"type"`
// Name 覆盖推送给客户端看的 provider 名(默认 = Type)。
// 用途:同一个实例接两套同厂商凭证(例如两个应用)时区分开来,
// 客户端登记 token 时用的就是这个值。
Name string `json:"name"`
// Enabled 缺省视为 true;显式 false = 留配置但不启用。
Enabled *bool `json:"enabled"`
// AppID / AppSecret 是厂商的凭证。AppSecret 建议走 AppSecretFile。
AppID string `json:"app_id"`
AppSecret string `json:"app_secret"`
// AppSecretFile 指向**存放密钥的文件**(配置项,不是硬编码)。
AppSecretFile string `json:"app_secret_file"`
// ClientConfigFile 指向厂商给的客户端配置文件(如 agconnect-services.json)。
// 服务端用它核对 app_id/package_name 是否与客户端一致——不一致的推送永远送不到,
// 而症状会表现为"推送静默失效",所以这里宁可启动时就说清楚。
ClientConfigFile string `json:"client_config_file"`
// Files 是**厂商自定义的文件类配置**(键名由厂商实现定义)。
//
// 为什么要有它:不同厂商的密钥形状本就不同(华为是 app_secret,
// Web Push 是 VAPID 密钥对,有的用服务账号 JSON…)。给每个厂商加一个专用字段
// 会让配置层随厂商数量膨胀;一张「名字 → 文件路径」的表则不用改配置层就能接新厂商。
//
// 例:{"app_secret": "/etc/agentmail/hms.secret", "vapid_private_key": "/etc/agentmail/vapid.pem"}
Files map[string]string `json:"files"`
// TestMessage 见 hms.go:未上架应用必须为 true。缺省 true。
TestMessage *bool `json:"test_message"`
// DailyLimit 每日发送上限(条),0 = 用实现的默认值。
DailyLimit int `json:"daily_limit"`
}
// Factory 按配置造一个通道。凭证已在这之前解析好(见 resolveSecret)。
type Factory func(cfg ProviderConfig) (Notifier, error)
var factories = map[string]Factory{}
// RegisterType 注册一个厂商实现。加厂商 = 加一个实现 + 一行这个调用。
func RegisterType(name string, f Factory) {
factories[name] = f
}
func init() {
RegisterType("hms", newHMSFromConfig)
}
// KnownTypes 列出已注册的厂商类型(日志与文档用)。
func KnownTypes() []string {
out := make([]string, 0, len(factories))
for k := range factories {
out = append(out, k)
}
return out
}
type configFile struct {
Providers []ProviderConfig `json:"providers"`
}
// configPath 返回配置文件路径。
func configPath() string {
if p := strings.TrimSpace(os.Getenv("PUSH_CONFIG")); p != "" {
return p
}
dir := strings.TrimSpace(os.Getenv("AGENTMAIL_DATA_DIR"))
if dir == "" {
dir = "data"
}
return filepath.Join(dir, "push.json")
}
// LoadProviders 读配置并造出所有启用的通道。
//
// 单项失败只跳过该项(见包注释):返回值可能少于配置里的条目数。
func LoadProviders() []Notifier {
path := configPath()
entries, err := readConfigEntries(path)
if err != nil {
log.Printf("[push] 配置文件 %s 读取失败,推送不可用(不影响邮件服务): %v", path, err)
return nil
}
// 环境变量是**可选覆盖**:只有在配置里没有同类型的条目时才补一条。
// 保留它是因为临时验证(以及没有配置文件的小部署)很常用;
// 但它不是主路径 —— 主路径是配置文件(用户要求「密钥应当是配置项」)。
if env, ok := hmsConfigFromEnv(); ok && !hasType(entries, env.Type) {
entries = append(entries, env)
}
var out []Notifier
for i, cfg := range entries {
cfg.Type = strings.TrimSpace(cfg.Type)
if cfg.Type == "" {
log.Printf("[push] 第 %d 项缺 type 字段,已跳过", i+1)
continue
}
if cfg.Enabled != nil && !*cfg.Enabled {
log.Printf("[push] %s(%s)在配置里是 disabled,已跳过", nameOf(cfg), cfg.Type)
continue
}
f, ok := factories[cfg.Type]
if !ok {
log.Printf("[push] 不支持的类型 %q(已注册:%s),已跳过该项", cfg.Type, strings.Join(KnownTypes(), ", "))
continue
}
if err := resolveSecret(&cfg); err != nil {
log.Printf("[push] %s(%s)凭证不可用,已跳过: %v", nameOf(cfg), cfg.Type, err)
continue
}
checkClientConfig(&cfg)
n, err := f(cfg)
if err != nil {
log.Printf("[push] %s(%s)初始化失败,已跳过: %v", nameOf(cfg), cfg.Type, err)
continue
}
out = append(out, n)
}
return out
}
// Setup 读配置、注册通道,并把结果打成一行日志(main 调用它)。
//
// 返回已启用的通道名:空表示"这台实例没配推送"—— 那是正常状态,不是错误,
// 所以这里用普通日志而不是告警。
func Setup() []string {
providers := LoadProviders()
for _, p := range providers {
Register(p)
}
if len(providers) == 0 {
log.Printf("[push] 未配置推送通道(%s 不存在或为空)—— 正常状态,SSE 仍是收信主通道", configPath())
return nil
}
return Names()
}
func readConfigEntries(path string) ([]ProviderConfig, error) {
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // 没配就是没配
}
return nil, err
}
if strings.TrimSpace(string(b)) == "" {
return nil, nil
}
var f configFile
if err := json.Unmarshal(b, &f); err != nil {
return nil, fmt.Errorf("JSON 解析失败: %w", err)
}
return f.Providers, nil
}
func hasType(entries []ProviderConfig, t string) bool {
for _, e := range entries {
if e.Type == t {
return true
}
}
return false
}
// resolveSecret 把密钥文件的内容解析到 cfg.AppSecret(文件优先于内联)。
//
// **不在这里要求密钥必须存在**:不是每个厂商都用 app_secret(Web Push 用 VAPID
// 密钥对),"这个字段是必需的"是各厂商自己的事,由它的 Factory 判定。
// 这条是判据抓出来的:第一版把"必须有密钥"写在通用层,于是无密钥的厂商条目
// 被默默跳过(测试里 test-echo 就没建起来)。
//
// 但**指明了文件却读不到**是真错误(配置里写了却用不了),所以它照旧让该项被判失败。
func resolveSecret(cfg *ProviderConfig) error {
file := strings.TrimSpace(cfg.AppSecretFile)
if file == "" {
file = strings.TrimSpace(cfg.Files["app_secret"])
}
if file == "" {
return nil
}
fi, err := os.Stat(file)
if err != nil {
return fmt.Errorf("密钥文件不可读 %s: %w", file, err)
}
if fi.Mode().Perm()&0o044 != 0 {
log.Printf("[push] 提醒:密钥文件 %s 权限 %o 对同组/其他人可读,建议 chmod 600", file, fi.Mode().Perm())
}
b, err := os.ReadFile(file)
if err != nil {
return fmt.Errorf("读密钥文件 %s 失败: %w", file, err)
}
secret := strings.TrimSpace(string(b))
if secret == "" {
return fmt.Errorf("密钥文件 %s 是空的", file)
}
cfg.AppSecret = secret
return nil
}
// checkClientConfig 核对客户端配置文件里的 app_id / package_name 与配置是否一致。
//
// 为什么值得做:推送送不到设备的最隐蔽原因是**服务端应用与设备上装的包不是同一个**
// (包名/App ID 对不上),而症状只是"怎么都不来通知"。这里在启动时说清楚,
// 比事后拿着一堆 80300007 猜要便宜得多。只比对能对上的字段,格式不认识就跳过。
func checkClientConfig(cfg *ProviderConfig) {
path := strings.TrimSpace(cfg.ClientConfigFile)
if path == "" {
return
}
b, err := os.ReadFile(path)
if err != nil {
log.Printf("[push] 提醒:客户端配置文件 %s 读不到: %v", path, err)
return
}
var doc struct {
Client struct {
AppID string `json:"app_id"`
PackageName string `json:"package_name"`
} `json:"client"`
}
if err := json.Unmarshal(b, &doc); err != nil {
log.Printf("[push] 提醒:客户端配置文件 %s 不是可识别的 JSON(已跳过核对)", path)
return
}
if doc.Client.AppID != "" && cfg.AppID != "" && doc.Client.AppID != cfg.AppID {
log.Printf("[push] 不一致:客户端配置的 app_id=%s 与服务端配置的 app_id=%s 不是同一个应用 —— 推送送不到设备",
doc.Client.AppID, cfg.AppID)
}
if doc.Client.PackageName != "" {
log.Printf("[push] 客户端包名:%s(设备的包名必须与它一致,且签名指纹要在厂商后台登记过)", doc.Client.PackageName)
}
}
func nameOf(cfg ProviderConfig) string {
if n := strings.TrimSpace(cfg.Name); n != "" {
return n
}
return cfg.Type
}
// hmsConfigFromEnv 把 HMS_* 环境变量转成一条配置(可选覆盖,见 LoadProviders)。
func hmsConfigFromEnv() (ProviderConfig, bool) {
appID := strings.TrimSpace(os.Getenv("HMS_APP_ID"))
secret := strings.TrimSpace(os.Getenv("HMS_APP_SECRET"))
if appID == "" || secret == "" {
return ProviderConfig{}, false
}
cfg := ProviderConfig{Type: "hms", AppID: appID, AppSecret: secret}
if v := strings.TrimSpace(os.Getenv("HMS_TEST_MESSAGE")); v != "" {
b := v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
cfg.TestMessage = &b
}
if v := strings.TrimSpace(os.Getenv("HMS_DAILY_LIMIT")); v != "" {
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n >= 0 {
cfg.DailyLimit = n
}
}
if v := strings.TrimSpace(os.Getenv("HMS_CLIENT_CONFIG_FILE")); v != "" {
cfg.ClientConfigFile = v
}
return cfg, true
}