219 lines
6.6 KiB
Go
219 lines
6.6 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"mime"
|
|
"net"
|
|
"net/http"
|
|
"net/smtp"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// 通知渠道类型;telegram 走既有独立路径,不纳入此枚举。
|
|
const (
|
|
ChannelWebhook = "webhook"
|
|
ChannelNtfy = "ntfy"
|
|
ChannelBark = "bark"
|
|
ChannelSMTP = "smtp"
|
|
)
|
|
|
|
// NotifyChannelTypes 是新增渠道的稳定顺序(视图输出与遍历发送共用)。
|
|
var NotifyChannelTypes = []string{ChannelWebhook, ChannelNtfy, ChannelBark, ChannelSMTP}
|
|
|
|
// notifyMsg 是渲染完成的渠道无关载荷:Title 取通知类型显示名,Text 为模板渲染文本。
|
|
type notifyMsg struct {
|
|
Title string
|
|
Text string
|
|
}
|
|
|
|
// defaultWebhookBody 是通用 Webhook 的缺省 body 模板(兼容 Slack incoming webhook)。
|
|
const defaultWebhookBody = `{"text": "{{title}}\n{{text}}"}`
|
|
|
|
// 渠道服务端缺省地址。
|
|
const (
|
|
defaultNtfyServer = "https://ntfy.sh"
|
|
defaultBarkServer = "https://api.day.app"
|
|
)
|
|
|
|
// WebhookChannel 是通用 Webhook 渠道配置;BodyTemplate 空串按缺省模板发送。
|
|
type WebhookChannel struct {
|
|
Enabled bool
|
|
URL string
|
|
BodyTemplate string
|
|
}
|
|
|
|
// NtfyChannel 是 ntfy 渠道配置;Server 空串用官方 ntfy.sh,Token 可选。
|
|
type NtfyChannel struct {
|
|
Enabled bool
|
|
Server string
|
|
Topic string
|
|
Token string
|
|
}
|
|
|
|
// BarkChannel 是 Bark(iOS)渠道配置;Server 空串用官方 api.day.app。
|
|
type BarkChannel struct {
|
|
Enabled bool
|
|
Server string
|
|
DeviceKey string
|
|
}
|
|
|
|
// SMTPChannel 是邮件渠道配置;465 端口走隐式 TLS,其余端口 STARTTLS,Username 空则免认证。
|
|
type SMTPChannel struct {
|
|
Enabled bool
|
|
Host string
|
|
Port int
|
|
Username string
|
|
Password string
|
|
From string
|
|
To string
|
|
}
|
|
|
|
// validNotifyURL 校验渠道出站地址:仅接受 http/https 且 host 非空。
|
|
func validNotifyURL(raw string) error {
|
|
u, err := url.Parse(raw)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
|
return fmt.Errorf("地址须为 http(s):// 开头的合法 URL")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// renderWebhookBody 渲染 body 模板:占位值先做 JSON 字符串转义(去掉包裹引号)再替换,
|
|
// 保证换行/引号嵌入 JSON 字符串后仍合法。
|
|
func renderWebhookBody(tpl string, msg notifyMsg) string {
|
|
if strings.TrimSpace(tpl) == "" {
|
|
tpl = defaultWebhookBody
|
|
}
|
|
r := strings.NewReplacer("{{title}}", jsonEscape(msg.Title), "{{text}}", jsonEscape(msg.Text))
|
|
return r.Replace(tpl)
|
|
}
|
|
|
|
// jsonEscape 返回 s 作为 JSON 字符串字面量的内容(不含首尾引号)。
|
|
func jsonEscape(s string) string {
|
|
b, _ := json.Marshal(s)
|
|
return string(b[1 : len(b)-1])
|
|
}
|
|
|
|
// sendWebhook 向自定义 endpoint POST 渲染后的 JSON body。
|
|
func sendWebhook(ctx context.Context, client *http.Client, cfg WebhookChannel, msg notifyMsg) error {
|
|
body := renderWebhookBody(cfg.BodyTemplate, msg)
|
|
return postJSON(ctx, client, cfg.URL, "", []byte(body))
|
|
}
|
|
|
|
// sendNtfy 以 JSON publish 模式向 ntfy 服务端根路径发布(UTF-8 标题无兼容问题)。
|
|
func sendNtfy(ctx context.Context, client *http.Client, cfg NtfyChannel, msg notifyMsg) error {
|
|
server := cfg.Server
|
|
if server == "" {
|
|
server = defaultNtfyServer
|
|
}
|
|
body, err := json.Marshal(map[string]string{"topic": cfg.Topic, "title": msg.Title, "message": msg.Text})
|
|
if err != nil {
|
|
return fmt.Errorf("ntfy send: %w", err)
|
|
}
|
|
return postJSON(ctx, client, strings.TrimRight(server, "/"), cfg.Token, body)
|
|
}
|
|
|
|
// sendBark 调用 Bark v2 push 接口。
|
|
func sendBark(ctx context.Context, client *http.Client, cfg BarkChannel, msg notifyMsg) error {
|
|
server := cfg.Server
|
|
if server == "" {
|
|
server = defaultBarkServer
|
|
}
|
|
body, err := json.Marshal(map[string]string{"title": msg.Title, "body": msg.Text, "device_key": cfg.DeviceKey})
|
|
if err != nil {
|
|
return fmt.Errorf("bark send: %w", err)
|
|
}
|
|
return postJSON(ctx, client, strings.TrimRight(server, "/")+"/push", "", body)
|
|
}
|
|
|
|
// postJSON 发送 JSON POST;token 非空时附 Bearer 认证,非 2xx 一律视为失败。
|
|
func postJSON(ctx context.Context, client *http.Client, u, token string, body []byte) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
|
|
if err != nil {
|
|
return sanitizeURLError(err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return sanitizeURLError(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("http %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sendSMTP 组装邮件报文并投递;主题按 RFC2047 编码,正文 UTF-8 纯文本。
|
|
func sendSMTP(ctx context.Context, cfg SMTPChannel, msg notifyMsg) error {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "From: %s\r\n", cfg.From)
|
|
fmt.Fprintf(&b, "To: %s\r\n", cfg.To)
|
|
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", msg.Title))
|
|
b.WriteString("MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n")
|
|
b.WriteString(strings.ReplaceAll(msg.Text, "\n", "\r\n"))
|
|
return smtpDeliver(ctx, cfg, []byte(b.String()))
|
|
}
|
|
|
|
// smtpDeliver 建立 SMTP 会话:465 端口隐式 TLS,其余端口连通后尝试 STARTTLS。
|
|
func smtpDeliver(ctx context.Context, cfg SMTPChannel, mail []byte) error {
|
|
addr := net.JoinHostPort(cfg.Host, fmt.Sprint(cfg.Port))
|
|
d := &net.Dialer{}
|
|
conn, err := d.DialContext(ctx, "tcp", addr)
|
|
if err != nil {
|
|
return fmt.Errorf("smtp dial: %w", err)
|
|
}
|
|
if cfg.Port == 465 {
|
|
conn = tls.Client(conn, &tls.Config{ServerName: cfg.Host})
|
|
}
|
|
c, err := smtp.NewClient(conn, cfg.Host)
|
|
if err != nil {
|
|
conn.Close()
|
|
return fmt.Errorf("smtp handshake: %w", err)
|
|
}
|
|
defer c.Close()
|
|
return smtpWrite(c, cfg, mail)
|
|
}
|
|
|
|
// smtpWrite 在既有会话上完成 STARTTLS/认证/收发件与报文写入。
|
|
func smtpWrite(c *smtp.Client, cfg SMTPChannel, mail []byte) error {
|
|
if cfg.Port != 465 {
|
|
if ok, _ := c.Extension("STARTTLS"); ok {
|
|
if err := c.StartTLS(&tls.Config{ServerName: cfg.Host}); err != nil {
|
|
return fmt.Errorf("smtp starttls: %w", err)
|
|
}
|
|
}
|
|
}
|
|
if cfg.Username != "" {
|
|
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
|
|
if err := c.Auth(auth); err != nil {
|
|
return fmt.Errorf("smtp auth: %w", err)
|
|
}
|
|
}
|
|
if err := c.Mail(cfg.From); err != nil {
|
|
return fmt.Errorf("smtp from: %w", err)
|
|
}
|
|
if err := c.Rcpt(cfg.To); err != nil {
|
|
return fmt.Errorf("smtp rcpt: %w", err)
|
|
}
|
|
w, err := c.Data()
|
|
if err != nil {
|
|
return fmt.Errorf("smtp data: %w", err)
|
|
}
|
|
if _, err := w.Write(mail); err != nil {
|
|
w.Close()
|
|
return fmt.Errorf("smtp write: %w", err)
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
return fmt.Errorf("smtp close: %w", err)
|
|
}
|
|
return c.Quit()
|
|
}
|