notification_email_service.go 72 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697
  1. package service
  2. import (
  3. "context"
  4. "crypto/hmac"
  5. "crypto/rand"
  6. "crypto/sha256"
  7. "encoding/base64"
  8. "encoding/hex"
  9. "encoding/json"
  10. "errors"
  11. "fmt"
  12. "html"
  13. "log/slog"
  14. "net/url"
  15. "regexp"
  16. "strconv"
  17. "strings"
  18. "time"
  19. )
  20. const (
  21. NotificationEmailEventAuthVerifyCode = "auth.verify_code"
  22. NotificationEmailEventAuthPasswordReset = "auth.password_reset"
  23. NotificationEmailEventNotificationEmailVerifyCode = "notification_email.verify_code"
  24. NotificationEmailEventSubscriptionPurchaseSuccess = "subscription.purchase_success"
  25. NotificationEmailEventSubscriptionExpiryReminder = "subscription.expiry_reminder"
  26. NotificationEmailEventBalanceLow = "balance.low"
  27. NotificationEmailEventBalanceRechargeSuccess = "balance.recharge_success"
  28. NotificationEmailEventAccountQuotaAlert = "account.quota_alert"
  29. NotificationEmailEventContentModerationViolation = "content_moderation.violation_notice"
  30. NotificationEmailEventContentModerationDisabled = "content_moderation.account_disabled"
  31. NotificationEmailEventCyberPolicyNotice = "content_moderation.cyber_policy_notice"
  32. NotificationEmailEventSecurityPolicyNotice = "security_policy.violation_notice"
  33. NotificationEmailEventOpsAlert = "ops.alert"
  34. NotificationEmailEventOpsScheduledReport = "ops.scheduled_report"
  35. notificationEmailTemplateKeyPrefix = "notification_email_template:"
  36. notificationEmailPreferenceKeyPrefix = "notification_email_preference:"
  37. notificationEmailDeliveryKeyPrefix = "notification_email_delivery:"
  38. notificationEmailLocaleUserKeyPrefix = "notification_email_locale:user:"
  39. notificationEmailLocaleEmailKeyPrefix = "notification_email_locale:email:"
  40. notificationEmailUnsubscribeSecretKey = "notification_email_unsubscribe_secret"
  41. notificationEmailDefaultLocale = "en"
  42. notificationEmailLocaleChinese = "zh"
  43. notificationEmailMaxSubjectLength = 200
  44. notificationEmailMaxHTMLLength = 30000
  45. notificationEmailUnsubscribeTTL = 365 * 24 * time.Hour
  46. )
  47. var (
  48. notificationEmailPlaceholderPattern = regexp.MustCompile(`{{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*}}`)
  49. notificationEmailLocales = []string{notificationEmailDefaultLocale, notificationEmailLocaleChinese}
  50. notificationEmailCommonPlaceholders = []string{"site_name", "recipient_name", "recipient_email"}
  51. // Keep summary values separate so admins can rearrange or omit individual metrics in the template.
  52. notificationEmailOpsSummaryPlaceholders = []string{
  53. "report_summary_display",
  54. "report_total_requests",
  55. "report_success_count",
  56. "report_sla_error_count",
  57. "report_business_limited_count",
  58. "report_sla",
  59. "report_error_rate",
  60. "report_upstream_error_rate",
  61. "report_upstream_error_count_excl_429_529",
  62. "report_upstream_429_count",
  63. "report_upstream_529_count",
  64. "report_latency_p50",
  65. "report_latency_p99",
  66. "report_ttft_p50",
  67. "report_ttft_p99",
  68. "report_tokens",
  69. "report_qps_current",
  70. "report_qps_peak",
  71. "report_qps_avg",
  72. "report_tps_current",
  73. "report_tps_peak",
  74. "report_tps_avg",
  75. }
  76. )
  77. type NotificationEmailService struct {
  78. settingRepo SettingRepository
  79. emailService *EmailService
  80. }
  81. type NotificationEmailEventInfo struct {
  82. Event string `json:"event"`
  83. Label string `json:"label"`
  84. Description string `json:"description"`
  85. Category string `json:"category"`
  86. Optional bool `json:"optional"`
  87. Placeholders []string `json:"placeholders"`
  88. }
  89. type NotificationEmailTemplate struct {
  90. Event string `json:"event"`
  91. Locale string `json:"locale"`
  92. Subject string `json:"subject"`
  93. HTML string `json:"html"`
  94. IsCustom bool `json:"is_custom"`
  95. UpdatedAt *time.Time `json:"updated_at,omitempty"`
  96. Placeholders []string `json:"placeholders"`
  97. }
  98. type NotificationEmailPreview struct {
  99. Subject string `json:"subject"`
  100. HTML string `json:"html"`
  101. }
  102. type NotificationEmailPreviewInput struct {
  103. Event string `json:"event"`
  104. Locale string `json:"locale"`
  105. Subject string `json:"subject"`
  106. HTML string `json:"html"`
  107. Variables map[string]string `json:"variables,omitempty"`
  108. }
  109. type NotificationEmailSendInput struct {
  110. Event string
  111. Locale string
  112. RecipientEmail string
  113. RecipientName string
  114. UserID int64
  115. SourceType string
  116. SourceID string
  117. ReminderKey string
  118. Variables map[string]string
  119. RawHTMLVariables map[string]string
  120. }
  121. type NotificationEmailUnsubscribeResult struct {
  122. Event string `json:"event"`
  123. Email string `json:"email"`
  124. Done bool `json:"done"`
  125. }
  126. type notificationEmailStoredTemplate struct {
  127. Subject string `json:"subject"`
  128. HTML string `json:"html"`
  129. UpdatedAt time.Time `json:"updated_at"`
  130. }
  131. type notificationEmailOfficialTemplate struct {
  132. Subject string
  133. HTML string
  134. }
  135. type notificationEmailTemplateError struct {
  136. Err error
  137. }
  138. func (e notificationEmailTemplateError) Error() string {
  139. return e.Err.Error()
  140. }
  141. func (e notificationEmailTemplateError) Unwrap() error {
  142. return e.Err
  143. }
  144. type notificationEmailConfigError struct {
  145. Err error
  146. }
  147. func (e notificationEmailConfigError) Error() string {
  148. return e.Err.Error()
  149. }
  150. func (e notificationEmailConfigError) Unwrap() error {
  151. return e.Err
  152. }
  153. type notificationEmailDeliveryError struct {
  154. Err error
  155. }
  156. func (e notificationEmailDeliveryError) Error() string {
  157. return e.Err.Error()
  158. }
  159. func (e notificationEmailDeliveryError) Unwrap() error {
  160. return e.Err
  161. }
  162. type notificationEmailUnsubscribeClaims struct {
  163. Email string `json:"email"`
  164. Event string `json:"event"`
  165. Exp int64 `json:"exp"`
  166. }
  167. func NewNotificationEmailService(settingRepo SettingRepository, emailService *EmailService) *NotificationEmailService {
  168. svc := &NotificationEmailService{settingRepo: settingRepo, emailService: emailService}
  169. if emailService != nil {
  170. emailService.SetNotificationEmailService(svc)
  171. }
  172. return svc
  173. }
  174. func notificationEmailTemplateErr(err error) error {
  175. if err == nil {
  176. return nil
  177. }
  178. return notificationEmailTemplateError{Err: err}
  179. }
  180. func notificationEmailConfigErr(err error) error {
  181. if err == nil {
  182. return nil
  183. }
  184. return notificationEmailConfigError{Err: err}
  185. }
  186. func notificationEmailDeliveryErr(err error) error {
  187. if err == nil {
  188. return nil
  189. }
  190. return notificationEmailDeliveryError{Err: err}
  191. }
  192. func shouldFallbackNotificationEmail(err error) bool {
  193. if err == nil {
  194. return false
  195. }
  196. var templateErr notificationEmailTemplateError
  197. if errors.As(err, &templateErr) {
  198. return true
  199. }
  200. var configErr notificationEmailConfigError
  201. return errors.As(err, &configErr)
  202. }
  203. func isNotificationEmailDeliveryError(err error) bool {
  204. var deliveryErr notificationEmailDeliveryError
  205. return errors.As(err, &deliveryErr)
  206. }
  207. func (s *NotificationEmailService) ListEventInfos() []NotificationEmailEventInfo {
  208. infos := make([]NotificationEmailEventInfo, 0, len(notificationEmailEventDefinitions))
  209. for _, event := range notificationEmailEventOrder {
  210. info := notificationEmailEventDefinitions[event]
  211. info.Placeholders = append([]string(nil), info.Placeholders...)
  212. infos = append(infos, info)
  213. }
  214. return infos
  215. }
  216. func (s *NotificationEmailService) SupportedLocales() []string {
  217. return append([]string(nil), notificationEmailLocales...)
  218. }
  219. func (s *NotificationEmailService) ListTemplates(ctx context.Context) ([]NotificationEmailTemplate, error) {
  220. items := make([]NotificationEmailTemplate, 0, len(notificationEmailEventOrder)*len(notificationEmailLocales))
  221. for _, event := range notificationEmailEventOrder {
  222. for _, locale := range notificationEmailLocales {
  223. tmpl, err := s.GetTemplate(ctx, event, locale)
  224. if err != nil {
  225. return nil, err
  226. }
  227. items = append(items, tmpl)
  228. }
  229. }
  230. return items, nil
  231. }
  232. func (s *NotificationEmailService) GetTemplate(ctx context.Context, event, locale string) (NotificationEmailTemplate, error) {
  233. info, normalizedEvent, err := s.eventInfo(event)
  234. if err != nil {
  235. return NotificationEmailTemplate{}, err
  236. }
  237. normalizedLocale := normalizeNotificationLocale(locale)
  238. official, ok := notificationEmailOfficialTemplates[normalizedEvent][normalizedLocale]
  239. if !ok {
  240. return NotificationEmailTemplate{}, fmt.Errorf("official template not found for %s/%s", normalizedEvent, normalizedLocale)
  241. }
  242. tmpl := NotificationEmailTemplate{
  243. Event: normalizedEvent,
  244. Locale: normalizedLocale,
  245. Subject: official.Subject,
  246. HTML: official.HTML,
  247. Placeholders: append([]string(nil), info.Placeholders...),
  248. }
  249. raw, err := s.settingRepo.GetValue(ctx, notificationEmailTemplateKey(normalizedEvent, normalizedLocale))
  250. if err != nil {
  251. if errors.Is(err, ErrSettingNotFound) {
  252. return tmpl, nil
  253. }
  254. return NotificationEmailTemplate{}, err
  255. }
  256. if strings.TrimSpace(raw) == "" {
  257. return tmpl, nil
  258. }
  259. var stored notificationEmailStoredTemplate
  260. if err := json.Unmarshal([]byte(raw), &stored); err != nil {
  261. return NotificationEmailTemplate{}, fmt.Errorf("decode email template override: %w", err)
  262. }
  263. if err := validateNotificationEmailTemplate(normalizedEvent, stored.Subject, stored.HTML); err != nil {
  264. return NotificationEmailTemplate{}, err
  265. }
  266. tmpl.Subject = stored.Subject
  267. tmpl.HTML = stored.HTML
  268. tmpl.IsCustom = true
  269. updatedAt := stored.UpdatedAt
  270. tmpl.UpdatedAt = &updatedAt
  271. return tmpl, nil
  272. }
  273. func (s *NotificationEmailService) UpdateTemplate(ctx context.Context, event, locale, subject, htmlBody string) (NotificationEmailTemplate, error) {
  274. _, normalizedEvent, err := s.eventInfo(event)
  275. if err != nil {
  276. return NotificationEmailTemplate{}, err
  277. }
  278. normalizedLocale := normalizeNotificationLocale(locale)
  279. if err := validateNotificationEmailTemplate(normalizedEvent, subject, htmlBody); err != nil {
  280. return NotificationEmailTemplate{}, err
  281. }
  282. stored := notificationEmailStoredTemplate{
  283. Subject: strings.TrimSpace(subject),
  284. HTML: htmlBody,
  285. UpdatedAt: time.Now().UTC(),
  286. }
  287. payload, err := json.Marshal(stored)
  288. if err != nil {
  289. return NotificationEmailTemplate{}, err
  290. }
  291. if err := s.settingRepo.Set(ctx, notificationEmailTemplateKey(normalizedEvent, normalizedLocale), string(payload)); err != nil {
  292. return NotificationEmailTemplate{}, err
  293. }
  294. return s.GetTemplate(ctx, normalizedEvent, normalizedLocale)
  295. }
  296. func (s *NotificationEmailService) RestoreOfficialTemplate(ctx context.Context, event, locale string) (NotificationEmailTemplate, error) {
  297. _, normalizedEvent, err := s.eventInfo(event)
  298. if err != nil {
  299. return NotificationEmailTemplate{}, err
  300. }
  301. normalizedLocale := normalizeNotificationLocale(locale)
  302. if err := s.settingRepo.Delete(ctx, notificationEmailTemplateKey(normalizedEvent, normalizedLocale)); err != nil && !errors.Is(err, ErrSettingNotFound) {
  303. return NotificationEmailTemplate{}, err
  304. }
  305. return s.GetTemplate(ctx, normalizedEvent, normalizedLocale)
  306. }
  307. func (s *NotificationEmailService) PreviewTemplate(ctx context.Context, input NotificationEmailPreviewInput) (NotificationEmailPreview, error) {
  308. _, normalizedEvent, err := s.eventInfo(input.Event)
  309. if err != nil {
  310. return NotificationEmailPreview{}, err
  311. }
  312. normalizedLocale := normalizeNotificationLocale(input.Locale)
  313. subject := input.Subject
  314. htmlBody := input.HTML
  315. if strings.TrimSpace(subject) == "" || strings.TrimSpace(htmlBody) == "" {
  316. tmpl, err := s.GetTemplate(ctx, normalizedEvent, normalizedLocale)
  317. if err != nil {
  318. return NotificationEmailPreview{}, err
  319. }
  320. if strings.TrimSpace(subject) == "" {
  321. subject = tmpl.Subject
  322. }
  323. if strings.TrimSpace(htmlBody) == "" {
  324. htmlBody = tmpl.HTML
  325. }
  326. }
  327. if err := validateNotificationEmailTemplate(normalizedEvent, subject, htmlBody); err != nil {
  328. return NotificationEmailPreview{}, err
  329. }
  330. variables := s.sampleVariables(ctx, normalizedEvent, normalizedLocale)
  331. for key, value := range input.Variables {
  332. variables[key] = value
  333. }
  334. return renderNotificationEmail(normalizedEvent, subject, htmlBody, variables, nil)
  335. }
  336. func (s *NotificationEmailService) Send(ctx context.Context, input NotificationEmailSendInput) error {
  337. info, normalizedEvent, err := s.eventInfo(input.Event)
  338. if err != nil {
  339. return notificationEmailTemplateErr(err)
  340. }
  341. recipient := strings.TrimSpace(input.RecipientEmail)
  342. if recipient == "" {
  343. return nil
  344. }
  345. if info.Optional {
  346. unsubscribed, err := s.IsUnsubscribed(ctx, recipient, normalizedEvent)
  347. if err != nil {
  348. return err
  349. }
  350. if unsubscribed {
  351. slog.Info("notification email suppressed by unsubscribe preference", "event", normalizedEvent, "recipient_hash", notificationEmailHash(recipient))
  352. return nil
  353. }
  354. }
  355. locale := normalizeNotificationLocale(input.Locale)
  356. if strings.TrimSpace(input.Locale) == "" {
  357. locale = s.ResolveRecipientLocale(ctx, input.UserID, recipient)
  358. }
  359. tmpl, err := s.GetTemplate(ctx, normalizedEvent, locale)
  360. if err != nil {
  361. return notificationEmailTemplateErr(err)
  362. }
  363. variables := s.runtimeVariables(ctx, normalizedEvent, locale, input)
  364. rendered, err := renderNotificationEmail(normalizedEvent, tmpl.Subject, tmpl.HTML, variables, input.RawHTMLVariables)
  365. if err != nil {
  366. return notificationEmailTemplateErr(err)
  367. }
  368. deliveryKey := notificationEmailDeliveryKey(normalizedEvent, input.SourceType, input.SourceID, recipient, input.ReminderKey)
  369. if deliveryKey != "" {
  370. sent, err := s.deliveryExists(ctx, deliveryKey, legacyNotificationEmailDeliveryKey(normalizedEvent, input.SourceType, input.SourceID, recipient, input.ReminderKey))
  371. if err != nil {
  372. return err
  373. }
  374. if sent {
  375. return nil
  376. }
  377. }
  378. if s.emailService == nil {
  379. return notificationEmailConfigErr(errors.New("email service is not configured"))
  380. }
  381. if err := s.emailService.SendEmail(ctx, recipient, rendered.Subject, rendered.HTML); err != nil {
  382. return notificationEmailDeliveryErr(err)
  383. }
  384. if deliveryKey != "" {
  385. if err := s.settingRepo.Set(ctx, deliveryKey, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
  386. return err
  387. }
  388. }
  389. return nil
  390. }
  391. func (s *NotificationEmailService) RememberRecipientLocale(ctx context.Context, userID int64, email, acceptLanguage string) {
  392. locale := normalizeNotificationLocale(acceptLanguage)
  393. if strings.TrimSpace(acceptLanguage) == "" || s == nil || s.settingRepo == nil {
  394. return
  395. }
  396. if userID > 0 {
  397. _ = s.settingRepo.Set(ctx, notificationEmailLocaleUserKeyPrefix+strconv.FormatInt(userID, 10), locale)
  398. }
  399. if emailHash := notificationEmailHash(email); emailHash != "" {
  400. _ = s.settingRepo.Set(ctx, notificationEmailLocaleEmailKeyPrefix+emailHash, locale)
  401. }
  402. }
  403. func (s *NotificationEmailService) ResolveRecipientLocale(ctx context.Context, userID int64, email string) string {
  404. if s == nil || s.settingRepo == nil {
  405. return notificationEmailDefaultLocale
  406. }
  407. if userID > 0 {
  408. if locale, err := s.settingRepo.GetValue(ctx, notificationEmailLocaleUserKeyPrefix+strconv.FormatInt(userID, 10)); err == nil && strings.TrimSpace(locale) != "" {
  409. return normalizeNotificationLocale(locale)
  410. }
  411. }
  412. if emailHash := notificationEmailHash(email); emailHash != "" {
  413. if locale, err := s.settingRepo.GetValue(ctx, notificationEmailLocaleEmailKeyPrefix+emailHash); err == nil && strings.TrimSpace(locale) != "" {
  414. return normalizeNotificationLocale(locale)
  415. }
  416. }
  417. return notificationEmailDefaultLocale
  418. }
  419. func (s *NotificationEmailService) IsUnsubscribed(ctx context.Context, email, event string) (bool, error) {
  420. info, normalizedEvent, err := s.eventInfo(event)
  421. if err != nil {
  422. return false, err
  423. }
  424. if !info.Optional {
  425. return false, nil
  426. }
  427. for _, key := range []string{notificationEmailPreferenceKey(normalizedEvent, email), legacyNotificationEmailPreferenceKey(normalizedEvent, email)} {
  428. if strings.TrimSpace(key) == "" {
  429. continue
  430. }
  431. value, err := s.settingRepo.GetValue(ctx, key)
  432. if err == nil {
  433. return strings.EqualFold(strings.TrimSpace(value), "unsubscribed"), nil
  434. }
  435. if !errors.Is(err, ErrSettingNotFound) {
  436. return false, err
  437. }
  438. }
  439. return false, nil
  440. }
  441. func (s *NotificationEmailService) Unsubscribe(ctx context.Context, token string) (NotificationEmailUnsubscribeResult, error) {
  442. claims, err := s.parseUnsubscribeToken(ctx, token)
  443. if err != nil {
  444. return NotificationEmailUnsubscribeResult{}, err
  445. }
  446. info, normalizedEvent, err := s.eventInfo(claims.Event)
  447. if err != nil {
  448. return NotificationEmailUnsubscribeResult{}, err
  449. }
  450. if !info.Optional {
  451. return NotificationEmailUnsubscribeResult{}, fmt.Errorf("%s is transactional and cannot be unsubscribed", normalizedEvent)
  452. }
  453. if err := s.settingRepo.Set(ctx, notificationEmailPreferenceKey(normalizedEvent, claims.Email), "unsubscribed"); err != nil {
  454. return NotificationEmailUnsubscribeResult{}, err
  455. }
  456. return NotificationEmailUnsubscribeResult{Event: normalizedEvent, Email: claims.Email, Done: true}, nil
  457. }
  458. func (s *NotificationEmailService) eventInfo(event string) (NotificationEmailEventInfo, string, error) {
  459. normalized := strings.ToLower(strings.TrimSpace(event))
  460. info, ok := notificationEmailEventDefinitions[normalized]
  461. if !ok {
  462. return NotificationEmailEventInfo{}, "", fmt.Errorf("unsupported email template event: %s", event)
  463. }
  464. return info, normalized, nil
  465. }
  466. func (s *NotificationEmailService) sampleVariables(ctx context.Context, event, locale string) map[string]string {
  467. info := notificationEmailEventDefinitions[event]
  468. variables := make(map[string]string, len(info.Placeholders))
  469. for key, value := range notificationEmailSampleVariables(locale) {
  470. variables[key] = value
  471. }
  472. variables["site_name"] = s.siteName(ctx)
  473. if variables["unsubscribe_url"] == "" && info.Optional {
  474. variables["unsubscribe_url"] = "https://example.com/unsubscribe"
  475. }
  476. return variables
  477. }
  478. func (s *NotificationEmailService) runtimeVariables(ctx context.Context, event, locale string, input NotificationEmailSendInput) map[string]string {
  479. variables := s.sampleVariables(ctx, event, locale)
  480. for key, value := range input.Variables {
  481. variables[key] = value
  482. }
  483. if event == NotificationEmailEventOpsScheduledReport {
  484. // Scheduled reports may be sent by integrations that only provide report_html.
  485. // Do not let preview sample values appear in a live email in that case.
  486. if _, ok := input.Variables["report_html"]; !ok {
  487. variables["report_html"] = ""
  488. }
  489. if _, ok := input.Variables["report_detail_display"]; !ok {
  490. // Keep legacy/custom templates useful when they only render report_html.
  491. variables["report_detail_display"] = "block"
  492. }
  493. hasSummaryValues := false
  494. for _, placeholder := range notificationEmailOpsSummaryPlaceholders {
  495. if _, ok := input.Variables[placeholder]; ok {
  496. if placeholder != "report_summary_display" {
  497. hasSummaryValues = true
  498. }
  499. continue
  500. }
  501. variables[placeholder] = "-"
  502. }
  503. if _, ok := input.Variables["report_summary_display"]; !ok {
  504. if hasSummaryValues {
  505. variables["report_summary_display"] = "block"
  506. } else {
  507. variables["report_summary_display"] = "none"
  508. }
  509. }
  510. }
  511. variables["site_name"] = s.siteName(ctx)
  512. variables["recipient_email"] = input.RecipientEmail
  513. if strings.TrimSpace(input.RecipientName) != "" {
  514. variables["recipient_name"] = input.RecipientName
  515. }
  516. if notificationEmailEventDefinitions[event].Optional {
  517. if unsubscribeURL, err := s.buildUnsubscribeURL(ctx, input.RecipientEmail, event); err == nil {
  518. variables["unsubscribe_url"] = unsubscribeURL
  519. }
  520. }
  521. return variables
  522. }
  523. func (s *NotificationEmailService) siteName(ctx context.Context) string {
  524. if s == nil || s.settingRepo == nil {
  525. return defaultSiteName
  526. }
  527. name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName)
  528. if err != nil || strings.TrimSpace(name) == "" {
  529. return defaultSiteName
  530. }
  531. return strings.TrimSpace(name)
  532. }
  533. func (s *NotificationEmailService) baseURL(ctx context.Context) string {
  534. if s == nil || s.settingRepo == nil {
  535. return ""
  536. }
  537. for _, key := range []string{SettingKeyAPIBaseURL, SettingKeyFrontendURL} {
  538. value, err := s.settingRepo.GetValue(ctx, key)
  539. if err == nil && strings.TrimSpace(value) != "" {
  540. return strings.TrimRight(strings.TrimSpace(value), "/")
  541. }
  542. }
  543. return ""
  544. }
  545. func (s *NotificationEmailService) buildUnsubscribeURL(ctx context.Context, email, event string) (string, error) {
  546. token, err := s.createUnsubscribeToken(ctx, email, event)
  547. if err != nil {
  548. return "", err
  549. }
  550. path := "/api/v1/settings/email-unsubscribe?token=" + url.QueryEscape(token)
  551. baseURL := s.baseURL(ctx)
  552. if baseURL == "" {
  553. return path, nil
  554. }
  555. return baseURL + path, nil
  556. }
  557. func (s *NotificationEmailService) createUnsubscribeToken(ctx context.Context, email, event string) (string, error) {
  558. secret, err := s.unsubscribeSecret(ctx)
  559. if err != nil {
  560. return "", err
  561. }
  562. claims := notificationEmailUnsubscribeClaims{Email: strings.TrimSpace(email), Event: event, Exp: time.Now().Add(notificationEmailUnsubscribeTTL).Unix()}
  563. payload, err := json.Marshal(claims)
  564. if err != nil {
  565. return "", err
  566. }
  567. encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
  568. signature := signNotificationEmailToken(secret, encodedPayload)
  569. return encodedPayload + "." + signature, nil
  570. }
  571. func (s *NotificationEmailService) parseUnsubscribeToken(ctx context.Context, token string) (notificationEmailUnsubscribeClaims, error) {
  572. parts := strings.Split(strings.TrimSpace(token), ".")
  573. if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
  574. return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token")
  575. }
  576. secret, err := s.unsubscribeSecret(ctx)
  577. if err != nil {
  578. return notificationEmailUnsubscribeClaims{}, err
  579. }
  580. expected := signNotificationEmailToken(secret, parts[0])
  581. if !hmac.Equal([]byte(expected), []byte(parts[1])) {
  582. return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token signature")
  583. }
  584. payload, err := base64.RawURLEncoding.DecodeString(parts[0])
  585. if err != nil {
  586. return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token payload")
  587. }
  588. var claims notificationEmailUnsubscribeClaims
  589. if err := json.Unmarshal(payload, &claims); err != nil {
  590. return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token payload")
  591. }
  592. if strings.TrimSpace(claims.Email) == "" || strings.TrimSpace(claims.Event) == "" {
  593. return notificationEmailUnsubscribeClaims{}, errors.New("invalid unsubscribe token claims")
  594. }
  595. if claims.Exp <= time.Now().Unix() {
  596. return notificationEmailUnsubscribeClaims{}, errors.New("unsubscribe token expired")
  597. }
  598. return claims, nil
  599. }
  600. func (s *NotificationEmailService) unsubscribeSecret(ctx context.Context) (string, error) {
  601. secret, err := s.settingRepo.GetValue(ctx, notificationEmailUnsubscribeSecretKey)
  602. if err == nil && strings.TrimSpace(secret) != "" {
  603. return strings.TrimSpace(secret), nil
  604. }
  605. if err != nil && !errors.Is(err, ErrSettingNotFound) {
  606. return "", err
  607. }
  608. buf := make([]byte, 32)
  609. if _, err := rand.Read(buf); err != nil {
  610. return "", err
  611. }
  612. secret = base64.RawURLEncoding.EncodeToString(buf)
  613. if err := s.settingRepo.Set(ctx, notificationEmailUnsubscribeSecretKey, secret); err != nil {
  614. return "", err
  615. }
  616. return secret, nil
  617. }
  618. func (s *NotificationEmailService) deliveryExists(ctx context.Context, keys ...string) (bool, error) {
  619. for _, key := range keys {
  620. if strings.TrimSpace(key) == "" {
  621. continue
  622. }
  623. _, err := s.settingRepo.GetValue(ctx, key)
  624. if err == nil {
  625. return true, nil
  626. }
  627. if !errors.Is(err, ErrSettingNotFound) {
  628. return false, err
  629. }
  630. }
  631. return false, nil
  632. }
  633. func validateNotificationEmailTemplate(event, subject, htmlBody string) error {
  634. subject = strings.TrimSpace(subject)
  635. if subject == "" {
  636. return errors.New("email subject cannot be empty")
  637. }
  638. if len([]rune(subject)) > notificationEmailMaxSubjectLength {
  639. return fmt.Errorf("email subject cannot exceed %d characters", notificationEmailMaxSubjectLength)
  640. }
  641. if strings.TrimSpace(htmlBody) == "" {
  642. return errors.New("email html cannot be empty")
  643. }
  644. if len([]byte(htmlBody)) > notificationEmailMaxHTMLLength {
  645. return fmt.Errorf("email html cannot exceed %d bytes", notificationEmailMaxHTMLLength)
  646. }
  647. allowed := notificationEmailAllowedPlaceholderSet(event)
  648. for _, placeholder := range notificationEmailPlaceholdersIn(subject + "\n" + htmlBody) {
  649. if _, ok := allowed[placeholder]; !ok {
  650. return fmt.Errorf("unsupported placeholder {{%s}} for event %s", placeholder, event)
  651. }
  652. }
  653. return nil
  654. }
  655. func renderNotificationEmail(event, subject, htmlBody string, variables map[string]string, rawHTMLVariables map[string]string) (NotificationEmailPreview, error) {
  656. if err := validateNotificationEmailTemplate(event, subject, htmlBody); err != nil {
  657. return NotificationEmailPreview{}, err
  658. }
  659. renderedSubject, err := renderNotificationEmailString(event, subject, variables, nil, false)
  660. if err != nil {
  661. return NotificationEmailPreview{}, err
  662. }
  663. renderedHTML, err := renderNotificationEmailString(event, htmlBody, variables, rawHTMLVariables, true)
  664. if err != nil {
  665. return NotificationEmailPreview{}, err
  666. }
  667. return NotificationEmailPreview{Subject: sanitizeEmailHeader(renderedSubject), HTML: renderedHTML}, nil
  668. }
  669. func renderNotificationEmailString(event, raw string, variables map[string]string, rawHTMLVariables map[string]string, escapeHTML bool) (string, error) {
  670. allowed := notificationEmailAllowedPlaceholderSet(event)
  671. var renderErr error
  672. rendered := notificationEmailPlaceholderPattern.ReplaceAllStringFunc(raw, func(match string) string {
  673. if renderErr != nil {
  674. return ""
  675. }
  676. parts := notificationEmailPlaceholderPattern.FindStringSubmatch(match)
  677. if len(parts) != 2 {
  678. return ""
  679. }
  680. name := parts[1]
  681. if _, ok := allowed[name]; !ok {
  682. renderErr = fmt.Errorf("unsupported placeholder {{%s}} for event %s", name, event)
  683. return ""
  684. }
  685. value := variables[name]
  686. if escapeHTML && notificationEmailRawHTMLAllowed(event, name) {
  687. if rawHTMLVariables != nil {
  688. if rawValue, ok := rawHTMLVariables[name]; ok {
  689. return rawValue
  690. }
  691. }
  692. }
  693. if strings.HasSuffix(name, "_url") && !isSafeNotificationEmailURL(value) {
  694. value = ""
  695. }
  696. if escapeHTML {
  697. return html.EscapeString(value)
  698. }
  699. return sanitizeEmailHeader(value)
  700. })
  701. if renderErr != nil {
  702. return "", renderErr
  703. }
  704. return rendered, nil
  705. }
  706. func notificationEmailRawHTMLAllowed(event, placeholder string) bool {
  707. return event == NotificationEmailEventOpsScheduledReport && placeholder == "report_html"
  708. }
  709. func notificationEmailAllowedPlaceholderSet(event string) map[string]struct{} {
  710. info := notificationEmailEventDefinitions[event]
  711. allowed := make(map[string]struct{}, len(info.Placeholders))
  712. for _, placeholder := range info.Placeholders {
  713. allowed[placeholder] = struct{}{}
  714. }
  715. return allowed
  716. }
  717. func notificationEmailPlaceholdersIn(raw string) []string {
  718. matches := notificationEmailPlaceholderPattern.FindAllStringSubmatch(raw, -1)
  719. seen := make(map[string]struct{}, len(matches))
  720. out := make([]string, 0, len(matches))
  721. for _, match := range matches {
  722. if len(match) != 2 {
  723. continue
  724. }
  725. if _, exists := seen[match[1]]; exists {
  726. continue
  727. }
  728. seen[match[1]] = struct{}{}
  729. out = append(out, match[1])
  730. }
  731. return out
  732. }
  733. func normalizeNotificationLocale(raw string) string {
  734. trimmed := strings.ToLower(strings.TrimSpace(raw))
  735. if trimmed == "" {
  736. return notificationEmailDefaultLocale
  737. }
  738. for _, part := range strings.Split(trimmed, ",") {
  739. tag := strings.TrimSpace(strings.Split(part, ";")[0])
  740. if strings.HasPrefix(tag, "zh") || tag == "cn" {
  741. return notificationEmailLocaleChinese
  742. }
  743. if strings.HasPrefix(tag, "en") {
  744. return notificationEmailDefaultLocale
  745. }
  746. }
  747. return notificationEmailDefaultLocale
  748. }
  749. func notificationEmailTemplateKey(event, locale string) string {
  750. return notificationEmailTemplateKeyPrefix + event + ":" + locale
  751. }
  752. func notificationEmailPreferenceKey(event, email string) string {
  753. if strings.TrimSpace(event) == "" || strings.TrimSpace(email) == "" {
  754. return ""
  755. }
  756. identity := strings.TrimSpace(event) + "\x00" + strings.ToLower(strings.TrimSpace(email))
  757. return notificationEmailPreferenceKeyPrefix + "v2:" + notificationEmailHash(identity)
  758. }
  759. func legacyNotificationEmailPreferenceKey(event, email string) string {
  760. return notificationEmailPreferenceKeyPrefix + event + ":" + notificationEmailHash(email)
  761. }
  762. func notificationEmailDeliveryKey(event, sourceType, sourceID, recipient, reminderKey string) string {
  763. if strings.TrimSpace(sourceType) == "" || strings.TrimSpace(sourceID) == "" || strings.TrimSpace(recipient) == "" {
  764. return ""
  765. }
  766. identity := strings.Join([]string{
  767. strings.ToLower(strings.TrimSpace(event)),
  768. safeNotificationEmailKeyPart(sourceType),
  769. safeNotificationEmailKeyPart(sourceID),
  770. strings.ToLower(strings.TrimSpace(recipient)),
  771. safeNotificationEmailKeyPart(reminderKey),
  772. }, "\x00")
  773. return notificationEmailDeliveryKeyPrefix + "v2:" + notificationEmailHash(identity)
  774. }
  775. func legacyNotificationEmailDeliveryKey(event, sourceType, sourceID, recipient, reminderKey string) string {
  776. if strings.TrimSpace(sourceType) == "" || strings.TrimSpace(sourceID) == "" || strings.TrimSpace(recipient) == "" {
  777. return ""
  778. }
  779. parts := []string{notificationEmailDeliveryKeyPrefix, event, ":", safeNotificationEmailKeyPart(sourceType), ":", safeNotificationEmailKeyPart(sourceID), ":", notificationEmailHash(recipient)}
  780. if strings.TrimSpace(reminderKey) != "" {
  781. parts = append(parts, ":", safeNotificationEmailKeyPart(reminderKey))
  782. }
  783. return strings.Join(parts, "")
  784. }
  785. func notificationEmailHash(value string) string {
  786. trimmed := strings.ToLower(strings.TrimSpace(value))
  787. if trimmed == "" {
  788. return ""
  789. }
  790. sum := sha256.Sum256([]byte(trimmed))
  791. return hex.EncodeToString(sum[:])
  792. }
  793. func safeNotificationEmailKeyPart(value string) string {
  794. value = strings.ToLower(strings.TrimSpace(value))
  795. var builder strings.Builder
  796. for _, r := range value {
  797. if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.' {
  798. _, _ = builder.WriteRune(r)
  799. } else {
  800. _, _ = builder.WriteRune('_')
  801. }
  802. }
  803. return builder.String()
  804. }
  805. func signNotificationEmailToken(secret, payload string) string {
  806. mac := hmac.New(sha256.New, []byte(secret))
  807. _, _ = mac.Write([]byte(payload))
  808. return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
  809. }
  810. func isSafeNotificationEmailURL(raw string) bool {
  811. trimmed := strings.TrimSpace(raw)
  812. if trimmed == "" {
  813. return true
  814. }
  815. parsed, err := url.Parse(trimmed)
  816. if err != nil {
  817. return false
  818. }
  819. if parsed.IsAbs() {
  820. scheme := strings.ToLower(parsed.Scheme)
  821. return scheme == "http" || scheme == "https" || scheme == "mailto"
  822. }
  823. return strings.HasPrefix(trimmed, "/")
  824. }
  825. func notificationEmailSampleVariables(locale string) map[string]string {
  826. if normalizeNotificationLocale(locale) == notificationEmailLocaleChinese {
  827. variables := map[string]string{
  828. "site_name": defaultSiteName,
  829. "recipient_name": "张三",
  830. "recipient_email": "user@example.com",
  831. "verification_code": "123456",
  832. "expires_in_minutes": "15",
  833. "reset_url": "https://example.com/reset-password?token=preview",
  834. "subscription_group": "Claude Pro",
  835. "subscription_days": "30",
  836. "expiry_time": "2026-06-18 12:00",
  837. "days_remaining": "3",
  838. "current_balance": "12.34",
  839. "threshold": "20.00",
  840. "recharge_url": "https://example.com/recharge",
  841. "recharge_amount": "50.00",
  842. "order_id": "1024",
  843. "unsubscribe_url": "https://example.com/unsubscribe",
  844. "account_id": "1001",
  845. "account_name": "openai-main",
  846. "platform": "openai",
  847. "quota_dimension": "每日额度",
  848. "quota_used": "80.00",
  849. "quota_limit": "100.00",
  850. "quota_remaining": "20.00",
  851. "quota_threshold": "20%",
  852. "triggered_at": "2026-05-20 12:00:00",
  853. "group_name": "默认分组",
  854. "moderation_category": "violence",
  855. "moderation_score": "0.982",
  856. "violation_count": "2",
  857. "ban_threshold": "3",
  858. "rule_name": "错误率过高",
  859. "severity": "critical",
  860. "alert_status": "firing",
  861. "metric_type": "error_rate",
  862. "operator": ">=",
  863. "metric_value": "12.50",
  864. "threshold_value": "10.00",
  865. "alert_description": "最近 10 分钟错误率超过阈值",
  866. "report_name": "日报",
  867. "report_type": "daily_summary",
  868. "report_start_time": "2026-07-18T01:00:26Z",
  869. "report_end_time": "2026-07-19T01:00:26Z",
  870. "report_html": "<h2>日报</h2><p>请求量:2,374</p>",
  871. }
  872. addNotificationEmailOpsSummarySampleVariables(variables)
  873. return variables
  874. }
  875. variables := map[string]string{
  876. "site_name": defaultSiteName,
  877. "recipient_name": "Alex",
  878. "recipient_email": "user@example.com",
  879. "verification_code": "123456",
  880. "expires_in_minutes": "15",
  881. "reset_url": "https://example.com/reset-password?token=preview",
  882. "subscription_group": "Claude Pro",
  883. "subscription_days": "30",
  884. "expiry_time": "2026-06-18 12:00",
  885. "days_remaining": "3",
  886. "current_balance": "12.34",
  887. "threshold": "20.00",
  888. "recharge_url": "https://example.com/recharge",
  889. "recharge_amount": "50.00",
  890. "order_id": "1024",
  891. "unsubscribe_url": "https://example.com/unsubscribe",
  892. "account_id": "1001",
  893. "account_name": "openai-main",
  894. "platform": "openai",
  895. "quota_dimension": "Daily quota",
  896. "quota_used": "80.00",
  897. "quota_limit": "100.00",
  898. "quota_remaining": "20.00",
  899. "quota_threshold": "20%",
  900. "triggered_at": "2026-05-20 12:00:00",
  901. "group_name": "Default group",
  902. "moderation_category": "violence",
  903. "moderation_score": "0.982",
  904. "violation_count": "2",
  905. "ban_threshold": "3",
  906. "rule_name": "High error rate",
  907. "severity": "critical",
  908. "alert_status": "firing",
  909. "metric_type": "error_rate",
  910. "operator": ">=",
  911. "metric_value": "12.50",
  912. "threshold_value": "10.00",
  913. "alert_description": "Error rate exceeded threshold in the last 10 minutes.",
  914. "report_name": "Daily summary",
  915. "report_type": "daily_summary",
  916. "report_start_time": "2026-07-18T01:00:26Z",
  917. "report_end_time": "2026-07-19T01:00:26Z",
  918. "report_html": "<h2>Daily summary</h2><p>Requests: 2,374</p>",
  919. }
  920. addNotificationEmailOpsSummarySampleVariables(variables)
  921. return variables
  922. }
  923. func addNotificationEmailOpsSummarySampleVariables(variables map[string]string) {
  924. variables["report_summary_display"] = "block"
  925. variables["report_detail_display"] = "none"
  926. variables["report_total_requests"] = "2,374"
  927. variables["report_success_count"] = "1,451"
  928. variables["report_sla_error_count"] = "2"
  929. variables["report_business_limited_count"] = "921"
  930. variables["report_sla"] = "99.86%"
  931. variables["report_error_rate"] = "0.14%"
  932. variables["report_upstream_error_rate"] = "0.28%"
  933. variables["report_upstream_error_count_excl_429_529"] = "4"
  934. variables["report_upstream_429_count"] = "0"
  935. variables["report_upstream_529_count"] = "0"
  936. variables["report_latency_p50"] = "8,231 ms"
  937. variables["report_latency_p99"] = "151,260 ms"
  938. variables["report_ttft_p50"] = "1,674 ms"
  939. variables["report_ttft_p99"] = "11,222 ms"
  940. variables["report_tokens"] = "121,550,190"
  941. variables["report_qps_current"] = "0.0"
  942. variables["report_qps_peak"] = "1.2"
  943. variables["report_qps_avg"] = "0.0"
  944. variables["report_tps_current"] = "0.0"
  945. variables["report_tps_peak"] = "133421.2"
  946. variables["report_tps_avg"] = "1406.8"
  947. }
  948. var notificationEmailEventOrder = []string{
  949. NotificationEmailEventAuthVerifyCode,
  950. NotificationEmailEventAuthPasswordReset,
  951. NotificationEmailEventNotificationEmailVerifyCode,
  952. NotificationEmailEventSubscriptionPurchaseSuccess,
  953. NotificationEmailEventSubscriptionExpiryReminder,
  954. NotificationEmailEventBalanceLow,
  955. NotificationEmailEventBalanceRechargeSuccess,
  956. NotificationEmailEventAccountQuotaAlert,
  957. NotificationEmailEventContentModerationViolation,
  958. NotificationEmailEventContentModerationDisabled,
  959. NotificationEmailEventCyberPolicyNotice,
  960. NotificationEmailEventSecurityPolicyNotice,
  961. NotificationEmailEventOpsAlert,
  962. NotificationEmailEventOpsScheduledReport,
  963. }
  964. var notificationEmailEventDefinitions = map[string]NotificationEmailEventInfo{
  965. NotificationEmailEventAuthVerifyCode: {
  966. Event: NotificationEmailEventAuthVerifyCode,
  967. Label: "Email verification code",
  968. Description: "Sent for registration, email binding, OAuth pending email, and TOTP verification flows.",
  969. Category: "auth",
  970. Optional: false,
  971. Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "verification_code", "expires_in_minutes"),
  972. },
  973. NotificationEmailEventAuthPasswordReset: {
  974. Event: NotificationEmailEventAuthPasswordReset,
  975. Label: "Password reset",
  976. Description: "Sent when a user requests a password reset link.",
  977. Category: "auth",
  978. Optional: false,
  979. Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "reset_url", "expires_in_minutes"),
  980. },
  981. NotificationEmailEventNotificationEmailVerifyCode: {
  982. Event: NotificationEmailEventNotificationEmailVerifyCode,
  983. Label: "Notification email verification code",
  984. Description: "Sent when a user verifies an extra notification email address.",
  985. Category: "auth",
  986. Optional: false,
  987. Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "verification_code", "expires_in_minutes"),
  988. },
  989. NotificationEmailEventSubscriptionPurchaseSuccess: {
  990. Event: NotificationEmailEventSubscriptionPurchaseSuccess,
  991. Label: "Subscription purchase success",
  992. Description: "Sent after a subscription purchase is fulfilled.",
  993. Category: "subscription",
  994. Optional: false,
  995. Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "subscription_group", "subscription_days", "expiry_time", "order_id"),
  996. },
  997. NotificationEmailEventSubscriptionExpiryReminder: {
  998. Event: NotificationEmailEventSubscriptionExpiryReminder,
  999. Label: "Subscription expiry reminder",
  1000. Description: "Optional reminder sent before an active subscription expires.",
  1001. Category: "subscription",
  1002. Optional: true,
  1003. Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "subscription_group", "expiry_time", "days_remaining", "unsubscribe_url"),
  1004. },
  1005. NotificationEmailEventBalanceLow: {
  1006. Event: NotificationEmailEventBalanceLow,
  1007. Label: "Low balance alert",
  1008. Description: "Optional alert sent when balance crosses the configured low-balance threshold.",
  1009. Category: "billing",
  1010. Optional: true,
  1011. Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "current_balance", "threshold", "recharge_url", "unsubscribe_url"),
  1012. },
  1013. NotificationEmailEventBalanceRechargeSuccess: {
  1014. Event: NotificationEmailEventBalanceRechargeSuccess,
  1015. Label: "Balance recharge success",
  1016. Description: "Sent after a balance recharge order is fulfilled.",
  1017. Category: "billing",
  1018. Optional: false,
  1019. Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...), "recharge_amount", "current_balance", "order_id"),
  1020. },
  1021. NotificationEmailEventAccountQuotaAlert: {
  1022. Event: NotificationEmailEventAccountQuotaAlert,
  1023. Label: "Account quota alert",
  1024. Description: "Sent to configured admin notification emails when an upstream account quota threshold is crossed.",
  1025. Category: "admin",
  1026. Optional: false,
  1027. Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...),
  1028. "account_id", "account_name", "platform", "quota_dimension", "quota_used", "quota_limit", "quota_remaining", "quota_threshold"),
  1029. },
  1030. NotificationEmailEventContentModerationViolation: {
  1031. Event: NotificationEmailEventContentModerationViolation,
  1032. Label: "Risk control violation notice",
  1033. Description: "Sent to users when a request triggers content moderation/risk control rules.",
  1034. Category: "risk_control",
  1035. Optional: false,
  1036. Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...),
  1037. "triggered_at", "group_name", "moderation_category", "moderation_score", "violation_count", "ban_threshold"),
  1038. },
  1039. NotificationEmailEventContentModerationDisabled: {
  1040. Event: NotificationEmailEventContentModerationDisabled,
  1041. Label: "Risk control account disabled",
  1042. Description: "Sent to users when content moderation automatically disables their account.",
  1043. Category: "risk_control",
  1044. Optional: false,
  1045. Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...),
  1046. "triggered_at", "group_name", "moderation_category", "moderation_score", "violation_count", "ban_threshold"),
  1047. },
  1048. NotificationEmailEventCyberPolicyNotice: {
  1049. Event: NotificationEmailEventCyberPolicyNotice,
  1050. Label: "Cyber policy notice",
  1051. Description: "Sent to users when an upstream request is blocked by cyber-security policy (cyber_policy).",
  1052. Category: "risk_control",
  1053. Optional: false,
  1054. Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...),
  1055. "triggered_at", "model", "group_name", "upstream_message"),
  1056. },
  1057. NotificationEmailEventSecurityPolicyNotice: {
  1058. Event: NotificationEmailEventSecurityPolicyNotice,
  1059. Label: "Group security policy notice",
  1060. Description: "Sent to users when a request hits the group security policy (sensitive topics) and is blocked or its session terminated.",
  1061. Category: "risk_control",
  1062. Optional: false,
  1063. Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...),
  1064. "triggered_at", "model", "group_name", "matched_keyword"),
  1065. },
  1066. NotificationEmailEventOpsAlert: {
  1067. Event: NotificationEmailEventOpsAlert,
  1068. Label: "Ops alert",
  1069. Description: "Sent to configured operations recipients when an ops alert rule fires.",
  1070. Category: "ops",
  1071. Optional: false,
  1072. Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...),
  1073. "rule_name", "severity", "alert_status", "metric_type", "operator", "metric_value", "threshold_value", "triggered_at", "alert_description"),
  1074. },
  1075. NotificationEmailEventOpsScheduledReport: {
  1076. Event: NotificationEmailEventOpsScheduledReport,
  1077. Label: "Ops scheduled report",
  1078. Description: "Sent to configured operations recipients for scheduled daily/weekly/error/account-health reports.",
  1079. Category: "ops",
  1080. Optional: false,
  1081. Placeholders: append(
  1082. append(
  1083. append([]string{}, notificationEmailCommonPlaceholders...),
  1084. "report_name", "report_type", "report_start_time", "report_end_time",
  1085. ),
  1086. append(append([]string{}, notificationEmailOpsSummaryPlaceholders...), "report_detail_display", "report_html")...,
  1087. ),
  1088. },
  1089. }
  1090. var notificationEmailOfficialTemplates = map[string]map[string]notificationEmailOfficialTemplate{
  1091. NotificationEmailEventAuthVerifyCode: {
  1092. notificationEmailDefaultLocale: {
  1093. Subject: "[{{site_name}}] Email verification code",
  1094. HTML: notificationEmailCard("#4f46e5", "Email verification code", `
  1095. <p>Hello {{recipient_name}},</p>
  1096. <p>Your verification code is:</p>
  1097. <p style="font-size: 32px; font-weight: 700; letter-spacing: 8px; text-align: center;">{{verification_code}}</p>
  1098. <p>This code expires in <strong>{{expires_in_minutes}}</strong> minutes.</p>
  1099. <p>If you did not request this code, please ignore this email.</p>`),
  1100. },
  1101. notificationEmailLocaleChinese: {
  1102. Subject: "[{{site_name}}] 邮箱验证码",
  1103. HTML: notificationEmailCard("#4f46e5", "邮箱验证码", `
  1104. <p>{{recipient_name}},您好:</p>
  1105. <p>您的验证码是:</p>
  1106. <p style="font-size: 32px; font-weight: 700; letter-spacing: 8px; text-align: center;">{{verification_code}}</p>
  1107. <p>验证码将在 <strong>{{expires_in_minutes}}</strong> 分钟后失效。</p>
  1108. <p>如果不是您本人操作,请忽略此邮件。</p>`),
  1109. },
  1110. },
  1111. NotificationEmailEventAuthPasswordReset: {
  1112. notificationEmailDefaultLocale: {
  1113. Subject: "[{{site_name}}] Password reset request",
  1114. HTML: notificationEmailCard("#7c3aed", "Password reset", `
  1115. <p>Hello {{recipient_name}},</p>
  1116. <p>We received a request to reset your password. Click the button below to set a new password.</p>
  1117. <p><a class="button" href="{{reset_url}}">Reset password</a></p>
  1118. <p>This link expires in <strong>{{expires_in_minutes}}</strong> minutes.</p>
  1119. <p class="muted">If the button does not work, copy this link into your browser:<br>{{reset_url}}</p>
  1120. <p>If you did not request this, you can safely ignore this email.</p>`),
  1121. },
  1122. notificationEmailLocaleChinese: {
  1123. Subject: "[{{site_name}}] 密码重置请求",
  1124. HTML: notificationEmailCard("#7c3aed", "密码重置", `
  1125. <p>{{recipient_name}},您好:</p>
  1126. <p>我们收到了您的密码重置请求,请点击下方按钮设置新密码。</p>
  1127. <p><a class="button" href="{{reset_url}}">重置密码</a></p>
  1128. <p>此链接将在 <strong>{{expires_in_minutes}}</strong> 分钟后失效。</p>
  1129. <p class="muted">如果按钮无法点击,请复制以下链接到浏览器中打开:<br>{{reset_url}}</p>
  1130. <p>如果不是您本人操作,请忽略此邮件。</p>`),
  1131. },
  1132. },
  1133. NotificationEmailEventNotificationEmailVerifyCode: {
  1134. notificationEmailDefaultLocale: {
  1135. Subject: "[{{site_name}}] Notification email verification code",
  1136. HTML: notificationEmailCard("#0ea5e9", "Notification email verification", `
  1137. <p>Hello {{recipient_name}},</p>
  1138. <p>You are adding this address as an extra notification email.</p>
  1139. <p>Your verification code is:</p>
  1140. <p style="font-size: 32px; font-weight: 700; letter-spacing: 8px; text-align: center;">{{verification_code}}</p>
  1141. <p>This code expires in <strong>{{expires_in_minutes}}</strong> minutes.</p>
  1142. <p>If you did not request this code, please ignore this email.</p>`),
  1143. },
  1144. notificationEmailLocaleChinese: {
  1145. Subject: "[{{site_name}}] 通知邮箱验证码",
  1146. HTML: notificationEmailCard("#0ea5e9", "通知邮箱验证", `
  1147. <p>{{recipient_name}},您好:</p>
  1148. <p>您正在添加额外的通知邮箱,请输入以下验证码完成验证。</p>
  1149. <p style="font-size: 32px; font-weight: 700; letter-spacing: 8px; text-align: center;">{{verification_code}}</p>
  1150. <p>验证码将在 <strong>{{expires_in_minutes}}</strong> 分钟后失效。</p>
  1151. <p>如果不是您本人操作,请忽略此邮件。</p>`),
  1152. },
  1153. },
  1154. NotificationEmailEventSubscriptionPurchaseSuccess: {
  1155. notificationEmailDefaultLocale: {
  1156. Subject: "[{{site_name}}] Subscription purchase successful",
  1157. HTML: notificationEmailCard("#2563eb", "Subscription activated", `
  1158. <p>Hello {{recipient_name}},</p>
  1159. <p>Your subscription for <strong>{{subscription_group}}</strong> has been activated for <strong>{{subscription_days}}</strong> days.</p>
  1160. <p>Expiry time: <strong>{{expiry_time}}</strong></p>
  1161. <p>Order ID: {{order_id}}</p>`),
  1162. },
  1163. notificationEmailLocaleChinese: {
  1164. Subject: "[{{site_name}}] 订阅购买成功",
  1165. HTML: notificationEmailCard("#2563eb", "订阅已开通", `
  1166. <p>{{recipient_name}},您好:</p>
  1167. <p>您的 <strong>{{subscription_group}}</strong> 订阅已成功开通,有效期 <strong>{{subscription_days}}</strong> 天。</p>
  1168. <p>到期时间:<strong>{{expiry_time}}</strong></p>
  1169. <p>订单号:{{order_id}}</p>`),
  1170. },
  1171. },
  1172. NotificationEmailEventSubscriptionExpiryReminder: {
  1173. notificationEmailDefaultLocale: {
  1174. Subject: "[{{site_name}}] Subscription expires in {{days_remaining}} day(s)",
  1175. HTML: notificationEmailCard("#f97316", "Subscription expiry reminder", `
  1176. <p>Hello {{recipient_name}},</p>
  1177. <p>Your <strong>{{subscription_group}}</strong> subscription will expire in <strong>{{days_remaining}}</strong> day(s).</p>
  1178. <p>Expiry time: <strong>{{expiry_time}}</strong></p>
  1179. <p class="muted"><a href="{{unsubscribe_url}}">Unsubscribe from optional subscription reminders</a></p>`),
  1180. },
  1181. notificationEmailLocaleChinese: {
  1182. Subject: "[{{site_name}}] 订阅将在 {{days_remaining}} 天后到期",
  1183. HTML: notificationEmailCard("#f97316", "订阅到期提醒", `
  1184. <p>{{recipient_name}},您好:</p>
  1185. <p>您的 <strong>{{subscription_group}}</strong> 订阅将在 <strong>{{days_remaining}}</strong> 天后到期。</p>
  1186. <p>到期时间:<strong>{{expiry_time}}</strong></p>
  1187. <p class="muted"><a href="{{unsubscribe_url}}">退订此类订阅提醒</a></p>`),
  1188. },
  1189. },
  1190. NotificationEmailEventBalanceLow: {
  1191. notificationEmailDefaultLocale: {
  1192. Subject: "[{{site_name}}] Low balance alert",
  1193. HTML: notificationEmailCard("#d97706", "Low balance alert", `
  1194. <p>Hello {{recipient_name}},</p>
  1195. <p>Your current balance is <strong>${{current_balance}}</strong>, below the configured alert threshold of <strong>${{threshold}}</strong>.</p>
  1196. <p>Please recharge in time to avoid service interruption.</p>
  1197. <p><a class="button" href="{{recharge_url}}">Recharge now</a></p>
  1198. <p class="muted"><a href="{{unsubscribe_url}}">Unsubscribe from optional balance alerts</a></p>`),
  1199. },
  1200. notificationEmailLocaleChinese: {
  1201. Subject: "[{{site_name}}] 余额不足提醒",
  1202. HTML: notificationEmailCard("#d97706", "余额不足提醒", `
  1203. <p>{{recipient_name}},您好:</p>
  1204. <p>您当前余额为 <strong>${{current_balance}}</strong>,已低于提醒阈值 <strong>${{threshold}}</strong>。</p>
  1205. <p>请及时充值以免服务中断。</p>
  1206. <p><a class="button" href="{{recharge_url}}">立即充值</a></p>
  1207. <p class="muted"><a href="{{unsubscribe_url}}">退订此类余额提醒</a></p>`),
  1208. },
  1209. },
  1210. NotificationEmailEventBalanceRechargeSuccess: {
  1211. notificationEmailDefaultLocale: {
  1212. Subject: "[{{site_name}}] Balance recharge successful",
  1213. HTML: notificationEmailCard("#16a34a", "Recharge successful", `
  1214. <p>Hello {{recipient_name}},</p>
  1215. <p>Your balance recharge of <strong>${{recharge_amount}}</strong> has been completed.</p>
  1216. <p>Current balance: <strong>${{current_balance}}</strong></p>
  1217. <p>Order ID: {{order_id}}</p>`),
  1218. },
  1219. notificationEmailLocaleChinese: {
  1220. Subject: "[{{site_name}}] 余额充值成功",
  1221. HTML: notificationEmailCard("#16a34a", "余额充值成功", `
  1222. <p>{{recipient_name}},您好:</p>
  1223. <p>您的余额充值 <strong>${{recharge_amount}}</strong> 已完成。</p>
  1224. <p>当前余额:<strong>${{current_balance}}</strong></p>
  1225. <p>订单号:{{order_id}}</p>`),
  1226. },
  1227. },
  1228. NotificationEmailEventAccountQuotaAlert: {
  1229. notificationEmailDefaultLocale: {
  1230. Subject: "[{{site_name}}] Account quota alert - {{account_name}}",
  1231. HTML: notificationEmailCard("#dc2626", "Account quota alert", `
  1232. <p>The upstream account <strong>{{account_name}}</strong> has crossed its configured quota alert threshold.</p>
  1233. <table style="width:100%;border-collapse:collapse;">
  1234. <tr><td>Account ID</td><td>{{account_id}}</td></tr>
  1235. <tr><td>Platform</td><td>{{platform}}</td></tr>
  1236. <tr><td>Dimension</td><td>{{quota_dimension}}</td></tr>
  1237. <tr><td>Used / Limit</td><td>{{quota_used}} / {{quota_limit}}</td></tr>
  1238. <tr><td>Remaining</td><td>{{quota_remaining}}</td></tr>
  1239. <tr><td>Threshold</td><td>{{quota_threshold}}</td></tr>
  1240. </table>`),
  1241. },
  1242. notificationEmailLocaleChinese: {
  1243. Subject: "[{{site_name}}] 账号限额告警 - {{account_name}}",
  1244. HTML: notificationEmailCard("#dc2626", "账号限额告警", `
  1245. <p>上游账号 <strong>{{account_name}}</strong> 已触发配置的额度告警阈值。</p>
  1246. <table style="width:100%;border-collapse:collapse;">
  1247. <tr><td>账号 ID</td><td>{{account_id}}</td></tr>
  1248. <tr><td>平台</td><td>{{platform}}</td></tr>
  1249. <tr><td>维度</td><td>{{quota_dimension}}</td></tr>
  1250. <tr><td>已用 / 限额</td><td>{{quota_used}} / {{quota_limit}}</td></tr>
  1251. <tr><td>剩余额度</td><td>{{quota_remaining}}</td></tr>
  1252. <tr><td>告警阈值</td><td>{{quota_threshold}}</td></tr>
  1253. </table>`),
  1254. },
  1255. },
  1256. NotificationEmailEventContentModerationViolation: {
  1257. notificationEmailDefaultLocale: {
  1258. Subject: "[{{site_name}}] Risk control notice",
  1259. HTML: notificationEmailCard("#ef4444", "Risk control notice", `
  1260. <p>Hello {{recipient_name}},</p>
  1261. <p>Your API request triggered the platform content moderation/risk-control policy.</p>
  1262. <table style="width:100%;border-collapse:collapse;">
  1263. <tr><td>Triggered at</td><td>{{triggered_at}}</td></tr>
  1264. <tr><td>Group</td><td>{{group_name}}</td></tr>
  1265. <tr><td>Category / Score</td><td>{{moderation_category}} / {{moderation_score}}</td></tr>
  1266. <tr><td>Violation count</td><td>{{violation_count}} / {{ban_threshold}}</td></tr>
  1267. </table>
  1268. <p>Please review your request content to avoid future service interruptions.</p>`),
  1269. },
  1270. notificationEmailLocaleChinese: {
  1271. Subject: "[{{site_name}}] 账户风控提醒",
  1272. HTML: notificationEmailCard("#ef4444", "账户风控提醒", `
  1273. <p>{{recipient_name}},您好:</p>
  1274. <p>您的 API 请求触发了平台内容审核/风控策略。</p>
  1275. <table style="width:100%;border-collapse:collapse;">
  1276. <tr><td>触发时间</td><td>{{triggered_at}}</td></tr>
  1277. <tr><td>所属分组</td><td>{{group_name}}</td></tr>
  1278. <tr><td>命中类别 / 分数</td><td>{{moderation_category}} / {{moderation_score}}</td></tr>
  1279. <tr><td>累计触发次数</td><td>{{violation_count}} / {{ban_threshold}}</td></tr>
  1280. </table>
  1281. <p>请检查请求内容,避免后续服务受到影响。</p>`),
  1282. },
  1283. },
  1284. NotificationEmailEventContentModerationDisabled: {
  1285. notificationEmailDefaultLocale: {
  1286. Subject: "[{{site_name}}] Account disabled by risk control",
  1287. HTML: notificationEmailCard("#b91c1c", "Account disabled", `
  1288. <p>Hello {{recipient_name}},</p>
  1289. <p>Your account has repeatedly triggered platform content moderation/risk-control rules and has been automatically disabled.</p>
  1290. <table style="width:100%;border-collapse:collapse;">
  1291. <tr><td>Disabled at</td><td>{{triggered_at}}</td></tr>
  1292. <tr><td>Group</td><td>{{group_name}}</td></tr>
  1293. <tr><td>Category / Score</td><td>{{moderation_category}} / {{moderation_score}}</td></tr>
  1294. <tr><td>Violation count</td><td>{{violation_count}} / {{ban_threshold}}</td></tr>
  1295. </table>
  1296. <p>Please contact the administrator if you need to appeal or restore access.</p>`),
  1297. },
  1298. notificationEmailLocaleChinese: {
  1299. Subject: "[{{site_name}}] 账户已被禁用",
  1300. HTML: notificationEmailCard("#b91c1c", "账户已被禁用", `
  1301. <p>{{recipient_name}},您好:</p>
  1302. <p>您的账户在统计周期内多次触发平台内容审核/风控规则,系统已自动禁用该账户。</p>
  1303. <table style="width:100%;border-collapse:collapse;">
  1304. <tr><td>禁用时间</td><td>{{triggered_at}}</td></tr>
  1305. <tr><td>所属分组</td><td>{{group_name}}</td></tr>
  1306. <tr><td>命中类别 / 分数</td><td>{{moderation_category}} / {{moderation_score}}</td></tr>
  1307. <tr><td>累计触发次数</td><td>{{violation_count}} / {{ban_threshold}}</td></tr>
  1308. </table>
  1309. <p>如需申诉或恢复账号,请联系平台管理员处理。</p>`),
  1310. },
  1311. },
  1312. NotificationEmailEventCyberPolicyNotice: {
  1313. notificationEmailDefaultLocale: {
  1314. Subject: "[{{site_name}}] Cyber-security policy notice",
  1315. HTML: notificationEmailCard("#ef4444", "Cyber-security policy notice", `
  1316. <p>Hello {{recipient_name}},</p>
  1317. <p>Your request was blocked by the upstream provider's cyber-security policy.</p>
  1318. <table style="width:100%;border-collapse:collapse;table-layout:fixed;">
  1319. <tr><td style="width:128px;vertical-align:top;">Triggered at</td><td style="overflow-wrap:anywhere;word-break:break-word;">{{triggered_at}}</td></tr>
  1320. <tr><td style="width:128px;vertical-align:top;">Model</td><td style="overflow-wrap:anywhere;word-break:break-word;">{{model}}</td></tr>
  1321. <tr><td style="width:128px;vertical-align:top;">Group</td><td style="overflow-wrap:anywhere;word-break:break-word;">{{group_name}}</td></tr>
  1322. <tr><td style="width:128px;vertical-align:top;">Upstream message</td><td style="overflow-wrap:anywhere;word-break:break-all;white-space:pre-wrap;">{{upstream_message}}</td></tr>
  1323. </table>
  1324. <p>If you believe this is a mistake, try rephrasing your request, or apply for authorized security access.</p>`),
  1325. },
  1326. notificationEmailLocaleChinese: {
  1327. Subject: "[{{site_name}}] 网络安全策略拦截提醒",
  1328. HTML: notificationEmailCard("#ef4444", "网络安全策略拦截提醒", `
  1329. <p>{{recipient_name}},您好:</p>
  1330. <p>您的请求被上游服务商的网络安全策略(cyber policy)拦截。</p>
  1331. <table style="width:100%;border-collapse:collapse;table-layout:fixed;">
  1332. <tr><td style="width:128px;vertical-align:top;">触发时间</td><td style="overflow-wrap:anywhere;word-break:break-word;">{{triggered_at}}</td></tr>
  1333. <tr><td style="width:128px;vertical-align:top;">模型</td><td style="overflow-wrap:anywhere;word-break:break-word;">{{model}}</td></tr>
  1334. <tr><td style="width:128px;vertical-align:top;">所属分组</td><td style="overflow-wrap:anywhere;word-break:break-word;">{{group_name}}</td></tr>
  1335. <tr><td style="width:128px;vertical-align:top;">上游说明</td><td style="overflow-wrap:anywhere;word-break:break-all;white-space:pre-wrap;">{{upstream_message}}</td></tr>
  1336. </table>
  1337. <p>如认为系误判,可调整请求措辞后重试,或申请获得授权的安全访问权限。</p>`),
  1338. },
  1339. },
  1340. NotificationEmailEventSecurityPolicyNotice: {
  1341. notificationEmailDefaultLocale: {
  1342. Subject: "[{{site_name}}] Security policy notice",
  1343. HTML: notificationEmailCard("#ef4444", "Security policy notice", `
  1344. <p>Hello {{recipient_name}},</p>
  1345. <p>Your request hit the group security policy (sensitive topics) and was blocked.</p>
  1346. <table style="width:100%;border-collapse:collapse;table-layout:fixed;">
  1347. <tr><td style="width:128px;vertical-align:top;">Triggered at</td><td style="overflow-wrap:anywhere;word-break:break-word;">{{triggered_at}}</td></tr>
  1348. <tr><td style="width:128px;vertical-align:top;">Model</td><td style="overflow-wrap:anywhere;word-break:break-word;">{{model}}</td></tr>
  1349. <tr><td style="width:128px;vertical-align:top;">Group</td><td style="overflow-wrap:anywhere;word-break:break-word;">{{group_name}}</td></tr>
  1350. <tr><td style="width:128px;vertical-align:top;">Matched keyword</td><td style="overflow-wrap:anywhere;word-break:break-all;white-space:pre-wrap;">{{matched_keyword}}</td></tr>
  1351. </table>
  1352. <p>If the session was terminated, please start a new session. If you believe this is a mistake, contact the administrator.</p>`),
  1353. },
  1354. notificationEmailLocaleChinese: {
  1355. Subject: "[{{site_name}}] 安全策略提醒",
  1356. HTML: notificationEmailCard("#ef4444", "安全策略提醒", `
  1357. <p>{{recipient_name}},您好:</p>
  1358. <p>您的请求触发了所在分组的安全策略(敏感话题),已被拦截。</p>
  1359. <table style="width:100%;border-collapse:collapse;table-layout:fixed;">
  1360. <tr><td style="width:128px;vertical-align:top;">触发时间</td><td style="overflow-wrap:anywhere;word-break:break-word;">{{triggered_at}}</td></tr>
  1361. <tr><td style="width:128px;vertical-align:top;">模型</td><td style="overflow-wrap:anywhere;word-break:break-word;">{{model}}</td></tr>
  1362. <tr><td style="width:128px;vertical-align:top;">所属分组</td><td style="overflow-wrap:anywhere;word-break:break-word;">{{group_name}}</td></tr>
  1363. <tr><td style="width:128px;vertical-align:top;">命中关键词</td><td style="overflow-wrap:anywhere;word-break:break-all;white-space:pre-wrap;">{{matched_keyword}}</td></tr>
  1364. </table>
  1365. <p>如会话已被终止,请新建会话后继续。如认为系误判,请联系管理员。</p>`),
  1366. },
  1367. },
  1368. NotificationEmailEventOpsAlert: {
  1369. notificationEmailDefaultLocale: {
  1370. Subject: "[Ops Alert][{{severity}}] {{rule_name}}",
  1371. HTML: notificationEmailCard("#ea580c", "Ops alert", `
  1372. <p><strong>Rule</strong>: {{rule_name}}</p>
  1373. <p><strong>Severity</strong>: {{severity}}</p>
  1374. <p><strong>Status</strong>: {{alert_status}}</p>
  1375. <p><strong>Metric</strong>: {{metric_type}} {{operator}} {{metric_value}} (threshold {{threshold_value}})</p>
  1376. <p><strong>Fired at</strong>: {{triggered_at}}</p>
  1377. <p><strong>Description</strong>: {{alert_description}}</p>`),
  1378. },
  1379. notificationEmailLocaleChinese: {
  1380. Subject: "[运维告警][{{severity}}] {{rule_name}}",
  1381. HTML: notificationEmailCard("#ea580c", "运维告警", `
  1382. <p><strong>规则</strong>:{{rule_name}}</p>
  1383. <p><strong>严重级别</strong>:{{severity}}</p>
  1384. <p><strong>状态</strong>:{{alert_status}}</p>
  1385. <p><strong>指标</strong>:{{metric_type}} {{operator}} {{metric_value}}(阈值 {{threshold_value}})</p>
  1386. <p><strong>触发时间</strong>:{{triggered_at}}</p>
  1387. <p><strong>说明</strong>:{{alert_description}}</p>`),
  1388. },
  1389. },
  1390. NotificationEmailEventOpsScheduledReport: {
  1391. notificationEmailDefaultLocale: {
  1392. Subject: "[Ops Report] {{report_name}}",
  1393. HTML: notificationEmailOpsScheduledReportTemplate(notificationEmailDefaultLocale),
  1394. },
  1395. notificationEmailLocaleChinese: {
  1396. Subject: "[运维报表] {{report_name}}",
  1397. HTML: notificationEmailOpsScheduledReportTemplate(notificationEmailLocaleChinese),
  1398. },
  1399. },
  1400. }
  1401. func notificationEmailOpsScheduledReportTemplate(locale string) string {
  1402. if normalizeNotificationLocale(locale) == notificationEmailLocaleChinese {
  1403. return `<!DOCTYPE html>
  1404. <html lang="zh-CN">
  1405. <head>
  1406. <meta charset="UTF-8">
  1407. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  1408. <style>
  1409. body { margin: 0; padding: 24px 12px; background: #f4f6f8; color: #1f2937; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
  1410. .container { width: 100%; max-width: 680px; margin: 0 auto; background: #ffffff; border: 1px solid #dfe7ea; border-radius: 8px; overflow: hidden; }
  1411. .header { padding: 28px 32px 24px; background: #0f766e; color: #ffffff; }
  1412. .eyebrow { margin: 0 0 8px; color: #ccfbf1; font-size: 12px; font-weight: 700; letter-spacing: 0; text-transform: uppercase; }
  1413. h1 { margin: 0; font-size: 26px; line-height: 1.3; }
  1414. .header p { margin: 8px 0 0; color: #e6fffb; font-size: 14px; }
  1415. .content { padding: 28px 32px 32px; }
  1416. .meta { width: 100%; margin: 0 0 20px; border-collapse: collapse; background: #f8fafc; border: 1px solid #e2e8f0; }
  1417. .meta td { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; font-size: 13px; vertical-align: top; }
  1418. .meta tr:last-child td { border-bottom: 0; }
  1419. .meta-label { width: 112px; color: #64748b; font-weight: 600; }
  1420. .section-title { margin: 28px 0 12px; color: #0f172a; font-size: 16px; line-height: 1.4; }
  1421. .metric-grid { width: 100%; border-collapse: separate; border-spacing: 8px; margin: -8px; }
  1422. .metric-cell { width: 50%; padding: 14px 16px; border: 1px solid #e2e8f0; background: #ffffff; vertical-align: top; }
  1423. .metric-label { display: block; color: #64748b; font-size: 12px; line-height: 1.4; }
  1424. .metric-value { display: block; margin-top: 6px; color: #0f172a; font-size: 20px; font-weight: 700; line-height: 1.2; }
  1425. .metric-value.good { color: #15803d; }
  1426. .metric-value.alert { color: #b91c1c; }
  1427. .detail { width: 100%; border-collapse: collapse; }
  1428. .detail td { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; font-size: 13px; }
  1429. .detail td:first-child { width: 56%; color: #475569; }
  1430. .detail td:last-child { color: #0f172a; font-weight: 600; text-align: right; }
  1431. .report-detail { margin-top: 28px; }
  1432. .report-detail:empty { display: none; }
  1433. .footer { padding: 18px 32px; background: #f8fafc; border-top: 1px solid #e2e8f0; color: #64748b; font-size: 12px; line-height: 1.6; }
  1434. @media only screen and (max-width: 620px) {
  1435. body { padding: 0; }
  1436. .container { border: 0; border-radius: 0; }
  1437. .header, .content, .footer { padding-left: 20px; padding-right: 20px; }
  1438. .metric-grid, .metric-grid tbody, .metric-grid tr, .metric-cell { display: block; width: 100% !important; box-sizing: border-box; }
  1439. .metric-cell { margin: 8px 0; }
  1440. }
  1441. </style>
  1442. </head>
  1443. <body>
  1444. <div class="container">
  1445. <div class="header">
  1446. <p class="eyebrow">运维报表</p>
  1447. <h1>{{report_name}}</h1>
  1448. <p>{{site_name}} 的运行概览</p>
  1449. </div>
  1450. <div class="content">
  1451. <table class="meta" role="presentation">
  1452. <tr><td class="meta-label">报表</td><td>{{report_name}}</td></tr>
  1453. <tr><td class="meta-label">类型</td><td>{{report_type}}</td></tr>
  1454. <tr><td class="meta-label">统计周期</td><td>{{report_start_time}} 至 {{report_end_time}} (UTC)</td></tr>
  1455. </table>
  1456. <div style="display: {{report_summary_display}};">
  1457. <h2 class="section-title">请求概览</h2>
  1458. <table class="metric-grid" role="presentation"><tr>
  1459. <td class="metric-cell"><span class="metric-label">总请求数</span><span class="metric-value">{{report_total_requests}}</span></td>
  1460. <td class="metric-cell"><span class="metric-label">成功请求</span><span class="metric-value good">{{report_success_count}}</span></td>
  1461. </tr><tr>
  1462. <td class="metric-cell"><span class="metric-label">SLA 错误</span><span class="metric-value alert">{{report_sla_error_count}}</span></td>
  1463. <td class="metric-cell"><span class="metric-label">业务限流</span><span class="metric-value">{{report_business_limited_count}}</span></td>
  1464. </tr></table>
  1465. <h2 class="section-title">可靠性</h2>
  1466. <table class="detail" role="presentation">
  1467. <tr><td>SLA</td><td>{{report_sla}}</td></tr>
  1468. <tr><td>错误率</td><td>{{report_error_rate}}</td></tr>
  1469. <tr><td>上游错误率(不含 429 / 529)</td><td>{{report_upstream_error_rate}}</td></tr>
  1470. <tr><td>上游错误(不含 429 / 529)</td><td>{{report_upstream_error_count_excl_429_529}}</td></tr>
  1471. <tr><td>上游 429 / 529</td><td>{{report_upstream_429_count}} / {{report_upstream_529_count}}</td></tr>
  1472. </table>
  1473. <h2 class="section-title">延迟表现</h2>
  1474. <table class="detail" role="presentation">
  1475. <tr><td>请求延迟 p50 / p99</td><td>{{report_latency_p50}} / {{report_latency_p99}}</td></tr>
  1476. <tr><td>首 Token 时间 p50 / p99</td><td>{{report_ttft_p50}} / {{report_ttft_p99}}</td></tr>
  1477. </table>
  1478. <h2 class="section-title">吞吐量</h2>
  1479. <table class="detail" role="presentation">
  1480. <tr><td>Token 消耗</td><td>{{report_tokens}}</td></tr>
  1481. <tr><td>QPS(当前 / 峰值 / 平均)</td><td>{{report_qps_current}} / {{report_qps_peak}} / {{report_qps_avg}}</td></tr>
  1482. <tr><td>TPS(当前 / 峰值 / 平均)</td><td>{{report_tps_current}} / {{report_tps_peak}} / {{report_tps_avg}}</td></tr>
  1483. </table>
  1484. </div>
  1485. <div class="report-detail" style="display: {{report_detail_display}};">{{report_html}}</div>
  1486. </div>
  1487. <div class="footer">此邮件由 {{site_name}} 自动发送,请勿直接回复。</div>
  1488. </div>
  1489. </body>
  1490. </html>`
  1491. }
  1492. return `<!DOCTYPE html>
  1493. <html lang="en">
  1494. <head>
  1495. <meta charset="UTF-8">
  1496. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  1497. <style>
  1498. body { margin: 0; padding: 24px 12px; background: #f4f6f8; color: #1f2937; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
  1499. .container { width: 100%; max-width: 680px; margin: 0 auto; background: #ffffff; border: 1px solid #dfe7ea; border-radius: 8px; overflow: hidden; }
  1500. .header { padding: 28px 32px 24px; background: #0f766e; color: #ffffff; }
  1501. .eyebrow { margin: 0 0 8px; color: #ccfbf1; font-size: 12px; font-weight: 700; letter-spacing: 0; text-transform: uppercase; }
  1502. h1 { margin: 0; font-size: 26px; line-height: 1.3; }
  1503. .header p { margin: 8px 0 0; color: #e6fffb; font-size: 14px; }
  1504. .content { padding: 28px 32px 32px; }
  1505. .meta { width: 100%; margin: 0 0 20px; border-collapse: collapse; background: #f8fafc; border: 1px solid #e2e8f0; }
  1506. .meta td { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; font-size: 13px; vertical-align: top; }
  1507. .meta tr:last-child td { border-bottom: 0; }
  1508. .meta-label { width: 112px; color: #64748b; font-weight: 600; }
  1509. .section-title { margin: 28px 0 12px; color: #0f172a; font-size: 16px; line-height: 1.4; }
  1510. .metric-grid { width: 100%; border-collapse: separate; border-spacing: 8px; margin: -8px; }
  1511. .metric-cell { width: 50%; padding: 14px 16px; border: 1px solid #e2e8f0; background: #ffffff; vertical-align: top; }
  1512. .metric-label { display: block; color: #64748b; font-size: 12px; line-height: 1.4; }
  1513. .metric-value { display: block; margin-top: 6px; color: #0f172a; font-size: 20px; font-weight: 700; line-height: 1.2; }
  1514. .metric-value.good { color: #15803d; }
  1515. .metric-value.alert { color: #b91c1c; }
  1516. .detail { width: 100%; border-collapse: collapse; }
  1517. .detail td { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; font-size: 13px; }
  1518. .detail td:first-child { width: 56%; color: #475569; }
  1519. .detail td:last-child { color: #0f172a; font-weight: 600; text-align: right; }
  1520. .report-detail { margin-top: 28px; }
  1521. .report-detail:empty { display: none; }
  1522. .footer { padding: 18px 32px; background: #f8fafc; border-top: 1px solid #e2e8f0; color: #64748b; font-size: 12px; line-height: 1.6; }
  1523. @media only screen and (max-width: 620px) {
  1524. body { padding: 0; }
  1525. .container { border: 0; border-radius: 0; }
  1526. .header, .content, .footer { padding-left: 20px; padding-right: 20px; }
  1527. .metric-grid, .metric-grid tbody, .metric-grid tr, .metric-cell { display: block; width: 100% !important; box-sizing: border-box; }
  1528. .metric-cell { margin: 8px 0; }
  1529. }
  1530. </style>
  1531. </head>
  1532. <body>
  1533. <div class="container">
  1534. <div class="header">
  1535. <p class="eyebrow">Operations report</p>
  1536. <h1>{{report_name}}</h1>
  1537. <p>{{site_name}} runtime overview</p>
  1538. </div>
  1539. <div class="content">
  1540. <table class="meta" role="presentation">
  1541. <tr><td class="meta-label">Report</td><td>{{report_name}}</td></tr>
  1542. <tr><td class="meta-label">Type</td><td>{{report_type}}</td></tr>
  1543. <tr><td class="meta-label">Reporting period</td><td>{{report_start_time}} to {{report_end_time}} (UTC)</td></tr>
  1544. </table>
  1545. <div style="display: {{report_summary_display}};">
  1546. <h2 class="section-title">Request Overview</h2>
  1547. <table class="metric-grid" role="presentation"><tr>
  1548. <td class="metric-cell"><span class="metric-label">Total Requests</span><span class="metric-value">{{report_total_requests}}</span></td>
  1549. <td class="metric-cell"><span class="metric-label">Successful Requests</span><span class="metric-value good">{{report_success_count}}</span></td>
  1550. </tr><tr>
  1551. <td class="metric-cell"><span class="metric-label">SLA Errors</span><span class="metric-value alert">{{report_sla_error_count}}</span></td>
  1552. <td class="metric-cell"><span class="metric-label">Business Limited</span><span class="metric-value">{{report_business_limited_count}}</span></td>
  1553. </tr></table>
  1554. <h2 class="section-title">Reliability</h2>
  1555. <table class="detail" role="presentation">
  1556. <tr><td>SLA</td><td>{{report_sla}}</td></tr>
  1557. <tr><td>Error Rate</td><td>{{report_error_rate}}</td></tr>
  1558. <tr><td>Upstream Error Rate (excluding 429 / 529)</td><td>{{report_upstream_error_rate}}</td></tr>
  1559. <tr><td>Upstream Errors (excluding 429 / 529)</td><td>{{report_upstream_error_count_excl_429_529}}</td></tr>
  1560. <tr><td>Upstream 429 / 529</td><td>{{report_upstream_429_count}} / {{report_upstream_529_count}}</td></tr>
  1561. </table>
  1562. <h2 class="section-title">Latency</h2>
  1563. <table class="detail" role="presentation">
  1564. <tr><td>Request Latency p50 / p99</td><td>{{report_latency_p50}} / {{report_latency_p99}}</td></tr>
  1565. <tr><td>Time to First Token p50 / p99</td><td>{{report_ttft_p50}} / {{report_ttft_p99}}</td></tr>
  1566. </table>
  1567. <h2 class="section-title">Throughput</h2>
  1568. <table class="detail" role="presentation">
  1569. <tr><td>Tokens Consumed</td><td>{{report_tokens}}</td></tr>
  1570. <tr><td>QPS (current / peak / average)</td><td>{{report_qps_current}} / {{report_qps_peak}} / {{report_qps_avg}}</td></tr>
  1571. <tr><td>TPS (current / peak / average)</td><td>{{report_tps_current}} / {{report_tps_peak}} / {{report_tps_avg}}</td></tr>
  1572. </table>
  1573. </div>
  1574. <div class="report-detail" style="display: {{report_detail_display}};">{{report_html}}</div>
  1575. </div>
  1576. <div class="footer">This email was sent automatically by {{site_name}}. Please do not reply directly.</div>
  1577. </div>
  1578. </body>
  1579. </html>`
  1580. }
  1581. func notificationEmailCard(accent, title, content string) string {
  1582. return `<!DOCTYPE html>
  1583. <html>
  1584. <head>
  1585. <meta charset="UTF-8">
  1586. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  1587. <style>
  1588. body { margin: 0; padding: 24px; background: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #18181b; }
  1589. .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); }
  1590. .header { background: ` + accent + `; color: #ffffff; padding: 28px 32px; }
  1591. .header h1 { margin: 0; font-size: 24px; line-height: 1.25; }
  1592. .content { padding: 32px; font-size: 15px; line-height: 1.7; }
  1593. .button { display: inline-block; margin-top: 12px; padding: 11px 18px; border-radius: 8px; background: ` + accent + `; color: #ffffff; text-decoration: none; font-weight: 600; }
  1594. .muted { color: #71717a; font-size: 13px; }
  1595. .footer { padding: 18px 32px; background: #fafafa; color: #a1a1aa; font-size: 12px; }
  1596. </style>
  1597. </head>
  1598. <body>
  1599. <div class="container">
  1600. <div class="header"><h1>` + title + `</h1></div>
  1601. <div class="content">` + content + `</div>
  1602. <div class="footer">This email was sent by {{site_name}}. Please do not reply directly.</div>
  1603. </div>
  1604. </body>
  1605. </html>`
  1606. }