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: "

这是一封来自反馈中心后台的测试通知。

时间:" + htmlEscape(now) + "

", }, 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 := `

YMhut Box 反馈单

` for _, row := range rows { html += "" } html += "
" + htmlEscape(row[0]) + "" + strings.ReplaceAll(htmlEscape(row[1]), "\n", "
") + "
" html += `

正文

` + htmlEscape(record.Body) + "

" html += `

反馈包摘要

` + htmlEscape(record.SummaryText) + "
" 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]) }