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() }