fix(email): 避免模板投递错误重复 fallback

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
benjamin 2026-05-20 13:25:18 +08:00
parent 7e0b22ceb6
commit 3fdd5cbaef
3 changed files with 191 additions and 8 deletions

View File

@ -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)

View File

@ -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"

View File

@ -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
}