notification_email_service_test.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704
  1. package service
  2. import (
  3. "bufio"
  4. "context"
  5. "errors"
  6. "io"
  7. "mime/quotedprintable"
  8. "net"
  9. "net/mail"
  10. "strings"
  11. "sync"
  12. "sync/atomic"
  13. "testing"
  14. "github.com/stretchr/testify/require"
  15. )
  16. func TestNotificationEmailPreviewEscapesHTMLAndSanitizesSubject(t *testing.T) {
  17. ctx := context.Background()
  18. svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
  19. preview, err := svc.PreviewTemplate(ctx, NotificationEmailPreviewInput{
  20. Event: NotificationEmailEventBalanceLow,
  21. Locale: "en-US,en;q=0.9",
  22. Subject: "Low balance for {{recipient_name}}\r\nInjected",
  23. HTML: `<p>{{recipient_name}}</p><a href="{{recharge_url}}">Recharge</a>`,
  24. Variables: map[string]string{
  25. "recipient_name": `<script>alert("x")</script>`,
  26. "recharge_url": `javascript:alert(1)`,
  27. },
  28. })
  29. require.NoError(t, err)
  30. require.NotContains(t, preview.Subject, "\r")
  31. require.NotContains(t, preview.Subject, "\n")
  32. require.Contains(t, preview.Subject, `Low balance for <script>alert("x")</script>Injected`)
  33. require.Contains(t, preview.HTML, `&lt;script&gt;alert(&#34;x&#34;)&lt;/script&gt;`)
  34. require.NotContains(t, preview.HTML, `javascript:alert(1)`)
  35. require.Contains(t, preview.HTML, `href=""`)
  36. }
  37. func TestNotificationEmailTemplateOverrideAndRestore(t *testing.T) {
  38. ctx := context.Background()
  39. repo := newNotificationEmailMemorySettingRepo()
  40. svc := NewNotificationEmailService(repo, nil)
  41. official, err := svc.GetTemplate(ctx, NotificationEmailEventBalanceRechargeSuccess, "en")
  42. require.NoError(t, err)
  43. require.False(t, official.IsCustom)
  44. updated, err := svc.UpdateTemplate(
  45. ctx,
  46. NotificationEmailEventBalanceRechargeSuccess,
  47. "zh-Hans",
  48. "充值完成:{{recharge_amount}}",
  49. "<p>{{recipient_name}} 已充值 {{recharge_amount}}</p>",
  50. )
  51. require.NoError(t, err)
  52. require.True(t, updated.IsCustom)
  53. require.Equal(t, "zh", updated.Locale)
  54. require.Equal(t, "充值完成:{{recharge_amount}}", updated.Subject)
  55. require.NotNil(t, updated.UpdatedAt)
  56. restored, err := svc.RestoreOfficialTemplate(ctx, NotificationEmailEventBalanceRechargeSuccess, "zh")
  57. require.NoError(t, err)
  58. require.False(t, restored.IsCustom)
  59. require.NotEqual(t, updated.Subject, restored.Subject)
  60. _, err = repo.GetValue(ctx, notificationEmailTemplateKey(NotificationEmailEventBalanceRechargeSuccess, "zh"))
  61. require.ErrorIs(t, err, ErrSettingNotFound)
  62. }
  63. func TestNotificationEmailTemplateRejectsUnsupportedPlaceholder(t *testing.T) {
  64. ctx := context.Background()
  65. svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
  66. _, err := svc.UpdateTemplate(
  67. ctx,
  68. NotificationEmailEventSubscriptionPurchaseSuccess,
  69. "en",
  70. "Purchased {{not_allowed}}",
  71. "<p>{{subscription_group}}</p>",
  72. )
  73. require.Error(t, err)
  74. require.Contains(t, err.Error(), "unsupported placeholder")
  75. }
  76. func TestNotificationEmailAuthTemplatesAreListedAndPreviewable(t *testing.T) {
  77. ctx := context.Background()
  78. svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
  79. infos := svc.ListEventInfos()
  80. events := make(map[string]NotificationEmailEventInfo, len(infos))
  81. for _, info := range infos {
  82. events[info.Event] = info
  83. }
  84. require.Contains(t, events, NotificationEmailEventAuthVerifyCode)
  85. require.Contains(t, events, NotificationEmailEventAuthPasswordReset)
  86. require.False(t, events[NotificationEmailEventAuthVerifyCode].Optional)
  87. require.False(t, events[NotificationEmailEventAuthPasswordReset].Optional)
  88. require.Contains(t, events[NotificationEmailEventAuthVerifyCode].Placeholders, "verification_code")
  89. require.Contains(t, events[NotificationEmailEventAuthPasswordReset].Placeholders, "reset_url")
  90. verifyPreview, err := svc.PreviewTemplate(ctx, NotificationEmailPreviewInput{
  91. Event: NotificationEmailEventAuthVerifyCode,
  92. Locale: "zh-CN",
  93. Variables: map[string]string{
  94. "verification_code": "654321",
  95. "expires_in_minutes": "15",
  96. },
  97. })
  98. require.NoError(t, err)
  99. require.Contains(t, verifyPreview.Subject, "邮箱验证码")
  100. require.Contains(t, verifyPreview.HTML, "654321")
  101. resetPreview, err := svc.PreviewTemplate(ctx, NotificationEmailPreviewInput{
  102. Event: NotificationEmailEventAuthPasswordReset,
  103. Locale: "en",
  104. Variables: map[string]string{
  105. "reset_url": "https://example.com/reset?token=abc",
  106. "expires_in_minutes": "30",
  107. },
  108. })
  109. require.NoError(t, err)
  110. require.Contains(t, resetPreview.Subject, "Password reset")
  111. require.Contains(t, resetPreview.HTML, "https://example.com/reset?token=abc")
  112. }
  113. func TestNotificationEmailAdditionalEventsAreListedAndPreviewable(t *testing.T) {
  114. ctx := context.Background()
  115. svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
  116. infos := svc.ListEventInfos()
  117. events := make(map[string]NotificationEmailEventInfo, len(infos))
  118. for _, info := range infos {
  119. events[info.Event] = info
  120. }
  121. checks := []struct {
  122. event string
  123. placeholder string
  124. }{
  125. {NotificationEmailEventNotificationEmailVerifyCode, "verification_code"},
  126. {NotificationEmailEventAccountQuotaAlert, "account_name"},
  127. {NotificationEmailEventContentModerationViolation, "moderation_category"},
  128. {NotificationEmailEventContentModerationDisabled, "violation_count"},
  129. {NotificationEmailEventCyberPolicyNotice, "upstream_message"},
  130. {NotificationEmailEventOpsAlert, "rule_name"},
  131. {NotificationEmailEventOpsScheduledReport, "report_html"},
  132. }
  133. for _, check := range checks {
  134. info, ok := events[check.event]
  135. require.Truef(t, ok, "expected %s to be listed", check.event)
  136. require.False(t, info.Optional)
  137. require.Contains(t, info.Placeholders, check.placeholder)
  138. preview, err := svc.PreviewTemplate(ctx, NotificationEmailPreviewInput{Event: check.event, Locale: "zh"})
  139. require.NoError(t, err)
  140. require.NotEmpty(t, preview.Subject)
  141. require.NotEmpty(t, preview.HTML)
  142. }
  143. }
  144. func TestCyberPolicyNoticeTemplateWrapsLongUpstreamMessages(t *testing.T) {
  145. ctx := context.Background()
  146. svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
  147. longMessage := strings.Repeat("0123456789abcdef", 256)
  148. for _, locale := range []string{"en", "zh"} {
  149. preview, err := svc.PreviewTemplate(ctx, NotificationEmailPreviewInput{
  150. Event: NotificationEmailEventCyberPolicyNotice,
  151. Locale: locale,
  152. Variables: map[string]string{
  153. "upstream_message": longMessage,
  154. },
  155. })
  156. require.NoError(t, err)
  157. require.Contains(t, preview.HTML, "table-layout:fixed")
  158. require.Contains(t, preview.HTML, "overflow-wrap:anywhere")
  159. require.Contains(t, preview.HTML, "word-break:break-all")
  160. require.NotContains(t, preview.HTML, "{{upstream_message}}")
  161. }
  162. }
  163. func TestOpsScheduledReportTemplateExposesEditableSummaryMetrics(t *testing.T) {
  164. ctx := context.Background()
  165. svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
  166. requiredPlaceholders := []string{
  167. "report_summary_display",
  168. "report_detail_display",
  169. "report_total_requests",
  170. "report_success_count",
  171. "report_sla_error_count",
  172. "report_business_limited_count",
  173. "report_sla",
  174. "report_error_rate",
  175. "report_upstream_error_rate",
  176. "report_upstream_error_count_excl_429_529",
  177. "report_upstream_429_count",
  178. "report_upstream_529_count",
  179. "report_latency_p50",
  180. "report_latency_p99",
  181. "report_ttft_p50",
  182. "report_ttft_p99",
  183. "report_tokens",
  184. "report_qps_current",
  185. "report_qps_peak",
  186. "report_qps_avg",
  187. "report_tps_current",
  188. "report_tps_peak",
  189. "report_tps_avg",
  190. }
  191. for _, locale := range []string{"en", "zh"} {
  192. tmpl, err := svc.GetTemplate(ctx, NotificationEmailEventOpsScheduledReport, locale)
  193. require.NoError(t, err)
  194. for _, placeholder := range requiredPlaceholders {
  195. require.Contains(t, tmpl.Placeholders, placeholder)
  196. require.Contains(t, tmpl.HTML, "{{"+placeholder+"}}")
  197. }
  198. preview, err := svc.PreviewTemplate(ctx, NotificationEmailPreviewInput{
  199. Event: NotificationEmailEventOpsScheduledReport,
  200. Locale: locale,
  201. })
  202. require.NoError(t, err)
  203. require.Contains(t, preview.HTML, "2,374")
  204. require.Contains(t, preview.HTML, "99.86%")
  205. require.Contains(t, preview.HTML, "151,260 ms")
  206. require.Contains(t, preview.HTML, `style="display: none;"`)
  207. require.NotContains(t, preview.HTML, "{{report_total_requests}}")
  208. }
  209. }
  210. func TestOpsScheduledReportRuntimeVariablesDoNotLeakPreviewSamples(t *testing.T) {
  211. ctx := context.Background()
  212. svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
  213. variables := svc.runtimeVariables(ctx, NotificationEmailEventOpsScheduledReport, "en", NotificationEmailSendInput{})
  214. require.Equal(t, "none", variables["report_summary_display"])
  215. require.Equal(t, "block", variables["report_detail_display"])
  216. require.Empty(t, variables["report_html"])
  217. for _, placeholder := range notificationEmailOpsSummaryPlaceholders {
  218. if placeholder == "report_summary_display" {
  219. continue
  220. }
  221. require.Equal(t, "-", variables[placeholder])
  222. }
  223. rendered, err := renderNotificationEmail(
  224. NotificationEmailEventOpsScheduledReport,
  225. "Report",
  226. `<div style="display: {{report_detail_display}};">{{report_html}}</div>`,
  227. variables,
  228. nil,
  229. )
  230. require.NoError(t, err)
  231. require.NotContains(t, rendered.HTML, "<h2>Daily summary</h2>")
  232. }
  233. func TestNotificationEmailRawHTMLVariablesAreTrustedOnlyForHTMLPlaceholders(t *testing.T) {
  234. require.True(t, notificationEmailRawHTMLAllowed(NotificationEmailEventOpsScheduledReport, "report_html"))
  235. require.False(t, notificationEmailRawHTMLAllowed(NotificationEmailEventOpsScheduledReport, "recipient_name"))
  236. require.False(t, notificationEmailRawHTMLAllowed(NotificationEmailEventOpsAlert, "report_html"))
  237. preview, err := renderNotificationEmail(
  238. NotificationEmailEventOpsScheduledReport,
  239. "Report for {{recipient_name}}",
  240. `<section>{{report_html}}</section><p>{{recipient_name}}</p>`,
  241. map[string]string{
  242. "recipient_name": `<script>alert("x")</script>`,
  243. "report_html": `<p>escaped report</p>`,
  244. },
  245. map[string]string{
  246. "report_html": `<table><tr><td>trusted report</td></tr></table>`,
  247. },
  248. )
  249. require.NoError(t, err)
  250. require.Contains(t, preview.HTML, `<table><tr><td>trusted report</td></tr></table>`)
  251. require.NotContains(t, preview.HTML, `escaped report`)
  252. require.Contains(t, preview.HTML, `&lt;script&gt;alert(&#34;x&#34;)&lt;/script&gt;`)
  253. require.Contains(t, preview.Subject, `<script>alert("x")</script>`)
  254. preview, err = renderNotificationEmail(
  255. NotificationEmailEventOpsScheduledReport,
  256. "Recipient {{recipient_name}}",
  257. `<p>{{recipient_name}}</p>`,
  258. map[string]string{"recipient_name": `<em>escaped</em>`},
  259. map[string]string{"recipient_name": `<strong>raw</strong>`},
  260. )
  261. require.NoError(t, err)
  262. require.Contains(t, preview.HTML, `&lt;em&gt;escaped&lt;/em&gt;`)
  263. require.NotContains(t, preview.HTML, `<strong>raw</strong>`)
  264. }
  265. func TestNotificationEmailFallbackClassification(t *testing.T) {
  266. templateErr := notificationEmailTemplateErr(errors.New("bad template"))
  267. configErr := notificationEmailConfigErr(errors.New("missing email service"))
  268. deliveryErr := notificationEmailDeliveryErr(errors.New("smtp timeout"))
  269. require.True(t, shouldFallbackNotificationEmail(templateErr))
  270. require.True(t, shouldFallbackNotificationEmail(configErr))
  271. require.False(t, shouldFallbackNotificationEmail(deliveryErr))
  272. require.True(t, isNotificationEmailDeliveryError(deliveryErr))
  273. require.False(t, isNotificationEmailDeliveryError(templateErr))
  274. require.False(t, shouldFallbackNotificationEmail(nil))
  275. }
  276. func TestEmailQueueTasksPreserveLocaleHints(t *testing.T) {
  277. queue := &EmailQueueService{taskChan: make(chan EmailTask, 2)}
  278. require.NoError(t, queue.EnqueueVerifyCode("user@example.com", "Sub2API", "zh-CN"))
  279. require.NoError(t, queue.EnqueuePasswordReset("user@example.com", "Sub2API", "https://example.com/reset", "en-US"))
  280. verifyTask := <-queue.taskChan
  281. require.Equal(t, TaskTypeVerifyCode, verifyTask.TaskType)
  282. require.Equal(t, "zh-CN", verifyTask.Locale)
  283. resetTask := <-queue.taskChan
  284. require.Equal(t, TaskTypePasswordReset, resetTask.TaskType)
  285. require.Equal(t, "en-US", resetTask.Locale)
  286. }
  287. func TestOpsScheduledReportDeliverySourceIDIncludesReportIdentity(t *testing.T) {
  288. report := &opsScheduledReport{Name: "日报", ReportType: "daily_summary", Schedule: "0 9 * * *"}
  289. sourceID := opsScheduledReportDeliverySourceID(report)
  290. require.Contains(t, sourceID, "daily_summary")
  291. require.Contains(t, sourceID, "日报")
  292. require.Contains(t, sourceID, "0 9 * * *")
  293. require.NotEqual(t, sourceID, opsScheduledReportDeliverySourceID(&opsScheduledReport{Name: "周报", ReportType: "weekly_summary", Schedule: "0 9 * * 1"}))
  294. require.Equal(t, "scheduled_report", opsScheduledReportDeliverySourceID(nil))
  295. }
  296. func TestNotificationEmailUnsubscribeOnlyAllowsOptionalEvents(t *testing.T) {
  297. ctx := context.Background()
  298. svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
  299. token, err := svc.createUnsubscribeToken(ctx, "User@Example.com", NotificationEmailEventBalanceLow)
  300. require.NoError(t, err)
  301. result, err := svc.Unsubscribe(ctx, token)
  302. require.NoError(t, err)
  303. require.True(t, result.Done)
  304. require.Equal(t, NotificationEmailEventBalanceLow, result.Event)
  305. unsubscribed, err := svc.IsUnsubscribed(ctx, "user@example.com", NotificationEmailEventBalanceLow)
  306. require.NoError(t, err)
  307. require.True(t, unsubscribed)
  308. transactionalToken, err := svc.createUnsubscribeToken(ctx, "user@example.com", NotificationEmailEventBalanceRechargeSuccess)
  309. require.NoError(t, err)
  310. _, err = svc.Unsubscribe(ctx, transactionalToken)
  311. require.Error(t, err)
  312. require.Contains(t, err.Error(), "transactional")
  313. authToken, err := svc.createUnsubscribeToken(ctx, "user@example.com", NotificationEmailEventAuthVerifyCode)
  314. require.NoError(t, err)
  315. _, err = svc.Unsubscribe(ctx, authToken)
  316. require.Error(t, err)
  317. require.Contains(t, err.Error(), "transactional")
  318. }
  319. func TestNotificationEmailLocaleMemoryNormalizesAcceptLanguage(t *testing.T) {
  320. ctx := context.Background()
  321. svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
  322. svc.RememberRecipientLocale(ctx, 42, "User@Example.com", "zh-CN,zh;q=0.9,en;q=0.8")
  323. require.Equal(t, "zh", svc.ResolveRecipientLocale(ctx, 42, "user@example.com"))
  324. require.Equal(t, "zh", svc.ResolveRecipientLocale(ctx, 0, "user@example.com"))
  325. }
  326. func TestNotificationEmailDeliveryKeyUsesShortStableHash(t *testing.T) {
  327. key := notificationEmailDeliveryKey(
  328. NotificationEmailEventSubscriptionExpiryReminder,
  329. "user_subscription",
  330. "1234567890",
  331. "User@Example.com",
  332. "7d",
  333. )
  334. require.NotEmpty(t, key)
  335. require.LessOrEqual(t, len(key), 100)
  336. require.True(t, strings.HasPrefix(key, notificationEmailDeliveryKeyPrefix+"v2:"))
  337. require.Equal(t, key, notificationEmailDeliveryKey(
  338. NotificationEmailEventSubscriptionExpiryReminder,
  339. "user_subscription",
  340. "1234567890",
  341. "user@example.com",
  342. "7d",
  343. ))
  344. require.NotEqual(t, key, notificationEmailDeliveryKey(
  345. NotificationEmailEventSubscriptionExpiryReminder,
  346. "user_subscription",
  347. "1234567890",
  348. "user@example.com",
  349. "3d",
  350. ))
  351. legacyKey := legacyNotificationEmailDeliveryKey(
  352. NotificationEmailEventSubscriptionExpiryReminder,
  353. "user_subscription",
  354. "1234567890",
  355. "user@example.com",
  356. "7d",
  357. )
  358. require.Greater(t, len(legacyKey), 100)
  359. }
  360. func TestNotificationEmailPreferenceKeyUsesShortStableHashAndReadsLegacyKey(t *testing.T) {
  361. ctx := context.Background()
  362. repo := newNotificationEmailMemorySettingRepo()
  363. svc := NewNotificationEmailService(repo, nil)
  364. key := notificationEmailPreferenceKey(NotificationEmailEventSubscriptionExpiryReminder, "User@Example.com")
  365. require.NotEmpty(t, key)
  366. require.LessOrEqual(t, len(key), 100)
  367. require.True(t, strings.HasPrefix(key, notificationEmailPreferenceKeyPrefix+"v2:"))
  368. require.Equal(t, key, notificationEmailPreferenceKey(NotificationEmailEventSubscriptionExpiryReminder, "user@example.com"))
  369. legacyKey := legacyNotificationEmailPreferenceKey(NotificationEmailEventSubscriptionExpiryReminder, "user@example.com")
  370. require.Greater(t, len(legacyKey), 100)
  371. require.NoError(t, repo.Set(ctx, legacyKey, "unsubscribed"))
  372. unsubscribed, err := svc.IsUnsubscribed(ctx, "User@Example.com", NotificationEmailEventSubscriptionExpiryReminder)
  373. require.NoError(t, err)
  374. require.True(t, unsubscribed)
  375. }
  376. func TestNotificationEmailSendDeduplicatesSubscriptionExpiryReminder(t *testing.T) {
  377. ctx := context.Background()
  378. repo := newNotificationEmailMemorySettingRepo()
  379. smtpServer := startNotificationEmailTestSMTPServer(t)
  380. require.NoError(t, repo.SetMultiple(ctx, smtpServer.settings()))
  381. emailSvc := NewEmailService(repo, nil)
  382. svc := NewNotificationEmailService(repo, emailSvc)
  383. input := NotificationEmailSendInput{
  384. Event: NotificationEmailEventSubscriptionExpiryReminder,
  385. RecipientEmail: "User@Example.com",
  386. RecipientName: "User",
  387. UserID: 42,
  388. SourceType: "user_subscription",
  389. SourceID: "1234567890",
  390. ReminderKey: "7d",
  391. Variables: map[string]string{
  392. "subscription_group": "Codex",
  393. "expiry_time": "2026-05-27 12:00",
  394. "days_remaining": "7",
  395. },
  396. }
  397. require.NoError(t, svc.Send(ctx, input))
  398. require.Equal(t, int64(1), smtpServer.messageCount())
  399. key := notificationEmailDeliveryKey(input.Event, input.SourceType, input.SourceID, input.RecipientEmail, input.ReminderKey)
  400. require.LessOrEqual(t, len(key), 100)
  401. _, err := repo.GetValue(ctx, key)
  402. require.NoError(t, err)
  403. require.NoError(t, svc.Send(ctx, input))
  404. require.Equal(t, int64(1), smtpServer.messageCount())
  405. }
  406. func TestNotificationEmailSendRespectsLegacyDeliveryKey(t *testing.T) {
  407. ctx := context.Background()
  408. repo := newNotificationEmailMemorySettingRepo()
  409. svc := NewNotificationEmailService(repo, nil)
  410. input := NotificationEmailSendInput{
  411. Event: NotificationEmailEventSubscriptionExpiryReminder,
  412. RecipientEmail: "user@example.com",
  413. SourceType: "user_subscription",
  414. SourceID: "1234567890",
  415. ReminderKey: "7d",
  416. }
  417. legacyKey := legacyNotificationEmailDeliveryKey(input.Event, input.SourceType, input.SourceID, input.RecipientEmail, input.ReminderKey)
  418. require.NoError(t, repo.Set(ctx, legacyKey, "sent"))
  419. require.NoError(t, svc.Send(ctx, input))
  420. }
  421. type notificationEmailMemorySettingRepo struct {
  422. mu sync.RWMutex
  423. values map[string]string
  424. }
  425. func newNotificationEmailMemorySettingRepo() *notificationEmailMemorySettingRepo {
  426. return &notificationEmailMemorySettingRepo{values: make(map[string]string)}
  427. }
  428. func (r *notificationEmailMemorySettingRepo) Get(_ context.Context, key string) (*Setting, error) {
  429. r.mu.RLock()
  430. defer r.mu.RUnlock()
  431. value, ok := r.values[key]
  432. if !ok {
  433. return nil, ErrSettingNotFound
  434. }
  435. return &Setting{Key: key, Value: value}, nil
  436. }
  437. func (r *notificationEmailMemorySettingRepo) GetValue(ctx context.Context, key string) (string, error) {
  438. setting, err := r.Get(ctx, key)
  439. if err != nil {
  440. return "", err
  441. }
  442. return setting.Value, nil
  443. }
  444. func (r *notificationEmailMemorySettingRepo) Set(_ context.Context, key, value string) error {
  445. r.mu.Lock()
  446. defer r.mu.Unlock()
  447. r.values[key] = value
  448. return nil
  449. }
  450. func (r *notificationEmailMemorySettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
  451. r.mu.RLock()
  452. defer r.mu.RUnlock()
  453. out := make(map[string]string, len(keys))
  454. for _, key := range keys {
  455. if value, ok := r.values[key]; ok {
  456. out[key] = value
  457. }
  458. }
  459. return out, nil
  460. }
  461. func (r *notificationEmailMemorySettingRepo) SetMultiple(_ context.Context, settings map[string]string) error {
  462. r.mu.Lock()
  463. defer r.mu.Unlock()
  464. for key, value := range settings {
  465. r.values[key] = value
  466. }
  467. return nil
  468. }
  469. func (r *notificationEmailMemorySettingRepo) GetAll(_ context.Context) (map[string]string, error) {
  470. r.mu.RLock()
  471. defer r.mu.RUnlock()
  472. out := make(map[string]string, len(r.values))
  473. for key, value := range r.values {
  474. out[key] = value
  475. }
  476. return out, nil
  477. }
  478. func (r *notificationEmailMemorySettingRepo) Delete(_ context.Context, key string) error {
  479. r.mu.Lock()
  480. defer r.mu.Unlock()
  481. if _, ok := r.values[key]; !ok {
  482. return ErrSettingNotFound
  483. }
  484. delete(r.values, key)
  485. return nil
  486. }
  487. func TestNotificationEmailMemorySettingRepoSatisfiesInterface(t *testing.T) {
  488. var _ SettingRepository = (*notificationEmailMemorySettingRepo)(nil)
  489. require.False(t, strings.Contains(notificationEmailPreferenceKey(NotificationEmailEventBalanceLow, "User@Example.com"), "User@Example.com"))
  490. }
  491. type notificationEmailTestSMTPServer struct {
  492. listener net.Listener
  493. wg sync.WaitGroup
  494. messages atomic.Int64
  495. messageMu sync.Mutex
  496. messageBodies []string
  497. }
  498. func startNotificationEmailTestSMTPServer(t *testing.T) *notificationEmailTestSMTPServer {
  499. t.Helper()
  500. listener, err := net.Listen("tcp", "127.0.0.1:0")
  501. require.NoError(t, err)
  502. server := &notificationEmailTestSMTPServer{listener: listener}
  503. server.wg.Add(1)
  504. go server.serve()
  505. t.Cleanup(server.close)
  506. return server
  507. }
  508. func (s *notificationEmailTestSMTPServer) settings() map[string]string {
  509. host, port, _ := net.SplitHostPort(s.listener.Addr().String())
  510. return map[string]string{
  511. SettingKeySMTPHost: host,
  512. SettingKeySMTPPort: port,
  513. SettingKeySMTPUsername: "user",
  514. SettingKeySMTPPassword: "password",
  515. SettingKeySMTPFrom: "noreply@example.com",
  516. SettingKeySMTPFromName: "Sub2API",
  517. SettingKeySMTPUseTLS: "false",
  518. }
  519. }
  520. func (s *notificationEmailTestSMTPServer) messageCount() int64 {
  521. return s.messages.Load()
  522. }
  523. func (s *notificationEmailTestSMTPServer) lastMessage() string {
  524. s.messageMu.Lock()
  525. defer s.messageMu.Unlock()
  526. if len(s.messageBodies) == 0 {
  527. return ""
  528. }
  529. return s.messageBodies[len(s.messageBodies)-1]
  530. }
  531. func (s *notificationEmailTestSMTPServer) lastMessageBody(t *testing.T) string {
  532. t.Helper()
  533. message, err := mail.ReadMessage(strings.NewReader(s.lastMessage()))
  534. require.NoError(t, err)
  535. bodyReader := io.Reader(message.Body)
  536. if strings.EqualFold(message.Header.Get("Content-Transfer-Encoding"), "quoted-printable") {
  537. bodyReader = quotedprintable.NewReader(message.Body)
  538. }
  539. body, err := io.ReadAll(bodyReader)
  540. require.NoError(t, err)
  541. return string(body)
  542. }
  543. func (s *notificationEmailTestSMTPServer) close() {
  544. _ = s.listener.Close()
  545. s.wg.Wait()
  546. }
  547. func (s *notificationEmailTestSMTPServer) serve() {
  548. defer s.wg.Done()
  549. for {
  550. conn, err := s.listener.Accept()
  551. if err != nil {
  552. return
  553. }
  554. s.handleConn(conn)
  555. }
  556. }
  557. func (s *notificationEmailTestSMTPServer) handleConn(conn net.Conn) {
  558. defer func() { _ = conn.Close() }()
  559. rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
  560. writeLine := func(line string) bool {
  561. if _, err := rw.WriteString(line + "\r\n"); err != nil {
  562. return false
  563. }
  564. return rw.Flush() == nil
  565. }
  566. if !writeLine("220 localhost ESMTP") {
  567. return
  568. }
  569. for {
  570. line, err := rw.ReadString('\n')
  571. if err != nil {
  572. return
  573. }
  574. cmd := strings.ToUpper(strings.TrimRight(line, "\r\n"))
  575. switch {
  576. case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
  577. if _, err := rw.WriteString("250-localhost\r\n250 AUTH PLAIN\r\n"); err != nil {
  578. return
  579. }
  580. if err := rw.Flush(); err != nil {
  581. return
  582. }
  583. case strings.HasPrefix(cmd, "AUTH"):
  584. if !writeLine("235 2.7.0 Authentication successful") {
  585. return
  586. }
  587. case strings.HasPrefix(cmd, "MAIL FROM:"):
  588. if !writeLine("250 2.1.0 OK") {
  589. return
  590. }
  591. case strings.HasPrefix(cmd, "RCPT TO:"):
  592. if !writeLine("250 2.1.5 OK") {
  593. return
  594. }
  595. case strings.HasPrefix(cmd, "DATA"):
  596. if !writeLine("354 End data with <CR><LF>.<CR><LF>") {
  597. return
  598. }
  599. var message strings.Builder
  600. for {
  601. dataLine, err := rw.ReadString('\n')
  602. if err != nil {
  603. return
  604. }
  605. if strings.TrimRight(dataLine, "\r\n") == "." {
  606. break
  607. }
  608. _, _ = message.WriteString(dataLine)
  609. }
  610. s.messageMu.Lock()
  611. s.messageBodies = append(s.messageBodies, message.String())
  612. s.messageMu.Unlock()
  613. s.messages.Add(1)
  614. if !writeLine("250 2.0.0 OK") {
  615. return
  616. }
  617. case strings.HasPrefix(cmd, "QUIT"):
  618. _ = writeLine("221 2.0.0 Bye")
  619. return
  620. default:
  621. if !writeLine("250 OK") {
  622. return
  623. }
  624. }
  625. }
  626. }