更新 update 门户站点界面和后台功能
build-winui / winui (push) Waiting to run

This commit is contained in:
QWQLwToo
2026-06-27 18:09:11 +08:00
parent 2513eb2903
commit 962a2f2143
56 changed files with 4564 additions and 714 deletions
@@ -0,0 +1,357 @@
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, "&", "&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])
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}