diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 1e5625a6..af7e537d 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -186,7 +186,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { channelRepository := repository.NewChannelRepository(db) channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService) modelPricingResolver := service.NewModelPricingResolver(channelService, billingService) - balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository, accountRepository) + notificationEmailService := service.NewNotificationEmailService(settingRepository, emailService) + balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository, accountRepository, notificationEmailService) gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService) openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI) openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService) @@ -200,8 +201,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { paymentConfigService := service.ProvidePaymentConfigService(client, settingRepository, encryptionKey) registry := payment.ProvideRegistry() defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey) - paymentService := service.NewPaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService) - settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService) + paymentService := service.ProvidePaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService, notificationEmailService) + settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService) opsHandler := admin.NewOpsHandler(opsService) updateCache := repository.NewUpdateCache(redisClient) gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig) @@ -242,7 +243,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService) openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, configConfig) - handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo) + handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService) totpHandler := handler.NewTotpHandler(totpService) handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService) paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry) @@ -262,7 +263,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig) tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI) accountExpiryService := service.ProvideAccountExpiryService(accountRepository) - subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository) + subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, notificationEmailService) scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig) paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService) channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService) diff --git a/backend/go.sum b/backend/go.sum index db410b49..993e5086 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -162,6 +162,8 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index f73e4486..14f5dce0 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -56,13 +56,14 @@ func firstNonEmpty(values ...string) string { // SettingHandler 系统设置处理器 type SettingHandler struct { - settingService *service.SettingService - emailService *service.EmailService - turnstileService *service.TurnstileService - opsService *service.OpsService - paymentConfigService *service.PaymentConfigService - paymentService *service.PaymentService - userAttributeService *service.UserAttributeService + settingService *service.SettingService + emailService *service.EmailService + turnstileService *service.TurnstileService + opsService *service.OpsService + paymentConfigService *service.PaymentConfigService + paymentService *service.PaymentService + userAttributeService *service.UserAttributeService + notificationEmailService *service.NotificationEmailService } // NewSettingHandler 创建系统设置处理器 @@ -78,6 +79,12 @@ func NewSettingHandler(settingService *service.SettingService, emailService *ser } } +// SetNotificationEmailService attaches the notification template service without changing +// the constructor signature used by existing unit tests. +func (h *SettingHandler) SetNotificationEmailService(notificationEmailService *service.NotificationEmailService) { + h.notificationEmailService = notificationEmailService +} + // GetSettings 获取所有系统设置 // GET /api/v1/admin/settings func (h *SettingHandler) GetSettings(c *gin.Context) { @@ -3360,3 +3367,160 @@ func (h *SettingHandler) ensureUserAttributeDefinition(ctx context.Context, key, } slog.Info("dingtalk: created user attribute definition", "key", key, "name", name, "type", attrType) } + +// ListEmailTemplates returns all editable notification email templates. +// GET /api/v1/admin/settings/email-templates +func (h *SettingHandler) ListEmailTemplates(c *gin.Context) { + if h.notificationEmailService == nil { + response.InternalError(c, "notification email service is not configured") + return + } + events := h.notificationEmailService.ListEventInfos() + templates, err := h.notificationEmailService.ListTemplates(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, dto.EmailTemplateListResponse{ + Events: emailTemplateEventOptionsToDTO(events), + Locales: h.notificationEmailService.SupportedLocales(), + Templates: emailTemplateSummariesToDTO(templates), + Placeholders: emailTemplatePlaceholderUnion(events), + }) +} + +// GetEmailTemplate returns one editable notification email template. +// GET /api/v1/admin/settings/email-templates/:event/:locale +func (h *SettingHandler) GetEmailTemplate(c *gin.Context) { + if h.notificationEmailService == nil { + response.InternalError(c, "notification email service is not configured") + return + } + tmpl, err := h.notificationEmailService.GetTemplate(c.Request.Context(), c.Param("event"), c.Param("locale")) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, emailTemplateDetailToDTO(tmpl)) +} + +// UpdateEmailTemplate saves an override for one event/locale template. +// PUT /api/v1/admin/settings/email-templates/:event/:locale +func (h *SettingHandler) UpdateEmailTemplate(c *gin.Context) { + if h.notificationEmailService == nil { + response.InternalError(c, "notification email service is not configured") + return + } + var req dto.UpdateEmailTemplateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + tmpl, err := h.notificationEmailService.UpdateTemplate(c.Request.Context(), c.Param("event"), c.Param("locale"), req.Subject, req.HTML) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, emailTemplateDetailToDTO(tmpl)) +} + +// RestoreOfficialEmailTemplate removes an override and returns the built-in template. +// POST /api/v1/admin/settings/email-templates/:event/:locale/restore-official +func (h *SettingHandler) RestoreOfficialEmailTemplate(c *gin.Context) { + if h.notificationEmailService == nil { + response.InternalError(c, "notification email service is not configured") + return + } + tmpl, err := h.notificationEmailService.RestoreOfficialTemplate(c.Request.Context(), c.Param("event"), c.Param("locale")) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, emailTemplateDetailToDTO(tmpl)) +} + +// PreviewEmailTemplate renders a template with safe sample variables without saving it. +// POST /api/v1/admin/settings/email-templates/preview +func (h *SettingHandler) PreviewEmailTemplate(c *gin.Context) { + if h.notificationEmailService == nil { + response.InternalError(c, "notification email service is not configured") + return + } + var req dto.PreviewEmailTemplateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + preview, err := h.notificationEmailService.PreviewTemplate(c.Request.Context(), service.NotificationEmailPreviewInput{ + Event: req.Event, + Locale: req.Locale, + Subject: req.Subject, + HTML: req.HTML, + Variables: req.Variables, + }) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, dto.EmailTemplatePreviewResponse{Subject: preview.Subject, HTML: preview.HTML}) +} + +func emailTemplateEventOptionsToDTO(events []service.NotificationEmailEventInfo) []dto.EmailTemplateEventOption { + items := make([]dto.EmailTemplateEventOption, 0, len(events)) + for _, event := range events { + items = append(items, dto.EmailTemplateEventOption{ + Value: event.Event, + Label: event.Label, + Description: event.Description, + }) + } + return items +} + +func emailTemplateSummariesToDTO(templates []service.NotificationEmailTemplate) []dto.EmailTemplateSummary { + items := make([]dto.EmailTemplateSummary, 0, len(templates)) + for _, tmpl := range templates { + items = append(items, dto.EmailTemplateSummary{ + Event: tmpl.Event, + Locale: tmpl.Locale, + Subject: tmpl.Subject, + IsCustom: tmpl.IsCustom, + UpdatedAt: emailTemplateUpdatedAt(tmpl), + }) + } + return items +} + +func emailTemplateDetailToDTO(tmpl service.NotificationEmailTemplate) dto.EmailTemplateDetail { + return dto.EmailTemplateDetail{ + Event: tmpl.Event, + Locale: tmpl.Locale, + Subject: tmpl.Subject, + HTML: tmpl.HTML, + IsCustom: tmpl.IsCustom, + UpdatedAt: emailTemplateUpdatedAt(tmpl), + Placeholders: tmpl.Placeholders, + } +} + +func emailTemplateUpdatedAt(tmpl service.NotificationEmailTemplate) string { + if tmpl.UpdatedAt == nil { + return "" + } + return tmpl.UpdatedAt.Format("2006-01-02T15:04:05Z07:00") +} + +func emailTemplatePlaceholderUnion(events []service.NotificationEmailEventInfo) []string { + seen := make(map[string]struct{}) + placeholders := make([]string, 0) + for _, event := range events { + for _, placeholder := range event.Placeholders { + if _, ok := seen[placeholder]; ok { + continue + } + seen[placeholder] = struct{}{} + placeholders = append(placeholders, placeholder) + } + } + return placeholders +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index a9af910d..592a0d82 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -203,7 +203,7 @@ func (h *AuthHandler) SendVerifyCode(c *gin.Context) { return } - result, err := h.authService.SendVerifyCodeAsync(c.Request.Context(), req.Email) + result, err := h.authService.SendVerifyCodeAsync(c.Request.Context(), req.Email, c.GetHeader("Accept-Language")) if err != nil { response.ErrorFrom(c, err) return @@ -602,7 +602,7 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) { // Request password reset (async) // Note: This returns success even if email doesn't exist (to prevent enumeration) - if err := h.authService.RequestPasswordResetAsync(c.Request.Context(), req.Email, frontendBaseURL); err != nil { + if err := h.authService.RequestPasswordResetAsync(c.Request.Context(), req.Email, frontendBaseURL, c.GetHeader("Accept-Language")); err != nil { response.ErrorFrom(c, err) return } diff --git a/backend/internal/handler/auth_oauth_pending_flow.go b/backend/internal/handler/auth_oauth_pending_flow.go index 1014a3e8..550363fd 100644 --- a/backend/internal/handler/auth_oauth_pending_flow.go +++ b/backend/internal/handler/auth_oauth_pending_flow.go @@ -545,7 +545,7 @@ func (h *AuthHandler) SendPendingOAuthVerifyCode(c *gin.Context) { return } - result, err := h.authService.SendPendingOAuthVerifyCode(c.Request.Context(), req.Email) + result, err := h.authService.SendPendingOAuthVerifyCode(c.Request.Context(), req.Email, c.GetHeader("Accept-Language")) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index fd3a90b3..bdad5572 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -378,6 +378,62 @@ type OpenAIFastPolicySettings struct { Rules []OpenAIFastPolicyRule `json:"rules"` } +// EmailTemplateEventOption describes an editable notification email event. +type EmailTemplateEventOption struct { + Value string `json:"value"` + Label string `json:"label,omitempty"` + Description string `json:"description,omitempty"` +} + +// EmailTemplateSummary is shown in the admin email template list. +type EmailTemplateSummary struct { + Event string `json:"event"` + Locale string `json:"locale"` + Subject string `json:"subject"` + IsCustom bool `json:"is_custom,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// EmailTemplateListResponse is returned by GET /admin/settings/email-templates. +type EmailTemplateListResponse struct { + Events []EmailTemplateEventOption `json:"events"` + Locales []string `json:"locales"` + Templates []EmailTemplateSummary `json:"templates,omitempty"` + Placeholders []string `json:"placeholders,omitempty"` +} + +// EmailTemplateDetail is returned for a specific event/locale template. +type EmailTemplateDetail struct { + Event string `json:"event"` + Locale string `json:"locale"` + Subject string `json:"subject"` + HTML string `json:"html"` + IsCustom bool `json:"is_custom,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Placeholders []string `json:"placeholders,omitempty"` +} + +// UpdateEmailTemplateRequest updates a template override. +type UpdateEmailTemplateRequest struct { + Subject string `json:"subject"` + HTML string `json:"html"` +} + +// PreviewEmailTemplateRequest previews a template without saving it. +type PreviewEmailTemplateRequest struct { + Event string `json:"event"` + Locale string `json:"locale"` + Subject string `json:"subject"` + HTML string `json:"html"` + Variables map[string]string `json:"variables,omitempty"` +} + +// EmailTemplatePreviewResponse is the rendered preview payload. +type EmailTemplatePreviewResponse struct { + Subject string `json:"subject"` + HTML string `json:"html"` +} + // ParseCustomMenuItems parses a JSON string into a slice of CustomMenuItem. // Returns empty slice on empty/invalid input. func ParseCustomMenuItems(raw string) []CustomMenuItem { diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 1bb81190..f3c16f5d 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -266,6 +266,7 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) { PaymentSource: req.PaymentSource, OrderType: req.OrderType, PlanID: req.PlanID, + Locale: c.GetHeader("Accept-Language"), }) if err != nil { response.ErrorFrom(c, err) diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index c4ba43e4..7413b840 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -1,6 +1,10 @@ package handler import ( + "html" + "net/http" + "strings" + "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" @@ -10,8 +14,9 @@ import ( // SettingHandler 公开设置处理器(无需认证) type SettingHandler struct { - settingService *service.SettingService - version string + settingService *service.SettingService + notificationEmailService *service.NotificationEmailService + version string } // NewSettingHandler 创建公开设置处理器 @@ -22,6 +27,12 @@ func NewSettingHandler(settingService *service.SettingService, version string) * } } +// SetNotificationEmailService attaches the public notification email service without +// changing the constructor signature used by existing tests. +func (h *SettingHandler) SetNotificationEmailService(notificationEmailService *service.NotificationEmailService) { + h.notificationEmailService = notificationEmailService +} + // GetPublicSettings 获取公开设置 // GET /api/v1/settings/public func (h *SettingHandler) GetPublicSettings(c *gin.Context) { @@ -90,6 +101,27 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { }) } +// UnsubscribeNotificationEmail handles optional notification email opt-outs. +// GET /api/v1/settings/email-unsubscribe?token=... +func (h *SettingHandler) UnsubscribeNotificationEmail(c *gin.Context) { + if h.notificationEmailService == nil { + response.InternalError(c, "notification email service is not configured") + return + } + token := strings.TrimSpace(c.Query("token")) + if token == "" { + response.BadRequest(c, "token is required") + return + } + result, err := h.notificationEmailService.Unsubscribe(c.Request.Context(), token) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + body := "Unsubscribed

Unsubscribed

You have unsubscribed " + html.EscapeString(result.Email) + " from " + html.EscapeString(result.Event) + " emails.

" + c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(body)) +} + func publicLoginAgreementDocumentsToDTO(items []service.LoginAgreementDocument) []dto.LoginAgreementDocument { result := make([]dto.LoginAgreementDocument, 0, len(items)) for _, item := range items { diff --git a/backend/internal/handler/totp_handler.go b/backend/internal/handler/totp_handler.go index 5c5eb567..f9151dab 100644 --- a/backend/internal/handler/totp_handler.go +++ b/backend/internal/handler/totp_handler.go @@ -172,7 +172,7 @@ func (h *TotpHandler) SendVerifyCode(c *gin.Context) { return } - if err := h.totpService.SendVerifyCode(c.Request.Context(), subject.UserID); err != nil { + if err := h.totpService.SendVerifyCode(c.Request.Context(), subject.UserID, c.GetHeader("Accept-Language")); err != nil { response.ErrorFrom(c, err) return } diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index f1dbf4e1..95cb1482 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -335,7 +335,7 @@ func (h *UserHandler) SendEmailBindingCode(c *gin.Context) { return } - if err := h.authService.SendEmailIdentityBindCode(c.Request.Context(), subject.UserID, req.Email); err != nil { + if err := h.authService.SendEmailIdentityBindCode(c.Request.Context(), subject.UserID, req.Email, c.GetHeader("Accept-Language")); err != nil { response.ErrorFrom(c, err) return } @@ -363,7 +363,7 @@ func (h *UserHandler) SendNotifyEmailCode(c *gin.Context) { return } - err := h.userService.SendNotifyEmailCode(c.Request.Context(), subject.UserID, req.Email, h.emailService, h.emailCache) + err := h.userService.SendNotifyEmailCode(c.Request.Context(), subject.UserID, req.Email, h.emailService, h.emailCache, c.GetHeader("Accept-Language")) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 7f9f9e3c..c8c46157 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -80,8 +80,17 @@ func ProvideSystemHandler(updateService *service.UpdateService, lockService *ser } // ProvideSettingHandler creates SettingHandler with version from BuildInfo -func ProvideSettingHandler(settingService *service.SettingService, buildInfo BuildInfo) *SettingHandler { - return NewSettingHandler(settingService, buildInfo.Version) +func ProvideSettingHandler(settingService *service.SettingService, buildInfo BuildInfo, notificationEmailService *service.NotificationEmailService) *SettingHandler { + h := NewSettingHandler(settingService, buildInfo.Version) + h.SetNotificationEmailService(notificationEmailService) + return h +} + +// ProvideAdminSettingHandler creates admin.SettingHandler with notification template APIs. +func ProvideAdminSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, paymentConfigService *service.PaymentConfigService, paymentService *service.PaymentService, userAttributeService *service.UserAttributeService, notificationEmailService *service.NotificationEmailService) *admin.SettingHandler { + h := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService) + h.SetNotificationEmailService(notificationEmailService) + return h } // ProvideHandlers creates the Handlers struct @@ -159,7 +168,7 @@ var ProviderSet = wire.NewSet( admin.NewProxyHandler, admin.NewRedeemHandler, admin.NewPromoHandler, - admin.NewSettingHandler, + ProvideAdminSettingHandler, admin.NewOpsHandler, ProvideSystemHandler, admin.NewSubscriptionHandler, diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 1be01b06..f7215414 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -416,6 +416,11 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { adminSettings.PUT("", h.Admin.Setting.UpdateSettings) adminSettings.POST("/test-smtp", h.Admin.Setting.TestSMTPConnection) adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail) + adminSettings.GET("/email-templates", h.Admin.Setting.ListEmailTemplates) + adminSettings.POST("/email-template-preview", h.Admin.Setting.PreviewEmailTemplate) + adminSettings.GET("/email-templates/:event/:locale", h.Admin.Setting.GetEmailTemplate) + adminSettings.PUT("/email-templates/:event/:locale", h.Admin.Setting.UpdateEmailTemplate) + adminSettings.POST("/email-templates/:event/:locale/restore-official", h.Admin.Setting.RestoreOfficialEmailTemplate) // Admin API Key 管理 adminSettings.GET("/admin-api-key", h.Admin.Setting.GetAdminAPIKey) adminSettings.POST("/admin-api-key/regenerate", h.Admin.Setting.RegenerateAdminAPIKey) diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index 19d0fd2a..2c44a2b3 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -214,6 +214,7 @@ func RegisterAuthRoutes( settings := v1.Group("/settings") { settings.GET("/public", h.Setting.GetPublicSettings) + settings.GET("/email-unsubscribe", h.Setting.UnsubscribeNotificationEmail) } // 需要认证的当前用户信息 diff --git a/backend/internal/service/auth_email_binding.go b/backend/internal/service/auth_email_binding.go index 78f1185d..84f61d78 100644 --- a/backend/internal/service/auth_email_binding.go +++ b/backend/internal/service/auth_email_binding.go @@ -94,7 +94,7 @@ func (s *AuthService) BindEmailIdentity( } // SendEmailIdentityBindCode sends a verification code for authenticated email binding flows. -func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int64, email string) error { +func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int64, email string, locale ...string) error { if s == nil { return ErrServiceUnavailable } @@ -128,7 +128,7 @@ func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int6 if s.settingService != nil { siteName = s.settingService.GetSiteName(ctx) } - return s.emailService.SendVerifyCode(ctx, normalizedEmail, siteName) + return s.emailService.SendVerifyCode(ctx, normalizedEmail, siteName, firstEmailLocale(locale)) } func normalizeEmailForIdentityBinding(email string) (string, error) { diff --git a/backend/internal/service/auth_oauth_email_flow.go b/backend/internal/service/auth_oauth_email_flow.go index 3478fda5..cf0be652 100644 --- a/backend/internal/service/auth_oauth_email_flow.go +++ b/backend/internal/service/auth_oauth_email_flow.go @@ -28,7 +28,7 @@ func normalizeOAuthSignupSource(signupSource string) string { // SendPendingOAuthVerifyCode sends a local verification code for pending OAuth // account-creation flows without relying on the public registration gate. -func (s *AuthService) SendPendingOAuthVerifyCode(ctx context.Context, email string) (*SendVerifyCodeResult, error) { +func (s *AuthService) SendPendingOAuthVerifyCode(ctx context.Context, email string, locale ...string) (*SendVerifyCodeResult, error) { email = strings.TrimSpace(strings.ToLower(email)) if email == "" { return nil, ErrEmailVerifyRequired @@ -47,7 +47,7 @@ func (s *AuthService) SendPendingOAuthVerifyCode(ctx context.Context, email stri if s.settingService != nil { siteName = s.settingService.GetSiteName(ctx) } - if err := s.emailService.SendVerifyCode(ctx, email, siteName); err != nil { + if err := s.emailService.SendVerifyCode(ctx, email, siteName, firstEmailLocale(locale)); err != nil { return nil, err } return &SendVerifyCodeResult{ diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index ce2b3fa3..4e5b7b94 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -273,7 +273,7 @@ type SendVerifyCodeResult struct { } // SendVerifyCode 发送邮箱验证码(同步方式) -func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error { +func (s *AuthService) SendVerifyCode(ctx context.Context, email string, locale ...string) error { // 检查是否开放注册(默认关闭) if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { return ErrRegDisabled @@ -307,11 +307,11 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error { siteName = s.settingService.GetSiteName(ctx) } - return s.emailService.SendVerifyCode(ctx, email, siteName) + return s.emailService.SendVerifyCode(ctx, email, siteName, firstEmailLocale(locale)) } // SendVerifyCodeAsync 异步发送邮箱验证码并返回倒计时 -func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*SendVerifyCodeResult, error) { +func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string, locale ...string) (*SendVerifyCodeResult, error) { logger.LegacyPrintf("service.auth", "[Auth] SendVerifyCodeAsync called for email: %s", email) // 检查是否开放注册(默认关闭) @@ -352,7 +352,7 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*S // 异步发送 logger.LegacyPrintf("service.auth", "[Auth] Enqueueing verify code for: %s", email) - if err := s.emailQueueService.EnqueueVerifyCode(email, siteName); err != nil { + if err := s.emailQueueService.EnqueueVerifyCode(email, siteName, firstEmailLocale(locale)); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to enqueue: %v", err) return nil, fmt.Errorf("enqueue verify code: %w", err) } @@ -1251,7 +1251,7 @@ func (s *AuthService) preparePasswordReset(ctx context.Context, email, frontendB // RequestPasswordReset 请求密码重置(同步发送) // Security: Returns the same response regardless of whether the email exists (prevent user enumeration) -func (s *AuthService) RequestPasswordReset(ctx context.Context, email, frontendBaseURL string) error { +func (s *AuthService) RequestPasswordReset(ctx context.Context, email, frontendBaseURL string, locale ...string) error { if !s.IsPasswordResetEnabled(ctx) { return infraerrors.Forbidden("PASSWORD_RESET_DISABLED", "password reset is not enabled") } @@ -1264,7 +1264,7 @@ func (s *AuthService) RequestPasswordReset(ctx context.Context, email, frontendB return nil // Silent success to prevent enumeration } - if err := s.emailService.SendPasswordResetEmail(ctx, email, siteName, resetURL); err != nil { + if err := s.emailService.SendPasswordResetEmail(ctx, email, siteName, resetURL, firstEmailLocale(locale)); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to send password reset email to %s: %v", email, err) return nil // Silent success to prevent enumeration } @@ -1275,7 +1275,7 @@ func (s *AuthService) RequestPasswordReset(ctx context.Context, email, frontendB // RequestPasswordResetAsync 异步请求密码重置(队列发送) // Security: Returns the same response regardless of whether the email exists (prevent user enumeration) -func (s *AuthService) RequestPasswordResetAsync(ctx context.Context, email, frontendBaseURL string) error { +func (s *AuthService) RequestPasswordResetAsync(ctx context.Context, email, frontendBaseURL string, locale ...string) error { if !s.IsPasswordResetEnabled(ctx) { return infraerrors.Forbidden("PASSWORD_RESET_DISABLED", "password reset is not enabled") } @@ -1288,7 +1288,7 @@ func (s *AuthService) RequestPasswordResetAsync(ctx context.Context, email, fron return nil // Silent success to prevent enumeration } - if err := s.emailQueueService.EnqueuePasswordReset(email, siteName, resetURL); err != nil { + if err := s.emailQueueService.EnqueuePasswordReset(email, siteName, resetURL, firstEmailLocale(locale)); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to enqueue password reset email for %s: %v", email, err) return nil // Silent success to prevent enumeration } diff --git a/backend/internal/service/balance_notify_service.go b/backend/internal/service/balance_notify_service.go index 5b7e413a..26803275 100644 --- a/backend/internal/service/balance_notify_service.go +++ b/backend/internal/service/balance_notify_service.go @@ -39,9 +39,10 @@ type AccountQuotaReader interface { // BalanceNotifyService handles balance and quota threshold notifications. type BalanceNotifyService struct { - emailService *EmailService - settingRepo SettingRepository - accountRepo AccountQuotaReader + emailService *EmailService + settingRepo SettingRepository + accountRepo AccountQuotaReader + notificationEmailService *NotificationEmailService } // NewBalanceNotifyService creates a new BalanceNotifyService. @@ -53,6 +54,10 @@ func NewBalanceNotifyService(emailService *EmailService, settingRepo SettingRepo } } +func (s *BalanceNotifyService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) { + s.notificationEmailService = notificationEmailService +} + // resolveBalanceThreshold returns the effective balance threshold. // For percentage type, it computes threshold = totalRecharged * percentage / 100. func resolveBalanceThreshold(threshold float64, thresholdType string, totalRecharged float64) float64 { @@ -125,7 +130,7 @@ func (s *BalanceNotifyService) dispatchBalanceLowEmail(ctx context.Context, user slog.Error("panic in balance notification", "recover", r) } }() - s.sendBalanceLowEmails(recipients, user.Username, user.Email, newBalance, threshold, siteName, rechargeURL) + s.sendBalanceLowEmails(recipients, user.ID, user.Username, user.Email, newBalance, threshold, siteName, rechargeURL) }() } @@ -342,11 +347,44 @@ func (s *BalanceNotifyService) sendEmails(recipients []string, subject, body str } // sendBalanceLowEmails sends balance low notification to all recipients. -func (s *BalanceNotifyService) sendBalanceLowEmails(recipients []string, userName, userEmail string, balance, threshold float64, siteName, rechargeURL string) { +func (s *BalanceNotifyService) sendBalanceLowEmails(recipients []string, userID int64, userName, userEmail string, balance, threshold float64, siteName, rechargeURL string) { displayName := userName if displayName == "" { displayName = userEmail } + if s.notificationEmailService != nil { + fallbackRecipients := make([]string, 0, len(recipients)) + for _, to := range recipients { + ctx, cancel := context.WithTimeout(context.Background(), emailSendTimeout) + err := s.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventBalanceLow, + RecipientEmail: to, + RecipientName: displayName, + UserID: userID, + SourceType: "balance_low", + SourceID: firstNonEmpty(strconv.FormatInt(userID, 10), userEmail), + ReminderKey: time.Now().UTC().Format("2006-01-02"), + Variables: map[string]string{ + "current_balance": fmt.Sprintf("%.2f", balance), + "threshold": fmt.Sprintf("%.2f", threshold), + "recharge_url": rechargeURL, + }, + }) + cancel() + if err != nil { + if shouldFallbackNotificationEmail(err) { + slog.Warn("template balance low notification failed; falling back to built-in body", "to", to, "err", err.Error()) + fallbackRecipients = append(fallbackRecipients, to) + } else { + slog.Warn("template balance low notification delivery failed; not sending fallback to avoid duplicates", "to", to, "err", err.Error()) + } + } + } + if len(fallbackRecipients) == 0 { + return + } + recipients = fallbackRecipients + } subject := fmt.Sprintf("[%s] 余额不足提醒 / Balance Low Alert", sanitizeEmailHeader(siteName)) body := s.buildBalanceLowEmailBody(html.EscapeString(displayName), balance, threshold, html.EscapeString(siteName), rechargeURL) s.sendEmails(recipients, subject, body, "user_email", userEmail, "balance", balance) @@ -369,6 +407,44 @@ func (s *BalanceNotifyService) sendQuotaAlertEmails(adminEmails []string, accoun remaining = 0 } + if s.notificationEmailService != nil { + fallbackRecipients := make([]string, 0, len(adminEmails)) + for _, to := range adminEmails { + ctx, cancel := context.WithTimeout(context.Background(), emailSendTimeout) + err := s.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventAccountQuotaAlert, + RecipientEmail: to, + RecipientName: emailRecipientName(to), + SourceType: "account_quota", + SourceID: fmt.Sprintf("%d-%s", accountID, dim.name), + ReminderKey: time.Now().UTC().Format("2006-01-02"), + Variables: map[string]string{ + "account_id": strconv.FormatInt(accountID, 10), + "account_name": accountName, + "platform": platform, + "quota_dimension": dimLabel, + "quota_used": fmt.Sprintf("%.2f", used), + "quota_limit": fmt.Sprintf("%.2f", dim.limit), + "quota_remaining": fmt.Sprintf("%.2f", remaining), + "quota_threshold": thresholdDisplay, + }, + }) + cancel() + if err != nil { + if shouldFallbackNotificationEmail(err) { + slog.Warn("template account quota alert failed; falling back to built-in body", "to", to, "account_id", accountID, "dimension", dim.name, "err", err.Error()) + fallbackRecipients = append(fallbackRecipients, to) + } else { + slog.Warn("template account quota alert delivery failed; not sending fallback to avoid duplicates", "to", to, "account_id", accountID, "dimension", dim.name, "err", err.Error()) + } + } + } + if len(fallbackRecipients) == 0 { + return + } + adminEmails = fallbackRecipients + } + subject := fmt.Sprintf("[%s] 账号限额告警 / Account Quota Alert - %s", sanitizeEmailHeader(siteName), sanitizeEmailHeader(accountName)) body := s.buildQuotaAlertEmailBody(accountID, html.EscapeString(accountName), html.EscapeString(platform), html.EscapeString(dimLabel), used, dim.limit, remaining, thresholdDisplay, html.EscapeString(siteName)) s.sendEmails(adminEmails, subject, body, "account", accountName, "dimension", dim.name) diff --git a/backend/internal/service/content_moderation.go b/backend/internal/service/content_moderation.go index 6a7c9904..2d066298 100644 --- a/backend/internal/service/content_moderation.go +++ b/backend/internal/service/content_moderation.go @@ -1463,6 +1463,24 @@ func (s *ContentModerationService) applyFlaggedSideEffects(ctx context.Context, func (s *ContentModerationService) sendViolationEmail(ctx context.Context, cfg *ContentModerationConfig, log *ContentModerationLog) error { siteName := s.siteName(ctx) + if s.emailService.notificationEmailService != nil { + if err := s.emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventContentModerationViolation, + RecipientEmail: log.UserEmail, + RecipientName: emailRecipientName(log.UserEmail), + UserID: contentModerationEmailUserID(log), + SourceType: "content_moderation", + SourceID: contentModerationEmailSourceID(log), + Variables: contentModerationEmailVariables(log, cfg), + }); err == nil { + return nil + } else { + if !shouldFallbackNotificationEmail(err) { + return err + } + slog.Warn("template content moderation violation email failed; falling back to built-in body", "log_id", log.ID, "recipient_hash", notificationEmailHash(log.UserEmail), "err", err.Error()) + } + } subject := fmt.Sprintf("[%s] 账户风控提醒 / Risk Control Notice", sanitizeEmailHeader(siteName)) body := buildContentModerationViolationEmailBody(siteName, log, cfg) return s.emailService.SendEmail(ctx, log.UserEmail, subject, body) @@ -1470,11 +1488,71 @@ func (s *ContentModerationService) sendViolationEmail(ctx context.Context, cfg * func (s *ContentModerationService) sendAccountDisabledEmail(ctx context.Context, cfg *ContentModerationConfig, log *ContentModerationLog) error { siteName := s.siteName(ctx) + if s.emailService.notificationEmailService != nil { + if err := s.emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventContentModerationDisabled, + RecipientEmail: log.UserEmail, + RecipientName: emailRecipientName(log.UserEmail), + UserID: contentModerationEmailUserID(log), + SourceType: "content_moderation", + SourceID: contentModerationEmailSourceID(log), + Variables: contentModerationEmailVariables(log, cfg), + }); err == nil { + return nil + } else { + if !shouldFallbackNotificationEmail(err) { + return err + } + slog.Warn("template content moderation disabled email failed; falling back to built-in body", "log_id", log.ID, "recipient_hash", notificationEmailHash(log.UserEmail), "err", err.Error()) + } + } subject := fmt.Sprintf("[%s] 账户已被禁用 / Account Disabled", sanitizeEmailHeader(siteName)) body := buildContentModerationAccountDisabledEmailBody(siteName, log, cfg) return s.emailService.SendEmail(ctx, log.UserEmail, subject, body) } +func contentModerationEmailUserID(log *ContentModerationLog) int64 { + if log == nil || log.UserID == nil { + return 0 + } + return *log.UserID +} + +func contentModerationEmailSourceID(log *ContentModerationLog) string { + if log == nil || log.ID <= 0 { + return "" + } + return fmt.Sprintf("%d", log.ID) +} + +func contentModerationEmailVariables(log *ContentModerationLog, cfg *ContentModerationConfig) map[string]string { + variables := map[string]string{ + "triggered_at": time.Now().UTC().Format(time.RFC3339), + "group_name": "-", + "moderation_category": "-", + "moderation_score": "0.000", + "violation_count": "0", + "ban_threshold": "0", + } + if log != nil { + if !log.CreatedAt.IsZero() { + variables["triggered_at"] = log.CreatedAt.UTC().Format(time.RFC3339) + } + if strings.TrimSpace(log.GroupName) != "" { + variables["group_name"] = strings.TrimSpace(log.GroupName) + } + if strings.TrimSpace(log.HighestCategory) != "" { + variables["moderation_category"] = strings.TrimSpace(log.HighestCategory) + } + variables["moderation_score"] = fmt.Sprintf("%.3f", log.HighestScore) + variables["violation_count"] = fmt.Sprintf("%d", log.ViolationCount) + } + if cfg != nil { + variables["ban_threshold"] = fmt.Sprintf("%d", cfg.BanThreshold) + } + return variables +} + func (s *ContentModerationService) siteName(ctx context.Context) string { if s == nil || s.settingRepo == nil { return "Sub2API" diff --git a/backend/internal/service/email_queue_service.go b/backend/internal/service/email_queue_service.go index d8f0a518..a933e6bb 100644 --- a/backend/internal/service/email_queue_service.go +++ b/backend/internal/service/email_queue_service.go @@ -21,6 +21,7 @@ type EmailTask struct { SiteName string TaskType string // "verify_code" or "password_reset" ResetURL string // Only used for password_reset task type + Locale string // Optional Accept-Language locale hint } // EmailQueueService 异步邮件队列服务 @@ -82,13 +83,13 @@ func (s *EmailQueueService) processTask(workerID int, task EmailTask) { switch task.TaskType { case TaskTypeVerifyCode: - if err := s.emailService.SendVerifyCode(ctx, task.Email, task.SiteName); err != nil { + if err := s.emailService.SendVerifyCode(ctx, task.Email, task.SiteName, task.Locale); err != nil { logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d failed to send verify code to %s: %v", workerID, task.Email, err) } else { logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d sent verify code to %s", workerID, task.Email) } case TaskTypePasswordReset: - if err := s.emailService.SendPasswordResetEmailWithCooldown(ctx, task.Email, task.SiteName, task.ResetURL); err != nil { + if err := s.emailService.SendPasswordResetEmailWithCooldown(ctx, task.Email, task.SiteName, task.ResetURL, task.Locale); err != nil { logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d failed to send password reset to %s: %v", workerID, task.Email, err) } else { logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d sent password reset to %s", workerID, task.Email) @@ -99,11 +100,12 @@ func (s *EmailQueueService) processTask(workerID int, task EmailTask) { } // EnqueueVerifyCode 将验证码发送任务加入队列 -func (s *EmailQueueService) EnqueueVerifyCode(email, siteName string) error { +func (s *EmailQueueService) EnqueueVerifyCode(email, siteName string, locale ...string) error { task := EmailTask{ Email: email, SiteName: siteName, TaskType: TaskTypeVerifyCode, + Locale: firstEmailLocale(locale), } select { @@ -116,12 +118,13 @@ func (s *EmailQueueService) EnqueueVerifyCode(email, siteName string) error { } // EnqueuePasswordReset 将密码重置邮件任务加入队列 -func (s *EmailQueueService) EnqueuePasswordReset(email, siteName, resetURL string) error { +func (s *EmailQueueService) EnqueuePasswordReset(email, siteName, resetURL string, locale ...string) error { task := EmailTask{ Email: email, SiteName: siteName, TaskType: TaskTypePasswordReset, ResetURL: resetURL, + Locale: firstEmailLocale(locale), } select { diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 9a03ea30..2cf42d73 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -94,8 +94,9 @@ type SMTPConfig struct { // EmailService 邮件服务 type EmailService struct { - settingRepo SettingRepository - cache EmailCache + settingRepo SettingRepository + cache EmailCache + notificationEmailService *NotificationEmailService } // NewEmailService 创建邮件服务实例 @@ -106,6 +107,28 @@ func NewEmailService(settingRepo SettingRepository, cache EmailCache) *EmailServ } } +func (s *EmailService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) { + s.notificationEmailService = notificationEmailService +} + +func firstEmailLocale(locales []string) string { + if len(locales) == 0 { + return "" + } + return strings.TrimSpace(locales[0]) +} + +func emailRecipientName(email string) string { + trimmed := strings.TrimSpace(email) + if trimmed == "" { + return "" + } + if at := strings.Index(trimmed, "@"); at > 0 { + return trimmed[:at] + } + return trimmed +} + // GetSMTPConfig 从数据库获取SMTP配置 func (s *EmailService) GetSMTPConfig(ctx context.Context) (*SMTPConfig, error) { keys := []string{ @@ -301,7 +324,7 @@ func (s *EmailService) GenerateVerifyCode() (string, error) { } // SendVerifyCode 发送验证码邮件 -func (s *EmailService) SendVerifyCode(ctx context.Context, email, siteName string) error { +func (s *EmailService) SendVerifyCode(ctx context.Context, email, siteName string, locale ...string) error { // 检查是否在冷却期内 existing, err := s.cache.GetVerificationCode(ctx, email) if err == nil && existing != nil { @@ -327,6 +350,26 @@ func (s *EmailService) SendVerifyCode(ctx context.Context, email, siteName strin return fmt.Errorf("save verify code: %w", err) } + if s.notificationEmailService != nil { + err := s.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventAuthVerifyCode, + Locale: firstEmailLocale(locale), + RecipientEmail: email, + RecipientName: emailRecipientName(email), + Variables: map[string]string{ + "verification_code": code, + "expires_in_minutes": strconv.Itoa(int(verifyCodeTTL / time.Minute)), + }, + }) + if err == nil { + return nil + } + if !shouldFallbackNotificationEmail(err) { + return err + } + slog.Warn("failed to send templated verification email, falling back to legacy template", "recipient_hash", notificationEmailHash(email), "error", err) + } + // 构建邮件内容 subject := fmt.Sprintf("[%s] Email Verification Code", siteName) body := s.buildVerifyCodeEmailBody(code, siteName) @@ -469,7 +512,7 @@ func (s *EmailService) GeneratePasswordResetToken() (string, error) { } // SendPasswordResetEmail sends a password reset email with a reset link -func (s *EmailService) SendPasswordResetEmail(ctx context.Context, email, siteName, resetURL string) error { +func (s *EmailService) SendPasswordResetEmail(ctx context.Context, email, siteName, resetURL string, locale ...string) error { var token string var needSaveToken bool @@ -502,6 +545,26 @@ func (s *EmailService) SendPasswordResetEmail(ctx context.Context, email, siteNa // Build full reset URL with URL-encoded token and email fullResetURL := fmt.Sprintf("%s?email=%s&token=%s", resetURL, url.QueryEscape(email), url.QueryEscape(token)) + if s.notificationEmailService != nil { + err := s.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventAuthPasswordReset, + Locale: firstEmailLocale(locale), + RecipientEmail: email, + RecipientName: emailRecipientName(email), + Variables: map[string]string{ + "reset_url": fullResetURL, + "expires_in_minutes": strconv.Itoa(int(passwordResetTokenTTL / time.Minute)), + }, + }) + if err == nil { + return nil + } + if !shouldFallbackNotificationEmail(err) { + return err + } + slog.Warn("failed to send templated password reset email, falling back to legacy template", "recipient_hash", notificationEmailHash(email), "error", err) + } + // Build email content subject := fmt.Sprintf("[%s] 密码重置请求", siteName) body := s.buildPasswordResetEmailBody(fullResetURL, siteName) @@ -516,7 +579,7 @@ func (s *EmailService) SendPasswordResetEmail(ctx context.Context, email, siteNa // SendPasswordResetEmailWithCooldown sends password reset email with cooldown check (called by queue worker) // This method wraps SendPasswordResetEmail with email cooldown to prevent email bombing -func (s *EmailService) SendPasswordResetEmailWithCooldown(ctx context.Context, email, siteName, resetURL string) error { +func (s *EmailService) SendPasswordResetEmailWithCooldown(ctx context.Context, email, siteName, resetURL string, locale ...string) error { // Check email cooldown to prevent email bombing if s.cache.IsPasswordResetEmailInCooldown(ctx, email) { slog.Info("password reset email skipped due to cooldown", "email", email) @@ -524,7 +587,7 @@ func (s *EmailService) SendPasswordResetEmailWithCooldown(ctx context.Context, e } // Send email using core method - if err := s.SendPasswordResetEmail(ctx, email, siteName, resetURL); err != nil { + if err := s.SendPasswordResetEmail(ctx, email, siteName, resetURL, firstEmailLocale(locale)); err != nil { return err } diff --git a/backend/internal/service/notification_email_service.go b/backend/internal/service/notification_email_service.go new file mode 100644 index 00000000..82078283 --- /dev/null +++ b/backend/internal/service/notification_email_service.go @@ -0,0 +1,1313 @@ +package service + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "html" + "log/slog" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +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:" + notificationEmailDeliveryKeyPrefix = "notification_email_delivery:" + notificationEmailLocaleUserKeyPrefix = "notification_email_locale:user:" + notificationEmailLocaleEmailKeyPrefix = "notification_email_locale:email:" + notificationEmailUnsubscribeSecretKey = "notification_email_unsubscribe_secret" + notificationEmailDefaultLocale = "en" + notificationEmailLocaleChinese = "zh" + notificationEmailMaxSubjectLength = 200 + notificationEmailMaxHTMLLength = 30000 + notificationEmailUnsubscribeTTL = 365 * 24 * time.Hour +) + +var ( + notificationEmailPlaceholderPattern = regexp.MustCompile(`{{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*}}`) + notificationEmailLocales = []string{notificationEmailDefaultLocale, notificationEmailLocaleChinese} + notificationEmailCommonPlaceholders = []string{"site_name", "recipient_name", "recipient_email"} +) + +type NotificationEmailService struct { + settingRepo SettingRepository + emailService *EmailService +} + +type NotificationEmailEventInfo struct { + Event string `json:"event"` + Label string `json:"label"` + Description string `json:"description"` + Category string `json:"category"` + Optional bool `json:"optional"` + Placeholders []string `json:"placeholders"` +} + +type NotificationEmailTemplate struct { + Event string `json:"event"` + Locale string `json:"locale"` + Subject string `json:"subject"` + HTML string `json:"html"` + IsCustom bool `json:"is_custom"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Placeholders []string `json:"placeholders"` +} + +type NotificationEmailPreview struct { + Subject string `json:"subject"` + HTML string `json:"html"` +} + +type NotificationEmailPreviewInput struct { + Event string `json:"event"` + Locale string `json:"locale"` + Subject string `json:"subject"` + HTML string `json:"html"` + Variables map[string]string `json:"variables,omitempty"` +} + +type NotificationEmailSendInput struct { + 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 { + Event string `json:"event"` + Email string `json:"email"` + Done bool `json:"done"` +} + +type notificationEmailStoredTemplate struct { + Subject string `json:"subject"` + HTML string `json:"html"` + UpdatedAt time.Time `json:"updated_at"` +} + +type notificationEmailOfficialTemplate struct { + Subject string + 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"` + Exp int64 `json:"exp"` +} + +func NewNotificationEmailService(settingRepo SettingRepository, emailService *EmailService) *NotificationEmailService { + 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 { + infos := make([]NotificationEmailEventInfo, 0, len(notificationEmailEventDefinitions)) + for _, event := range notificationEmailEventOrder { + info := notificationEmailEventDefinitions[event] + info.Placeholders = append([]string(nil), info.Placeholders...) + infos = append(infos, info) + } + return infos +} + +func (s *NotificationEmailService) SupportedLocales() []string { + return append([]string(nil), notificationEmailLocales...) +} + +func (s *NotificationEmailService) ListTemplates(ctx context.Context) ([]NotificationEmailTemplate, error) { + items := make([]NotificationEmailTemplate, 0, len(notificationEmailEventOrder)*len(notificationEmailLocales)) + for _, event := range notificationEmailEventOrder { + for _, locale := range notificationEmailLocales { + tmpl, err := s.GetTemplate(ctx, event, locale) + if err != nil { + return nil, err + } + items = append(items, tmpl) + } + } + return items, nil +} + +func (s *NotificationEmailService) GetTemplate(ctx context.Context, event, locale string) (NotificationEmailTemplate, error) { + info, normalizedEvent, err := s.eventInfo(event) + if err != nil { + return NotificationEmailTemplate{}, err + } + normalizedLocale := normalizeNotificationLocale(locale) + official, ok := notificationEmailOfficialTemplates[normalizedEvent][normalizedLocale] + if !ok { + return NotificationEmailTemplate{}, fmt.Errorf("official template not found for %s/%s", normalizedEvent, normalizedLocale) + } + + tmpl := NotificationEmailTemplate{ + Event: normalizedEvent, + Locale: normalizedLocale, + Subject: official.Subject, + HTML: official.HTML, + Placeholders: append([]string(nil), info.Placeholders...), + } + + raw, err := s.settingRepo.GetValue(ctx, notificationEmailTemplateKey(normalizedEvent, normalizedLocale)) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return tmpl, nil + } + return NotificationEmailTemplate{}, err + } + if strings.TrimSpace(raw) == "" { + return tmpl, nil + } + + var stored notificationEmailStoredTemplate + if err := json.Unmarshal([]byte(raw), &stored); err != nil { + return NotificationEmailTemplate{}, fmt.Errorf("decode email template override: %w", err) + } + if err := validateNotificationEmailTemplate(normalizedEvent, stored.Subject, stored.HTML); err != nil { + return NotificationEmailTemplate{}, err + } + tmpl.Subject = stored.Subject + tmpl.HTML = stored.HTML + tmpl.IsCustom = true + updatedAt := stored.UpdatedAt + tmpl.UpdatedAt = &updatedAt + return tmpl, nil +} + +func (s *NotificationEmailService) UpdateTemplate(ctx context.Context, event, locale, subject, htmlBody string) (NotificationEmailTemplate, error) { + _, normalizedEvent, err := s.eventInfo(event) + if err != nil { + return NotificationEmailTemplate{}, err + } + normalizedLocale := normalizeNotificationLocale(locale) + if err := validateNotificationEmailTemplate(normalizedEvent, subject, htmlBody); err != nil { + return NotificationEmailTemplate{}, err + } + stored := notificationEmailStoredTemplate{ + Subject: strings.TrimSpace(subject), + HTML: htmlBody, + UpdatedAt: time.Now().UTC(), + } + payload, err := json.Marshal(stored) + if err != nil { + return NotificationEmailTemplate{}, err + } + if err := s.settingRepo.Set(ctx, notificationEmailTemplateKey(normalizedEvent, normalizedLocale), string(payload)); err != nil { + return NotificationEmailTemplate{}, err + } + return s.GetTemplate(ctx, normalizedEvent, normalizedLocale) +} + +func (s *NotificationEmailService) RestoreOfficialTemplate(ctx context.Context, event, locale string) (NotificationEmailTemplate, error) { + _, normalizedEvent, err := s.eventInfo(event) + if err != nil { + return NotificationEmailTemplate{}, err + } + normalizedLocale := normalizeNotificationLocale(locale) + if err := s.settingRepo.Delete(ctx, notificationEmailTemplateKey(normalizedEvent, normalizedLocale)); err != nil && !errors.Is(err, ErrSettingNotFound) { + return NotificationEmailTemplate{}, err + } + return s.GetTemplate(ctx, normalizedEvent, normalizedLocale) +} + +func (s *NotificationEmailService) PreviewTemplate(ctx context.Context, input NotificationEmailPreviewInput) (NotificationEmailPreview, error) { + _, normalizedEvent, err := s.eventInfo(input.Event) + if err != nil { + return NotificationEmailPreview{}, err + } + normalizedLocale := normalizeNotificationLocale(input.Locale) + subject := input.Subject + htmlBody := input.HTML + if strings.TrimSpace(subject) == "" || strings.TrimSpace(htmlBody) == "" { + tmpl, err := s.GetTemplate(ctx, normalizedEvent, normalizedLocale) + if err != nil { + return NotificationEmailPreview{}, err + } + if strings.TrimSpace(subject) == "" { + subject = tmpl.Subject + } + if strings.TrimSpace(htmlBody) == "" { + htmlBody = tmpl.HTML + } + } + if err := validateNotificationEmailTemplate(normalizedEvent, subject, htmlBody); err != nil { + return NotificationEmailPreview{}, err + } + variables := s.sampleVariables(ctx, normalizedEvent, normalizedLocale) + for key, value := range input.Variables { + variables[key] = value + } + 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 notificationEmailTemplateErr(err) + } + recipient := strings.TrimSpace(input.RecipientEmail) + if recipient == "" { + return nil + } + if info.Optional { + unsubscribed, err := s.IsUnsubscribed(ctx, recipient, normalizedEvent) + if err != nil { + return err + } + if unsubscribed { + slog.Info("notification email suppressed by unsubscribe preference", "event", normalizedEvent, "recipient_hash", notificationEmailHash(recipient)) + return nil + } + } + + locale := normalizeNotificationLocale(input.Locale) + if strings.TrimSpace(input.Locale) == "" { + locale = s.ResolveRecipientLocale(ctx, input.UserID, recipient) + } + tmpl, err := s.GetTemplate(ctx, normalizedEvent, locale) + if err != nil { + return notificationEmailTemplateErr(err) + } + variables := s.runtimeVariables(ctx, normalizedEvent, locale, input) + rendered, err := renderNotificationEmail(normalizedEvent, tmpl.Subject, tmpl.HTML, variables, input.RawHTMLVariables) + if err != nil { + return notificationEmailTemplateErr(err) + } + + deliveryKey := notificationEmailDeliveryKey(normalizedEvent, input.SourceType, input.SourceID, recipient, input.ReminderKey) + if deliveryKey != "" { + sent, err := s.deliveryExists(ctx, deliveryKey) + if err != nil { + return err + } + if sent { + return nil + } + } + + if s.emailService == nil { + return notificationEmailConfigErr(errors.New("email service is not configured")) + } + if err := s.emailService.SendEmail(ctx, recipient, rendered.Subject, rendered.HTML); err != nil { + return notificationEmailDeliveryErr(err) + } + if deliveryKey != "" { + _ = s.settingRepo.Set(ctx, deliveryKey, time.Now().UTC().Format(time.RFC3339Nano)) + } + return nil +} + +func (s *NotificationEmailService) RememberRecipientLocale(ctx context.Context, userID int64, email, acceptLanguage string) { + locale := normalizeNotificationLocale(acceptLanguage) + if strings.TrimSpace(acceptLanguage) == "" || s == nil || s.settingRepo == nil { + return + } + if userID > 0 { + _ = s.settingRepo.Set(ctx, notificationEmailLocaleUserKeyPrefix+strconv.FormatInt(userID, 10), locale) + } + if emailHash := notificationEmailHash(email); emailHash != "" { + _ = s.settingRepo.Set(ctx, notificationEmailLocaleEmailKeyPrefix+emailHash, locale) + } +} + +func (s *NotificationEmailService) ResolveRecipientLocale(ctx context.Context, userID int64, email string) string { + if s == nil || s.settingRepo == nil { + return notificationEmailDefaultLocale + } + if userID > 0 { + if locale, err := s.settingRepo.GetValue(ctx, notificationEmailLocaleUserKeyPrefix+strconv.FormatInt(userID, 10)); err == nil && strings.TrimSpace(locale) != "" { + return normalizeNotificationLocale(locale) + } + } + if emailHash := notificationEmailHash(email); emailHash != "" { + if locale, err := s.settingRepo.GetValue(ctx, notificationEmailLocaleEmailKeyPrefix+emailHash); err == nil && strings.TrimSpace(locale) != "" { + return normalizeNotificationLocale(locale) + } + } + return notificationEmailDefaultLocale +} + +func (s *NotificationEmailService) IsUnsubscribed(ctx context.Context, email, event string) (bool, error) { + info, normalizedEvent, err := s.eventInfo(event) + if err != nil { + return false, err + } + if !info.Optional { + return false, nil + } + value, err := s.settingRepo.GetValue(ctx, notificationEmailPreferenceKey(normalizedEvent, email)) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return false, nil + } + return false, err + } + return strings.EqualFold(strings.TrimSpace(value), "unsubscribed"), nil +} + +func (s *NotificationEmailService) Unsubscribe(ctx context.Context, token string) (NotificationEmailUnsubscribeResult, error) { + claims, err := s.parseUnsubscribeToken(ctx, token) + if err != nil { + return NotificationEmailUnsubscribeResult{}, err + } + info, normalizedEvent, err := s.eventInfo(claims.Event) + if err != nil { + return NotificationEmailUnsubscribeResult{}, err + } + if !info.Optional { + return NotificationEmailUnsubscribeResult{}, fmt.Errorf("%s is transactional and cannot be unsubscribed", normalizedEvent) + } + if err := s.settingRepo.Set(ctx, notificationEmailPreferenceKey(normalizedEvent, claims.Email), "unsubscribed"); err != nil { + return NotificationEmailUnsubscribeResult{}, err + } + return NotificationEmailUnsubscribeResult{Event: normalizedEvent, Email: claims.Email, Done: true}, nil +} + +func (s *NotificationEmailService) eventInfo(event string) (NotificationEmailEventInfo, string, error) { + normalized := strings.ToLower(strings.TrimSpace(event)) + info, ok := notificationEmailEventDefinitions[normalized] + if !ok { + return NotificationEmailEventInfo{}, "", fmt.Errorf("unsupported email template event: %s", event) + } + return info, normalized, nil +} + +func (s *NotificationEmailService) sampleVariables(ctx context.Context, event, locale string) map[string]string { + info := notificationEmailEventDefinitions[event] + variables := make(map[string]string, len(info.Placeholders)) + for key, value := range notificationEmailSampleVariables(locale) { + variables[key] = value + } + variables["site_name"] = s.siteName(ctx) + if variables["unsubscribe_url"] == "" && info.Optional { + variables["unsubscribe_url"] = "https://example.com/unsubscribe" + } + return variables +} + +func (s *NotificationEmailService) runtimeVariables(ctx context.Context, event, locale string, input NotificationEmailSendInput) map[string]string { + variables := s.sampleVariables(ctx, event, locale) + for key, value := range input.Variables { + variables[key] = value + } + variables["site_name"] = s.siteName(ctx) + variables["recipient_email"] = input.RecipientEmail + if strings.TrimSpace(input.RecipientName) != "" { + variables["recipient_name"] = input.RecipientName + } + if notificationEmailEventDefinitions[event].Optional { + if unsubscribeURL, err := s.buildUnsubscribeURL(ctx, input.RecipientEmail, event); err == nil { + variables["unsubscribe_url"] = unsubscribeURL + } + } + return variables +} + +func (s *NotificationEmailService) siteName(ctx context.Context) string { + if s == nil || s.settingRepo == nil { + return defaultSiteName + } + name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName) + if err != nil || strings.TrimSpace(name) == "" { + return defaultSiteName + } + return strings.TrimSpace(name) +} + +func (s *NotificationEmailService) baseURL(ctx context.Context) string { + if s == nil || s.settingRepo == nil { + return "" + } + for _, key := range []string{SettingKeyAPIBaseURL, SettingKeyFrontendURL} { + value, err := s.settingRepo.GetValue(ctx, key) + if err == nil && strings.TrimSpace(value) != "" { + return strings.TrimRight(strings.TrimSpace(value), "/") + } + } + return "" +} + +func (s *NotificationEmailService) buildUnsubscribeURL(ctx context.Context, email, event string) (string, error) { + token, err := s.createUnsubscribeToken(ctx, email, event) + if err != nil { + return "", err + } + path := "/api/v1/settings/email-unsubscribe?token=" + url.QueryEscape(token) + baseURL := s.baseURL(ctx) + if baseURL == "" { + return path, nil + } + return baseURL + path, nil +} + +func (s *NotificationEmailService) createUnsubscribeToken(ctx context.Context, email, event string) (string, error) { + secret, err := s.unsubscribeSecret(ctx) + if err != nil { + return "", err + } + claims := notificationEmailUnsubscribeClaims{Email: strings.TrimSpace(email), Event: event, Exp: time.Now().Add(notificationEmailUnsubscribeTTL).Unix()} + payload, err := json.Marshal(claims) + if err != nil { + return "", err + } + encodedPayload := base64.RawURLEncoding.EncodeToString(payload) + signature := signNotificationEmailToken(secret, encodedPayload) + return encodedPayload + "." + signature, nil +} + +func (s *NotificationEmailService) parseUnsubscribeToken(ctx context.Context, token string) (notificationEmailUnsubscribeClaims, error) { + parts := strings.Split(strings.TrimSpace(token), ".") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token") + } + secret, err := s.unsubscribeSecret(ctx) + if err != nil { + return notificationEmailUnsubscribeClaims{}, err + } + expected := signNotificationEmailToken(secret, parts[0]) + if !hmac.Equal([]byte(expected), []byte(parts[1])) { + return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token signature") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token payload") + } + var claims notificationEmailUnsubscribeClaims + if err := json.Unmarshal(payload, &claims); err != nil { + return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token payload") + } + if strings.TrimSpace(claims.Email) == "" || strings.TrimSpace(claims.Event) == "" { + return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token claims") + } + if claims.Exp <= time.Now().Unix() { + return notificationEmailUnsubscribeClaims{}, errors.New("unsubscribe token expired") + } + return claims, nil +} + +func (s *NotificationEmailService) unsubscribeSecret(ctx context.Context) (string, error) { + secret, err := s.settingRepo.GetValue(ctx, notificationEmailUnsubscribeSecretKey) + if err == nil && strings.TrimSpace(secret) != "" { + return strings.TrimSpace(secret), nil + } + if err != nil && !errors.Is(err, ErrSettingNotFound) { + return "", err + } + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + secret = base64.RawURLEncoding.EncodeToString(buf) + if err := s.settingRepo.Set(ctx, notificationEmailUnsubscribeSecretKey, secret); err != nil { + return "", err + } + return secret, nil +} + +func (s *NotificationEmailService) deliveryExists(ctx context.Context, key string) (bool, error) { + _, err := s.settingRepo.GetValue(ctx, key) + if err == nil { + return true, nil + } + if errors.Is(err, ErrSettingNotFound) { + return false, nil + } + return false, err +} + +func validateNotificationEmailTemplate(event, subject, htmlBody string) error { + subject = strings.TrimSpace(subject) + if subject == "" { + return errors.New("email subject cannot be empty") + } + if len([]rune(subject)) > notificationEmailMaxSubjectLength { + return fmt.Errorf("email subject cannot exceed %d characters", notificationEmailMaxSubjectLength) + } + if strings.TrimSpace(htmlBody) == "" { + return errors.New("email html cannot be empty") + } + if len([]byte(htmlBody)) > notificationEmailMaxHTMLLength { + return fmt.Errorf("email html cannot exceed %d bytes", notificationEmailMaxHTMLLength) + } + allowed := notificationEmailAllowedPlaceholderSet(event) + for _, placeholder := range notificationEmailPlaceholdersIn(subject + "\n" + htmlBody) { + if _, ok := allowed[placeholder]; !ok { + return fmt.Errorf("unsupported placeholder {{%s}} for event %s", placeholder, event) + } + } + return nil +} + +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, nil, false) + if err != nil { + return NotificationEmailPreview{}, err + } + 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, rawHTMLVariables map[string]string, escapeHTML bool) (string, error) { + allowed := notificationEmailAllowedPlaceholderSet(event) + var renderErr error + rendered := notificationEmailPlaceholderPattern.ReplaceAllStringFunc(raw, func(match string) string { + if renderErr != nil { + return "" + } + parts := notificationEmailPlaceholderPattern.FindStringSubmatch(match) + if len(parts) != 2 { + return "" + } + name := parts[1] + if _, ok := allowed[name]; !ok { + renderErr = fmt.Errorf("unsupported placeholder {{%s}} for event %s", name, event) + 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 = "" + } + if escapeHTML { + return html.EscapeString(value) + } + return sanitizeEmailHeader(value) + }) + if renderErr != nil { + return "", renderErr + } + 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)) + for _, placeholder := range info.Placeholders { + allowed[placeholder] = struct{}{} + } + return allowed +} + +func notificationEmailPlaceholdersIn(raw string) []string { + matches := notificationEmailPlaceholderPattern.FindAllStringSubmatch(raw, -1) + seen := make(map[string]struct{}, len(matches)) + out := make([]string, 0, len(matches)) + for _, match := range matches { + if len(match) != 2 { + continue + } + if _, exists := seen[match[1]]; exists { + continue + } + seen[match[1]] = struct{}{} + out = append(out, match[1]) + } + return out +} + +func normalizeNotificationLocale(raw string) string { + trimmed := strings.ToLower(strings.TrimSpace(raw)) + if trimmed == "" { + return notificationEmailDefaultLocale + } + for _, part := range strings.Split(trimmed, ",") { + tag := strings.TrimSpace(strings.Split(part, ";")[0]) + if strings.HasPrefix(tag, "zh") || tag == "cn" { + return notificationEmailLocaleChinese + } + if strings.HasPrefix(tag, "en") { + return notificationEmailDefaultLocale + } + } + return notificationEmailDefaultLocale +} + +func notificationEmailTemplateKey(event, locale string) string { + return notificationEmailTemplateKeyPrefix + event + ":" + locale +} + +func notificationEmailPreferenceKey(event, email string) string { + return notificationEmailPreferenceKeyPrefix + event + ":" + notificationEmailHash(email) +} + +func notificationEmailDeliveryKey(event, sourceType, sourceID, recipient, reminderKey string) string { + if strings.TrimSpace(sourceType) == "" || strings.TrimSpace(sourceID) == "" || strings.TrimSpace(recipient) == "" { + return "" + } + parts := []string{notificationEmailDeliveryKeyPrefix, event, ":", safeNotificationEmailKeyPart(sourceType), ":", safeNotificationEmailKeyPart(sourceID), ":", notificationEmailHash(recipient)} + if strings.TrimSpace(reminderKey) != "" { + parts = append(parts, ":", safeNotificationEmailKeyPart(reminderKey)) + } + return strings.Join(parts, "") +} + +func notificationEmailHash(value string) string { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed == "" { + return "" + } + sum := sha256.Sum256([]byte(trimmed)) + return hex.EncodeToString(sum[:]) +} + +func safeNotificationEmailKeyPart(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + var builder strings.Builder + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.' { + _, _ = builder.WriteRune(r) + } else { + _, _ = builder.WriteRune('_') + } + } + return builder.String() +} + +func signNotificationEmailToken(secret, payload string) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func isSafeNotificationEmailURL(raw string) bool { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return true + } + parsed, err := url.Parse(trimmed) + if err != nil { + return false + } + if parsed.IsAbs() { + scheme := strings.ToLower(parsed.Scheme) + return scheme == "http" || scheme == "https" || scheme == "mailto" + } + return strings.HasPrefix(trimmed, "/") +} + +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", + "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", + "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", + Description: "Sent after a subscription purchase is fulfilled.", + Category: "subscription", + Optional: false, + Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "subscription_group", "subscription_days", "expiry_time", "order_id"), + }, + NotificationEmailEventSubscriptionExpiryReminder: { + Event: NotificationEmailEventSubscriptionExpiryReminder, + Label: "Subscription expiry reminder", + Description: "Optional reminder sent before an active subscription expires.", + Category: "subscription", + Optional: true, + Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "subscription_group", "expiry_time", "days_remaining", "unsubscribe_url"), + }, + NotificationEmailEventBalanceLow: { + Event: NotificationEmailEventBalanceLow, + Label: "Low balance alert", + Description: "Optional alert sent when balance crosses the configured low-balance threshold.", + Category: "billing", + Optional: true, + Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "current_balance", "threshold", "recharge_url", "unsubscribe_url"), + }, + NotificationEmailEventBalanceRechargeSuccess: { + Event: NotificationEmailEventBalanceRechargeSuccess, + Label: "Balance recharge success", + Description: "Sent after a balance recharge order is fulfilled.", + Category: "billing", + 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", + HTML: notificationEmailCard("#2563eb", "Subscription activated", ` +

Hello {{recipient_name}},

+

Your subscription for {{subscription_group}} has been activated for {{subscription_days}} days.

+

Expiry time: {{expiry_time}}

+

Order ID: {{order_id}}

`), + }, + notificationEmailLocaleChinese: { + Subject: "[{{site_name}}] 订阅购买成功", + HTML: notificationEmailCard("#2563eb", "订阅已开通", ` +

{{recipient_name}},您好:

+

您的 {{subscription_group}} 订阅已成功开通,有效期 {{subscription_days}} 天。

+

到期时间:{{expiry_time}}

+

订单号:{{order_id}}

`), + }, + }, + NotificationEmailEventSubscriptionExpiryReminder: { + notificationEmailDefaultLocale: { + Subject: "[{{site_name}}] Subscription expires in {{days_remaining}} day(s)", + HTML: notificationEmailCard("#f97316", "Subscription expiry reminder", ` +

Hello {{recipient_name}},

+

Your {{subscription_group}} subscription will expire in {{days_remaining}} day(s).

+

Expiry time: {{expiry_time}}

+

Unsubscribe from optional subscription reminders

`), + }, + notificationEmailLocaleChinese: { + Subject: "[{{site_name}}] 订阅将在 {{days_remaining}} 天后到期", + HTML: notificationEmailCard("#f97316", "订阅到期提醒", ` +

{{recipient_name}},您好:

+

您的 {{subscription_group}} 订阅将在 {{days_remaining}} 天后到期。

+

到期时间:{{expiry_time}}

+

退订此类订阅提醒

`), + }, + }, + NotificationEmailEventBalanceLow: { + notificationEmailDefaultLocale: { + Subject: "[{{site_name}}] Low balance alert", + HTML: notificationEmailCard("#d97706", "Low balance alert", ` +

Hello {{recipient_name}},

+

Your current balance is ${{current_balance}}, below the configured alert threshold of ${{threshold}}.

+

Please recharge in time to avoid service interruption.

+

Recharge now

+

Unsubscribe from optional balance alerts

`), + }, + notificationEmailLocaleChinese: { + Subject: "[{{site_name}}] 余额不足提醒", + HTML: notificationEmailCard("#d97706", "余额不足提醒", ` +

{{recipient_name}},您好:

+

您当前余额为 ${{current_balance}},已低于提醒阈值 ${{threshold}}

+

请及时充值以免服务中断。

+

立即充值

+

退订此类余额提醒

`), + }, + }, + NotificationEmailEventBalanceRechargeSuccess: { + notificationEmailDefaultLocale: { + Subject: "[{{site_name}}] Balance recharge successful", + HTML: notificationEmailCard("#16a34a", "Recharge successful", ` +

Hello {{recipient_name}},

+

Your balance recharge of ${{recharge_amount}} has been completed.

+

Current balance: ${{current_balance}}

+

Order ID: {{order_id}}

`), + }, + notificationEmailLocaleChinese: { + Subject: "[{{site_name}}] 余额充值成功", + HTML: notificationEmailCard("#16a34a", "余额充值成功", ` +

{{recipient_name}},您好:

+

您的余额充值 ${{recharge_amount}} 已完成。

+

当前余额:${{current_balance}}

+

订单号:{{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}}
`), + }, + }, +} + +func notificationEmailCard(accent, title, content string) string { + return ` + + + + + + + +
+

` + title + `

+
` + content + `
+ +
+ +` +} diff --git a/backend/internal/service/notification_email_service_test.go b/backend/internal/service/notification_email_service_test.go new file mode 100644 index 00000000..f375ba7e --- /dev/null +++ b/backend/internal/service/notification_email_service_test.go @@ -0,0 +1,343 @@ +package service + +import ( + "context" + "errors" + "strings" + "sync" + "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")) +} + +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")) +} diff --git a/backend/internal/service/ops_alert_evaluator_service.go b/backend/internal/service/ops_alert_evaluator_service.go index 11c5d5ce..c6a58a1b 100644 --- a/backend/internal/service/ops_alert_evaluator_service.go +++ b/backend/internal/service/ops_alert_evaluator_service.go @@ -686,6 +686,21 @@ func (s *OpsAlertEvaluatorService) maybeSendAlertEmail(ctx context.Context, runt if !s.emailLimiter.Allow(time.Now().UTC()) { continue } + if s.emailService.notificationEmailService != nil { + if err := s.emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventOpsAlert, + RecipientEmail: addr, + RecipientName: emailRecipientName(addr), + SourceType: "ops_alert", + SourceID: fmt.Sprintf("%d", event.ID), + Variables: opsAlertEmailVariables(rule, event), + }); err == nil { + anySent = true + continue + } else if !shouldFallbackNotificationEmail(err) { + continue + } + } if err := s.emailService.SendEmail(ctx, addr, subject, body); err != nil { // Ignore per-recipient failures; continue best-effort. continue @@ -699,6 +714,46 @@ func (s *OpsAlertEvaluatorService) maybeSendAlertEmail(ctx context.Context, runt return anySent } +func opsAlertEmailVariables(rule *OpsAlertRule, event *OpsAlertEvent) map[string]string { + variables := map[string]string{ + "rule_name": "-", + "severity": "-", + "alert_status": "-", + "metric_type": "-", + "operator": "-", + "metric_value": "-", + "threshold_value": "-", + "triggered_at": time.Now().UTC().Format(time.RFC3339), + "alert_description": "-", + } + if rule != nil { + variables["rule_name"] = strings.TrimSpace(rule.Name) + variables["severity"] = strings.TrimSpace(rule.Severity) + variables["metric_type"] = strings.TrimSpace(rule.MetricType) + variables["operator"] = strings.TrimSpace(rule.Operator) + variables["threshold_value"] = fmt.Sprintf("%.2f", rule.Threshold) + if strings.TrimSpace(rule.Description) != "" { + variables["alert_description"] = strings.TrimSpace(rule.Description) + } + } + if event != nil { + variables["alert_status"] = strings.TrimSpace(event.Status) + if event.MetricValue != nil { + variables["metric_value"] = fmt.Sprintf("%.2f", *event.MetricValue) + } + if event.ThresholdValue != nil { + variables["threshold_value"] = fmt.Sprintf("%.2f", *event.ThresholdValue) + } + if !event.FiredAt.IsZero() { + variables["triggered_at"] = event.FiredAt.UTC().Format(time.RFC3339) + } + if strings.TrimSpace(event.Description) != "" { + variables["alert_description"] = strings.TrimSpace(event.Description) + } + } + return variables +} + func buildOpsAlertEmailBody(rule *OpsAlertRule, event *OpsAlertEvent) string { if rule == nil || event == nil { return "" diff --git a/backend/internal/service/ops_scheduled_report_service.go b/backend/internal/service/ops_scheduled_report_service.go index 98b2045d..54aad114 100644 --- a/backend/internal/service/ops_scheduled_report_service.go +++ b/backend/internal/service/ops_scheduled_report_service.go @@ -337,6 +337,7 @@ func (s *OpsScheduledReportService) runReport(ctx context.Context, report *opsSc } subject := fmt.Sprintf("[Ops Report] %s", strings.TrimSpace(report.Name)) + templateVariables := opsScheduledReportEmailVariables(report, now) attempts := 0 for _, to := range recipients { @@ -345,6 +346,24 @@ func (s *OpsScheduledReportService) runReport(ctx context.Context, report *opsSc continue } attempts++ + if s.emailService.notificationEmailService != nil { + if err := s.emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventOpsScheduledReport, + RecipientEmail: addr, + RecipientName: emailRecipientName(addr), + SourceType: "ops_scheduled_report", + SourceID: opsScheduledReportDeliverySourceID(report), + ReminderKey: now.UTC().Format("2006-01-02T15:04"), + Variables: templateVariables, + RawHTMLVariables: map[string]string{ + "report_html": content, + }, + }); err == nil { + continue + } else if !shouldFallbackNotificationEmail(err) { + continue + } + } if err := s.emailService.SendEmail(ctx, addr, subject, content); err != nil { // Ignore per-recipient failures; continue best-effort. continue @@ -353,6 +372,46 @@ func (s *OpsScheduledReportService) runReport(ctx context.Context, report *opsSc return attempts, nil } +func opsScheduledReportDeliverySourceID(report *opsScheduledReport) string { + if report == nil { + return "scheduled_report" + } + parts := []string{ + strings.TrimSpace(report.ReportType), + strings.TrimSpace(report.Name), + strings.TrimSpace(report.Schedule), + } + joined := strings.Trim(strings.Join(parts, ":"), ":") + if joined == "" { + return "scheduled_report" + } + return joined +} + +func opsScheduledReportEmailVariables(report *opsScheduledReport, now time.Time) map[string]string { + end := now.UTC() + start := end + name := "Ops report" + reportType := "scheduled_report" + if report != nil { + if strings.TrimSpace(report.Name) != "" { + name = strings.TrimSpace(report.Name) + } + if strings.TrimSpace(report.ReportType) != "" { + reportType = strings.TrimSpace(report.ReportType) + } + if report.TimeRange > 0 { + start = end.Add(-report.TimeRange) + } + } + return map[string]string{ + "report_name": name, + "report_type": reportType, + "report_start_time": start.Format(time.RFC3339), + "report_end_time": end.Format(time.RFC3339), + } +} + func (s *OpsScheduledReportService) generateReportHTML(ctx context.Context, report *opsScheduledReport, now time.Time) (string, error) { if s == nil || s.opsService == nil || report == nil { return "", fmt.Errorf("service not initialized") diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index 8a26e868..b6b19ca0 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -310,9 +310,87 @@ func (s *PaymentService) markCompleted(ctx context.Context, o *dbent.PaymentOrde "creditedAmount": o.Amount, "payAmount": o.PayAmount, }) + s.dispatchPaymentFulfillmentNotification(o, auditAction) return nil } +func (s *PaymentService) dispatchPaymentFulfillmentNotification(o *dbent.PaymentOrder, auditAction string) { + if s == nil || s.notificationEmailService == nil || o == nil { + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), emailSendTimeout) + defer cancel() + var err error + switch auditAction { + case "RECHARGE_SUCCESS": + err = s.sendBalanceRechargeSuccessNotification(ctx, o) + case "SUBSCRIPTION_SUCCESS": + err = s.sendSubscriptionPurchaseSuccessNotification(ctx, o) + default: + return + } + if err != nil { + slog.Warn("payment fulfillment notification email failed", "order_id", o.ID, "action", auditAction, "err", err.Error()) + } + }() +} + +func (s *PaymentService) sendBalanceRechargeSuccessNotification(ctx context.Context, o *dbent.PaymentOrder) error { + currentBalance := "" + if s.userRepo != nil { + if user, err := s.userRepo.GetByID(ctx, o.UserID); err == nil && user != nil { + currentBalance = fmt.Sprintf("%.2f", user.Balance) + } + } + return s.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventBalanceRechargeSuccess, + RecipientEmail: o.UserEmail, + RecipientName: firstNonEmpty(o.UserName, o.UserEmail), + UserID: o.UserID, + SourceType: "payment_order", + SourceID: strconv.FormatInt(o.ID, 10), + Variables: map[string]string{ + "recharge_amount": fmt.Sprintf("%.2f", o.Amount), + "current_balance": currentBalance, + "order_id": strconv.FormatInt(o.ID, 10), + }, + }) +} + +func (s *PaymentService) sendSubscriptionPurchaseSuccessNotification(ctx context.Context, o *dbent.PaymentOrder) error { + variables := map[string]string{ + "subscription_group": "Subscription", + "subscription_days": "", + "expiry_time": "", + "order_id": strconv.FormatInt(o.ID, 10), + } + if o.SubscriptionDays != nil { + variables["subscription_days"] = strconv.Itoa(*o.SubscriptionDays) + } + if o.SubscriptionGroupID != nil { + if s.groupRepo != nil { + if group, err := s.groupRepo.GetByID(ctx, *o.SubscriptionGroupID); err == nil && group != nil && strings.TrimSpace(group.Name) != "" { + variables["subscription_group"] = group.Name + } + } + if s.subscriptionSvc != nil { + if sub, err := s.subscriptionSvc.GetActiveSubscription(ctx, o.UserID, *o.SubscriptionGroupID); err == nil && sub != nil { + variables["expiry_time"] = sub.ExpiresAt.Format("2006-01-02 15:04") + } + } + } + return s.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventSubscriptionPurchaseSuccess, + RecipientEmail: o.UserEmail, + RecipientName: firstNonEmpty(o.UserName, o.UserEmail), + UserID: o.UserID, + SourceType: "payment_order", + SourceID: strconv.FormatInt(o.ID, 10), + Variables: variables, + }) +} + func (s *PaymentService) ExecuteSubscriptionFulfillment(ctx context.Context, oid int64) error { o, err := s.entClient.PaymentOrder.Get(ctx, oid) if err != nil { diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index e6cc4b3c..83edb9e1 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -48,6 +48,9 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest if user.Status != payment.EntityStatusActive { return nil, infraerrors.Forbidden("USER_INACTIVE", "user account is disabled") } + if s.notificationEmailService != nil { + s.notificationEmailService.RememberRecipientLocale(ctx, req.UserID, user.Email, req.Locale) + } orderAmount := req.Amount limitAmount := req.Amount if plan != nil { diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index 42553840..2759aba1 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -83,6 +83,7 @@ type CreateOrderRequest struct { PaymentSource string OrderType string PlanID int64 + Locale string } type CreateOrderResponse struct { @@ -174,18 +175,19 @@ type TopUserStat struct { // --- Service --- type PaymentService struct { - providerMu sync.Mutex - providersLoaded bool - entClient *dbent.Client - registry *payment.Registry - loadBalancer payment.LoadBalancer - redeemService *RedeemService - subscriptionSvc *SubscriptionService - configService *PaymentConfigService - userRepo UserRepository - groupRepo GroupRepository - resumeService *PaymentResumeService - affiliateService *AffiliateService + providerMu sync.Mutex + providersLoaded bool + entClient *dbent.Client + registry *payment.Registry + loadBalancer payment.LoadBalancer + redeemService *RedeemService + subscriptionSvc *SubscriptionService + configService *PaymentConfigService + userRepo UserRepository + groupRepo GroupRepository + resumeService *PaymentResumeService + affiliateService *AffiliateService + notificationEmailService *NotificationEmailService } func NewPaymentService(entClient *dbent.Client, registry *payment.Registry, loadBalancer payment.LoadBalancer, redeemService *RedeemService, subscriptionSvc *SubscriptionService, configService *PaymentConfigService, userRepo UserRepository, groupRepo GroupRepository, affiliateService *AffiliateService) *PaymentService { @@ -194,6 +196,10 @@ func NewPaymentService(entClient *dbent.Client, registry *payment.Registry, load return svc } +func (s *PaymentService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) { + s.notificationEmailService = notificationEmailService +} + // --- Provider Registry --- // EnsureProviders lazily initializes the provider registry on first call. diff --git a/backend/internal/service/subscription_expiry_service.go b/backend/internal/service/subscription_expiry_service.go index ce6b32b8..9b3a0309 100644 --- a/backend/internal/service/subscription_expiry_service.go +++ b/backend/internal/service/subscription_expiry_service.go @@ -2,18 +2,23 @@ package service import ( "context" + "fmt" "log" + "strconv" "sync" "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" ) // SubscriptionExpiryService periodically updates expired subscription status. type SubscriptionExpiryService struct { - userSubRepo UserSubscriptionRepository - interval time.Duration - stopCh chan struct{} - stopOnce sync.Once - wg sync.WaitGroup + userSubRepo UserSubscriptionRepository + notificationEmailService *NotificationEmailService + interval time.Duration + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup } func NewSubscriptionExpiryService(userSubRepo UserSubscriptionRepository, interval time.Duration) *SubscriptionExpiryService { @@ -24,6 +29,10 @@ func NewSubscriptionExpiryService(userSubRepo UserSubscriptionRepository, interv } } +func (s *SubscriptionExpiryService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) { + s.notificationEmailService = notificationEmailService +} + func (s *SubscriptionExpiryService) Start() { if s == nil || s.userSubRepo == nil || s.interval <= 0 { return @@ -68,4 +77,50 @@ func (s *SubscriptionExpiryService) runOnce() { if updated > 0 { log.Printf("[SubscriptionExpiry] Updated %d expired subscriptions", updated) } + s.sendExpiryReminders(ctx) +} + +func (s *SubscriptionExpiryService) sendExpiryReminders(ctx context.Context) { + if s == nil || s.userSubRepo == nil || s.notificationEmailService == nil { + return + } + for page := 1; ; page++ { + subs, pag, err := s.userSubRepo.List(ctx, pagination.PaginationParams{Page: page, PageSize: 200}, nil, nil, SubscriptionStatusActive, "", "expires_at", "asc") + if err != nil { + log.Printf("[SubscriptionExpiry] List active subscriptions for reminder failed: %v", err) + return + } + for i := range subs { + s.sendExpiryReminderIfDue(ctx, &subs[i]) + } + if pag == nil || page >= pag.Pages || len(subs) == 0 { + return + } + } +} + +func (s *SubscriptionExpiryService) sendExpiryReminderIfDue(ctx context.Context, sub *UserSubscription) { + if sub == nil || sub.User == nil || sub.Group == nil || sub.User.Email == "" { + return + } + daysRemaining := sub.DaysRemaining() + if daysRemaining != 7 && daysRemaining != 3 && daysRemaining != 1 { + return + } + if err := s.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventSubscriptionExpiryReminder, + RecipientEmail: sub.User.Email, + RecipientName: firstNonEmpty(sub.User.Username, sub.User.Email), + UserID: sub.UserID, + SourceType: "user_subscription", + SourceID: strconv.FormatInt(sub.ID, 10), + ReminderKey: fmt.Sprintf("%dd", daysRemaining), + Variables: map[string]string{ + "subscription_group": sub.Group.Name, + "expiry_time": sub.ExpiresAt.Format("2006-01-02 15:04"), + "days_remaining": strconv.Itoa(daysRemaining), + }, + }); err != nil { + log.Printf("[SubscriptionExpiry] Send expiry reminder failed: subscription=%d user=%d err=%v", sub.ID, sub.UserID, err) + } } diff --git a/backend/internal/service/totp_service.go b/backend/internal/service/totp_service.go index 052739ed..6a0989c3 100644 --- a/backend/internal/service/totp_service.go +++ b/backend/internal/service/totp_service.go @@ -517,7 +517,7 @@ func (s *TotpService) GetVerificationMethod(ctx context.Context) *VerificationMe } // SendVerifyCode sends an email verification code for TOTP operations -func (s *TotpService) SendVerifyCode(ctx context.Context, userID int64) error { +func (s *TotpService) SendVerifyCode(ctx context.Context, userID int64, locale ...string) error { // Check if email verification is enabled if !s.settingService.IsEmailVerifyEnabled(ctx) { return infraerrors.BadRequest("EMAIL_VERIFY_NOT_ENABLED", "email verification is not enabled") @@ -533,5 +533,5 @@ func (s *TotpService) SendVerifyCode(ctx context.Context, userID int64) error { siteName := s.settingService.GetSiteName(ctx) // Send verification code via queue - return s.emailQueueService.EnqueueVerifyCode(user.Email, siteName) + return s.emailQueueService.EnqueueVerifyCode(user.Email, siteName, firstEmailLocale(locale)) } diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index b346f6e7..36bcf1c8 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -1121,7 +1121,7 @@ func (s *UserService) Delete(ctx context.Context, userID int64) error { } // SendNotifyEmailCode sends a verification code to the extra notification email. -func (s *UserService) SendNotifyEmailCode(ctx context.Context, userID int64, email string, emailService *EmailService, cache EmailCache) error { +func (s *UserService) SendNotifyEmailCode(ctx context.Context, userID int64, email string, emailService *EmailService, cache EmailCache, locale ...string) error { if err := checkNotifyCodeRateLimit(ctx, cache, userID, email); err != nil { return err } @@ -1133,7 +1133,7 @@ func (s *UserService) SendNotifyEmailCode(ctx context.Context, userID int64, ema // Send email first — if SMTP fails, don't write cache or increment counters, // so the user is not locked out by cooldown/rate-limit for a code they never received. - if err := s.sendNotifyVerifyEmail(ctx, emailService, email, code); err != nil { + if err := s.sendNotifyVerifyEmail(ctx, emailService, userID, email, code, firstEmailLocale(locale)); err != nil { return err } @@ -1179,13 +1179,33 @@ func saveNotifyVerifyCode(ctx context.Context, cache EmailCache, email, code str } // sendNotifyVerifyEmail builds and sends the verification email. -func (s *UserService) sendNotifyVerifyEmail(ctx context.Context, emailService *EmailService, email, code string) error { +func (s *UserService) sendNotifyVerifyEmail(ctx context.Context, emailService *EmailService, userID int64, email, code, locale string) error { siteName := "Sub2API" if s.settingRepo != nil { if name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName); err == nil && name != "" { siteName = name } } + if emailService.notificationEmailService != nil { + if err := emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventNotificationEmailVerifyCode, + Locale: locale, + RecipientEmail: email, + RecipientName: emailRecipientName(email), + UserID: userID, + Variables: map[string]string{ + "verification_code": code, + "expires_in_minutes": strconv.Itoa(int(verifyCodeTTL / time.Minute)), + }, + }); err == nil { + return nil + } else { + if !shouldFallbackNotificationEmail(err) { + return err + } + slog.Warn("template notification email verification failed; falling back to built-in body", "recipient_hash", notificationEmailHash(email), "err", err.Error()) + } + } subject := fmt.Sprintf("[%s] 通知邮箱验证码 / Notification Email Verification", siteName) body := buildNotifyVerifyEmailBody(code, siteName) return emailService.SendEmail(ctx, email, subject, body) diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index f0f5ff14..2bd5812b 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -151,8 +151,9 @@ func ProvideAccountExpiryService(accountRepo AccountRepository) *AccountExpirySe } // ProvideSubscriptionExpiryService creates and starts SubscriptionExpiryService. -func ProvideSubscriptionExpiryService(userSubRepo UserSubscriptionRepository) *SubscriptionExpiryService { +func ProvideSubscriptionExpiryService(userSubRepo UserSubscriptionRepository, notificationEmailService *NotificationEmailService) *SubscriptionExpiryService { svc := NewSubscriptionExpiryService(userSubRepo, time.Minute) + svc.SetNotificationEmailService(notificationEmailService) svc.Start() return svc } @@ -478,6 +479,7 @@ var ProviderSet = wire.NewSet( ProvideOpsCleanupService, ProvideOpsScheduledReportService, NewEmailService, + NewNotificationEmailService, ProvideEmailQueueService, NewTurnstileService, NewSubscriptionService, @@ -514,7 +516,7 @@ var ProviderSet = wire.NewSet( NewContentModerationService, NewAffiliateService, ProvidePaymentConfigService, - NewPaymentService, + ProvidePaymentService, ProvidePaymentOrderExpiryService, ProvideBalanceNotifyService, ProvideChannelMonitorService, @@ -529,8 +531,17 @@ func ProvidePaymentConfigService(entClient *dbent.Client, settingRepo SettingRep } // ProvideBalanceNotifyService creates BalanceNotifyService -func ProvideBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository, accountRepo AccountRepository) *BalanceNotifyService { - return NewBalanceNotifyService(emailService, settingRepo, accountRepo) +func ProvideBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository, accountRepo AccountRepository, notificationEmailService *NotificationEmailService) *BalanceNotifyService { + svc := NewBalanceNotifyService(emailService, settingRepo, accountRepo) + svc.SetNotificationEmailService(notificationEmailService) + return svc +} + +// ProvidePaymentService creates PaymentService and attaches notification email delivery. +func ProvidePaymentService(entClient *dbent.Client, registry *payment.Registry, loadBalancer payment.LoadBalancer, redeemService *RedeemService, subscriptionSvc *SubscriptionService, configService *PaymentConfigService, userRepo UserRepository, groupRepo GroupRepository, affiliateService *AffiliateService, notificationEmailService *NotificationEmailService) *PaymentService { + svc := NewPaymentService(entClient, registry, loadBalancer, redeemService, subscriptionSvc, configService, userRepo, groupRepo, affiliateService) + svc.SetNotificationEmailService(notificationEmailService) + return svc } // ProvidePaymentOrderExpiryService creates and starts PaymentOrderExpiryService. diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts index decb2a37..b777b22e 100644 --- a/frontend/src/__tests__/setup.ts +++ b/frontend/src/__tests__/setup.ts @@ -5,6 +5,45 @@ import { config } from '@vue/test-utils' import { vi } from 'vitest' +function createMemoryStorage(): Storage { + const values = new Map() + + return { + get length() { + return values.size + }, + clear() { + values.clear() + }, + getItem(key: string) { + return values.has(key) ? values.get(key)! : null + }, + key(index: number) { + return Array.from(values.keys())[index] ?? null + }, + removeItem(key: string) { + values.delete(key) + }, + setItem(key: string, value: string) { + values.set(key, String(value)) + } + } +} + +if (typeof globalThis.localStorage === 'undefined' || typeof globalThis.localStorage.getItem !== 'function') { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: createMemoryStorage() + }) +} + +if (typeof window !== 'undefined' && typeof window.localStorage.getItem !== 'function') { + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: globalThis.localStorage + }) +} + // Mock requestIdleCallback (Safari < 15 不支持) if (typeof globalThis.requestIdleCallback === 'undefined') { globalThis.requestIdleCallback = ((callback: IdleRequestCallback) => { diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index fd9e2b8d..882ab83b 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -854,6 +854,105 @@ export async function sendTestEmail( return data; } +// ==================== Email Template Settings ==================== + +export interface EmailTemplateOption { + value: string; + label?: string; + description?: string; +} + +export type EmailTemplateEventOption = string | EmailTemplateOption; + +export interface EmailTemplateSummary { + event: string; + locale: string; + subject: string; + is_custom?: boolean; + updated_at?: string; +} + +export interface EmailTemplateListResponse { + events: EmailTemplateEventOption[]; + locales: string[]; + templates?: EmailTemplateSummary[]; + placeholders?: string[]; +} + +export interface EmailTemplateDetail { + event: string; + locale: string; + subject: string; + html: string; + is_custom?: boolean; + updated_at?: string; + placeholders?: string[]; +} + +export interface UpdateEmailTemplateRequest { + subject: string; + html: string; +} + +export interface PreviewEmailTemplateRequest extends UpdateEmailTemplateRequest { + event: string; + locale: string; +} + +export interface EmailTemplatePreviewResponse { + subject: string; + html: string; +} + +export async function getEmailTemplates(): Promise { + const { data } = await apiClient.get( + "/admin/settings/email-templates", + ); + return data; +} + +export async function getEmailTemplate( + event: string, + locale: string, +): Promise { + const { data } = await apiClient.get( + `/admin/settings/email-templates/${encodeURIComponent(event)}/${encodeURIComponent(locale)}`, + ); + return data; +} + +export async function updateEmailTemplate( + event: string, + locale: string, + request: UpdateEmailTemplateRequest, +): Promise { + const { data } = await apiClient.put( + `/admin/settings/email-templates/${encodeURIComponent(event)}/${encodeURIComponent(locale)}`, + request, + ); + return data; +} + +export async function restoreOfficialEmailTemplate( + event: string, + locale: string, +): Promise { + const { data } = await apiClient.post( + `/admin/settings/email-templates/${encodeURIComponent(event)}/${encodeURIComponent(locale)}/restore-official`, + ); + return data; +} + +export async function previewEmailTemplate( + request: PreviewEmailTemplateRequest, +): Promise { + const { data } = await apiClient.post( + "/admin/settings/email-template-preview", + request, + ); + return data; +} + /** * Admin API Key status response */ @@ -1160,6 +1259,11 @@ export const settingsAPI = { updateSettings, testSmtpConnection, sendTestEmail, + getEmailTemplates, + getEmailTemplate, + updateEmailTemplate, + restoreOfficialEmailTemplate, + previewEmailTemplate, getAdminApiKey, regenerateAdminApiKey, deleteAdminApiKey, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 70f7724c..5f0fe6f5 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5796,6 +5796,36 @@ export default { sending: 'Sending...', enterRecipientHint: 'Please enter a recipient email address' }, + emailTemplates: { + title: 'Email Templates', + description: 'Customize notification email subjects and HTML content for each event and locale.', + event: 'Event', + locale: 'Locale', + localeEn: 'English', + localeZh: 'Chinese', + subject: 'Subject', + subjectPlaceholder: 'Enter the email subject', + html: 'HTML Template', + htmlPlaceholder: 'Edit the email HTML template', + placeholders: 'Available Placeholders', + placeholdersHelp: 'Click a placeholder to copy it. The backend replaces these values when sending emails.', + livePreview: 'Live Preview', + previewSecurityHint: 'Preview HTML is generated by the backend preview endpoint and displayed in a sandboxed iframe with scripts disabled.', + preview: 'Preview / Refresh', + previewing: 'Previewing...', + save: 'Save Template', + saving: 'Saving...', + restoreOfficial: 'Restore Official', + restoring: 'Restoring...', + restoreConfirm: 'Restore the official template for this event and locale? Your custom version will be replaced.', + restoreSuccess: 'Official template restored', + saveSuccess: 'Email template saved', + placeholderCopied: 'Placeholder copied', + validationRequired: 'Subject and HTML template are required', + empty: 'No email template events or locales are available yet.', + noPreview: 'Refresh the preview to see the rendered email subject.', + customized: 'Customized' + }, opsMonitoring: { title: 'Ops Monitoring', description: 'Enable ops monitoring for troubleshooting and health visibility', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 3a9a64ff..a03435db 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5956,6 +5956,36 @@ export default { sending: '发送中...', enterRecipientHint: '请输入收件人邮箱地址' }, + emailTemplates: { + title: '邮件模板', + description: '按事件和语言自定义通知邮件主题与 HTML 内容。', + event: '事件', + locale: '语言', + localeEn: '英文', + localeZh: '中文', + subject: '主题', + subjectPlaceholder: '输入邮件主题', + html: 'HTML 模板', + htmlPlaceholder: '编辑邮件 HTML 模板', + placeholders: '可用占位符', + placeholdersHelp: '点击占位符可复制。后端发送邮件时会替换这些值。', + livePreview: '实时预览', + previewSecurityHint: '预览 HTML 由后端预览接口生成,并在禁用脚本的沙盒 iframe 中展示。', + preview: '预览 / 刷新', + previewing: '预览中...', + save: '保存模板', + saving: '保存中...', + restoreOfficial: '恢复官方模板', + restoring: '恢复中...', + restoreConfirm: '确定恢复此事件和语言的官方模板吗?当前自定义版本将被替换。', + restoreSuccess: '已恢复官方模板', + saveSuccess: '邮件模板已保存', + placeholderCopied: '占位符已复制', + validationRequired: '主题和 HTML 模板不能为空', + empty: '暂无可用的邮件模板事件或语言。', + noPreview: '刷新预览后查看渲染后的邮件主题。', + customized: '已自定义' + }, opsMonitoring: { title: '运维监控', description: '启用运维监控模块,用于排障与健康可视化', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 118dd1a9..ac3c3cf3 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -6255,6 +6255,9 @@ + + +
+
+
+
+

+ {{ t("admin.settings.emailTemplates.title") }} +

+

+ {{ t("admin.settings.emailTemplates.description") }} +

+
+
+ + + +
+
+ +
+
+ + {{ t("common.loading") }} +
+ + +
+
+ + + diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 39568250..8c45a9ff 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ test: { globals: true, environment: 'jsdom', + setupFiles: ['./src/__tests__/setup.ts'], include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'], exclude: ['node_modules', 'dist'], coverage: {