diff --git a/go.mod b/go.mod index 7b565e12..9f111413 100644 --- a/go.mod +++ b/go.mod @@ -113,7 +113,7 @@ require ( github.com/cloudflare/cloudflare-go v0.104.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/disintegration/imaging v1.6.2 // indirect - github.com/domodwyer/mailyak/v3 v3.6.2 // indirect + github.com/domodwyer/mailyak/v3 v3.6.2 github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.17.0 // indirect github.com/gabriel-vasile/mimetype v1.4.6 // indirect diff --git a/internal/domain/notify.go b/internal/domain/notify.go index 440bbef7..6c164f39 100644 --- a/internal/domain/notify.go +++ b/internal/domain/notify.go @@ -1,12 +1,12 @@ package domain const ( - NotifyChannelDingtalk = "dingtalk" + NotifyChannelEmail = "email" NotifyChannelWebhook = "webhook" - NotifyChannelTelegram = "telegram" + NotifyChannelDingtalk = "dingtalk" NotifyChannelLark = "lark" + NotifyChannelTelegram = "telegram" NotifyChannelServerChan = "serverchan" - NotifyChannelMail = "mail" NotifyChannelBark = "bark" ) diff --git a/internal/notify/expire.go b/internal/notify/expire.go index d4942272..5dc424ce 100644 --- a/internal/notify/expire.go +++ b/internal/notify/expire.go @@ -12,19 +12,13 @@ import ( "github.com/usual2970/certimate/internal/utils/xtime" ) -type msg struct { - subject string - message string -} - const ( - defaultExpireSubject = "您有{COUNT}张证书即将过期" - defaultExpireMsg = "有{COUNT}张证书即将过期,域名分别为{DOMAINS},请保持关注!" + defaultExpireSubject = "您有 {COUNT} 张证书即将过期" + defaultExpireMessage = "有 {COUNT} 张证书即将过期,域名分别为 {DOMAINS},请保持关注!" ) func PushExpireMsg() { // 查询即将过期的证书 - records, err := app.GetApp().Dao().FindRecordsByFilter("domains", "expiredAt<{:time}&&certUrl!=''", "-created", 500, 0, dbx.Params{"time": xtime.GetTimeAfter(24 * time.Hour * 15)}) if err != nil { @@ -34,12 +28,12 @@ func PushExpireMsg() { // 组装消息 msg := buildMsg(records) - if msg == nil { return } - if err := Send(msg.subject, msg.message); err != nil { + // 发送通知 + if err := SendToAllChannels(msg.Subject, msg.Message); err != nil { app.GetApp().Logger().Error("send expire msg", "error", err) } } @@ -53,22 +47,27 @@ type notifyTemplate struct { Content string `json:"content"` } -func buildMsg(records []*models.Record) *msg { +type notifyMessage struct { + Subject string + Message string +} + +func buildMsg(records []*models.Record) *notifyMessage { if len(records) == 0 { return nil } // 查询模板信息 templateRecord, err := app.GetApp().Dao().FindFirstRecordByFilter("settings", "name='templates'") - title := defaultExpireSubject - content := defaultExpireMsg + subject := defaultExpireSubject + message := defaultExpireMessage if err == nil { var templates *notifyTemplates templateRecord.UnmarshalJSONField("content", templates) if templates != nil && len(templates.NotifyTemplates) > 0 { - title = templates.NotifyTemplates[0].Title - content = templates.NotifyTemplates[0].Content + subject = templates.NotifyTemplates[0].Title + message = templates.NotifyTemplates[0].Content } } @@ -81,17 +80,17 @@ func buildMsg(records []*models.Record) *msg { } countStr := strconv.Itoa(count) - domainStr := strings.Join(domains, ",") + domainStr := strings.Join(domains, ";") - title = strings.ReplaceAll(title, "{COUNT}", countStr) - title = strings.ReplaceAll(title, "{DOMAINS}", domainStr) + subject = strings.ReplaceAll(subject, "{COUNT}", countStr) + subject = strings.ReplaceAll(subject, "{DOMAINS}", domainStr) - content = strings.ReplaceAll(content, "{COUNT}", countStr) - content = strings.ReplaceAll(content, "{DOMAINS}", domainStr) + message = strings.ReplaceAll(message, "{COUNT}", countStr) + message = strings.ReplaceAll(message, "{DOMAINS}", domainStr) // 返回消息 - return &msg{ - subject: title, - message: content, + return ¬ifyMessage{ + Subject: subject, + Message: message, } } diff --git a/internal/notify/factory.go b/internal/notify/factory.go new file mode 100644 index 00000000..ccdd5389 --- /dev/null +++ b/internal/notify/factory.go @@ -0,0 +1,66 @@ +package notify + +import ( + "errors" + + "github.com/usual2970/certimate/internal/domain" + "github.com/usual2970/certimate/internal/pkg/core/notifier" + notifierBark "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/bark" + notifierDingTalk "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/dingtalk" + notifierEmail "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/email" + notifierLark "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/lark" + notifierServerChan "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/serverchan" + notifierTelegram "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/telegram" + notifierWebhook "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/webhook" + "github.com/usual2970/certimate/internal/pkg/utils/maps" +) + +func createNotifier(channel string, channelConfig map[string]any) (notifier.Notifier, error) { + switch channel { + case domain.NotifyChannelEmail: + return notifierEmail.New(¬ifierEmail.EmailNotifierConfig{ + SmtpHost: maps.GetValueAsString(channelConfig, "smtpHost"), + SmtpPort: maps.GetValueAsInt32(channelConfig, "smtpPort"), + SmtpTLS: maps.GetValueOrDefaultAsBool(channelConfig, "smtpTLS", true), + Username: maps.GetValueOrDefaultAsString(channelConfig, "username", maps.GetValueAsString(channelConfig, "senderAddress")), + Password: maps.GetValueAsString(channelConfig, "password"), + SenderAddress: maps.GetValueAsString(channelConfig, "senderAddress"), + ReceiverAddress: maps.GetValueAsString(channelConfig, "receiverAddress"), + }) + + case domain.NotifyChannelWebhook: + return notifierWebhook.New(¬ifierWebhook.WebhookNotifierConfig{ + Url: maps.GetValueAsString(channelConfig, "url"), + }) + + case domain.NotifyChannelDingtalk: + return notifierDingTalk.New(¬ifierDingTalk.DingTalkNotifierConfig{ + AccessToken: maps.GetValueAsString(channelConfig, "accessToken"), + Secret: maps.GetValueAsString(channelConfig, "secret"), + }) + + case domain.NotifyChannelLark: + return notifierLark.New(¬ifierLark.LarkNotifierConfig{ + WebhookUrl: maps.GetValueAsString(channelConfig, "webhookUrl"), + }) + + case domain.NotifyChannelTelegram: + return notifierTelegram.New(¬ifierTelegram.TelegramNotifierConfig{ + ApiToken: maps.GetValueAsString(channelConfig, "apiToken"), + ChatId: maps.GetValueAsInt64(channelConfig, "chatId"), + }) + + case domain.NotifyChannelServerChan: + return notifierServerChan.New(¬ifierServerChan.ServerChanNotifierConfig{ + Url: maps.GetValueAsString(channelConfig, "url"), + }) + + case domain.NotifyChannelBark: + return notifierBark.New(¬ifierBark.BarkNotifierConfig{ + DeviceKey: maps.GetValueAsString(channelConfig, "deviceKey"), + ServerUrl: maps.GetValueAsString(channelConfig, "serverUrl"), + }) + } + + return nil, errors.New("unsupported notifier channel") +} diff --git a/internal/notify/mail.go b/internal/notify/mail.go deleted file mode 100644 index 060ffcd6..00000000 --- a/internal/notify/mail.go +++ /dev/null @@ -1,56 +0,0 @@ -package notify - -import ( - "context" - "fmt" - "net/mail" - "strconv" - - "github.com/pocketbase/pocketbase/tools/mailer" -) - -const defaultSmtpHostPort = "25" - -type Mail struct { - username string - to string - client *mailer.SmtpClient -} - -func NewMail(senderAddress, receiverAddresses, smtpHostAddr, smtpHostPort, password string) (*Mail, error) { - if smtpHostPort == "" { - smtpHostPort = defaultSmtpHostPort - } - - port, err := strconv.Atoi(smtpHostPort) - if err != nil { - return nil, fmt.Errorf("invalid smtp port: %w", err) - } - - client := mailer.SmtpClient{ - Host: smtpHostAddr, - Port: port, - Username: senderAddress, - Password: password, - Tls: true, - } - - return &Mail{ - username: senderAddress, - client: &client, - to: receiverAddresses, - }, nil -} - -func (m *Mail) Send(ctx context.Context, subject, content string) error { - message := &mailer.Message{ - From: mail.Address{ - Address: m.username, - }, - To: []mail.Address{{Address: m.to}}, - Subject: subject, - Text: content, - } - - return m.client.Send(message) -} diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 9e09c32b..6b218a18 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -4,22 +4,15 @@ import ( "context" "fmt" - stdhttp "net/http" + "golang.org/x/sync/errgroup" - "github.com/usual2970/certimate/internal/domain" + "github.com/usual2970/certimate/internal/pkg/core/notifier" + "github.com/usual2970/certimate/internal/pkg/utils/maps" "github.com/usual2970/certimate/internal/utils/app" - - notifyPackage "github.com/nikoksr/notify" - "github.com/nikoksr/notify/service/bark" - "github.com/nikoksr/notify/service/dingding" - "github.com/nikoksr/notify/service/http" - "github.com/nikoksr/notify/service/lark" - "github.com/nikoksr/notify/service/telegram" ) -func Send(title, content string) error { - // 获取所有的推送渠道 - notifiers, err := getNotifiers() +func SendToAllChannels(subject, message string) error { + notifiers, err := getEnabledNotifiers() if err != nil { return err } @@ -27,161 +20,56 @@ func Send(title, content string) error { return nil } - n := notifyPackage.New() - // 添加推送渠道 - n.UseServices(notifiers...) + var eg errgroup.Group + for _, n := range notifiers { + if n == nil { + continue + } - // 发送消息 - return n.Send(context.Background(), title, content) + eg.Go(func() error { + _, err := n.Notify(context.Background(), subject, message) + return err + }) + } + + err = eg.Wait() + return err } -type sendTestParam struct { - Title string `json:"title"` - Content string `json:"content"` - Channel string `json:"channel"` - Conf map[string]any `json:"conf"` -} - -func SendTest(param *sendTestParam) error { - notifier, err := getNotifier(param.Channel, param.Conf) +func SendToChannel(subject, message string, channel string, channelConfig map[string]any) error { + notifier, err := createNotifier(channel, channelConfig) if err != nil { return err } - n := notifyPackage.New() - - // 添加推送渠道 - n.UseServices(notifier) - - // 发送消息 - return n.Send(context.Background(), param.Title, param.Content) + _, err = notifier.Notify(context.Background(), subject, message) + return err } -func getNotifiers() ([]notifyPackage.Notifier, error) { - resp, err := app.GetApp().Dao().FindFirstRecordByFilter("settings", "name='notifyChannels'") +func getEnabledNotifiers() ([]notifier.Notifier, error) { + settings, err := app.GetApp().Dao().FindFirstRecordByFilter("settings", "name='notifyChannels'") if err != nil { return nil, fmt.Errorf("find notifyChannels error: %w", err) } - notifiers := make([]notifyPackage.Notifier, 0) - rs := make(map[string]map[string]any) - - if err := resp.UnmarshalJSONField("content", &rs); err != nil { + if err := settings.UnmarshalJSONField("content", &rs); err != nil { return nil, fmt.Errorf("unmarshal notifyChannels error: %w", err) } + notifiers := make([]notifier.Notifier, 0) for k, v := range rs { - - if !getConfigAsBool(v, "enabled") { + if !maps.GetValueAsBool(v, "enabled") { continue } - notifier, err := getNotifier(k, v) + notifier, err := createNotifier(k, v) if err != nil { continue } notifiers = append(notifiers, notifier) - } return notifiers, nil } - -func getNotifier(channel string, conf map[string]any) (notifyPackage.Notifier, error) { - switch channel { - case domain.NotifyChannelTelegram: - temp := getTelegramNotifier(conf) - if temp == nil { - return nil, fmt.Errorf("telegram notifier config error") - } - - return temp, nil - case domain.NotifyChannelDingtalk: - return getDingTalkNotifier(conf), nil - case domain.NotifyChannelLark: - return getLarkNotifier(conf), nil - case domain.NotifyChannelWebhook: - return getWebhookNotifier(conf), nil - case domain.NotifyChannelServerChan: - return getServerChanNotifier(conf), nil - case domain.NotifyChannelMail: - return getMailNotifier(conf) - case domain.NotifyChannelBark: - return getBarkNotifier(conf), nil - } - - return nil, fmt.Errorf("notifier not found") -} - -func getWebhookNotifier(conf map[string]any) notifyPackage.Notifier { - rs := http.New() - - rs.AddReceiversURLs(getConfigAsString(conf, "url")) - - return rs -} - -func getTelegramNotifier(conf map[string]any) notifyPackage.Notifier { - rs, err := telegram.New(getConfigAsString(conf, "apiToken")) - if err != nil { - return nil - } - - rs.AddReceivers(getConfigAsInt64(conf, "chatId")) - return rs -} - -func getServerChanNotifier(conf map[string]any) notifyPackage.Notifier { - rs := http.New() - - rs.AddReceivers(&http.Webhook{ - URL: getConfigAsString(conf, "url"), - Header: stdhttp.Header{}, - ContentType: "application/json", - Method: stdhttp.MethodPost, - BuildPayload: func(subject, message string) (payload any) { - return map[string]string{ - "text": subject, - "desp": message, - } - }, - }) - - return rs -} - -func getBarkNotifier(conf map[string]any) notifyPackage.Notifier { - deviceKey := getConfigAsString(conf, "deviceKey") - serverURL := getConfigAsString(conf, "serverUrl") - if serverURL == "" { - return bark.New(deviceKey) - } - return bark.NewWithServers(deviceKey, serverURL) -} - -func getDingTalkNotifier(conf map[string]any) notifyPackage.Notifier { - return dingding.New(&dingding.Config{ - Token: getConfigAsString(conf, "accessToken"), - Secret: getConfigAsString(conf, "secret"), - }) -} - -func getLarkNotifier(conf map[string]any) notifyPackage.Notifier { - return lark.NewWebhookService(getConfigAsString(conf, "webhookUrl")) -} - -func getMailNotifier(conf map[string]any) (notifyPackage.Notifier, error) { - rs, err := NewMail(getConfigAsString(conf, "senderAddress"), - getConfigAsString(conf, "receiverAddresses"), - getConfigAsString(conf, "smtpHostAddr"), - getConfigAsString(conf, "smtpHostPort"), - getConfigAsString(conf, "password"), - ) - if err != nil { - return nil, err - } - - return rs, nil -} diff --git a/internal/notify/service.go b/internal/notify/service.go index 22b77160..76162ff1 100644 --- a/internal/notify/service.go +++ b/internal/notify/service.go @@ -29,18 +29,13 @@ func NewNotifyService(settingRepo SettingRepository) *NotifyService { func (n *NotifyService) Test(ctx context.Context, req *domain.NotifyTestPushReq) error { setting, err := n.settingRepo.GetByName(ctx, "notifyChannels") if err != nil { - return fmt.Errorf("get notify channels setting failed: %w", err) + return fmt.Errorf("failed to get notify channels settings: %w", err) } - conf, err := setting.GetChannelContent(req.Channel) + channelConfig, err := setting.GetChannelContent(req.Channel) if err != nil { - return fmt.Errorf("get notify channel %s config failed: %w", req.Channel, err) + return fmt.Errorf("failed to get notify channel \"%s\" config: %w", req.Channel, err) } - return SendTest(&sendTestParam{ - Title: notifyTestTitle, - Content: notifyTestBody, - Channel: req.Channel, - Conf: conf, - }) + return SendToChannel(notifyTestTitle, notifyTestBody, req.Channel, channelConfig) } diff --git a/internal/notify/utils.go b/internal/notify/utils.go deleted file mode 100644 index 5d7e9ac9..00000000 --- a/internal/notify/utils.go +++ /dev/null @@ -1,21 +0,0 @@ -package notify - -import ( - "github.com/usual2970/certimate/internal/pkg/utils/maps" -) - -func getConfigAsString(conf map[string]any, key string) string { - return maps.GetValueAsString(conf, key) -} - -func getConfigAsInt32(conf map[string]any, key string) int32 { - return maps.GetValueAsInt32(conf, key) -} - -func getConfigAsInt64(conf map[string]any, key string) int64 { - return maps.GetValueAsInt64(conf, key) -} - -func getConfigAsBool(conf map[string]any, key string) bool { - return maps.GetValueAsBool(conf, key) -} diff --git a/internal/pkg/core/notifier/providers/email/email.go b/internal/pkg/core/notifier/providers/email/email.go index 5f767659..b79618cc 100644 --- a/internal/pkg/core/notifier/providers/email/email.go +++ b/internal/pkg/core/notifier/providers/email/email.go @@ -2,10 +2,10 @@ import ( "context" + "crypto/tls" "errors" "fmt" "net/smtp" - "os" "github.com/domodwyer/mailyak/v3" @@ -44,15 +44,25 @@ func (n *EmailNotifier) Notify(ctx context.Context, subject string, message stri smtpAuth = smtp.PlainAuth("", n.config.Username, n.config.Password, n.config.SmtpHost) } + var smtpAddr string + if n.config.SmtpPort == 0 { + if n.config.SmtpTLS { + smtpAddr = fmt.Sprintf("%s:465", n.config.SmtpHost) + } else { + smtpAddr = fmt.Sprintf("%s:25", n.config.SmtpHost) + } + } else { + smtpAddr = fmt.Sprintf("%s:%d", n.config.SmtpHost, n.config.SmtpPort) + } + var yak *mailyak.MailYak if n.config.SmtpTLS { - os.Setenv("GODEBUG", "tlsrsakex=1") // Fix for TLS handshake error - yak, err = mailyak.NewWithTLS(fmt.Sprintf("%s:%d", n.config.SmtpHost, n.config.SmtpPort), smtpAuth, nil) + yak, err = mailyak.NewWithTLS(smtpAddr, smtpAuth, newTlsConfig()) if err != nil { return nil, err } } else { - yak = mailyak.New(fmt.Sprintf("%s:%d", n.config.SmtpHost, n.config.SmtpPort), smtpAuth) + yak = mailyak.New(smtpAddr, smtpAuth) } yak.From(n.config.SenderAddress) @@ -67,3 +77,19 @@ func (n *EmailNotifier) Notify(ctx context.Context, subject string, message stri return ¬ifier.NotifyResult{}, nil } + +func newTlsConfig() *tls.Config { + var suiteIds []uint16 + for _, suite := range tls.CipherSuites() { + suiteIds = append(suiteIds, suite.ID) + } + for _, suite := range tls.InsecureCipherSuites() { + suiteIds = append(suiteIds, suite.ID) + } + + // 为兼容国内部分低版本 TLS 的 SMTP 服务商 + return &tls.Config{ + MinVersion: tls.VersionTLS10, + CipherSuites: suiteIds, + } +} diff --git a/internal/pkg/utils/maps/maps.go b/internal/pkg/utils/maps/maps.go index 6f9746e8..b475f09d 100644 --- a/internal/pkg/utils/maps/maps.go +++ b/internal/pkg/utils/maps/maps.go @@ -9,7 +9,7 @@ import "strconv" // - key: 键。 // // 出参: -// - 字典中键对应的值。如果指定键不存在或者类型不是字符串,则返回空字符串。 +// - 字典中键对应的值。如果指定键不存在或者值的类型不是字符串,则返回空字符串。 func GetValueAsString(dict map[string]any, key string) string { return GetValueOrDefaultAsString(dict, key, "") } @@ -22,7 +22,7 @@ func GetValueAsString(dict map[string]any, key string) string { // - defaultValue: 默认值。 // // 出参: -// - 字典中键对应的值。如果指定键不存在或者类型不是字符串,则返回默认值。 +// - 字典中键对应的值。如果指定键不存在或者值的类型不是字符串,则返回默认值。 func GetValueOrDefaultAsString(dict map[string]any, key string, defaultValue string) string { if dict == nil { return defaultValue @@ -44,7 +44,7 @@ func GetValueOrDefaultAsString(dict map[string]any, key string, defaultValue str // - key: 键。 // // 出参: -// - 字典中键对应的值。如果指定键不存在或者类型不是 32 位整数,则返回 0。 +// - 字典中键对应的值。如果指定键不存在或者值的类型不是 32 位整数,则返回 0。 func GetValueAsInt32(dict map[string]any, key string) int32 { return GetValueOrDefaultAsInt32(dict, key, 0) } @@ -57,7 +57,7 @@ func GetValueAsInt32(dict map[string]any, key string) int32 { // - defaultValue: 默认值。 // // 出参: -// - 字典中键对应的值。如果指定键不存在或者类型不是 32 位整数,则返回默认值。 +// - 字典中键对应的值。如果指定键不存在或者值的类型不是 32 位整数,则返回默认值。 func GetValueOrDefaultAsInt32(dict map[string]any, key string, defaultValue int32) int32 { if dict == nil { return defaultValue @@ -69,8 +69,8 @@ func GetValueOrDefaultAsInt32(dict map[string]any, key string, defaultValue int3 } // 兼容字符串类型的值 - if s, ok := value.(string); ok { - if result, err := strconv.ParseInt(s, 10, 32); err == nil { + if str, ok := value.(string); ok { + if result, err := strconv.ParseInt(str, 10, 32); err == nil { return int32(result) } } @@ -86,7 +86,7 @@ func GetValueOrDefaultAsInt32(dict map[string]any, key string, defaultValue int3 // - key: 键。 // // 出参: -// - 字典中键对应的值。如果指定键不存在或者类型不是 64 位整数,则返回 0。 +// - 字典中键对应的值。如果指定键不存在或者值的类型不是 64 位整数,则返回 0。 func GetValueAsInt64(dict map[string]any, key string) int64 { return GetValueOrDefaultAsInt64(dict, key, 0) } @@ -99,7 +99,7 @@ func GetValueAsInt64(dict map[string]any, key string) int64 { // - defaultValue: 默认值。 // // 出参: -// - 字典中键对应的值。如果指定键不存在或者类型不是 64 位整数,则返回默认值。 +// - 字典中键对应的值。如果指定键不存在或者值的类型不是 64 位整数,则返回默认值。 func GetValueOrDefaultAsInt64(dict map[string]any, key string, defaultValue int64) int64 { if dict == nil { return defaultValue @@ -111,8 +111,8 @@ func GetValueOrDefaultAsInt64(dict map[string]any, key string, defaultValue int6 } // 兼容字符串类型的值 - if s, ok := value.(string); ok { - if result, err := strconv.ParseInt(s, 10, 64); err == nil { + if str, ok := value.(string); ok { + if result, err := strconv.ParseInt(str, 10, 64); err == nil { return result } } @@ -128,7 +128,7 @@ func GetValueOrDefaultAsInt64(dict map[string]any, key string, defaultValue int6 // - key: 键。 // // 出参: -// - 字典中键对应的值。如果指定键不存在或者类型不是布尔,则返回 false。 +// - 字典中键对应的值。如果指定键不存在或者值的类型不是布尔,则返回 false。 func GetValueAsBool(dict map[string]any, key string) bool { return GetValueOrDefaultAsBool(dict, key, false) } @@ -141,7 +141,7 @@ func GetValueAsBool(dict map[string]any, key string) bool { // - defaultValue: 默认值。 // // 出参: -// - 字典中键对应的值。如果指定键不存在或者类型不是布尔,则返回默认值。 +// - 字典中键对应的值。如果指定键不存在或者值的类型不是布尔,则返回默认值。 func GetValueOrDefaultAsBool(dict map[string]any, key string, defaultValue bool) bool { if dict == nil { return defaultValue