diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 49e7aeed..fe46e821 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -178,7 +178,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled, BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, - AccountQuotaNotifyEmails: settings.AccountQuotaNotifyEmails, + AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(settings.AccountQuotaNotifyEmails), PaymentEnabled: paymentCfg.Enabled, PaymentMinAmount: paymentCfg.MinAmount, PaymentMaxAmount: paymentCfg.MaxAmount, @@ -311,7 +311,7 @@ type UpdateSettingsRequest struct { // Balance low notification BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"` BalanceLowNotifyThreshold *float64 `json:"balance_low_notify_threshold"` - AccountQuotaNotifyEmails *[]string `json:"account_quota_notify_emails"` + AccountQuotaNotifyEmails *[]dto.NotifyEmailEntry `json:"account_quota_notify_emails"` // Payment configuration (integrated into settings, full replace) PaymentEnabled *bool `json:"payment_enabled"` @@ -902,9 +902,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.BalanceLowNotifyThreshold }(), - AccountQuotaNotifyEmails: func() []string { + AccountQuotaNotifyEmails: func() []service.NotifyEmailEntry { if req.AccountQuotaNotifyEmails != nil { - return *req.AccountQuotaNotifyEmails + return dto.NotifyEmailEntriesToService(*req.AccountQuotaNotifyEmails) } return previousSettings.AccountQuotaNotifyEmails }(), @@ -1056,7 +1056,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { EnableCCHSigning: updatedSettings.EnableCCHSigning, BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled, BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold, - AccountQuotaNotifyEmails: updatedSettings.AccountQuotaNotifyEmails, + AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(updatedSettings.AccountQuotaNotifyEmails), PaymentEnabled: updatedPaymentCfg.Enabled, PaymentMinAmount: updatedPaymentCfg.MinAmount, PaymentMaxAmount: updatedPaymentCfg.MaxAmount, diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 147072c3..a7a93d07 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -26,7 +26,7 @@ func UserFromServiceShallow(u *service.User) *User { BalanceNotifyEnabled: u.BalanceNotifyEnabled, BalanceNotifyThresholdType: u.BalanceNotifyThresholdType, BalanceNotifyThreshold: u.BalanceNotifyThreshold, - BalanceNotifyExtraEmails: u.BalanceNotifyExtraEmails, + BalanceNotifyExtraEmails: NotifyEmailEntriesFromService(u.BalanceNotifyExtraEmails), TotalRecharged: u.TotalRecharged, } } diff --git a/backend/internal/handler/dto/notify_email_entry.go b/backend/internal/handler/dto/notify_email_entry.go new file mode 100644 index 00000000..180f8b25 --- /dev/null +++ b/backend/internal/handler/dto/notify_email_entry.go @@ -0,0 +1,43 @@ +package dto + +import "github.com/Wei-Shaw/sub2api/internal/service" + +// NotifyEmailEntry represents a notification email with enable/disable and verification state. +// Email="" is a placeholder for the "primary email" (user's registration email or first admin email). +type NotifyEmailEntry struct { + Email string `json:"email"` + Disabled bool `json:"disabled"` + Verified bool `json:"verified"` +} + +// NotifyEmailEntriesFromService converts service entries to DTO entries. +func NotifyEmailEntriesFromService(entries []service.NotifyEmailEntry) []NotifyEmailEntry { + if entries == nil { + return nil + } + result := make([]NotifyEmailEntry, len(entries)) + for i, e := range entries { + result[i] = NotifyEmailEntry{ + Email: e.Email, + Disabled: e.Disabled, + Verified: e.Verified, + } + } + return result +} + +// NotifyEmailEntriesToService converts DTO entries to service entries. +func NotifyEmailEntriesToService(entries []NotifyEmailEntry) []service.NotifyEmailEntry { + if entries == nil { + return nil + } + result := make([]service.NotifyEmailEntry, len(entries)) + for i, e := range entries { + result[i] = service.NotifyEmailEntry{ + Email: e.Email, + Disabled: e.Disabled, + Verified: e.Verified, + } + } + return result +} diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 9c2ff263..545458c8 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -152,7 +152,7 @@ type SystemSettings struct { // Balance low notification BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` - AccountQuotaNotifyEmails []string `json:"account_quota_notify_emails"` + AccountQuotaNotifyEmails []NotifyEmailEntry `json:"account_quota_notify_emails"` } type DefaultSubscriptionSetting struct { diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 425d3df9..afb782b0 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -22,7 +22,7 @@ type User struct { BalanceNotifyEnabled bool `json:"balance_notify_enabled"` BalanceNotifyThresholdType string `json:"balance_notify_threshold_type"` BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"` - BalanceNotifyExtraEmails []string `json:"balance_notify_extra_emails"` + BalanceNotifyExtraEmails []NotifyEmailEntry `json:"balance_notify_extra_emails"` TotalRecharged float64 `json:"total_recharged"` APIKeys []APIKey `json:"api_keys,omitempty"` diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 4fb72ce7..9e0a243a 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -214,3 +214,39 @@ func (h *UserHandler) RemoveNotifyEmail(c *gin.Context) { response.Success(c, dto.UserFromService(updatedUser)) } + +// ToggleNotifyEmailRequest represents the request to toggle a notify email's disabled state +type ToggleNotifyEmailRequest struct { + Email string `json:"email"` // empty string for primary email placeholder + Disabled bool `json:"disabled"` +} + +// ToggleNotifyEmail toggles the disabled state of a notification email +// PUT /api/v1/user/notify-email/toggle +func (h *UserHandler) ToggleNotifyEmail(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + var req ToggleNotifyEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + err := h.userService.ToggleNotifyEmail(c.Request.Context(), subject.UserID, req.Email, req.Disabled) + if err != nil { + response.ErrorFrom(c, err) + return + } + + updatedUser, err := h.userService.GetByID(c.Request.Context(), subject.UserID) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.UserFromService(updatedUser)) +} diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index d1b42750..38ea9bde 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -3,7 +3,6 @@ package repository import ( "context" "database/sql" - "encoding/json" "fmt" "strings" "time" @@ -667,12 +666,9 @@ func userEntityToService(u *dbent.User) *service.User { CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt, } - // Parse extra emails JSON array + // Parse extra emails JSON (supports both old []string and new []NotifyEmailEntry format) if u.BalanceNotifyExtraEmails != "" && u.BalanceNotifyExtraEmails != "[]" { - var emails []string - if err := json.Unmarshal([]byte(u.BalanceNotifyExtraEmails), &emails); err == nil { - out.BalanceNotifyExtraEmails = emails - } + out.BalanceNotifyExtraEmails = service.ParseNotifyEmails(u.BalanceNotifyExtraEmails) } return out } diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index 1792ef8d..913e1c40 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -3,7 +3,6 @@ package repository import ( "context" "database/sql" - "encoding/json" "errors" "fmt" "sort" @@ -563,16 +562,9 @@ func applyUserEntityToService(dst *service.User, src *dbent.User) { dst.UpdatedAt = src.UpdatedAt } -// marshalExtraEmails serializes a string slice to JSON for storage. -func marshalExtraEmails(emails []string) string { - if len(emails) == 0 { - return "[]" - } - data, err := json.Marshal(emails) - if err != nil { - return "[]" - } - return string(data) +// marshalExtraEmails serializes notify email entries to JSON for storage. +func marshalExtraEmails(entries []service.NotifyEmailEntry) string { + return service.MarshalNotifyEmails(entries) } // UpdateTotpSecret 更新用户的 TOTP 加密密钥 diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index 088565fa..d004f8b4 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -31,6 +31,7 @@ func RegisterUserRoutes( { notifyEmail.POST("/send-code", h.User.SendNotifyEmailCode) notifyEmail.POST("/verify", h.User.VerifyNotifyEmail) + notifyEmail.PUT("/toggle", h.User.ToggleNotifyEmail) notifyEmail.DELETE("", h.User.RemoveNotifyEmail) } diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index c2e96df1..60cb6233 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -34,6 +34,14 @@ type APIKeyAuthUserSnapshot struct { Role string `json:"role"` Balance float64 `json:"balance"` Concurrency int `json:"concurrency"` + + // Balance notification fields (required for CheckBalanceAfterDeduction) + Email string `json:"email"` + Username string `json:"username"` + BalanceNotifyEnabled bool `json:"balance_notify_enabled"` + BalanceNotifyThresholdType string `json:"balance_notify_threshold_type"` + BalanceNotifyThreshold *float64 `json:"balance_notify_threshold,omitempty"` + BalanceNotifyExtraEmails []NotifyEmailEntry `json:"balance_notify_extra_emails,omitempty"` } // APIKeyAuthGroupSnapshot 分组快照 diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 8069ed4f..711090c2 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -13,7 +13,7 @@ import ( "github.com/dgraph-io/ristretto" ) -const apiKeyAuthSnapshotVersion = 3 +const apiKeyAuthSnapshotVersion = 4 // v4: added balance notification fields to UserSnapshot type apiKeyAuthCacheConfig struct { l1Size int @@ -219,11 +219,17 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot { RateLimit1d: apiKey.RateLimit1d, RateLimit7d: apiKey.RateLimit7d, User: APIKeyAuthUserSnapshot{ - ID: apiKey.User.ID, - Status: apiKey.User.Status, - Role: apiKey.User.Role, - Balance: apiKey.User.Balance, - Concurrency: apiKey.User.Concurrency, + ID: apiKey.User.ID, + Status: apiKey.User.Status, + Role: apiKey.User.Role, + Balance: apiKey.User.Balance, + Concurrency: apiKey.User.Concurrency, + Email: apiKey.User.Email, + Username: apiKey.User.Username, + BalanceNotifyEnabled: apiKey.User.BalanceNotifyEnabled, + BalanceNotifyThresholdType: apiKey.User.BalanceNotifyThresholdType, + BalanceNotifyThreshold: apiKey.User.BalanceNotifyThreshold, + BalanceNotifyExtraEmails: apiKey.User.BalanceNotifyExtraEmails, }, } if apiKey.Group != nil { @@ -274,11 +280,17 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho RateLimit1d: snapshot.RateLimit1d, RateLimit7d: snapshot.RateLimit7d, User: &User{ - ID: snapshot.User.ID, - Status: snapshot.User.Status, - Role: snapshot.User.Role, - Balance: snapshot.User.Balance, - Concurrency: snapshot.User.Concurrency, + ID: snapshot.User.ID, + Status: snapshot.User.Status, + Role: snapshot.User.Role, + Balance: snapshot.User.Balance, + Concurrency: snapshot.User.Concurrency, + Email: snapshot.User.Email, + Username: snapshot.User.Username, + BalanceNotifyEnabled: snapshot.User.BalanceNotifyEnabled, + BalanceNotifyThresholdType: snapshot.User.BalanceNotifyThresholdType, + BalanceNotifyThreshold: snapshot.User.BalanceNotifyThreshold, + BalanceNotifyExtraEmails: snapshot.User.BalanceNotifyExtraEmails, }, } if snapshot.Group != nil { diff --git a/backend/internal/service/balance_notify_service.go b/backend/internal/service/balance_notify_service.go index 68202958..ba1c7037 100644 --- a/backend/internal/service/balance_notify_service.go +++ b/backend/internal/service/balance_notify_service.go @@ -176,13 +176,38 @@ func (s *BalanceNotifyService) isAccountQuotaNotifyEnabled(ctx context.Context) return val == "true" } -// getAccountQuotaNotifyEmails reads admin notification emails from settings. +// getAccountQuotaNotifyEmails reads admin notification emails from settings, +// filtering out disabled entries. Entries with email="" are resolved to the first admin's email. func (s *BalanceNotifyService) getAccountQuotaNotifyEmails(ctx context.Context) []string { raw, err := s.settingRepo.GetValue(ctx, SettingKeyAccountQuotaNotifyEmails) if err != nil || strings.TrimSpace(raw) == "" || raw == "[]" { return nil } - return parseJSONStringArray(raw) + + entries := ParseNotifyEmails(raw) + if len(entries) == 0 { + return nil + } + + var recipients []string + seen := make(map[string]bool) + for _, entry := range entries { + if entry.Disabled { + continue + } + email := strings.TrimSpace(entry.Email) + // email="" placeholder is not resolved here; admin should configure actual emails + if email == "" { + continue + } + lower := strings.ToLower(email) + if seen[lower] { + continue + } + seen[lower] = true + recipients = append(recipients, email) + } + return recipients } // getSiteName reads site name from settings with fallback. @@ -194,18 +219,36 @@ func (s *BalanceNotifyService) getSiteName(ctx context.Context) string { return name } -// collectBalanceNotifyRecipients collects all email recipients for balance notifications. +// collectBalanceNotifyRecipients collects all non-disabled email recipients for balance notifications. +// Entries with email="" are resolved to the user's primary email. func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []string { var recipients []string - if user.Email != "" { + seen := make(map[string]bool) + + for _, entry := range user.BalanceNotifyExtraEmails { + if entry.Disabled { + continue + } + email := strings.TrimSpace(entry.Email) + if email == "" { + email = user.Email // Resolve primary email placeholder + } + if email == "" { + continue + } + lower := strings.ToLower(email) + if seen[lower] { + continue + } + seen[lower] = true + recipients = append(recipients, email) + } + + // If no entries exist at all (legacy/empty), fall back to user's primary email + if len(user.BalanceNotifyExtraEmails) == 0 && user.Email != "" { recipients = append(recipients, user.Email) } - for _, extra := range user.BalanceNotifyExtraEmails { - email := strings.TrimSpace(extra) - if email != "" && !strings.EqualFold(email, user.Email) { - recipients = append(recipients, email) - } - } + return recipients } diff --git a/backend/internal/service/notify_email_entry.go b/backend/internal/service/notify_email_entry.go new file mode 100644 index 00000000..3caf689f --- /dev/null +++ b/backend/internal/service/notify_email_entry.go @@ -0,0 +1,107 @@ +package service + +import ( + "encoding/json" + "strings" +) + +// NotifyEmailEntry represents a notification email with enable/disable and verification state. +// Email="" is a placeholder for the "primary email" (user's registration email or first admin email). +type NotifyEmailEntry struct { + Email string `json:"email"` + Disabled bool `json:"disabled"` + Verified bool `json:"verified"` +} + +// parseNotifyEmails parses a JSON string into []NotifyEmailEntry. +// It auto-detects the format: +// - Old format ["email1","email2"] → converted to [{email, disabled:false, verified:true}, ...] +// - New format [{email,disabled,verified}, ...] → parsed directly +// +// Returns nil on empty/invalid input. +func ParseNotifyEmails(raw string) []NotifyEmailEntry { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "[]" { + return nil + } + + // Try parsing as new format first (array of objects) + var entries []NotifyEmailEntry + if err := json.Unmarshal([]byte(raw), &entries); err == nil && len(entries) > 0 { + // Verify it's actually the new format by checking the first element + // json.Unmarshal into []NotifyEmailEntry succeeds even for ["string"] + // because it tries to fit "string" into NotifyEmailEntry and gets zero values. + // We need to detect old format explicitly. + if !isOldStringArrayFormat(raw) { + return entries + } + } + + // Try parsing as old format (array of strings) + var emails []string + if err := json.Unmarshal([]byte(raw), &emails); err == nil { + result := make([]NotifyEmailEntry, 0, len(emails)) + for _, e := range emails { + e = strings.TrimSpace(e) + if e != "" { + result = append(result, NotifyEmailEntry{ + Email: e, + Disabled: false, + Verified: false, // Old format emails default to unverified + }) + } + } + return result + } + + return nil +} + +// isOldStringArrayFormat checks if the JSON is a string array like ["email1","email2"]. +func isOldStringArrayFormat(raw string) bool { + var arr []json.RawMessage + if err := json.Unmarshal([]byte(raw), &arr); err != nil || len(arr) == 0 { + return false + } + // Check if first element starts with a quote (string) vs { (object) + first := strings.TrimSpace(string(arr[0])) + return len(first) > 0 && first[0] == '"' +} + +// marshalNotifyEmails serializes []NotifyEmailEntry to JSON string. +func MarshalNotifyEmails(entries []NotifyEmailEntry) string { + if len(entries) == 0 { + return "[]" + } + data, err := json.Marshal(entries) + if err != nil { + return "[]" + } + return string(data) +} + +// filterEnabledEmails returns only non-disabled email addresses from entries. +// Empty email placeholders are skipped (caller should resolve them separately). +func FilterEnabledEmails(entries []NotifyEmailEntry) []string { + var result []string + for _, e := range entries { + if e.Disabled { + continue + } + email := strings.TrimSpace(e.Email) + if email != "" { + result = append(result, email) + } + } + return result +} + +// isPrimaryDisabled checks if the primary email placeholder (email="") exists and is disabled. +func IsPrimaryDisabled(entries []NotifyEmailEntry) bool { + for _, e := range entries { + if e.Email == "" { + return e.Disabled + } + } + return false // No primary placeholder = not disabled +} diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 773b84ba..0267040d 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -1272,13 +1272,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // Account quota notification result.AccountQuotaNotifyEnabled = settings[SettingKeyAccountQuotaNotifyEnabled] == "true" if raw := strings.TrimSpace(settings[SettingKeyAccountQuotaNotifyEmails]); raw != "" { - var emails []string - if err := json.Unmarshal([]byte(raw), &emails); err == nil { - result.AccountQuotaNotifyEmails = emails - } + result.AccountQuotaNotifyEmails = ParseNotifyEmails(raw) } if result.AccountQuotaNotifyEmails == nil { - result.AccountQuotaNotifyEmails = []string{} + result.AccountQuotaNotifyEmails = []NotifyEmailEntry{} } return result diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 274ec792..1346372d 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -113,7 +113,7 @@ type SystemSettings struct { // Account quota notification AccountQuotaNotifyEnabled bool - AccountQuotaNotifyEmails []string + AccountQuotaNotifyEmails []NotifyEmailEntry } type DefaultSubscriptionSetting struct { diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index 4ca31adc..d3d8c954 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -34,7 +34,7 @@ type User struct { BalanceNotifyEnabled bool BalanceNotifyThresholdType string // "fixed" (default) | "percentage" BalanceNotifyThreshold *float64 - BalanceNotifyExtraEmails []string + BalanceNotifyExtraEmails []NotifyEmailEntry TotalRecharged float64 APIKeys []APIKey diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index e6b9a210..6b75140f 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -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(` diff --git a/backend/migrations/104_migrate_notify_emails_to_struct.sql b/backend/migrations/104_migrate_notify_emails_to_struct.sql new file mode 100644 index 00000000..4356da4f --- /dev/null +++ b/backend/migrations/104_migrate_notify_emails_to_struct.sql @@ -0,0 +1,35 @@ +-- Migrate notification email lists from old []string format to new []NotifyEmailEntry format +-- Old: ["a@x.com", "b@x.com"] +-- New: [{"email":"a@x.com","disabled":false,"verified":true}, ...] +-- Existing emails are marked as verified=false (unverified), disabled=false (enabled) + +-- 1. User balance notification emails +UPDATE users +SET balance_notify_extra_emails = ( + SELECT COALESCE( + jsonb_agg(jsonb_build_object('email', elem::text, 'disabled', false, 'verified', false)), + '[]'::jsonb + )::text + FROM jsonb_array_elements_text(balance_notify_extra_emails::jsonb) AS elem +) +WHERE balance_notify_extra_emails IS NOT NULL + AND balance_notify_extra_emails <> '[]' + AND balance_notify_extra_emails <> '' + AND (balance_notify_extra_emails::jsonb -> 0) IS NOT NULL + AND jsonb_typeof(balance_notify_extra_emails::jsonb -> 0) = 'string'; + +-- 2. Admin account quota notification emails +UPDATE settings +SET value = ( + SELECT COALESCE( + jsonb_agg(jsonb_build_object('email', elem::text, 'disabled', false, 'verified', false)), + '[]'::jsonb + )::text + FROM jsonb_array_elements_text(value::jsonb) AS elem +) +WHERE key = 'account_quota_notify_emails' + AND value IS NOT NULL + AND value <> '[]' + AND value <> '' + AND (value::jsonb -> 0) IS NOT NULL + AND jsonb_typeof(value::jsonb -> 0) = 'string'; diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 5c5de2d1..230232df 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -4,7 +4,7 @@ */ import { apiClient } from '../client' -import type { CustomMenuItem, CustomEndpoint } from '@/types' +import type { CustomMenuItem, CustomEndpoint, NotifyEmailEntry } from '@/types' export interface DefaultSubscriptionSetting { group_id: number @@ -139,7 +139,7 @@ export interface SystemSettings { balance_low_notify_enabled: boolean balance_low_notify_threshold: number account_quota_notify_enabled: boolean - account_quota_notify_emails: string[] + account_quota_notify_emails: NotifyEmailEntry[] } export interface UpdateSettingsRequest { @@ -243,7 +243,7 @@ export interface UpdateSettingsRequest { balance_low_notify_enabled?: boolean balance_low_notify_threshold?: number account_quota_notify_enabled?: boolean - account_quota_notify_emails?: string[] + account_quota_notify_emails?: NotifyEmailEntry[] } /** diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 9ef0f59c..cd648270 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -4,7 +4,7 @@ */ import { apiClient } from './client' -import type { User, ChangePasswordRequest } from '@/types' +import type { User, ChangePasswordRequest, NotifyEmailEntry } from '@/types' /** * Get current user profile @@ -24,7 +24,7 @@ export async function updateProfile(profile: { username?: string balance_notify_enabled?: boolean balance_notify_threshold?: number | null - balance_notify_extra_emails?: string[] + balance_notify_extra_emails?: NotifyEmailEntry[] }): Promise { const { data } = await apiClient.put('/user', profile) return data @@ -73,13 +73,24 @@ export async function removeNotifyEmail(email: string): Promise { await apiClient.delete('/user/notify-email', { data: { email } }) } +/** + * Toggle a notify email's disabled state + * @param email - Email address (empty string for primary email placeholder) + * @param disabled - Whether to disable the email + */ +export async function toggleNotifyEmail(email: string, disabled: boolean): Promise { + const { data } = await apiClient.put('/user/notify-email/toggle', { email, disabled }) + return data +} + export const userAPI = { getProfile, updateProfile, changePassword, sendNotifyEmailCode, verifyNotifyEmail, - removeNotifyEmail + removeNotifyEmail, + toggleNotifyEmail } export default userAPI diff --git a/frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue b/frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue index 589cc9e8..1d88ad82 100644 --- a/frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue +++ b/frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue @@ -45,23 +45,26 @@ - +
-
- {{ userEmail }} - {{ t('profile.balanceNotify.primaryEmail') }} -
-
- - -
-
+
- {{ email }} -
-
@@ -100,8 +103,8 @@
- -
+ +
+

+ {{ t('profile.balanceNotify.maxEmailsReached') }} +

@@ -124,12 +130,15 @@