diff --git a/backend/internal/service/notification_email_service.go b/backend/internal/service/notification_email_service.go new file mode 100644 index 00000000..5b758339 --- /dev/null +++ b/backend/internal/service/notification_email_service.go @@ -0,0 +1,891 @@ +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 ( + NotificationEmailEventSubscriptionPurchaseSuccess = "subscription.purchase_success" + NotificationEmailEventSubscriptionExpiryReminder = "subscription.expiry_reminder" + NotificationEmailEventBalanceLow = "balance.low" + NotificationEmailEventBalanceRechargeSuccess = "balance.recharge_success" + + 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 +} + +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 notificationEmailUnsubscribeClaims struct { + Email string `json:"email"` + Event string `json:"event"` + Exp int64 `json:"exp"` +} + +func NewNotificationEmailService(settingRepo SettingRepository, emailService *EmailService) *NotificationEmailService { + return &NotificationEmailService{settingRepo: settingRepo, emailService: emailService} +} + +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) +} + +func (s *NotificationEmailService) Send(ctx context.Context, input NotificationEmailSendInput) error { + info, normalizedEvent, err := s.eventInfo(input.Event) + if err != nil { + return 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 err + } + variables := s.runtimeVariables(ctx, normalizedEvent, locale, input) + rendered, err := renderNotificationEmail(normalizedEvent, tmpl.Subject, tmpl.HTML, variables) + if err != nil { + return err + } + + deliveryKey := notificationEmailDeliveryKey(normalizedEvent, input.SourceType, input.SourceID, recipient, input.ReminderKey) + if deliveryKey != "" { + sent, err := s.deliveryExists(ctx, deliveryKey) + if err != nil { + return err + } + if sent { + return nil + } + } + + if s.emailService == nil { + return errors.New("email service is not configured") + } + if err := s.emailService.SendEmail(ctx, recipient, rendered.Subject, rendered.HTML); err != nil { + return err + } + if deliveryKey != "" { + _ = s.settingRepo.Set(ctx, deliveryKey, time.Now().UTC().Format(time.RFC3339Nano)) + } + 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 + } + value, err := s.settingRepo.GetValue(ctx, notificationEmailPreferenceKey(normalizedEvent, email)) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return false, nil + } + return false, err + } + return strings.EqualFold(strings.TrimSpace(value), "unsubscribed"), 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, key string) (bool, error) { + _, err := s.settingRepo.GetValue(ctx, key) + if err == nil { + return true, nil + } + if errors.Is(err, ErrSettingNotFound) { + return false, nil + } + return false, err +} + +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) (NotificationEmailPreview, error) { + if err := validateNotificationEmailTemplate(event, subject, htmlBody); err != nil { + return NotificationEmailPreview{}, err + } + renderedSubject, err := renderNotificationEmailString(event, subject, variables, false) + if err != nil { + return NotificationEmailPreview{}, err + } + renderedHTML, err := renderNotificationEmailString(event, htmlBody, variables, true) + if err != nil { + return NotificationEmailPreview{}, err + } + return NotificationEmailPreview{Subject: sanitizeEmailHeader(renderedSubject), HTML: renderedHTML}, nil +} + +func renderNotificationEmailString(event, raw string, variables 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 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 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 { + 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 "" + } + 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", + "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", + } + } + return map[string]string{ + "site_name": defaultSiteName, + "recipient_name": "Alex", + "recipient_email": "user@example.com", + "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", + } +} + +var notificationEmailEventOrder = []string{ + NotificationEmailEventSubscriptionPurchaseSuccess, + NotificationEmailEventSubscriptionExpiryReminder, + NotificationEmailEventBalanceLow, + NotificationEmailEventBalanceRechargeSuccess, +} + +var notificationEmailEventDefinitions = map[string]NotificationEmailEventInfo{ + 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"), + }, +} + +var notificationEmailOfficialTemplates = map[string]map[string]notificationEmailOfficialTemplate{ + 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}}

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

` + title + `

+
` + content + `
+ +
+ +` +} diff --git a/backend/internal/service/notification_email_service_test.go b/backend/internal/service/notification_email_service_test.go new file mode 100644 index 00000000..692b68ef --- /dev/null +++ b/backend/internal/service/notification_email_service_test.go @@ -0,0 +1,188 @@ +package service + +import ( + "context" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNotificationEmailPreviewEscapesHTMLAndSanitizesSubject(t *testing.T) { + ctx := context.Background() + svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil) + + preview, err := svc.PreviewTemplate(ctx, NotificationEmailPreviewInput{ + Event: NotificationEmailEventBalanceLow, + Locale: "en-US,en;q=0.9", + Subject: "Low balance for {{recipient_name}}\r\nInjected", + HTML: `

{{recipient_name}}

