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:
parent
7e0b22ceb6
commit
3fdd5cbaef
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user