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)
|
||||
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService)
|
||||
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
|
||||
balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository, accountRepository)
|
||||
notificationEmailService := service.NewNotificationEmailService(settingRepository, emailService)
|
||||
balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository, accountRepository, notificationEmailService)
|
||||
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService)
|
||||
openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService)
|
||||
@ -200,8 +201,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
paymentConfigService := service.ProvidePaymentConfigService(client, settingRepository, encryptionKey)
|
||||
registry := payment.ProvideRegistry()
|
||||
defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey)
|
||||
paymentService := service.NewPaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService)
|
||||
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService)
|
||||
paymentService := service.ProvidePaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService, notificationEmailService)
|
||||
settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService)
|
||||
opsHandler := admin.NewOpsHandler(opsService)
|
||||
updateCache := repository.NewUpdateCache(redisClient)
|
||||
gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig)
|
||||
@ -242,7 +243,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService)
|
||||
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, configConfig)
|
||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
|
||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
|
||||
totpHandler := handler.NewTotpHandler(totpService)
|
||||
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService)
|
||||
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
|
||||
@ -262,7 +263,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI)
|
||||
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
||||
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
|
||||
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, notificationEmailService)
|
||||
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
|
||||
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
|
||||
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
|
||||
|
||||
@ -162,6 +162,8 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
|
||||
@ -56,13 +56,14 @@ func firstNonEmpty(values ...string) string {
|
||||
|
||||
// SettingHandler 系统设置处理器
|
||||
type SettingHandler struct {
|
||||
settingService *service.SettingService
|
||||
emailService *service.EmailService
|
||||
turnstileService *service.TurnstileService
|
||||
opsService *service.OpsService
|
||||
paymentConfigService *service.PaymentConfigService
|
||||
paymentService *service.PaymentService
|
||||
userAttributeService *service.UserAttributeService
|
||||
settingService *service.SettingService
|
||||
emailService *service.EmailService
|
||||
turnstileService *service.TurnstileService
|
||||
opsService *service.OpsService
|
||||
paymentConfigService *service.PaymentConfigService
|
||||
paymentService *service.PaymentService
|
||||
userAttributeService *service.UserAttributeService
|
||||
notificationEmailService *service.NotificationEmailService
|
||||
}
|
||||
|
||||
// NewSettingHandler 创建系统设置处理器
|
||||
@ -78,6 +79,12 @@ func NewSettingHandler(settingService *service.SettingService, emailService *ser
|
||||
}
|
||||
}
|
||||
|
||||
// SetNotificationEmailService attaches the notification template service without changing
|
||||
// the constructor signature used by existing unit tests.
|
||||
func (h *SettingHandler) SetNotificationEmailService(notificationEmailService *service.NotificationEmailService) {
|
||||
h.notificationEmailService = notificationEmailService
|
||||
}
|
||||
|
||||
// GetSettings 获取所有系统设置
|
||||
// GET /api/v1/admin/settings
|
||||
func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
@ -3360,3 +3367,160 @@ func (h *SettingHandler) ensureUserAttributeDefinition(ctx context.Context, key,
|
||||
}
|
||||
slog.Info("dingtalk: created user attribute definition", "key", key, "name", name, "type", attrType)
|
||||
}
|
||||
|
||||
// ListEmailTemplates returns all editable notification email templates.
|
||||
// GET /api/v1/admin/settings/email-templates
|
||||
func (h *SettingHandler) ListEmailTemplates(c *gin.Context) {
|
||||
if h.notificationEmailService == nil {
|
||||
response.InternalError(c, "notification email service is not configured")
|
||||
return
|
||||
}
|
||||
events := h.notificationEmailService.ListEventInfos()
|
||||
templates, err := h.notificationEmailService.ListTemplates(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.EmailTemplateListResponse{
|
||||
Events: emailTemplateEventOptionsToDTO(events),
|
||||
Locales: h.notificationEmailService.SupportedLocales(),
|
||||
Templates: emailTemplateSummariesToDTO(templates),
|
||||
Placeholders: emailTemplatePlaceholderUnion(events),
|
||||
})
|
||||
}
|
||||
|
||||
// GetEmailTemplate returns one editable notification email template.
|
||||
// GET /api/v1/admin/settings/email-templates/:event/:locale
|
||||
func (h *SettingHandler) GetEmailTemplate(c *gin.Context) {
|
||||
if h.notificationEmailService == nil {
|
||||
response.InternalError(c, "notification email service is not configured")
|
||||
return
|
||||
}
|
||||
tmpl, err := h.notificationEmailService.GetTemplate(c.Request.Context(), c.Param("event"), c.Param("locale"))
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, emailTemplateDetailToDTO(tmpl))
|
||||
}
|
||||
|
||||
// UpdateEmailTemplate saves an override for one event/locale template.
|
||||
// PUT /api/v1/admin/settings/email-templates/:event/:locale
|
||||
func (h *SettingHandler) UpdateEmailTemplate(c *gin.Context) {
|
||||
if h.notificationEmailService == nil {
|
||||
response.InternalError(c, "notification email service is not configured")
|
||||
return
|
||||
}
|
||||
var req dto.UpdateEmailTemplateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
tmpl, err := h.notificationEmailService.UpdateTemplate(c.Request.Context(), c.Param("event"), c.Param("locale"), req.Subject, req.HTML)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, emailTemplateDetailToDTO(tmpl))
|
||||
}
|
||||
|
||||
// RestoreOfficialEmailTemplate removes an override and returns the built-in template.
|
||||
// POST /api/v1/admin/settings/email-templates/:event/:locale/restore-official
|
||||
func (h *SettingHandler) RestoreOfficialEmailTemplate(c *gin.Context) {
|
||||
if h.notificationEmailService == nil {
|
||||
response.InternalError(c, "notification email service is not configured")
|
||||
return
|
||||
}
|
||||
tmpl, err := h.notificationEmailService.RestoreOfficialTemplate(c.Request.Context(), c.Param("event"), c.Param("locale"))
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, emailTemplateDetailToDTO(tmpl))
|
||||
}
|
||||
|
||||
// PreviewEmailTemplate renders a template with safe sample variables without saving it.
|
||||
// POST /api/v1/admin/settings/email-templates/preview
|
||||
func (h *SettingHandler) PreviewEmailTemplate(c *gin.Context) {
|
||||
if h.notificationEmailService == nil {
|
||||
response.InternalError(c, "notification email service is not configured")
|
||||
return
|
||||
}
|
||||
var req dto.PreviewEmailTemplateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
preview, err := h.notificationEmailService.PreviewTemplate(c.Request.Context(), service.NotificationEmailPreviewInput{
|
||||
Event: req.Event,
|
||||
Locale: req.Locale,
|
||||
Subject: req.Subject,
|
||||
HTML: req.HTML,
|
||||
Variables: req.Variables,
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.EmailTemplatePreviewResponse{Subject: preview.Subject, HTML: preview.HTML})
|
||||
}
|
||||
|
||||
func emailTemplateEventOptionsToDTO(events []service.NotificationEmailEventInfo) []dto.EmailTemplateEventOption {
|
||||
items := make([]dto.EmailTemplateEventOption, 0, len(events))
|
||||
for _, event := range events {
|
||||
items = append(items, dto.EmailTemplateEventOption{
|
||||
Value: event.Event,
|
||||
Label: event.Label,
|
||||
Description: event.Description,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func emailTemplateSummariesToDTO(templates []service.NotificationEmailTemplate) []dto.EmailTemplateSummary {
|
||||
items := make([]dto.EmailTemplateSummary, 0, len(templates))
|
||||
for _, tmpl := range templates {
|
||||
items = append(items, dto.EmailTemplateSummary{
|
||||
Event: tmpl.Event,
|
||||
Locale: tmpl.Locale,
|
||||
Subject: tmpl.Subject,
|
||||
IsCustom: tmpl.IsCustom,
|
||||
UpdatedAt: emailTemplateUpdatedAt(tmpl),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func emailTemplateDetailToDTO(tmpl service.NotificationEmailTemplate) dto.EmailTemplateDetail {
|
||||
return dto.EmailTemplateDetail{
|
||||
Event: tmpl.Event,
|
||||
Locale: tmpl.Locale,
|
||||
Subject: tmpl.Subject,
|
||||
HTML: tmpl.HTML,
|
||||
IsCustom: tmpl.IsCustom,
|
||||
UpdatedAt: emailTemplateUpdatedAt(tmpl),
|
||||
Placeholders: tmpl.Placeholders,
|
||||
}
|
||||
}
|
||||
|
||||
func emailTemplateUpdatedAt(tmpl service.NotificationEmailTemplate) string {
|
||||
if tmpl.UpdatedAt == nil {
|
||||
return ""
|
||||
}
|
||||
return tmpl.UpdatedAt.Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
|
||||
func emailTemplatePlaceholderUnion(events []service.NotificationEmailEventInfo) []string {
|
||||
seen := make(map[string]struct{})
|
||||
placeholders := make([]string, 0)
|
||||
for _, event := range events {
|
||||
for _, placeholder := range event.Placeholders {
|
||||
if _, ok := seen[placeholder]; ok {
|
||||
continue
|
||||
}
|
||||
seen[placeholder] = struct{}{}
|
||||
placeholders = append(placeholders, placeholder)
|
||||
}
|
||||
}
|
||||
return placeholders
|
||||
}
|
||||
|
||||
@ -203,7 +203,7 @@ func (h *AuthHandler) SendVerifyCode(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.authService.SendVerifyCodeAsync(c.Request.Context(), req.Email)
|
||||
result, err := h.authService.SendVerifyCodeAsync(c.Request.Context(), req.Email, c.GetHeader("Accept-Language"))
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
@ -602,7 +602,7 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) {
|
||||
|
||||
// Request password reset (async)
|
||||
// Note: This returns success even if email doesn't exist (to prevent enumeration)
|
||||
if err := h.authService.RequestPasswordResetAsync(c.Request.Context(), req.Email, frontendBaseURL); err != nil {
|
||||
if err := h.authService.RequestPasswordResetAsync(c.Request.Context(), req.Email, frontendBaseURL, c.GetHeader("Accept-Language")); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -545,7 +545,7 @@ func (h *AuthHandler) SendPendingOAuthVerifyCode(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.authService.SendPendingOAuthVerifyCode(c.Request.Context(), req.Email)
|
||||
result, err := h.authService.SendPendingOAuthVerifyCode(c.Request.Context(), req.Email, c.GetHeader("Accept-Language"))
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
|
||||
@ -378,6 +378,62 @@ type OpenAIFastPolicySettings struct {
|
||||
Rules []OpenAIFastPolicyRule `json:"rules"`
|
||||
}
|
||||
|
||||
// EmailTemplateEventOption describes an editable notification email event.
|
||||
type EmailTemplateEventOption struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// EmailTemplateSummary is shown in the admin email template list.
|
||||
type EmailTemplateSummary struct {
|
||||
Event string `json:"event"`
|
||||
Locale string `json:"locale"`
|
||||
Subject string `json:"subject"`
|
||||
IsCustom bool `json:"is_custom,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// EmailTemplateListResponse is returned by GET /admin/settings/email-templates.
|
||||
type EmailTemplateListResponse struct {
|
||||
Events []EmailTemplateEventOption `json:"events"`
|
||||
Locales []string `json:"locales"`
|
||||
Templates []EmailTemplateSummary `json:"templates,omitempty"`
|
||||
Placeholders []string `json:"placeholders,omitempty"`
|
||||
}
|
||||
|
||||
// EmailTemplateDetail is returned for a specific event/locale template.
|
||||
type EmailTemplateDetail struct {
|
||||
Event string `json:"event"`
|
||||
Locale string `json:"locale"`
|
||||
Subject string `json:"subject"`
|
||||
HTML string `json:"html"`
|
||||
IsCustom bool `json:"is_custom,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
Placeholders []string `json:"placeholders,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateEmailTemplateRequest updates a template override.
|
||||
type UpdateEmailTemplateRequest struct {
|
||||
Subject string `json:"subject"`
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
|
||||
// PreviewEmailTemplateRequest previews a template without saving it.
|
||||
type PreviewEmailTemplateRequest struct {
|
||||
Event string `json:"event"`
|
||||
Locale string `json:"locale"`
|
||||
Subject string `json:"subject"`
|
||||
HTML string `json:"html"`
|
||||
Variables map[string]string `json:"variables,omitempty"`
|
||||
}
|
||||
|
||||
// EmailTemplatePreviewResponse is the rendered preview payload.
|
||||
type EmailTemplatePreviewResponse struct {
|
||||
Subject string `json:"subject"`
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
|
||||
// ParseCustomMenuItems parses a JSON string into a slice of CustomMenuItem.
|
||||
// Returns empty slice on empty/invalid input.
|
||||
func ParseCustomMenuItems(raw string) []CustomMenuItem {
|
||||
|
||||
@ -266,6 +266,7 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
|
||||
PaymentSource: req.PaymentSource,
|
||||
OrderType: req.OrderType,
|
||||
PlanID: req.PlanID,
|
||||
Locale: c.GetHeader("Accept-Language"),
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"html"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@ -10,8 +14,9 @@ import (
|
||||
|
||||
// SettingHandler 公开设置处理器(无需认证)
|
||||
type SettingHandler struct {
|
||||
settingService *service.SettingService
|
||||
version string
|
||||
settingService *service.SettingService
|
||||
notificationEmailService *service.NotificationEmailService
|
||||
version string
|
||||
}
|
||||
|
||||
// NewSettingHandler 创建公开设置处理器
|
||||
@ -22,6 +27,12 @@ func NewSettingHandler(settingService *service.SettingService, version string) *
|
||||
}
|
||||
}
|
||||
|
||||
// SetNotificationEmailService attaches the public notification email service without
|
||||
// changing the constructor signature used by existing tests.
|
||||
func (h *SettingHandler) SetNotificationEmailService(notificationEmailService *service.NotificationEmailService) {
|
||||
h.notificationEmailService = notificationEmailService
|
||||
}
|
||||
|
||||
// GetPublicSettings 获取公开设置
|
||||
// GET /api/v1/settings/public
|
||||
func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
@ -90,6 +101,27 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// UnsubscribeNotificationEmail handles optional notification email opt-outs.
|
||||
// GET /api/v1/settings/email-unsubscribe?token=...
|
||||
func (h *SettingHandler) UnsubscribeNotificationEmail(c *gin.Context) {
|
||||
if h.notificationEmailService == nil {
|
||||
response.InternalError(c, "notification email service is not configured")
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(c.Query("token"))
|
||||
if token == "" {
|
||||
response.BadRequest(c, "token is required")
|
||||
return
|
||||
}
|
||||
result, err := h.notificationEmailService.Unsubscribe(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
body := "<!doctype html><html><head><meta charset=\"utf-8\"><title>Unsubscribed</title></head><body style=\"font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;padding:32px;\"><h1>Unsubscribed</h1><p>You have unsubscribed <strong>" + html.EscapeString(result.Email) + "</strong> from <strong>" + html.EscapeString(result.Event) + "</strong> emails.</p></body></html>"
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(body))
|
||||
}
|
||||
|
||||
func publicLoginAgreementDocumentsToDTO(items []service.LoginAgreementDocument) []dto.LoginAgreementDocument {
|
||||
result := make([]dto.LoginAgreementDocument, 0, len(items))
|
||||
for _, item := range items {
|
||||
|
||||
@ -172,7 +172,7 @@ func (h *TotpHandler) SendVerifyCode(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.totpService.SendVerifyCode(c.Request.Context(), subject.UserID); err != nil {
|
||||
if err := h.totpService.SendVerifyCode(c.Request.Context(), subject.UserID, c.GetHeader("Accept-Language")); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -335,7 +335,7 @@ func (h *UserHandler) SendEmailBindingCode(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.authService.SendEmailIdentityBindCode(c.Request.Context(), subject.UserID, req.Email); err != nil {
|
||||
if err := h.authService.SendEmailIdentityBindCode(c.Request.Context(), subject.UserID, req.Email, c.GetHeader("Accept-Language")); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
@ -363,7 +363,7 @@ func (h *UserHandler) SendNotifyEmailCode(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
err := h.userService.SendNotifyEmailCode(c.Request.Context(), subject.UserID, req.Email, h.emailService, h.emailCache)
|
||||
err := h.userService.SendNotifyEmailCode(c.Request.Context(), subject.UserID, req.Email, h.emailService, h.emailCache, c.GetHeader("Accept-Language"))
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
|
||||
@ -80,8 +80,17 @@ func ProvideSystemHandler(updateService *service.UpdateService, lockService *ser
|
||||
}
|
||||
|
||||
// ProvideSettingHandler creates SettingHandler with version from BuildInfo
|
||||
func ProvideSettingHandler(settingService *service.SettingService, buildInfo BuildInfo) *SettingHandler {
|
||||
return NewSettingHandler(settingService, buildInfo.Version)
|
||||
func ProvideSettingHandler(settingService *service.SettingService, buildInfo BuildInfo, notificationEmailService *service.NotificationEmailService) *SettingHandler {
|
||||
h := NewSettingHandler(settingService, buildInfo.Version)
|
||||
h.SetNotificationEmailService(notificationEmailService)
|
||||
return h
|
||||
}
|
||||
|
||||
// ProvideAdminSettingHandler creates admin.SettingHandler with notification template APIs.
|
||||
func ProvideAdminSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, paymentConfigService *service.PaymentConfigService, paymentService *service.PaymentService, userAttributeService *service.UserAttributeService, notificationEmailService *service.NotificationEmailService) *admin.SettingHandler {
|
||||
h := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService)
|
||||
h.SetNotificationEmailService(notificationEmailService)
|
||||
return h
|
||||
}
|
||||
|
||||
// ProvideHandlers creates the Handlers struct
|
||||
@ -159,7 +168,7 @@ var ProviderSet = wire.NewSet(
|
||||
admin.NewProxyHandler,
|
||||
admin.NewRedeemHandler,
|
||||
admin.NewPromoHandler,
|
||||
admin.NewSettingHandler,
|
||||
ProvideAdminSettingHandler,
|
||||
admin.NewOpsHandler,
|
||||
ProvideSystemHandler,
|
||||
admin.NewSubscriptionHandler,
|
||||
|
||||
@ -416,6 +416,11 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
adminSettings.PUT("", h.Admin.Setting.UpdateSettings)
|
||||
adminSettings.POST("/test-smtp", h.Admin.Setting.TestSMTPConnection)
|
||||
adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail)
|
||||
adminSettings.GET("/email-templates", h.Admin.Setting.ListEmailTemplates)
|
||||
adminSettings.POST("/email-template-preview", h.Admin.Setting.PreviewEmailTemplate)
|
||||
adminSettings.GET("/email-templates/:event/:locale", h.Admin.Setting.GetEmailTemplate)
|
||||
adminSettings.PUT("/email-templates/:event/:locale", h.Admin.Setting.UpdateEmailTemplate)
|
||||
adminSettings.POST("/email-templates/:event/:locale/restore-official", h.Admin.Setting.RestoreOfficialEmailTemplate)
|
||||
// Admin API Key 管理
|
||||
adminSettings.GET("/admin-api-key", h.Admin.Setting.GetAdminAPIKey)
|
||||
adminSettings.POST("/admin-api-key/regenerate", h.Admin.Setting.RegenerateAdminAPIKey)
|
||||
|
||||
@ -214,6 +214,7 @@ func RegisterAuthRoutes(
|
||||
settings := v1.Group("/settings")
|
||||
{
|
||||
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.
|
||||
func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int64, email string) error {
|
||||
func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int64, email string, locale ...string) error {
|
||||
if s == nil {
|
||||
return ErrServiceUnavailable
|
||||
}
|
||||
@ -128,7 +128,7 @@ func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int6
|
||||
if s.settingService != nil {
|
||||
siteName = s.settingService.GetSiteName(ctx)
|
||||
}
|
||||
return s.emailService.SendVerifyCode(ctx, normalizedEmail, siteName)
|
||||
return s.emailService.SendVerifyCode(ctx, normalizedEmail, siteName, firstEmailLocale(locale))
|
||||
}
|
||||
|
||||
func normalizeEmailForIdentityBinding(email string) (string, error) {
|
||||
|
||||
@ -28,7 +28,7 @@ func normalizeOAuthSignupSource(signupSource string) string {
|
||||
|
||||
// SendPendingOAuthVerifyCode sends a local verification code for pending OAuth
|
||||
// account-creation flows without relying on the public registration gate.
|
||||
func (s *AuthService) SendPendingOAuthVerifyCode(ctx context.Context, email string) (*SendVerifyCodeResult, error) {
|
||||
func (s *AuthService) SendPendingOAuthVerifyCode(ctx context.Context, email string, locale ...string) (*SendVerifyCodeResult, error) {
|
||||
email = strings.TrimSpace(strings.ToLower(email))
|
||||
if email == "" {
|
||||
return nil, ErrEmailVerifyRequired
|
||||
@ -47,7 +47,7 @@ func (s *AuthService) SendPendingOAuthVerifyCode(ctx context.Context, email stri
|
||||
if s.settingService != nil {
|
||||
siteName = s.settingService.GetSiteName(ctx)
|
||||
}
|
||||
if err := s.emailService.SendVerifyCode(ctx, email, siteName); err != nil {
|
||||
if err := s.emailService.SendVerifyCode(ctx, email, siteName, firstEmailLocale(locale)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SendVerifyCodeResult{
|
||||
|
||||
@ -273,7 +273,7 @@ type SendVerifyCodeResult struct {
|
||||
}
|
||||
|
||||
// SendVerifyCode 发送邮箱验证码(同步方式)
|
||||
func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error {
|
||||
func (s *AuthService) SendVerifyCode(ctx context.Context, email string, locale ...string) error {
|
||||
// 检查是否开放注册(默认关闭)
|
||||
if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
|
||||
return ErrRegDisabled
|
||||
@ -307,11 +307,11 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error {
|
||||
siteName = s.settingService.GetSiteName(ctx)
|
||||
}
|
||||
|
||||
return s.emailService.SendVerifyCode(ctx, email, siteName)
|
||||
return s.emailService.SendVerifyCode(ctx, email, siteName, firstEmailLocale(locale))
|
||||
}
|
||||
|
||||
// SendVerifyCodeAsync 异步发送邮箱验证码并返回倒计时
|
||||
func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*SendVerifyCodeResult, error) {
|
||||
func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string, locale ...string) (*SendVerifyCodeResult, error) {
|
||||
logger.LegacyPrintf("service.auth", "[Auth] SendVerifyCodeAsync called for email: %s", email)
|
||||
|
||||
// 检查是否开放注册(默认关闭)
|
||||
@ -352,7 +352,7 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*S
|
||||
|
||||
// 异步发送
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Enqueueing verify code for: %s", email)
|
||||
if err := s.emailQueueService.EnqueueVerifyCode(email, siteName); err != nil {
|
||||
if err := s.emailQueueService.EnqueueVerifyCode(email, siteName, firstEmailLocale(locale)); err != nil {
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to enqueue: %v", err)
|
||||
return nil, fmt.Errorf("enqueue verify code: %w", err)
|
||||
}
|
||||
@ -1251,7 +1251,7 @@ func (s *AuthService) preparePasswordReset(ctx context.Context, email, frontendB
|
||||
|
||||
// RequestPasswordReset 请求密码重置(同步发送)
|
||||
// Security: Returns the same response regardless of whether the email exists (prevent user enumeration)
|
||||
func (s *AuthService) RequestPasswordReset(ctx context.Context, email, frontendBaseURL string) error {
|
||||
func (s *AuthService) RequestPasswordReset(ctx context.Context, email, frontendBaseURL string, locale ...string) error {
|
||||
if !s.IsPasswordResetEnabled(ctx) {
|
||||
return infraerrors.Forbidden("PASSWORD_RESET_DISABLED", "password reset is not enabled")
|
||||
}
|
||||
@ -1264,7 +1264,7 @@ func (s *AuthService) RequestPasswordReset(ctx context.Context, email, frontendB
|
||||
return nil // Silent success to prevent enumeration
|
||||
}
|
||||
|
||||
if err := s.emailService.SendPasswordResetEmail(ctx, email, siteName, resetURL); err != nil {
|
||||
if err := s.emailService.SendPasswordResetEmail(ctx, email, siteName, resetURL, firstEmailLocale(locale)); err != nil {
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to send password reset email to %s: %v", email, err)
|
||||
return nil // Silent success to prevent enumeration
|
||||
}
|
||||
@ -1275,7 +1275,7 @@ func (s *AuthService) RequestPasswordReset(ctx context.Context, email, frontendB
|
||||
|
||||
// RequestPasswordResetAsync 异步请求密码重置(队列发送)
|
||||
// Security: Returns the same response regardless of whether the email exists (prevent user enumeration)
|
||||
func (s *AuthService) RequestPasswordResetAsync(ctx context.Context, email, frontendBaseURL string) error {
|
||||
func (s *AuthService) RequestPasswordResetAsync(ctx context.Context, email, frontendBaseURL string, locale ...string) error {
|
||||
if !s.IsPasswordResetEnabled(ctx) {
|
||||
return infraerrors.Forbidden("PASSWORD_RESET_DISABLED", "password reset is not enabled")
|
||||
}
|
||||
@ -1288,7 +1288,7 @@ func (s *AuthService) RequestPasswordResetAsync(ctx context.Context, email, fron
|
||||
return nil // Silent success to prevent enumeration
|
||||
}
|
||||
|
||||
if err := s.emailQueueService.EnqueuePasswordReset(email, siteName, resetURL); err != nil {
|
||||
if err := s.emailQueueService.EnqueuePasswordReset(email, siteName, resetURL, firstEmailLocale(locale)); err != nil {
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to enqueue password reset email for %s: %v", email, err)
|
||||
return nil // Silent success to prevent enumeration
|
||||
}
|
||||
|
||||
@ -39,9 +39,10 @@ type AccountQuotaReader interface {
|
||||
|
||||
// BalanceNotifyService handles balance and quota threshold notifications.
|
||||
type BalanceNotifyService struct {
|
||||
emailService *EmailService
|
||||
settingRepo SettingRepository
|
||||
accountRepo AccountQuotaReader
|
||||
emailService *EmailService
|
||||
settingRepo SettingRepository
|
||||
accountRepo AccountQuotaReader
|
||||
notificationEmailService *NotificationEmailService
|
||||
}
|
||||
|
||||
// NewBalanceNotifyService creates a new BalanceNotifyService.
|
||||
@ -53,6 +54,10 @@ func NewBalanceNotifyService(emailService *EmailService, settingRepo SettingRepo
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BalanceNotifyService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) {
|
||||
s.notificationEmailService = notificationEmailService
|
||||
}
|
||||
|
||||
// resolveBalanceThreshold returns the effective balance threshold.
|
||||
// For percentage type, it computes threshold = totalRecharged * percentage / 100.
|
||||
func resolveBalanceThreshold(threshold float64, thresholdType string, totalRecharged float64) float64 {
|
||||
@ -125,7 +130,7 @@ func (s *BalanceNotifyService) dispatchBalanceLowEmail(ctx context.Context, user
|
||||
slog.Error("panic in balance notification", "recover", r)
|
||||
}
|
||||
}()
|
||||
s.sendBalanceLowEmails(recipients, user.Username, user.Email, newBalance, threshold, siteName, rechargeURL)
|
||||
s.sendBalanceLowEmails(recipients, user.ID, user.Username, user.Email, newBalance, threshold, siteName, rechargeURL)
|
||||
}()
|
||||
}
|
||||
|
||||
@ -342,11 +347,44 @@ func (s *BalanceNotifyService) sendEmails(recipients []string, subject, body str
|
||||
}
|
||||
|
||||
// sendBalanceLowEmails sends balance low notification to all recipients.
|
||||
func (s *BalanceNotifyService) sendBalanceLowEmails(recipients []string, userName, userEmail string, balance, threshold float64, siteName, rechargeURL string) {
|
||||
func (s *BalanceNotifyService) sendBalanceLowEmails(recipients []string, userID int64, userName, userEmail string, balance, threshold float64, siteName, rechargeURL string) {
|
||||
displayName := userName
|
||||
if displayName == "" {
|
||||
displayName = userEmail
|
||||
}
|
||||
if s.notificationEmailService != nil {
|
||||
fallbackRecipients := make([]string, 0, len(recipients))
|
||||
for _, to := range recipients {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), emailSendTimeout)
|
||||
err := s.notificationEmailService.Send(ctx, NotificationEmailSendInput{
|
||||
Event: NotificationEmailEventBalanceLow,
|
||||
RecipientEmail: to,
|
||||
RecipientName: displayName,
|
||||
UserID: userID,
|
||||
SourceType: "balance_low",
|
||||
SourceID: firstNonEmpty(strconv.FormatInt(userID, 10), userEmail),
|
||||
ReminderKey: time.Now().UTC().Format("2006-01-02"),
|
||||
Variables: map[string]string{
|
||||
"current_balance": fmt.Sprintf("%.2f", balance),
|
||||
"threshold": fmt.Sprintf("%.2f", threshold),
|
||||
"recharge_url": rechargeURL,
|
||||
},
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
if shouldFallbackNotificationEmail(err) {
|
||||
slog.Warn("template balance low notification failed; falling back to built-in body", "to", to, "err", err.Error())
|
||||
fallbackRecipients = append(fallbackRecipients, to)
|
||||
} else {
|
||||
slog.Warn("template balance low notification delivery failed; not sending fallback to avoid duplicates", "to", to, "err", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(fallbackRecipients) == 0 {
|
||||
return
|
||||
}
|
||||
recipients = fallbackRecipients
|
||||
}
|
||||
subject := fmt.Sprintf("[%s] 余额不足提醒 / Balance Low Alert", sanitizeEmailHeader(siteName))
|
||||
body := s.buildBalanceLowEmailBody(html.EscapeString(displayName), balance, threshold, html.EscapeString(siteName), rechargeURL)
|
||||
s.sendEmails(recipients, subject, body, "user_email", userEmail, "balance", balance)
|
||||
@ -369,6 +407,44 @@ func (s *BalanceNotifyService) sendQuotaAlertEmails(adminEmails []string, accoun
|
||||
remaining = 0
|
||||
}
|
||||
|
||||
if s.notificationEmailService != nil {
|
||||
fallbackRecipients := make([]string, 0, len(adminEmails))
|
||||
for _, to := range adminEmails {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), emailSendTimeout)
|
||||
err := s.notificationEmailService.Send(ctx, NotificationEmailSendInput{
|
||||
Event: NotificationEmailEventAccountQuotaAlert,
|
||||
RecipientEmail: to,
|
||||
RecipientName: emailRecipientName(to),
|
||||
SourceType: "account_quota",
|
||||
SourceID: fmt.Sprintf("%d-%s", accountID, dim.name),
|
||||
ReminderKey: time.Now().UTC().Format("2006-01-02"),
|
||||
Variables: map[string]string{
|
||||
"account_id": strconv.FormatInt(accountID, 10),
|
||||
"account_name": accountName,
|
||||
"platform": platform,
|
||||
"quota_dimension": dimLabel,
|
||||
"quota_used": fmt.Sprintf("%.2f", used),
|
||||
"quota_limit": fmt.Sprintf("%.2f", dim.limit),
|
||||
"quota_remaining": fmt.Sprintf("%.2f", remaining),
|
||||
"quota_threshold": thresholdDisplay,
|
||||
},
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
if shouldFallbackNotificationEmail(err) {
|
||||
slog.Warn("template account quota alert failed; falling back to built-in body", "to", to, "account_id", accountID, "dimension", dim.name, "err", err.Error())
|
||||
fallbackRecipients = append(fallbackRecipients, to)
|
||||
} else {
|
||||
slog.Warn("template account quota alert delivery failed; not sending fallback to avoid duplicates", "to", to, "account_id", accountID, "dimension", dim.name, "err", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(fallbackRecipients) == 0 {
|
||||
return
|
||||
}
|
||||
adminEmails = fallbackRecipients
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("[%s] 账号限额告警 / Account Quota Alert - %s", sanitizeEmailHeader(siteName), sanitizeEmailHeader(accountName))
|
||||
body := s.buildQuotaAlertEmailBody(accountID, html.EscapeString(accountName), html.EscapeString(platform), html.EscapeString(dimLabel), used, dim.limit, remaining, thresholdDisplay, html.EscapeString(siteName))
|
||||
s.sendEmails(adminEmails, subject, body, "account", accountName, "dimension", dim.name)
|
||||
|
||||
@ -1463,6 +1463,24 @@ func (s *ContentModerationService) applyFlaggedSideEffects(ctx context.Context,
|
||||
|
||||
func (s *ContentModerationService) sendViolationEmail(ctx context.Context, cfg *ContentModerationConfig, log *ContentModerationLog) error {
|
||||
siteName := s.siteName(ctx)
|
||||
if s.emailService.notificationEmailService != nil {
|
||||
if err := s.emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{
|
||||
Event: NotificationEmailEventContentModerationViolation,
|
||||
RecipientEmail: log.UserEmail,
|
||||
RecipientName: emailRecipientName(log.UserEmail),
|
||||
UserID: contentModerationEmailUserID(log),
|
||||
SourceType: "content_moderation",
|
||||
SourceID: contentModerationEmailSourceID(log),
|
||||
Variables: contentModerationEmailVariables(log, cfg),
|
||||
}); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
if !shouldFallbackNotificationEmail(err) {
|
||||
return err
|
||||
}
|
||||
slog.Warn("template content moderation violation email failed; falling back to built-in body", "log_id", log.ID, "recipient_hash", notificationEmailHash(log.UserEmail), "err", err.Error())
|
||||
}
|
||||
}
|
||||
subject := fmt.Sprintf("[%s] 账户风控提醒 / Risk Control Notice", sanitizeEmailHeader(siteName))
|
||||
body := buildContentModerationViolationEmailBody(siteName, log, cfg)
|
||||
return s.emailService.SendEmail(ctx, log.UserEmail, subject, body)
|
||||
@ -1470,11 +1488,71 @@ func (s *ContentModerationService) sendViolationEmail(ctx context.Context, cfg *
|
||||
|
||||
func (s *ContentModerationService) sendAccountDisabledEmail(ctx context.Context, cfg *ContentModerationConfig, log *ContentModerationLog) error {
|
||||
siteName := s.siteName(ctx)
|
||||
if s.emailService.notificationEmailService != nil {
|
||||
if err := s.emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{
|
||||
Event: NotificationEmailEventContentModerationDisabled,
|
||||
RecipientEmail: log.UserEmail,
|
||||
RecipientName: emailRecipientName(log.UserEmail),
|
||||
UserID: contentModerationEmailUserID(log),
|
||||
SourceType: "content_moderation",
|
||||
SourceID: contentModerationEmailSourceID(log),
|
||||
Variables: contentModerationEmailVariables(log, cfg),
|
||||
}); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
if !shouldFallbackNotificationEmail(err) {
|
||||
return err
|
||||
}
|
||||
slog.Warn("template content moderation disabled email failed; falling back to built-in body", "log_id", log.ID, "recipient_hash", notificationEmailHash(log.UserEmail), "err", err.Error())
|
||||
}
|
||||
}
|
||||
subject := fmt.Sprintf("[%s] 账户已被禁用 / Account Disabled", sanitizeEmailHeader(siteName))
|
||||
body := buildContentModerationAccountDisabledEmailBody(siteName, log, cfg)
|
||||
return s.emailService.SendEmail(ctx, log.UserEmail, subject, body)
|
||||
}
|
||||
|
||||
func contentModerationEmailUserID(log *ContentModerationLog) int64 {
|
||||
if log == nil || log.UserID == nil {
|
||||
return 0
|
||||
}
|
||||
return *log.UserID
|
||||
}
|
||||
|
||||
func contentModerationEmailSourceID(log *ContentModerationLog) string {
|
||||
if log == nil || log.ID <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", log.ID)
|
||||
}
|
||||
|
||||
func contentModerationEmailVariables(log *ContentModerationLog, cfg *ContentModerationConfig) map[string]string {
|
||||
variables := map[string]string{
|
||||
"triggered_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"group_name": "-",
|
||||
"moderation_category": "-",
|
||||
"moderation_score": "0.000",
|
||||
"violation_count": "0",
|
||||
"ban_threshold": "0",
|
||||
}
|
||||
if log != nil {
|
||||
if !log.CreatedAt.IsZero() {
|
||||
variables["triggered_at"] = log.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if strings.TrimSpace(log.GroupName) != "" {
|
||||
variables["group_name"] = strings.TrimSpace(log.GroupName)
|
||||
}
|
||||
if strings.TrimSpace(log.HighestCategory) != "" {
|
||||
variables["moderation_category"] = strings.TrimSpace(log.HighestCategory)
|
||||
}
|
||||
variables["moderation_score"] = fmt.Sprintf("%.3f", log.HighestScore)
|
||||
variables["violation_count"] = fmt.Sprintf("%d", log.ViolationCount)
|
||||
}
|
||||
if cfg != nil {
|
||||
variables["ban_threshold"] = fmt.Sprintf("%d", cfg.BanThreshold)
|
||||
}
|
||||
return variables
|
||||
}
|
||||
|
||||
func (s *ContentModerationService) siteName(ctx context.Context) string {
|
||||
if s == nil || s.settingRepo == nil {
|
||||
return "Sub2API"
|
||||
|
||||
@ -21,6 +21,7 @@ type EmailTask struct {
|
||||
SiteName string
|
||||
TaskType string // "verify_code" or "password_reset"
|
||||
ResetURL string // Only used for password_reset task type
|
||||
Locale string // Optional Accept-Language locale hint
|
||||
}
|
||||
|
||||
// EmailQueueService 异步邮件队列服务
|
||||
@ -82,13 +83,13 @@ func (s *EmailQueueService) processTask(workerID int, task EmailTask) {
|
||||
|
||||
switch task.TaskType {
|
||||
case TaskTypeVerifyCode:
|
||||
if err := s.emailService.SendVerifyCode(ctx, task.Email, task.SiteName); err != nil {
|
||||
if err := s.emailService.SendVerifyCode(ctx, task.Email, task.SiteName, task.Locale); err != nil {
|
||||
logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d failed to send verify code to %s: %v", workerID, task.Email, err)
|
||||
} else {
|
||||
logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d sent verify code to %s", workerID, task.Email)
|
||||
}
|
||||
case TaskTypePasswordReset:
|
||||
if err := s.emailService.SendPasswordResetEmailWithCooldown(ctx, task.Email, task.SiteName, task.ResetURL); err != nil {
|
||||
if err := s.emailService.SendPasswordResetEmailWithCooldown(ctx, task.Email, task.SiteName, task.ResetURL, task.Locale); err != nil {
|
||||
logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d failed to send password reset to %s: %v", workerID, task.Email, err)
|
||||
} else {
|
||||
logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d sent password reset to %s", workerID, task.Email)
|
||||
@ -99,11 +100,12 @@ func (s *EmailQueueService) processTask(workerID int, task EmailTask) {
|
||||
}
|
||||
|
||||
// EnqueueVerifyCode 将验证码发送任务加入队列
|
||||
func (s *EmailQueueService) EnqueueVerifyCode(email, siteName string) error {
|
||||
func (s *EmailQueueService) EnqueueVerifyCode(email, siteName string, locale ...string) error {
|
||||
task := EmailTask{
|
||||
Email: email,
|
||||
SiteName: siteName,
|
||||
TaskType: TaskTypeVerifyCode,
|
||||
Locale: firstEmailLocale(locale),
|
||||
}
|
||||
|
||||
select {
|
||||
@ -116,12 +118,13 @@ func (s *EmailQueueService) EnqueueVerifyCode(email, siteName string) error {
|
||||
}
|
||||
|
||||
// EnqueuePasswordReset 将密码重置邮件任务加入队列
|
||||
func (s *EmailQueueService) EnqueuePasswordReset(email, siteName, resetURL string) error {
|
||||
func (s *EmailQueueService) EnqueuePasswordReset(email, siteName, resetURL string, locale ...string) error {
|
||||
task := EmailTask{
|
||||
Email: email,
|
||||
SiteName: siteName,
|
||||
TaskType: TaskTypePasswordReset,
|
||||
ResetURL: resetURL,
|
||||
Locale: firstEmailLocale(locale),
|
||||
}
|
||||
|
||||
select {
|
||||
|
||||
@ -94,8 +94,9 @@ type SMTPConfig struct {
|
||||
|
||||
// EmailService 邮件服务
|
||||
type EmailService struct {
|
||||
settingRepo SettingRepository
|
||||
cache EmailCache
|
||||
settingRepo SettingRepository
|
||||
cache EmailCache
|
||||
notificationEmailService *NotificationEmailService
|
||||
}
|
||||
|
||||
// NewEmailService 创建邮件服务实例
|
||||
@ -106,6 +107,28 @@ func NewEmailService(settingRepo SettingRepository, cache EmailCache) *EmailServ
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EmailService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) {
|
||||
s.notificationEmailService = notificationEmailService
|
||||
}
|
||||
|
||||
func firstEmailLocale(locales []string) string {
|
||||
if len(locales) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(locales[0])
|
||||
}
|
||||
|
||||
func emailRecipientName(email string) string {
|
||||
trimmed := strings.TrimSpace(email)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if at := strings.Index(trimmed, "@"); at > 0 {
|
||||
return trimmed[:at]
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// GetSMTPConfig 从数据库获取SMTP配置
|
||||
func (s *EmailService) GetSMTPConfig(ctx context.Context) (*SMTPConfig, error) {
|
||||
keys := []string{
|
||||
@ -301,7 +324,7 @@ func (s *EmailService) GenerateVerifyCode() (string, error) {
|
||||
}
|
||||
|
||||
// SendVerifyCode 发送验证码邮件
|
||||
func (s *EmailService) SendVerifyCode(ctx context.Context, email, siteName string) error {
|
||||
func (s *EmailService) SendVerifyCode(ctx context.Context, email, siteName string, locale ...string) error {
|
||||
// 检查是否在冷却期内
|
||||
existing, err := s.cache.GetVerificationCode(ctx, email)
|
||||
if err == nil && existing != nil {
|
||||
@ -327,6 +350,26 @@ func (s *EmailService) SendVerifyCode(ctx context.Context, email, siteName strin
|
||||
return fmt.Errorf("save verify code: %w", err)
|
||||
}
|
||||
|
||||
if s.notificationEmailService != nil {
|
||||
err := s.notificationEmailService.Send(ctx, NotificationEmailSendInput{
|
||||
Event: NotificationEmailEventAuthVerifyCode,
|
||||
Locale: firstEmailLocale(locale),
|
||||
RecipientEmail: email,
|
||||
RecipientName: emailRecipientName(email),
|
||||
Variables: map[string]string{
|
||||
"verification_code": code,
|
||||
"expires_in_minutes": strconv.Itoa(int(verifyCodeTTL / time.Minute)),
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !shouldFallbackNotificationEmail(err) {
|
||||
return err
|
||||
}
|
||||
slog.Warn("failed to send templated verification email, falling back to legacy template", "recipient_hash", notificationEmailHash(email), "error", err)
|
||||
}
|
||||
|
||||
// 构建邮件内容
|
||||
subject := fmt.Sprintf("[%s] Email Verification Code", siteName)
|
||||
body := s.buildVerifyCodeEmailBody(code, siteName)
|
||||
@ -469,7 +512,7 @@ func (s *EmailService) GeneratePasswordResetToken() (string, error) {
|
||||
}
|
||||
|
||||
// SendPasswordResetEmail sends a password reset email with a reset link
|
||||
func (s *EmailService) SendPasswordResetEmail(ctx context.Context, email, siteName, resetURL string) error {
|
||||
func (s *EmailService) SendPasswordResetEmail(ctx context.Context, email, siteName, resetURL string, locale ...string) error {
|
||||
var token string
|
||||
var needSaveToken bool
|
||||
|
||||
@ -502,6 +545,26 @@ func (s *EmailService) SendPasswordResetEmail(ctx context.Context, email, siteNa
|
||||
// Build full reset URL with URL-encoded token and email
|
||||
fullResetURL := fmt.Sprintf("%s?email=%s&token=%s", resetURL, url.QueryEscape(email), url.QueryEscape(token))
|
||||
|
||||
if s.notificationEmailService != nil {
|
||||
err := s.notificationEmailService.Send(ctx, NotificationEmailSendInput{
|
||||
Event: NotificationEmailEventAuthPasswordReset,
|
||||
Locale: firstEmailLocale(locale),
|
||||
RecipientEmail: email,
|
||||
RecipientName: emailRecipientName(email),
|
||||
Variables: map[string]string{
|
||||
"reset_url": fullResetURL,
|
||||
"expires_in_minutes": strconv.Itoa(int(passwordResetTokenTTL / time.Minute)),
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !shouldFallbackNotificationEmail(err) {
|
||||
return err
|
||||
}
|
||||
slog.Warn("failed to send templated password reset email, falling back to legacy template", "recipient_hash", notificationEmailHash(email), "error", err)
|
||||
}
|
||||
|
||||
// Build email content
|
||||
subject := fmt.Sprintf("[%s] 密码重置请求", siteName)
|
||||
body := s.buildPasswordResetEmailBody(fullResetURL, siteName)
|
||||
@ -516,7 +579,7 @@ func (s *EmailService) SendPasswordResetEmail(ctx context.Context, email, siteNa
|
||||
|
||||
// SendPasswordResetEmailWithCooldown sends password reset email with cooldown check (called by queue worker)
|
||||
// This method wraps SendPasswordResetEmail with email cooldown to prevent email bombing
|
||||
func (s *EmailService) SendPasswordResetEmailWithCooldown(ctx context.Context, email, siteName, resetURL string) error {
|
||||
func (s *EmailService) SendPasswordResetEmailWithCooldown(ctx context.Context, email, siteName, resetURL string, locale ...string) error {
|
||||
// Check email cooldown to prevent email bombing
|
||||
if s.cache.IsPasswordResetEmailInCooldown(ctx, email) {
|
||||
slog.Info("password reset email skipped due to cooldown", "email", email)
|
||||
@ -524,7 +587,7 @@ func (s *EmailService) SendPasswordResetEmailWithCooldown(ctx context.Context, e
|
||||
}
|
||||
|
||||
// Send email using core method
|
||||
if err := s.SendPasswordResetEmail(ctx, email, siteName, resetURL); err != nil {
|
||||
if err := s.SendPasswordResetEmail(ctx, email, siteName, resetURL, firstEmailLocale(locale)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
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()) {
|
||||
continue
|
||||
}
|
||||
if s.emailService.notificationEmailService != nil {
|
||||
if err := s.emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{
|
||||
Event: NotificationEmailEventOpsAlert,
|
||||
RecipientEmail: addr,
|
||||
RecipientName: emailRecipientName(addr),
|
||||
SourceType: "ops_alert",
|
||||
SourceID: fmt.Sprintf("%d", event.ID),
|
||||
Variables: opsAlertEmailVariables(rule, event),
|
||||
}); err == nil {
|
||||
anySent = true
|
||||
continue
|
||||
} else if !shouldFallbackNotificationEmail(err) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := s.emailService.SendEmail(ctx, addr, subject, body); err != nil {
|
||||
// Ignore per-recipient failures; continue best-effort.
|
||||
continue
|
||||
@ -699,6 +714,46 @@ func (s *OpsAlertEvaluatorService) maybeSendAlertEmail(ctx context.Context, runt
|
||||
return anySent
|
||||
}
|
||||
|
||||
func opsAlertEmailVariables(rule *OpsAlertRule, event *OpsAlertEvent) map[string]string {
|
||||
variables := map[string]string{
|
||||
"rule_name": "-",
|
||||
"severity": "-",
|
||||
"alert_status": "-",
|
||||
"metric_type": "-",
|
||||
"operator": "-",
|
||||
"metric_value": "-",
|
||||
"threshold_value": "-",
|
||||
"triggered_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"alert_description": "-",
|
||||
}
|
||||
if rule != nil {
|
||||
variables["rule_name"] = strings.TrimSpace(rule.Name)
|
||||
variables["severity"] = strings.TrimSpace(rule.Severity)
|
||||
variables["metric_type"] = strings.TrimSpace(rule.MetricType)
|
||||
variables["operator"] = strings.TrimSpace(rule.Operator)
|
||||
variables["threshold_value"] = fmt.Sprintf("%.2f", rule.Threshold)
|
||||
if strings.TrimSpace(rule.Description) != "" {
|
||||
variables["alert_description"] = strings.TrimSpace(rule.Description)
|
||||
}
|
||||
}
|
||||
if event != nil {
|
||||
variables["alert_status"] = strings.TrimSpace(event.Status)
|
||||
if event.MetricValue != nil {
|
||||
variables["metric_value"] = fmt.Sprintf("%.2f", *event.MetricValue)
|
||||
}
|
||||
if event.ThresholdValue != nil {
|
||||
variables["threshold_value"] = fmt.Sprintf("%.2f", *event.ThresholdValue)
|
||||
}
|
||||
if !event.FiredAt.IsZero() {
|
||||
variables["triggered_at"] = event.FiredAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if strings.TrimSpace(event.Description) != "" {
|
||||
variables["alert_description"] = strings.TrimSpace(event.Description)
|
||||
}
|
||||
}
|
||||
return variables
|
||||
}
|
||||
|
||||
func buildOpsAlertEmailBody(rule *OpsAlertRule, event *OpsAlertEvent) string {
|
||||
if rule == nil || event == nil {
|
||||
return ""
|
||||
|
||||
@ -337,6 +337,7 @@ func (s *OpsScheduledReportService) runReport(ctx context.Context, report *opsSc
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("[Ops Report] %s", strings.TrimSpace(report.Name))
|
||||
templateVariables := opsScheduledReportEmailVariables(report, now)
|
||||
|
||||
attempts := 0
|
||||
for _, to := range recipients {
|
||||
@ -345,6 +346,24 @@ func (s *OpsScheduledReportService) runReport(ctx context.Context, report *opsSc
|
||||
continue
|
||||
}
|
||||
attempts++
|
||||
if s.emailService.notificationEmailService != nil {
|
||||
if err := s.emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{
|
||||
Event: NotificationEmailEventOpsScheduledReport,
|
||||
RecipientEmail: addr,
|
||||
RecipientName: emailRecipientName(addr),
|
||||
SourceType: "ops_scheduled_report",
|
||||
SourceID: opsScheduledReportDeliverySourceID(report),
|
||||
ReminderKey: now.UTC().Format("2006-01-02T15:04"),
|
||||
Variables: templateVariables,
|
||||
RawHTMLVariables: map[string]string{
|
||||
"report_html": content,
|
||||
},
|
||||
}); err == nil {
|
||||
continue
|
||||
} else if !shouldFallbackNotificationEmail(err) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := s.emailService.SendEmail(ctx, addr, subject, content); err != nil {
|
||||
// Ignore per-recipient failures; continue best-effort.
|
||||
continue
|
||||
@ -353,6 +372,46 @@ func (s *OpsScheduledReportService) runReport(ctx context.Context, report *opsSc
|
||||
return attempts, nil
|
||||
}
|
||||
|
||||
func opsScheduledReportDeliverySourceID(report *opsScheduledReport) string {
|
||||
if report == nil {
|
||||
return "scheduled_report"
|
||||
}
|
||||
parts := []string{
|
||||
strings.TrimSpace(report.ReportType),
|
||||
strings.TrimSpace(report.Name),
|
||||
strings.TrimSpace(report.Schedule),
|
||||
}
|
||||
joined := strings.Trim(strings.Join(parts, ":"), ":")
|
||||
if joined == "" {
|
||||
return "scheduled_report"
|
||||
}
|
||||
return joined
|
||||
}
|
||||
|
||||
func opsScheduledReportEmailVariables(report *opsScheduledReport, now time.Time) map[string]string {
|
||||
end := now.UTC()
|
||||
start := end
|
||||
name := "Ops report"
|
||||
reportType := "scheduled_report"
|
||||
if report != nil {
|
||||
if strings.TrimSpace(report.Name) != "" {
|
||||
name = strings.TrimSpace(report.Name)
|
||||
}
|
||||
if strings.TrimSpace(report.ReportType) != "" {
|
||||
reportType = strings.TrimSpace(report.ReportType)
|
||||
}
|
||||
if report.TimeRange > 0 {
|
||||
start = end.Add(-report.TimeRange)
|
||||
}
|
||||
}
|
||||
return map[string]string{
|
||||
"report_name": name,
|
||||
"report_type": reportType,
|
||||
"report_start_time": start.Format(time.RFC3339),
|
||||
"report_end_time": end.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OpsScheduledReportService) generateReportHTML(ctx context.Context, report *opsScheduledReport, now time.Time) (string, error) {
|
||||
if s == nil || s.opsService == nil || report == nil {
|
||||
return "", fmt.Errorf("service not initialized")
|
||||
|
||||
@ -310,9 +310,87 @@ func (s *PaymentService) markCompleted(ctx context.Context, o *dbent.PaymentOrde
|
||||
"creditedAmount": o.Amount,
|
||||
"payAmount": o.PayAmount,
|
||||
})
|
||||
s.dispatchPaymentFulfillmentNotification(o, auditAction)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) dispatchPaymentFulfillmentNotification(o *dbent.PaymentOrder, auditAction string) {
|
||||
if s == nil || s.notificationEmailService == nil || o == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), emailSendTimeout)
|
||||
defer cancel()
|
||||
var err error
|
||||
switch auditAction {
|
||||
case "RECHARGE_SUCCESS":
|
||||
err = s.sendBalanceRechargeSuccessNotification(ctx, o)
|
||||
case "SUBSCRIPTION_SUCCESS":
|
||||
err = s.sendSubscriptionPurchaseSuccessNotification(ctx, o)
|
||||
default:
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("payment fulfillment notification email failed", "order_id", o.ID, "action", auditAction, "err", err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *PaymentService) sendBalanceRechargeSuccessNotification(ctx context.Context, o *dbent.PaymentOrder) error {
|
||||
currentBalance := ""
|
||||
if s.userRepo != nil {
|
||||
if user, err := s.userRepo.GetByID(ctx, o.UserID); err == nil && user != nil {
|
||||
currentBalance = fmt.Sprintf("%.2f", user.Balance)
|
||||
}
|
||||
}
|
||||
return s.notificationEmailService.Send(ctx, NotificationEmailSendInput{
|
||||
Event: NotificationEmailEventBalanceRechargeSuccess,
|
||||
RecipientEmail: o.UserEmail,
|
||||
RecipientName: firstNonEmpty(o.UserName, o.UserEmail),
|
||||
UserID: o.UserID,
|
||||
SourceType: "payment_order",
|
||||
SourceID: strconv.FormatInt(o.ID, 10),
|
||||
Variables: map[string]string{
|
||||
"recharge_amount": fmt.Sprintf("%.2f", o.Amount),
|
||||
"current_balance": currentBalance,
|
||||
"order_id": strconv.FormatInt(o.ID, 10),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *PaymentService) sendSubscriptionPurchaseSuccessNotification(ctx context.Context, o *dbent.PaymentOrder) error {
|
||||
variables := map[string]string{
|
||||
"subscription_group": "Subscription",
|
||||
"subscription_days": "",
|
||||
"expiry_time": "",
|
||||
"order_id": strconv.FormatInt(o.ID, 10),
|
||||
}
|
||||
if o.SubscriptionDays != nil {
|
||||
variables["subscription_days"] = strconv.Itoa(*o.SubscriptionDays)
|
||||
}
|
||||
if o.SubscriptionGroupID != nil {
|
||||
if s.groupRepo != nil {
|
||||
if group, err := s.groupRepo.GetByID(ctx, *o.SubscriptionGroupID); err == nil && group != nil && strings.TrimSpace(group.Name) != "" {
|
||||
variables["subscription_group"] = group.Name
|
||||
}
|
||||
}
|
||||
if s.subscriptionSvc != nil {
|
||||
if sub, err := s.subscriptionSvc.GetActiveSubscription(ctx, o.UserID, *o.SubscriptionGroupID); err == nil && sub != nil {
|
||||
variables["expiry_time"] = sub.ExpiresAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.notificationEmailService.Send(ctx, NotificationEmailSendInput{
|
||||
Event: NotificationEmailEventSubscriptionPurchaseSuccess,
|
||||
RecipientEmail: o.UserEmail,
|
||||
RecipientName: firstNonEmpty(o.UserName, o.UserEmail),
|
||||
UserID: o.UserID,
|
||||
SourceType: "payment_order",
|
||||
SourceID: strconv.FormatInt(o.ID, 10),
|
||||
Variables: variables,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *PaymentService) ExecuteSubscriptionFulfillment(ctx context.Context, oid int64) error {
|
||||
o, err := s.entClient.PaymentOrder.Get(ctx, oid)
|
||||
if err != nil {
|
||||
|
||||
@ -48,6 +48,9 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
|
||||
if user.Status != payment.EntityStatusActive {
|
||||
return nil, infraerrors.Forbidden("USER_INACTIVE", "user account is disabled")
|
||||
}
|
||||
if s.notificationEmailService != nil {
|
||||
s.notificationEmailService.RememberRecipientLocale(ctx, req.UserID, user.Email, req.Locale)
|
||||
}
|
||||
orderAmount := req.Amount
|
||||
limitAmount := req.Amount
|
||||
if plan != nil {
|
||||
|
||||
@ -83,6 +83,7 @@ type CreateOrderRequest struct {
|
||||
PaymentSource string
|
||||
OrderType string
|
||||
PlanID int64
|
||||
Locale string
|
||||
}
|
||||
|
||||
type CreateOrderResponse struct {
|
||||
@ -174,18 +175,19 @@ type TopUserStat struct {
|
||||
// --- Service ---
|
||||
|
||||
type PaymentService struct {
|
||||
providerMu sync.Mutex
|
||||
providersLoaded bool
|
||||
entClient *dbent.Client
|
||||
registry *payment.Registry
|
||||
loadBalancer payment.LoadBalancer
|
||||
redeemService *RedeemService
|
||||
subscriptionSvc *SubscriptionService
|
||||
configService *PaymentConfigService
|
||||
userRepo UserRepository
|
||||
groupRepo GroupRepository
|
||||
resumeService *PaymentResumeService
|
||||
affiliateService *AffiliateService
|
||||
providerMu sync.Mutex
|
||||
providersLoaded bool
|
||||
entClient *dbent.Client
|
||||
registry *payment.Registry
|
||||
loadBalancer payment.LoadBalancer
|
||||
redeemService *RedeemService
|
||||
subscriptionSvc *SubscriptionService
|
||||
configService *PaymentConfigService
|
||||
userRepo UserRepository
|
||||
groupRepo GroupRepository
|
||||
resumeService *PaymentResumeService
|
||||
affiliateService *AffiliateService
|
||||
notificationEmailService *NotificationEmailService
|
||||
}
|
||||
|
||||
func NewPaymentService(entClient *dbent.Client, registry *payment.Registry, loadBalancer payment.LoadBalancer, redeemService *RedeemService, subscriptionSvc *SubscriptionService, configService *PaymentConfigService, userRepo UserRepository, groupRepo GroupRepository, affiliateService *AffiliateService) *PaymentService {
|
||||
@ -194,6 +196,10 @@ func NewPaymentService(entClient *dbent.Client, registry *payment.Registry, load
|
||||
return svc
|
||||
}
|
||||
|
||||
func (s *PaymentService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) {
|
||||
s.notificationEmailService = notificationEmailService
|
||||
}
|
||||
|
||||
// --- Provider Registry ---
|
||||
|
||||
// EnsureProviders lazily initializes the provider registry on first call.
|
||||
|
||||
@ -2,18 +2,23 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
)
|
||||
|
||||
// SubscriptionExpiryService periodically updates expired subscription status.
|
||||
type SubscriptionExpiryService struct {
|
||||
userSubRepo UserSubscriptionRepository
|
||||
interval time.Duration
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
userSubRepo UserSubscriptionRepository
|
||||
notificationEmailService *NotificationEmailService
|
||||
interval time.Duration
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewSubscriptionExpiryService(userSubRepo UserSubscriptionRepository, interval time.Duration) *SubscriptionExpiryService {
|
||||
@ -24,6 +29,10 @@ func NewSubscriptionExpiryService(userSubRepo UserSubscriptionRepository, interv
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubscriptionExpiryService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) {
|
||||
s.notificationEmailService = notificationEmailService
|
||||
}
|
||||
|
||||
func (s *SubscriptionExpiryService) Start() {
|
||||
if s == nil || s.userSubRepo == nil || s.interval <= 0 {
|
||||
return
|
||||
@ -68,4 +77,50 @@ func (s *SubscriptionExpiryService) runOnce() {
|
||||
if updated > 0 {
|
||||
log.Printf("[SubscriptionExpiry] Updated %d expired subscriptions", updated)
|
||||
}
|
||||
s.sendExpiryReminders(ctx)
|
||||
}
|
||||
|
||||
func (s *SubscriptionExpiryService) sendExpiryReminders(ctx context.Context) {
|
||||
if s == nil || s.userSubRepo == nil || s.notificationEmailService == nil {
|
||||
return
|
||||
}
|
||||
for page := 1; ; page++ {
|
||||
subs, pag, err := s.userSubRepo.List(ctx, pagination.PaginationParams{Page: page, PageSize: 200}, nil, nil, SubscriptionStatusActive, "", "expires_at", "asc")
|
||||
if err != nil {
|
||||
log.Printf("[SubscriptionExpiry] List active subscriptions for reminder failed: %v", err)
|
||||
return
|
||||
}
|
||||
for i := range subs {
|
||||
s.sendExpiryReminderIfDue(ctx, &subs[i])
|
||||
}
|
||||
if pag == nil || page >= pag.Pages || len(subs) == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubscriptionExpiryService) sendExpiryReminderIfDue(ctx context.Context, sub *UserSubscription) {
|
||||
if sub == nil || sub.User == nil || sub.Group == nil || sub.User.Email == "" {
|
||||
return
|
||||
}
|
||||
daysRemaining := sub.DaysRemaining()
|
||||
if daysRemaining != 7 && daysRemaining != 3 && daysRemaining != 1 {
|
||||
return
|
||||
}
|
||||
if err := s.notificationEmailService.Send(ctx, NotificationEmailSendInput{
|
||||
Event: NotificationEmailEventSubscriptionExpiryReminder,
|
||||
RecipientEmail: sub.User.Email,
|
||||
RecipientName: firstNonEmpty(sub.User.Username, sub.User.Email),
|
||||
UserID: sub.UserID,
|
||||
SourceType: "user_subscription",
|
||||
SourceID: strconv.FormatInt(sub.ID, 10),
|
||||
ReminderKey: fmt.Sprintf("%dd", daysRemaining),
|
||||
Variables: map[string]string{
|
||||
"subscription_group": sub.Group.Name,
|
||||
"expiry_time": sub.ExpiresAt.Format("2006-01-02 15:04"),
|
||||
"days_remaining": strconv.Itoa(daysRemaining),
|
||||
},
|
||||
}); err != nil {
|
||||
log.Printf("[SubscriptionExpiry] Send expiry reminder failed: subscription=%d user=%d err=%v", sub.ID, sub.UserID, err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -517,7 +517,7 @@ func (s *TotpService) GetVerificationMethod(ctx context.Context) *VerificationMe
|
||||
}
|
||||
|
||||
// SendVerifyCode sends an email verification code for TOTP operations
|
||||
func (s *TotpService) SendVerifyCode(ctx context.Context, userID int64) error {
|
||||
func (s *TotpService) SendVerifyCode(ctx context.Context, userID int64, locale ...string) error {
|
||||
// Check if email verification is enabled
|
||||
if !s.settingService.IsEmailVerifyEnabled(ctx) {
|
||||
return infraerrors.BadRequest("EMAIL_VERIFY_NOT_ENABLED", "email verification is not enabled")
|
||||
@ -533,5 +533,5 @@ func (s *TotpService) SendVerifyCode(ctx context.Context, userID int64) error {
|
||||
siteName := s.settingService.GetSiteName(ctx)
|
||||
|
||||
// Send verification code via queue
|
||||
return s.emailQueueService.EnqueueVerifyCode(user.Email, siteName)
|
||||
return s.emailQueueService.EnqueueVerifyCode(user.Email, siteName, firstEmailLocale(locale))
|
||||
}
|
||||
|
||||
@ -1121,7 +1121,7 @@ func (s *UserService) Delete(ctx context.Context, userID int64) error {
|
||||
}
|
||||
|
||||
// SendNotifyEmailCode sends a verification code to the extra notification email.
|
||||
func (s *UserService) SendNotifyEmailCode(ctx context.Context, userID int64, email string, emailService *EmailService, cache EmailCache) error {
|
||||
func (s *UserService) SendNotifyEmailCode(ctx context.Context, userID int64, email string, emailService *EmailService, cache EmailCache, locale ...string) error {
|
||||
if err := checkNotifyCodeRateLimit(ctx, cache, userID, email); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -1133,7 +1133,7 @@ func (s *UserService) SendNotifyEmailCode(ctx context.Context, userID int64, ema
|
||||
|
||||
// Send email first — if SMTP fails, don't write cache or increment counters,
|
||||
// so the user is not locked out by cooldown/rate-limit for a code they never received.
|
||||
if err := s.sendNotifyVerifyEmail(ctx, emailService, email, code); err != nil {
|
||||
if err := s.sendNotifyVerifyEmail(ctx, emailService, userID, email, code, firstEmailLocale(locale)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -1179,13 +1179,33 @@ func saveNotifyVerifyCode(ctx context.Context, cache EmailCache, email, code str
|
||||
}
|
||||
|
||||
// sendNotifyVerifyEmail builds and sends the verification email.
|
||||
func (s *UserService) sendNotifyVerifyEmail(ctx context.Context, emailService *EmailService, email, code string) error {
|
||||
func (s *UserService) sendNotifyVerifyEmail(ctx context.Context, emailService *EmailService, userID int64, email, code, locale string) error {
|
||||
siteName := "Sub2API"
|
||||
if s.settingRepo != nil {
|
||||
if name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName); err == nil && name != "" {
|
||||
siteName = name
|
||||
}
|
||||
}
|
||||
if emailService.notificationEmailService != nil {
|
||||
if err := emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{
|
||||
Event: NotificationEmailEventNotificationEmailVerifyCode,
|
||||
Locale: locale,
|
||||
RecipientEmail: email,
|
||||
RecipientName: emailRecipientName(email),
|
||||
UserID: userID,
|
||||
Variables: map[string]string{
|
||||
"verification_code": code,
|
||||
"expires_in_minutes": strconv.Itoa(int(verifyCodeTTL / time.Minute)),
|
||||
},
|
||||
}); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
if !shouldFallbackNotificationEmail(err) {
|
||||
return err
|
||||
}
|
||||
slog.Warn("template notification email verification failed; falling back to built-in body", "recipient_hash", notificationEmailHash(email), "err", err.Error())
|
||||
}
|
||||
}
|
||||
subject := fmt.Sprintf("[%s] 通知邮箱验证码 / Notification Email Verification", siteName)
|
||||
body := buildNotifyVerifyEmailBody(code, siteName)
|
||||
return emailService.SendEmail(ctx, email, subject, body)
|
||||
|
||||
@ -151,8 +151,9 @@ func ProvideAccountExpiryService(accountRepo AccountRepository) *AccountExpirySe
|
||||
}
|
||||
|
||||
// ProvideSubscriptionExpiryService creates and starts SubscriptionExpiryService.
|
||||
func ProvideSubscriptionExpiryService(userSubRepo UserSubscriptionRepository) *SubscriptionExpiryService {
|
||||
func ProvideSubscriptionExpiryService(userSubRepo UserSubscriptionRepository, notificationEmailService *NotificationEmailService) *SubscriptionExpiryService {
|
||||
svc := NewSubscriptionExpiryService(userSubRepo, time.Minute)
|
||||
svc.SetNotificationEmailService(notificationEmailService)
|
||||
svc.Start()
|
||||
return svc
|
||||
}
|
||||
@ -478,6 +479,7 @@ var ProviderSet = wire.NewSet(
|
||||
ProvideOpsCleanupService,
|
||||
ProvideOpsScheduledReportService,
|
||||
NewEmailService,
|
||||
NewNotificationEmailService,
|
||||
ProvideEmailQueueService,
|
||||
NewTurnstileService,
|
||||
NewSubscriptionService,
|
||||
@ -514,7 +516,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewContentModerationService,
|
||||
NewAffiliateService,
|
||||
ProvidePaymentConfigService,
|
||||
NewPaymentService,
|
||||
ProvidePaymentService,
|
||||
ProvidePaymentOrderExpiryService,
|
||||
ProvideBalanceNotifyService,
|
||||
ProvideChannelMonitorService,
|
||||
@ -529,8 +531,17 @@ func ProvidePaymentConfigService(entClient *dbent.Client, settingRepo SettingRep
|
||||
}
|
||||
|
||||
// ProvideBalanceNotifyService creates BalanceNotifyService
|
||||
func ProvideBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository, accountRepo AccountRepository) *BalanceNotifyService {
|
||||
return NewBalanceNotifyService(emailService, settingRepo, accountRepo)
|
||||
func ProvideBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository, accountRepo AccountRepository, notificationEmailService *NotificationEmailService) *BalanceNotifyService {
|
||||
svc := NewBalanceNotifyService(emailService, settingRepo, accountRepo)
|
||||
svc.SetNotificationEmailService(notificationEmailService)
|
||||
return svc
|
||||
}
|
||||
|
||||
// ProvidePaymentService creates PaymentService and attaches notification email delivery.
|
||||
func ProvidePaymentService(entClient *dbent.Client, registry *payment.Registry, loadBalancer payment.LoadBalancer, redeemService *RedeemService, subscriptionSvc *SubscriptionService, configService *PaymentConfigService, userRepo UserRepository, groupRepo GroupRepository, affiliateService *AffiliateService, notificationEmailService *NotificationEmailService) *PaymentService {
|
||||
svc := NewPaymentService(entClient, registry, loadBalancer, redeemService, subscriptionSvc, configService, userRepo, groupRepo, affiliateService)
|
||||
svc.SetNotificationEmailService(notificationEmailService)
|
||||
return svc
|
||||
}
|
||||
|
||||
// ProvidePaymentOrderExpiryService creates and starts PaymentOrderExpiryService.
|
||||
|
||||
@ -5,6 +5,45 @@
|
||||
import { config } from '@vue/test-utils'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
|
||||
return {
|
||||
get length() {
|
||||
return values.size
|
||||
},
|
||||
clear() {
|
||||
values.clear()
|
||||
},
|
||||
getItem(key: string) {
|
||||
return values.has(key) ? values.get(key)! : null
|
||||
},
|
||||
key(index: number) {
|
||||
return Array.from(values.keys())[index] ?? null
|
||||
},
|
||||
removeItem(key: string) {
|
||||
values.delete(key)
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
values.set(key, String(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof globalThis.localStorage === 'undefined' || typeof globalThis.localStorage.getItem !== 'function') {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: createMemoryStorage()
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && typeof window.localStorage.getItem !== 'function') {
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
value: globalThis.localStorage
|
||||
})
|
||||
}
|
||||
|
||||
// Mock requestIdleCallback (Safari < 15 不支持)
|
||||
if (typeof globalThis.requestIdleCallback === 'undefined') {
|
||||
globalThis.requestIdleCallback = ((callback: IdleRequestCallback) => {
|
||||
|
||||
@ -854,6 +854,105 @@ export async function sendTestEmail(
|
||||
return data;
|
||||
}
|
||||
|
||||
// ==================== Email Template Settings ====================
|
||||
|
||||
export interface EmailTemplateOption {
|
||||
value: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type EmailTemplateEventOption = string | EmailTemplateOption;
|
||||
|
||||
export interface EmailTemplateSummary {
|
||||
event: string;
|
||||
locale: string;
|
||||
subject: string;
|
||||
is_custom?: boolean;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface EmailTemplateListResponse {
|
||||
events: EmailTemplateEventOption[];
|
||||
locales: string[];
|
||||
templates?: EmailTemplateSummary[];
|
||||
placeholders?: string[];
|
||||
}
|
||||
|
||||
export interface EmailTemplateDetail {
|
||||
event: string;
|
||||
locale: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
is_custom?: boolean;
|
||||
updated_at?: string;
|
||||
placeholders?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateEmailTemplateRequest {
|
||||
subject: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export interface PreviewEmailTemplateRequest extends UpdateEmailTemplateRequest {
|
||||
event: string;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export interface EmailTemplatePreviewResponse {
|
||||
subject: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export async function getEmailTemplates(): Promise<EmailTemplateListResponse> {
|
||||
const { data } = await apiClient.get<EmailTemplateListResponse>(
|
||||
"/admin/settings/email-templates",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getEmailTemplate(
|
||||
event: string,
|
||||
locale: string,
|
||||
): Promise<EmailTemplateDetail> {
|
||||
const { data } = await apiClient.get<EmailTemplateDetail>(
|
||||
`/admin/settings/email-templates/${encodeURIComponent(event)}/${encodeURIComponent(locale)}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateEmailTemplate(
|
||||
event: string,
|
||||
locale: string,
|
||||
request: UpdateEmailTemplateRequest,
|
||||
): Promise<EmailTemplateDetail> {
|
||||
const { data } = await apiClient.put<EmailTemplateDetail>(
|
||||
`/admin/settings/email-templates/${encodeURIComponent(event)}/${encodeURIComponent(locale)}`,
|
||||
request,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function restoreOfficialEmailTemplate(
|
||||
event: string,
|
||||
locale: string,
|
||||
): Promise<EmailTemplateDetail> {
|
||||
const { data } = await apiClient.post<EmailTemplateDetail>(
|
||||
`/admin/settings/email-templates/${encodeURIComponent(event)}/${encodeURIComponent(locale)}/restore-official`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function previewEmailTemplate(
|
||||
request: PreviewEmailTemplateRequest,
|
||||
): Promise<EmailTemplatePreviewResponse> {
|
||||
const { data } = await apiClient.post<EmailTemplatePreviewResponse>(
|
||||
"/admin/settings/email-template-preview",
|
||||
request,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin API Key status response
|
||||
*/
|
||||
@ -1160,6 +1259,11 @@ export const settingsAPI = {
|
||||
updateSettings,
|
||||
testSmtpConnection,
|
||||
sendTestEmail,
|
||||
getEmailTemplates,
|
||||
getEmailTemplate,
|
||||
updateEmailTemplate,
|
||||
restoreOfficialEmailTemplate,
|
||||
previewEmailTemplate,
|
||||
getAdminApiKey,
|
||||
regenerateAdminApiKey,
|
||||
deleteAdminApiKey,
|
||||
|
||||
@ -5796,6 +5796,36 @@ export default {
|
||||
sending: 'Sending...',
|
||||
enterRecipientHint: 'Please enter a recipient email address'
|
||||
},
|
||||
emailTemplates: {
|
||||
title: 'Email Templates',
|
||||
description: 'Customize notification email subjects and HTML content for each event and locale.',
|
||||
event: 'Event',
|
||||
locale: 'Locale',
|
||||
localeEn: 'English',
|
||||
localeZh: 'Chinese',
|
||||
subject: 'Subject',
|
||||
subjectPlaceholder: 'Enter the email subject',
|
||||
html: 'HTML Template',
|
||||
htmlPlaceholder: 'Edit the email HTML template',
|
||||
placeholders: 'Available Placeholders',
|
||||
placeholdersHelp: 'Click a placeholder to copy it. The backend replaces these values when sending emails.',
|
||||
livePreview: 'Live Preview',
|
||||
previewSecurityHint: 'Preview HTML is generated by the backend preview endpoint and displayed in a sandboxed iframe with scripts disabled.',
|
||||
preview: 'Preview / Refresh',
|
||||
previewing: 'Previewing...',
|
||||
save: 'Save Template',
|
||||
saving: 'Saving...',
|
||||
restoreOfficial: 'Restore Official',
|
||||
restoring: 'Restoring...',
|
||||
restoreConfirm: 'Restore the official template for this event and locale? Your custom version will be replaced.',
|
||||
restoreSuccess: 'Official template restored',
|
||||
saveSuccess: 'Email template saved',
|
||||
placeholderCopied: 'Placeholder copied',
|
||||
validationRequired: 'Subject and HTML template are required',
|
||||
empty: 'No email template events or locales are available yet.',
|
||||
noPreview: 'Refresh the preview to see the rendered email subject.',
|
||||
customized: 'Customized'
|
||||
},
|
||||
opsMonitoring: {
|
||||
title: 'Ops Monitoring',
|
||||
description: 'Enable ops monitoring for troubleshooting and health visibility',
|
||||
|
||||
@ -5956,6 +5956,36 @@ export default {
|
||||
sending: '发送中...',
|
||||
enterRecipientHint: '请输入收件人邮箱地址'
|
||||
},
|
||||
emailTemplates: {
|
||||
title: '邮件模板',
|
||||
description: '按事件和语言自定义通知邮件主题与 HTML 内容。',
|
||||
event: '事件',
|
||||
locale: '语言',
|
||||
localeEn: '英文',
|
||||
localeZh: '中文',
|
||||
subject: '主题',
|
||||
subjectPlaceholder: '输入邮件主题',
|
||||
html: 'HTML 模板',
|
||||
htmlPlaceholder: '编辑邮件 HTML 模板',
|
||||
placeholders: '可用占位符',
|
||||
placeholdersHelp: '点击占位符可复制。后端发送邮件时会替换这些值。',
|
||||
livePreview: '实时预览',
|
||||
previewSecurityHint: '预览 HTML 由后端预览接口生成,并在禁用脚本的沙盒 iframe 中展示。',
|
||||
preview: '预览 / 刷新',
|
||||
previewing: '预览中...',
|
||||
save: '保存模板',
|
||||
saving: '保存中...',
|
||||
restoreOfficial: '恢复官方模板',
|
||||
restoring: '恢复中...',
|
||||
restoreConfirm: '确定恢复此事件和语言的官方模板吗?当前自定义版本将被替换。',
|
||||
restoreSuccess: '已恢复官方模板',
|
||||
saveSuccess: '邮件模板已保存',
|
||||
placeholderCopied: '占位符已复制',
|
||||
validationRequired: '主题和 HTML 模板不能为空',
|
||||
empty: '暂无可用的邮件模板事件或语言。',
|
||||
noPreview: '刷新预览后查看渲染后的邮件主题。',
|
||||
customized: '已自定义'
|
||||
},
|
||||
opsMonitoring: {
|
||||
title: '运维监控',
|
||||
description: '启用运维监控模块,用于排障与健康可视化',
|
||||
|
||||
@ -6255,6 +6255,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmailTemplateEditor />
|
||||
|
||||
<!-- Balance Low Notification -->
|
||||
<div class="card">
|
||||
<div
|
||||
@ -6512,6 +6515,7 @@ import Toggle from "@/components/common/Toggle.vue";
|
||||
import ProxySelector from "@/components/common/ProxySelector.vue";
|
||||
import ImageUpload from "@/components/common/ImageUpload.vue";
|
||||
import BackupSettings from "@/views/admin/BackupView.vue";
|
||||
import EmailTemplateEditor from "@/views/admin/settings/EmailTemplateEditor.vue";
|
||||
import { useClipboard } from "@/composables/useClipboard";
|
||||
import { affiliatesAPI, type AffiliateAdminEntry, type SimpleUser as AffiliateSimpleUser } from "@/api/admin/affiliates";
|
||||
import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError";
|
||||
|
||||
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: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/__tests__/setup.ts'],
|
||||
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
coverage: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user