Add server components
build-winui / winui (push) Has been cancelled

This commit is contained in:
QWQLwToo
2026-06-26 13:28:09 +08:00
parent 7ecc6a8923
commit 079ee4eaeb
168 changed files with 37475 additions and 0 deletions
@@ -0,0 +1,322 @@
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, "&", "&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])
}