fix email reminder dedup keys
This commit is contained in:
parent
f5a2ad688a
commit
dd4d482a70
@ -382,7 +382,7 @@ func (s *NotificationEmailService) Send(ctx context.Context, input NotificationE
|
||||
|
||||
deliveryKey := notificationEmailDeliveryKey(normalizedEvent, input.SourceType, input.SourceID, recipient, input.ReminderKey)
|
||||
if deliveryKey != "" {
|
||||
sent, err := s.deliveryExists(ctx, deliveryKey)
|
||||
sent, err := s.deliveryExists(ctx, deliveryKey, legacyNotificationEmailDeliveryKey(normalizedEvent, input.SourceType, input.SourceID, recipient, input.ReminderKey))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -398,7 +398,9 @@ func (s *NotificationEmailService) Send(ctx context.Context, input NotificationE
|
||||
return notificationEmailDeliveryErr(err)
|
||||
}
|
||||
if deliveryKey != "" {
|
||||
_ = s.settingRepo.Set(ctx, deliveryKey, time.Now().UTC().Format(time.RFC3339Nano))
|
||||
if err := s.settingRepo.Set(ctx, deliveryKey, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -441,14 +443,19 @@ func (s *NotificationEmailService) IsUnsubscribed(ctx context.Context, email, ev
|
||||
if !info.Optional {
|
||||
return false, nil
|
||||
}
|
||||
value, err := s.settingRepo.GetValue(ctx, notificationEmailPreferenceKey(normalizedEvent, email))
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSettingNotFound) {
|
||||
return false, nil
|
||||
for _, key := range []string{notificationEmailPreferenceKey(normalizedEvent, email), legacyNotificationEmailPreferenceKey(normalizedEvent, email)} {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
continue
|
||||
}
|
||||
value, err := s.settingRepo.GetValue(ctx, key)
|
||||
if err == nil {
|
||||
return strings.EqualFold(strings.TrimSpace(value), "unsubscribed"), nil
|
||||
}
|
||||
if !errors.Is(err, ErrSettingNotFound) {
|
||||
return false, err
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(value), "unsubscribed"), nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *NotificationEmailService) Unsubscribe(ctx context.Context, token string) (NotificationEmailUnsubscribeResult, error) {
|
||||
@ -610,15 +617,20 @@ func (s *NotificationEmailService) unsubscribeSecret(ctx context.Context) (strin
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
func (s *NotificationEmailService) deliveryExists(ctx context.Context, key string) (bool, error) {
|
||||
_, err := s.settingRepo.GetValue(ctx, key)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
func (s *NotificationEmailService) deliveryExists(ctx context.Context, keys ...string) (bool, error) {
|
||||
for _, key := range keys {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
continue
|
||||
}
|
||||
_, err := s.settingRepo.GetValue(ctx, key)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if !errors.Is(err, ErrSettingNotFound) {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if errors.Is(err, ErrSettingNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func validateNotificationEmailTemplate(event, subject, htmlBody string) error {
|
||||
@ -749,10 +761,32 @@ func notificationEmailTemplateKey(event, locale string) string {
|
||||
}
|
||||
|
||||
func notificationEmailPreferenceKey(event, email string) string {
|
||||
if strings.TrimSpace(event) == "" || strings.TrimSpace(email) == "" {
|
||||
return ""
|
||||
}
|
||||
identity := strings.TrimSpace(event) + "\x00" + strings.ToLower(strings.TrimSpace(email))
|
||||
return notificationEmailPreferenceKeyPrefix + "v2:" + notificationEmailHash(identity)
|
||||
}
|
||||
|
||||
func legacyNotificationEmailPreferenceKey(event, email string) string {
|
||||
return notificationEmailPreferenceKeyPrefix + event + ":" + notificationEmailHash(email)
|
||||
}
|
||||
|
||||
func notificationEmailDeliveryKey(event, sourceType, sourceID, recipient, reminderKey string) string {
|
||||
if strings.TrimSpace(sourceType) == "" || strings.TrimSpace(sourceID) == "" || strings.TrimSpace(recipient) == "" {
|
||||
return ""
|
||||
}
|
||||
identity := strings.Join([]string{
|
||||
strings.ToLower(strings.TrimSpace(event)),
|
||||
safeNotificationEmailKeyPart(sourceType),
|
||||
safeNotificationEmailKeyPart(sourceID),
|
||||
strings.ToLower(strings.TrimSpace(recipient)),
|
||||
safeNotificationEmailKeyPart(reminderKey),
|
||||
}, "\x00")
|
||||
return notificationEmailDeliveryKeyPrefix + "v2:" + notificationEmailHash(identity)
|
||||
}
|
||||
|
||||
func legacyNotificationEmailDeliveryKey(event, sourceType, sourceID, recipient, reminderKey string) string {
|
||||
if strings.TrimSpace(sourceType) == "" || strings.TrimSpace(sourceID) == "" || strings.TrimSpace(recipient) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@ -262,6 +265,114 @@ func TestNotificationEmailLocaleMemoryNormalizesAcceptLanguage(t *testing.T) {
|
||||
require.Equal(t, "zh", svc.ResolveRecipientLocale(ctx, 0, "user@example.com"))
|
||||
}
|
||||
|
||||
func TestNotificationEmailDeliveryKeyUsesShortStableHash(t *testing.T) {
|
||||
key := notificationEmailDeliveryKey(
|
||||
NotificationEmailEventSubscriptionExpiryReminder,
|
||||
"user_subscription",
|
||||
"1234567890",
|
||||
"User@Example.com",
|
||||
"7d",
|
||||
)
|
||||
require.NotEmpty(t, key)
|
||||
require.LessOrEqual(t, len(key), 100)
|
||||
require.True(t, strings.HasPrefix(key, notificationEmailDeliveryKeyPrefix+"v2:"))
|
||||
require.Equal(t, key, notificationEmailDeliveryKey(
|
||||
NotificationEmailEventSubscriptionExpiryReminder,
|
||||
"user_subscription",
|
||||
"1234567890",
|
||||
"user@example.com",
|
||||
"7d",
|
||||
))
|
||||
require.NotEqual(t, key, notificationEmailDeliveryKey(
|
||||
NotificationEmailEventSubscriptionExpiryReminder,
|
||||
"user_subscription",
|
||||
"1234567890",
|
||||
"user@example.com",
|
||||
"3d",
|
||||
))
|
||||
|
||||
legacyKey := legacyNotificationEmailDeliveryKey(
|
||||
NotificationEmailEventSubscriptionExpiryReminder,
|
||||
"user_subscription",
|
||||
"1234567890",
|
||||
"user@example.com",
|
||||
"7d",
|
||||
)
|
||||
require.Greater(t, len(legacyKey), 100)
|
||||
}
|
||||
|
||||
func TestNotificationEmailPreferenceKeyUsesShortStableHashAndReadsLegacyKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newNotificationEmailMemorySettingRepo()
|
||||
svc := NewNotificationEmailService(repo, nil)
|
||||
|
||||
key := notificationEmailPreferenceKey(NotificationEmailEventSubscriptionExpiryReminder, "User@Example.com")
|
||||
require.NotEmpty(t, key)
|
||||
require.LessOrEqual(t, len(key), 100)
|
||||
require.True(t, strings.HasPrefix(key, notificationEmailPreferenceKeyPrefix+"v2:"))
|
||||
require.Equal(t, key, notificationEmailPreferenceKey(NotificationEmailEventSubscriptionExpiryReminder, "user@example.com"))
|
||||
|
||||
legacyKey := legacyNotificationEmailPreferenceKey(NotificationEmailEventSubscriptionExpiryReminder, "user@example.com")
|
||||
require.Greater(t, len(legacyKey), 100)
|
||||
require.NoError(t, repo.Set(ctx, legacyKey, "unsubscribed"))
|
||||
|
||||
unsubscribed, err := svc.IsUnsubscribed(ctx, "User@Example.com", NotificationEmailEventSubscriptionExpiryReminder)
|
||||
require.NoError(t, err)
|
||||
require.True(t, unsubscribed)
|
||||
}
|
||||
|
||||
func TestNotificationEmailSendDeduplicatesSubscriptionExpiryReminder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newNotificationEmailMemorySettingRepo()
|
||||
smtpServer := startNotificationEmailTestSMTPServer(t)
|
||||
require.NoError(t, repo.SetMultiple(ctx, smtpServer.settings()))
|
||||
|
||||
emailSvc := NewEmailService(repo, nil)
|
||||
svc := NewNotificationEmailService(repo, emailSvc)
|
||||
input := NotificationEmailSendInput{
|
||||
Event: NotificationEmailEventSubscriptionExpiryReminder,
|
||||
RecipientEmail: "User@Example.com",
|
||||
RecipientName: "User",
|
||||
UserID: 42,
|
||||
SourceType: "user_subscription",
|
||||
SourceID: "1234567890",
|
||||
ReminderKey: "7d",
|
||||
Variables: map[string]string{
|
||||
"subscription_group": "Codex",
|
||||
"expiry_time": "2026-05-27 12:00",
|
||||
"days_remaining": "7",
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, svc.Send(ctx, input))
|
||||
require.Equal(t, int64(1), smtpServer.messageCount())
|
||||
|
||||
key := notificationEmailDeliveryKey(input.Event, input.SourceType, input.SourceID, input.RecipientEmail, input.ReminderKey)
|
||||
require.LessOrEqual(t, len(key), 100)
|
||||
_, err := repo.GetValue(ctx, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, svc.Send(ctx, input))
|
||||
require.Equal(t, int64(1), smtpServer.messageCount())
|
||||
}
|
||||
|
||||
func TestNotificationEmailSendRespectsLegacyDeliveryKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newNotificationEmailMemorySettingRepo()
|
||||
svc := NewNotificationEmailService(repo, nil)
|
||||
input := NotificationEmailSendInput{
|
||||
Event: NotificationEmailEventSubscriptionExpiryReminder,
|
||||
RecipientEmail: "user@example.com",
|
||||
SourceType: "user_subscription",
|
||||
SourceID: "1234567890",
|
||||
ReminderKey: "7d",
|
||||
}
|
||||
legacyKey := legacyNotificationEmailDeliveryKey(input.Event, input.SourceType, input.SourceID, input.RecipientEmail, input.ReminderKey)
|
||||
require.NoError(t, repo.Set(ctx, legacyKey, "sent"))
|
||||
|
||||
require.NoError(t, svc.Send(ctx, input))
|
||||
}
|
||||
|
||||
type notificationEmailMemorySettingRepo struct {
|
||||
mu sync.RWMutex
|
||||
values map[string]string
|
||||
@ -341,3 +452,120 @@ func TestNotificationEmailMemorySettingRepoSatisfiesInterface(t *testing.T) {
|
||||
var _ SettingRepository = (*notificationEmailMemorySettingRepo)(nil)
|
||||
require.False(t, strings.Contains(notificationEmailPreferenceKey(NotificationEmailEventBalanceLow, "User@Example.com"), "User@Example.com"))
|
||||
}
|
||||
|
||||
type notificationEmailTestSMTPServer struct {
|
||||
listener net.Listener
|
||||
wg sync.WaitGroup
|
||||
messages atomic.Int64
|
||||
}
|
||||
|
||||
func startNotificationEmailTestSMTPServer(t *testing.T) *notificationEmailTestSMTPServer {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
server := ¬ificationEmailTestSMTPServer{listener: listener}
|
||||
server.wg.Add(1)
|
||||
go server.serve()
|
||||
t.Cleanup(server.close)
|
||||
return server
|
||||
}
|
||||
|
||||
func (s *notificationEmailTestSMTPServer) settings() map[string]string {
|
||||
host, port, _ := net.SplitHostPort(s.listener.Addr().String())
|
||||
return map[string]string{
|
||||
SettingKeySMTPHost: host,
|
||||
SettingKeySMTPPort: port,
|
||||
SettingKeySMTPUsername: "user",
|
||||
SettingKeySMTPPassword: "password",
|
||||
SettingKeySMTPFrom: "noreply@example.com",
|
||||
SettingKeySMTPFromName: "Sub2API",
|
||||
SettingKeySMTPUseTLS: "false",
|
||||
}
|
||||
}
|
||||
|
||||
func (s *notificationEmailTestSMTPServer) messageCount() int64 {
|
||||
return s.messages.Load()
|
||||
}
|
||||
|
||||
func (s *notificationEmailTestSMTPServer) close() {
|
||||
_ = s.listener.Close()
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
func (s *notificationEmailTestSMTPServer) serve() {
|
||||
defer s.wg.Done()
|
||||
for {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *notificationEmailTestSMTPServer) handleConn(conn net.Conn) {
|
||||
defer func() { _ = conn.Close() }()
|
||||
rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
|
||||
writeLine := func(line string) bool {
|
||||
if _, err := rw.WriteString(line + "\r\n"); err != nil {
|
||||
return false
|
||||
}
|
||||
return rw.Flush() == nil
|
||||
}
|
||||
if !writeLine("220 localhost ESMTP") {
|
||||
return
|
||||
}
|
||||
for {
|
||||
line, err := rw.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := strings.ToUpper(strings.TrimRight(line, "\r\n"))
|
||||
switch {
|
||||
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
|
||||
if _, err := rw.WriteString("250-localhost\r\n250 AUTH PLAIN\r\n"); err != nil {
|
||||
return
|
||||
}
|
||||
if err := rw.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(cmd, "AUTH"):
|
||||
if !writeLine("235 2.7.0 Authentication successful") {
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(cmd, "MAIL FROM:"):
|
||||
if !writeLine("250 2.1.0 OK") {
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(cmd, "RCPT TO:"):
|
||||
if !writeLine("250 2.1.5 OK") {
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(cmd, "DATA"):
|
||||
if !writeLine("354 End data with <CR><LF>.<CR><LF>") {
|
||||
return
|
||||
}
|
||||
for {
|
||||
dataLine, err := rw.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimRight(dataLine, "\r\n") == "." {
|
||||
break
|
||||
}
|
||||
}
|
||||
s.messages.Add(1)
|
||||
if !writeLine("250 2.0.0 OK") {
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(cmd, "QUIT"):
|
||||
_ = writeLine("221 2.0.0 Bye")
|
||||
return
|
||||
default:
|
||||
if !writeLine("250 OK") {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user