diff --git a/backend/internal/service/balance_notify_service.go b/backend/internal/service/balance_notify_service.go index d896810e..26803275 100644 --- a/backend/internal/service/balance_notify_service.go +++ b/backend/internal/service/balance_notify_service.go @@ -372,8 +372,12 @@ func (s *BalanceNotifyService) sendBalanceLowEmails(recipients []string, userID }) cancel() if err != nil { - slog.Warn("template balance low notification failed; falling back to built-in body", "to", to, "err", err.Error()) - fallbackRecipients = append(fallbackRecipients, to) + if shouldFallbackNotificationEmail(err) { + slog.Warn("template balance low notification failed; falling back to built-in body", "to", to, "err", err.Error()) + fallbackRecipients = append(fallbackRecipients, to) + } else { + slog.Warn("template balance low notification delivery failed; not sending fallback to avoid duplicates", "to", to, "err", err.Error()) + } } } if len(fallbackRecipients) == 0 { @@ -403,6 +407,44 @@ func (s *BalanceNotifyService) sendQuotaAlertEmails(adminEmails []string, accoun remaining = 0 } + if s.notificationEmailService != nil { + fallbackRecipients := make([]string, 0, len(adminEmails)) + for _, to := range adminEmails { + ctx, cancel := context.WithTimeout(context.Background(), emailSendTimeout) + err := s.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventAccountQuotaAlert, + RecipientEmail: to, + RecipientName: emailRecipientName(to), + SourceType: "account_quota", + SourceID: fmt.Sprintf("%d-%s", accountID, dim.name), + ReminderKey: time.Now().UTC().Format("2006-01-02"), + Variables: map[string]string{ + "account_id": strconv.FormatInt(accountID, 10), + "account_name": accountName, + "platform": platform, + "quota_dimension": dimLabel, + "quota_used": fmt.Sprintf("%.2f", used), + "quota_limit": fmt.Sprintf("%.2f", dim.limit), + "quota_remaining": fmt.Sprintf("%.2f", remaining), + "quota_threshold": thresholdDisplay, + }, + }) + cancel() + if err != nil { + if shouldFallbackNotificationEmail(err) { + slog.Warn("template account quota alert failed; falling back to built-in body", "to", to, "account_id", accountID, "dimension", dim.name, "err", err.Error()) + fallbackRecipients = append(fallbackRecipients, to) + } else { + slog.Warn("template account quota alert delivery failed; not sending fallback to avoid duplicates", "to", to, "account_id", accountID, "dimension", dim.name, "err", err.Error()) + } + } + } + if len(fallbackRecipients) == 0 { + return + } + adminEmails = fallbackRecipients + } + subject := fmt.Sprintf("[%s] 账号限额告警 / Account Quota Alert - %s", sanitizeEmailHeader(siteName), sanitizeEmailHeader(accountName)) body := s.buildQuotaAlertEmailBody(accountID, html.EscapeString(accountName), html.EscapeString(platform), html.EscapeString(dimLabel), used, dim.limit, remaining, thresholdDisplay, html.EscapeString(siteName)) s.sendEmails(adminEmails, subject, body, "account", accountName, "dimension", dim.name) diff --git a/backend/internal/service/content_moderation.go b/backend/internal/service/content_moderation.go index 144222c2..2793d66e 100644 --- a/backend/internal/service/content_moderation.go +++ b/backend/internal/service/content_moderation.go @@ -1404,6 +1404,24 @@ func (s *ContentModerationService) applyFlaggedSideEffects(ctx context.Context, func (s *ContentModerationService) sendViolationEmail(ctx context.Context, cfg *ContentModerationConfig, log *ContentModerationLog) error { siteName := s.siteName(ctx) + if s.emailService.notificationEmailService != nil { + if err := s.emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventContentModerationViolation, + RecipientEmail: log.UserEmail, + RecipientName: emailRecipientName(log.UserEmail), + UserID: contentModerationEmailUserID(log), + SourceType: "content_moderation", + SourceID: contentModerationEmailSourceID(log), + Variables: contentModerationEmailVariables(log, cfg), + }); err == nil { + return nil + } else { + if !shouldFallbackNotificationEmail(err) { + return err + } + slog.Warn("template content moderation violation email failed; falling back to built-in body", "log_id", log.ID, "recipient_hash", notificationEmailHash(log.UserEmail), "err", err.Error()) + } + } subject := fmt.Sprintf("[%s] 账户风控提醒 / Risk Control Notice", sanitizeEmailHeader(siteName)) body := buildContentModerationViolationEmailBody(siteName, log, cfg) return s.emailService.SendEmail(ctx, log.UserEmail, subject, body) @@ -1411,11 +1429,71 @@ func (s *ContentModerationService) sendViolationEmail(ctx context.Context, cfg * func (s *ContentModerationService) sendAccountDisabledEmail(ctx context.Context, cfg *ContentModerationConfig, log *ContentModerationLog) error { siteName := s.siteName(ctx) + if s.emailService.notificationEmailService != nil { + if err := s.emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventContentModerationDisabled, + RecipientEmail: log.UserEmail, + RecipientName: emailRecipientName(log.UserEmail), + UserID: contentModerationEmailUserID(log), + SourceType: "content_moderation", + SourceID: contentModerationEmailSourceID(log), + Variables: contentModerationEmailVariables(log, cfg), + }); err == nil { + return nil + } else { + if !shouldFallbackNotificationEmail(err) { + return err + } + slog.Warn("template content moderation disabled email failed; falling back to built-in body", "log_id", log.ID, "recipient_hash", notificationEmailHash(log.UserEmail), "err", err.Error()) + } + } subject := fmt.Sprintf("[%s] 账户已被禁用 / Account Disabled", sanitizeEmailHeader(siteName)) body := buildContentModerationAccountDisabledEmailBody(siteName, log, cfg) return s.emailService.SendEmail(ctx, log.UserEmail, subject, body) } +func contentModerationEmailUserID(log *ContentModerationLog) int64 { + if log == nil || log.UserID == nil { + return 0 + } + return *log.UserID +} + +func contentModerationEmailSourceID(log *ContentModerationLog) string { + if log == nil || log.ID <= 0 { + return "" + } + return fmt.Sprintf("%d", log.ID) +} + +func contentModerationEmailVariables(log *ContentModerationLog, cfg *ContentModerationConfig) map[string]string { + variables := map[string]string{ + "triggered_at": time.Now().UTC().Format(time.RFC3339), + "group_name": "-", + "moderation_category": "-", + "moderation_score": "0.000", + "violation_count": "0", + "ban_threshold": "0", + } + if log != nil { + if !log.CreatedAt.IsZero() { + variables["triggered_at"] = log.CreatedAt.UTC().Format(time.RFC3339) + } + if strings.TrimSpace(log.GroupName) != "" { + variables["group_name"] = strings.TrimSpace(log.GroupName) + } + if strings.TrimSpace(log.HighestCategory) != "" { + variables["moderation_category"] = strings.TrimSpace(log.HighestCategory) + } + variables["moderation_score"] = fmt.Sprintf("%.3f", log.HighestScore) + variables["violation_count"] = fmt.Sprintf("%d", log.ViolationCount) + } + if cfg != nil { + variables["ban_threshold"] = fmt.Sprintf("%d", cfg.BanThreshold) + } + return variables +} + func (s *ContentModerationService) siteName(ctx context.Context) string { if s == nil || s.settingRepo == nil { return "Sub2API" diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 9a03ea30..2cf42d73 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -94,8 +94,9 @@ type SMTPConfig struct { // EmailService 邮件服务 type EmailService struct { - settingRepo SettingRepository - cache EmailCache + settingRepo SettingRepository + cache EmailCache + notificationEmailService *NotificationEmailService } // NewEmailService 创建邮件服务实例 @@ -106,6 +107,28 @@ func NewEmailService(settingRepo SettingRepository, cache EmailCache) *EmailServ } } +func (s *EmailService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) { + s.notificationEmailService = notificationEmailService +} + +func firstEmailLocale(locales []string) string { + if len(locales) == 0 { + return "" + } + return strings.TrimSpace(locales[0]) +} + +func emailRecipientName(email string) string { + trimmed := strings.TrimSpace(email) + if trimmed == "" { + return "" + } + if at := strings.Index(trimmed, "@"); at > 0 { + return trimmed[:at] + } + return trimmed +} + // GetSMTPConfig 从数据库获取SMTP配置 func (s *EmailService) GetSMTPConfig(ctx context.Context) (*SMTPConfig, error) { keys := []string{ @@ -301,7 +324,7 @@ func (s *EmailService) GenerateVerifyCode() (string, error) { } // SendVerifyCode 发送验证码邮件 -func (s *EmailService) SendVerifyCode(ctx context.Context, email, siteName string) error { +func (s *EmailService) SendVerifyCode(ctx context.Context, email, siteName string, locale ...string) error { // 检查是否在冷却期内 existing, err := s.cache.GetVerificationCode(ctx, email) if err == nil && existing != nil { @@ -327,6 +350,26 @@ func (s *EmailService) SendVerifyCode(ctx context.Context, email, siteName strin return fmt.Errorf("save verify code: %w", err) } + if s.notificationEmailService != nil { + err := s.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventAuthVerifyCode, + Locale: firstEmailLocale(locale), + RecipientEmail: email, + RecipientName: emailRecipientName(email), + Variables: map[string]string{ + "verification_code": code, + "expires_in_minutes": strconv.Itoa(int(verifyCodeTTL / time.Minute)), + }, + }) + if err == nil { + return nil + } + if !shouldFallbackNotificationEmail(err) { + return err + } + slog.Warn("failed to send templated verification email, falling back to legacy template", "recipient_hash", notificationEmailHash(email), "error", err) + } + // 构建邮件内容 subject := fmt.Sprintf("[%s] Email Verification Code", siteName) body := s.buildVerifyCodeEmailBody(code, siteName) @@ -469,7 +512,7 @@ func (s *EmailService) GeneratePasswordResetToken() (string, error) { } // SendPasswordResetEmail sends a password reset email with a reset link -func (s *EmailService) SendPasswordResetEmail(ctx context.Context, email, siteName, resetURL string) error { +func (s *EmailService) SendPasswordResetEmail(ctx context.Context, email, siteName, resetURL string, locale ...string) error { var token string var needSaveToken bool @@ -502,6 +545,26 @@ func (s *EmailService) SendPasswordResetEmail(ctx context.Context, email, siteNa // Build full reset URL with URL-encoded token and email fullResetURL := fmt.Sprintf("%s?email=%s&token=%s", resetURL, url.QueryEscape(email), url.QueryEscape(token)) + if s.notificationEmailService != nil { + err := s.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventAuthPasswordReset, + Locale: firstEmailLocale(locale), + RecipientEmail: email, + RecipientName: emailRecipientName(email), + Variables: map[string]string{ + "reset_url": fullResetURL, + "expires_in_minutes": strconv.Itoa(int(passwordResetTokenTTL / time.Minute)), + }, + }) + if err == nil { + return nil + } + if !shouldFallbackNotificationEmail(err) { + return err + } + slog.Warn("failed to send templated password reset email, falling back to legacy template", "recipient_hash", notificationEmailHash(email), "error", err) + } + // Build email content subject := fmt.Sprintf("[%s] 密码重置请求", siteName) body := s.buildPasswordResetEmailBody(fullResetURL, siteName) @@ -516,7 +579,7 @@ func (s *EmailService) SendPasswordResetEmail(ctx context.Context, email, siteNa // SendPasswordResetEmailWithCooldown sends password reset email with cooldown check (called by queue worker) // This method wraps SendPasswordResetEmail with email cooldown to prevent email bombing -func (s *EmailService) SendPasswordResetEmailWithCooldown(ctx context.Context, email, siteName, resetURL string) error { +func (s *EmailService) SendPasswordResetEmailWithCooldown(ctx context.Context, email, siteName, resetURL string, locale ...string) error { // Check email cooldown to prevent email bombing if s.cache.IsPasswordResetEmailInCooldown(ctx, email) { slog.Info("password reset email skipped due to cooldown", "email", email) @@ -524,7 +587,7 @@ func (s *EmailService) SendPasswordResetEmailWithCooldown(ctx context.Context, e } // Send email using core method - if err := s.SendPasswordResetEmail(ctx, email, siteName, resetURL); err != nil { + if err := s.SendPasswordResetEmail(ctx, email, siteName, resetURL, firstEmailLocale(locale)); err != nil { return err }