358 lines
9.8 KiB
Go
358 lines
9.8 KiB
Go
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, "&", "&")
|
||
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])
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, value := range values {
|
||
if strings.TrimSpace(value) != "" {
|
||
return strings.TrimSpace(value)
|
||
}
|
||
}
|
||
return ""
|
||
}
|