// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/me/change_email_request.go package me import ( "context" "crypto/rand" "errors" "fmt" "html" "net/mail" "strings" "time" "github.com/awnumar/memguard" "github.com/gocql/gocql" "go.uber.org/zap" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants" uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/auditlog" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/emailer/mailgun" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation" ) type ChangeEmailRequestDTO struct { NewEmail string `json:"new_email"` } type ChangeEmailRequestResponseDTO struct { Message string `json:"message"` Success bool `json:"success"` } type ChangeEmailRequestService interface { Execute(ctx context.Context, req *ChangeEmailRequestDTO) (*ChangeEmailRequestResponseDTO, error) } type changeEmailRequestServiceImpl struct { config *config.Configuration logger *zap.Logger auditLogger auditlog.AuditLogger userGetByIDUseCase uc_user.UserGetByIDUseCase userGetByEmailUseCase uc_user.UserGetByEmailUseCase userUpdateUseCase uc_user.UserUpdateUseCase emailer mailgun.Emailer } func NewChangeEmailRequestService( config *config.Configuration, logger *zap.Logger, auditLogger auditlog.AuditLogger, userGetByIDUseCase uc_user.UserGetByIDUseCase, userGetByEmailUseCase uc_user.UserGetByEmailUseCase, userUpdateUseCase uc_user.UserUpdateUseCase, emailer mailgun.Emailer, ) ChangeEmailRequestService { logger = logger.Named("ChangeEmailRequestService") return &changeEmailRequestServiceImpl{ config: config, logger: logger, auditLogger: auditLogger, userGetByIDUseCase: userGetByIDUseCase, userGetByEmailUseCase: userGetByEmailUseCase, userUpdateUseCase: userUpdateUseCase, emailer: emailer, } } func (svc *changeEmailRequestServiceImpl) Execute(ctx context.Context, req *ChangeEmailRequestDTO) (*ChangeEmailRequestResponseDTO, error) { // // Get user from context // userID, ok := ctx.Value(constants.SessionUserID).(gocql.UUID) if !ok { svc.logger.Error("Failed getting user id from context") return nil, errors.New("user id not found in context") } // // Validation // if req == nil { svc.logger.Warn("Request is nil") return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "Request is required") } // Sanitize and validate new email req.NewEmail = strings.ToLower(strings.TrimSpace(req.NewEmail)) e := make(map[string]string) if req.NewEmail == "" { e["new_email"] = "New email address is required" } else { // Validate email format if _, err := mail.ParseAddress(req.NewEmail); err != nil { e["new_email"] = "Please enter a valid email address" } } if len(e) != 0 { svc.logger.Warn("Validation failed", zap.Any("errors", e)) return nil, httperror.NewForBadRequest(&e) } // // Get current user // user, err := svc.userGetByIDUseCase.Execute(ctx, userID) if err != nil { svc.logger.Error("Failed getting user by ID", zap.Error(err)) return nil, err } if user == nil { err := fmt.Errorf("user is nil after lookup for id: %v", userID.String()) svc.logger.Error("User not found", zap.Error(err)) return nil, err } // // Check if new email is same as current // if req.NewEmail == user.Email { e["new_email"] = "New email is the same as your current email" return nil, httperror.NewForBadRequest(&e) } // // Check if new email is already in use // existingUser, err := svc.userGetByEmailUseCase.Execute(ctx, req.NewEmail) if err != nil { svc.logger.Error("Failed checking if email exists", zap.String("email", validation.MaskEmail(req.NewEmail)), zap.Error(err)) return nil, err } if existingUser != nil { svc.logger.Warn("Attempted to change to email already in use", zap.String("user_id", userID.String()), zap.String("existing_user_id", existingUser.ID.String()), zap.String("new_email", validation.MaskEmail(req.NewEmail))) e["new_email"] = "This email address is already in use" return nil, httperror.NewForBadRequest(&e) } // // Generate verification code // verificationCode := svc.generateVerificationCode() verificationExpiry := time.Now().Add(24 * time.Hour) svc.logger.Debug("Generated verification code", zap.String("user_id", user.ID.String()), zap.String("code", verificationCode), zap.Int("code_length", len(verificationCode)), zap.String("code_bytes", fmt.Sprintf("%v", []byte(verificationCode)))) // // Store pending email change // user.SecurityData.PendingEmail = req.NewEmail user.SecurityData.PendingEmailCode = verificationCode user.SecurityData.PendingEmailExpiry = verificationExpiry user.ModifiedAt = time.Now() if err := svc.userUpdateUseCase.Execute(ctx, user); err != nil { svc.logger.Error("Failed to update user with pending email", zap.String("user_id", user.ID.String()), zap.Error(err)) return nil, httperror.NewInternalServerError("Failed to initiate email change. Please try again.") } // Verify code was saved correctly by reading it back verifyUser, err := svc.userGetByIDUseCase.Execute(ctx, userID) if err == nil && verifyUser != nil { svc.logger.Debug("Verified stored code after save", zap.String("user_id", userID.String()), zap.String("stored_code", verifyUser.SecurityData.PendingEmailCode), zap.String("original_code", verificationCode), zap.Bool("codes_match", verifyUser.SecurityData.PendingEmailCode == verificationCode)) } // // Send verification email to NEW address // if err := svc.sendVerificationEmail(ctx, req.NewEmail, user.FirstName, verificationCode); err != nil { svc.logger.Error("Failed to send verification email to new address", zap.String("email", validation.MaskEmail(req.NewEmail)), zap.Error(err)) // Rollback: Clear pending email change user.SecurityData.PendingEmail = "" user.SecurityData.PendingEmailCode = "" user.SecurityData.PendingEmailExpiry = time.Time{} svc.userUpdateUseCase.Execute(ctx, user) return nil, httperror.NewInternalServerError("Failed to send verification email. Please try again.") } // // Send notification to OLD address // if err := svc.sendChangeNotificationEmail(ctx, user.Email, user.FirstName, req.NewEmail); err != nil { // Log error but don't fail the request - notification is informational svc.logger.Warn("Failed to send change notification to old email", zap.String("email", validation.MaskEmail(user.Email)), zap.Error(err)) } // // Audit log // svc.auditLogger.LogAuth(ctx, "email_change_requested", auditlog.OutcomeSuccess, validation.MaskEmail(user.Email), "", map[string]string{ "user_id": user.ID.String(), "new_email": validation.MaskEmail(req.NewEmail), }) svc.logger.Info("Email change requested successfully", zap.String("user_id", user.ID.String()), zap.String("old_email", validation.MaskEmail(user.Email)), zap.String("new_email", validation.MaskEmail(req.NewEmail))) return &ChangeEmailRequestResponseDTO{ Message: fmt.Sprintf("Verification code sent to %s. Please check your email and verify within 24 hours.", req.NewEmail), Success: true, }, nil } func (svc *changeEmailRequestServiceImpl) generateVerificationCode() string { // Generate random 8-digit code for increased entropy // 8 digits = 90,000,000 combinations vs 6 digits = 900,000 b := make([]byte, 4) rand.Read(b) defer memguard.WipeBytes(b) // SECURITY: Wipe random bytes after use code := int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3]) code = (code % 90000000) + 10000000 return fmt.Sprintf("%d", code) } func (svc *changeEmailRequestServiceImpl) sendVerificationEmail(ctx context.Context, email, firstName, code string) error { subject := "Verify Your New Email Address" sender := svc.emailer.GetSenderEmail() // Escape user input to prevent HTML injection safeFirstName := html.EscapeString(firstName) htmlContent := fmt.Sprintf(`
You requested to change your email address on MapleFile. Please verify your new email address by entering this code:
This code will expire in 24 hours.
If you didn't request this change, please ignore this email and contact support immediately.
Your account email will not be changed until you verify this code.
`, safeFirstName, code) return svc.emailer.Send(ctx, sender, subject, email, htmlContent) } func (svc *changeEmailRequestServiceImpl) sendChangeNotificationEmail(ctx context.Context, oldEmail, firstName, newEmail string) error { subject := "Email Change Request - Action Required" sender := svc.emailer.GetSenderEmail() // Escape user input to prevent HTML injection safeFirstName := html.EscapeString(firstName) safeNewEmail := html.EscapeString(newEmail) // Show full new email so user knows what email is being requested htmlContent := fmt.Sprintf(`Important Security Notice: A request was made to change your MapleFile account email address.
New email address: %s
If you made this request, you can safely ignore this email. The new email address must be verified before the change takes effect.
If you did NOT request this change:
This email change request will expire in 24 hours if not verified.
`, safeFirstName, safeNewEmail) return svc.emailer.Send(ctx, sender, subject, oldEmail, htmlContent) }