Merge pull request #2599 from Arron196/feature/email-template-editor

feat: 添加邮件模板编辑器与通知邮件模板化
This commit is contained in:
Wesley Liddick 2026-05-20 15:12:57 +08:00 committed by GitHub
commit 378a0a6a61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 3201 additions and 76 deletions

View File

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

View File

@ -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=

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

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

View File

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

View File

@ -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 := "<!doctype html><html><head><meta charset=\"utf-8\"><title>Unsubscribed</title></head><body style=\"font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;padding:32px;\"><h1>Unsubscribed</h1><p>You have unsubscribed <strong>" + html.EscapeString(result.Email) + "</strong> from <strong>" + html.EscapeString(result.Event) + "</strong> emails.</p></body></html>"
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 {

View File

@ -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
}

View File

@ -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

View File

@ -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,

View File

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

View File

@ -214,6 +214,7 @@ func RegisterAuthRoutes(
settings := v1.Group("/settings")
{
settings.GET("/public", h.Setting.GetPublicSettings)
settings.GET("/email-unsubscribe", h.Setting.UnsubscribeNotificationEmail)
}
// 需要认证的当前用户信息

View File

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

View File

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

View File

@ -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
}

View File

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

View File

@ -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"

View File

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

View File

@ -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
}

File diff suppressed because it is too large Load Diff

View File

@ -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: `<p>{{recipient_name}}</p><a href="{{recharge_url}}">Recharge</a>`,
Variables: map[string]string{
"recipient_name": `<script>alert("x")</script>`,
"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 <script>alert("x")</script>Injected`)
require.Contains(t, preview.HTML, `&lt;script&gt;alert(&#34;x&#34;)&lt;/script&gt;`)
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}}",
"<p>{{recipient_name}} 已充值 {{recharge_amount}}</p>",
)
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}}",
"<p>{{subscription_group}}</p>",
)
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}}",
`<section>{{report_html}}</section><p>{{recipient_name}}</p>`,
map[string]string{
"recipient_name": `<script>alert("x")</script>`,
"report_html": `<p>escaped report</p>`,
},
map[string]string{
"report_html": `<table><tr><td>trusted report</td></tr></table>`,
},
)
require.NoError(t, err)
require.Contains(t, preview.HTML, `<table><tr><td>trusted report</td></tr></table>`)
require.NotContains(t, preview.HTML, `escaped report`)
require.Contains(t, preview.HTML, `&lt;script&gt;alert(&#34;x&#34;)&lt;/script&gt;`)
require.Contains(t, preview.Subject, `<script>alert("x")</script>`)
preview, err = renderNotificationEmail(
NotificationEmailEventOpsScheduledReport,
"Recipient {{recipient_name}}",
`<p>{{recipient_name}}</p>`,
map[string]string{"recipient_name": `<em>escaped</em>`},
map[string]string{"recipient_name": `<strong>raw</strong>`},
)
require.NoError(t, err)
require.Contains(t, preview.HTML, `&lt;em&gt;escaped&lt;/em&gt;`)
require.NotContains(t, preview.HTML, `<strong>raw</strong>`)
}
func TestNotificationEmailFallbackClassification(t *testing.T) {
templateErr := notificationEmailTemplateErr(errors.New("bad template"))
configErr := notificationEmailConfigErr(errors.New("missing email service"))
deliveryErr := notificationEmailDeliveryErr(errors.New("smtp timeout"))
require.True(t, shouldFallbackNotificationEmail(templateErr))
require.True(t, shouldFallbackNotificationEmail(configErr))
require.False(t, shouldFallbackNotificationEmail(deliveryErr))
require.True(t, isNotificationEmailDeliveryError(deliveryErr))
require.False(t, isNotificationEmailDeliveryError(templateErr))
require.False(t, shouldFallbackNotificationEmail(nil))
}
func TestEmailQueueTasksPreserveLocaleHints(t *testing.T) {
queue := &EmailQueueService{taskChan: make(chan EmailTask, 2)}
require.NoError(t, queue.EnqueueVerifyCode("user@example.com", "Sub2API", "zh-CN"))
require.NoError(t, queue.EnqueuePasswordReset("user@example.com", "Sub2API", "https://example.com/reset", "en-US"))
verifyTask := <-queue.taskChan
require.Equal(t, TaskTypeVerifyCode, verifyTask.TaskType)
require.Equal(t, "zh-CN", verifyTask.Locale)
resetTask := <-queue.taskChan
require.Equal(t, TaskTypePasswordReset, resetTask.TaskType)
require.Equal(t, "en-US", resetTask.Locale)
}
func TestOpsScheduledReportDeliverySourceIDIncludesReportIdentity(t *testing.T) {
report := &opsScheduledReport{Name: "日报", ReportType: "daily_summary", Schedule: "0 9 * * *"}
sourceID := opsScheduledReportDeliverySourceID(report)
require.Contains(t, sourceID, "daily_summary")
require.Contains(t, sourceID, "日报")
require.Contains(t, sourceID, "0 9 * * *")
require.NotEqual(t, sourceID, opsScheduledReportDeliverySourceID(&opsScheduledReport{Name: "周报", ReportType: "weekly_summary", Schedule: "0 9 * * 1"}))
require.Equal(t, "scheduled_report", opsScheduledReportDeliverySourceID(nil))
}
func TestNotificationEmailUnsubscribeOnlyAllowsOptionalEvents(t *testing.T) {
ctx := context.Background()
svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
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 &notificationEmailMemorySettingRepo{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"))
}

View File

@ -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 ""

View File

@ -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")

View File

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

View File

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

View File

@ -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.

View File

@ -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)
}
}

