Compare commits

...
4 Commits
Author SHA1 Message Date
github-actions[bot] 0af5903bfe Merge pull request #283 from komari-monitor/dev
fix: 增强模板替换功能,支持 JSON 内容类型的占位符转义
2025-09-21 14:09:52 +00:00
akizon77 717b7b4a88 fix: 增强模板替换功能,支持 JSON 内容类型的占位符转义 2025-09-21 22:08:54 +08:00
github-actions[bot] 05eec9515b Merge pull request #282 from komari-monitor/dev
fix: 处理QQ发送消息时的短响应错误
2025-09-21 13:42:07 +00:00
akizon77 877852fc29 fix: 处理QQ发送消息时的短响应错误 2025-09-21 21:38:00 +08:00
3 changed files with 207 additions and 47 deletions

No files matched your search

+151 -42
View File
@@ -1,10 +1,16 @@
package email
import (
"bytes"
"crypto/tls"
"fmt"
"mime"
"mime/quotedprintable"
"net/mail"
"net/smtp"
"strconv"
"strings"
"time"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
@@ -35,6 +41,7 @@ func (e *EmailSender) SendTextMessage(message, title string) error {
return fmt.Errorf("email sending is not fully configured")
}
// Prepare auth
auth := smtp.PlainAuth(
"",
e.Addition.Username,
@@ -42,61 +49,163 @@ func (e *EmailSender) SendTextMessage(message, title string) error {
e.Addition.Host,
)
msg := []byte("To: " + e.Addition.Receiver + "\r\n" +
"From: " + e.Addition.Sender + "\r\n" +
"Subject: " + title + "\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n" +
"\r\n" +
message)
// Parse sender address (for MAIL FROM and header)
var senderAddr string
var senderHeader string
if addr, err := mail.ParseAddress(e.Addition.Sender); err == nil {
senderAddr = addr.Address
senderHeader = addr.String()
} else {
// Fallback: use raw string
senderAddr = e.Addition.Sender
senderHeader = e.Addition.Sender
}
// Parse recipients (support comma-separated list)
var rcptList []string
var rcptHeaderParts []string
if addrs, err := mail.ParseAddressList(e.Addition.Receiver); err == nil {
for _, a := range addrs {
rcptList = append(rcptList, a.Address)
rcptHeaderParts = append(rcptHeaderParts, a.String())
}
} else {
// Fallback simple split
parts := strings.Split(e.Addition.Receiver, ",")
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if a, err := mail.ParseAddress(p); err == nil {
rcptList = append(rcptList, a.Address)
rcptHeaderParts = append(rcptHeaderParts, a.String())
} else {
rcptList = append(rcptList, p)
rcptHeaderParts = append(rcptHeaderParts, p)
}
}
}
if len(rcptList) == 0 {
return fmt.Errorf("no valid recipient address parsed")
}
// RFC 2047 encode subject if non-ASCII
encodedSubject := mime.QEncoding.Encode("UTF-8", title)
// Encode body as quoted-printable to be safe with UTF-8 on servers lacking 8BITMIME
var bodyBuf bytes.Buffer
qp := quotedprintable.NewWriter(&bodyBuf)
if _, err := qp.Write([]byte(message)); err != nil {
return fmt.Errorf("failed to encode body: %w", err)
}
if err := qp.Close(); err != nil {
return fmt.Errorf("failed to finalize body encoding: %w", err)
}
// Compose headers
headers := []string{
"To: " + strings.Join(rcptHeaderParts, ", "),
"From: " + senderHeader,
"Subject: " + encodedSubject,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"Content-Transfer-Encoding: quoted-printable",
"Date: " + time.Now().Format(time.RFC1123Z),
fmt.Sprintf("Message-ID: <%d@%s>", time.Now().UnixNano(), e.Addition.Host),
}
fullMsg := []byte(strings.Join(headers, "\r\n") + "\r\n\r\n" + bodyBuf.String())
addr := e.Addition.Host + ":" + strconv.Itoa(e.Addition.Port)
if e.Addition.UseSSL {
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("failed to dial SMTP server: %w", err)
}
defer c.Close()
// Use TLS. If port is 465, prefer implicit TLS. Otherwise, use STARTTLS.
if e.Addition.Port == 465 {
// Implicit TLS (SMTPS)
tlsCfg := &tls.Config{ServerName: e.Addition.Host}
conn, err := tls.Dial("tcp", addr, tlsCfg)
if err != nil {
return fmt.Errorf("failed to establish implicit TLS connection: %w", err)
}
defer conn.Close()
if err = c.StartTLS(&tls.Config{ServerName: e.Addition.Host}); err != nil {
return fmt.Errorf("failed to start TLS: %w", err)
}
c, err := smtp.NewClient(conn, e.Addition.Host)
if err != nil {
return fmt.Errorf("failed to create SMTP client over TLS: %w", err)
}
defer c.Close()
// Authenticate
if err = c.Auth(auth); err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
if err = c.Auth(auth); err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
// Set the sender and recipient
if err = c.Mail(e.Addition.Sender); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err = c.Rcpt(e.Addition.Receiver); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
if err = c.Mail(senderAddr); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
for _, rcpt := range rcptList {
if err = c.Rcpt(rcpt); err != nil {
return fmt.Errorf("failed to add recipient %s: %w", rcpt, err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
_, err = w.Write(msg)
if err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
err = w.Close()
if err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
if _, err = w.Write(fullMsg); err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err = w.Close(); err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return c.Quit()
} else {
// STARTTLS
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("failed to dial SMTP server: %w", err)
}
defer c.Close()
return c.Quit()
if err = c.StartTLS(&tls.Config{ServerName: e.Addition.Host}); err != nil {
return fmt.Errorf("failed to start TLS: %w", err)
}
if err = c.Auth(auth); err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
if err = c.Mail(senderAddr); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
for _, rcpt := range rcptList {
if err = c.Rcpt(rcpt); err != nil {
return fmt.Errorf("failed to add recipient %s: %w", rcpt, err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
if _, err = w.Write(fullMsg); err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err = w.Close(); err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return c.Quit()
}
} else {
// Send without SSL/TLS (less secure)
// Send without SSL/TLS (less secure). We still reuse the composed message and parsed addresses.
return smtp.SendMail(
addr,
auth,
e.Addition.Sender,
[]string{e.Addition.Receiver},
msg,
senderAddr,
rcptList,
fullMsg,
)
}
}
+1 -1
View File
@@ -111,7 +111,7 @@ func SendEvent(event models.EventMessage) error {
for i := 0; i < 3; i++ {
err = CurrentProvider().SendTextMessage(messageTemplate, event.Event)
if err == nil {
if err == nil || err.Error() == "short response: \x00\x00\x00\x1a\x00\x00\x00" { // QQ 会返回这个错误,但实际上消息是发送成功的
auditlog.Log("", "", "Event message sent: "+event.Event, "info")
return nil
}
+55 -4
View File
@@ -93,14 +93,19 @@ func (w *WebhookSender) SendTextMessage(message, title string) error {
}
func (w *WebhookSender) createPOSTRequest(message, title string) (*http.Request, error) {
// 替换模板中的占位符
body := w.replaceTemplate(w.Addition.Body, message, title)
contentType := w.Addition.ContentType
if contentType == "" {
contentType = "application/json"
}
// 用户自定义模板,按 Content-Type 决定如何替换占位符
var body string
if isJSONContentType(contentType) {
body = w.replaceTemplateJSON(w.Addition.Body, message, title)
} else {
body = w.replaceTemplate(w.Addition.Body, message, title)
}
req, err := http.NewRequest("POST", w.Addition.URL, bytes.NewBufferString(body))
if err != nil {
return nil, err
@@ -125,10 +130,56 @@ func (w *WebhookSender) createGETRequest(message, title string) (*http.Request,
return req, nil
}
// replaceTemplate 替换模板中的 {{message}} 和 {{title}} 占位符
// replaceTemplate 替换模板中的 {{message}} 和 {{title}} 占位符(不做转义)
func (w *WebhookSender) replaceTemplate(template, message, title string) string {
result := template
result = strings.ReplaceAll(result, "{{message}}", message)
result = strings.ReplaceAll(result, "{{title}}", title)
return result
}
// replaceTemplateJSON 在 JSON 场景下替换占位符,使用 \uXXXX 形式进行转义(尤其换行/控制字符)
func (w *WebhookSender) replaceTemplateJSON(template, message, title string) string {
result := template
result = strings.ReplaceAll(result, "{{message}}", jsonUnicodeEscapeString(message))
result = strings.ReplaceAll(result, "{{title}}", jsonUnicodeEscapeString(title))
return result
}
// isJSONContentType 判断是否为 JSON 内容类型
func isJSONContentType(ct string) bool {
return strings.Contains(strings.ToLower(ct), "application/json")
}
// jsonUnicodeEscapeString 将字符串按 JSON 规则进行转义,并尽量使用 \uXXXX 形式(尤其控制字符、引号和反斜杠)
// 注意:该结果适合放入 JSON 字符串字面量的引号内部。
func jsonUnicodeEscapeString(s string) string {
var b strings.Builder
for _, r := range s {
switch r {
case '"':
b.WriteString("\\u0022")
case '\\':
b.WriteString("\\u005C")
case '\b':
b.WriteString("\\u0008")
case '\f':
b.WriteString("\\u000C")
case '\n':
b.WriteString("\\u000A")
case '\r':
b.WriteString("\\u000D")
case '\t':
b.WriteString("\\u0009")
default:
if r < 0x20 {
// 其他控制字符
b.WriteString(fmt.Sprintf("\\u%04X", r))
} else {
// 其余字符按原样输出(UTF-8),JSON 允许非 ASCII 字符
b.WriteRune(r)
}
}
}
return b.String()
}