diff --git a/backend/internal/service/notification_email_service.go b/backend/internal/service/notification_email_service.go index 5b758339..ba281915 100644 --- a/backend/internal/service/notification_email_service.go +++ b/backend/internal/service/notification_email_service.go @@ -20,10 +20,18 @@ import ( ) const ( + NotificationEmailEventAuthVerifyCode = "auth.verify_code" + NotificationEmailEventAuthPasswordReset = "auth.password_reset" + NotificationEmailEventNotificationEmailVerifyCode = "notification_email.verify_code" NotificationEmailEventSubscriptionPurchaseSuccess = "subscription.purchase_success" NotificationEmailEventSubscriptionExpiryReminder = "subscription.expiry_reminder" NotificationEmailEventBalanceLow = "balance.low" NotificationEmailEventBalanceRechargeSuccess = "balance.recharge_success" + NotificationEmailEventAccountQuotaAlert = "account.quota_alert" + NotificationEmailEventContentModerationViolation = "content_moderation.violation_notice" + NotificationEmailEventContentModerationDisabled = "content_moderation.account_disabled" + NotificationEmailEventOpsAlert = "ops.alert" + NotificationEmailEventOpsScheduledReport = "ops.scheduled_report" notificationEmailTemplateKeyPrefix = "notification_email_template:" notificationEmailPreferenceKeyPrefix = "notification_email_preference:" @@ -82,15 +90,16 @@ type NotificationEmailPreviewInput struct { } type NotificationEmailSendInput struct { - Event string - Locale string - RecipientEmail string - RecipientName string - UserID int64 - SourceType string - SourceID string - ReminderKey string - Variables map[string]string + Event string + Locale string + RecipientEmail string + RecipientName string + UserID int64 + SourceType string + SourceID string + ReminderKey string + Variables map[string]string + RawHTMLVariables map[string]string } type NotificationEmailUnsubscribeResult struct { @@ -110,6 +119,42 @@ type notificationEmailOfficialTemplate struct { HTML string } +type notificationEmailTemplateError struct { + Err error +} + +func (e notificationEmailTemplateError) Error() string { + return e.Err.Error() +} + +func (e notificationEmailTemplateError) Unwrap() error { + return e.Err +} + +type notificationEmailConfigError struct { + Err error +} + +func (e notificationEmailConfigError) Error() string { + return e.Err.Error() +} + +func (e notificationEmailConfigError) Unwrap() error { + return e.Err +} + +type notificationEmailDeliveryError struct { + Err error +} + +func (e notificationEmailDeliveryError) Error() string { + return e.Err.Error() +} + +func (e notificationEmailDeliveryError) Unwrap() error { + return e.Err +} + type notificationEmailUnsubscribeClaims struct { Email string `json:"email"` Event string `json:"event"` @@ -117,7 +162,49 @@ type notificationEmailUnsubscribeClaims struct { } func NewNotificationEmailService(settingRepo SettingRepository, emailService *EmailService) *NotificationEmailService { - return &NotificationEmailService{settingRepo: settingRepo, emailService: emailService} + svc := &NotificationEmailService{settingRepo: settingRepo, emailService: emailService} + if emailService != nil { + emailService.SetNotificationEmailService(svc) + } + return svc +} + +func notificationEmailTemplateErr(err error) error { + if err == nil { + return nil + } + return notificationEmailTemplateError{Err: err} +} + +func notificationEmailConfigErr(err error) error { + if err == nil { + return nil + } + return notificationEmailConfigError{Err: err} +} + +func notificationEmailDeliveryErr(err error) error { + if err == nil { + return nil + } + return notificationEmailDeliveryError{Err: err} +} + +func shouldFallbackNotificationEmail(err error) bool { + if err == nil { + return false + } + var templateErr notificationEmailTemplateError + if errors.As(err, &templateErr) { + return true + } + var configErr notificationEmailConfigError + return errors.As(err, &configErr) +} + +func isNotificationEmailDeliveryError(err error) bool { + var deliveryErr notificationEmailDeliveryError + return errors.As(err, &deliveryErr) } func (s *NotificationEmailService) ListEventInfos() []NotificationEmailEventInfo { @@ -256,13 +343,13 @@ func (s *NotificationEmailService) PreviewTemplate(ctx context.Context, input No for key, value := range input.Variables { variables[key] = value } - return renderNotificationEmail(normalizedEvent, subject, htmlBody, variables) + return renderNotificationEmail(normalizedEvent, subject, htmlBody, variables, nil) } func (s *NotificationEmailService) Send(ctx context.Context, input NotificationEmailSendInput) error { info, normalizedEvent, err := s.eventInfo(input.Event) if err != nil { - return err + return notificationEmailTemplateErr(err) } recipient := strings.TrimSpace(input.RecipientEmail) if recipient == "" { @@ -285,12 +372,12 @@ func (s *NotificationEmailService) Send(ctx context.Context, input NotificationE } tmpl, err := s.GetTemplate(ctx, normalizedEvent, locale) if err != nil { - return err + return notificationEmailTemplateErr(err) } variables := s.runtimeVariables(ctx, normalizedEvent, locale, input) - rendered, err := renderNotificationEmail(normalizedEvent, tmpl.Subject, tmpl.HTML, variables) + rendered, err := renderNotificationEmail(normalizedEvent, tmpl.Subject, tmpl.HTML, variables, input.RawHTMLVariables) if err != nil { - return err + return notificationEmailTemplateErr(err) } deliveryKey := notificationEmailDeliveryKey(normalizedEvent, input.SourceType, input.SourceID, recipient, input.ReminderKey) @@ -305,10 +392,10 @@ func (s *NotificationEmailService) Send(ctx context.Context, input NotificationE } if s.emailService == nil { - return errors.New("email service is not configured") + return notificationEmailConfigErr(errors.New("email service is not configured")) } if err := s.emailService.SendEmail(ctx, recipient, rendered.Subject, rendered.HTML); err != nil { - return err + return notificationEmailDeliveryErr(err) } if deliveryKey != "" { _ = s.settingRepo.Set(ctx, deliveryKey, time.Now().UTC().Format(time.RFC3339Nano)) @@ -557,22 +644,22 @@ func validateNotificationEmailTemplate(event, subject, htmlBody string) error { return nil } -func renderNotificationEmail(event, subject, htmlBody string, variables map[string]string) (NotificationEmailPreview, error) { +func renderNotificationEmail(event, subject, htmlBody string, variables map[string]string, rawHTMLVariables map[string]string) (NotificationEmailPreview, error) { if err := validateNotificationEmailTemplate(event, subject, htmlBody); err != nil { return NotificationEmailPreview{}, err } - renderedSubject, err := renderNotificationEmailString(event, subject, variables, false) + renderedSubject, err := renderNotificationEmailString(event, subject, variables, nil, false) if err != nil { return NotificationEmailPreview{}, err } - renderedHTML, err := renderNotificationEmailString(event, htmlBody, variables, true) + renderedHTML, err := renderNotificationEmailString(event, htmlBody, variables, rawHTMLVariables, 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) { +func renderNotificationEmailString(event, raw string, variables map[string]string, rawHTMLVariables map[string]string, escapeHTML bool) (string, error) { allowed := notificationEmailAllowedPlaceholderSet(event) var renderErr error rendered := notificationEmailPlaceholderPattern.ReplaceAllStringFunc(raw, func(match string) string { @@ -589,6 +676,13 @@ func renderNotificationEmailString(event, raw string, variables map[string]strin return "" } value := variables[name] + if escapeHTML && notificationEmailRawHTMLAllowed(event, name) { + if rawHTMLVariables != nil { + if rawValue, ok := rawHTMLVariables[name]; ok { + return rawValue + } + } + } if strings.HasSuffix(name, "_url") && !isSafeNotificationEmailURL(value) { value = "" } @@ -603,6 +697,10 @@ func renderNotificationEmailString(event, raw string, variables map[string]strin return rendered, nil } +func notificationEmailRawHTMLAllowed(event, placeholder string) bool { + return event == NotificationEmailEventOpsScheduledReport && placeholder == "report_html" +} + func notificationEmailAllowedPlaceholderSet(event string) map[string]struct{} { info := notificationEmailEventDefinitions[event] allowed := make(map[string]struct{}, len(info.Placeholders)) @@ -712,46 +810,138 @@ func isSafeNotificationEmailURL(raw string) bool { 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", + "site_name": defaultSiteName, + "recipient_name": "张三", + "recipient_email": "user@example.com", + "verification_code": "123456", + "expires_in_minutes": "15", + "reset_url": "https://example.com/reset-password?token=preview", + "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", + "account_id": "1001", + "account_name": "openai-main", + "platform": "openai", + "quota_dimension": "每日额度", + "quota_used": "80.00", + "quota_limit": "100.00", + "quota_remaining": "20.00", + "quota_threshold": "20%", + "triggered_at": "2026-05-20 12:00:00", + "group_name": "默认分组", + "moderation_category": "violence", + "moderation_score": "0.982", + "violation_count": "2", + "ban_threshold": "3", + "rule_name": "错误率过高", + "severity": "critical", + "alert_status": "firing", + "metric_type": "error_rate", + "operator": ">=", + "metric_value": "12.50", + "threshold_value": "10.00", + "alert_description": "最近 10 分钟错误率超过阈值", + "report_name": "日报", + "report_type": "daily_summary", + "report_start_time": "2026-05-19 12:00", + "report_end_time": "2026-05-20 12:00", + "report_html": "
请求量:1024
", } } 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", + "site_name": defaultSiteName, + "recipient_name": "Alex", + "recipient_email": "user@example.com", + "verification_code": "123456", + "expires_in_minutes": "15", + "reset_url": "https://example.com/reset-password?token=preview", + "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", + "account_id": "1001", + "account_name": "openai-main", + "platform": "openai", + "quota_dimension": "Daily quota", + "quota_used": "80.00", + "quota_limit": "100.00", + "quota_remaining": "20.00", + "quota_threshold": "20%", + "triggered_at": "2026-05-20 12:00:00", + "group_name": "Default group", + "moderation_category": "violence", + "moderation_score": "0.982", + "violation_count": "2", + "ban_threshold": "3", + "rule_name": "High error rate", + "severity": "critical", + "alert_status": "firing", + "metric_type": "error_rate", + "operator": ">=", + "metric_value": "12.50", + "threshold_value": "10.00", + "alert_description": "Error rate exceeded threshold in the last 10 minutes.", + "report_name": "Daily summary", + "report_type": "daily_summary", + "report_start_time": "2026-05-19 12:00", + "report_end_time": "2026-05-20 12:00", + "report_html": "Requests: 1024
", } } var notificationEmailEventOrder = []string{ + NotificationEmailEventAuthVerifyCode, + NotificationEmailEventAuthPasswordReset, + NotificationEmailEventNotificationEmailVerifyCode, NotificationEmailEventSubscriptionPurchaseSuccess, NotificationEmailEventSubscriptionExpiryReminder, NotificationEmailEventBalanceLow, NotificationEmailEventBalanceRechargeSuccess, + NotificationEmailEventAccountQuotaAlert, + NotificationEmailEventContentModerationViolation, + NotificationEmailEventContentModerationDisabled, + NotificationEmailEventOpsAlert, + NotificationEmailEventOpsScheduledReport, } var notificationEmailEventDefinitions = map[string]NotificationEmailEventInfo{ + NotificationEmailEventAuthVerifyCode: { + Event: NotificationEmailEventAuthVerifyCode, + Label: "Email verification code", + Description: "Sent for registration, email binding, OAuth pending email, and TOTP verification flows.", + Category: "auth", + Optional: false, + Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "verification_code", "expires_in_minutes"), + }, + NotificationEmailEventAuthPasswordReset: { + Event: NotificationEmailEventAuthPasswordReset, + Label: "Password reset", + Description: "Sent when a user requests a password reset link.", + Category: "auth", + Optional: false, + Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "reset_url", "expires_in_minutes"), + }, + NotificationEmailEventNotificationEmailVerifyCode: { + Event: NotificationEmailEventNotificationEmailVerifyCode, + Label: "Notification email verification code", + Description: "Sent when a user verifies an extra notification email address.", + Category: "auth", + Optional: false, + Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "verification_code", "expires_in_minutes"), + }, NotificationEmailEventSubscriptionPurchaseSuccess: { Event: NotificationEmailEventSubscriptionPurchaseSuccess, Label: "Subscription purchase success", @@ -784,9 +974,117 @@ var notificationEmailEventDefinitions = map[string]NotificationEmailEventInfo{ Optional: false, Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "recharge_amount", "current_balance", "order_id"), }, + NotificationEmailEventAccountQuotaAlert: { + Event: NotificationEmailEventAccountQuotaAlert, + Label: "Account quota alert", + Description: "Sent to configured admin notification emails when an upstream account quota threshold is crossed.", + Category: "admin", + Optional: false, + Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), + "account_id", "account_name", "platform", "quota_dimension", "quota_used", "quota_limit", "quota_remaining", "quota_threshold"), + }, + NotificationEmailEventContentModerationViolation: { + Event: NotificationEmailEventContentModerationViolation, + Label: "Risk control violation notice", + Description: "Sent to users when a request triggers content moderation/risk control rules.", + Category: "risk_control", + Optional: false, + Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), + "triggered_at", "group_name", "moderation_category", "moderation_score", "violation_count", "ban_threshold"), + }, + NotificationEmailEventContentModerationDisabled: { + Event: NotificationEmailEventContentModerationDisabled, + Label: "Risk control account disabled", + Description: "Sent to users when content moderation automatically disables their account.", + Category: "risk_control", + Optional: false, + Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), + "triggered_at", "group_name", "moderation_category", "moderation_score", "violation_count", "ban_threshold"), + }, + NotificationEmailEventOpsAlert: { + Event: NotificationEmailEventOpsAlert, + Label: "Ops alert", + Description: "Sent to configured operations recipients when an ops alert rule fires.", + Category: "ops", + Optional: false, + Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), + "rule_name", "severity", "alert_status", "metric_type", "operator", "metric_value", "threshold_value", "triggered_at", "alert_description"), + }, + NotificationEmailEventOpsScheduledReport: { + Event: NotificationEmailEventOpsScheduledReport, + Label: "Ops scheduled report", + Description: "Sent to configured operations recipients for scheduled daily/weekly/error/account-health reports.", + Category: "ops", + Optional: false, + Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), + "report_name", "report_type", "report_start_time", "report_end_time", "report_html"), + }, } var notificationEmailOfficialTemplates = map[string]map[string]notificationEmailOfficialTemplate{ + NotificationEmailEventAuthVerifyCode: { + notificationEmailDefaultLocale: { + Subject: "[{{site_name}}] Email verification code", + HTML: notificationEmailCard("#4f46e5", "Email verification code", ` +Hello {{recipient_name}},
+Your verification code is:
+{{verification_code}}
+This code expires in {{expires_in_minutes}} minutes.
+If you did not request this code, please ignore this email.
`), + }, + notificationEmailLocaleChinese: { + Subject: "[{{site_name}}] 邮箱验证码", + HTML: notificationEmailCard("#4f46e5", "邮箱验证码", ` +{{recipient_name}},您好:
+您的验证码是:
+{{verification_code}}
+验证码将在 {{expires_in_minutes}} 分钟后失效。
+如果不是您本人操作,请忽略此邮件。
`), + }, + }, + NotificationEmailEventAuthPasswordReset: { + notificationEmailDefaultLocale: { + Subject: "[{{site_name}}] Password reset request", + HTML: notificationEmailCard("#7c3aed", "Password reset", ` +Hello {{recipient_name}},
+We received a request to reset your password. Click the button below to set a new password.
+ +This link expires in {{expires_in_minutes}} minutes.
+If the button does not work, copy this link into your browser:
{{reset_url}}
If you did not request this, you can safely ignore this email.
`), + }, + notificationEmailLocaleChinese: { + Subject: "[{{site_name}}] 密码重置请求", + HTML: notificationEmailCard("#7c3aed", "密码重置", ` +{{recipient_name}},您好:
+我们收到了您的密码重置请求,请点击下方按钮设置新密码。
+ +此链接将在 {{expires_in_minutes}} 分钟后失效。
+如果按钮无法点击,请复制以下链接到浏览器中打开:
{{reset_url}}
如果不是您本人操作,请忽略此邮件。
`), + }, + }, + NotificationEmailEventNotificationEmailVerifyCode: { + notificationEmailDefaultLocale: { + Subject: "[{{site_name}}] Notification email verification code", + HTML: notificationEmailCard("#0ea5e9", "Notification email verification", ` +Hello {{recipient_name}},
+You are adding this address as an extra notification email.
+Your verification code is:
+{{verification_code}}
+This code expires in {{expires_in_minutes}} minutes.
+If you did not request this code, please ignore this email.
`), + }, + notificationEmailLocaleChinese: { + Subject: "[{{site_name}}] 通知邮箱验证码", + HTML: notificationEmailCard("#0ea5e9", "通知邮箱验证", ` +{{recipient_name}},您好:
+您正在添加额外的通知邮箱,请输入以下验证码完成验证。
+{{verification_code}}
+验证码将在 {{expires_in_minutes}} 分钟后失效。
+如果不是您本人操作,请忽略此邮件。
`), + }, + }, NotificationEmailEventSubscriptionPurchaseSuccess: { notificationEmailDefaultLocale: { Subject: "[{{site_name}}] Subscription purchase successful", @@ -858,7 +1156,131 @@ var notificationEmailOfficialTemplates = map[string]map[string]notificationEmail{{recipient_name}},您好:
您的余额充值 ${{recharge_amount}} 已完成。
当前余额:${{current_balance}}
-订单号:{{order_id}}
`), +订单号:{{order_id}}
`), + }, + }, + NotificationEmailEventAccountQuotaAlert: { + notificationEmailDefaultLocale: { + Subject: "[{{site_name}}] Account quota alert - {{account_name}}", + HTML: notificationEmailCard("#dc2626", "Account quota alert", ` +The upstream account {{account_name}} has crossed its configured quota alert threshold.
+| Account ID | {{account_id}} |
| Platform | {{platform}} |
| Dimension | {{quota_dimension}} |
| Used / Limit | {{quota_used}} / {{quota_limit}} |
| Remaining | {{quota_remaining}} |
| Threshold | {{quota_threshold}} |
上游账号 {{account_name}} 已触发配置的额度告警阈值。
+| 账号 ID | {{account_id}} |
| 平台 | {{platform}} |
| 维度 | {{quota_dimension}} |
| 已用 / 限额 | {{quota_used}} / {{quota_limit}} |
| 剩余额度 | {{quota_remaining}} |
| 告警阈值 | {{quota_threshold}} |
Hello {{recipient_name}},
+Your API request triggered the platform content moderation/risk-control policy.
+| Triggered at | {{triggered_at}} |
| Group | {{group_name}} |
| Category / Score | {{moderation_category}} / {{moderation_score}} |
| Violation count | {{violation_count}} / {{ban_threshold}} |
Please review your request content to avoid future service interruptions.
`), + }, + notificationEmailLocaleChinese: { + Subject: "[{{site_name}}] 账户风控提醒", + HTML: notificationEmailCard("#ef4444", "账户风控提醒", ` +{{recipient_name}},您好:
+您的 API 请求触发了平台内容审核/风控策略。
+| 触发时间 | {{triggered_at}} |
| 所属分组 | {{group_name}} |
| 命中类别 / 分数 | {{moderation_category}} / {{moderation_score}} |
| 累计触发次数 | {{violation_count}} / {{ban_threshold}} |
请检查请求内容,避免后续服务受到影响。
`), + }, + }, + NotificationEmailEventContentModerationDisabled: { + notificationEmailDefaultLocale: { + Subject: "[{{site_name}}] Account disabled by risk control", + HTML: notificationEmailCard("#b91c1c", "Account disabled", ` +Hello {{recipient_name}},
+Your account has repeatedly triggered platform content moderation/risk-control rules and has been automatically disabled.
+| Disabled at | {{triggered_at}} |
| Group | {{group_name}} |
| Category / Score | {{moderation_category}} / {{moderation_score}} |
| Violation count | {{violation_count}} / {{ban_threshold}} |
Please contact the administrator if you need to appeal or restore access.
`), + }, + notificationEmailLocaleChinese: { + Subject: "[{{site_name}}] 账户已被禁用", + HTML: notificationEmailCard("#b91c1c", "账户已被禁用", ` +{{recipient_name}},您好:
+您的账户在统计周期内多次触发平台内容审核/风控规则,系统已自动禁用该账户。
+| 禁用时间 | {{triggered_at}} |
| 所属分组 | {{group_name}} |
| 命中类别 / 分数 | {{moderation_category}} / {{moderation_score}} |
| 累计触发次数 | {{violation_count}} / {{ban_threshold}} |
如需申诉或恢复账号,请联系平台管理员处理。
`), + }, + }, + NotificationEmailEventOpsAlert: { + notificationEmailDefaultLocale: { + Subject: "[Ops Alert][{{severity}}] {{rule_name}}", + HTML: notificationEmailCard("#ea580c", "Ops alert", ` +Rule: {{rule_name}}
+Severity: {{severity}}
+Status: {{alert_status}}
+Metric: {{metric_type}} {{operator}} {{metric_value}} (threshold {{threshold_value}})
+Fired at: {{triggered_at}}
+Description: {{alert_description}}
`), + }, + notificationEmailLocaleChinese: { + Subject: "[运维告警][{{severity}}] {{rule_name}}", + HTML: notificationEmailCard("#ea580c", "运维告警", ` +规则:{{rule_name}}
+严重级别:{{severity}}
+状态:{{alert_status}}
+指标:{{metric_type}} {{operator}} {{metric_value}}(阈值 {{threshold_value}})
+触发时间:{{triggered_at}}
+说明:{{alert_description}}
`), + }, + }, + NotificationEmailEventOpsScheduledReport: { + notificationEmailDefaultLocale: { + Subject: "[Ops Report] {{report_name}}", + HTML: notificationEmailCard("#0891b2", "Ops report", ` +Report: {{report_name}}
+Type: {{report_type}}
+Range: {{report_start_time}} - {{report_end_time}}
+报表:{{report_name}}
+类型:{{report_type}}
+时间范围:{{report_start_time}} - {{report_end_time}}
+{{recipient_name}}
`, + map[string]string{ + "recipient_name": ``, + "report_html": `escaped report
`, + }, + map[string]string{ + "report_html": `| trusted report |
| trusted report |
{{recipient_name}}
`, + map[string]string{"recipient_name": `escaped`}, + map[string]string{"recipient_name": `raw`}, + ) + require.NoError(t, err) + require.Contains(t, preview.HTML, `<em>escaped</em>`) + require.NotContains(t, preview.HTML, `raw`) +} + +func TestNotificationEmailFallbackClassification(t *testing.T) { + templateErr := notificationEmailTemplateErr(errors.New("bad template")) + configErr := notificationEmailConfigErr(errors.New("missing email service")) + deliveryErr := notificationEmailDeliveryErr(errors.New("smtp timeout")) + + require.True(t, shouldFallbackNotificationEmail(templateErr)) + require.True(t, shouldFallbackNotificationEmail(configErr)) + require.False(t, shouldFallbackNotificationEmail(deliveryErr)) + require.True(t, isNotificationEmailDeliveryError(deliveryErr)) + require.False(t, isNotificationEmailDeliveryError(templateErr)) + require.False(t, shouldFallbackNotificationEmail(nil)) +} + +func TestEmailQueueTasksPreserveLocaleHints(t *testing.T) { + queue := &EmailQueueService{taskChan: make(chan EmailTask, 2)} + require.NoError(t, queue.EnqueueVerifyCode("user@example.com", "Sub2API", "zh-CN")) + require.NoError(t, queue.EnqueuePasswordReset("user@example.com", "Sub2API", "https://example.com/reset", "en-US")) + + verifyTask := <-queue.taskChan + require.Equal(t, TaskTypeVerifyCode, verifyTask.TaskType) + require.Equal(t, "zh-CN", verifyTask.Locale) + + resetTask := <-queue.taskChan + require.Equal(t, TaskTypePasswordReset, resetTask.TaskType) + require.Equal(t, "en-US", resetTask.Locale) +} + +func TestOpsScheduledReportDeliverySourceIDIncludesReportIdentity(t *testing.T) { + report := &opsScheduledReport{Name: "日报", ReportType: "daily_summary", Schedule: "0 9 * * *"} + sourceID := opsScheduledReportDeliverySourceID(report) + require.Contains(t, sourceID, "daily_summary") + require.Contains(t, sourceID, "日报") + require.Contains(t, sourceID, "0 9 * * *") + require.NotEqual(t, sourceID, opsScheduledReportDeliverySourceID(&opsScheduledReport{Name: "周报", ReportType: "weekly_summary", Schedule: "0 9 * * 1"})) + require.Equal(t, "scheduled_report", opsScheduledReportDeliverySourceID(nil)) +} + func TestNotificationEmailUnsubscribeOnlyAllowsOptionalEvents(t *testing.T) { ctx := context.Background() svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil) @@ -96,6 +245,12 @@ func TestNotificationEmailUnsubscribeOnlyAllowsOptionalEvents(t *testing.T) { _, err = svc.Unsubscribe(ctx, transactionalToken) require.Error(t, err) require.Contains(t, err.Error(), "transactional") + + authToken, err := svc.createUnsubscribeToken(ctx, "user@example.com", NotificationEmailEventAuthVerifyCode) + require.NoError(t, err) + _, err = svc.Unsubscribe(ctx, authToken) + require.Error(t, err) + require.Contains(t, err.Error(), "transactional") } func TestNotificationEmailLocaleMemoryNormalizesAcceptLanguage(t *testing.T) {