View File

@ -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))
}

View File

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

View File

@ -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.

View File

@ -5,6 +5,45 @@
import { config } from '@vue/test-utils'
import { vi } from 'vitest'
function createMemoryStorage(): Storage {
const values = new Map<string, string>()
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) => {

View File

@ -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<EmailTemplateListResponse> {
const { data } = await apiClient.get<EmailTemplateListResponse>(
"/admin/settings/email-templates",
);
return data;
}
export async function getEmailTemplate(
event: string,
locale: string,
): Promise<EmailTemplateDetail> {
const { data } = await apiClient.get<EmailTemplateDetail>(
`/admin/settings/email-templates/${encodeURIComponent(event)}/${encodeURIComponent(locale)}`,
);
return data;
}
export async function updateEmailTemplate(
event: string,
locale: string,
request: UpdateEmailTemplateRequest,
): Promise<EmailTemplateDetail> {
const { data } = await apiClient.put<EmailTemplateDetail>(
`/admin/settings/email-templates/${encodeURIComponent(event)}/${encodeURIComponent(locale)}`,
request,
);
return data;
}
export async function restoreOfficialEmailTemplate(
event: string,
locale: string,
): Promise<EmailTemplateDetail> {
const { data } = await apiClient.post<EmailTemplateDetail>(
`/admin/settings/email-templates/${encodeURIComponent(event)}/${encodeURIComponent(locale)}/restore-official`,
);
return data;
}
export async function previewEmailTemplate(
request: PreviewEmailTemplateRequest,
): Promise<EmailTemplatePreviewResponse> {
const { data } = await apiClient.post<EmailTemplatePreviewResponse>(
"/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,

View File

@ -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',

View File

@ -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: '启用运维监控模块,用于排障与健康可视化',

View File

@ -6255,6 +6255,9 @@
</div>
</div>
</div>
<EmailTemplateEditor />
<!-- Balance Low Notification -->
<div class="card">
<div
@ -6512,6 +6515,7 @@ import Toggle from "@/components/common/Toggle.vue";
import ProxySelector from "@/components/common/ProxySelector.vue";
import ImageUpload from "@/components/common/ImageUpload.vue";
import BackupSettings from "@/views/admin/BackupView.vue";
import EmailTemplateEditor from "@/views/admin/settings/EmailTemplateEditor.vue";
import { useClipboard } from "@/composables/useClipboard";
import { affiliatesAPI, type AffiliateAdminEntry, type SimpleUser as AffiliateSimpleUser } from "@/api/admin/affiliates";
import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError";

View File

@ -0,0 +1,483 @@
<template>
<div class="card">
<div
class="flex flex-col gap-3 border-b border-gray-100 px-6 py-4 dark:border-dark-700 lg:flex-row lg:items-start lg:justify-between"
>
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t("admin.settings.emailTemplates.title") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.emailTemplates.description") }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="btn btn-secondary btn-sm"
:disabled="loadingTemplate || previewing || !canPreview"
@click="refreshPreview"
>
{{ previewing ? t("admin.settings.emailTemplates.previewing") : t("admin.settings.emailTemplates.preview") }}
</button>
<button
type="button"
class="btn btn-secondary btn-sm"
:disabled="loadingTemplate || restoring || !selectedEvent || !selectedLocale"
@click="restoreOfficial"
>
{{ restoring ? t("admin.settings.emailTemplates.restoring") : t("admin.settings.emailTemplates.restoreOfficial") }}
</button>
<button
type="button"
class="btn btn-primary btn-sm"
:disabled="loadingTemplate || saving || !canSave"
@click="saveTemplate"
>
{{ saving ? t("admin.settings.emailTemplates.saving") : t("admin.settings.emailTemplates.save") }}
</button>
</div>
</div>
<div class="space-y-6 p-6">
<div
v-if="loadingList"
class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"
>
<span
class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600"
></span>
{{ t("common.loading") }}
</div>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="input-label" for="email-template-event">
{{ t("admin.settings.emailTemplates.event") }}
</label>
<select
id="email-template-event"
v-model="selectedEvent"
class="input"
:disabled="loadingTemplate || eventOptions.length === 0"
>
<option
v-for="option in eventOptions"
:key="option.value"
:value="option.value"
>
{{ option.label || option.value }}
</option>
</select>
<p v-if="selectedEventDescription" class="input-hint">
{{ selectedEventDescription }}
</p>
</div>
<div>
<label class="input-label" for="email-template-locale">
{{ t("admin.settings.emailTemplates.locale") }}
</label>
<select
id="email-template-locale"
v-model="selectedLocale"
class="input"
:disabled="loadingTemplate || localeOptions.length === 0"
>
<option
v-for="localeOption in localeOptions"
:key="localeOption"
:value="localeOption"
>
{{ formatLocale(localeOption) }}
</option>
</select>
</div>
</div>
<div
v-if="!eventOptions.length || !localeOptions.length"
class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-700 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-300"
>
{{ t("admin.settings.emailTemplates.empty") }}
</div>
<div v-else class="grid grid-cols-1 gap-6 xl:grid-cols-2">
<div class="space-y-4">
<div>
<label class="input-label" for="email-template-subject">
{{ t("admin.settings.emailTemplates.subject") }}
</label>
<input
id="email-template-subject"
v-model="subject"
type="text"
class="input"
:disabled="loadingTemplate"
:placeholder="t('admin.settings.emailTemplates.subjectPlaceholder')"
/>
</div>
<div>
<label class="input-label" for="email-template-html">
{{ t("admin.settings.emailTemplates.html") }}
</label>
<textarea
id="email-template-html"
v-model="html"
rows="18"
class="input min-h-[28rem] resize-y font-mono text-sm leading-6"
:disabled="loadingTemplate"
:placeholder="t('admin.settings.emailTemplates.htmlPlaceholder')"
></textarea>
</div>
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800/60"
>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ t("admin.settings.emailTemplates.placeholders") }}
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.emailTemplates.placeholdersHelp") }}
</p>
<div class="mt-3 flex flex-wrap gap-2">
<button
v-for="placeholder in placeholderList"
:key="placeholder"
type="button"
class="rounded-full border border-gray-200 bg-white px-3 py-1 font-mono text-xs text-gray-700 transition-colors hover:border-primary-300 hover:text-primary-600 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-200 dark:hover:border-primary-500 dark:hover:text-primary-300"
@click="copyPlaceholder(placeholder)"
>
{{ placeholder }}
</button>
</div>
</div>
</div>
<div class="space-y-4">
<div
class="rounded-lg border border-gray-200 bg-white dark:border-dark-700 dark:bg-dark-800"
>
<div
class="flex items-center justify-between border-b border-gray-100 px-4 py-3 dark:border-dark-700"
>
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ t("admin.settings.emailTemplates.livePreview") }}
</div>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ previewSubject || t("admin.settings.emailTemplates.noPreview") }}
</div>
</div>
<span
v-if="isCustomTemplate"
class="rounded-full bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{ t("admin.settings.emailTemplates.customized") }}
</span>
</div>
<div class="bg-gray-100 p-3 dark:bg-dark-900">
<iframe
class="h-[36rem] w-full rounded-md border border-gray-200 bg-white dark:border-dark-700"
sandbox=""
:srcdoc="previewHtml"
:title="t('admin.settings.emailTemplates.livePreview')"
></iframe>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.emailTemplates.previewSecurityHint") }}
</p>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { adminAPI } from "@/api";
import type {
EmailTemplateEventOption,
EmailTemplateOption,
} from "@/api/admin/settings";
import { useAppStore } from "@/stores";
import { extractApiErrorMessage } from "@/utils/apiError";
const { t, locale } = useI18n();
const appStore = useAppStore();
const fallbackPlaceholders = [
"{{site_name}}",
"{{recipient_name}}",
"{{recipient_email}}",
"{{verification_code}}",
"{{expires_in_minutes}}",
"{{reset_url}}",
"{{subscription_group}}",
"{{subscription_days}}",
"{{expiry_time}}",
"{{days_remaining}}",
"{{current_balance}}",
"{{threshold}}",
"{{recharge_url}}",
"{{recharge_amount}}",
"{{order_id}}",
"{{unsubscribe_url}}",
"{{account_id}}",
"{{account_name}}",
"{{platform}}",
"{{quota_dimension}}",
"{{quota_used}}",
"{{quota_limit}}",
"{{quota_remaining}}",
"{{quota_threshold}}",
"{{triggered_at}}",
"{{group_name}}",
"{{moderation_category}}",
"{{moderation_score}}",
"{{violation_count}}",
"{{ban_threshold}}",
"{{rule_name}}",
"{{severity}}",
"{{alert_status}}",
"{{metric_type}}",
"{{operator}}",
"{{metric_value}}",
"{{threshold_value}}",
"{{alert_description}}",
"{{report_name}}",
"{{report_type}}",
"{{report_start_time}}",
"{{report_end_time}}",
"{{report_html}}",
];
const loadingList = ref(true);
const loadingTemplate = ref(false);
const saving = ref(false);
const previewing = ref(false);
const restoring = ref(false);
const eventOptions = ref<EmailTemplateOption[]>([]);
const localeOptions = ref<string[]>([]);
const selectedEvent = ref("");
const selectedLocale = ref("");
const subject = ref("");
const html = ref("");
const isCustomTemplate = ref(false);
const placeholders = ref<string[]>([]);
const previewSubject = ref("");
const previewHtml = ref("");
const initializingSelection = ref(false);
function normalizeEventOption(option: EmailTemplateEventOption): EmailTemplateOption {
if (typeof option === "string") {
return { value: option };
}
return option;
}
const selectedEventDescription = computed(() => {
return (
eventOptions.value.find((option) => option.value === selectedEvent.value)
?.description || ""
);
});
const placeholderList = computed(() => {
const combined = [...placeholders.value, ...fallbackPlaceholders];
return Array.from(
new Set(
combined
.map((item) => formatPlaceholder(item))
.filter((item) => item.length > 0),
),
);
});
function formatPlaceholder(placeholder: string): string {
const trimmed = placeholder.trim();
if (!trimmed) return "";
if (trimmed.startsWith("{{") && trimmed.endsWith("}}")) return trimmed;
return `{{${trimmed}}}`;
}
const canSave = computed(
() =>
Boolean(selectedEvent.value && selectedLocale.value) &&
subject.value.trim().length > 0 &&
html.value.trim().length > 0,
);
const canPreview = computed(
() => Boolean(selectedEvent.value && selectedLocale.value) && html.value.trim().length > 0,
);
function formatLocale(locale: string): string {
const lower = locale.toLowerCase();
if (lower === "zh" || lower.startsWith("zh-")) {
return t("admin.settings.emailTemplates.localeZh");
}
if (lower === "en" || lower.startsWith("en-")) {
return t("admin.settings.emailTemplates.localeEn");
}
return locale;
}
function selectInitialLocale(locales: string[]): string {
const currentLocale = locale.value.toLowerCase();
const exactMatch = locales.find(
(availableLocale) => availableLocale.toLowerCase() === currentLocale,
);
if (exactMatch) return exactMatch;
const currentLanguage = currentLocale.split("-")[0];
const languageMatch = locales.find(
(availableLocale) => availableLocale.toLowerCase().split("-")[0] === currentLanguage,
);
if (languageMatch) return languageMatch;
return locales[0] || "";
}
function applyTemplate(template: {
subject: string;
html: string;
is_custom?: boolean;
placeholders?: string[];
}) {
subject.value = template.subject;
html.value = template.html;
isCustomTemplate.value = template.is_custom === true;
placeholders.value = template.placeholders || [];
}
async function loadTemplate() {
if (!selectedEvent.value || !selectedLocale.value) return;
loadingTemplate.value = true;
try {
const template = await adminAPI.settings.getEmailTemplate(
selectedEvent.value,
selectedLocale.value,
);
applyTemplate(template);
await refreshPreview();
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
} finally {
loadingTemplate.value = false;
}
}
async function loadTemplateList() {
loadingList.value = true;
try {
const response = await adminAPI.settings.getEmailTemplates();
eventOptions.value = response.events.map(normalizeEventOption);
localeOptions.value = response.locales;
placeholders.value = response.placeholders || [];
initializingSelection.value = true;
selectedEvent.value = eventOptions.value[0]?.value || "";
selectedLocale.value = selectInitialLocale(response.locales);
await loadTemplate();
initializingSelection.value = false;
} catch (err: unknown) {
initializingSelection.value = false;
appStore.showError(extractApiErrorMessage(err, t("common.error")));
} finally {
loadingList.value = false;
}
}
async function saveTemplate() {
if (!canSave.value) {
appStore.showError(t("admin.settings.emailTemplates.validationRequired"));
return;
}
saving.value = true;
try {
const template = await adminAPI.settings.updateEmailTemplate(
selectedEvent.value,
selectedLocale.value,
{
subject: subject.value,
html: html.value,
},
);
applyTemplate(template);
await refreshPreview();
appStore.showSuccess(t("admin.settings.emailTemplates.saveSuccess"));
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
} finally {
saving.value = false;
}
}
async function refreshPreview() {
if (!canPreview.value) {
previewSubject.value = "";
previewHtml.value = "";
return;
}
previewing.value = true;
try {
const preview = await adminAPI.settings.previewEmailTemplate({
event: selectedEvent.value,
locale: selectedLocale.value,
subject: subject.value,
html: html.value,
});
previewSubject.value = preview.subject;
previewHtml.value = preview.html;
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
} finally {
previewing.value = false;
}
}
async function restoreOfficial() {
if (!selectedEvent.value || !selectedLocale.value) return;
if (!window.confirm(t("admin.settings.emailTemplates.restoreConfirm"))) return;
restoring.value = true;
try {
const template = await adminAPI.settings.restoreOfficialEmailTemplate(
selectedEvent.value,
selectedLocale.value,
);
applyTemplate(template);
await refreshPreview();
appStore.showSuccess(t("admin.settings.emailTemplates.restoreSuccess"));
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
} finally {
restoring.value = false;
}
}
async function copyPlaceholder(placeholder: string) {
try {
await navigator.clipboard.writeText(placeholder);
appStore.showSuccess(t("admin.settings.emailTemplates.placeholderCopied"));
} catch {
appStore.showError(t("common.error"));
}
}
watch([selectedEvent, selectedLocale], ([eventValue, localeValue], [oldEvent, oldLocale]) => {
if (initializingSelection.value) return;
if (!eventValue || !localeValue) return;
if (eventValue === oldEvent && localeValue === oldLocale) return;
void loadTemplate();
});
onMounted(() => {
void loadTemplateList();
});
</script>

View File

@ -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: {