323 lines
8.7 KiB
Go
323 lines
8.7 KiB
Go
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])
|
|
}
|