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:
parent
61aa197b0b
commit
915b7a4a56
@ -178,7 +178,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
|||||||
WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled,
|
WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled,
|
||||||
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
||||||
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
|
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
|
||||||
AccountQuotaNotifyEmails: settings.AccountQuotaNotifyEmails,
|
AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(settings.AccountQuotaNotifyEmails),
|
||||||
PaymentEnabled: paymentCfg.Enabled,
|
PaymentEnabled: paymentCfg.Enabled,
|
||||||
PaymentMinAmount: paymentCfg.MinAmount,
|
PaymentMinAmount: paymentCfg.MinAmount,
|
||||||
PaymentMaxAmount: paymentCfg.MaxAmount,
|
PaymentMaxAmount: paymentCfg.MaxAmount,
|
||||||
@ -311,7 +311,7 @@ type UpdateSettingsRequest struct {
|
|||||||
// Balance low notification
|
// Balance low notification
|
||||||
BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"`
|
BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"`
|
||||||
BalanceLowNotifyThreshold *float64 `json:"balance_low_notify_threshold"`
|
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)
|
// Payment configuration (integrated into settings, full replace)
|
||||||
PaymentEnabled *bool `json:"payment_enabled"`
|
PaymentEnabled *bool `json:"payment_enabled"`
|
||||||
@ -902,9 +902,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
return previousSettings.BalanceLowNotifyThreshold
|
return previousSettings.BalanceLowNotifyThreshold
|
||||||
}(),
|
}(),
|
||||||
AccountQuotaNotifyEmails: func() []string {
|
AccountQuotaNotifyEmails: func() []service.NotifyEmailEntry {
|
||||||
if req.AccountQuotaNotifyEmails != nil {
|
if req.AccountQuotaNotifyEmails != nil {
|
||||||
return *req.AccountQuotaNotifyEmails
|
return dto.NotifyEmailEntriesToService(*req.AccountQuotaNotifyEmails)
|
||||||
}
|
}
|
||||||
return previousSettings.AccountQuotaNotifyEmails
|
return previousSettings.AccountQuotaNotifyEmails
|
||||||
}(),
|
}(),
|
||||||
@ -1056,7 +1056,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
EnableCCHSigning: updatedSettings.EnableCCHSigning,
|
EnableCCHSigning: updatedSettings.EnableCCHSigning,
|
||||||
BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled,
|
BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled,
|
||||||
BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold,
|
BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold,
|
||||||
AccountQuotaNotifyEmails: updatedSettings.AccountQuotaNotifyEmails,
|
AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(updatedSettings.AccountQuotaNotifyEmails),
|
||||||
PaymentEnabled: updatedPaymentCfg.Enabled,
|
PaymentEnabled: updatedPaymentCfg.Enabled,
|
||||||
PaymentMinAmount: updatedPaymentCfg.MinAmount,
|
PaymentMinAmount: updatedPaymentCfg.MinAmount,
|
||||||
PaymentMaxAmount: updatedPaymentCfg.MaxAmount,
|
PaymentMaxAmount: updatedPaymentCfg.MaxAmount,
|
||||||
|
|||||||
@ -26,7 +26,7 @@ func UserFromServiceShallow(u *service.User) *User {
|
|||||||
BalanceNotifyEnabled: u.BalanceNotifyEnabled,
|
BalanceNotifyEnabled: u.BalanceNotifyEnabled,
|
||||||
BalanceNotifyThresholdType: u.BalanceNotifyThresholdType,
|
BalanceNotifyThresholdType: u.BalanceNotifyThresholdType,
|
||||||
BalanceNotifyThreshold: u.BalanceNotifyThreshold,
|
BalanceNotifyThreshold: u.BalanceNotifyThreshold,
|
||||||
BalanceNotifyExtraEmails: u.BalanceNotifyExtraEmails,
|
BalanceNotifyExtraEmails: NotifyEmailEntriesFromService(u.BalanceNotifyExtraEmails),
|
||||||
TotalRecharged: u.TotalRecharged,
|
TotalRecharged: u.TotalRecharged,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
backend/internal/handler/dto/notify_email_entry.go
Normal file
43
backend/internal/handler/dto/notify_email_entry.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -152,7 +152,7 @@ type SystemSettings struct {
|
|||||||
// Balance low notification
|
// Balance low notification
|
||||||
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
||||||
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
||||||
AccountQuotaNotifyEmails []string `json:"account_quota_notify_emails"`
|
AccountQuotaNotifyEmails []NotifyEmailEntry `json:"account_quota_notify_emails"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DefaultSubscriptionSetting struct {
|
type DefaultSubscriptionSetting struct {
|
||||||
|
|||||||
@ -22,7 +22,7 @@ type User struct {
|
|||||||
BalanceNotifyEnabled bool `json:"balance_notify_enabled"`
|
BalanceNotifyEnabled bool `json:"balance_notify_enabled"`
|
||||||
BalanceNotifyThresholdType string `json:"balance_notify_threshold_type"`
|
BalanceNotifyThresholdType string `json:"balance_notify_threshold_type"`
|
||||||
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
|
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"`
|
TotalRecharged float64 `json:"total_recharged"`
|
||||||
|
|
||||||
APIKeys []APIKey `json:"api_keys,omitempty"`
|
APIKeys []APIKey `json:"api_keys,omitempty"`
|
||||||
|
|||||||
@ -214,3 +214,39 @@ func (h *UserHandler) RemoveNotifyEmail(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, dto.UserFromService(updatedUser))
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -667,12 +666,9 @@ func userEntityToService(u *dbent.User) *service.User {
|
|||||||
CreatedAt: u.CreatedAt,
|
CreatedAt: u.CreatedAt,
|
||||||
UpdatedAt: u.UpdatedAt,
|
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 != "[]" {
|
if u.BalanceNotifyExtraEmails != "" && u.BalanceNotifyExtraEmails != "[]" {
|
||||||
var emails []string
|
out.BalanceNotifyExtraEmails = service.ParseNotifyEmails(u.BalanceNotifyExtraEmails)
|
||||||
if err := json.Unmarshal([]byte(u.BalanceNotifyExtraEmails), &emails); err == nil {
|
|
||||||
out.BalanceNotifyExtraEmails = emails
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
@ -563,16 +562,9 @@ func applyUserEntityToService(dst *service.User, src *dbent.User) {
|
|||||||
dst.UpdatedAt = src.UpdatedAt
|
dst.UpdatedAt = src.UpdatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
// marshalExtraEmails serializes a string slice to JSON for storage.
|
// marshalExtraEmails serializes notify email entries to JSON for storage.
|
||||||
func marshalExtraEmails(emails []string) string {
|
func marshalExtraEmails(entries []service.NotifyEmailEntry) string {
|
||||||
if len(emails) == 0 {
|
return service.MarshalNotifyEmails(entries)
|
||||||
return "[]"
|
|
||||||
}
|
|
||||||
data, err := json.Marshal(emails)
|
|
||||||
if err != nil {
|
|
||||||
return "[]"
|
|
||||||
}
|
|
||||||
return string(data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateTotpSecret 更新用户的 TOTP 加密密钥
|
// UpdateTotpSecret 更新用户的 TOTP 加密密钥
|
||||||
|
|||||||
@ -31,6 +31,7 @@ func RegisterUserRoutes(
|
|||||||
{
|
{
|
||||||
notifyEmail.POST("/send-code", h.User.SendNotifyEmailCode)
|
notifyEmail.POST("/send-code", h.User.SendNotifyEmailCode)
|
||||||
notifyEmail.POST("/verify", h.User.VerifyNotifyEmail)
|
notifyEmail.POST("/verify", h.User.VerifyNotifyEmail)
|
||||||
|
notifyEmail.PUT("/toggle", h.User.ToggleNotifyEmail)
|
||||||
notifyEmail.DELETE("", h.User.RemoveNotifyEmail)
|
notifyEmail.DELETE("", h.User.RemoveNotifyEmail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,14 @@ type APIKeyAuthUserSnapshot struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Balance float64 `json:"balance"`
|
Balance float64 `json:"balance"`
|
||||||
Concurrency int `json:"concurrency"`
|
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 分组快照
|
// APIKeyAuthGroupSnapshot 分组快照
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import (
|
|||||||
"github.com/dgraph-io/ristretto"
|
"github.com/dgraph-io/ristretto"
|
||||||
)
|
)
|
||||||
|
|
||||||
const apiKeyAuthSnapshotVersion = 3
|
const apiKeyAuthSnapshotVersion = 4 // v4: added balance notification fields to UserSnapshot
|
||||||
|
|
||||||
type apiKeyAuthCacheConfig struct {
|
type apiKeyAuthCacheConfig struct {
|
||||||
l1Size int
|
l1Size int
|
||||||
@ -219,11 +219,17 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot {
|
|||||||
RateLimit1d: apiKey.RateLimit1d,
|
RateLimit1d: apiKey.RateLimit1d,
|
||||||
RateLimit7d: apiKey.RateLimit7d,
|
RateLimit7d: apiKey.RateLimit7d,
|
||||||
User: APIKeyAuthUserSnapshot{
|
User: APIKeyAuthUserSnapshot{
|
||||||
ID: apiKey.User.ID,
|
ID: apiKey.User.ID,
|
||||||
Status: apiKey.User.Status,
|
Status: apiKey.User.Status,
|
||||||
Role: apiKey.User.Role,
|
Role: apiKey.User.Role,
|
||||||
Balance: apiKey.User.Balance,
|
Balance: apiKey.User.Balance,
|
||||||
Concurrency: apiKey.User.Concurrency,
|
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 {
|
if apiKey.Group != nil {
|
||||||
@ -274,11 +280,17 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
|
|||||||
RateLimit1d: snapshot.RateLimit1d,
|
RateLimit1d: snapshot.RateLimit1d,
|
||||||
RateLimit7d: snapshot.RateLimit7d,
|
RateLimit7d: snapshot.RateLimit7d,
|
||||||
User: &User{
|
User: &User{
|
||||||
ID: snapshot.User.ID,
|
ID: snapshot.User.ID,
|
||||||
Status: snapshot.User.Status,
|
Status: snapshot.User.Status,
|
||||||
Role: snapshot.User.Role,
|
Role: snapshot.User.Role,
|
||||||
Balance: snapshot.User.Balance,
|
Balance: snapshot.User.Balance,
|
||||||
Concurrency: snapshot.User.Concurrency,
|
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 {
|
if snapshot.Group != nil {
|
||||||
|
|||||||
@ -176,13 +176,38 @@ func (s *BalanceNotifyService) isAccountQuotaNotifyEnabled(ctx context.Context)
|
|||||||
return val == "true"
|
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 {
|
func (s *BalanceNotifyService) getAccountQuotaNotifyEmails(ctx context.Context) []string {
|
||||||
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAccountQuotaNotifyEmails)
|
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAccountQuotaNotifyEmails)
|
||||||
if err != nil || strings.TrimSpace(raw) == "" || raw == "[]" {
|
if err != nil || strings.TrimSpace(raw) == "" || raw == "[]" {
|
||||||
return nil
|
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.
|
// getSiteName reads site name from settings with fallback.
|
||||||
@ -194,18 +219,36 @@ func (s *BalanceNotifyService) getSiteName(ctx context.Context) string {
|
|||||||
return name
|
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 {
|
func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []string {
|
||||||
var recipients []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)
|
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
|
return recipients
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
107
backend/internal/service/notify_email_entry.go
Normal file
107
backend/internal/service/notify_email_entry.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -1272,13 +1272,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|||||||
// Account quota notification
|
// Account quota notification
|
||||||
result.AccountQuotaNotifyEnabled = settings[SettingKeyAccountQuotaNotifyEnabled] == "true"
|
result.AccountQuotaNotifyEnabled = settings[SettingKeyAccountQuotaNotifyEnabled] == "true"
|
||||||
if raw := strings.TrimSpace(settings[SettingKeyAccountQuotaNotifyEmails]); raw != "" {
|
if raw := strings.TrimSpace(settings[SettingKeyAccountQuotaNotifyEmails]); raw != "" {
|
||||||
var emails []string
|
result.AccountQuotaNotifyEmails = ParseNotifyEmails(raw)
|
||||||
if err := json.Unmarshal([]byte(raw), &emails); err == nil {
|
|
||||||
result.AccountQuotaNotifyEmails = emails
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if result.AccountQuotaNotifyEmails == nil {
|
if result.AccountQuotaNotifyEmails == nil {
|
||||||
result.AccountQuotaNotifyEmails = []string{}
|
result.AccountQuotaNotifyEmails = []NotifyEmailEntry{}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@ -113,7 +113,7 @@ type SystemSettings struct {
|
|||||||
|
|
||||||
// Account quota notification
|
// Account quota notification
|
||||||
AccountQuotaNotifyEnabled bool
|
AccountQuotaNotifyEnabled bool
|
||||||
AccountQuotaNotifyEmails []string
|
AccountQuotaNotifyEmails []NotifyEmailEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
type DefaultSubscriptionSetting struct {
|
type DefaultSubscriptionSetting struct {
|
||||||
|
|||||||
@ -34,7 +34,7 @@ type User struct {
|
|||||||
BalanceNotifyEnabled bool
|
BalanceNotifyEnabled bool
|
||||||
BalanceNotifyThresholdType string // "fixed" (default) | "percentage"
|
BalanceNotifyThresholdType string // "fixed" (default) | "percentage"
|
||||||
BalanceNotifyThreshold *float64
|
BalanceNotifyThreshold *float64
|
||||||
BalanceNotifyExtraEmails []string
|
BalanceNotifyExtraEmails []NotifyEmailEntry
|
||||||
TotalRecharged float64
|
TotalRecharged float64
|
||||||
|
|
||||||
APIKeys []APIKey
|
APIKeys []APIKey
|
||||||
|
|||||||
@ -18,7 +18,7 @@ var (
|
|||||||
ErrInsufficientPerms = infraerrors.Forbidden("INSUFFICIENT_PERMISSIONS", "insufficient permissions")
|
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
|
// UserListFilters contains all filter options for listing users
|
||||||
type UserListFilters struct {
|
type UserListFilters struct {
|
||||||
@ -338,17 +338,21 @@ func (s *UserService) VerifyAndAddNotifyEmail(ctx context.Context, userID int64,
|
|||||||
|
|
||||||
// Check if already exists
|
// Check if already exists
|
||||||
for _, e := range user.BalanceNotifyExtraEmails {
|
for _, e := range user.BalanceNotifyExtraEmails {
|
||||||
if strings.EqualFold(e, email) {
|
if strings.EqualFold(e.Email, email) {
|
||||||
return nil // Already added
|
return nil // Already added
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check limit
|
// Check limit (total includes primary email="" placeholder + extra emails)
|
||||||
if len(user.BalanceNotifyExtraEmails) >= maxNotifyExtraEmails {
|
if len(user.BalanceNotifyExtraEmails) >= maxNotifyEmails {
|
||||||
return infraerrors.BadRequest("TOO_MANY_NOTIFY_EMAILS", fmt.Sprintf("maximum %d extra notification emails allowed", maxNotifyExtraEmails))
|
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)
|
return s.userRepo.Update(ctx, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -359,9 +363,9 @@ func (s *UserService) RemoveNotifyEmail(ctx context.Context, userID int64, email
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered := make([]string, 0, len(user.BalanceNotifyExtraEmails))
|
filtered := make([]NotifyEmailEntry, 0, len(user.BalanceNotifyExtraEmails))
|
||||||
for _, e := range user.BalanceNotifyExtraEmails {
|
for _, e := range user.BalanceNotifyExtraEmails {
|
||||||
if !strings.EqualFold(e, email) {
|
if !strings.EqualFold(e.Email, email) {
|
||||||
filtered = append(filtered, e)
|
filtered = append(filtered, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -369,6 +373,28 @@ func (s *UserService) RemoveNotifyEmail(ctx context.Context, userID int64, email
|
|||||||
return s.userRepo.Update(ctx, user)
|
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.
|
// buildNotifyVerifyEmailBody builds the HTML email body for notify email verification.
|
||||||
func buildNotifyVerifyEmailBody(code, siteName string) string {
|
func buildNotifyVerifyEmailBody(code, siteName string) string {
|
||||||
return fmt.Sprintf(`
|
return fmt.Sprintf(`
|
||||||
|
|||||||
35
backend/migrations/104_migrate_notify_emails_to_struct.sql
Normal file
35
backend/migrations/104_migrate_notify_emails_to_struct.sql
Normal file
@ -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';
|
||||||
@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiClient } from '../client'
|
import { apiClient } from '../client'
|
||||||
import type { CustomMenuItem, CustomEndpoint } from '@/types'
|
import type { CustomMenuItem, CustomEndpoint, NotifyEmailEntry } from '@/types'
|
||||||
|
|
||||||
export interface DefaultSubscriptionSetting {
|
export interface DefaultSubscriptionSetting {
|
||||||
group_id: number
|
group_id: number
|
||||||
@ -139,7 +139,7 @@ export interface SystemSettings {
|
|||||||
balance_low_notify_enabled: boolean
|
balance_low_notify_enabled: boolean
|
||||||
balance_low_notify_threshold: number
|
balance_low_notify_threshold: number
|
||||||
account_quota_notify_enabled: boolean
|
account_quota_notify_enabled: boolean
|
||||||
account_quota_notify_emails: string[]
|
account_quota_notify_emails: NotifyEmailEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateSettingsRequest {
|
export interface UpdateSettingsRequest {
|
||||||
@ -243,7 +243,7 @@ export interface UpdateSettingsRequest {
|
|||||||
balance_low_notify_enabled?: boolean
|
balance_low_notify_enabled?: boolean
|
||||||
balance_low_notify_threshold?: number
|
balance_low_notify_threshold?: number
|
||||||
account_quota_notify_enabled?: boolean
|
account_quota_notify_enabled?: boolean
|
||||||
account_quota_notify_emails?: string[]
|
account_quota_notify_emails?: NotifyEmailEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
import type { User, ChangePasswordRequest } from '@/types'
|
import type { User, ChangePasswordRequest, NotifyEmailEntry } from '@/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current user profile
|
* Get current user profile
|
||||||
@ -24,7 +24,7 @@ export async function updateProfile(profile: {
|
|||||||
username?: string
|
username?: string
|
||||||
balance_notify_enabled?: boolean
|
balance_notify_enabled?: boolean
|
||||||
balance_notify_threshold?: number | null
|
balance_notify_threshold?: number | null
|
||||||
balance_notify_extra_emails?: string[]
|
balance_notify_extra_emails?: NotifyEmailEntry[]
|
||||||
}): Promise<User> {
|
}): Promise<User> {
|
||||||
const { data } = await apiClient.put<User>('/user', profile)
|
const { data } = await apiClient.put<User>('/user', profile)
|
||||||
return data
|
return data
|
||||||
@ -73,13 +73,24 @@ export async function removeNotifyEmail(email: string): Promise<void> {
|
|||||||
await apiClient.delete('/user/notify-email', { data: { email } })
|
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<User> {
|
||||||
|
const { data } = await apiClient.put<User>('/user/notify-email/toggle', { email, disabled })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export const userAPI = {
|
export const userAPI = {
|
||||||
getProfile,
|
getProfile,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
changePassword,
|
changePassword,
|
||||||
sendNotifyEmailCode,
|
sendNotifyEmailCode,
|
||||||
verifyNotifyEmail,
|
verifyNotifyEmail,
|
||||||
removeNotifyEmail
|
removeNotifyEmail,
|
||||||
|
toggleNotifyEmail
|
||||||
}
|
}
|
||||||
|
|
||||||
export default userAPI
|
export default userAPI
|
||||||
|
|||||||
@ -45,23 +45,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Primary email (always shown, with toggle) -->
|
<!-- Email list with toggles -->
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('profile.balanceNotify.extraEmails') }}</label>
|
<label class="input-label">{{ t('profile.balanceNotify.extraEmails') }}</label>
|
||||||
<div class="space-y-2 mb-3">
|
<div class="space-y-2 mb-3">
|
||||||
<div class="flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
<!-- All email entries (primary placeholder + extra) -->
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ userEmail }}</span>
|
<div v-for="(entry, idx) in emailEntries" :key="idx"
|
||||||
<span class="text-xs text-gray-400">{{ t('profile.balanceNotify.primaryEmail') }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Verified extra emails with toggle -->
|
|
||||||
<div v-if="extraEmails.length > 0" class="space-y-2 mb-3">
|
|
||||||
<div v-for="email in extraEmails" :key="email"
|
|
||||||
class="flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
class="flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ email }}</span>
|
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-2">
|
<label class="relative inline-flex items-center cursor-pointer shrink-0">
|
||||||
<button @click="handleRemoveEmail(email)" class="text-red-500 hover:text-red-700 text-xs">
|
<input type="checkbox" :checked="!entry.disabled" @change="handleEmailToggle(entry)" class="sr-only peer" />
|
||||||
|
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:after:border-gray-500 peer-checked:bg-primary-600"></div>
|
||||||
|
</label>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||||
|
{{ entry.email === '' ? userEmail : entry.email }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<span v-if="entry.email === ''" class="text-xs text-gray-400">{{ t('profile.balanceNotify.primaryEmail') }}</span>
|
||||||
|
<span v-else-if="!entry.verified" class="text-xs text-yellow-500">{{ t('profile.balanceNotify.unverified') }}</span>
|
||||||
|
<button v-if="entry.email !== ''" @click="handleRemoveEmail(entry.email)" class="text-red-500 hover:text-red-700 text-xs">
|
||||||
{{ t('profile.balanceNotify.removeEmail') }}
|
{{ t('profile.balanceNotify.removeEmail') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -100,8 +103,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add new email input -->
|
<!-- Add new email input (hidden when at limit) -->
|
||||||
<div class="flex gap-2">
|
<div v-if="canAddMore" class="flex gap-2">
|
||||||
<input
|
<input
|
||||||
v-model="newEmail"
|
v-model="newEmail"
|
||||||
type="email"
|
type="email"
|
||||||
@ -117,6 +120,9 @@
|
|||||||
{{ t('common.add') }}
|
{{ t('common.add') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-else class="text-xs text-gray-400">
|
||||||
|
{{ t('profile.balanceNotify.maxEmailsReached') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@ -124,12 +130,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onUnmounted } from 'vue'
|
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { userAPI } from '@/api'
|
import { userAPI } from '@/api'
|
||||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||||
|
import type { NotifyEmailEntry } from '@/types'
|
||||||
|
|
||||||
|
const maxTotalEmails = 3 // primary + 2 extra
|
||||||
|
|
||||||
interface PendingEmail {
|
interface PendingEmail {
|
||||||
email: string
|
email: string
|
||||||
@ -144,7 +153,7 @@ interface PendingEmail {
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
threshold: number | null
|
threshold: number | null
|
||||||
extraEmails: string[]
|
extraEmails: NotifyEmailEntry[]
|
||||||
systemDefaultThreshold: number
|
systemDefaultThreshold: number
|
||||||
userEmail: string
|
userEmail: string
|
||||||
}>()
|
}>()
|
||||||
@ -155,14 +164,18 @@ const appStore = useAppStore()
|
|||||||
|
|
||||||
const notifyEnabled = ref(props.enabled)
|
const notifyEnabled = ref(props.enabled)
|
||||||
const customThreshold = ref<number | null>(props.threshold)
|
const customThreshold = ref<number | null>(props.threshold)
|
||||||
const extraEmails = ref<string[]>([...props.extraEmails])
|
const emailEntries = ref<NotifyEmailEntry[]>([...props.extraEmails])
|
||||||
const pendingEmails = ref<PendingEmail[]>([])
|
const pendingEmails = ref<PendingEmail[]>([])
|
||||||
const newEmail = ref('')
|
const newEmail = ref('')
|
||||||
const savingThreshold = ref(false)
|
const savingThreshold = ref(false)
|
||||||
|
|
||||||
|
const canAddMore = computed(() => {
|
||||||
|
return emailEntries.value.length + pendingEmails.value.length < maxTotalEmails
|
||||||
|
})
|
||||||
|
|
||||||
watch(() => props.enabled, (val) => { notifyEnabled.value = val })
|
watch(() => props.enabled, (val) => { notifyEnabled.value = val })
|
||||||
watch(() => props.threshold, (val) => { customThreshold.value = val })
|
watch(() => props.threshold, (val) => { customThreshold.value = val })
|
||||||
watch(() => props.extraEmails, (val) => { extraEmails.value = [...val] })
|
watch(() => props.extraEmails, (val) => { emailEntries.value = [...val] })
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
for (const pe of pendingEmails.value) {
|
for (const pe of pendingEmails.value) {
|
||||||
@ -194,10 +207,25 @@ const handleThresholdUpdate = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleEmailToggle(entry: NotifyEmailEntry) {
|
||||||
|
const newDisabled = !entry.disabled
|
||||||
|
try {
|
||||||
|
const updated = await userAPI.toggleNotifyEmail(entry.email, newDisabled)
|
||||||
|
authStore.user = updated
|
||||||
|
emailEntries.value = [...updated.balance_notify_extra_emails]
|
||||||
|
} catch (err: unknown) {
|
||||||
|
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function addPendingEmail() {
|
function addPendingEmail() {
|
||||||
const email = newEmail.value.trim()
|
const email = newEmail.value.trim()
|
||||||
if (!email) return
|
if (!email) return
|
||||||
if (email === props.userEmail || extraEmails.value.includes(email) || pendingEmails.value.some(p => p.email === email)) {
|
// Check duplicates against existing entries and pending
|
||||||
|
const isDuplicate = emailEntries.value.some(e =>
|
||||||
|
(e.email === '' ? props.userEmail : e.email).toLowerCase() === email.toLowerCase()
|
||||||
|
) || pendingEmails.value.some(p => p.email.toLowerCase() === email.toLowerCase())
|
||||||
|
if (isDuplicate) {
|
||||||
appStore.showError(t('profile.balanceNotify.emailDuplicate'))
|
appStore.showError(t('profile.balanceNotify.emailDuplicate'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -234,12 +262,12 @@ async function verifyPending(idx: number) {
|
|||||||
pe.verifying = true
|
pe.verifying = true
|
||||||
try {
|
try {
|
||||||
await userAPI.verifyNotifyEmail(pe.email, pe.code)
|
await userAPI.verifyNotifyEmail(pe.email, pe.code)
|
||||||
extraEmails.value.push(pe.email)
|
|
||||||
if (pe.timer) clearInterval(pe.timer)
|
if (pe.timer) clearInterval(pe.timer)
|
||||||
pendingEmails.value.splice(idx, 1)
|
pendingEmails.value.splice(idx, 1)
|
||||||
appStore.showSuccess(t('profile.balanceNotify.verifySuccess'))
|
appStore.showSuccess(t('profile.balanceNotify.verifySuccess'))
|
||||||
const updated = await userAPI.getProfile()
|
const updated = await userAPI.getProfile()
|
||||||
authStore.user = updated
|
authStore.user = updated
|
||||||
|
emailEntries.value = [...updated.balance_notify_extra_emails]
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||||
} finally {
|
} finally {
|
||||||
@ -250,10 +278,10 @@ async function verifyPending(idx: number) {
|
|||||||
const handleRemoveEmail = async (email: string) => {
|
const handleRemoveEmail = async (email: string) => {
|
||||||
try {
|
try {
|
||||||
await userAPI.removeNotifyEmail(email)
|
await userAPI.removeNotifyEmail(email)
|
||||||
extraEmails.value = extraEmails.value.filter(e => e !== email)
|
|
||||||
appStore.showSuccess(t('profile.balanceNotify.removeSuccess'))
|
appStore.showSuccess(t('profile.balanceNotify.removeSuccess'))
|
||||||
const updated = await userAPI.getProfile()
|
const updated = await userAPI.getProfile()
|
||||||
authStore.user = updated
|
authStore.user = updated
|
||||||
|
emailEntries.value = [...updated.balance_notify_extra_emails]
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -930,6 +930,8 @@ export default {
|
|||||||
removeEmail: 'Remove',
|
removeEmail: 'Remove',
|
||||||
removeSuccess: 'Email removed',
|
removeSuccess: 'Email removed',
|
||||||
emailDuplicate: 'This email already exists',
|
emailDuplicate: 'This email already exists',
|
||||||
|
maxEmailsReached: 'Maximum number of notification emails reached',
|
||||||
|
unverified: 'Unverified',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -934,6 +934,8 @@ export default {
|
|||||||
removeEmail: '移除',
|
removeEmail: '移除',
|
||||||
removeSuccess: '邮箱已移除',
|
removeSuccess: '邮箱已移除',
|
||||||
emailDuplicate: '该邮箱已存在',
|
emailDuplicate: '该邮箱已存在',
|
||||||
|
maxEmailsReached: '已达到通知邮箱数量上限',
|
||||||
|
unverified: '未验证',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,16 @@ export interface FetchOptions {
|
|||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Notification Types ====================
|
||||||
|
|
||||||
|
/** Notification email entry with enable/disable and verification state.
|
||||||
|
* email="" is a placeholder for the primary email (user's registration email or admin email). */
|
||||||
|
export interface NotifyEmailEntry {
|
||||||
|
email: string
|
||||||
|
disabled: boolean
|
||||||
|
verified: boolean
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== User & Auth Types ====================
|
// ==================== User & Auth Types ====================
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
@ -35,7 +45,7 @@ export interface User {
|
|||||||
allowed_groups: number[] | null // Allowed group IDs (null = all non-exclusive groups)
|
allowed_groups: number[] | null // Allowed group IDs (null = all non-exclusive groups)
|
||||||
balance_notify_enabled: boolean
|
balance_notify_enabled: boolean
|
||||||
balance_notify_threshold: number | null
|
balance_notify_threshold: number | null
|
||||||
balance_notify_extra_emails: string[]
|
balance_notify_extra_emails: NotifyEmailEntry[]
|
||||||
subscriptions?: UserSubscription[] // User's active subscriptions
|
subscriptions?: UserSubscription[] // User's active subscriptions
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
|||||||
@ -2726,8 +2726,12 @@
|
|||||||
<div v-if="form.account_quota_notify_enabled">
|
<div v-if="form.account_quota_notify_enabled">
|
||||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.settings.quotaNotify.emails') }}</label>
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.settings.quotaNotify.emails') }}</label>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div v-for="(_, index) in (form.account_quota_notify_emails || [])" :key="index" class="flex items-center gap-2">
|
<div v-for="(entry, index) in (form.account_quota_notify_emails || [])" :key="index" class="flex items-center gap-2">
|
||||||
<input v-model="form.account_quota_notify_emails[index]" type="email" class="input flex-1" :placeholder="t('admin.settings.quotaNotify.emailPlaceholder')" />
|
<label class="relative inline-flex items-center cursor-pointer shrink-0">
|
||||||
|
<input type="checkbox" :checked="!entry.disabled" @change="entry.disabled = !entry.disabled" class="sr-only peer" />
|
||||||
|
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:after:border-gray-500 peer-checked:bg-primary-600"></div>
|
||||||
|
</label>
|
||||||
|
<input v-model="entry.email" type="email" class="input flex-1" :placeholder="t('admin.settings.quotaNotify.emailPlaceholder')" />
|
||||||
<button @click="form.account_quota_notify_emails.splice(index, 1)" class="btn btn-secondary px-2" type="button">
|
<button @click="form.account_quota_notify_emails.splice(index, 1)" class="btn btn-secondary px-2" type="button">
|
||||||
<Icon name="x" size="xs" class="h-4 w-4" />
|
<Icon name="x" size="xs" class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@ -3024,7 +3028,7 @@ const form = reactive<SettingsForm>({
|
|||||||
balance_low_notify_enabled: false,
|
balance_low_notify_enabled: false,
|
||||||
balance_low_notify_threshold: 0,
|
balance_low_notify_threshold: 0,
|
||||||
account_quota_notify_enabled: false,
|
account_quota_notify_enabled: false,
|
||||||
account_quota_notify_emails: [] as string[]
|
account_quota_notify_emails: [] as { email: string; disabled: boolean; verified: boolean }[]
|
||||||
})
|
})
|
||||||
|
|
||||||
// Proxies for web search emulation ProxySelector
|
// Proxies for web search emulation ProxySelector
|
||||||
@ -3249,7 +3253,7 @@ const addQuotaNotifyEmail = () => {
|
|||||||
if (!form.account_quota_notify_emails) {
|
if (!form.account_quota_notify_emails) {
|
||||||
form.account_quota_notify_emails = []
|
form.account_quota_notify_emails = []
|
||||||
}
|
}
|
||||||
form.account_quota_notify_emails.push('')
|
form.account_quota_notify_emails.push({ email: '', disabled: false, verified: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// LinuxDo OAuth redirect URL suggestion
|
// LinuxDo OAuth redirect URL suggestion
|
||||||
@ -3595,7 +3599,7 @@ async function saveSettings() {
|
|||||||
balance_low_notify_enabled: form.balance_low_notify_enabled,
|
balance_low_notify_enabled: form.balance_low_notify_enabled,
|
||||||
balance_low_notify_threshold: Number(form.balance_low_notify_threshold) || 0,
|
balance_low_notify_threshold: Number(form.balance_low_notify_threshold) || 0,
|
||||||
account_quota_notify_enabled: form.account_quota_notify_enabled,
|
account_quota_notify_enabled: form.account_quota_notify_enabled,
|
||||||
account_quota_notify_emails: (form.account_quota_notify_emails || []).filter((e: string) => e.trim() !== ''),
|
account_quota_notify_emails: (form.account_quota_notify_emails || []).filter((e) => e.email.trim() !== ''),
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await adminAPI.settings.updateSettings(payload)
|
const updated = await adminAPI.settings.updateSettings(payload)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user