package service import ( "bufio" "context" "errors" "net" "strings" "sync" "sync/atomic" "testing" "github.com/stretchr/testify/require" ) func TestNotificationEmailPreviewEscapesHTMLAndSanitizesSubject(t *testing.T) { ctx := context.Background() svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil) preview, err := svc.PreviewTemplate(ctx, NotificationEmailPreviewInput{ Event: NotificationEmailEventBalanceLow, Locale: "en-US,en;q=0.9", Subject: "Low balance for {{recipient_name}}\r\nInjected", HTML: `

{{recipient_name}}

Recharge`, Variables: map[string]string{ "recipient_name": ``, "recharge_url": `javascript:alert(1)`, }, }) require.NoError(t, err) require.NotContains(t, preview.Subject, "\r") require.NotContains(t, preview.Subject, "\n") require.Contains(t, preview.Subject, `Low balance for Injected`) require.Contains(t, preview.HTML, `<script>alert("x")</script>`) require.NotContains(t, preview.HTML, `javascript:alert(1)`) require.Contains(t, preview.HTML, `href=""`) } func TestNotificationEmailTemplateOverrideAndRestore(t *testing.T) { ctx := context.Background() repo := newNotificationEmailMemorySettingRepo() svc := NewNotificationEmailService(repo, nil) official, err := svc.GetTemplate(ctx, NotificationEmailEventBalanceRechargeSuccess, "en") require.NoError(t, err) require.False(t, official.IsCustom) updated, err := svc.UpdateTemplate( ctx, NotificationEmailEventBalanceRechargeSuccess, "zh-Hans", "充值完成:{{recharge_amount}}", "

{{recipient_name}} 已充值 {{recharge_amount}}

", ) require.NoError(t, err) require.True(t, updated.IsCustom) require.Equal(t, "zh", updated.Locale) require.Equal(t, "充值完成:{{recharge_amount}}", updated.Subject) require.NotNil(t, updated.UpdatedAt) restored, err := svc.RestoreOfficialTemplate(ctx, NotificationEmailEventBalanceRechargeSuccess, "zh") require.NoError(t, err) require.False(t, restored.IsCustom) require.NotEqual(t, updated.Subject, restored.Subject) _, err = repo.GetValue(ctx, notificationEmailTemplateKey(NotificationEmailEventBalanceRechargeSuccess, "zh")) require.ErrorIs(t, err, ErrSettingNotFound) } func TestNotificationEmailTemplateRejectsUnsupportedPlaceholder(t *testing.T) { ctx := context.Background() svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil) _, err := svc.UpdateTemplate( ctx, NotificationEmailEventSubscriptionPurchaseSuccess, "en", "Purchased {{not_allowed}}", "

{{subscription_group}}

", ) require.Error(t, err) 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) token, err := svc.createUnsubscribeToken(ctx, "User@Example.com", NotificationEmailEventBalanceLow) require.NoError(t, err) result, err := svc.Unsubscribe(ctx, token) require.NoError(t, err) require.True(t, result.Done) require.Equal(t, NotificationEmailEventBalanceLow, result.Event) unsubscribed, err := svc.IsUnsubscribed(ctx, "user@example.com", NotificationEmailEventBalanceLow) require.NoError(t, err) require.True(t, unsubscribed) transactionalToken, err := svc.createUnsubscribeToken(ctx, "user@example.com", NotificationEmailEventBalanceRechargeSuccess) require.NoError(t, err) _, 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) { ctx := context.Background() svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil) svc.RememberRecipientLocale(ctx, 42, "User@Example.com", "zh-CN,zh;q=0.9,en;q=0.8") require.Equal(t, "zh", svc.ResolveRecipientLocale(ctx, 42, "user@example.com")) require.Equal(t, "zh", svc.ResolveRecipientLocale(ctx, 0, "user@example.com")) } func TestNotificationEmailDeliveryKeyUsesShortStableHash(t *testing.T) { key := notificationEmailDeliveryKey( NotificationEmailEventSubscriptionExpiryReminder, "user_subscription", "1234567890", "User@Example.com", "7d", ) require.NotEmpty(t, key) require.LessOrEqual(t, len(key), 100) require.True(t, strings.HasPrefix(key, notificationEmailDeliveryKeyPrefix+"v2:")) require.Equal(t, key, notificationEmailDeliveryKey( NotificationEmailEventSubscriptionExpiryReminder, "user_subscription", "1234567890", "user@example.com", "7d", )) require.NotEqual(t, key, notificationEmailDeliveryKey( NotificationEmailEventSubscriptionExpiryReminder, "user_subscription", "1234567890", "user@example.com", "3d", )) legacyKey := legacyNotificationEmailDeliveryKey( NotificationEmailEventSubscriptionExpiryReminder, "user_subscription", "1234567890", "user@example.com", "7d", ) require.Greater(t, len(legacyKey), 100) } func TestNotificationEmailPreferenceKeyUsesShortStableHashAndReadsLegacyKey(t *testing.T) { ctx := context.Background() repo := newNotificationEmailMemorySettingRepo() svc := NewNotificationEmailService(repo, nil) key := notificationEmailPreferenceKey(NotificationEmailEventSubscriptionExpiryReminder, "User@Example.com") require.NotEmpty(t, key) require.LessOrEqual(t, len(key), 100) require.True(t, strings.HasPrefix(key, notificationEmailPreferenceKeyPrefix+"v2:")) require.Equal(t, key, notificationEmailPreferenceKey(NotificationEmailEventSubscriptionExpiryReminder, "user@example.com")) legacyKey := legacyNotificationEmailPreferenceKey(NotificationEmailEventSubscriptionExpiryReminder, "user@example.com") require.Greater(t, len(legacyKey), 100) require.NoError(t, repo.Set(ctx, legacyKey, "unsubscribed")) unsubscribed, err := svc.IsUnsubscribed(ctx, "User@Example.com", NotificationEmailEventSubscriptionExpiryReminder) require.NoError(t, err) require.True(t, unsubscribed) } func TestNotificationEmailSendDeduplicatesSubscriptionExpiryReminder(t *testing.T) { ctx := context.Background() repo := newNotificationEmailMemorySettingRepo() smtpServer := startNotificationEmailTestSMTPServer(t) require.NoError(t, repo.SetMultiple(ctx, smtpServer.settings())) emailSvc := NewEmailService(repo, nil) svc := NewNotificationEmailService(repo, emailSvc) input := NotificationEmailSendInput{ Event: NotificationEmailEventSubscriptionExpiryReminder, RecipientEmail: "User@Example.com", RecipientName: "User", UserID: 42, SourceType: "user_subscription", SourceID: "1234567890", ReminderKey: "7d", Variables: map[string]string{ "subscription_group": "Codex", "expiry_time": "2026-05-27 12:00", "days_remaining": "7", }, } require.NoError(t, svc.Send(ctx, input)) require.Equal(t, int64(1), smtpServer.messageCount()) key := notificationEmailDeliveryKey(input.Event, input.SourceType, input.SourceID, input.RecipientEmail, input.ReminderKey) require.LessOrEqual(t, len(key), 100) _, err := repo.GetValue(ctx, key) require.NoError(t, err) require.NoError(t, svc.Send(ctx, input)) require.Equal(t, int64(1), smtpServer.messageCount()) } func TestNotificationEmailSendRespectsLegacyDeliveryKey(t *testing.T) { ctx := context.Background() repo := newNotificationEmailMemorySettingRepo() svc := NewNotificationEmailService(repo, nil) input := NotificationEmailSendInput{ Event: NotificationEmailEventSubscriptionExpiryReminder, RecipientEmail: "user@example.com", SourceType: "user_subscription", SourceID: "1234567890", ReminderKey: "7d", } legacyKey := legacyNotificationEmailDeliveryKey(input.Event, input.SourceType, input.SourceID, input.RecipientEmail, input.ReminderKey) require.NoError(t, repo.Set(ctx, legacyKey, "sent")) require.NoError(t, svc.Send(ctx, input)) } type notificationEmailMemorySettingRepo struct { mu sync.RWMutex values map[string]string } func newNotificationEmailMemorySettingRepo() *notificationEmailMemorySettingRepo { return ¬ificationEmailMemorySettingRepo{values: make(map[string]string)} } func (r *notificationEmailMemorySettingRepo) Get(_ context.Context, key string) (*Setting, error) { r.mu.RLock() defer r.mu.RUnlock() value, ok := r.values[key] if !ok { return nil, ErrSettingNotFound } return &Setting{Key: key, Value: value}, nil } func (r *notificationEmailMemorySettingRepo) GetValue(ctx context.Context, key string) (string, error) { setting, err := r.Get(ctx, key) if err != nil { return "", err } return setting.Value, nil } func (r *notificationEmailMemorySettingRepo) Set(_ context.Context, key, value string) error { r.mu.Lock() defer r.mu.Unlock() r.values[key] = value return nil } func (r *notificationEmailMemorySettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { r.mu.RLock() defer r.mu.RUnlock() out := make(map[string]string, len(keys)) for _, key := range keys { if value, ok := r.values[key]; ok { out[key] = value } } return out, nil } func (r *notificationEmailMemorySettingRepo) SetMultiple(_ context.Context, settings map[string]string) error { r.mu.Lock() defer r.mu.Unlock() for key, value := range settings { r.values[key] = value } return nil } func (r *notificationEmailMemorySettingRepo) GetAll(_ context.Context) (map[string]string, error) { r.mu.RLock() defer r.mu.RUnlock() out := make(map[string]string, len(r.values)) for key, value := range r.values { out[key] = value } return out, nil } func (r *notificationEmailMemorySettingRepo) Delete(_ context.Context, key string) error { r.mu.Lock() defer r.mu.Unlock() if _, ok := r.values[key]; !ok { return ErrSettingNotFound } delete(r.values, key) return nil } func TestNotificationEmailMemorySettingRepoSatisfiesInterface(t *testing.T) { var _ SettingRepository = (*notificationEmailMemorySettingRepo)(nil) require.False(t, strings.Contains(notificationEmailPreferenceKey(NotificationEmailEventBalanceLow, "User@Example.com"), "User@Example.com")) } type notificationEmailTestSMTPServer struct { listener net.Listener wg sync.WaitGroup messages atomic.Int64 } func startNotificationEmailTestSMTPServer(t *testing.T) *notificationEmailTestSMTPServer { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) server := ¬ificationEmailTestSMTPServer{listener: listener} server.wg.Add(1) go server.serve() t.Cleanup(server.close) return server } func (s *notificationEmailTestSMTPServer) settings() map[string]string { host, port, _ := net.SplitHostPort(s.listener.Addr().String()) return map[string]string{ SettingKeySMTPHost: host, SettingKeySMTPPort: port, SettingKeySMTPUsername: "user", SettingKeySMTPPassword: "password", SettingKeySMTPFrom: "noreply@example.com", SettingKeySMTPFromName: "Sub2API", SettingKeySMTPUseTLS: "false", } } func (s *notificationEmailTestSMTPServer) messageCount() int64 { return s.messages.Load() } func (s *notificationEmailTestSMTPServer) close() { _ = s.listener.Close() s.wg.Wait() } func (s *notificationEmailTestSMTPServer) serve() { defer s.wg.Done() for { conn, err := s.listener.Accept() if err != nil { return } s.handleConn(conn) } } func (s *notificationEmailTestSMTPServer) handleConn(conn net.Conn) { defer func() { _ = conn.Close() }() rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)) writeLine := func(line string) bool { if _, err := rw.WriteString(line + "\r\n"); err != nil { return false } return rw.Flush() == nil } if !writeLine("220 localhost ESMTP") { return } for { line, err := rw.ReadString('\n') if err != nil { return } cmd := strings.ToUpper(strings.TrimRight(line, "\r\n")) switch { case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"): if _, err := rw.WriteString("250-localhost\r\n250 AUTH PLAIN\r\n"); err != nil { return } if err := rw.Flush(); err != nil { return } case strings.HasPrefix(cmd, "AUTH"): if !writeLine("235 2.7.0 Authentication successful") { return } case strings.HasPrefix(cmd, "MAIL FROM:"): if !writeLine("250 2.1.0 OK") { return } case strings.HasPrefix(cmd, "RCPT TO:"): if !writeLine("250 2.1.5 OK") { return } case strings.HasPrefix(cmd, "DATA"): if !writeLine("354 End data with .") { return } for { dataLine, err := rw.ReadString('\n') if err != nil { return } if strings.TrimRight(dataLine, "\r\n") == "." { break } } s.messages.Add(1) if !writeLine("250 2.0.0 OK") { return } case strings.HasPrefix(cmd, "QUIT"): _ = writeLine("221 2.0.0 Bye") return default: if !writeLine("250 OK") { return } } } }