Files
YMhut-box-C-/server/unified-management/internal/mail/mail.go
T
QWQLwToo 962a2f2143
build-winui / winui (push) Waiting to run
更新 update 门户站点界面和后台功能
2026-06-27 18:09:11 +08:00

358 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package mail
import (
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"mime"
"net"
"net/smtp"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"ymhut-box/server/unified-management/internal/config"
"ymhut-box/server/unified-management/internal/db"
)
type Message struct {
From string
FromName string
To string
Subject string
PlainBody string
HTMLBody string
AttachmentPath string
AttachmentName string
}
func SafeConfig(cfg config.MailConfig) map[string]any {
return map[string]any{
"host": cfg.Host,
"port": cfg.Port,
"secure": cfg.Secure,
"username": cfg.Username,
"fromAddress": cfg.FromAddress,
"fromName": cfg.FromName,
"developerAddress": cfg.DeveloperAddress,
"timeoutSeconds": cfg.TimeoutSeconds,
"hasPassword": strings.TrimSpace(cfg.Password) != "",
"configured": IsConfigured(cfg),
}
}
func IsConfigured(cfg config.MailConfig) bool {
channel := normalize(cfg)
return channel.Host != "" && channel.FromAddress != "" && channel.DeveloperAddress != ""
}
func BuildFeedbackMessage(cfg *config.Config, record db.Feedback) (Message, error) {
channel, err := channel(cfg.Mail)
if err != nil {
return Message{}, err
}
attachment := record.PackagePath
name := ""
if attachment != "" {
name = record.Code + ".zip"
}
subject := "[" + record.Code + "] YMhut Box 反馈:" + truncate(record.Title, 80)
return Message{
From: channel.FromAddress,
FromName: channel.FromName,
To: channel.DeveloperAddress,
Subject: subject,
PlainBody: feedbackPlain(record),
HTMLBody: feedbackHTML(record),
AttachmentPath: attachment,
AttachmentName: name,
}, nil
}
func BuildTestMessage(cfg *config.Config) (Message, error) {
channel, err := channel(cfg.Mail)
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: "这是一封来自 unified-management 的测试通知。\n时间:" + now,
HTMLBody: "<p>这是一封来自 unified-management 的测试通知。</p><p>时间:" + htmlEscape(now) + "</p>",
}, nil
}
func Send(cfg *config.Config, message Message) error {
channel, err := channel(cfg.Mail)
if err != nil {
return err
}
raw, err := BuildMIME(message)
if err != nil {
return err
}
return smtpSend(channel, message.From, message.To, raw)
}
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 := firstNonEmpty(message.AttachmentName, 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 channel(cfg config.MailConfig) (config.MailConfig, error) {
cfg = normalize(cfg)
if cfg.Host == "" || cfg.FromAddress == "" || cfg.DeveloperAddress == "" {
return cfg, errors.New("mail is not configured")
}
return cfg, nil
}
func normalize(cfg config.MailConfig) config.MailConfig {
cfg.Secure = strings.ToLower(strings.TrimSpace(cfg.Secure))
if cfg.Secure == "" {
cfg.Secure = "ssl"
}
if cfg.Port <= 0 {
cfg.Port = 465
}
if cfg.FromAddress == "" {
cfg.FromAddress = cfg.Username
}
if cfg.FromName == "" {
cfg.FromName = "YMhut Box Feedback"
}
if cfg.TimeoutSeconds <= 0 {
cfg.TimeoutSeconds = 20
}
return cfg
}
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)
}
var clientErr error
client, clientErr = smtp.NewClient(conn, channel.Host)
if clientErr != nil {
_ = conn.Close()
return clientErr
}
} else {
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return fmt.Errorf("邮件服务器连接失败:%w", err)
}
var clientErr error
client, clientErr = smtp.NewClient(conn, channel.Host)
if clientErr != nil {
_ = conn.Close()
return clientErr
}
}
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 feedbackPlain(record db.Feedback) string {
return strings.Join([]string{
"YMhut Box 反馈工单",
"反馈编号:" + record.Code,
"标题:" + record.Title,
"类型:" + typeLabel(record.Type),
"优先级:" + priorityLabel(record.Priority, record.Severity),
"联系方式:" + record.Contact,
"接收时间:" + record.CreatedAt,
"包含文件:" + record.IncludedFiles,
"反馈包 SHA256" + record.PlainPackageSha256,
"",
"正文:",
record.Body,
"",
"反馈包摘要:",
record.SummaryText,
}, "\n")
}
func feedbackHTML(record db.Feedback) string {
rows := [][2]string{
{"反馈编号", record.Code},
{"标题", record.Title},
{"类型", typeLabel(record.Type)},
{"优先级", priorityLabel(record.Priority, record.Severity)},
{"联系方式", record.Contact},
{"接收时间", record.CreatedAt},
{"包含文件", record.IncludedFiles},
{"反馈包 SHA256", 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(strings.TrimSpace(value)) {
case "suggestion":
return "建议"
case "ui":
return "界面反馈"
case "other":
return "其他"
default:
return "问题"
}
}
func priorityLabel(priority, severity string) string {
value := firstNonEmpty(priority, severity)
switch strings.ToLower(strings.TrimSpace(value)) {
case "urgent", "blocking":
return "紧急"
case "high", "major":
return "高"
case "low", "minor":
return "低"
default:
return "普通"
}
}
func htmlEscape(value string) string {
value = strings.ReplaceAll(value, "&", "&amp;")
value = strings.ReplaceAll(value, "<", "&lt;")
value = strings.ReplaceAll(value, ">", "&gt;")
value = strings.ReplaceAll(value, `"`, "&quot;")
return strings.ReplaceAll(value, "'", "&#39;")
}
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])
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}