mirror of
https://github.com/komari-monitor/komari.git
synced 2026-11-11 08:42:39 +00:00
Compare commits
4
Commits
1.0.8-fix1
...
1.0.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0af5903bfe | ||
|
|
717b7b4a88 | ||
|
|
05eec9515b | ||
|
|
877852fc29 |
No files matched your search
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user