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()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("template balance low notification failed; falling back to built-in body", "to", to, "err", err.Error())
|
if shouldFallbackNotificationEmail(err) {
|
||||||
fallbackRecipients = append(fallbackRecipients, to)
|
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 {
|
if len(fallbackRecipients) == 0 {
|
||||||
@ -403,6 +407,44 @@ func (s *BalanceNotifyService) sendQuotaAlertEmails(adminEmails []string, accoun
|
|||||||
remaining = 0
|
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))
|
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))
|
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)
|
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 {
|
func (s *ContentModerationService) sendViolationEmail(ctx context.Context, cfg *ContentModerationConfig, log *ContentModerationLog) error {
|
||||||
siteName := s.siteName(ctx)
|
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))
|
subject := fmt.Sprintf("[%s] 账户风控提醒 / Risk Control Notice", sanitizeEmailHeader(siteName))
|
||||||
body := buildContentModerationViolationEmailBody(siteName, log, cfg)
|
body := buildContentModerationViolationEmailBody(siteName, log, cfg)
|
||||||
return s.emailService.SendEmail(ctx, log.UserEmail, subject, body)
|
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 {
|
func (s *ContentModerationService) sendAccountDisabledEmail(ctx context.Context, cfg *ContentModerationConfig, log *ContentModerationLog) error {
|
||||||
siteName := s.siteName(ctx)
|
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))
|
subject := fmt.Sprintf("[%s] 账户已被禁用 / Account Disabled", sanitizeEmailHeader(siteName))
|
||||||
body := buildContentModerationAccountDisabledEmailBody(siteName, log, cfg)
|
body := buildContentModerationAccountDisabledEmailBody(siteName, log, cfg)
|
||||||
return s.emailService.SendEmail(ctx, log.UserEmail, subject, body)
|
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 {
|
func (s *ContentModerationService) siteName(ctx context.Context) string {
|
||||||
if s == nil || s.settingRepo == nil {
|
if s == nil || s.settingRepo == nil {
|
||||||
return "Sub2API"
|
return "Sub2API"
|
||||||
|
|||||||
@ -94,8 +94,9 @@ type SMTPConfig struct {
|
|||||||
|
|
||||||
// EmailService 邮件服务
|
// EmailService 邮件服务
|
||||||
type EmailService struct {
|
type EmailService struct {
|
||||||
settingRepo SettingRepository
|
settingRepo SettingRepository
|
||||||
cache EmailCache
|
cache EmailCache
|
||||||
|
notificationEmailService *NotificationEmailService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEmailService 创建邮件服务实例
|
// 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配置
|
// GetSMTPConfig 从数据库获取SMTP配置
|
||||||
func (s *EmailService) GetSMTPConfig(ctx context.Context) (*SMTPConfig, error) {
|
func (s *EmailService) GetSMTPConfig(ctx context.Context) (*SMTPConfig, error) {
|
||||||
keys := []string{
|
keys := []string{
|
||||||
@ -301,7 +324,7 @@ func (s *EmailService) GenerateVerifyCode() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SendVerifyCode 发送验证码邮件
|
// 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)
|
existing, err := s.cache.GetVerificationCode(ctx, email)
|
||||||
if err == nil && existing != nil {
|
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)
|
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)
|
subject := fmt.Sprintf("[%s] Email Verification Code", siteName)
|
||||||
body := s.buildVerifyCodeEmailBody(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
|
// 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 token string
|
||||||
var needSaveToken bool
|
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
|
// 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))
|
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
|
// Build email content
|
||||||
subject := fmt.Sprintf("[%s] 密码重置请求", siteName)
|
subject := fmt.Sprintf("[%s] 密码重置请求", siteName)
|
||||||
body := s.buildPasswordResetEmailBody(fullResetURL, 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)
|
// SendPasswordResetEmailWithCooldown sends password reset email with cooldown check (called by queue worker)
|
||||||
// This method wraps SendPasswordResetEmail with email cooldown to prevent email bombing
|
// 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
|
// Check email cooldown to prevent email bombing
|
||||||
if s.cache.IsPasswordResetEmailInCooldown(ctx, email) {
|
if s.cache.IsPasswordResetEmailInCooldown(ctx, email) {
|
||||||
slog.Info("password reset email skipped due to cooldown", "email", 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
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user