package auth import ( "context" "net/mail" "strings" "github.com/perfect-panel/server/internal/config" usermodel "github.com/perfect-panel/server/internal/model/user" "gorm.io/gorm" ) // IsEmailDomainWhitelisted checks if the email's domain is in the comma-separated whitelist. // Returns false if the email format is invalid. func IsEmailDomainWhitelisted(email, whitelistCSV string) bool { if whitelistCSV == "" { return false } _, domain, ok := parseStrictEmail(email) if !ok { return false } for _, d := range strings.Split(whitelistCSV, ",") { if strings.ToLower(strings.TrimSpace(d)) == domain { return true } } return false } func ShouldGrantTrialForEmail(register config.RegisterConfig, email string) bool { if !register.EnableTrial { return false } if !IsValidTrialEmail(email) { return false } // 无论白名单是否启用,泛域名邮箱(含 + 别名或 Gmail 点号)始终拒绝赠送 if IsDisposableAlias(email) { return false } if isConfusableGmailDomain(emailDomain(email)) { return false } if !register.EnableTrialEmailWhitelist { return true } if register.TrialEmailDomainWhitelist == "" { return false } if !IsEmailDomainWhitelisted(email, register.TrialEmailDomainWhitelist) { return false } return true } // IsTrialConfigReady verifies that trial auto-grant has all required config. func IsTrialConfigReady(register config.RegisterConfig) bool { return register.EnableTrial && register.TrialSubscribe > 0 && register.TrialTime > 0 && strings.TrimSpace(register.TrialTimeUnit) != "" } // ShouldAutoGrantTrialOnPublicEmailFlows defines whether browser/email-originated // flows may auto-create a trial subscription. Email-specific abuse protection // is still handled by ShouldGrantTrialForEmail and NormalizedEmailHasTrial. func ShouldAutoGrantTrialOnPublicEmailFlows(register config.RegisterConfig) bool { return IsTrialConfigReady(register) } // IsDisposableAlias detects Gmail dot trick and + alias abuse. // For Gmail-like domains, local part containing "." or "+" is rejected. // For all other domains, only "+" alias is rejected. func IsDisposableAlias(email string) bool { local, domain, ok := parseStrictEmail(email) if !ok { return false } // All domains: reject + alias if strings.ContainsRune(local, '+') { return true } // Gmail-like domains: reject dots in local part if isGmailLikeDomain(domain) && strings.ContainsRune(local, '.') { return true } return false } // NormalizeEmail returns a canonical form of the email for trial deduplication. // Strips "+" aliases universally (user+tag@any.com → user@any.com). // Removes dots from local part for Gmail-like providers (gmail.com, googlemail.com). func NormalizeEmail(email string) string { email = strings.ToLower(strings.TrimSpace(email)) local, domain, ok := parseStrictEmail(email) if !ok { return email } // Strip + alias if idx := strings.IndexByte(local, '+'); idx != -1 { local = local[:idx] } // Remove dots for Gmail-like providers that ignore dots in local part if isGmailLikeDomain(domain) { local = strings.ReplaceAll(local, ".", "") } return local + "@" + domain } func isGmailLikeDomain(domain string) bool { switch domain { case "gmail.com", "googlemail.com": return true } return false } func IsValidTrialEmail(email string) bool { local, domain, ok := parseStrictEmail(email) if !ok { return false } return local != "" && domain != "" } func parseStrictEmail(email string) (local, domain string, ok bool) { email = strings.ToLower(strings.TrimSpace(email)) if email == "" || strings.ContainsAny(email, " \t\r\n") { return "", "", false } addr, err := mail.ParseAddress(email) if err != nil || addr.Address != email || addr.Name != "" { return "", "", false } parts := strings.Split(addr.Address, "@") if len(parts) != 2 { return "", "", false } local = strings.TrimSpace(parts[0]) domain = strings.Trim(strings.TrimSpace(parts[1]), ".") if local == "" || domain == "" || strings.Contains(domain, "..") || !strings.Contains(domain, ".") { return "", "", false } return local, domain, true } func emailDomain(email string) string { _, domain, ok := parseStrictEmail(email) if !ok { return "" } return domain } func isConfusableGmailDomain(domain string) bool { switch strings.ToLower(strings.TrimSpace(domain)) { case "gmaial.com", "gmial.com", "gmai.com", "gamil.com", "gmal.com", "gmail.co", "gmail.con": return true } return false } // NormalizedEmailHasTrial returns true if any user with the same normalized email // already holds a trial subscription. Only performs the cross-user DB check when // normalization actually changes the email (i.e., dots removed or + alias stripped). func NormalizedEmailHasTrial(ctx context.Context, db *gorm.DB, email string, trialSubscribeId int64) bool { normalized := NormalizeEmail(email) if normalized == strings.ToLower(strings.TrimSpace(email)) { return false // normalization changed nothing, skip cross-user check } parts := strings.SplitN(normalized, "@", 2) if len(parts) != 2 { return false } domain := parts[1] var authMethods []usermodel.AuthMethods if err := db.WithContext(ctx). Model(&usermodel.AuthMethods{}). Select("user_id, auth_identifier"). Where("auth_type = ? AND auth_identifier LIKE ?", "email", "%@"+domain). Find(&authMethods).Error; err != nil { return false } for _, am := range authMethods { if NormalizeEmail(am.AuthIdentifier) != normalized { continue } var count int64 if err := db.WithContext(ctx). Model(&usermodel.Subscribe{}). Where("user_id = ? AND subscribe_id = ?", am.UserId, trialSubscribeId). Count(&count).Error; err != nil { continue } if count > 0 { return true } } return false }