feat(notify): convert email lists to NotifyEmailEntry struct with toggle support

- Change balance_notify_extra_emails and account_quota_notify_emails
  from []string to []NotifyEmailEntry{email, disabled, verified}
- Add per-email enable/disable toggle for both user and admin notifications
- Add PUT /user/notify-email/toggle API endpoint
- Fix critical bug: API key auth cache snapshot missing balance notify
  fields (Email, Username, BalanceNotifyEnabled, etc.), causing
  notifications to never fire on cached request paths
- Bump cache snapshot version 3→4 to invalidate stale entries
- Add SQL migration 104 to convert old format data
- Backward compatible: parseNotifyEmails auto-detects old/new format
- User balance notify: max 3 emails (primary + 2 extra)
- Admin quota notify: unlimited emails, each with toggle
This commit is contained in:
erio
2026-04-13 00:52:42 +08:00
parent 61aa197b0b
commit 915b7a4a56
25 changed files with 448 additions and 95 deletions

View File

@@ -18,7 +18,7 @@ var (
ErrInsufficientPerms = infraerrors.Forbidden("INSUFFICIENT_PERMISSIONS", "insufficient permissions")
)
const maxNotifyExtraEmails = 5
const maxNotifyEmails = 3 // Total limit: primary (email="") + up to 2 extra
// UserListFilters contains all filter options for listing users
type UserListFilters struct {
@@ -338,17 +338,21 @@ func (s *UserService) VerifyAndAddNotifyEmail(ctx context.Context, userID int64,
// Check if already exists
for _, e := range user.BalanceNotifyExtraEmails {
if strings.EqualFold(e, email) {
if strings.EqualFold(e.Email, email) {
return nil // Already added
}
}
// Check limit
if len(user.BalanceNotifyExtraEmails) >= maxNotifyExtraEmails {
return infraerrors.BadRequest("TOO_MANY_NOTIFY_EMAILS", fmt.Sprintf("maximum %d extra notification emails allowed", maxNotifyExtraEmails))
// Check limit (total includes primary email="" placeholder + extra emails)
if len(user.BalanceNotifyExtraEmails) >= maxNotifyEmails {
return infraerrors.BadRequest("TOO_MANY_NOTIFY_EMAILS", fmt.Sprintf("maximum %d notification emails allowed", maxNotifyEmails))
}
user.BalanceNotifyExtraEmails = append(user.BalanceNotifyExtraEmails, email)
user.BalanceNotifyExtraEmails = append(user.BalanceNotifyExtraEmails, NotifyEmailEntry{
Email: email,
Disabled: false,
Verified: true,
})
return s.userRepo.Update(ctx, user)
}
@@ -359,9 +363,9 @@ func (s *UserService) RemoveNotifyEmail(ctx context.Context, userID int64, email
return err
}
filtered := make([]string, 0, len(user.BalanceNotifyExtraEmails))
filtered := make([]NotifyEmailEntry, 0, len(user.BalanceNotifyExtraEmails))
for _, e := range user.BalanceNotifyExtraEmails {
if !strings.EqualFold(e, email) {
if !strings.EqualFold(e.Email, email) {
filtered = append(filtered, e)
}
}
@@ -369,6 +373,28 @@ func (s *UserService) RemoveNotifyEmail(ctx context.Context, userID int64, email
return s.userRepo.Update(ctx, user)
}
// ToggleNotifyEmail toggles the disabled state of a notification email entry.
func (s *UserService) ToggleNotifyEmail(ctx context.Context, userID int64, email string, disabled bool) error {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return err
}
found := false
for i, e := range user.BalanceNotifyExtraEmails {
if strings.EqualFold(e.Email, email) {
user.BalanceNotifyExtraEmails[i].Disabled = disabled
found = true
break
}
}
if !found {
return infraerrors.BadRequest("EMAIL_NOT_FOUND", "notification email not found")
}
return s.userRepo.Update(ctx, user)
}
// buildNotifyVerifyEmailBody builds the HTML email body for notify email verification.
func buildNotifyVerifyEmailBody(code, siteName string) string {
return fmt.Sprintf(`