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: "
这是一封来自 unified-management 的测试通知。
时间:" + htmlEscape(now) + "
", }, 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 := `| ` + htmlEscape(row[0]) + " | " + strings.ReplaceAll(htmlEscape(row[1]), "\n", " ") + " |
|---|
` + htmlEscape(record.Body) + "
" html += `` + htmlEscape(record.SummaryText) + "" 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 "" }