Merge pull request #2599 from Arron196/feature/email-template-editor
feat: 添加邮件模板编辑器与通知邮件模板化
This commit is contained in:
commit
378a0a6a61
@ -186,7 +186,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
channelRepository := repository.NewChannelRepository(db)
|
channelRepository := repository.NewChannelRepository(db)
|
||||||
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService)
|
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService)
|
||||||
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
|
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)
|
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)
|
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)
|
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)
|
paymentConfigService := service.ProvidePaymentConfigService(client, settingRepository, encryptionKey)
|
||||||
registry := payment.ProvideRegistry()
|
registry := payment.ProvideRegistry()
|
||||||
defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey)
|
defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey)
|
||||||
paymentService := service.NewPaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService)
|
paymentService := service.ProvidePaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService, notificationEmailService)
|
||||||
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService)
|
settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService)
|
||||||
opsHandler := admin.NewOpsHandler(opsService)
|
opsHandler := admin.NewOpsHandler(opsService)
|
||||||
updateCache := repository.NewUpdateCache(redisClient)
|
updateCache := repository.NewUpdateCache(redisClient)
|
||||||
gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig)
|
gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig)
|
||||||
@ -242,7 +243,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService)
|
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)
|
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)
|
totpHandler := handler.NewTotpHandler(totpService)
|
||||||
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService)
|
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService)
|
||||||
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
|
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
|
||||||
@ -262,7 +263,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
|
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
|
||||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI)
|
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI)
|
||||||
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
||||||
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
|
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, notificationEmailService)
|
||||||
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
|
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
|
||||||
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
|
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
|
||||||
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
|
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
|
||||||
|
|||||||
@ -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/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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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=
|
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||||
|
|||||||
@ -56,13 +56,14 @@ func firstNonEmpty(values ...string) string {
|
|||||||
|
|
||||||
// SettingHandler 系统设置处理器
|
// SettingHandler 系统设置处理器
|
||||||
type SettingHandler struct {
|
type SettingHandler struct {
|
||||||
settingService *service.SettingService
|
settingService *service.SettingService
|
||||||
emailService *service.EmailService
|
emailService *service.EmailService
|
||||||
turnstileService *service.TurnstileService
|
turnstileService *service.TurnstileService
|
||||||
opsService *service.OpsService
|
opsService *service.OpsService
|
||||||
paymentConfigService *service.PaymentConfigService
|
paymentConfigService *service.PaymentConfigService
|
||||||
paymentService *service.PaymentService
|
paymentService *service.PaymentService
|
||||||
userAttributeService *service.UserAttributeService
|
userAttributeService *service.UserAttributeService
|
||||||
|
notificationEmailService *service.NotificationEmailService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSettingHandler 创建系统设置处理器
|
// 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 获取所有系统设置
|
// GetSettings 获取所有系统设置
|
||||||
// GET /api/v1/admin/settings
|
// GET /api/v1/admin/settings
|
||||||
func (h *SettingHandler) GetSettings(c *gin.Context) {
|
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)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -203,7 +203,7 @@ func (h *AuthHandler) SendVerifyCode(c *gin.Context) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
@ -602,7 +602,7 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) {
|
|||||||
|
|
||||||
// Request password reset (async)
|
// Request password reset (async)
|
||||||
// Note: This returns success even if email doesn't exist (to prevent enumeration)
|
// 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)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -545,7 +545,7 @@ func (h *AuthHandler) SendPendingOAuthVerifyCode(c *gin.Context) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -378,6 +378,62 @@ type OpenAIFastPolicySettings struct {
|
|||||||
Rules []OpenAIFastPolicyRule `json:"rules"`
|
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.
|
// ParseCustomMenuItems parses a JSON string into a slice of CustomMenuItem.
|
||||||
// Returns empty slice on empty/invalid input.
|
// Returns empty slice on empty/invalid input.
|
||||||
func ParseCustomMenuItems(raw string) []CustomMenuItem {
|
func ParseCustomMenuItems(raw string) []CustomMenuItem {
|
||||||
|
|||||||
@ -266,6 +266,7 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
|
|||||||
PaymentSource: req.PaymentSource,
|
PaymentSource: req.PaymentSource,
|
||||||
OrderType: req.OrderType,
|
OrderType: req.OrderType,
|
||||||
PlanID: req.PlanID,
|
PlanID: req.PlanID,
|
||||||
|
Locale: c.GetHeader("Accept-Language"),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
@ -10,8 +14,9 @@ import (
|
|||||||
|
|
||||||
// SettingHandler 公开设置处理器(无需认证)
|
// SettingHandler 公开设置处理器(无需认证)
|
||||||
type SettingHandler struct {
|
type SettingHandler struct {
|
||||||
settingService *service.SettingService
|
settingService *service.SettingService
|
||||||
version string
|
notificationEmailService *service.NotificationEmailService
|
||||||
|
version string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSettingHandler 创建公开设置处理器
|
// 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 获取公开设置
|
// GetPublicSettings 获取公开设置
|
||||||
// GET /api/v1/settings/public
|
// GET /api/v1/settings/public
|
||||||
func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
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 {
|
func publicLoginAgreementDocumentsToDTO(items []service.LoginAgreementDocument) []dto.LoginAgreementDocument {
|
||||||
result := make([]dto.LoginAgreementDocument, 0, len(items))
|
result := make([]dto.LoginAgreementDocument, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
|
|||||||
@ -172,7 +172,7 @@ func (h *TotpHandler) SendVerifyCode(c *gin.Context) {
|
|||||||
return
|
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)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -335,7 +335,7 @@ func (h *UserHandler) SendEmailBindingCode(c *gin.Context) {
|
|||||||
return
|
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)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -363,7 +363,7 @@ func (h *UserHandler) SendNotifyEmailCode(c *gin.Context) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -80,8 +80,17 @@ func ProvideSystemHandler(updateService *service.UpdateService, lockService *ser
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProvideSettingHandler creates SettingHandler with version from BuildInfo
|
// ProvideSettingHandler creates SettingHandler with version from BuildInfo
|
||||||
func ProvideSettingHandler(settingService *service.SettingService, buildInfo BuildInfo) *SettingHandler {
|
func ProvideSettingHandler(settingService *service.SettingService, buildInfo BuildInfo, notificationEmailService *service.NotificationEmailService) *SettingHandler {
|
||||||
return NewSettingHandler(settingService, buildInfo.Version)
|
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
|
// ProvideHandlers creates the Handlers struct
|
||||||
@ -159,7 +168,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
admin.NewProxyHandler,
|
admin.NewProxyHandler,
|
||||||
admin.NewRedeemHandler,
|
admin.NewRedeemHandler,
|
||||||
admin.NewPromoHandler,
|
admin.NewPromoHandler,
|
||||||
admin.NewSettingHandler,
|
ProvideAdminSettingHandler,
|
||||||
admin.NewOpsHandler,
|
admin.NewOpsHandler,
|
||||||
ProvideSystemHandler,
|
ProvideSystemHandler,
|
||||||
admin.NewSubscriptionHandler,
|
admin.NewSubscriptionHandler,
|
||||||
|
|||||||
@ -416,6 +416,11 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
adminSettings.PUT("", h.Admin.Setting.UpdateSettings)
|
adminSettings.PUT("", h.Admin.Setting.UpdateSettings)
|
||||||
adminSettings.POST("/test-smtp", h.Admin.Setting.TestSMTPConnection)
|
adminSettings.POST("/test-smtp", h.Admin.Setting.TestSMTPConnection)
|
||||||
adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail)
|
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 管理
|
// Admin API Key 管理
|
||||||
adminSettings.GET("/admin-api-key", h.Admin.Setting.GetAdminAPIKey)
|
adminSettings.GET("/admin-api-key", h.Admin.Setting.GetAdminAPIKey)
|
||||||
adminSettings.POST("/admin-api-key/regenerate", h.Admin.Setting.RegenerateAdminAPIKey)
|
adminSettings.POST("/admin-api-key/regenerate", h.Admin.Setting.RegenerateAdminAPIKey)
|
||||||
|
|||||||
@ -214,6 +214,7 @@ func RegisterAuthRoutes(
|
|||||||
settings := v1.Group("/settings")
|
settings := v1.Group("/settings")
|
||||||
{
|
{
|
||||||
settings.GET("/public", h.Setting.GetPublicSettings)
|
settings.GET("/public", h.Setting.GetPublicSettings)
|
||||||
|
settings.GET("/email-unsubscribe", h.Setting.UnsubscribeNotificationEmail)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 需要认证的当前用户信息
|
// 需要认证的当前用户信息
|
||||||
|
|||||||
@ -94,7 +94,7 @@ func (s *AuthService) BindEmailIdentity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SendEmailIdentityBindCode sends a verification code for authenticated email binding flows.
|
// 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 {
|
if s == nil {
|
||||||
return ErrServiceUnavailable
|
return ErrServiceUnavailable
|
||||||
}
|
}
|
||||||
@ -128,7 +128,7 @@ func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int6
|
|||||||
if s.settingService != nil {
|
if s.settingService != nil {
|
||||||
siteName = s.settingService.GetSiteName(ctx)
|
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) {
|
func normalizeEmailForIdentityBinding(email string) (string, error) {
|
||||||
|
|||||||
@ -28,7 +28,7 @@ func normalizeOAuthSignupSource(signupSource string) string {
|
|||||||
|
|
||||||
// SendPendingOAuthVerifyCode sends a local verification code for pending OAuth
|
// SendPendingOAuthVerifyCode sends a local verification code for pending OAuth
|
||||||
// account-creation flows without relying on the public registration gate.
|
// 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))
|
email = strings.TrimSpace(strings.ToLower(email))
|
||||||
if email == "" {
|
if email == "" {
|
||||||
return nil, ErrEmailVerifyRequired
|
return nil, ErrEmailVerifyRequired
|
||||||
@ -47,7 +47,7 @@ func (s *AuthService) SendPendingOAuthVerifyCode(ctx context.Context, email stri
|
|||||||
if s.settingService != nil {
|
if s.settingService != nil {
|
||||||
siteName = s.settingService.GetSiteName(ctx)
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &SendVerifyCodeResult{
|
return &SendVerifyCodeResult{
|
||||||
|
|||||||
@ -273,7 +273,7 @@ type SendVerifyCodeResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SendVerifyCode 发送邮箱验证码(同步方式)
|
// 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) {
|
if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
|
||||||
return ErrRegDisabled
|
return ErrRegDisabled
|
||||||
@ -307,11 +307,11 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error {
|
|||||||
siteName = s.settingService.GetSiteName(ctx)
|
siteName = s.settingService.GetSiteName(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.emailService.SendVerifyCode(ctx, email, siteName)
|
return s.emailService.SendVerifyCode(ctx, email, siteName, firstEmailLocale(locale))
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendVerifyCodeAsync 异步发送邮箱验证码并返回倒计时
|
// 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)
|
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)
|
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)
|
logger.LegacyPrintf("service.auth", "[Auth] Failed to enqueue: %v", err)
|
||||||
return nil, fmt.Errorf("enqueue verify code: %w", err)
|
return nil, fmt.Errorf("enqueue verify code: %w", err)
|
||||||
}
|
}
|
||||||
@ -1251,7 +1251,7 @@ func (s *AuthService) preparePasswordReset(ctx context.Context, email, frontendB
|
|||||||
|
|
||||||
// RequestPasswordReset 请求密码重置(同步发送)
|
// RequestPasswordReset 请求密码重置(同步发送)
|
||||||
// Security: Returns the same response regardless of whether the email exists (prevent user enumeration)
|
// 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) {
|
if !s.IsPasswordResetEnabled(ctx) {
|
||||||
return infraerrors.Forbidden("PASSWORD_RESET_DISABLED", "password reset is not enabled")
|
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
|
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)
|
logger.LegacyPrintf("service.auth", "[Auth] Failed to send password reset email to %s: %v", email, err)
|
||||||
return nil // Silent success to prevent enumeration
|
return nil // Silent success to prevent enumeration
|
||||||
}
|
}
|
||||||
@ -1275,7 +1275,7 @@ func (s *AuthService) RequestPasswordReset(ctx context.Context, email, frontendB
|
|||||||
|
|
||||||
// RequestPasswordResetAsync 异步请求密码重置(队列发送)
|
// RequestPasswordResetAsync 异步请求密码重置(队列发送)
|
||||||
// Security: Returns the same response regardless of whether the email exists (prevent user enumeration)
|
// 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) {
|
if !s.IsPasswordResetEnabled(ctx) {
|
||||||
return infraerrors.Forbidden("PASSWORD_RESET_DISABLED", "password reset is not enabled")
|
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
|
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)
|
logger.LegacyPrintf("service.auth", "[Auth] Failed to enqueue password reset email for %s: %v", email, err)
|
||||||
return nil // Silent success to prevent enumeration
|
return nil // Silent success to prevent enumeration
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,9 +39,10 @@ type AccountQuotaReader interface {
|
|||||||
|
|
||||||
// BalanceNotifyService handles balance and quota threshold notifications.
|
// BalanceNotifyService handles balance and quota threshold notifications.
|
||||||
type BalanceNotifyService struct {
|
type BalanceNotifyService struct {
|
||||||
emailService *EmailService
|
emailService *EmailService
|
||||||
settingRepo SettingRepository
|
settingRepo SettingRepository
|
||||||
accountRepo AccountQuotaReader
|
accountRepo AccountQuotaReader
|
||||||
|
notificationEmailService *NotificationEmailService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBalanceNotifyService creates a new BalanceNotifyService.
|
// 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.
|
// resolveBalanceThreshold returns the effective balance threshold.
|
||||||
// For percentage type, it computes threshold = totalRecharged * percentage / 100.
|
// For percentage type, it computes threshold = totalRecharged * percentage / 100.
|
||||||
func resolveBalanceThreshold(threshold float64, thresholdType string, totalRecharged float64) float64 {
|
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)
|
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.
|
// 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
|
displayName := userName
|
||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
displayName = userEmail
|
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))
|
subject := fmt.Sprintf("[%s] 余额不足提醒 / Balance Low Alert", sanitizeEmailHeader(siteName))
|
||||||
body := s.buildBalanceLowEmailBody(html.EscapeString(displayName), balance, threshold, html.EscapeString(siteName), rechargeURL)
|
body := s.buildBalanceLowEmailBody(html.EscapeString(displayName), balance, threshold, html.EscapeString(siteName), rechargeURL)
|
||||||
s.sendEmails(recipients, subject, body, "user_email", userEmail, "balance", balance)
|
s.sendEmails(recipients, subject, body, "user_email", userEmail, "balance", balance)
|
||||||
@ -369,6 +407,44 @@ func (s *BalanceNotifyService) sendQuotaAlertEmails(adminEmails []string, accoun
|
|||||||
remaining = 0
|
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))
|
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))
|
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)
|
s.sendEmails(adminEmails, subject, body, "account", accountName, "dimension", dim.name)
|
||||||
|
|||||||
@ -1463,6 +1463,24 @@ func (s *ContentModerationService) applyFlaggedSideEffects(ctx context.Context,
|
|||||||
|
|
||||||
func (s *ContentModerationService) sendViolationEmail(ctx context.Context, cfg *ContentModerationConfig, log *ContentModerationLog) error {
|
func (s *ContentModerationService) sendViolationEmail(ctx context.Context, cfg *ContentModerationConfig, log *ContentModerationLog) error {
|
||||||
siteName := s.siteName(ctx)
|
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))
|
subject := fmt.Sprintf("[%s] 账户风控提醒 / Risk Control Notice", sanitizeEmailHeader(siteName))
|
||||||
body := buildContentModerationViolationEmailBody(siteName, log, cfg)
|
body := buildContentModerationViolationEmailBody(siteName, log, cfg)
|
||||||
return s.emailService.SendEmail(ctx, log.UserEmail, subject, body)
|
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 {
|
func (s *ContentModerationService) sendAccountDisabledEmail(ctx context.Context, cfg *ContentModerationConfig, log *ContentModerationLog) error {
|
||||||
siteName := s.siteName(ctx)
|
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))
|
subject := fmt.Sprintf("[%s] 账户已被禁用 / Account Disabled", sanitizeEmailHeader(siteName))
|
||||||
body := buildContentModerationAccountDisabledEmailBody(siteName, log, cfg)
|
body := buildContentModerationAccountDisabledEmailBody(siteName, log, cfg)
|
||||||
return s.emailService.SendEmail(ctx, log.UserEmail, subject, body)
|
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 {
|
func (s *ContentModerationService) siteName(ctx context.Context) string {
|
||||||
if s == nil || s.settingRepo == nil {
|
if s == nil || s.settingRepo == nil {
|
||||||
return "Sub2API"
|
return "Sub2API"
|
||||||
|
|||||||
@ -21,6 +21,7 @@ type EmailTask struct {
|
|||||||
SiteName string
|
SiteName string
|
||||||
TaskType string // "verify_code" or "password_reset"
|
TaskType string // "verify_code" or "password_reset"
|
||||||
ResetURL string // Only used for password_reset task type
|
ResetURL string // Only used for password_reset task type
|
||||||
|
Locale string // Optional Accept-Language locale hint
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmailQueueService 异步邮件队列服务
|
// EmailQueueService 异步邮件队列服务
|
||||||
@ -82,13 +83,13 @@ func (s *EmailQueueService) processTask(workerID int, task EmailTask) {
|
|||||||
|
|
||||||
switch task.TaskType {
|
switch task.TaskType {
|
||||||
case TaskTypeVerifyCode:
|
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)
|
logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d failed to send verify code to %s: %v", workerID, task.Email, err)
|
||||||
} else {
|
} else {
|
||||||
logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d sent verify code to %s", workerID, task.Email)
|
logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d sent verify code to %s", workerID, task.Email)
|
||||||
}
|
}
|
||||||
case TaskTypePasswordReset:
|
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)
|
logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d failed to send password reset to %s: %v", workerID, task.Email, err)
|
||||||
} else {
|
} else {
|
||||||
logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d sent password reset to %s", workerID, task.Email)
|
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 将验证码发送任务加入队列
|
// EnqueueVerifyCode 将验证码发送任务加入队列
|
||||||
func (s *EmailQueueService) EnqueueVerifyCode(email, siteName string) error {
|
func (s *EmailQueueService) EnqueueVerifyCode(email, siteName string, locale ...string) error {
|
||||||
task := EmailTask{
|
task := EmailTask{
|
||||||
Email: email,
|
Email: email,
|
||||||
SiteName: siteName,
|
SiteName: siteName,
|
||||||
TaskType: TaskTypeVerifyCode,
|
TaskType: TaskTypeVerifyCode,
|
||||||
|
Locale: firstEmailLocale(locale),
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@ -116,12 +118,13 @@ func (s *EmailQueueService) EnqueueVerifyCode(email, siteName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// EnqueuePasswordReset 将密码重置邮件任务加入队列
|
// EnqueuePasswordReset 将密码重置邮件任务加入队列
|
||||||
func (s *EmailQueueService) EnqueuePasswordReset(email, siteName, resetURL string) error {
|
func (s *EmailQueueService) EnqueuePasswordReset(email, siteName, resetURL string, locale ...string) error {
|
||||||
task := EmailTask{
|
task := EmailTask{
|
||||||
Email: email,
|
Email: email,
|
||||||
SiteName: siteName,
|
SiteName: siteName,
|
||||||
TaskType: TaskTypePasswordReset,
|
TaskType: TaskTypePasswordReset,
|
||||||
ResetURL: resetURL,
|
ResetURL: resetURL,
|
||||||
|
Locale: firstEmailLocale(locale),
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
|||||||
@ -94,8 +94,9 @@ type SMTPConfig struct {
|
|||||||
|
|
||||||
// EmailService 邮件服务
|
// EmailService 邮件服务
|
||||||
type EmailService struct {
|
type EmailService struct {
|
||||||
settingRepo SettingRepository
|
settingRepo SettingRepository
|
||||||
cache EmailCache
|
cache EmailCache
|
||||||
|
notificationEmailService *NotificationEmailService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEmailService 创建邮件服务实例
|
// 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配置
|
// GetSMTPConfig 从数据库获取SMTP配置
|
||||||
func (s *EmailService) GetSMTPConfig(ctx context.Context) (*SMTPConfig, error) {
|
func (s *EmailService) GetSMTPConfig(ctx context.Context) (*SMTPConfig, error) {
|
||||||
keys := []string{
|
keys := []string{
|
||||||
@ -301,7 +324,7 @@ func (s *EmailService) GenerateVerifyCode() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SendVerifyCode 发送验证码邮件
|
// 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)
|
existing, err := s.cache.GetVerificationCode(ctx, email)
|
||||||
if err == nil && existing != nil {
|
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)
|
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)
|
subject := fmt.Sprintf("[%s] Email Verification Code", siteName)
|
||||||
body := s.buildVerifyCodeEmailBody(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
|
// 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 token string
|
||||||
var needSaveToken bool
|
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
|
// 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))
|
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
|
// Build email content
|
||||||
subject := fmt.Sprintf("[%s] 密码重置请求", siteName)
|
subject := fmt.Sprintf("[%s] 密码重置请求", siteName)
|
||||||
body := s.buildPasswordResetEmailBody(fullResetURL, 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)
|
// SendPasswordResetEmailWithCooldown sends password reset email with cooldown check (called by queue worker)
|
||||||
// This method wraps SendPasswordResetEmail with email cooldown to prevent email bombing
|
// 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
|
// Check email cooldown to prevent email bombing
|
||||||
if s.cache.IsPasswordResetEmailInCooldown(ctx, email) {
|
if s.cache.IsPasswordResetEmailInCooldown(ctx, email) {
|
||||||
slog.Info("password reset email skipped due to cooldown", "email", 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
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1313
backend/internal/service/notification_email_service.go
Normal file
1313
backend/internal/service/notification_email_service.go
Normal file
File diff suppressed because it is too large
Load Diff
343
backend/internal/service/notification_email_service_test.go
Normal file
343
backend/internal/service/notification_email_service_test.go
Normal 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, `<script>alert("x")</script>`)
|
||||||
|
require.NotContains(t, preview.HTML, `javascript:alert(1)`)
|
||||||
|
require.Contains(t, preview.HTML, `href=""`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationEmailTemplateOverrideAndRestore(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := newNotificationEmailMemorySettingRepo()
|
||||||
|
svc := NewNotificationEmailService(repo, nil)
|
||||||
|
|
||||||
|
official, err := svc.GetTemplate(ctx, NotificationEmailEventBalanceRechargeSuccess, "en")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, official.IsCustom)
|
||||||
|
|
||||||
|
updated, err := svc.UpdateTemplate(
|
||||||
|
ctx,
|
||||||
|
NotificationEmailEventBalanceRechargeSuccess,
|
||||||
|
"zh-Hans",
|
||||||
|
"充值完成:{{recharge_amount}}",
|
||||||
|
"<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, `<script>alert("x")</script>`)
|
||||||
|
require.Contains(t, preview.Subject, `<script>alert("x")</script>`)
|
||||||
|
|
||||||
|
preview, err = renderNotificationEmail(
|
||||||
|
NotificationEmailEventOpsScheduledReport,
|
||||||
|
"Recipient {{recipient_name}}",
|
||||||
|
`<p>{{recipient_name}}</p>`,
|
||||||
|
map[string]string{"recipient_name": `<em>escaped</em>`},
|
||||||
|
map[string]string{"recipient_name": `<strong>raw</strong>`},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, preview.HTML, `<em>escaped</em>`)
|
||||||
|
require.NotContains(t, preview.HTML, `<strong>raw</strong>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationEmailFallbackClassification(t *testing.T) {
|
||||||
|
templateErr := notificationEmailTemplateErr(errors.New("bad template"))
|
||||||
|
configErr := notificationEmailConfigErr(errors.New("missing email service"))
|
||||||
|
deliveryErr := notificationEmailDeliveryErr(errors.New("smtp timeout"))
|
||||||
|
|
||||||
|
require.True(t, shouldFallbackNotificationEmail(templateErr))
|
||||||
|
require.True(t, shouldFallbackNotificationEmail(configErr))
|
||||||
|
require.False(t, shouldFallbackNotificationEmail(deliveryErr))
|
||||||
|
require.True(t, isNotificationEmailDeliveryError(deliveryErr))
|
||||||
|
require.False(t, isNotificationEmailDeliveryError(templateErr))
|
||||||
|
require.False(t, shouldFallbackNotificationEmail(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmailQueueTasksPreserveLocaleHints(t *testing.T) {
|
||||||
|
queue := &EmailQueueService{taskChan: make(chan EmailTask, 2)}
|
||||||
|
require.NoError(t, queue.EnqueueVerifyCode("user@example.com", "Sub2API", "zh-CN"))
|
||||||
|
require.NoError(t, queue.EnqueuePasswordReset("user@example.com", "Sub2API", "https://example.com/reset", "en-US"))
|
||||||
|
|
||||||
|
verifyTask := <-queue.taskChan
|
||||||
|
require.Equal(t, TaskTypeVerifyCode, verifyTask.TaskType)
|
||||||
|
require.Equal(t, "zh-CN", verifyTask.Locale)
|
||||||
|
|
||||||
|
resetTask := <-queue.taskChan
|
||||||
|
require.Equal(t, TaskTypePasswordReset, resetTask.TaskType)
|
||||||
|
require.Equal(t, "en-US", resetTask.Locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpsScheduledReportDeliverySourceIDIncludesReportIdentity(t *testing.T) {
|
||||||
|
report := &opsScheduledReport{Name: "日报", ReportType: "daily_summary", Schedule: "0 9 * * *"}
|
||||||
|
sourceID := opsScheduledReportDeliverySourceID(report)
|
||||||
|
require.Contains(t, sourceID, "daily_summary")
|
||||||
|
require.Contains(t, sourceID, "日报")
|
||||||
|
require.Contains(t, sourceID, "0 9 * * *")
|
||||||
|
require.NotEqual(t, sourceID, opsScheduledReportDeliverySourceID(&opsScheduledReport{Name: "周报", ReportType: "weekly_summary", Schedule: "0 9 * * 1"}))
|
||||||
|
require.Equal(t, "scheduled_report", opsScheduledReportDeliverySourceID(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationEmailUnsubscribeOnlyAllowsOptionalEvents(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
|
||||||
|
|
||||||
|
token, err := svc.createUnsubscribeToken(ctx, "User@Example.com", NotificationEmailEventBalanceLow)
|
||||||
|
require.NoError(t, err)
|
||||||
|
result, err := svc.Unsubscribe(ctx, token)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, result.Done)
|
||||||
|
require.Equal(t, NotificationEmailEventBalanceLow, result.Event)
|
||||||
|
unsubscribed, err := svc.IsUnsubscribed(ctx, "user@example.com", NotificationEmailEventBalanceLow)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, unsubscribed)
|
||||||
|
|
||||||
|
transactionalToken, err := svc.createUnsubscribeToken(ctx, "user@example.com", NotificationEmailEventBalanceRechargeSuccess)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = svc.Unsubscribe(ctx, transactionalToken)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "transactional")
|
||||||
|
|
||||||
|
authToken, err := svc.createUnsubscribeToken(ctx, "user@example.com", NotificationEmailEventAuthVerifyCode)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = svc.Unsubscribe(ctx, authToken)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "transactional")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationEmailLocaleMemoryNormalizesAcceptLanguage(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
|
||||||
|
|
||||||
|
svc.RememberRecipientLocale(ctx, 42, "User@Example.com", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||||
|
require.Equal(t, "zh", svc.ResolveRecipientLocale(ctx, 42, "user@example.com"))
|
||||||
|
require.Equal(t, "zh", svc.ResolveRecipientLocale(ctx, 0, "user@example.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
type notificationEmailMemorySettingRepo struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
values map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNotificationEmailMemorySettingRepo() *notificationEmailMemorySettingRepo {
|
||||||
|
return ¬ificationEmailMemorySettingRepo{values: make(map[string]string)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *notificationEmailMemorySettingRepo) Get(_ context.Context, key string) (*Setting, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
value, ok := r.values[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrSettingNotFound
|
||||||
|
}
|
||||||
|
return &Setting{Key: key, Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *notificationEmailMemorySettingRepo) GetValue(ctx context.Context, key string) (string, error) {
|
||||||
|
setting, err := r.Get(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return setting.Value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *notificationEmailMemorySettingRepo) Set(_ context.Context, key, value string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.values[key] = value
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *notificationEmailMemorySettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
out := make(map[string]string, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
if value, ok := r.values[key]; ok {
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *notificationEmailMemorySettingRepo) SetMultiple(_ context.Context, settings map[string]string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
for key, value := range settings {
|
||||||
|
r.values[key] = value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *notificationEmailMemorySettingRepo) GetAll(_ context.Context) (map[string]string, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
out := make(map[string]string, len(r.values))
|
||||||
|
for key, value := range r.values {
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *notificationEmailMemorySettingRepo) Delete(_ context.Context, key string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if _, ok := r.values[key]; !ok {
|
||||||
|
return ErrSettingNotFound
|
||||||
|
}
|
||||||
|
delete(r.values, key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationEmailMemorySettingRepoSatisfiesInterface(t *testing.T) {
|
||||||
|
var _ SettingRepository = (*notificationEmailMemorySettingRepo)(nil)
|
||||||
|
require.False(t, strings.Contains(notificationEmailPreferenceKey(NotificationEmailEventBalanceLow, "User@Example.com"), "User@Example.com"))
|
||||||
|
}
|
||||||
@ -686,6 +686,21 @@ func (s *OpsAlertEvaluatorService) maybeSendAlertEmail(ctx context.Context, runt
|
|||||||
if !s.emailLimiter.Allow(time.Now().UTC()) {
|
if !s.emailLimiter.Allow(time.Now().UTC()) {
|
||||||
continue
|
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 {
|
if err := s.emailService.SendEmail(ctx, addr, subject, body); err != nil {
|
||||||
// Ignore per-recipient failures; continue best-effort.
|
// Ignore per-recipient failures; continue best-effort.
|
||||||
continue
|
continue
|
||||||
@ -699,6 +714,46 @@ func (s *OpsAlertEvaluatorService) maybeSendAlertEmail(ctx context.Context, runt
|
|||||||
return anySent
|
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 {
|
func buildOpsAlertEmailBody(rule *OpsAlertRule, event *OpsAlertEvent) string {
|
||||||
if rule == nil || event == nil {
|
if rule == nil || event == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@ -337,6 +337,7 @@ func (s *OpsScheduledReportService) runReport(ctx context.Context, report *opsSc
|
|||||||
}
|
}
|
||||||
|
|
||||||
subject := fmt.Sprintf("[Ops Report] %s", strings.TrimSpace(report.Name))
|
subject := fmt.Sprintf("[Ops Report] %s", strings.TrimSpace(report.Name))
|
||||||
|
templateVariables := opsScheduledReportEmailVariables(report, now)
|
||||||
|
|
||||||
attempts := 0
|
attempts := 0
|
||||||
for _, to := range recipients {
|
for _, to := range recipients {
|
||||||
@ -345,6 +346,24 @@ func (s *OpsScheduledReportService) runReport(ctx context.Context, report *opsSc
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
attempts++
|
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 {
|
if err := s.emailService.SendEmail(ctx, addr, subject, content); err != nil {
|
||||||
// Ignore per-recipient failures; continue best-effort.
|
// Ignore per-recipient failures; continue best-effort.
|
||||||
continue
|
continue
|
||||||
@ -353,6 +372,46 @@ func (s *OpsScheduledReportService) runReport(ctx context.Context, report *opsSc
|
|||||||
return attempts, nil
|
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) {
|
func (s *OpsScheduledReportService) generateReportHTML(ctx context.Context, report *opsScheduledReport, now time.Time) (string, error) {
|
||||||
if s == nil || s.opsService == nil || report == nil {
|
if s == nil || s.opsService == nil || report == nil {
|
||||||
return "", fmt.Errorf("service not initialized")
|
return "", fmt.Errorf("service not initialized")
|
||||||
|
|||||||
@ -310,9 +310,87 @@ func (s *PaymentService) markCompleted(ctx context.Context, o *dbent.PaymentOrde
|
|||||||
"creditedAmount": o.Amount,
|
"creditedAmount": o.Amount,
|
||||||
"payAmount": o.PayAmount,
|
"payAmount": o.PayAmount,
|
||||||
})
|
})
|
||||||
|
s.dispatchPaymentFulfillmentNotification(o, auditAction)
|
||||||
return nil
|
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 {
|
func (s *PaymentService) ExecuteSubscriptionFulfillment(ctx context.Context, oid int64) error {
|
||||||
o, err := s.entClient.PaymentOrder.Get(ctx, oid)
|
o, err := s.entClient.PaymentOrder.Get(ctx, oid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -48,6 +48,9 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
|
|||||||
if user.Status != payment.EntityStatusActive {
|
if user.Status != payment.EntityStatusActive {
|
||||||
return nil, infraerrors.Forbidden("USER_INACTIVE", "user account is disabled")
|
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
|
orderAmount := req.Amount
|
||||||
limitAmount := req.Amount
|
limitAmount := req.Amount
|
||||||
if plan != nil {
|
if plan != nil {
|
||||||
|
|||||||
@ -83,6 +83,7 @@ type CreateOrderRequest struct {
|
|||||||
PaymentSource string
|
PaymentSource string
|
||||||
OrderType string
|
OrderType string
|
||||||
PlanID int64
|
PlanID int64
|
||||||
|
Locale string
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateOrderResponse struct {
|
type CreateOrderResponse struct {
|
||||||
@ -174,18 +175,19 @@ type TopUserStat struct {
|
|||||||
// --- Service ---
|
// --- Service ---
|
||||||
|
|
||||||
type PaymentService struct {
|
type PaymentService struct {
|
||||||
providerMu sync.Mutex
|
providerMu sync.Mutex
|
||||||
providersLoaded bool
|
providersLoaded bool
|
||||||
entClient *dbent.Client
|
entClient *dbent.Client
|
||||||
registry *payment.Registry
|
registry *payment.Registry
|
||||||
loadBalancer payment.LoadBalancer
|
loadBalancer payment.LoadBalancer
|
||||||
redeemService *RedeemService
|
redeemService *RedeemService
|
||||||
subscriptionSvc *SubscriptionService
|
subscriptionSvc *SubscriptionService
|
||||||
configService *PaymentConfigService
|
configService *PaymentConfigService
|
||||||
userRepo UserRepository
|
userRepo UserRepository
|
||||||
groupRepo GroupRepository
|
groupRepo GroupRepository
|
||||||
resumeService *PaymentResumeService
|
resumeService *PaymentResumeService
|
||||||
affiliateService *AffiliateService
|
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 {
|
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
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *PaymentService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) {
|
||||||
|
s.notificationEmailService = notificationEmailService
|
||||||
|
}
|
||||||
|
|
||||||
// --- Provider Registry ---
|
// --- Provider Registry ---
|
||||||
|
|
||||||
// EnsureProviders lazily initializes the provider registry on first call.
|
// EnsureProviders lazily initializes the provider registry on first call.
|
||||||
|
|||||||
@ -2,18 +2,23 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SubscriptionExpiryService periodically updates expired subscription status.
|
// SubscriptionExpiryService periodically updates expired subscription status.
|
||||||
type SubscriptionExpiryService struct {
|
type SubscriptionExpiryService struct {
|
||||||
userSubRepo UserSubscriptionRepository
|
userSubRepo UserSubscriptionRepository
|
||||||
interval time.Duration
|
notificationEmailService *NotificationEmailService
|
||||||
stopCh chan struct{}
|
interval time.Duration
|
||||||
stopOnce sync.Once
|
stopCh chan struct{}
|
||||||
wg sync.WaitGroup
|
stopOnce sync.Once
|
||||||
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSubscriptionExpiryService(userSubRepo UserSubscriptionRepository, interval time.Duration) *SubscriptionExpiryService {
|
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() {
|
func (s *SubscriptionExpiryService) Start() {
|
||||||
if s == nil || s.userSubRepo == nil || s.interval <= 0 {
|
if s == nil || s.userSubRepo == nil || s.interval <= 0 {
|
||||||
return
|
return
|
||||||
@ -68,4 +77,50 @@ func (s *SubscriptionExpiryService) runOnce() {
|
|||||||
if updated > 0 {
|
if updated > 0 {
|
||||||
log.Printf("[SubscriptionExpiry] Updated %d expired subscriptions", updated)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -517,7 +517,7 @@ func (s *TotpService) GetVerificationMethod(ctx context.Context) *VerificationMe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SendVerifyCode sends an email verification code for TOTP operations
|
// 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
|
// Check if email verification is enabled
|
||||||
if !s.settingService.IsEmailVerifyEnabled(ctx) {
|
if !s.settingService.IsEmailVerifyEnabled(ctx) {
|
||||||
return infraerrors.BadRequest("EMAIL_VERIFY_NOT_ENABLED", "email verification is not enabled")
|
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)
|
siteName := s.settingService.GetSiteName(ctx)
|
||||||
|
|
||||||
// Send verification code via queue
|
// Send verification code via queue
|
||||||
return s.emailQueueService.EnqueueVerifyCode(user.Email, siteName)
|
return s.emailQueueService.EnqueueVerifyCode(user.Email, siteName, firstEmailLocale(locale))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1121,7 +1121,7 @@ func (s *UserService) Delete(ctx context.Context, userID int64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SendNotifyEmailCode sends a verification code to the extra notification email.
|
// 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 {
|
if err := checkNotifyCodeRateLimit(ctx, cache, userID, email); err != nil {
|
||||||
return err
|
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,
|
// 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.
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1179,13 +1179,33 @@ func saveNotifyVerifyCode(ctx context.Context, cache EmailCache, email, code str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sendNotifyVerifyEmail builds and sends the verification email.
|
// 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"
|
siteName := "Sub2API"
|
||||||
if s.settingRepo != nil {
|
if s.settingRepo != nil {
|
||||||
if name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName); err == nil && name != "" {
|
if name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName); err == nil && name != "" {
|
||||||
siteName = 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)
|
subject := fmt.Sprintf("[%s] 通知邮箱验证码 / Notification Email Verification", siteName)
|
||||||
body := buildNotifyVerifyEmailBody(code, siteName)
|
body := buildNotifyVerifyEmailBody(code, siteName)
|
||||||
return emailService.SendEmail(ctx, email, subject, body)
|
return emailService.SendEmail(ctx, email, subject, body)
|
||||||
|
|||||||
@ -151,8 +151,9 @@ func ProvideAccountExpiryService(accountRepo AccountRepository) *AccountExpirySe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProvideSubscriptionExpiryService creates and starts SubscriptionExpiryService.
|
// ProvideSubscriptionExpiryService creates and starts SubscriptionExpiryService.
|
||||||
func ProvideSubscriptionExpiryService(userSubRepo UserSubscriptionRepository) *SubscriptionExpiryService {
|
func ProvideSubscriptionExpiryService(userSubRepo UserSubscriptionRepository, notificationEmailService *NotificationEmailService) *SubscriptionExpiryService {
|
||||||
svc := NewSubscriptionExpiryService(userSubRepo, time.Minute)
|
svc := NewSubscriptionExpiryService(userSubRepo, time.Minute)
|
||||||
|
svc.SetNotificationEmailService(notificationEmailService)
|
||||||
svc.Start()
|
svc.Start()
|
||||||
return svc
|
return svc
|
||||||
}
|
}
|
||||||
@ -478,6 +479,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
ProvideOpsCleanupService,
|
ProvideOpsCleanupService,
|
||||||
ProvideOpsScheduledReportService,
|
ProvideOpsScheduledReportService,
|
||||||
NewEmailService,
|
NewEmailService,
|
||||||
|
NewNotificationEmailService,
|
||||||
ProvideEmailQueueService,
|
ProvideEmailQueueService,
|
||||||
NewTurnstileService,
|
NewTurnstileService,
|
||||||
NewSubscriptionService,
|
NewSubscriptionService,
|
||||||
@ -514,7 +516,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
NewContentModerationService,
|
NewContentModerationService,
|
||||||
NewAffiliateService,
|
NewAffiliateService,
|
||||||
ProvidePaymentConfigService,
|
ProvidePaymentConfigService,
|
||||||
NewPaymentService,
|
ProvidePaymentService,
|
||||||
ProvidePaymentOrderExpiryService,
|
ProvidePaymentOrderExpiryService,
|
||||||
ProvideBalanceNotifyService,
|
ProvideBalanceNotifyService,
|
||||||
ProvideChannelMonitorService,
|
ProvideChannelMonitorService,
|
||||||
@ -529,8 +531,17 @@ func ProvidePaymentConfigService(entClient *dbent.Client, settingRepo SettingRep
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProvideBalanceNotifyService creates BalanceNotifyService
|
// ProvideBalanceNotifyService creates BalanceNotifyService
|
||||||
func ProvideBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository, accountRepo AccountRepository) *BalanceNotifyService {
|
func ProvideBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository, accountRepo AccountRepository, notificationEmailService *NotificationEmailService) *BalanceNotifyService {
|
||||||
return NewBalanceNotifyService(emailService, settingRepo, accountRepo)
|
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.
|
// ProvidePaymentOrderExpiryService creates and starts PaymentOrderExpiryService.
|
||||||
|
|||||||
@ -5,6 +5,45 @@
|
|||||||
import { config } from '@vue/test-utils'
|
import { config } from '@vue/test-utils'
|
||||||
import { vi } from 'vitest'
|
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 不支持)
|
// Mock requestIdleCallback (Safari < 15 不支持)
|
||||||
if (typeof globalThis.requestIdleCallback === 'undefined') {
|
if (typeof globalThis.requestIdleCallback === 'undefined') {
|
||||||
globalThis.requestIdleCallback = ((callback: IdleRequestCallback) => {
|
globalThis.requestIdleCallback = ((callback: IdleRequestCallback) => {
|
||||||
|
|||||||
@ -854,6 +854,105 @@ export async function sendTestEmail(
|
|||||||
return data;
|
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
|
* Admin API Key status response
|
||||||
*/
|
*/
|
||||||
@ -1160,6 +1259,11 @@ export const settingsAPI = {
|
|||||||
updateSettings,
|
updateSettings,
|
||||||
testSmtpConnection,
|
testSmtpConnection,
|
||||||
sendTestEmail,
|
sendTestEmail,
|
||||||
|
getEmailTemplates,
|
||||||
|
getEmailTemplate,
|
||||||
|
updateEmailTemplate,
|
||||||
|
restoreOfficialEmailTemplate,
|
||||||
|
previewEmailTemplate,
|
||||||
getAdminApiKey,
|
getAdminApiKey,
|
||||||
regenerateAdminApiKey,
|
regenerateAdminApiKey,
|
||||||
deleteAdminApiKey,
|
deleteAdminApiKey,
|
||||||
|
|||||||
@ -5796,6 +5796,36 @@ export default {
|
|||||||
sending: 'Sending...',
|
sending: 'Sending...',
|
||||||
enterRecipientHint: 'Please enter a recipient email address'
|
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: {
|
opsMonitoring: {
|
||||||
title: 'Ops Monitoring',
|
title: 'Ops Monitoring',
|
||||||
description: 'Enable ops monitoring for troubleshooting and health visibility',
|
description: 'Enable ops monitoring for troubleshooting and health visibility',
|
||||||
|
|||||||
@ -5956,6 +5956,36 @@ export default {
|
|||||||
sending: '发送中...',
|
sending: '发送中...',
|
||||||
enterRecipientHint: '请输入收件人邮箱地址'
|
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: {
|
opsMonitoring: {
|
||||||
title: '运维监控',
|
title: '运维监控',
|
||||||
description: '启用运维监控模块,用于排障与健康可视化',
|
description: '启用运维监控模块,用于排障与健康可视化',
|
||||||
|
|||||||
@ -6255,6 +6255,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EmailTemplateEditor />
|
||||||
|
|
||||||
<!-- Balance Low Notification -->
|
<!-- Balance Low Notification -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div
|
<div
|
||||||
@ -6512,6 +6515,7 @@ import Toggle from "@/components/common/Toggle.vue";
|
|||||||
import ProxySelector from "@/components/common/ProxySelector.vue";
|
import ProxySelector from "@/components/common/ProxySelector.vue";
|
||||||
import ImageUpload from "@/components/common/ImageUpload.vue";
|
import ImageUpload from "@/components/common/ImageUpload.vue";
|
||||||
import BackupSettings from "@/views/admin/BackupView.vue";
|
import BackupSettings from "@/views/admin/BackupView.vue";
|
||||||
|
import EmailTemplateEditor from "@/views/admin/settings/EmailTemplateEditor.vue";
|
||||||
import { useClipboard } from "@/composables/useClipboard";
|
import { useClipboard } from "@/composables/useClipboard";
|
||||||
import { affiliatesAPI, type AffiliateAdminEntry, type SimpleUser as AffiliateSimpleUser } from "@/api/admin/affiliates";
|
import { affiliatesAPI, type AffiliateAdminEntry, type SimpleUser as AffiliateSimpleUser } from "@/api/admin/affiliates";
|
||||||
import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError";
|
import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError";
|
||||||
|
|||||||
483
frontend/src/views/admin/settings/EmailTemplateEditor.vue
Normal file
483
frontend/src/views/admin/settings/EmailTemplateEditor.vue
Normal 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>
|
||||||
@ -13,6 +13,7 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/__tests__/setup.ts'],
|
||||||
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||||||
exclude: ['node_modules', 'dist'],
|
exclude: ['node_modules', 'dist'],
|
||||||
coverage: {
|
coverage: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user