@@ -0,0 +1,322 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ymhut-box/server/feedback-mailer/internal/config"
|
||||
"ymhut-box/server/feedback-mailer/internal/db"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
From string
|
||||
FromName string
|
||||
To string
|
||||
Subject string
|
||||
PlainBody string
|
||||
HTMLBody string
|
||||
AttachmentPath string
|
||||
AttachmentName string
|
||||
}
|
||||
|
||||
func BuildFeedbackMessage(cfg *config.Config, record db.FeedbackRecord, packagePath string) (Message, error) {
|
||||
channel, err := channel(cfg)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
subject := "[" + record.Code + "] YMhut Box 反馈:" + truncate(record.Title, 80)
|
||||
return Message{
|
||||
From: channel.FromAddress,
|
||||
FromName: channel.FromName,
|
||||
To: channel.DeveloperAddress,
|
||||
Subject: subject,
|
||||
PlainBody: buildFeedbackPlain(record),
|
||||
HTMLBody: buildFeedbackHTML(record),
|
||||
AttachmentPath: packagePath,
|
||||
AttachmentName: record.Code + ".zip",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func BuildTestMessage(cfg *config.Config) (Message, error) {
|
||||
channel, err := channel(cfg)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
return Message{
|
||||
From: channel.FromAddress,
|
||||
FromName: channel.FromName,
|
||||
To: channel.DeveloperAddress,
|
||||
Subject: "YMhut Box 反馈中心测试通知",
|
||||
PlainBody: "这是一封来自反馈中心后台的测试通知。\n时间:" + now,
|
||||
HTMLBody: "<p>这是一封来自反馈中心后台的测试通知。</p><p>时间:" + htmlEscape(now) + "</p>",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func Send(cfg *config.Config, message Message) error {
|
||||
channel, err := channel(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := BuildMIME(message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return smtpSend(channel, message.From, message.To, raw)
|
||||
}
|
||||
|
||||
func channel(cfg *config.Config) (config.MailConfig, error) {
|
||||
channel := cfg.Mail
|
||||
if channel.FromAddress == "" {
|
||||
channel.FromAddress = channel.Username
|
||||
}
|
||||
if channel.FromName == "" {
|
||||
channel.FromName = "YMhut Box Feedback"
|
||||
}
|
||||
if channel.Port <= 0 {
|
||||
channel.Port = 465
|
||||
}
|
||||
if channel.TimeoutSeconds <= 0 {
|
||||
channel.TimeoutSeconds = 20
|
||||
}
|
||||
channel.Secure = strings.ToLower(channel.Secure)
|
||||
if channel.Secure == "" {
|
||||
channel.Secure = "ssl"
|
||||
}
|
||||
if channel.Host == "" || channel.FromAddress == "" || channel.DeveloperAddress == "" {
|
||||
return channel, errors.New("通知配置不完整")
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func BuildMIME(message Message) (string, error) {
|
||||
boundary := "ymhut_" + randomish()
|
||||
altBoundary := "ymhut_alt_" + randomish()
|
||||
headers := []string{
|
||||
"Date: " + time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05") + " +0000",
|
||||
"From: " + mimeAddress(message.From, message.FromName),
|
||||
"To: " + message.To,
|
||||
"Subject: " + mime.BEncoding.Encode("UTF-8", message.Subject),
|
||||
"MIME-Version: 1.0",
|
||||
`Content-Type: multipart/mixed; boundary="` + boundary + `"`,
|
||||
}
|
||||
|
||||
body := []string{
|
||||
"--" + boundary,
|
||||
`Content-Type: multipart/alternative; boundary="` + altBoundary + `"`,
|
||||
"",
|
||||
"--" + altBoundary,
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
wrapBase64([]byte(message.PlainBody)),
|
||||
"--" + altBoundary,
|
||||
"Content-Type: text/html; charset=UTF-8",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
wrapBase64([]byte(message.HTMLBody)),
|
||||
"--" + altBoundary + "--",
|
||||
}
|
||||
|
||||
if message.AttachmentPath != "" {
|
||||
data, err := os.ReadFile(message.AttachmentPath)
|
||||
if err == nil {
|
||||
name := message.AttachmentName
|
||||
if name == "" {
|
||||
name = filepath.Base(message.AttachmentPath)
|
||||
}
|
||||
escaped := strings.ReplaceAll(strings.ReplaceAll(name, `\`, `\\`), `"`, `\"`)
|
||||
body = append(body,
|
||||
"--"+boundary,
|
||||
`Content-Type: application/zip; name="`+escaped+`"`,
|
||||
"Content-Transfer-Encoding: base64",
|
||||
`Content-Disposition: attachment; filename="`+escaped+`"`,
|
||||
"",
|
||||
wrapBase64(data),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
body = append(body, "--"+boundary+"--")
|
||||
return strings.Join(headers, "\r\n") + "\r\n\r\n" + strings.Join(body, "\r\n"), nil
|
||||
}
|
||||
|
||||
func smtpSend(channel config.MailConfig, from, to, rawMessage string) error {
|
||||
address := net.JoinHostPort(channel.Host, fmt.Sprintf("%d", channel.Port))
|
||||
timeout := time.Duration(channel.TimeoutSeconds) * time.Second
|
||||
|
||||
var client *smtp.Client
|
||||
if channel.Secure == "ssl" || channel.Secure == "tls" {
|
||||
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: timeout}, "tcp", address, &tls.Config{ServerName: channel.Host})
|
||||
if err != nil {
|
||||
return fmt.Errorf("通知连接失败:%w", err)
|
||||
}
|
||||
client, err = smtp.NewClient(conn, channel.Host)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
conn, err := net.DialTimeout("tcp", address, timeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("通知连接失败:%w", err)
|
||||
}
|
||||
client, err = smtp.NewClient(conn, channel.Host)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if channel.Secure == "starttls" {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: channel.Host}); err != nil {
|
||||
return fmt.Errorf("通知加密握手失败:%w", err)
|
||||
}
|
||||
}
|
||||
if channel.Username != "" || channel.Password != "" {
|
||||
if err := client.Auth(smtp.PlainAuth("", channel.Username, channel.Password, channel.Host)); err != nil {
|
||||
return fmt.Errorf("通知认证失败:%w", err)
|
||||
}
|
||||
}
|
||||
if err := client.Mail(extractEmail(from)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Rcpt(extractEmail(to)); err != nil {
|
||||
return err
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writer.Write([]byte(rawMessage)); err != nil {
|
||||
writer.Close()
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func buildFeedbackPlain(record db.FeedbackRecord) string {
|
||||
return strings.Join([]string{
|
||||
"YMhut Box 反馈单",
|
||||
"反馈编号:" + record.Code,
|
||||
"标题:" + record.Title,
|
||||
"类型:" + typeLabel(record.Type),
|
||||
"严重程度:" + severityLabel(record.Severity),
|
||||
"联系方式:" + record.Contact,
|
||||
"接收时间:" + record.ReceivedAt,
|
||||
"包含文件:" + record.IncludedFiles,
|
||||
"原始包校验:" + record.PlainPackageSha256,
|
||||
"",
|
||||
"正文:",
|
||||
record.Body,
|
||||
"",
|
||||
"反馈包摘要:",
|
||||
record.SummaryText,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func buildFeedbackHTML(record db.FeedbackRecord) string {
|
||||
rows := [][2]string{
|
||||
{"反馈编号", record.Code},
|
||||
{"标题", record.Title},
|
||||
{"类型", typeLabel(record.Type)},
|
||||
{"严重程度", severityLabel(record.Severity)},
|
||||
{"联系方式", record.Contact},
|
||||
{"接收时间", record.ReceivedAt},
|
||||
{"包含文件", record.IncludedFiles},
|
||||
{"原始包校验", record.PlainPackageSha256},
|
||||
}
|
||||
html := `<h2>YMhut Box 反馈单</h2><table cellpadding="8" cellspacing="0" border="1" style="border-collapse:collapse">`
|
||||
for _, row := range rows {
|
||||
html += "<tr><th align=\"left\">" + htmlEscape(row[0]) + "</th><td>" + strings.ReplaceAll(htmlEscape(row[1]), "\n", "<br>") + "</td></tr>"
|
||||
}
|
||||
html += "</table>"
|
||||
html += `<h3>正文</h3><p style="white-space:pre-wrap">` + htmlEscape(record.Body) + "</p>"
|
||||
html += `<h3>反馈包摘要</h3><pre style="white-space:pre-wrap">` + htmlEscape(record.SummaryText) + "</pre>"
|
||||
return html
|
||||
}
|
||||
|
||||
func typeLabel(value string) string {
|
||||
switch strings.ToLower(value) {
|
||||
case "suggestion":
|
||||
return "建议"
|
||||
case "ui":
|
||||
return "界面反馈"
|
||||
case "other":
|
||||
return "其他"
|
||||
default:
|
||||
return "问题"
|
||||
}
|
||||
}
|
||||
|
||||
func severityLabel(value string) string {
|
||||
switch strings.ToLower(value) {
|
||||
case "major":
|
||||
return "影响使用"
|
||||
case "blocking":
|
||||
return "阻塞"
|
||||
default:
|
||||
return "普通"
|
||||
}
|
||||
}
|
||||
|
||||
func htmlEscape(value string) string {
|
||||
value = strings.ReplaceAll(value, "&", "&")
|
||||
value = strings.ReplaceAll(value, "<", "<")
|
||||
value = strings.ReplaceAll(value, ">", ">")
|
||||
value = strings.ReplaceAll(value, `"`, """)
|
||||
return strings.ReplaceAll(value, "'", "'")
|
||||
}
|
||||
|
||||
func mimeAddress(address, name string) string {
|
||||
if name == "" {
|
||||
return address
|
||||
}
|
||||
return mime.BEncoding.Encode("UTF-8", name) + " <" + extractEmail(address) + ">"
|
||||
}
|
||||
|
||||
func extractEmail(value string) string {
|
||||
re := regexp.MustCompile(`<([^>]+)>`)
|
||||
if match := re.FindStringSubmatch(value); len(match) == 2 {
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func wrapBase64(data []byte) string {
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
var builder strings.Builder
|
||||
for len(encoded) > 76 {
|
||||
builder.WriteString(encoded[:76])
|
||||
builder.WriteString("\r\n")
|
||||
encoded = encoded[76:]
|
||||
}
|
||||
builder.WriteString(encoded)
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func randomish() string {
|
||||
return strings.ReplaceAll(fmt.Sprintf("%d", time.Now().UnixNano()), "-", "")
|
||||
}
|
||||
|
||||
func truncate(value string, max int) string {
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
if len(runes) <= max {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:max])
|
||||
}
|
||||
Reference in New Issue
Block a user