From 7e0b22ceb6fcf8ad672185a223c23225e80714c5 Mon Sep 17 00:00:00 2001 From: benjamin Date: Wed, 20 May 2026 13:25:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(email):=20=E6=89=A9=E5=B1=95=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E6=A8=A1=E6=9D=BF=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../service/notification_email_service.go | 518 ++++++++++++++++-- .../notification_email_service_test.go | 155 ++++++ 2 files changed, 625 insertions(+), 48 deletions(-) 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": "

Daily summary

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.

+

Reset 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}}
`), + }, + notificationEmailLocaleChinese: { + Subject: "[{{site_name}}] 账号限额告警 - {{account_name}}", + HTML: notificationEmailCard("#dc2626", "账号限额告警", ` +

上游账号 {{account_name}} 已触发配置的额度告警阈值。

+ + + + + + + +
账号 ID{{account_id}}
平台{{platform}}
维度{{quota_dimension}}
已用 / 限额{{quota_used}} / {{quota_limit}}
剩余额度{{quota_remaining}}
告警阈值{{quota_threshold}}
`), + }, + }, + NotificationEmailEventContentModerationViolation: { + notificationEmailDefaultLocale: { + Subject: "[{{site_name}}] Risk control notice", + HTML: notificationEmailCard("#ef4444", "Risk control notice", ` +

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_html}}
`), + }, + notificationEmailLocaleChinese: { + Subject: "[运维报表] {{report_name}}", + HTML: notificationEmailCard("#0891b2", "运维报表", ` +

报表:{{report_name}}

+

类型:{{report_type}}

+

时间范围:{{report_start_time}} - {{report_end_time}}

+
{{report_html}}
`), }, }, } diff --git a/backend/internal/service/notification_email_service_test.go b/backend/internal/service/notification_email_service_test.go index 692b68ef..f375ba7e 100644 --- a/backend/internal/service/notification_email_service_test.go +++ b/backend/internal/service/notification_email_service_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "strings" "sync" "testing" @@ -77,6 +78,154 @@ func TestNotificationEmailTemplateRejectsUnsupportedPlaceholder(t *testing.T) { require.Contains(t, err.Error(), "unsupported placeholder") } +func TestNotificationEmailAuthTemplatesAreListedAndPreviewable(t *testing.T) { + ctx := context.Background() + svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil) + + infos := svc.ListEventInfos() + events := make(map[string]NotificationEmailEventInfo, len(infos)) + for _, info := range infos { + events[info.Event] = info + } + require.Contains(t, events, NotificationEmailEventAuthVerifyCode) + require.Contains(t, events, NotificationEmailEventAuthPasswordReset) + require.False(t, events[NotificationEmailEventAuthVerifyCode].Optional) + require.False(t, events[NotificationEmailEventAuthPasswordReset].Optional) + require.Contains(t, events[NotificationEmailEventAuthVerifyCode].Placeholders, "verification_code") + require.Contains(t, events[NotificationEmailEventAuthPasswordReset].Placeholders, "reset_url") + + verifyPreview, err := svc.PreviewTemplate(ctx, NotificationEmailPreviewInput{ + Event: NotificationEmailEventAuthVerifyCode, + Locale: "zh-CN", + Variables: map[string]string{ + "verification_code": "654321", + "expires_in_minutes": "15", + }, + }) + require.NoError(t, err) + require.Contains(t, verifyPreview.Subject, "邮箱验证码") + require.Contains(t, verifyPreview.HTML, "654321") + + resetPreview, err := svc.PreviewTemplate(ctx, NotificationEmailPreviewInput{ + Event: NotificationEmailEventAuthPasswordReset, + Locale: "en", + Variables: map[string]string{ + "reset_url": "https://example.com/reset?token=abc", + "expires_in_minutes": "30", + }, + }) + require.NoError(t, err) + require.Contains(t, resetPreview.Subject, "Password reset") + require.Contains(t, resetPreview.HTML, "https://example.com/reset?token=abc") +} + +func TestNotificationEmailAdditionalEventsAreListedAndPreviewable(t *testing.T) { + ctx := context.Background() + svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil) + + infos := svc.ListEventInfos() + events := make(map[string]NotificationEmailEventInfo, len(infos)) + for _, info := range infos { + events[info.Event] = info + } + + checks := []struct { + event string + placeholder string + }{ + {NotificationEmailEventNotificationEmailVerifyCode, "verification_code"}, + {NotificationEmailEventAccountQuotaAlert, "account_name"}, + {NotificationEmailEventContentModerationViolation, "moderation_category"}, + {NotificationEmailEventContentModerationDisabled, "violation_count"}, + {NotificationEmailEventOpsAlert, "rule_name"}, + {NotificationEmailEventOpsScheduledReport, "report_html"}, + } + + for _, check := range checks { + info, ok := events[check.event] + require.Truef(t, ok, "expected %s to be listed", check.event) + require.False(t, info.Optional) + require.Contains(t, info.Placeholders, check.placeholder) + + preview, err := svc.PreviewTemplate(ctx, NotificationEmailPreviewInput{Event: check.event, Locale: "zh"}) + require.NoError(t, err) + require.NotEmpty(t, preview.Subject) + require.NotEmpty(t, preview.HTML) + } +} + +func TestNotificationEmailRawHTMLVariablesAreTrustedOnlyForHTMLPlaceholders(t *testing.T) { + require.True(t, notificationEmailRawHTMLAllowed(NotificationEmailEventOpsScheduledReport, "report_html")) + require.False(t, notificationEmailRawHTMLAllowed(NotificationEmailEventOpsScheduledReport, "recipient_name")) + require.False(t, notificationEmailRawHTMLAllowed(NotificationEmailEventOpsAlert, "report_html")) + + preview, err := renderNotificationEmail( + NotificationEmailEventOpsScheduledReport, + "Report for {{recipient_name}}", + `
{{report_html}}

{{recipient_name}}

`, + map[string]string{ + "recipient_name": ``, + "report_html": `

escaped report

`, + }, + map[string]string{ + "report_html": `
trusted report
`, + }, + ) + require.NoError(t, err) + require.Contains(t, preview.HTML, `
trusted report
`) + require.NotContains(t, preview.HTML, `escaped report`) + require.Contains(t, preview.HTML, `<script>alert("x")</script>`) + require.Contains(t, preview.Subject, ``) + + preview, err = renderNotificationEmail( + NotificationEmailEventOpsScheduledReport, + "Recipient {{recipient_name}}", + `

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