Recharge`, + Variables: map[string]string{ + "recipient_name": ``, + "recharge_url": `javascript:alert(1)`, + }, + }) + require.NoError(t, err) + require.NotContains(t, preview.Subject, "\r") + require.NotContains(t, preview.Subject, "\n") + require.Contains(t, preview.Subject, `Low balance for Injected`) + require.Contains(t, preview.HTML, `<script>alert("x")</script>`) + require.NotContains(t, preview.HTML, `javascript:alert(1)`) + require.Contains(t, preview.HTML, `href=""`) +} + +func TestNotificationEmailTemplateOverrideAndRestore(t *testing.T) { + ctx := context.Background() + repo := newNotificationEmailMemorySettingRepo() + svc := NewNotificationEmailService(repo, nil) + + official, err := svc.GetTemplate(ctx, NotificationEmailEventBalanceRechargeSuccess, "en") + require.NoError(t, err) + require.False(t, official.IsCustom) + + updated, err := svc.UpdateTemplate( + ctx, + NotificationEmailEventBalanceRechargeSuccess, + "zh-Hans", + "充值完成:{{recharge_amount}}", + "

{{recipient_name}} 已充值 {{recharge_amount}}

", + ) + require.NoError(t, err) + require.True(t, updated.IsCustom) + require.Equal(t, "zh", updated.Locale) + require.Equal(t, "充值完成:{{recharge_amount}}", updated.Subject) + require.NotNil(t, updated.UpdatedAt) + + restored, err := svc.RestoreOfficialTemplate(ctx, NotificationEmailEventBalanceRechargeSuccess, "zh") + require.NoError(t, err) + require.False(t, restored.IsCustom) + require.NotEqual(t, updated.Subject, restored.Subject) + _, err = repo.GetValue(ctx, notificationEmailTemplateKey(NotificationEmailEventBalanceRechargeSuccess, "zh")) + require.ErrorIs(t, err, ErrSettingNotFound) +} + +func TestNotificationEmailTemplateRejectsUnsupportedPlaceholder(t *testing.T) { + ctx := context.Background() + svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil) + + _, err := svc.UpdateTemplate( + ctx, + NotificationEmailEventSubscriptionPurchaseSuccess, + "en", + "Purchased {{not_allowed}}", + "

{{subscription_group}}

", + ) + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported placeholder") +} + +func TestNotificationEmailUnsubscribeOnlyAllowsOptionalEvents(t *testing.T) { + ctx := context.Background() + svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil) + + token, err := svc.createUnsubscribeToken(ctx, "User@Example.com", NotificationEmailEventBalanceLow) + require.NoError(t, err) + result, err := svc.Unsubscribe(ctx, token) + require.NoError(t, err) + require.True(t, result.Done) + require.Equal(t, NotificationEmailEventBalanceLow, result.Event) + unsubscribed, err := svc.IsUnsubscribed(ctx, "user@example.com", NotificationEmailEventBalanceLow) + require.NoError(t, err) + require.True(t, unsubscribed) + + transactionalToken, err := svc.createUnsubscribeToken(ctx, "user@example.com", NotificationEmailEventBalanceRechargeSuccess) + require.NoError(t, err) + _, err = svc.Unsubscribe(ctx, transactionalToken) + require.Error(t, err) + require.Contains(t, err.Error(), "transactional") +} + +func TestNotificationEmailLocaleMemoryNormalizesAcceptLanguage(t *testing.T) { + ctx := context.Background() + svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil) + + svc.RememberRecipientLocale(ctx, 42, "User@Example.com", "zh-CN,zh;q=0.9,en;q=0.8") + require.Equal(t, "zh", svc.ResolveRecipientLocale(ctx, 42, "user@example.com")) + require.Equal(t, "zh", svc.ResolveRecipientLocale(ctx, 0, "user@example.com")) +} + +type notificationEmailMemorySettingRepo struct { + mu sync.RWMutex + values map[string]string +} + +func newNotificationEmailMemorySettingRepo() *notificationEmailMemorySettingRepo { + return ¬ificationEmailMemorySettingRepo{values: make(map[string]string)} +} + +func (r *notificationEmailMemorySettingRepo) Get(_ context.Context, key string) (*Setting, error) { + r.mu.RLock() + defer r.mu.RUnlock() + value, ok := r.values[key] + if !ok { + return nil, ErrSettingNotFound + } + return &Setting{Key: key, Value: value}, nil +} + +func (r *notificationEmailMemorySettingRepo) GetValue(ctx context.Context, key string) (string, error) { + setting, err := r.Get(ctx, key) + if err != nil { + return "", err + } + return setting.Value, nil +} + +func (r *notificationEmailMemorySettingRepo) Set(_ context.Context, key, value string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.values[key] = value + return nil +} + +func (r *notificationEmailMemorySettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + out := make(map[string]string, len(keys)) + for _, key := range keys { + if value, ok := r.values[key]; ok { + out[key] = value + } + } + return out, nil +} + +func (r *notificationEmailMemorySettingRepo) SetMultiple(_ context.Context, settings map[string]string) error { + r.mu.Lock() + defer r.mu.Unlock() + for key, value := range settings { + r.values[key] = value + } + return nil +} + +func (r *notificationEmailMemorySettingRepo) GetAll(_ context.Context) (map[string]string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + out := make(map[string]string, len(r.values)) + for key, value := range r.values { + out[key] = value + } + return out, nil +} + +func (r *notificationEmailMemorySettingRepo) Delete(_ context.Context, key string) error { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.values[key]; !ok { + return ErrSettingNotFound + } + delete(r.values, key) + return nil +} + +func TestNotificationEmailMemorySettingRepoSatisfiesInterface(t *testing.T) { + var _ SettingRepository = (*notificationEmailMemorySettingRepo)(nil) + require.False(t, strings.Contains(notificationEmailPreferenceKey(NotificationEmailEventBalanceLow, "User@Example.com"), "User@Example.com")) +}