feat(email): 扩展邮件模板错误处理
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
d72bf0897e
commit
7e0b22ceb6
@ -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": "<h2>日报</h2><p>请求量:1024</p>",
|
||||
}
|
||||
}
|
||||
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": "<h2>Daily summary</h2><p>Requests: 1024</p>",
|
||||
}
|
||||
}
|
||||
|
||||
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", `
|
||||
<p>Hello {{recipient_name}},</p>
|
||||
<p>Your verification code is:</p>
|
||||
<p style="font-size: 32px; font-weight: 700; letter-spacing: 8px; text-align: center;">{{verification_code}}</p>
|
||||
<p>This code expires in <strong>{{expires_in_minutes}}</strong> minutes.</p>
|
||||
<p>If you did not request this code, please ignore this email.</p>`),
|
||||
},
|
||||
notificationEmailLocaleChinese: {
|
||||
Subject: "[{{site_name}}] 邮箱验证码",
|
||||
HTML: notificationEmailCard("#4f46e5", "邮箱验证码", `
|
||||
<p>{{recipient_name}},您好:</p>
|
||||
<p>您的验证码是:</p>
|
||||
<p style="font-size: 32px; font-weight: 700; letter-spacing: 8px; text-align: center;">{{verification_code}}</p>
|
||||
<p>验证码将在 <strong>{{expires_in_minutes}}</strong> 分钟后失效。</p>
|
||||
<p>如果不是您本人操作,请忽略此邮件。</p>`),
|
||||
},
|
||||
},
|
||||
NotificationEmailEventAuthPasswordReset: {
|
||||
notificationEmailDefaultLocale: {
|
||||
Subject: "[{{site_name}}] Password reset request",
|
||||
HTML: notificationEmailCard("#7c3aed", "Password reset", `
|
||||
<p>Hello {{recipient_name}},</p>
|
||||
<p>We received a request to reset your password. Click the button below to set a new password.</p>
|
||||
<p><a class="button" href="{{reset_url}}">Reset password</a></p>
|
||||
<p>This link expires in <strong>{{expires_in_minutes}}</strong> minutes.</p>
|
||||
<p class="muted">If the button does not work, copy this link into your browser:<br>{{reset_url}}</p>
|
||||
<p>If you did not request this, you can safely ignore this email.</p>`),
|
||||
},
|
||||
notificationEmailLocaleChinese: {
|
||||
Subject: "[{{site_name}}] 密码重置请求",
|
||||
HTML: notificationEmailCard("#7c3aed", "密码重置", `
|
||||
<p>{{recipient_name}},您好:</p>
|
||||
<p>我们收到了您的密码重置请求,请点击下方按钮设置新密码。</p>
|
||||
<p><a class="button" href="{{reset_url}}">重置密码</a></p>
|
||||
<p>此链接将在 <strong>{{expires_in_minutes}}</strong> 分钟后失效。</p>
|
||||
<p class="muted">如果按钮无法点击,请复制以下链接到浏览器中打开:<br>{{reset_url}}</p>
|
||||
<p>如果不是您本人操作,请忽略此邮件。</p>`),
|
||||
},
|
||||
},
|
||||
NotificationEmailEventNotificationEmailVerifyCode: {
|
||||
notificationEmailDefaultLocale: {
|
||||
Subject: "[{{site_name}}] Notification email verification code",
|
||||
HTML: notificationEmailCard("#0ea5e9", "Notification email verification", `
|
||||
<p>Hello {{recipient_name}},</p>
|
||||
<p>You are adding this address as an extra notification email.</p>
|
||||
<p>Your verification code is:</p>
|
||||
<p style="font-size: 32px; font-weight: 700; letter-spacing: 8px; text-align: center;">{{verification_code}}</p>
|
||||
<p>This code expires in <strong>{{expires_in_minutes}}</strong> minutes.</p>
|
||||
<p>If you did not request this code, please ignore this email.</p>`),
|
||||
},
|
||||
notificationEmailLocaleChinese: {
|
||||
Subject: "[{{site_name}}] 通知邮箱验证码",
|
||||
HTML: notificationEmailCard("#0ea5e9", "通知邮箱验证", `
|
||||
<p>{{recipient_name}},您好:</p>
|
||||
<p>您正在添加额外的通知邮箱,请输入以下验证码完成验证。</p>
|
||||
<p style="font-size: 32px; font-weight: 700; letter-spacing: 8px; text-align: center;">{{verification_code}}</p>
|
||||
<p>验证码将在 <strong>{{expires_in_minutes}}</strong> 分钟后失效。</p>
|
||||
<p>如果不是您本人操作,请忽略此邮件。</p>`),
|
||||
},
|
||||
},
|
||||
NotificationEmailEventSubscriptionPurchaseSuccess: {
|
||||
notificationEmailDefaultLocale: {
|
||||
Subject: "[{{site_name}}] Subscription purchase successful",
|
||||
@ -858,7 +1156,131 @@ var notificationEmailOfficialTemplates = map[string]map[string]notificationEmail
|
||||
<p>{{recipient_name}},您好:</p>
|
||||
<p>您的余额充值 <strong>${{recharge_amount}}</strong> 已完成。</p>
|
||||
<p>当前余额:<strong>${{current_balance}}</strong></p>
|
||||
<p>订单号:{{order_id}}</p>`),
|
||||
<p>订单号:{{order_id}}</p>`),
|
||||
},
|
||||
},
|
||||
NotificationEmailEventAccountQuotaAlert: {
|
||||
notificationEmailDefaultLocale: {
|
||||
Subject: "[{{site_name}}] Account quota alert - {{account_name}}",
|
||||
HTML: notificationEmailCard("#dc2626", "Account quota alert", `
|
||||
<p>The upstream account <strong>{{account_name}}</strong> has crossed its configured quota alert threshold.</p>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td>Account ID</td><td>{{account_id}}</td></tr>
|
||||
<tr><td>Platform</td><td>{{platform}}</td></tr>
|
||||
<tr><td>Dimension</td><td>{{quota_dimension}}</td></tr>
|
||||
<tr><td>Used / Limit</td><td>{{quota_used}} / {{quota_limit}}</td></tr>
|
||||
<tr><td>Remaining</td><td>{{quota_remaining}}</td></tr>
|
||||
<tr><td>Threshold</td><td>{{quota_threshold}}</td></tr>
|
||||
</table>`),
|
||||
},
|
||||
notificationEmailLocaleChinese: {
|
||||
Subject: "[{{site_name}}] 账号限额告警 - {{account_name}}",
|
||||
HTML: notificationEmailCard("#dc2626", "账号限额告警", `
|
||||
<p>上游账号 <strong>{{account_name}}</strong> 已触发配置的额度告警阈值。</p>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td>账号 ID</td><td>{{account_id}}</td></tr>
|
||||
<tr><td>平台</td><td>{{platform}}</td></tr>
|
||||
<tr><td>维度</td><td>{{quota_dimension}}</td></tr>
|
||||
<tr><td>已用 / 限额</td><td>{{quota_used}} / {{quota_limit}}</td></tr>
|
||||
<tr><td>剩余额度</td><td>{{quota_remaining}}</td></tr>
|
||||
<tr><td>告警阈值</td><td>{{quota_threshold}}</td></tr>
|
||||
</table>`),
|
||||
},
|
||||
},
|
||||
NotificationEmailEventContentModerationViolation: {
|
||||
notificationEmailDefaultLocale: {
|
||||
Subject: "[{{site_name}}] Risk control notice",
|
||||
HTML: notificationEmailCard("#ef4444", "Risk control notice", `
|
||||
<p>Hello {{recipient_name}},</p>
|
||||
<p>Your API request triggered the platform content moderation/risk-control policy.</p>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td>Triggered at</td><td>{{triggered_at}}</td></tr>
|
||||
<tr><td>Group</td><td>{{group_name}}</td></tr>
|
||||
<tr><td>Category / Score</td><td>{{moderation_category}} / {{moderation_score}}</td></tr>
|
||||
<tr><td>Violation count</td><td>{{violation_count}} / {{ban_threshold}}</td></tr>
|
||||
</table>
|
||||
<p>Please review your request content to avoid future service interruptions.</p>`),
|
||||
},
|
||||
notificationEmailLocaleChinese: {
|
||||
Subject: "[{{site_name}}] 账户风控提醒",
|
||||
HTML: notificationEmailCard("#ef4444", "账户风控提醒", `
|
||||
<p>{{recipient_name}},您好:</p>
|
||||
<p>您的 API 请求触发了平台内容审核/风控策略。</p>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td>触发时间</td><td>{{triggered_at}}</td></tr>
|
||||
<tr><td>所属分组</td><td>{{group_name}}</td></tr>
|
||||
<tr><td>命中类别 / 分数</td><td>{{moderation_category}} / {{moderation_score}}</td></tr>
|
||||
<tr><td>累计触发次数</td><td>{{violation_count}} / {{ban_threshold}}</td></tr>
|
||||
</table>
|
||||
<p>请检查请求内容,避免后续服务受到影响。</p>`),
|
||||
},
|
||||
},
|
||||
NotificationEmailEventContentModerationDisabled: {
|
||||
notificationEmailDefaultLocale: {
|
||||
Subject: "[{{site_name}}] Account disabled by risk control",
|
||||
HTML: notificationEmailCard("#b91c1c", "Account disabled", `
|
||||
<p>Hello {{recipient_name}},</p>
|
||||
<p>Your account has repeatedly triggered platform content moderation/risk-control rules and has been automatically disabled.</p>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td>Disabled at</td><td>{{triggered_at}}</td></tr>
|
||||
<tr><td>Group</td><td>{{group_name}}</td></tr>
|
||||
<tr><td>Category / Score</td><td>{{moderation_category}} / {{moderation_score}}</td></tr>
|
||||
<tr><td>Violation count</td><td>{{violation_count}} / {{ban_threshold}}</td></tr>
|
||||
</table>
|
||||
<p>Please contact the administrator if you need to appeal or restore access.</p>`),
|
||||
},
|
||||
notificationEmailLocaleChinese: {
|
||||
Subject: "[{{site_name}}] 账户已被禁用",
|
||||
HTML: notificationEmailCard("#b91c1c", "账户已被禁用", `
|
||||
<p>{{recipient_name}},您好:</p>
|
||||
<p>您的账户在统计周期内多次触发平台内容审核/风控规则,系统已自动禁用该账户。</p>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td>禁用时间</td><td>{{triggered_at}}</td></tr>
|
||||
<tr><td>所属分组</td><td>{{group_name}}</td></tr>
|
||||
<tr><td>命中类别 / 分数</td><td>{{moderation_category}} / {{moderation_score}}</td></tr>
|
||||
<tr><td>累计触发次数</td><td>{{violation_count}} / {{ban_threshold}}</td></tr>
|
||||
</table>
|
||||
<p>如需申诉或恢复账号,请联系平台管理员处理。</p>`),
|
||||
},
|
||||
},
|
||||
NotificationEmailEventOpsAlert: {
|
||||
notificationEmailDefaultLocale: {
|
||||
Subject: "[Ops Alert][{{severity}}] {{rule_name}}",
|
||||
HTML: notificationEmailCard("#ea580c", "Ops alert", `
|
||||
<p><strong>Rule</strong>: {{rule_name}}</p>
|
||||
<p><strong>Severity</strong>: {{severity}}</p>
|
||||
<p><strong>Status</strong>: {{alert_status}}</p>
|
||||
<p><strong>Metric</strong>: {{metric_type}} {{operator}} {{metric_value}} (threshold {{threshold_value}})</p>
|
||||
<p><strong>Fired at</strong>: {{triggered_at}}</p>
|
||||
<p><strong>Description</strong>: {{alert_description}}</p>`),
|
||||
},
|
||||
notificationEmailLocaleChinese: {
|
||||
Subject: "[运维告警][{{severity}}] {{rule_name}}",
|
||||
HTML: notificationEmailCard("#ea580c", "运维告警", `
|
||||
<p><strong>规则</strong>:{{rule_name}}</p>
|
||||
<p><strong>严重级别</strong>:{{severity}}</p>
|
||||
<p><strong>状态</strong>:{{alert_status}}</p>
|
||||
<p><strong>指标</strong>:{{metric_type}} {{operator}} {{metric_value}}(阈值 {{threshold_value}})</p>
|
||||
<p><strong>触发时间</strong>:{{triggered_at}}</p>
|
||||
<p><strong>说明</strong>:{{alert_description}}</p>`),
|
||||
},
|
||||
},
|
||||
NotificationEmailEventOpsScheduledReport: {
|
||||
notificationEmailDefaultLocale: {
|
||||
Subject: "[Ops Report] {{report_name}}",
|
||||
HTML: notificationEmailCard("#0891b2", "Ops report", `
|
||||
<p><strong>Report</strong>: {{report_name}}</p>
|
||||
<p><strong>Type</strong>: {{report_type}}</p>
|
||||
<p><strong>Range</strong>: {{report_start_time}} - {{report_end_time}}</p>
|
||||
<div>{{report_html}}</div>`),
|
||||
},
|
||||
notificationEmailLocaleChinese: {
|
||||
Subject: "[运维报表] {{report_name}}",
|
||||
HTML: notificationEmailCard("#0891b2", "运维报表", `
|
||||
<p><strong>报表</strong>:{{report_name}}</p>
|
||||
<p><strong>类型</strong>:{{report_type}}</p>
|
||||
<p><strong>时间范围</strong>:{{report_start_time}} - {{report_end_time}}</p>
|
||||
<div>{{report_html}}</div>`),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -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}}",
|
||||
`<section>{{report_html}}</section><p>{{recipient_name}}</p>`,
|
||||
map[string]string{
|
||||
"recipient_name": `<script>alert("x")</script>`,
|
||||
"report_html": `<p>escaped report</p>`,
|
||||
},
|
||||
map[string]string{
|
||||
"report_html": `<table><tr><td>trusted report</td></tr></table>`,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, preview.HTML, `<table><tr><td>trusted report</td></tr></table>`)
|
||||
require.NotContains(t, preview.HTML, `escaped report`)
|
||||
require.Contains(t, preview.HTML, `<script>alert("x")</script>`)
|
||||
require.Contains(t, preview.Subject, `<script>alert("x")</script>`)
|
||||
|
||||
preview, err = renderNotificationEmail(
|
||||
NotificationEmailEventOpsScheduledReport,
|
||||
"Recipient {{recipient_name}}",
|
||||
`<p>{{recipient_name}}</p>`,
|
||||
map[string]string{"recipient_name": `<em>escaped</em>`},
|
||||
map[string]string{"recipient_name": `<strong>raw</strong>`},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, preview.HTML, `<em>escaped</em>`)
|
||||
require.NotContains(t, preview.HTML, `<strong>raw</strong>`)
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user