package service import ( "context" "errors" "fmt" "net/mail" "strings" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) // BindEmailIdentity verifies and binds a local email/password identity to the current user. func (s *AuthService) BindEmailIdentity( ctx context.Context, userID int64, email string, verifyCode string, password string, ) (*User, error) { if s == nil { return nil, ErrServiceUnavailable } normalizedEmail, err := normalizeEmailForIdentityBinding(email) if err != nil { return nil, err } if isReservedEmail(normalizedEmail) { return nil, ErrEmailReserved } if strings.TrimSpace(password) == "" { return nil, ErrPasswordRequired } if err := s.VerifyOAuthEmailCode(ctx, normalizedEmail, verifyCode); err != nil { return nil, err } currentUser, err := s.userRepo.GetByID(ctx, userID) if err != nil { return nil, err } existingUser, err := s.userRepo.GetByEmail(ctx, normalizedEmail) switch { case err == nil && existingUser != nil && existingUser.ID != userID: return nil, ErrEmailExists case err != nil && !errors.Is(err, ErrUserNotFound): return nil, ErrServiceUnavailable } hashedPassword, err := s.HashPassword(password) if err != nil { return nil, fmt.Errorf("hash password: %w", err) } firstRealEmailBind := !hasBindableEmailIdentitySubject(currentUser.Email) currentUser.Email = normalizedEmail currentUser.PasswordHash = hashedPassword if err := s.userRepo.Update(ctx, currentUser); err != nil { if errors.Is(err, ErrEmailExists) { return nil, ErrEmailExists } return nil, ErrServiceUnavailable } if firstRealEmailBind { if err := s.ApplyProviderDefaultSettingsOnFirstBind(ctx, userID, "email"); err != nil { return nil, fmt.Errorf("apply email first bind defaults: %w", err) } } return currentUser, nil } // SendEmailIdentityBindCode sends a verification code for authenticated email binding flows. func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int64, email string) error { if s == nil { return ErrServiceUnavailable } normalizedEmail, err := normalizeEmailForIdentityBinding(email) if err != nil { return err } if isReservedEmail(normalizedEmail) { return ErrEmailReserved } if s.emailService == nil { return ErrServiceUnavailable } if _, err := s.userRepo.GetByID(ctx, userID); err != nil { if errors.Is(err, ErrUserNotFound) { return ErrUserNotFound } return ErrServiceUnavailable } existingUser, err := s.userRepo.GetByEmail(ctx, normalizedEmail) switch { case err == nil && existingUser != nil && existingUser.ID != userID: return ErrEmailExists case err != nil && !errors.Is(err, ErrUserNotFound): return ErrServiceUnavailable } siteName := "Sub2API" if s.settingService != nil { siteName = s.settingService.GetSiteName(ctx) } return s.emailService.SendVerifyCode(ctx, normalizedEmail, siteName) } func normalizeEmailForIdentityBinding(email string) (string, error) { normalized := strings.ToLower(strings.TrimSpace(email)) if normalized == "" || len(normalized) > 255 { return "", infraerrors.BadRequest("INVALID_EMAIL", "invalid email") } if _, err := mail.ParseAddress(normalized); err != nil { return "", infraerrors.BadRequest("INVALID_EMAIL", "invalid email") } return normalized, nil } func hasBindableEmailIdentitySubject(email string) bool { normalized := strings.ToLower(strings.TrimSpace(email)) return normalized != "" && !isReservedEmail(normalized) }