package notification import ( "fmt" "html" "net/url" "strings" "git.pcux.cn/xuans/Scert/backend/internal/config" "git.pcux.cn/xuans/Scert/backend/internal/deployment" ) type mailContent struct { title string status string statusColor string statusSummary string domain string domains []string expiresAt string certificateSerial string managementURL string problem string recoveryWarning string deployment deployment.Report includeDeployment bool hasDeployment bool } func buildMailSubject(title, status, domainName string) string { if status == "成功" { return fmt.Sprintf("[Scert] %s完成:%s", title, domainName) } return fmt.Sprintf("[Scert] %s%s:%s", title, status, domainName) } func buildMailContent(event Event, title, status, domainName string, cfg config.NotificationConfig) (string, string) { content := mailContent{ title: title, status: status, statusColor: mailStatusColor(status), statusSummary: mailStatusSummary(event, title, status), recoveryWarning: recoveryWarning(event), domain: domainName, domains: append([]string(nil), event.Domains...), deployment: event.Deployment, includeDeployment: cfg.IncludeDeploymentDetails, hasDeployment: hasDeployment(event.Deployment), } if event.ExpiresAt != nil && !event.ExpiresAt.IsZero() { content.expiresAt = event.ExpiresAt.Local().Format("2006-01-02 15:04:05") } if cfg.IncludeCertificateSerial { content.certificateSerial = strings.TrimSpace(event.CertificateSerial) } if cfg.IncludeManagementURL { content.managementURL = safeHTTPURL(event.ManagementURL) } if status != "成功" { content.problem = problemOverview(event) } return buildTextMail(content), buildHTMLMail(content) } func buildTextMail(content mailContent) string { lines := []string{ "Scert 证书管理通知", "", "状态:" + content.status, "摘要:" + content.statusSummary, "", "任务信息:", "- 任务类型:" + content.title, "- 域名:" + content.domain, } if len(content.domains) > 0 { lines = append(lines, "- 证书域名:"+strings.Join(content.domains, "、")) } if content.expiresAt != "" { lines = append(lines, "- 证书到期时间:"+content.expiresAt) } if content.certificateSerial != "" { lines = append(lines, "- 证书序列号:"+content.certificateSerial) } if content.hasDeployment { lines = append(lines, "", "部署概况:", "- "+deploymentSummary(content.deployment)) } if content.problem != "" { lines = append(lines, "", "问题概述:", content.problem) } if content.recoveryWarning != "" { lines = append(lines, "", content.recoveryWarning) } if content.includeDeployment && len(content.deployment.Results) > 0 { lines = append(lines, "", "处理结果:") for _, result := range content.deployment.Results { lines = append(lines, fmt.Sprintf("- %s:%s,%s", result.Server, resultStatus(result.Status), resultSummary(result.Message))) } } if content.managementURL != "" { lines = append(lines, "", "管理后台:"+content.managementURL) } if content.status != "成功" { lines = append(lines, "", "请登录管理后台查看完整执行记录,再决定是否重试。") } return strings.Join(lines, "\n") } func buildHTMLMail(content mailContent) string { var builder strings.Builder beginHTMLMail(&builder, "证书管理通知") builder.WriteString(`
` + escapeHTML(content.status) + `
`) builder.WriteString(`

` + escapeHTML(content.statusSummary) + `

`) builder.WriteString(`

` + escapeHTML(content.domain) + `

`) builder.WriteString(``) writeHTMLField(&builder, "任务类型", content.title) writeHTMLField(&builder, "域名", content.domain) if len(content.domains) > 0 { writeHTMLField(&builder, "证书域名", strings.Join(content.domains, "、")) } if content.expiresAt != "" { writeHTMLField(&builder, "证书到期时间", content.expiresAt) } if content.certificateSerial != "" { writeHTMLField(&builder, "证书序列号", content.certificateSerial) } builder.WriteString(`
`) if content.problem != "" { builder.WriteString(`
问题概述
` + escapeHTML(content.problem) + `
`) } if content.hasDeployment { builder.WriteString(`
部署概况
` + escapeHTML(deploymentSummary(content.deployment)) + `
`) } if content.recoveryWarning != "" { builder.WriteString(`
` + escapeHTML(content.recoveryWarning) + `
`) } if content.includeDeployment && len(content.deployment.Results) > 0 { builder.WriteString(`
处理结果
`) for _, result := range content.deployment.Results { color := resultColor(result.Status) builder.WriteString(`
` + escapeHTML(result.Server) + ` · ` + escapeHTML(resultStatus(result.Status)) + `
` + escapeHTML(resultSummary(result.Message)) + `
`) } builder.WriteString(`
`) } if content.managementURL != "" { builder.WriteString(`
打开管理后台
`) } endHTMLMail(&builder) return builder.String() } func beginHTMLMail(builder *strings.Builder, title string) { builder.WriteString(``) builder.WriteString(`
`) builder.WriteString(`

` + escapeHTML(title) + `

`) builder.WriteString(`
`) } func endHTMLMail(builder *strings.Builder) { builder.WriteString(`
此邮件由 Scert 自动发送,请勿直接回复。
`) } func writeHTMLField(builder *strings.Builder, label, value string) { builder.WriteString(`` + escapeHTML(label) + `` + escapeHTML(value) + ``) } func mailStatusColor(status string) string { switch status { case "成功": return "#16803c" case "失败", "错误": return "#c4322b" default: return "#667085" } } func mailStatusSummary(event Event, title, status string) string { if event.Deployment.Total > 0 && event.Deployment.Failed > 0 { return fmt.Sprintf("%s未完成,共 %d 台服务器失败", title, event.Deployment.Failed) } if status == eventStatus("success") && event.Deployment.Total > 0 && event.Deployment.Success == event.Deployment.Total { return fmt.Sprintf("%s已完成,共 %d 台服务器成功", title, event.Deployment.Success) } if status == "成功" { return title + "已完成" } return title + "未完成,请查看问题概述" } func hasDeployment(report deployment.Report) bool { return report.Total > 0 || len(report.Results) > 0 } func deploymentSummary(report deployment.Report) string { status := deploymentStatus(report.Status) return fmt.Sprintf("总计 %d 台,成功 %d 台,失败 %d 台,状态:%s", report.Total, report.Success, report.Failed, status) } func deploymentStatus(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "success": return "成功" case "partial": return "部分成功" case "failed", "failure": return "失败" default: return "未知" } } func resultStatus(value string) string { status := eventStatus(value) if status == "未知" && strings.EqualFold(strings.TrimSpace(value), "partial") { return "部分成功" } return status } func resultColor(value string) string { switch resultStatus(value) { case "成功": return "#16803c" case "失败", "错误": return "#c4322b" default: return "#667085" } } func problemOverview(event Event) string { items := make([]string, 0, 3) if detail := failureDetail(event.Error); detail != "" { if source := failureSource(event.Error); source != "" { detail = source + ":" + detail } items = appendUnique(items, detail) } for _, result := range event.Deployment.Results { if result.Status == "success" { continue } detail := failureDetail(result.Message) if detail == "" { continue } if result.Server != "" { detail = result.Server + ":" + detail } items = appendUnique(items, detail) } if len(items) == 0 { return "任务未完成,请登录管理后台查看详细执行记录。" } if len(items) > 2 { items = items[:2] } return strings.Join(items, ";") } func failureSource(value string) string { const prefix = "服务器 " start := strings.Index(value, prefix) if start < 0 { return "" } value = value[start+len(prefix):] end := strings.Index(value, " 的") if end < 0 { return "" } return strings.TrimSpace(value[:end]) } func failureDetail(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } lower := strings.ToLower(value) switch { case strings.Contains(lower, "x509:") && strings.Contains(lower, "certificate is valid for"): return "HTTPS 证书域名不匹配,服务器返回的证书不包含目标域名" case strings.Contains(value, "HTTPS 校验失败") || strings.Contains(lower, "https verification"): return "HTTPS 校验失败,请检查服务器证书、域名和 SNI 配置" case strings.Contains(value, "恢复旧文件") && (strings.Contains(value, "未确认") || strings.Contains(value, "待核查")): return "旧文件已恢复,但服务或站点状态尚未确认" case strings.Contains(value, "部署锁"): return "部署锁仍被保留,请先核查服务器和站点状态,再决定是否重试" case strings.Contains(lower, "connection failed") || strings.Contains(value, "连接失败"): return "无法连接服务器,请检查网络、端口和服务状态" default: return compactText(value, 160) } } func resultSummary(value string) string { if detail := failureDetail(value); detail != "" { return detail } return "处理完成" } func compactText(value string, limit int) string { value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ") if len([]rune(value)) <= limit { return value } return string([]rune(value)[:limit]) + "…" } func appendUnique(items []string, value string) []string { for _, item := range items { if item == value { return items } } return append(items, value) } func safeHTTPURL(value string) string { parsed, err := url.Parse(strings.TrimSpace(value)) if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { return "" } return parsed.String() } func escapeHTML(value string) string { return html.EscapeString(value) }