Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1314 lines
51 KiB
Go
1314 lines
51 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"html"
|
||
"log/slog"
|
||
"net/url"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
NotificationEmailEventAuthVerifyCode = "auth.verify_code"
|
||
NotificationEmailEventAuthPasswordReset = "auth.password_reset"
|
||
NotificationEmailEventNotificationEmailVerifyCode = "notification_email.verify_code"
|
||
NotificationEmailEventSubscriptionPurchaseSuccess = "subscription.purchase_success"
|
||
NotificationEmailEventSubscriptionExpiryReminder = "subscription.expiry_reminder"
|
||
NotificationEmailEventBalanceLow = "balance.low"
|
||
NotificationEmailEventBalanceRechargeSuccess = "balance.recharge_success"
|
||
NotificationEmailEventAccountQuotaAlert = "account.quota_alert"
|
||
NotificationEmailEventContentModerationViolation = "content_moderation.violation_notice"
|
||
NotificationEmailEventContentModerationDisabled = "content_moderation.account_disabled"
|
||
NotificationEmailEventOpsAlert = "ops.alert"
|
||
NotificationEmailEventOpsScheduledReport = "ops.scheduled_report"
|
||
|
||
notificationEmailTemplateKeyPrefix = "notification_email_template:"
|
||
notificationEmailPreferenceKeyPrefix = "notification_email_preference:"
|
||
notificationEmailDeliveryKeyPrefix = "notification_email_delivery:"
|
||
notificationEmailLocaleUserKeyPrefix = "notification_email_locale:user:"
|
||
notificationEmailLocaleEmailKeyPrefix = "notification_email_locale:email:"
|
||
notificationEmailUnsubscribeSecretKey = "notification_email_unsubscribe_secret"
|
||
notificationEmailDefaultLocale = "en"
|
||
notificationEmailLocaleChinese = "zh"
|
||
notificationEmailMaxSubjectLength = 200
|
||
notificationEmailMaxHTMLLength = 30000
|
||
notificationEmailUnsubscribeTTL = 365 * 24 * time.Hour
|
||
)
|
||
|
||
var (
|
||
notificationEmailPlaceholderPattern = regexp.MustCompile(`{{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*}}`)
|
||
notificationEmailLocales = []string{notificationEmailDefaultLocale, notificationEmailLocaleChinese}
|
||
notificationEmailCommonPlaceholders = []string{"site_name", "recipient_name", "recipient_email"}
|
||
)
|
||
|
||
type NotificationEmailService struct {
|
||
settingRepo SettingRepository
|
||
emailService *EmailService
|
||
}
|
||
|
||
type NotificationEmailEventInfo struct {
|
||
Event string `json:"event"`
|
||
Label string `json:"label"`
|
||
Description string `json:"description"`
|
||
Category string `json:"category"`
|
||
Optional bool `json:"optional"`
|
||
Placeholders []string `json:"placeholders"`
|
||
}
|
||
|
||
type NotificationEmailTemplate struct {
|
||
Event string `json:"event"`
|
||
Locale string `json:"locale"`
|
||
Subject string `json:"subject"`
|
||
HTML string `json:"html"`
|
||
IsCustom bool `json:"is_custom"`
|
||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||
Placeholders []string `json:"placeholders"`
|
||
}
|
||
|
||
type NotificationEmailPreview struct {
|
||
Subject string `json:"subject"`
|
||
HTML string `json:"html"`
|
||
}
|
||
|
||
type NotificationEmailPreviewInput struct {
|
||
Event string `json:"event"`
|
||
Locale string `json:"locale"`
|
||
Subject string `json:"subject"`
|
||
HTML string `json:"html"`
|
||
Variables map[string]string `json:"variables,omitempty"`
|
||
}
|
||
|
||
type NotificationEmailSendInput struct {
|
||
Event string
|
||
Locale string
|
||
RecipientEmail string
|
||
RecipientName string
|
||
UserID int64
|
||
SourceType string
|
||
SourceID string
|
||
ReminderKey string
|
||
Variables map[string]string
|
||
RawHTMLVariables map[string]string
|
||
}
|
||
|
||
type NotificationEmailUnsubscribeResult struct {
|
||
Event string `json:"event"`
|
||
Email string `json:"email"`
|
||
Done bool `json:"done"`
|
||
}
|
||
|
||
type notificationEmailStoredTemplate struct {
|
||
Subject string `json:"subject"`
|
||
HTML string `json:"html"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
type notificationEmailOfficialTemplate struct {
|
||
Subject string
|
||
HTML string
|
||
}
|
||
|
||
type notificationEmailTemplateError struct {
|
||
Err error
|
||
}
|
||
|
||
func (e notificationEmailTemplateError) Error() string {
|
||
return e.Err.Error()
|
||
}
|
||
|
||
func (e notificationEmailTemplateError) Unwrap() error {
|
||
return e.Err
|
||
}
|
||
|
||
type notificationEmailConfigError struct {
|
||
Err error
|
||
}
|
||
|
||
func (e notificationEmailConfigError) Error() string {
|
||
return e.Err.Error()
|
||
}
|
||
|
||
func (e notificationEmailConfigError) Unwrap() error {
|
||
return e.Err
|
||
}
|
||
|
||
type notificationEmailDeliveryError struct {
|
||
Err error
|
||
}
|
||
|
||
func (e notificationEmailDeliveryError) Error() string {
|
||
return e.Err.Error()
|
||
}
|
||
|
||
func (e notificationEmailDeliveryError) Unwrap() error {
|
||
return e.Err
|
||
}
|
||
|
||
type notificationEmailUnsubscribeClaims struct {
|
||
Email string `json:"email"`
|
||
Event string `json:"event"`
|
||
Exp int64 `json:"exp"`
|
||
}
|
||
|
||
func NewNotificationEmailService(settingRepo SettingRepository, emailService *EmailService) *NotificationEmailService {
|
||
svc := &NotificationEmailService{settingRepo: settingRepo, emailService: emailService}
|
||
if emailService != nil {
|
||
emailService.SetNotificationEmailService(svc)
|
||
}
|
||
return svc
|
||
}
|
||
|
||
func notificationEmailTemplateErr(err error) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
return notificationEmailTemplateError{Err: err}
|
||
}
|
||
|
||
func notificationEmailConfigErr(err error) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
return notificationEmailConfigError{Err: err}
|
||
}
|
||
|
||
func notificationEmailDeliveryErr(err error) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
return notificationEmailDeliveryError{Err: err}
|
||
}
|
||
|
||
func shouldFallbackNotificationEmail(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
var templateErr notificationEmailTemplateError
|
||
if errors.As(err, &templateErr) {
|
||
return true
|
||
}
|
||
var configErr notificationEmailConfigError
|
||
return errors.As(err, &configErr)
|
||
}
|
||
|
||
func isNotificationEmailDeliveryError(err error) bool {
|
||
var deliveryErr notificationEmailDeliveryError
|
||
return errors.As(err, &deliveryErr)
|
||
}
|
||
|
||
func (s *NotificationEmailService) ListEventInfos() []NotificationEmailEventInfo {
|
||
infos := make([]NotificationEmailEventInfo, 0, len(notificationEmailEventDefinitions))
|
||
for _, event := range notificationEmailEventOrder {
|
||
info := notificationEmailEventDefinitions[event]
|
||
info.Placeholders = append([]string(nil), info.Placeholders...)
|
||
infos = append(infos, info)
|
||
}
|
||
return infos
|
||
}
|
||
|
||
func (s *NotificationEmailService) SupportedLocales() []string {
|
||
return append([]string(nil), notificationEmailLocales...)
|
||
}
|
||
|
||
func (s *NotificationEmailService) ListTemplates(ctx context.Context) ([]NotificationEmailTemplate, error) {
|
||
items := make([]NotificationEmailTemplate, 0, len(notificationEmailEventOrder)*len(notificationEmailLocales))
|
||
for _, event := range notificationEmailEventOrder {
|
||
for _, locale := range notificationEmailLocales {
|
||
tmpl, err := s.GetTemplate(ctx, event, locale)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
items = append(items, tmpl)
|
||
}
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func (s *NotificationEmailService) GetTemplate(ctx context.Context, event, locale string) (NotificationEmailTemplate, error) {
|
||
info, normalizedEvent, err := s.eventInfo(event)
|
||
if err != nil {
|
||
return NotificationEmailTemplate{}, err
|
||
}
|
||
normalizedLocale := normalizeNotificationLocale(locale)
|
||
official, ok := notificationEmailOfficialTemplates[normalizedEvent][normalizedLocale]
|
||
if !ok {
|
||
return NotificationEmailTemplate{}, fmt.Errorf("official template not found for %s/%s", normalizedEvent, normalizedLocale)
|
||
}
|
||
|
||
tmpl := NotificationEmailTemplate{
|
||
Event: normalizedEvent,
|
||
Locale: normalizedLocale,
|
||
Subject: official.Subject,
|
||
HTML: official.HTML,
|
||
Placeholders: append([]string(nil), info.Placeholders...),
|
||
}
|
||
|
||
raw, err := s.settingRepo.GetValue(ctx, notificationEmailTemplateKey(normalizedEvent, normalizedLocale))
|
||
if err != nil {
|
||
if errors.Is(err, ErrSettingNotFound) {
|
||
return tmpl, nil
|
||
}
|
||
return NotificationEmailTemplate{}, err
|
||
}
|
||
if strings.TrimSpace(raw) == "" {
|
||
return tmpl, nil
|
||
}
|
||
|
||
var stored notificationEmailStoredTemplate
|
||
if err := json.Unmarshal([]byte(raw), &stored); err != nil {
|
||
return NotificationEmailTemplate{}, fmt.Errorf("decode email template override: %w", err)
|
||
}
|
||
if err := validateNotificationEmailTemplate(normalizedEvent, stored.Subject, stored.HTML); err != nil {
|
||
return NotificationEmailTemplate{}, err
|
||
}
|
||
tmpl.Subject = stored.Subject
|
||
tmpl.HTML = stored.HTML
|
||
tmpl.IsCustom = true
|
||
updatedAt := stored.UpdatedAt
|
||
tmpl.UpdatedAt = &updatedAt
|
||
return tmpl, nil
|
||
}
|
||
|
||
func (s *NotificationEmailService) UpdateTemplate(ctx context.Context, event, locale, subject, htmlBody string) (NotificationEmailTemplate, error) {
|
||
_, normalizedEvent, err := s.eventInfo(event)
|
||
if err != nil {
|
||
return NotificationEmailTemplate{}, err
|
||
}
|
||
normalizedLocale := normalizeNotificationLocale(locale)
|
||
if err := validateNotificationEmailTemplate(normalizedEvent, subject, htmlBody); err != nil {
|
||
return NotificationEmailTemplate{}, err
|
||
}
|
||
stored := notificationEmailStoredTemplate{
|
||
Subject: strings.TrimSpace(subject),
|
||
HTML: htmlBody,
|
||
UpdatedAt: time.Now().UTC(),
|
||
}
|
||
payload, err := json.Marshal(stored)
|
||
if err != nil {
|
||
return NotificationEmailTemplate{}, err
|
||
}
|
||
if err := s.settingRepo.Set(ctx, notificationEmailTemplateKey(normalizedEvent, normalizedLocale), string(payload)); err != nil {
|
||
return NotificationEmailTemplate{}, err
|
||
}
|
||
return s.GetTemplate(ctx, normalizedEvent, normalizedLocale)
|
||
}
|
||
|
||
func (s *NotificationEmailService) RestoreOfficialTemplate(ctx context.Context, event, locale string) (NotificationEmailTemplate, error) {
|
||
_, normalizedEvent, err := s.eventInfo(event)
|
||
if err != nil {
|
||
return NotificationEmailTemplate{}, err
|
||
}
|
||
normalizedLocale := normalizeNotificationLocale(locale)
|
||
if err := s.settingRepo.Delete(ctx, notificationEmailTemplateKey(normalizedEvent, normalizedLocale)); err != nil && !errors.Is(err, ErrSettingNotFound) {
|
||
return NotificationEmailTemplate{}, err
|
||
}
|
||
return s.GetTemplate(ctx, normalizedEvent, normalizedLocale)
|
||
}
|
||
|
||
func (s *NotificationEmailService) PreviewTemplate(ctx context.Context, input NotificationEmailPreviewInput) (NotificationEmailPreview, error) {
|
||
_, normalizedEvent, err := s.eventInfo(input.Event)
|
||
if err != nil {
|
||
return NotificationEmailPreview{}, err
|
||
}
|
||
normalizedLocale := normalizeNotificationLocale(input.Locale)
|
||
subject := input.Subject
|
||
htmlBody := input.HTML
|
||
if strings.TrimSpace(subject) == "" || strings.TrimSpace(htmlBody) == "" {
|
||
tmpl, err := s.GetTemplate(ctx, normalizedEvent, normalizedLocale)
|
||
if err != nil {
|
||
return NotificationEmailPreview{}, err
|
||
}
|
||
if strings.TrimSpace(subject) == "" {
|
||
subject = tmpl.Subject
|
||
}
|
||
if strings.TrimSpace(htmlBody) == "" {
|
||
htmlBody = tmpl.HTML
|
||
}
|
||
}
|
||
if err := validateNotificationEmailTemplate(normalizedEvent, subject, htmlBody); err != nil {
|
||
return NotificationEmailPreview{}, err
|
||
}
|
||
variables := s.sampleVariables(ctx, normalizedEvent, normalizedLocale)
|
||
for key, value := range input.Variables {
|
||
variables[key] = value
|
||
}
|
||
return renderNotificationEmail(normalizedEvent, subject, htmlBody, variables, nil)
|
||
}
|
||
|
||
func (s *NotificationEmailService) Send(ctx context.Context, input NotificationEmailSendInput) error {
|
||
info, normalizedEvent, err := s.eventInfo(input.Event)
|
||
if err != nil {
|
||
return notificationEmailTemplateErr(err)
|
||
}
|
||
recipient := strings.TrimSpace(input.RecipientEmail)
|
||
if recipient == "" {
|
||
return nil
|
||
}
|
||
if info.Optional {
|
||
unsubscribed, err := s.IsUnsubscribed(ctx, recipient, normalizedEvent)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if unsubscribed {
|
||
slog.Info("notification email suppressed by unsubscribe preference", "event", normalizedEvent, "recipient_hash", notificationEmailHash(recipient))
|
||
return nil
|
||
}
|
||
}
|
||
|
||
locale := normalizeNotificationLocale(input.Locale)
|
||
if strings.TrimSpace(input.Locale) == "" {
|
||
locale = s.ResolveRecipientLocale(ctx, input.UserID, recipient)
|
||
}
|
||
tmpl, err := s.GetTemplate(ctx, normalizedEvent, locale)
|
||
if err != nil {
|
||
return notificationEmailTemplateErr(err)
|
||
}
|
||
variables := s.runtimeVariables(ctx, normalizedEvent, locale, input)
|
||
rendered, err := renderNotificationEmail(normalizedEvent, tmpl.Subject, tmpl.HTML, variables, input.RawHTMLVariables)
|
||
if err != nil {
|
||
return notificationEmailTemplateErr(err)
|
||
}
|
||
|
||
deliveryKey := notificationEmailDeliveryKey(normalizedEvent, input.SourceType, input.SourceID, recipient, input.ReminderKey)
|
||
if deliveryKey != "" {
|
||
sent, err := s.deliveryExists(ctx, deliveryKey)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if sent {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
if s.emailService == nil {
|
||
return notificationEmailConfigErr(errors.New("email service is not configured"))
|
||
}
|
||
if err := s.emailService.SendEmail(ctx, recipient, rendered.Subject, rendered.HTML); err != nil {
|
||
return notificationEmailDeliveryErr(err)
|
||
}
|
||
if deliveryKey != "" {
|
||
_ = s.settingRepo.Set(ctx, deliveryKey, time.Now().UTC().Format(time.RFC3339Nano))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *NotificationEmailService) RememberRecipientLocale(ctx context.Context, userID int64, email, acceptLanguage string) {
|
||
locale := normalizeNotificationLocale(acceptLanguage)
|
||
if strings.TrimSpace(acceptLanguage) == "" || s == nil || s.settingRepo == nil {
|
||
return
|
||
}
|
||
if userID > 0 {
|
||
_ = s.settingRepo.Set(ctx, notificationEmailLocaleUserKeyPrefix+strconv.FormatInt(userID, 10), locale)
|
||
}
|
||
if emailHash := notificationEmailHash(email); emailHash != "" {
|
||
_ = s.settingRepo.Set(ctx, notificationEmailLocaleEmailKeyPrefix+emailHash, locale)
|
||
}
|
||
}
|
||
|
||
func (s *NotificationEmailService) ResolveRecipientLocale(ctx context.Context, userID int64, email string) string {
|
||
if s == nil || s.settingRepo == nil {
|
||
return notificationEmailDefaultLocale
|
||
}
|
||
if userID > 0 {
|
||
if locale, err := s.settingRepo.GetValue(ctx, notificationEmailLocaleUserKeyPrefix+strconv.FormatInt(userID, 10)); err == nil && strings.TrimSpace(locale) != "" {
|
||
return normalizeNotificationLocale(locale)
|
||
}
|
||
}
|
||
if emailHash := notificationEmailHash(email); emailHash != "" {
|
||
if locale, err := s.settingRepo.GetValue(ctx, notificationEmailLocaleEmailKeyPrefix+emailHash); err == nil && strings.TrimSpace(locale) != "" {
|
||
return normalizeNotificationLocale(locale)
|
||
}
|
||
}
|
||
return notificationEmailDefaultLocale
|
||
}
|
||
|
||
func (s *NotificationEmailService) IsUnsubscribed(ctx context.Context, email, event string) (bool, error) {
|
||
info, normalizedEvent, err := s.eventInfo(event)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if !info.Optional {
|
||
return false, nil
|
||
}
|
||
value, err := s.settingRepo.GetValue(ctx, notificationEmailPreferenceKey(normalizedEvent, email))
|
||
if err != nil {
|
||
if errors.Is(err, ErrSettingNotFound) {
|
||
return false, nil
|
||
}
|
||
return false, err
|
||
}
|
||
return strings.EqualFold(strings.TrimSpace(value), "unsubscribed"), nil
|
||
}
|
||
|
||
func (s *NotificationEmailService) Unsubscribe(ctx context.Context, token string) (NotificationEmailUnsubscribeResult, error) {
|
||
claims, err := s.parseUnsubscribeToken(ctx, token)
|
||
if err != nil {
|
||
return NotificationEmailUnsubscribeResult{}, err
|
||
}
|
||
info, normalizedEvent, err := s.eventInfo(claims.Event)
|
||
if err != nil {
|
||
return NotificationEmailUnsubscribeResult{}, err
|
||
}
|
||
if !info.Optional {
|
||
return NotificationEmailUnsubscribeResult{}, fmt.Errorf("%s is transactional and cannot be unsubscribed", normalizedEvent)
|
||
}
|
||
if err := s.settingRepo.Set(ctx, notificationEmailPreferenceKey(normalizedEvent, claims.Email), "unsubscribed"); err != nil {
|
||
return NotificationEmailUnsubscribeResult{}, err
|
||
}
|
||
return NotificationEmailUnsubscribeResult{Event: normalizedEvent, Email: claims.Email, Done: true}, nil
|
||
}
|
||
|
||
func (s *NotificationEmailService) eventInfo(event string) (NotificationEmailEventInfo, string, error) {
|
||
normalized := strings.ToLower(strings.TrimSpace(event))
|
||
info, ok := notificationEmailEventDefinitions[normalized]
|
||
if !ok {
|
||
return NotificationEmailEventInfo{}, "", fmt.Errorf("unsupported email template event: %s", event)
|
||
}
|
||
return info, normalized, nil
|
||
}
|
||
|
||
func (s *NotificationEmailService) sampleVariables(ctx context.Context, event, locale string) map[string]string {
|
||
info := notificationEmailEventDefinitions[event]
|
||
variables := make(map[string]string, len(info.Placeholders))
|
||
for key, value := range notificationEmailSampleVariables(locale) {
|
||
variables[key] = value
|
||
}
|
||
variables["site_name"] = s.siteName(ctx)
|
||
if variables["unsubscribe_url"] == "" && info.Optional {
|
||
variables["unsubscribe_url"] = "https://example.com/unsubscribe"
|
||
}
|
||
return variables
|
||
}
|
||
|
||
func (s *NotificationEmailService) runtimeVariables(ctx context.Context, event, locale string, input NotificationEmailSendInput) map[string]string {
|
||
variables := s.sampleVariables(ctx, event, locale)
|
||
for key, value := range input.Variables {
|
||
variables[key] = value
|
||
}
|
||
variables["site_name"] = s.siteName(ctx)
|
||
variables["recipient_email"] = input.RecipientEmail
|
||
if strings.TrimSpace(input.RecipientName) != "" {
|
||
variables["recipient_name"] = input.RecipientName
|
||
}
|
||
if notificationEmailEventDefinitions[event].Optional {
|
||
if unsubscribeURL, err := s.buildUnsubscribeURL(ctx, input.RecipientEmail, event); err == nil {
|
||
variables["unsubscribe_url"] = unsubscribeURL
|
||
}
|
||
}
|
||
return variables
|
||
}
|
||
|
||
func (s *NotificationEmailService) siteName(ctx context.Context) string {
|
||
if s == nil || s.settingRepo == nil {
|
||
return defaultSiteName
|
||
}
|
||
name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName)
|
||
if err != nil || strings.TrimSpace(name) == "" {
|
||
return defaultSiteName
|
||
}
|
||
return strings.TrimSpace(name)
|
||
}
|
||
|
||
func (s *NotificationEmailService) baseURL(ctx context.Context) string {
|
||
if s == nil || s.settingRepo == nil {
|
||
return ""
|
||
}
|
||
for _, key := range []string{SettingKeyAPIBaseURL, SettingKeyFrontendURL} {
|
||
value, err := s.settingRepo.GetValue(ctx, key)
|
||
if err == nil && strings.TrimSpace(value) != "" {
|
||
return strings.TrimRight(strings.TrimSpace(value), "/")
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (s *NotificationEmailService) buildUnsubscribeURL(ctx context.Context, email, event string) (string, error) {
|
||
token, err := s.createUnsubscribeToken(ctx, email, event)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
path := "/api/v1/settings/email-unsubscribe?token=" + url.QueryEscape(token)
|
||
baseURL := s.baseURL(ctx)
|
||
if baseURL == "" {
|
||
return path, nil
|
||
}
|
||
return baseURL + path, nil
|
||
}
|
||
|
||
func (s *NotificationEmailService) createUnsubscribeToken(ctx context.Context, email, event string) (string, error) {
|
||
secret, err := s.unsubscribeSecret(ctx)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
claims := notificationEmailUnsubscribeClaims{Email: strings.TrimSpace(email), Event: event, Exp: time.Now().Add(notificationEmailUnsubscribeTTL).Unix()}
|
||
payload, err := json.Marshal(claims)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
|
||
signature := signNotificationEmailToken(secret, encodedPayload)
|
||
return encodedPayload + "." + signature, nil
|
||
}
|
||
|
||
func (s *NotificationEmailService) parseUnsubscribeToken(ctx context.Context, token string) (notificationEmailUnsubscribeClaims, error) {
|
||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||
return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token")
|
||
}
|
||
secret, err := s.unsubscribeSecret(ctx)
|
||
if err != nil {
|
||
return notificationEmailUnsubscribeClaims{}, err
|
||
}
|
||
expected := signNotificationEmailToken(secret, parts[0])
|
||
if !hmac.Equal([]byte(expected), []byte(parts[1])) {
|
||
return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token signature")
|
||
}
|
||
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||
if err != nil {
|
||
return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token payload")
|
||
}
|
||
var claims notificationEmailUnsubscribeClaims
|
||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||
return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token payload")
|
||
}
|
||
if strings.TrimSpace(claims.Email) == "" || strings.TrimSpace(claims.Event) == "" {
|
||
return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token claims")
|
||
}
|
||
if claims.Exp <= time.Now().Unix() {
|
||
return notificationEmailUnsubscribeClaims{}, errors.New("unsubscribe token expired")
|
||
}
|
||
return claims, nil
|
||
}
|
||
|
||
func (s *NotificationEmailService) unsubscribeSecret(ctx context.Context) (string, error) {
|
||
secret, err := s.settingRepo.GetValue(ctx, notificationEmailUnsubscribeSecretKey)
|
||
if err == nil && strings.TrimSpace(secret) != "" {
|
||
return strings.TrimSpace(secret), nil
|
||
}
|
||
if err != nil && !errors.Is(err, ErrSettingNotFound) {
|
||
return "", err
|
||
}
|
||
buf := make([]byte, 32)
|
||
if _, err := rand.Read(buf); err != nil {
|
||
return "", err
|
||
}
|
||
secret = base64.RawURLEncoding.EncodeToString(buf)
|
||
if err := s.settingRepo.Set(ctx, notificationEmailUnsubscribeSecretKey, secret); err != nil {
|
||
return "", err
|
||
}
|
||
return secret, nil
|
||
}
|
||
|
||
func (s *NotificationEmailService) deliveryExists(ctx context.Context, key string) (bool, error) {
|
||
_, err := s.settingRepo.GetValue(ctx, key)
|
||
if err == nil {
|
||
return true, nil
|
||
}
|
||
if errors.Is(err, ErrSettingNotFound) {
|
||
return false, nil
|
||
}
|
||
return false, err
|
||
}
|
||
|
||
func validateNotificationEmailTemplate(event, subject, htmlBody string) error {
|
||
subject = strings.TrimSpace(subject)
|
||
if subject == "" {
|
||
return errors.New("email subject cannot be empty")
|
||
}
|
||
if len([]rune(subject)) > notificationEmailMaxSubjectLength {
|
||
return fmt.Errorf("email subject cannot exceed %d characters", notificationEmailMaxSubjectLength)
|
||
}
|
||
if strings.TrimSpace(htmlBody) == "" {
|
||
return errors.New("email html cannot be empty")
|
||
}
|
||
if len([]byte(htmlBody)) > notificationEmailMaxHTMLLength {
|
||
return fmt.Errorf("email html cannot exceed %d bytes", notificationEmailMaxHTMLLength)
|
||
}
|
||
allowed := notificationEmailAllowedPlaceholderSet(event)
|
||
for _, placeholder := range notificationEmailPlaceholdersIn(subject + "\n" + htmlBody) {
|
||
if _, ok := allowed[placeholder]; !ok {
|
||
return fmt.Errorf("unsupported placeholder {{%s}} for event %s", placeholder, event)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func renderNotificationEmail(event, subject, htmlBody string, variables map[string]string, rawHTMLVariables map[string]string) (NotificationEmailPreview, error) {
|
||
if err := validateNotificationEmailTemplate(event, subject, htmlBody); err != nil {
|
||
return NotificationEmailPreview{}, err
|
||
}
|
||
renderedSubject, err := renderNotificationEmailString(event, subject, variables, nil, false)
|
||
if err != nil {
|
||
return NotificationEmailPreview{}, err
|
||
}
|
||
renderedHTML, err := renderNotificationEmailString(event, htmlBody, variables, rawHTMLVariables, true)
|
||
if err != nil {
|
||
return NotificationEmailPreview{}, err
|
||
}
|
||
return NotificationEmailPreview{Subject: sanitizeEmailHeader(renderedSubject), HTML: renderedHTML}, nil
|
||
}
|
||
|
||
func renderNotificationEmailString(event, raw string, variables map[string]string, rawHTMLVariables map[string]string, escapeHTML bool) (string, error) {
|
||
allowed := notificationEmailAllowedPlaceholderSet(event)
|
||
var renderErr error
|
||
rendered := notificationEmailPlaceholderPattern.ReplaceAllStringFunc(raw, func(match string) string {
|
||
if renderErr != nil {
|
||
return ""
|
||
}
|
||
parts := notificationEmailPlaceholderPattern.FindStringSubmatch(match)
|
||
if len(parts) != 2 {
|
||
return ""
|
||
}
|
||
name := parts[1]
|
||
if _, ok := allowed[name]; !ok {
|
||
renderErr = fmt.Errorf("unsupported placeholder {{%s}} for event %s", name, event)
|
||
return ""
|
||
}
|
||
value := variables[name]
|
||
if escapeHTML && notificationEmailRawHTMLAllowed(event, name) {
|
||
if rawHTMLVariables != nil {
|
||
if rawValue, ok := rawHTMLVariables[name]; ok {
|
||
return rawValue
|
||
}
|
||
}
|
||
}
|
||
if strings.HasSuffix(name, "_url") && !isSafeNotificationEmailURL(value) {
|
||
value = ""
|
||
}
|
||
if escapeHTML {
|
||
return html.EscapeString(value)
|
||
}
|
||
return sanitizeEmailHeader(value)
|
||
})
|
||
if renderErr != nil {
|
||
return "", renderErr
|
||
}
|
||
return rendered, nil
|
||
}
|
||
|
||
func notificationEmailRawHTMLAllowed(event, placeholder string) bool {
|
||
return event == NotificationEmailEventOpsScheduledReport && placeholder == "report_html"
|
||
}
|
||
|
||
func notificationEmailAllowedPlaceholderSet(event string) map[string]struct{} {
|
||
info := notificationEmailEventDefinitions[event]
|
||
allowed := make(map[string]struct{}, len(info.Placeholders))
|
||
for _, placeholder := range info.Placeholders {
|
||
allowed[placeholder] = struct{}{}
|
||
}
|
||
return allowed
|
||
}
|
||
|
||
func notificationEmailPlaceholdersIn(raw string) []string {
|
||
matches := notificationEmailPlaceholderPattern.FindAllStringSubmatch(raw, -1)
|
||
seen := make(map[string]struct{}, len(matches))
|
||
out := make([]string, 0, len(matches))
|
||
for _, match := range matches {
|
||
if len(match) != 2 {
|
||
continue
|
||
}
|
||
if _, exists := seen[match[1]]; exists {
|
||
continue
|
||
}
|
||
seen[match[1]] = struct{}{}
|
||
out = append(out, match[1])
|
||
}
|
||
return out
|
||
}
|
||
|
||
func normalizeNotificationLocale(raw string) string {
|
||
trimmed := strings.ToLower(strings.TrimSpace(raw))
|
||
if trimmed == "" {
|
||
return notificationEmailDefaultLocale
|
||
}
|
||
for _, part := range strings.Split(trimmed, ",") {
|
||
tag := strings.TrimSpace(strings.Split(part, ";")[0])
|
||
if strings.HasPrefix(tag, "zh") || tag == "cn" {
|
||
return notificationEmailLocaleChinese
|
||
}
|
||
if strings.HasPrefix(tag, "en") {
|
||
return notificationEmailDefaultLocale
|
||
}
|
||
}
|
||
return notificationEmailDefaultLocale
|
||
}
|
||
|
||
func notificationEmailTemplateKey(event, locale string) string {
|
||
return notificationEmailTemplateKeyPrefix + event + ":" + locale
|
||
}
|
||
|
||
func notificationEmailPreferenceKey(event, email string) string {
|
||
return notificationEmailPreferenceKeyPrefix + event + ":" + notificationEmailHash(email)
|
||
}
|
||
|
||
func notificationEmailDeliveryKey(event, sourceType, sourceID, recipient, reminderKey string) string {
|
||
if strings.TrimSpace(sourceType) == "" || strings.TrimSpace(sourceID) == "" || strings.TrimSpace(recipient) == "" {
|
||
return ""
|
||
}
|
||
parts := []string{notificationEmailDeliveryKeyPrefix, event, ":", safeNotificationEmailKeyPart(sourceType), ":", safeNotificationEmailKeyPart(sourceID), ":", notificationEmailHash(recipient)}
|
||
if strings.TrimSpace(reminderKey) != "" {
|
||
parts = append(parts, ":", safeNotificationEmailKeyPart(reminderKey))
|
||
}
|
||
return strings.Join(parts, "")
|
||
}
|
||
|
||
func notificationEmailHash(value string) string {
|
||
trimmed := strings.ToLower(strings.TrimSpace(value))
|
||
if trimmed == "" {
|
||
return ""
|
||
}
|
||
sum := sha256.Sum256([]byte(trimmed))
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func safeNotificationEmailKeyPart(value string) string {
|
||
value = strings.ToLower(strings.TrimSpace(value))
|
||
var builder strings.Builder
|
||
for _, r := range value {
|
||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.' {
|
||
_, _ = builder.WriteRune(r)
|
||
} else {
|
||
_, _ = builder.WriteRune('_')
|
||
}
|
||
}
|
||
return builder.String()
|
||
}
|
||
|
||
func signNotificationEmailToken(secret, payload string) string {
|
||
mac := hmac.New(sha256.New, []byte(secret))
|
||
_, _ = mac.Write([]byte(payload))
|
||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||
}
|
||
|
||
func isSafeNotificationEmailURL(raw string) bool {
|
||
trimmed := strings.TrimSpace(raw)
|
||
if trimmed == "" {
|
||
return true
|
||
}
|
||
parsed, err := url.Parse(trimmed)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
if parsed.IsAbs() {
|
||
scheme := strings.ToLower(parsed.Scheme)
|
||
return scheme == "http" || scheme == "https" || scheme == "mailto"
|
||
}
|
||
return strings.HasPrefix(trimmed, "/")
|
||
}
|
||
|
||
func notificationEmailSampleVariables(locale string) map[string]string {
|
||
if normalizeNotificationLocale(locale) == notificationEmailLocaleChinese {
|
||
return map[string]string{
|
||
"site_name": defaultSiteName,
|
||
"recipient_name": "张三",
|
||
"recipient_email": "user@example.com",
|
||
"verification_code": "123456",
|
||
"expires_in_minutes": "15",
|
||
"reset_url": "https://example.com/reset-password?token=preview",
|
||
"subscription_group": "Claude Pro",
|
||
"subscription_days": "30",
|
||
"expiry_time": "2026-06-18 12:00",
|
||
"days_remaining": "3",
|
||
"current_balance": "12.34",
|
||
"threshold": "20.00",
|
||
"recharge_url": "https://example.com/recharge",
|
||
"recharge_amount": "50.00",
|
||
"order_id": "1024",
|
||
"unsubscribe_url": "https://example.com/unsubscribe",
|
||
"account_id": "1001",
|
||
"account_name": "openai-main",
|
||
"platform": "openai",
|
||
"quota_dimension": "每日额度",
|
||
"quota_used": "80.00",
|
||
"quota_limit": "100.00",
|
||
"quota_remaining": "20.00",
|
||
"quota_threshold": "20%",
|
||
"triggered_at": "2026-05-20 12:00:00",
|
||
"group_name": "默认分组",
|
||
"moderation_category": "violence",
|
||
"moderation_score": "0.982",
|
||
"violation_count": "2",
|
||
"ban_threshold": "3",
|
||
"rule_name": "错误率过高",
|
||
"severity": "critical",
|
||
"alert_status": "firing",
|
||
"metric_type": "error_rate",
|
||
"operator": ">=",
|
||
"metric_value": "12.50",
|
||
"threshold_value": "10.00",
|
||
"alert_description": "最近 10 分钟错误率超过阈值",
|
||
"report_name": "日报",
|
||
"report_type": "daily_summary",
|
||
"report_start_time": "2026-05-19 12:00",
|
||
"report_end_time": "2026-05-20 12:00",
|
||
"report_html": "<h2>日报</h2><p>请求量:1024</p>",
|
||
}
|
||
}
|
||
return map[string]string{
|
||
"site_name": defaultSiteName,
|
||
"recipient_name": "Alex",
|
||
"recipient_email": "user@example.com",
|
||
"verification_code": "123456",
|
||
"expires_in_minutes": "15",
|
||
"reset_url": "https://example.com/reset-password?token=preview",
|
||
"subscription_group": "Claude Pro",
|
||
"subscription_days": "30",
|
||
"expiry_time": "2026-06-18 12:00",
|
||
"days_remaining": "3",
|
||
"current_balance": "12.34",
|
||
"threshold": "20.00",
|
||
"recharge_url": "https://example.com/recharge",
|
||
"recharge_amount": "50.00",
|
||
"order_id": "1024",
|
||
"unsubscribe_url": "https://example.com/unsubscribe",
|
||
"account_id": "1001",
|
||
"account_name": "openai-main",
|
||
"platform": "openai",
|
||
"quota_dimension": "Daily quota",
|
||
"quota_used": "80.00",
|
||
"quota_limit": "100.00",
|
||
"quota_remaining": "20.00",
|
||
"quota_threshold": "20%",
|
||
"triggered_at": "2026-05-20 12:00:00",
|
||
"group_name": "Default group",
|
||
"moderation_category": "violence",
|
||
"moderation_score": "0.982",
|
||
"violation_count": "2",
|
||
"ban_threshold": "3",
|
||
"rule_name": "High error rate",
|
||
"severity": "critical",
|
||
"alert_status": "firing",
|
||
"metric_type": "error_rate",
|
||
"operator": ">=",
|
||
"metric_value": "12.50",
|
||
"threshold_value": "10.00",
|
||
"alert_description": "Error rate exceeded threshold in the last 10 minutes.",
|
||
"report_name": "Daily summary",
|
||
"report_type": "daily_summary",
|
||
"report_start_time": "2026-05-19 12:00",
|
||
"report_end_time": "2026-05-20 12:00",
|
||
"report_html": "<h2>Daily summary</h2><p>Requests: 1024</p>",
|
||
}
|
||
}
|
||
|
||
var notificationEmailEventOrder = []string{
|
||
NotificationEmailEventAuthVerifyCode,
|
||
NotificationEmailEventAuthPasswordReset,
|
||
NotificationEmailEventNotificationEmailVerifyCode,
|
||
NotificationEmailEventSubscriptionPurchaseSuccess,
|
||
NotificationEmailEventSubscriptionExpiryReminder,
|
||
NotificationEmailEventBalanceLow,
|
||
NotificationEmailEventBalanceRechargeSuccess,
|
||
NotificationEmailEventAccountQuotaAlert,
|
||
NotificationEmailEventContentModerationViolation,
|
||
NotificationEmailEventContentModerationDisabled,
|
||
NotificationEmailEventOpsAlert,
|
||
NotificationEmailEventOpsScheduledReport,
|
||
}
|
||
|
||
var notificationEmailEventDefinitions = map[string]NotificationEmailEventInfo{
|
||
NotificationEmailEventAuthVerifyCode: {
|
||
Event: NotificationEmailEventAuthVerifyCode,
|
||
Label: "Email verification code",
|
||
Description: "Sent for registration, email binding, OAuth pending email, and TOTP verification flows.",
|
||
Category: "auth",
|
||
Optional: false,
|
||
Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "verification_code", "expires_in_minutes"),
|
||
},
|
||
NotificationEmailEventAuthPasswordReset: {
|
||
Event: NotificationEmailEventAuthPasswordReset,
|
||
Label: "Password reset",
|
||
Description: "Sent when a user requests a password reset link.",
|
||
Category: "auth",
|
||
Optional: false,
|
||
Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "reset_url", "expires_in_minutes"),
|
||
},
|
||
NotificationEmailEventNotificationEmailVerifyCode: {
|
||
Event: NotificationEmailEventNotificationEmailVerifyCode,
|
||
Label: "Notification email verification code",
|
||
Description: "Sent when a user verifies an extra notification email address.",
|
||
Category: "auth",
|
||
Optional: false,
|
||
Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "verification_code", "expires_in_minutes"),
|
||
},
|
||
NotificationEmailEventSubscriptionPurchaseSuccess: {
|
||
Event: NotificationEmailEventSubscriptionPurchaseSuccess,
|
||
Label: "Subscription purchase success",
|
||
Description: "Sent after a subscription purchase is fulfilled.",
|
||
Category: "subscription",
|
||
Optional: false,
|
||
Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "subscription_group", "subscription_days", "expiry_time", "order_id"),
|
||
},
|
||
NotificationEmailEventSubscriptionExpiryReminder: {
|
||
Event: NotificationEmailEventSubscriptionExpiryReminder,
|
||
Label: "Subscription expiry reminder",
|
||
Description: "Optional reminder sent before an active subscription expires.",
|
||
Category: "subscription",
|
||
Optional: true,
|
||
Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "subscription_group", "expiry_time", "days_remaining", "unsubscribe_url"),
|
||
},
|
||
NotificationEmailEventBalanceLow: {
|
||
Event: NotificationEmailEventBalanceLow,
|
||
Label: "Low balance alert",
|
||
Description: "Optional alert sent when balance crosses the configured low-balance threshold.",
|
||
Category: "billing",
|
||
Optional: true,
|
||
Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "current_balance", "threshold", "recharge_url", "unsubscribe_url"),
|
||
},
|
||
NotificationEmailEventBalanceRechargeSuccess: {
|
||
Event: NotificationEmailEventBalanceRechargeSuccess,
|
||
Label: "Balance recharge success",
|
||
Description: "Sent after a balance recharge order is fulfilled.",
|
||
Category: "billing",
|
||
Optional: false,
|
||
Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "recharge_amount", "current_balance", "order_id"),
|
||
},
|
||
NotificationEmailEventAccountQuotaAlert: {
|
||
Event: NotificationEmailEventAccountQuotaAlert,
|
||
Label: "Account quota alert",
|
||
Description: "Sent to configured admin notification emails when an upstream account quota threshold is crossed.",
|
||
Category: "admin",
|
||
Optional: false,
|
||
Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...),
|
||
"account_id", "account_name", "platform", "quota_dimension", "quota_used", "quota_limit", "quota_remaining", "quota_threshold"),
|
||
},
|
||
NotificationEmailEventContentModerationViolation: {
|
||
Event: NotificationEmailEventContentModerationViolation,
|
||
Label: "Risk control violation notice",
|
||
Description: "Sent to users when a request triggers content moderation/risk control rules.",
|
||
Category: "risk_control",
|
||
Optional: false,
|
||
Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...),
|
||
"triggered_at", "group_name", "moderation_category", "moderation_score", "violation_count", "ban_threshold"),
|
||
},
|
||
NotificationEmailEventContentModerationDisabled: {
|
||
Event: NotificationEmailEventContentModerationDisabled,
|
||
Label: "Risk control account disabled",
|
||
Description: "Sent to users when content moderation automatically disables their account.",
|
||
Category: "risk_control",
|
||
Optional: false,
|
||
Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...),
|
||
"triggered_at", "group_name", "moderation_category", "moderation_score", "violation_count", "ban_threshold"),
|
||
},
|
||
NotificationEmailEventOpsAlert: {
|
||
Event: NotificationEmailEventOpsAlert,
|
||
Label: "Ops alert",
|
||
Description: "Sent to configured operations recipients when an ops alert rule fires.",
|
||
Category: "ops",
|
||
Optional: false,
|
||
Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...),
|
||
"rule_name", "severity", "alert_status", "metric_type", "operator", "metric_value", "threshold_value", "triggered_at", "alert_description"),
|
||
},
|
||
NotificationEmailEventOpsScheduledReport: {
|
||
Event: NotificationEmailEventOpsScheduledReport,
|
||
Label: "Ops scheduled report",
|
||
Description: "Sent to configured operations recipients for scheduled daily/weekly/error/account-health reports.",
|
||
Category: "ops",
|
||
Optional: false,
|
||
Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...),
|
||
"report_name", "report_type", "report_start_time", "report_end_time", "report_html"),
|
||
},
|
||
}
|
||
|
||
var notificationEmailOfficialTemplates = map[string]map[string]notificationEmailOfficialTemplate{
|
||
NotificationEmailEventAuthVerifyCode: {
|
||
notificationEmailDefaultLocale: {
|
||
Subject: "[{{site_name}}] Email verification code",
|
||
HTML: notificationEmailCard("#4f46e5", "Email verification code", `
|
||
<p>Hello {{recipient_name}},</p>
|
||
<p>Your verification code is:</p>
|
||
<p style="font-size: 32px; font-weight: 700; letter-spacing: 8px; text-align: center;">{{verification_code}}</p>
|
||
<p>This code expires in <strong>{{expires_in_minutes}}</strong> minutes.</p>
|
||
<p>If you did not request this code, please ignore this email.</p>`),
|
||
},
|
||
notificationEmailLocaleChinese: {
|
||
Subject: "[{{site_name}}] 邮箱验证码",
|
||
HTML: notificationEmailCard("#4f46e5", "邮箱验证码", `
|
||
<p>{{recipient_name}},您好:</p>
|
||
<p>您的验证码是:</p>
|
||
<p style="font-size: 32px; font-weight: 700; letter-spacing: 8px; text-align: center;">{{verification_code}}</p>
|
||
<p>验证码将在 <strong>{{expires_in_minutes}}</strong> 分钟后失效。</p>
|
||
<p>如果不是您本人操作,请忽略此邮件。</p>`),
|
||
},
|
||
},
|
||
NotificationEmailEventAuthPasswordReset: {
|
||
notificationEmailDefaultLocale: {
|
||
Subject: "[{{site_name}}] Password reset request",
|
||
HTML: notificationEmailCard("#7c3aed", "Password reset", `
|
||
<p>Hello {{recipient_name}},</p>
|
||
<p>We received a request to reset your password. Click the button below to set a new password.</p>
|
||
<p><a class="button" href="{{reset_url}}">Reset password</a></p>
|
||
<p>This link expires in <strong>{{expires_in_minutes}}</strong> minutes.</p>
|
||
<p class="muted">If the button does not work, copy this link into your browser:<br>{{reset_url}}</p>
|
||
<p>If you did not request this, you can safely ignore this email.</p>`),
|
||
},
|
||
notificationEmailLocaleChinese: {
|
||
Subject: "[{{site_name}}] 密码重置请求",
|
||
HTML: notificationEmailCard("#7c3aed", "密码重置", `
|
||
<p>{{recipient_name}},您好:</p>
|
||
<p>我们收到了您的密码重置请求,请点击下方按钮设置新密码。</p>
|
||
<p><a class="button" href="{{reset_url}}">重置密码</a></p>
|
||
<p>此链接将在 <strong>{{expires_in_minutes}}</strong> 分钟后失效。</p>
|
||
<p class="muted">如果按钮无法点击,请复制以下链接到浏览器中打开:<br>{{reset_url}}</p>
|
||
<p>如果不是您本人操作,请忽略此邮件。</p>`),
|
||
},
|
||
},
|
||
NotificationEmailEventNotificationEmailVerifyCode: {
|
||
notificationEmailDefaultLocale: {
|
||
Subject: "[{{site_name}}] Notification email verification code",
|
||
HTML: notificationEmailCard("#0ea5e9", "Notification email verification", `
|
||
<p>Hello {{recipient_name}},</p>
|
||
<p>You are adding this address as an extra notification email.</p>
|
||
<p>Your verification code is:</p>
|
||
<p style="font-size: 32px; font-weight: 700; letter-spacing: 8px; text-align: center;">{{verification_code}}</p>
|
||
<p>This code expires in <strong>{{expires_in_minutes}}</strong> minutes.</p>
|
||
<p>If you did not request this code, please ignore this email.</p>`),
|
||
},
|
||
notificationEmailLocaleChinese: {
|
||
Subject: "[{{site_name}}] 通知邮箱验证码",
|
||
HTML: notificationEmailCard("#0ea5e9", "通知邮箱验证", `
|
||
<p>{{recipient_name}},您好:</p>
|
||
<p>您正在添加额外的通知邮箱,请输入以下验证码完成验证。</p>
|
||
<p style="font-size: 32px; font-weight: 700; letter-spacing: 8px; text-align: center;">{{verification_code}}</p>
|
||
<p>验证码将在 <strong>{{expires_in_minutes}}</strong> 分钟后失效。</p>
|
||
<p>如果不是您本人操作,请忽略此邮件。</p>`),
|
||
},
|
||
},
|
||
NotificationEmailEventSubscriptionPurchaseSuccess: {
|
||
notificationEmailDefaultLocale: {
|
||
Subject: "[{{site_name}}] Subscription purchase successful",
|
||
HTML: notificationEmailCard("#2563eb", "Subscription activated", `
|
||
<p>Hello {{recipient_name}},</p>
|
||
<p>Your subscription for <strong>{{subscription_group}}</strong> has been activated for <strong>{{subscription_days}}</strong> days.</p>
|
||
<p>Expiry time: <strong>{{expiry_time}}</strong></p>
|
||
<p>Order ID: {{order_id}}</p>`),
|
||
},
|
||
notificationEmailLocaleChinese: {
|
||
Subject: "[{{site_name}}] 订阅购买成功",
|
||
HTML: notificationEmailCard("#2563eb", "订阅已开通", `
|
||
<p>{{recipient_name}},您好:</p>
|
||
<p>您的 <strong>{{subscription_group}}</strong> 订阅已成功开通,有效期 <strong>{{subscription_days}}</strong> 天。</p>
|
||
<p>到期时间:<strong>{{expiry_time}}</strong></p>
|
||
<p>订单号:{{order_id}}</p>`),
|
||
},
|
||
},
|
||
NotificationEmailEventSubscriptionExpiryReminder: {
|
||
notificationEmailDefaultLocale: {
|
||
Subject: "[{{site_name}}] Subscription expires in {{days_remaining}} day(s)",
|
||
HTML: notificationEmailCard("#f97316", "Subscription expiry reminder", `
|
||
<p>Hello {{recipient_name}},</p>
|
||
<p>Your <strong>{{subscription_group}}</strong> subscription will expire in <strong>{{days_remaining}}</strong> day(s).</p>
|
||
<p>Expiry time: <strong>{{expiry_time}}</strong></p>
|
||
<p class="muted"><a href="{{unsubscribe_url}}">Unsubscribe from optional subscription reminders</a></p>`),
|
||
},
|
||
notificationEmailLocaleChinese: {
|
||
Subject: "[{{site_name}}] 订阅将在 {{days_remaining}} 天后到期",
|
||
HTML: notificationEmailCard("#f97316", "订阅到期提醒", `
|
||
<p>{{recipient_name}},您好:</p>
|
||
<p>您的 <strong>{{subscription_group}}</strong> 订阅将在 <strong>{{days_remaining}}</strong> 天后到期。</p>
|
||
<p>到期时间:<strong>{{expiry_time}}</strong></p>
|
||
<p class="muted"><a href="{{unsubscribe_url}}">退订此类订阅提醒</a></p>`),
|
||
},
|
||
},
|
||
NotificationEmailEventBalanceLow: {
|
||
notificationEmailDefaultLocale: {
|
||
Subject: "[{{site_name}}] Low balance alert",
|
||
HTML: notificationEmailCard("#d97706", "Low balance alert", `
|
||
<p>Hello {{recipient_name}},</p>
|
||
<p>Your current balance is <strong>${{current_balance}}</strong>, below the configured alert threshold of <strong>${{threshold}}</strong>.</p>
|
||
<p>Please recharge in time to avoid service interruption.</p>
|
||
<p><a class="button" href="{{recharge_url}}">Recharge now</a></p>
|
||
<p class="muted"><a href="{{unsubscribe_url}}">Unsubscribe from optional balance alerts</a></p>`),
|
||
},
|
||
notificationEmailLocaleChinese: {
|
||
Subject: "[{{site_name}}] 余额不足提醒",
|
||
HTML: notificationEmailCard("#d97706", "余额不足提醒", `
|
||
<p>{{recipient_name}},您好:</p>
|
||
<p>您当前余额为 <strong>${{current_balance}}</strong>,已低于提醒阈值 <strong>${{threshold}}</strong>。</p>
|
||
<p>请及时充值以免服务中断。</p>
|
||
<p><a class="button" href="{{recharge_url}}">立即充值</a></p>
|
||
<p class="muted"><a href="{{unsubscribe_url}}">退订此类余额提醒</a></p>`),
|
||
},
|
||
},
|
||
NotificationEmailEventBalanceRechargeSuccess: {
|
||
notificationEmailDefaultLocale: {
|
||
Subject: "[{{site_name}}] Balance recharge successful",
|
||
HTML: notificationEmailCard("#16a34a", "Recharge successful", `
|
||
<p>Hello {{recipient_name}},</p>
|
||
<p>Your balance recharge of <strong>${{recharge_amount}}</strong> has been completed.</p>
|
||
<p>Current balance: <strong>${{current_balance}}</strong></p>
|
||
<p>Order ID: {{order_id}}</p>`),
|
||
},
|
||
notificationEmailLocaleChinese: {
|
||
Subject: "[{{site_name}}] 余额充值成功",
|
||
HTML: notificationEmailCard("#16a34a", "余额充值成功", `
|
||
<p>{{recipient_name}},您好:</p>
|
||
<p>您的余额充值 <strong>${{recharge_amount}}</strong> 已完成。</p>
|
||
<p>当前余额:<strong>${{current_balance}}</strong></p>
|
||
<p>订单号:{{order_id}}</p>`),
|
||
},
|
||
},
|
||
NotificationEmailEventAccountQuotaAlert: {
|
||
notificationEmailDefaultLocale: {
|
||
Subject: "[{{site_name}}] Account quota alert - {{account_name}}",
|
||
HTML: notificationEmailCard("#dc2626", "Account quota alert", `
|
||
<p>The upstream account <strong>{{account_name}}</strong> has crossed its configured quota alert threshold.</p>
|
||
<table style="width:100%;border-collapse:collapse;">
|
||
<tr><td>Account ID</td><td>{{account_id}}</td></tr>
|
||
<tr><td>Platform</td><td>{{platform}}</td></tr>
|
||
<tr><td>Dimension</td><td>{{quota_dimension}}</td></tr>
|
||
<tr><td>Used / Limit</td><td>{{quota_used}} / {{quota_limit}}</td></tr>
|
||
<tr><td>Remaining</td><td>{{quota_remaining}}</td></tr>
|
||
<tr><td>Threshold</td><td>{{quota_threshold}}</td></tr>
|
||
</table>`),
|
||
},
|
||
notificationEmailLocaleChinese: {
|
||
Subject: "[{{site_name}}] 账号限额告警 - {{account_name}}",
|
||
HTML: notificationEmailCard("#dc2626", "账号限额告警", `
|
||
<p>上游账号 <strong>{{account_name}}</strong> 已触发配置的额度告警阈值。</p>
|
||
<table style="width:100%;border-collapse:collapse;">
|
||
<tr><td>账号 ID</td><td>{{account_id}}</td></tr>
|
||
<tr><td>平台</td><td>{{platform}}</td></tr>
|
||
<tr><td>维度</td><td>{{quota_dimension}}</td></tr>
|
||
<tr><td>已用 / 限额</td><td>{{quota_used}} / {{quota_limit}}</td></tr>
|
||
<tr><td>剩余额度</td><td>{{quota_remaining}}</td></tr>
|
||
<tr><td>告警阈值</td><td>{{quota_threshold}}</td></tr>
|
||
</table>`),
|
||
},
|
||
},
|
||
NotificationEmailEventContentModerationViolation: {
|
||
notificationEmailDefaultLocale: {
|
||
Subject: "[{{site_name}}] Risk control notice",
|
||
HTML: notificationEmailCard("#ef4444", "Risk control notice", `
|
||
<p>Hello {{recipient_name}},</p>
|
||
<p>Your API request triggered the platform content moderation/risk-control policy.</p>
|
||
<table style="width:100%;border-collapse:collapse;">
|
||
<tr><td>Triggered at</td><td>{{triggered_at}}</td></tr>
|
||
<tr><td>Group</td><td>{{group_name}}</td></tr>
|
||
<tr><td>Category / Score</td><td>{{moderation_category}} / {{moderation_score}}</td></tr>
|
||
<tr><td>Violation count</td><td>{{violation_count}} / {{ban_threshold}}</td></tr>
|
||
</table>
|
||
<p>Please review your request content to avoid future service interruptions.</p>`),
|
||
},
|
||
notificationEmailLocaleChinese: {
|
||
Subject: "[{{site_name}}] 账户风控提醒",
|
||
HTML: notificationEmailCard("#ef4444", "账户风控提醒", `
|
||
<p>{{recipient_name}},您好:</p>
|
||
<p>您的 API 请求触发了平台内容审核/风控策略。</p>
|
||
<table style="width:100%;border-collapse:collapse;">
|
||
<tr><td>触发时间</td><td>{{triggered_at}}</td></tr>
|
||
<tr><td>所属分组</td><td>{{group_name}}</td></tr>
|
||
<tr><td>命中类别 / 分数</td><td>{{moderation_category}} / {{moderation_score}}</td></tr>
|
||
<tr><td>累计触发次数</td><td>{{violation_count}} / {{ban_threshold}}</td></tr>
|
||
</table>
|
||
<p>请检查请求内容,避免后续服务受到影响。</p>`),
|
||
},
|
||
},
|
||
NotificationEmailEventContentModerationDisabled: {
|
||
notificationEmailDefaultLocale: {
|
||
Subject: "[{{site_name}}] Account disabled by risk control",
|
||
HTML: notificationEmailCard("#b91c1c", "Account disabled", `
|
||
<p>Hello {{recipient_name}},</p>
|
||
<p>Your account has repeatedly triggered platform content moderation/risk-control rules and has been automatically disabled.</p>
|
||
<table style="width:100%;border-collapse:collapse;">
|
||
<tr><td>Disabled at</td><td>{{triggered_at}}</td></tr>
|
||
<tr><td>Group</td><td>{{group_name}}</td></tr>
|
||
<tr><td>Category / Score</td><td>{{moderation_category}} / {{moderation_score}}</td></tr>
|
||
<tr><td>Violation count</td><td>{{violation_count}} / {{ban_threshold}}</td></tr>
|
||
</table>
|
||
<p>Please contact the administrator if you need to appeal or restore access.</p>`),
|
||
},
|
||
notificationEmailLocaleChinese: {
|
||
Subject: "[{{site_name}}] 账户已被禁用",
|
||
HTML: notificationEmailCard("#b91c1c", "账户已被禁用", `
|
||
<p>{{recipient_name}},您好:</p>
|
||
<p>您的账户在统计周期内多次触发平台内容审核/风控规则,系统已自动禁用该账户。</p>
|
||
<table style="width:100%;border-collapse:collapse;">
|
||
<tr><td>禁用时间</td><td>{{triggered_at}}</td></tr>
|
||
<tr><td>所属分组</td><td>{{group_name}}</td></tr>
|
||
<tr><td>命中类别 / 分数</td><td>{{moderation_category}} / {{moderation_score}}</td></tr>
|
||
<tr><td>累计触发次数</td><td>{{violation_count}} / {{ban_threshold}}</td></tr>
|
||
</table>
|
||
<p>如需申诉或恢复账号,请联系平台管理员处理。</p>`),
|
||
},
|
||
},
|
||
NotificationEmailEventOpsAlert: {
|
||
notificationEmailDefaultLocale: {
|
||
Subject: "[Ops Alert][{{severity}}] {{rule_name}}",
|
||
HTML: notificationEmailCard("#ea580c", "Ops alert", `
|
||
<p><strong>Rule</strong>: {{rule_name}}</p>
|
||
<p><strong>Severity</strong>: {{severity}}</p>
|
||
<p><strong>Status</strong>: {{alert_status}}</p>
|
||
<p><strong>Metric</strong>: {{metric_type}} {{operator}} {{metric_value}} (threshold {{threshold_value}})</p>
|
||
<p><strong>Fired at</strong>: {{triggered_at}}</p>
|
||
<p><strong>Description</strong>: {{alert_description}}</p>`),
|
||
},
|
||
notificationEmailLocaleChinese: {
|
||
Subject: "[运维告警][{{severity}}] {{rule_name}}",
|
||
HTML: notificationEmailCard("#ea580c", "运维告警", `
|
||
<p><strong>规则</strong>:{{rule_name}}</p>
|
||
<p><strong>严重级别</strong>:{{severity}}</p>
|
||
<p><strong>状态</strong>:{{alert_status}}</p>
|
||
<p><strong>指标</strong>:{{metric_type}} {{operator}} {{metric_value}}(阈值 {{threshold_value}})</p>
|
||
<p><strong>触发时间</strong>:{{triggered_at}}</p>
|
||
<p><strong>说明</strong>:{{alert_description}}</p>`),
|
||
},
|
||
},
|
||
NotificationEmailEventOpsScheduledReport: {
|
||
notificationEmailDefaultLocale: {
|
||
Subject: "[Ops Report] {{report_name}}",
|
||
HTML: notificationEmailCard("#0891b2", "Ops report", `
|
||
<p><strong>Report</strong>: {{report_name}}</p>
|
||
<p><strong>Type</strong>: {{report_type}}</p>
|
||
<p><strong>Range</strong>: {{report_start_time}} - {{report_end_time}}</p>
|
||
<div>{{report_html}}</div>`),
|
||
},
|
||
notificationEmailLocaleChinese: {
|
||
Subject: "[运维报表] {{report_name}}",
|
||
HTML: notificationEmailCard("#0891b2", "运维报表", `
|
||
<p><strong>报表</strong>:{{report_name}}</p>
|
||
<p><strong>类型</strong>:{{report_type}}</p>
|
||
<p><strong>时间范围</strong>:{{report_start_time}} - {{report_end_time}}</p>
|
||
<div>{{report_html}}</div>`),
|
||
},
|
||
},
|
||
}
|
||
|
||
func notificationEmailCard(accent, title, content string) string {
|
||
return `<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<style>
|
||
body { margin: 0; padding: 24px; background: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #18181b; }
|
||
.container { max-width: 640px; margin: 0 auto; background: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 8px 30px rgba(15, 23, 42, 0.10); }
|
||
.header { background: ` + accent + `; color: #ffffff; padding: 28px 32px; }
|
||
.header h1 { margin: 0; font-size: 24px; line-height: 1.25; }
|
||
.content { padding: 32px; font-size: 15px; line-height: 1.7; }
|
||
.button { display: inline-block; margin-top: 12px; padding: 11px 18px; border-radius: 8px; background: ` + accent + `; color: #ffffff; text-decoration: none; font-weight: 600; }
|
||
.muted { color: #71717a; font-size: 13px; }
|
||
.footer { padding: 18px 32px; background: #fafafa; color: #a1a1aa; font-size: 12px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="header"><h1>` + title + `</h1></div>
|
||
<div class="content">` + content + `</div>
|
||
<div class="footer">This email was sent by {{site_name}}. Please do not reply directly.</div>
|
||
</div>
|
||
</body>
|
||
</html>`
|
||
}
|