package service import ( "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "html" "log/slog" "net/url" "regexp" "strconv" "strings" "time" ) const ( NotificationEmailEventAuthVerifyCode = "auth.verify_code" NotificationEmailEventAuthPasswordReset = "auth.password_reset" NotificationEmailEventNotificationEmailVerifyCode = "notification_email.verify_code" NotificationEmailEventSubscriptionPurchaseSuccess = "subscription.purchase_success" NotificationEmailEventSubscriptionExpiryReminder = "subscription.expiry_reminder" NotificationEmailEventBalanceLow = "balance.low" NotificationEmailEventBalanceRechargeSuccess = "balance.recharge_success" NotificationEmailEventAccountQuotaAlert = "account.quota_alert" NotificationEmailEventContentModerationViolation = "content_moderation.violation_notice" NotificationEmailEventContentModerationDisabled = "content_moderation.account_disabled" NotificationEmailEventOpsAlert = "ops.alert" NotificationEmailEventOpsScheduledReport = "ops.scheduled_report" notificationEmailTemplateKeyPrefix = "notification_email_template:" notificationEmailPreferenceKeyPrefix = "notification_email_preference:" notificationEmailDeliveryKeyPrefix = "notification_email_delivery:" notificationEmailLocaleUserKeyPrefix = "notification_email_locale:user:" notificationEmailLocaleEmailKeyPrefix = "notification_email_locale:email:" notificationEmailUnsubscribeSecretKey = "notification_email_unsubscribe_secret" notificationEmailDefaultLocale = "en" notificationEmailLocaleChinese = "zh" notificationEmailMaxSubjectLength = 200 notificationEmailMaxHTMLLength = 30000 notificationEmailUnsubscribeTTL = 365 * 24 * time.Hour ) var ( notificationEmailPlaceholderPattern = regexp.MustCompile(`{{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*}}`) notificationEmailLocales = []string{notificationEmailDefaultLocale, notificationEmailLocaleChinese} notificationEmailCommonPlaceholders = []string{"site_name", "recipient_name", "recipient_email"} ) type NotificationEmailService struct { settingRepo SettingRepository emailService *EmailService } type NotificationEmailEventInfo struct { Event string `json:"event"` Label string `json:"label"` Description string `json:"description"` Category string `json:"category"` Optional bool `json:"optional"` Placeholders []string `json:"placeholders"` } type NotificationEmailTemplate struct { Event string `json:"event"` Locale string `json:"locale"` Subject string `json:"subject"` HTML string `json:"html"` IsCustom bool `json:"is_custom"` UpdatedAt *time.Time `json:"updated_at,omitempty"` Placeholders []string `json:"placeholders"` } type NotificationEmailPreview struct { Subject string `json:"subject"` HTML string `json:"html"` } type NotificationEmailPreviewInput struct { Event string `json:"event"` Locale string `json:"locale"` Subject string `json:"subject"` HTML string `json:"html"` Variables map[string]string `json:"variables,omitempty"` } type NotificationEmailSendInput struct { Event string Locale string RecipientEmail string RecipientName string UserID int64 SourceType string SourceID string ReminderKey string Variables map[string]string RawHTMLVariables map[string]string } type NotificationEmailUnsubscribeResult struct { Event string `json:"event"` Email string `json:"email"` Done bool `json:"done"` } type notificationEmailStoredTemplate struct { Subject string `json:"subject"` HTML string `json:"html"` UpdatedAt time.Time `json:"updated_at"` } type notificationEmailOfficialTemplate struct { Subject string HTML string } type notificationEmailTemplateError struct { Err error } func (e notificationEmailTemplateError) Error() string { return e.Err.Error() } func (e notificationEmailTemplateError) Unwrap() error { return e.Err } type notificationEmailConfigError struct { Err error } func (e notificationEmailConfigError) Error() string { return e.Err.Error() } func (e notificationEmailConfigError) Unwrap() error { return e.Err } type notificationEmailDeliveryError struct { Err error } func (e notificationEmailDeliveryError) Error() string { return e.Err.Error() } func (e notificationEmailDeliveryError) Unwrap() error { return e.Err } type notificationEmailUnsubscribeClaims struct { Email string `json:"email"` Event string `json:"event"` Exp int64 `json:"exp"` } func NewNotificationEmailService(settingRepo SettingRepository, emailService *EmailService) *NotificationEmailService { svc := &NotificationEmailService{settingRepo: settingRepo, emailService: emailService} if emailService != nil { emailService.SetNotificationEmailService(svc) } return svc } func notificationEmailTemplateErr(err error) error { if err == nil { return nil } return notificationEmailTemplateError{Err: err} } func notificationEmailConfigErr(err error) error { if err == nil { return nil } return notificationEmailConfigError{Err: err} } func notificationEmailDeliveryErr(err error) error { if err == nil { return nil } return notificationEmailDeliveryError{Err: err} } func shouldFallbackNotificationEmail(err error) bool { if err == nil { return false } var templateErr notificationEmailTemplateError if errors.As(err, &templateErr) { return true } var configErr notificationEmailConfigError return errors.As(err, &configErr) } func isNotificationEmailDeliveryError(err error) bool { var deliveryErr notificationEmailDeliveryError return errors.As(err, &deliveryErr) } func (s *NotificationEmailService) ListEventInfos() []NotificationEmailEventInfo { infos := make([]NotificationEmailEventInfo, 0, len(notificationEmailEventDefinitions)) for _, event := range notificationEmailEventOrder { info := notificationEmailEventDefinitions[event] info.Placeholders = append([]string(nil), info.Placeholders...) infos = append(infos, info) } return infos } func (s *NotificationEmailService) SupportedLocales() []string { return append([]string(nil), notificationEmailLocales...) } func (s *NotificationEmailService) ListTemplates(ctx context.Context) ([]NotificationEmailTemplate, error) { items := make([]NotificationEmailTemplate, 0, len(notificationEmailEventOrder)*len(notificationEmailLocales)) for _, event := range notificationEmailEventOrder { for _, locale := range notificationEmailLocales { tmpl, err := s.GetTemplate(ctx, event, locale) if err != nil { return nil, err } items = append(items, tmpl) } } return items, nil } func (s *NotificationEmailService) GetTemplate(ctx context.Context, event, locale string) (NotificationEmailTemplate, error) { info, normalizedEvent, err := s.eventInfo(event) if err != nil { return NotificationEmailTemplate{}, err } normalizedLocale := normalizeNotificationLocale(locale) official, ok := notificationEmailOfficialTemplates[normalizedEvent][normalizedLocale] if !ok { return NotificationEmailTemplate{}, fmt.Errorf("official template not found for %s/%s", normalizedEvent, normalizedLocale) } tmpl := NotificationEmailTemplate{ Event: normalizedEvent, Locale: normalizedLocale, Subject: official.Subject, HTML: official.HTML, Placeholders: append([]string(nil), info.Placeholders...), } raw, err := s.settingRepo.GetValue(ctx, notificationEmailTemplateKey(normalizedEvent, normalizedLocale)) if err != nil { if errors.Is(err, ErrSettingNotFound) { return tmpl, nil } return NotificationEmailTemplate{}, err } if strings.TrimSpace(raw) == "" { return tmpl, nil } var stored notificationEmailStoredTemplate if err := json.Unmarshal([]byte(raw), &stored); err != nil { return NotificationEmailTemplate{}, fmt.Errorf("decode email template override: %w", err) } if err := validateNotificationEmailTemplate(normalizedEvent, stored.Subject, stored.HTML); err != nil { return NotificationEmailTemplate{}, err } tmpl.Subject = stored.Subject tmpl.HTML = stored.HTML tmpl.IsCustom = true updatedAt := stored.UpdatedAt tmpl.UpdatedAt = &updatedAt return tmpl, nil } func (s *NotificationEmailService) UpdateTemplate(ctx context.Context, event, locale, subject, htmlBody string) (NotificationEmailTemplate, error) { _, normalizedEvent, err := s.eventInfo(event) if err != nil { return NotificationEmailTemplate{}, err } normalizedLocale := normalizeNotificationLocale(locale) if err := validateNotificationEmailTemplate(normalizedEvent, subject, htmlBody); err != nil { return NotificationEmailTemplate{}, err } stored := notificationEmailStoredTemplate{ Subject: strings.TrimSpace(subject), HTML: htmlBody, UpdatedAt: time.Now().UTC(), } payload, err := json.Marshal(stored) if err != nil { return NotificationEmailTemplate{}, err } if err := s.settingRepo.Set(ctx, notificationEmailTemplateKey(normalizedEvent, normalizedLocale), string(payload)); err != nil { return NotificationEmailTemplate{}, err } return s.GetTemplate(ctx, normalizedEvent, normalizedLocale) } func (s *NotificationEmailService) RestoreOfficialTemplate(ctx context.Context, event, locale string) (NotificationEmailTemplate, error) { _, normalizedEvent, err := s.eventInfo(event) if err != nil { return NotificationEmailTemplate{}, err } normalizedLocale := normalizeNotificationLocale(locale) if err := s.settingRepo.Delete(ctx, notificationEmailTemplateKey(normalizedEvent, normalizedLocale)); err != nil && !errors.Is(err, ErrSettingNotFound) { return NotificationEmailTemplate{}, err } return s.GetTemplate(ctx, normalizedEvent, normalizedLocale) } func (s *NotificationEmailService) PreviewTemplate(ctx context.Context, input NotificationEmailPreviewInput) (NotificationEmailPreview, error) { _, normalizedEvent, err := s.eventInfo(input.Event) if err != nil { return NotificationEmailPreview{}, err } normalizedLocale := normalizeNotificationLocale(input.Locale) subject := input.Subject htmlBody := input.HTML if strings.TrimSpace(subject) == "" || strings.TrimSpace(htmlBody) == "" { tmpl, err := s.GetTemplate(ctx, normalizedEvent, normalizedLocale) if err != nil { return NotificationEmailPreview{}, err } if strings.TrimSpace(subject) == "" { subject = tmpl.Subject } if strings.TrimSpace(htmlBody) == "" { htmlBody = tmpl.HTML } } if err := validateNotificationEmailTemplate(normalizedEvent, subject, htmlBody); err != nil { return NotificationEmailPreview{}, err } variables := s.sampleVariables(ctx, normalizedEvent, normalizedLocale) for key, value := range input.Variables { variables[key] = value } return renderNotificationEmail(normalizedEvent, subject, htmlBody, variables, nil) } func (s *NotificationEmailService) Send(ctx context.Context, input NotificationEmailSendInput) error { info, normalizedEvent, err := s.eventInfo(input.Event) if err != nil { return notificationEmailTemplateErr(err) } recipient := strings.TrimSpace(input.RecipientEmail) if recipient == "" { return nil } if info.Optional { unsubscribed, err := s.IsUnsubscribed(ctx, recipient, normalizedEvent) if err != nil { return err } if unsubscribed { slog.Info("notification email suppressed by unsubscribe preference", "event", normalizedEvent, "recipient_hash", notificationEmailHash(recipient)) return nil } } locale := normalizeNotificationLocale(input.Locale) if strings.TrimSpace(input.Locale) == "" { locale = s.ResolveRecipientLocale(ctx, input.UserID, recipient) } tmpl, err := s.GetTemplate(ctx, normalizedEvent, locale) if err != nil { return notificationEmailTemplateErr(err) } variables := s.runtimeVariables(ctx, normalizedEvent, locale, input) rendered, err := renderNotificationEmail(normalizedEvent, tmpl.Subject, tmpl.HTML, variables, input.RawHTMLVariables) if err != nil { return notificationEmailTemplateErr(err) } deliveryKey := notificationEmailDeliveryKey(normalizedEvent, input.SourceType, input.SourceID, recipient, input.ReminderKey) if deliveryKey != "" { sent, err := s.deliveryExists(ctx, deliveryKey, legacyNotificationEmailDeliveryKey(normalizedEvent, input.SourceType, input.SourceID, recipient, input.ReminderKey)) if err != nil { return err } if sent { return nil } } if s.emailService == nil { return notificationEmailConfigErr(errors.New("email service is not configured")) } if err := s.emailService.SendEmail(ctx, recipient, rendered.Subject, rendered.HTML); err != nil { return notificationEmailDeliveryErr(err) } if deliveryKey != "" { if err := s.settingRepo.Set(ctx, deliveryKey, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { return err } } return nil } func (s *NotificationEmailService) RememberRecipientLocale(ctx context.Context, userID int64, email, acceptLanguage string) { locale := normalizeNotificationLocale(acceptLanguage) if strings.TrimSpace(acceptLanguage) == "" || s == nil || s.settingRepo == nil { return } if userID > 0 { _ = s.settingRepo.Set(ctx, notificationEmailLocaleUserKeyPrefix+strconv.FormatInt(userID, 10), locale) } if emailHash := notificationEmailHash(email); emailHash != "" { _ = s.settingRepo.Set(ctx, notificationEmailLocaleEmailKeyPrefix+emailHash, locale) } } func (s *NotificationEmailService) ResolveRecipientLocale(ctx context.Context, userID int64, email string) string { if s == nil || s.settingRepo == nil { return notificationEmailDefaultLocale } if userID > 0 { if locale, err := s.settingRepo.GetValue(ctx, notificationEmailLocaleUserKeyPrefix+strconv.FormatInt(userID, 10)); err == nil && strings.TrimSpace(locale) != "" { return normalizeNotificationLocale(locale) } } if emailHash := notificationEmailHash(email); emailHash != "" { if locale, err := s.settingRepo.GetValue(ctx, notificationEmailLocaleEmailKeyPrefix+emailHash); err == nil && strings.TrimSpace(locale) != "" { return normalizeNotificationLocale(locale) } } return notificationEmailDefaultLocale } func (s *NotificationEmailService) IsUnsubscribed(ctx context.Context, email, event string) (bool, error) { info, normalizedEvent, err := s.eventInfo(event) if err != nil { return false, err } if !info.Optional { return false, nil } for _, key := range []string{notificationEmailPreferenceKey(normalizedEvent, email), legacyNotificationEmailPreferenceKey(normalizedEvent, email)} { if strings.TrimSpace(key) == "" { continue } value, err := s.settingRepo.GetValue(ctx, key) if err == nil { return strings.EqualFold(strings.TrimSpace(value), "unsubscribed"), nil } if !errors.Is(err, ErrSettingNotFound) { return false, err } } return false, nil } func (s *NotificationEmailService) Unsubscribe(ctx context.Context, token string) (NotificationEmailUnsubscribeResult, error) { claims, err := s.parseUnsubscribeToken(ctx, token) if err != nil { return NotificationEmailUnsubscribeResult{}, err } info, normalizedEvent, err := s.eventInfo(claims.Event) if err != nil { return NotificationEmailUnsubscribeResult{}, err } if !info.Optional { return NotificationEmailUnsubscribeResult{}, fmt.Errorf("%s is transactional and cannot be unsubscribed", normalizedEvent) } if err := s.settingRepo.Set(ctx, notificationEmailPreferenceKey(normalizedEvent, claims.Email), "unsubscribed"); err != nil { return NotificationEmailUnsubscribeResult{}, err } return NotificationEmailUnsubscribeResult{Event: normalizedEvent, Email: claims.Email, Done: true}, nil } func (s *NotificationEmailService) eventInfo(event string) (NotificationEmailEventInfo, string, error) { normalized := strings.ToLower(strings.TrimSpace(event)) info, ok := notificationEmailEventDefinitions[normalized] if !ok { return NotificationEmailEventInfo{}, "", fmt.Errorf("unsupported email template event: %s", event) } return info, normalized, nil } func (s *NotificationEmailService) sampleVariables(ctx context.Context, event, locale string) map[string]string { info := notificationEmailEventDefinitions[event] variables := make(map[string]string, len(info.Placeholders)) for key, value := range notificationEmailSampleVariables(locale) { variables[key] = value } variables["site_name"] = s.siteName(ctx) if variables["unsubscribe_url"] == "" && info.Optional { variables["unsubscribe_url"] = "https://example.com/unsubscribe" } return variables } func (s *NotificationEmailService) runtimeVariables(ctx context.Context, event, locale string, input NotificationEmailSendInput) map[string]string { variables := s.sampleVariables(ctx, event, locale) for key, value := range input.Variables { variables[key] = value } variables["site_name"] = s.siteName(ctx) variables["recipient_email"] = input.RecipientEmail if strings.TrimSpace(input.RecipientName) != "" { variables["recipient_name"] = input.RecipientName } if notificationEmailEventDefinitions[event].Optional { if unsubscribeURL, err := s.buildUnsubscribeURL(ctx, input.RecipientEmail, event); err == nil { variables["unsubscribe_url"] = unsubscribeURL } } return variables } func (s *NotificationEmailService) siteName(ctx context.Context) string { if s == nil || s.settingRepo == nil { return defaultSiteName } name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName) if err != nil || strings.TrimSpace(name) == "" { return defaultSiteName } return strings.TrimSpace(name) } func (s *NotificationEmailService) baseURL(ctx context.Context) string { if s == nil || s.settingRepo == nil { return "" } for _, key := range []string{SettingKeyAPIBaseURL, SettingKeyFrontendURL} { value, err := s.settingRepo.GetValue(ctx, key) if err == nil && strings.TrimSpace(value) != "" { return strings.TrimRight(strings.TrimSpace(value), "/") } } return "" } func (s *NotificationEmailService) buildUnsubscribeURL(ctx context.Context, email, event string) (string, error) { token, err := s.createUnsubscribeToken(ctx, email, event) if err != nil { return "", err } path := "/api/v1/settings/email-unsubscribe?token=" + url.QueryEscape(token) baseURL := s.baseURL(ctx) if baseURL == "" { return path, nil } return baseURL + path, nil } func (s *NotificationEmailService) createUnsubscribeToken(ctx context.Context, email, event string) (string, error) { secret, err := s.unsubscribeSecret(ctx) if err != nil { return "", err } claims := notificationEmailUnsubscribeClaims{Email: strings.TrimSpace(email), Event: event, Exp: time.Now().Add(notificationEmailUnsubscribeTTL).Unix()} payload, err := json.Marshal(claims) if err != nil { return "", err } encodedPayload := base64.RawURLEncoding.EncodeToString(payload) signature := signNotificationEmailToken(secret, encodedPayload) return encodedPayload + "." + signature, nil } func (s *NotificationEmailService) parseUnsubscribeToken(ctx context.Context, token string) (notificationEmailUnsubscribeClaims, error) { parts := strings.Split(strings.TrimSpace(token), ".") if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token") } secret, err := s.unsubscribeSecret(ctx) if err != nil { return notificationEmailUnsubscribeClaims{}, err } expected := signNotificationEmailToken(secret, parts[0]) if !hmac.Equal([]byte(expected), []byte(parts[1])) { return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token signature") } payload, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token payload") } var claims notificationEmailUnsubscribeClaims if err := json.Unmarshal(payload, &claims); err != nil { return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token payload") } if strings.TrimSpace(claims.Email) == "" || strings.TrimSpace(claims.Event) == "" { return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token claims") } if claims.Exp <= time.Now().Unix() { return notificationEmailUnsubscribeClaims{}, errors.New("unsubscribe token expired") } return claims, nil } func (s *NotificationEmailService) unsubscribeSecret(ctx context.Context) (string, error) { secret, err := s.settingRepo.GetValue(ctx, notificationEmailUnsubscribeSecretKey) if err == nil && strings.TrimSpace(secret) != "" { return strings.TrimSpace(secret), nil } if err != nil && !errors.Is(err, ErrSettingNotFound) { return "", err } buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return "", err } secret = base64.RawURLEncoding.EncodeToString(buf) if err := s.settingRepo.Set(ctx, notificationEmailUnsubscribeSecretKey, secret); err != nil { return "", err } return secret, nil } func (s *NotificationEmailService) deliveryExists(ctx context.Context, keys ...string) (bool, error) { for _, key := range keys { if strings.TrimSpace(key) == "" { continue } _, err := s.settingRepo.GetValue(ctx, key) if err == nil { return true, nil } if !errors.Is(err, ErrSettingNotFound) { return false, err } } return false, nil } func validateNotificationEmailTemplate(event, subject, htmlBody string) error { subject = strings.TrimSpace(subject) if subject == "" { return errors.New("email subject cannot be empty") } if len([]rune(subject)) > notificationEmailMaxSubjectLength { return fmt.Errorf("email subject cannot exceed %d characters", notificationEmailMaxSubjectLength) } if strings.TrimSpace(htmlBody) == "" { return errors.New("email html cannot be empty") } if len([]byte(htmlBody)) > notificationEmailMaxHTMLLength { return fmt.Errorf("email html cannot exceed %d bytes", notificationEmailMaxHTMLLength) } allowed := notificationEmailAllowedPlaceholderSet(event) for _, placeholder := range notificationEmailPlaceholdersIn(subject + "\n" + htmlBody) { if _, ok := allowed[placeholder]; !ok { return fmt.Errorf("unsupported placeholder {{%s}} for event %s", placeholder, event) } } return nil } func renderNotificationEmail(event, subject, htmlBody string, variables map[string]string, rawHTMLVariables map[string]string) (NotificationEmailPreview, error) { if err := validateNotificationEmailTemplate(event, subject, htmlBody); err != nil { return NotificationEmailPreview{}, err } renderedSubject, err := renderNotificationEmailString(event, subject, variables, nil, false) if err != nil { return NotificationEmailPreview{}, err } renderedHTML, err := renderNotificationEmailString(event, htmlBody, variables, rawHTMLVariables, true) if err != nil { return NotificationEmailPreview{}, err } return NotificationEmailPreview{Subject: sanitizeEmailHeader(renderedSubject), HTML: renderedHTML}, nil } func renderNotificationEmailString(event, raw string, variables map[string]string, rawHTMLVariables map[string]string, escapeHTML bool) (string, error) { allowed := notificationEmailAllowedPlaceholderSet(event) var renderErr error rendered := notificationEmailPlaceholderPattern.ReplaceAllStringFunc(raw, func(match string) string { if renderErr != nil { return "" } parts := notificationEmailPlaceholderPattern.FindStringSubmatch(match) if len(parts) != 2 { return "" } name := parts[1] if _, ok := allowed[name]; !ok { renderErr = fmt.Errorf("unsupported placeholder {{%s}} for event %s", name, event) return "" } value := variables[name] if escapeHTML && notificationEmailRawHTMLAllowed(event, name) { if rawHTMLVariables != nil { if rawValue, ok := rawHTMLVariables[name]; ok { return rawValue } } } if strings.HasSuffix(name, "_url") && !isSafeNotificationEmailURL(value) { value = "" } if escapeHTML { return html.EscapeString(value) } return sanitizeEmailHeader(value) }) if renderErr != nil { return "", renderErr } return rendered, nil } func notificationEmailRawHTMLAllowed(event, placeholder string) bool { return event == NotificationEmailEventOpsScheduledReport && placeholder == "report_html" } func notificationEmailAllowedPlaceholderSet(event string) map[string]struct{} { info := notificationEmailEventDefinitions[event] allowed := make(map[string]struct{}, len(info.Placeholders)) for _, placeholder := range info.Placeholders { allowed[placeholder] = struct{}{} } return allowed } func notificationEmailPlaceholdersIn(raw string) []string { matches := notificationEmailPlaceholderPattern.FindAllStringSubmatch(raw, -1) seen := make(map[string]struct{}, len(matches)) out := make([]string, 0, len(matches)) for _, match := range matches { if len(match) != 2 { continue } if _, exists := seen[match[1]]; exists { continue } seen[match[1]] = struct{}{} out = append(out, match[1]) } return out } func normalizeNotificationLocale(raw string) string { trimmed := strings.ToLower(strings.TrimSpace(raw)) if trimmed == "" { return notificationEmailDefaultLocale } for _, part := range strings.Split(trimmed, ",") { tag := strings.TrimSpace(strings.Split(part, ";")[0]) if strings.HasPrefix(tag, "zh") || tag == "cn" { return notificationEmailLocaleChinese } if strings.HasPrefix(tag, "en") { return notificationEmailDefaultLocale } } return notificationEmailDefaultLocale } func notificationEmailTemplateKey(event, locale string) string { return notificationEmailTemplateKeyPrefix + event + ":" + locale } func notificationEmailPreferenceKey(event, email string) string { if strings.TrimSpace(event) == "" || strings.TrimSpace(email) == "" { return "" } identity := strings.TrimSpace(event) + "\x00" + strings.ToLower(strings.TrimSpace(email)) return notificationEmailPreferenceKeyPrefix + "v2:" + notificationEmailHash(identity) } func legacyNotificationEmailPreferenceKey(event, email string) string { return notificationEmailPreferenceKeyPrefix + event + ":" + notificationEmailHash(email) } func notificationEmailDeliveryKey(event, sourceType, sourceID, recipient, reminderKey string) string { if strings.TrimSpace(sourceType) == "" || strings.TrimSpace(sourceID) == "" || strings.TrimSpace(recipient) == "" { return "" } identity := strings.Join([]string{ strings.ToLower(strings.TrimSpace(event)), safeNotificationEmailKeyPart(sourceType), safeNotificationEmailKeyPart(sourceID), strings.ToLower(strings.TrimSpace(recipient)), safeNotificationEmailKeyPart(reminderKey), }, "\x00") return notificationEmailDeliveryKeyPrefix + "v2:" + notificationEmailHash(identity) } func legacyNotificationEmailDeliveryKey(event, sourceType, sourceID, recipient, reminderKey string) string { if strings.TrimSpace(sourceType) == "" || strings.TrimSpace(sourceID) == "" || strings.TrimSpace(recipient) == "" { return "" } parts := []string{notificationEmailDeliveryKeyPrefix, event, ":", safeNotificationEmailKeyPart(sourceType), ":", safeNotificationEmailKeyPart(sourceID), ":", notificationEmailHash(recipient)} if strings.TrimSpace(reminderKey) != "" { parts = append(parts, ":", safeNotificationEmailKeyPart(reminderKey)) } return strings.Join(parts, "") } func notificationEmailHash(value string) string { trimmed := strings.ToLower(strings.TrimSpace(value)) if trimmed == "" { return "" } sum := sha256.Sum256([]byte(trimmed)) return hex.EncodeToString(sum[:]) } func safeNotificationEmailKeyPart(value string) string { value = strings.ToLower(strings.TrimSpace(value)) var builder strings.Builder for _, r := range value { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.' { _, _ = builder.WriteRune(r) } else { _, _ = builder.WriteRune('_') } } return builder.String() } func signNotificationEmailToken(secret, payload string) string { mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(payload)) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } func isSafeNotificationEmailURL(raw string) bool { trimmed := strings.TrimSpace(raw) if trimmed == "" { return true } parsed, err := url.Parse(trimmed) if err != nil { return false } if parsed.IsAbs() { scheme := strings.ToLower(parsed.Scheme) return scheme == "http" || scheme == "https" || scheme == "mailto" } return strings.HasPrefix(trimmed, "/") } func notificationEmailSampleVariables(locale string) map[string]string { if normalizeNotificationLocale(locale) == notificationEmailLocaleChinese { return map[string]string{ "site_name": defaultSiteName, "recipient_name": "张三", "recipient_email": "user@example.com", "verification_code": "123456", "expires_in_minutes": "15", "reset_url": "https://example.com/reset-password?token=preview", "subscription_group": "Claude Pro", "subscription_days": "30", "expiry_time": "2026-06-18 12:00", "days_remaining": "3", "current_balance": "12.34", "threshold": "20.00", "recharge_url": "https://example.com/recharge", "recharge_amount": "50.00", "order_id": "1024", "unsubscribe_url": "https://example.com/unsubscribe", "account_id": "1001", "account_name": "openai-main", "platform": "openai", "quota_dimension": "每日额度", "quota_used": "80.00", "quota_limit": "100.00", "quota_remaining": "20.00", "quota_threshold": "20%", "triggered_at": "2026-05-20 12:00:00", "group_name": "默认分组", "moderation_category": "violence", "moderation_score": "0.982", "violation_count": "2", "ban_threshold": "3", "rule_name": "错误率过高", "severity": "critical", "alert_status": "firing", "metric_type": "error_rate", "operator": ">=", "metric_value": "12.50", "threshold_value": "10.00", "alert_description": "最近 10 分钟错误率超过阈值", "report_name": "日报", "report_type": "daily_summary", "report_start_time": "2026-05-19 12:00", "report_end_time": "2026-05-20 12:00", "report_html": "

日报

请求量:1024

", } } return map[string]string{ "site_name": defaultSiteName, "recipient_name": "Alex", "recipient_email": "user@example.com", "verification_code": "123456", "expires_in_minutes": "15", "reset_url": "https://example.com/reset-password?token=preview", "subscription_group": "Claude Pro", "subscription_days": "30", "expiry_time": "2026-06-18 12:00", "days_remaining": "3", "current_balance": "12.34", "threshold": "20.00", "recharge_url": "https://example.com/recharge", "recharge_amount": "50.00", "order_id": "1024", "unsubscribe_url": "https://example.com/unsubscribe", "account_id": "1001", "account_name": "openai-main", "platform": "openai", "quota_dimension": "Daily quota", "quota_used": "80.00", "quota_limit": "100.00", "quota_remaining": "20.00", "quota_threshold": "20%", "triggered_at": "2026-05-20 12:00:00", "group_name": "Default group", "moderation_category": "violence", "moderation_score": "0.982", "violation_count": "2", "ban_threshold": "3", "rule_name": "High error rate", "severity": "critical", "alert_status": "firing", "metric_type": "error_rate", "operator": ">=", "metric_value": "12.50", "threshold_value": "10.00", "alert_description": "Error rate exceeded threshold in the last 10 minutes.", "report_name": "Daily summary", "report_type": "daily_summary", "report_start_time": "2026-05-19 12:00", "report_end_time": "2026-05-20 12:00", "report_html": "

Daily summary

Requests: 1024

", } } var notificationEmailEventOrder = []string{ NotificationEmailEventAuthVerifyCode, NotificationEmailEventAuthPasswordReset, NotificationEmailEventNotificationEmailVerifyCode, NotificationEmailEventSubscriptionPurchaseSuccess, NotificationEmailEventSubscriptionExpiryReminder, NotificationEmailEventBalanceLow, NotificationEmailEventBalanceRechargeSuccess, NotificationEmailEventAccountQuotaAlert, NotificationEmailEventContentModerationViolation, NotificationEmailEventContentModerationDisabled, NotificationEmailEventOpsAlert, NotificationEmailEventOpsScheduledReport, } var notificationEmailEventDefinitions = map[string]NotificationEmailEventInfo{ NotificationEmailEventAuthVerifyCode: { Event: NotificationEmailEventAuthVerifyCode, Label: "Email verification code", Description: "Sent for registration, email binding, OAuth pending email, and TOTP verification flows.", Category: "auth", Optional: false, Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "verification_code", "expires_in_minutes"), }, NotificationEmailEventAuthPasswordReset: { Event: NotificationEmailEventAuthPasswordReset, Label: "Password reset", Description: "Sent when a user requests a password reset link.", Category: "auth", Optional: false, Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "reset_url", "expires_in_minutes"), }, NotificationEmailEventNotificationEmailVerifyCode: { Event: NotificationEmailEventNotificationEmailVerifyCode, Label: "Notification email verification code", Description: "Sent when a user verifies an extra notification email address.", Category: "auth", Optional: false, Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "verification_code", "expires_in_minutes"), }, NotificationEmailEventSubscriptionPurchaseSuccess: { Event: NotificationEmailEventSubscriptionPurchaseSuccess, Label: "Subscription purchase success", Description: "Sent after a subscription purchase is fulfilled.", Category: "subscription", Optional: false, Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "subscription_group", "subscription_days", "expiry_time", "order_id"), }, NotificationEmailEventSubscriptionExpiryReminder: { Event: NotificationEmailEventSubscriptionExpiryReminder, Label: "Subscription expiry reminder", Description: "Optional reminder sent before an active subscription expires.", Category: "subscription", Optional: true, Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "subscription_group", "expiry_time", "days_remaining", "unsubscribe_url"), }, NotificationEmailEventBalanceLow: { Event: NotificationEmailEventBalanceLow, Label: "Low balance alert", Description: "Optional alert sent when balance crosses the configured low-balance threshold.", Category: "billing", Optional: true, Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "current_balance", "threshold", "recharge_url", "unsubscribe_url"), }, NotificationEmailEventBalanceRechargeSuccess: { Event: NotificationEmailEventBalanceRechargeSuccess, Label: "Balance recharge success", Description: "Sent after a balance recharge order is fulfilled.", Category: "billing", Optional: false, Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "recharge_amount", "current_balance", "order_id"), }, NotificationEmailEventAccountQuotaAlert: { Event: NotificationEmailEventAccountQuotaAlert, Label: "Account quota alert", Description: "Sent to configured admin notification emails when an upstream account quota threshold is crossed.", Category: "admin", Optional: false, Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "account_id", "account_name", "platform", "quota_dimension", "quota_used", "quota_limit", "quota_remaining", "quota_threshold"), }, NotificationEmailEventContentModerationViolation: { Event: NotificationEmailEventContentModerationViolation, Label: "Risk control violation notice", Description: "Sent to users when a request triggers content moderation/risk control rules.", Category: "risk_control", Optional: false, Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "triggered_at", "group_name", "moderation_category", "moderation_score", "violation_count", "ban_threshold"), }, NotificationEmailEventContentModerationDisabled: { Event: NotificationEmailEventContentModerationDisabled, Label: "Risk control account disabled", Description: "Sent to users when content moderation automatically disables their account.", Category: "risk_control", Optional: false, Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "triggered_at", "group_name", "moderation_category", "moderation_score", "violation_count", "ban_threshold"), }, NotificationEmailEventOpsAlert: { Event: NotificationEmailEventOpsAlert, Label: "Ops alert", Description: "Sent to configured operations recipients when an ops alert rule fires.", Category: "ops", Optional: false, Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "rule_name", "severity", "alert_status", "metric_type", "operator", "metric_value", "threshold_value", "triggered_at", "alert_description"), }, NotificationEmailEventOpsScheduledReport: { Event: NotificationEmailEventOpsScheduledReport, Label: "Ops scheduled report", Description: "Sent to configured operations recipients for scheduled daily/weekly/error/account-health reports.", Category: "ops", Optional: false, Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "report_name", "report_type", "report_start_time", "report_end_time", "report_html"), }, } var notificationEmailOfficialTemplates = map[string]map[string]notificationEmailOfficialTemplate{ NotificationEmailEventAuthVerifyCode: { notificationEmailDefaultLocale: { Subject: "[{{site_name}}] Email verification code", HTML: notificationEmailCard("#4f46e5", "Email verification code", `

Hello {{recipient_name}},

Your verification code is:

{{verification_code}}

This code expires in {{expires_in_minutes}} minutes.

If you did not request this code, please ignore this email.

`), }, notificationEmailLocaleChinese: { Subject: "[{{site_name}}] 邮箱验证码", HTML: notificationEmailCard("#4f46e5", "邮箱验证码", `

{{recipient_name}},您好:

您的验证码是:

{{verification_code}}

验证码将在 {{expires_in_minutes}} 分钟后失效。

如果不是您本人操作,请忽略此邮件。

`), }, }, NotificationEmailEventAuthPasswordReset: { notificationEmailDefaultLocale: { Subject: "[{{site_name}}] Password reset request", HTML: notificationEmailCard("#7c3aed", "Password reset", `

Hello {{recipient_name}},

We received a request to reset your password. Click the button below to set a new password.

Reset password

This link expires in {{expires_in_minutes}} minutes.

If the button does not work, copy this link into your browser:
{{reset_url}}

If you did not request this, you can safely ignore this email.

`), }, notificationEmailLocaleChinese: { Subject: "[{{site_name}}] 密码重置请求", HTML: notificationEmailCard("#7c3aed", "密码重置", `

{{recipient_name}},您好:

我们收到了您的密码重置请求,请点击下方按钮设置新密码。

重置密码

此链接将在 {{expires_in_minutes}} 分钟后失效。

如果按钮无法点击,请复制以下链接到浏览器中打开:
{{reset_url}}

如果不是您本人操作,请忽略此邮件。

`), }, }, NotificationEmailEventNotificationEmailVerifyCode: { notificationEmailDefaultLocale: { Subject: "[{{site_name}}] Notification email verification code", HTML: notificationEmailCard("#0ea5e9", "Notification email verification", `

Hello {{recipient_name}},

You are adding this address as an extra notification email.

Your verification code is:

{{verification_code}}

This code expires in {{expires_in_minutes}} minutes.

If you did not request this code, please ignore this email.

`), }, notificationEmailLocaleChinese: { Subject: "[{{site_name}}] 通知邮箱验证码", HTML: notificationEmailCard("#0ea5e9", "通知邮箱验证", `

{{recipient_name}},您好:

您正在添加额外的通知邮箱,请输入以下验证码完成验证。

{{verification_code}}

验证码将在 {{expires_in_minutes}} 分钟后失效。

如果不是您本人操作,请忽略此邮件。

`), }, }, NotificationEmailEventSubscriptionPurchaseSuccess: { notificationEmailDefaultLocale: { Subject: "[{{site_name}}] Subscription purchase successful", HTML: notificationEmailCard("#2563eb", "Subscription activated", `

Hello {{recipient_name}},

Your subscription for {{subscription_group}} has been activated for {{subscription_days}} days.

Expiry time: {{expiry_time}}

Order ID: {{order_id}}

`), }, notificationEmailLocaleChinese: { Subject: "[{{site_name}}] 订阅购买成功", HTML: notificationEmailCard("#2563eb", "订阅已开通", `

{{recipient_name}},您好:

您的 {{subscription_group}} 订阅已成功开通,有效期 {{subscription_days}} 天。

到期时间:{{expiry_time}}

订单号:{{order_id}}

`), }, }, NotificationEmailEventSubscriptionExpiryReminder: { notificationEmailDefaultLocale: { Subject: "[{{site_name}}] Subscription expires in {{days_remaining}} day(s)", HTML: notificationEmailCard("#f97316", "Subscription expiry reminder", `

Hello {{recipient_name}},

Your {{subscription_group}} subscription will expire in {{days_remaining}} day(s).

Expiry time: {{expiry_time}}

Unsubscribe from optional subscription reminders

`), }, notificationEmailLocaleChinese: { Subject: "[{{site_name}}] 订阅将在 {{days_remaining}} 天后到期", HTML: notificationEmailCard("#f97316", "订阅到期提醒", `

{{recipient_name}},您好:

您的 {{subscription_group}} 订阅将在 {{days_remaining}} 天后到期。

到期时间:{{expiry_time}}

退订此类订阅提醒

`), }, }, NotificationEmailEventBalanceLow: { notificationEmailDefaultLocale: { Subject: "[{{site_name}}] Low balance alert", HTML: notificationEmailCard("#d97706", "Low balance alert", `

Hello {{recipient_name}},

Your current balance is ${{current_balance}}, below the configured alert threshold of ${{threshold}}.

Please recharge in time to avoid service interruption.

Recharge now

Unsubscribe from optional balance alerts

`), }, notificationEmailLocaleChinese: { Subject: "[{{site_name}}] 余额不足提醒", HTML: notificationEmailCard("#d97706", "余额不足提醒", `

{{recipient_name}},您好:

您当前余额为 ${{current_balance}},已低于提醒阈值 ${{threshold}}

请及时充值以免服务中断。

立即充值

退订此类余额提醒

`), }, }, NotificationEmailEventBalanceRechargeSuccess: { notificationEmailDefaultLocale: { Subject: "[{{site_name}}] Balance recharge successful", HTML: notificationEmailCard("#16a34a", "Recharge successful", `

Hello {{recipient_name}},

Your balance recharge of ${{recharge_amount}} has been completed.

Current balance: ${{current_balance}}

Order ID: {{order_id}}

`), }, notificationEmailLocaleChinese: { Subject: "[{{site_name}}] 余额充值成功", HTML: notificationEmailCard("#16a34a", "余额充值成功", `

{{recipient_name}},您好:

您的余额充值 ${{recharge_amount}} 已完成。

当前余额:${{current_balance}}

订单号:{{order_id}}

`), }, }, NotificationEmailEventAccountQuotaAlert: { notificationEmailDefaultLocale: { Subject: "[{{site_name}}] Account quota alert - {{account_name}}", HTML: notificationEmailCard("#dc2626", "Account quota alert", `

The upstream account {{account_name}} has crossed its configured quota alert threshold.

Account ID{{account_id}}
Platform{{platform}}
Dimension{{quota_dimension}}
Used / Limit{{quota_used}} / {{quota_limit}}
Remaining{{quota_remaining}}
Threshold{{quota_threshold}}
`), }, notificationEmailLocaleChinese: { Subject: "[{{site_name}}] 账号限额告警 - {{account_name}}", HTML: notificationEmailCard("#dc2626", "账号限额告警", `

上游账号 {{account_name}} 已触发配置的额度告警阈值。

账号 ID{{account_id}}
平台{{platform}}
维度{{quota_dimension}}
已用 / 限额{{quota_used}} / {{quota_limit}}
剩余额度{{quota_remaining}}
告警阈值{{quota_threshold}}
`), }, }, NotificationEmailEventContentModerationViolation: { notificationEmailDefaultLocale: { Subject: "[{{site_name}}] Risk control notice", HTML: notificationEmailCard("#ef4444", "Risk control notice", `

Hello {{recipient_name}},

Your API request triggered the platform content moderation/risk-control policy.

Triggered at{{triggered_at}}
Group{{group_name}}
Category / Score{{moderation_category}} / {{moderation_score}}
Violation count{{violation_count}} / {{ban_threshold}}

Please review your request content to avoid future service interruptions.

`), }, notificationEmailLocaleChinese: { Subject: "[{{site_name}}] 账户风控提醒", HTML: notificationEmailCard("#ef4444", "账户风控提醒", `

{{recipient_name}},您好:

您的 API 请求触发了平台内容审核/风控策略。

触发时间{{triggered_at}}
所属分组{{group_name}}
命中类别 / 分数{{moderation_category}} / {{moderation_score}}
累计触发次数{{violation_count}} / {{ban_threshold}}

请检查请求内容,避免后续服务受到影响。

`), }, }, NotificationEmailEventContentModerationDisabled: { notificationEmailDefaultLocale: { Subject: "[{{site_name}}] Account disabled by risk control", HTML: notificationEmailCard("#b91c1c", "Account disabled", `

Hello {{recipient_name}},

Your account has repeatedly triggered platform content moderation/risk-control rules and has been automatically disabled.

Disabled at{{triggered_at}}
Group{{group_name}}
Category / Score{{moderation_category}} / {{moderation_score}}
Violation count{{violation_count}} / {{ban_threshold}}

Please contact the administrator if you need to appeal or restore access.

`), }, notificationEmailLocaleChinese: { Subject: "[{{site_name}}] 账户已被禁用", HTML: notificationEmailCard("#b91c1c", "账户已被禁用", `

{{recipient_name}},您好:

您的账户在统计周期内多次触发平台内容审核/风控规则,系统已自动禁用该账户。

禁用时间{{triggered_at}}
所属分组{{group_name}}
命中类别 / 分数{{moderation_category}} / {{moderation_score}}
累计触发次数{{violation_count}} / {{ban_threshold}}

如需申诉或恢复账号,请联系平台管理员处理。

`), }, }, NotificationEmailEventOpsAlert: { notificationEmailDefaultLocale: { Subject: "[Ops Alert][{{severity}}] {{rule_name}}", HTML: notificationEmailCard("#ea580c", "Ops alert", `

Rule: {{rule_name}}

Severity: {{severity}}

Status: {{alert_status}}

Metric: {{metric_type}} {{operator}} {{metric_value}} (threshold {{threshold_value}})

Fired at: {{triggered_at}}

Description: {{alert_description}}

`), }, notificationEmailLocaleChinese: { Subject: "[运维告警][{{severity}}] {{rule_name}}", HTML: notificationEmailCard("#ea580c", "运维告警", `

规则:{{rule_name}}

严重级别:{{severity}}

状态:{{alert_status}}

指标:{{metric_type}} {{operator}} {{metric_value}}(阈值 {{threshold_value}})

触发时间:{{triggered_at}}

说明:{{alert_description}}

`), }, }, NotificationEmailEventOpsScheduledReport: { notificationEmailDefaultLocale: { Subject: "[Ops Report] {{report_name}}", HTML: notificationEmailCard("#0891b2", "Ops report", `

Report: {{report_name}}

Type: {{report_type}}

Range: {{report_start_time}} - {{report_end_time}}

{{report_html}}
`), }, notificationEmailLocaleChinese: { Subject: "[运维报表] {{report_name}}", HTML: notificationEmailCard("#0891b2", "运维报表", `

报表:{{report_name}}

类型:{{report_type}}

时间范围:{{report_start_time}} - {{report_end_time}}

{{report_html}}
`), }, }, } func notificationEmailCard(accent, title, content string) string { return `

` + title + `

` + content + `
` }