// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/me/change_email_verify.go package me import ( "context" "errors" "fmt" "html" "strings" "time" "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 ChangeEmailVerifyRequestDTO struct { VerificationCode string `json:"verification_code"` } type ChangeEmailVerifyResponseDTO struct { Message string `json:"message"` Success bool `json:"success"` NewEmail string `json:"new_email"` } type ChangeEmailVerifyService interface { Execute(ctx context.Context, req *ChangeEmailVerifyRequestDTO) (*ChangeEmailVerifyResponseDTO, error) } type changeEmailVerifyServiceImpl struct { config *config.Configuration logger *zap.Logger auditLogger auditlog.AuditLogger userGetByIDUseCase uc_user.UserGetByIDUseCase userUpdateUseCase uc_user.UserUpdateUseCase emailer mailgun.Emailer } func NewChangeEmailVerifyService( config *config.Configuration, logger *zap.Logger, auditLogger auditlog.AuditLogger, userGetByIDUseCase uc_user.UserGetByIDUseCase, userUpdateUseCase uc_user.UserUpdateUseCase, emailer mailgun.Emailer, ) ChangeEmailVerifyService { logger = logger.Named("ChangeEmailVerifyService") return &changeEmailVerifyServiceImpl{ config: config, logger: logger, auditLogger: auditLogger, userGetByIDUseCase: userGetByIDUseCase, userUpdateUseCase: userUpdateUseCase, emailer: emailer, } } func (svc *changeEmailVerifyServiceImpl) Execute(ctx context.Context, req *ChangeEmailVerifyRequestDTO) (*ChangeEmailVerifyResponseDTO, 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") } req.VerificationCode = strings.TrimSpace(req.VerificationCode) e := make(map[string]string) if req.VerificationCode == "" { e["verification_code"] = "Verification code is required" } else if len(req.VerificationCode) != 8 { e["verification_code"] = "Verification code must be 8 digits" } else { // Validate that code is numeric for _, c := range req.VerificationCode { if c < '0' || c > '9' { e["verification_code"] = "Verification code must contain only numbers" break } } } 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 there's a pending email change // if user.SecurityData.PendingEmail == "" { svc.logger.Warn("No pending email change found", zap.String("user_id", user.ID.String())) return nil, httperror.NewBadRequestError("No pending email change request found") } // // Check if verification code matches // svc.logger.Debug("Comparing verification codes", zap.String("user_id", user.ID.String()), zap.String("pending_email", validation.MaskEmail(user.SecurityData.PendingEmail)), zap.String("expected_code", user.SecurityData.PendingEmailCode), zap.String("provided_code", req.VerificationCode), zap.Int("expected_length", len(user.SecurityData.PendingEmailCode)), zap.Int("provided_length", len(req.VerificationCode)), zap.String("expected_bytes", fmt.Sprintf("%v", []byte(user.SecurityData.PendingEmailCode))), zap.String("provided_bytes", fmt.Sprintf("%v", []byte(req.VerificationCode)))) if user.SecurityData.PendingEmailCode != req.VerificationCode { svc.logger.Warn("Invalid verification code provided", zap.String("user_id", user.ID.String()), zap.String("pending_email", validation.MaskEmail(user.SecurityData.PendingEmail)), zap.String("expected_code", user.SecurityData.PendingEmailCode), zap.String("provided_code", req.VerificationCode), zap.Int("expected_length", len(user.SecurityData.PendingEmailCode)), zap.Int("provided_length", len(req.VerificationCode))) // Audit log failed attempt svc.auditLogger.LogAuth(ctx, "email_change_verify_failed", auditlog.OutcomeFailure, validation.MaskEmail(user.Email), "", map[string]string{ "user_id": user.ID.String(), "reason": "invalid_code", }) return nil, httperror.NewBadRequestError("Invalid verification code") } // // Check if verification code has expired // if time.Now().After(user.SecurityData.PendingEmailExpiry) { svc.logger.Warn("Verification code expired", zap.String("user_id", user.ID.String()), zap.Time("expiry", user.SecurityData.PendingEmailExpiry)) // Audit log expired attempt svc.auditLogger.LogAuth(ctx, "email_change_verify_failed", auditlog.OutcomeFailure, validation.MaskEmail(user.Email), "", map[string]string{ "user_id": user.ID.String(), "reason": "code_expired", }) // Clear expired pending email change user.SecurityData.PendingEmail = "" user.SecurityData.PendingEmailCode = "" user.SecurityData.PendingEmailExpiry = time.Time{} svc.userUpdateUseCase.Execute(ctx, user) return nil, httperror.NewBadRequestError("Verification code has expired. Please request a new email change.") } // // Perform the email change // oldEmail := user.Email newEmail := user.SecurityData.PendingEmail user.Email = newEmail user.SecurityData.PendingEmail = "" user.SecurityData.PendingEmailCode = "" user.SecurityData.PendingEmailExpiry = time.Time{} user.ModifiedAt = time.Now() // IMPORTANT: Email is still verified - don't reset WasEmailVerified flag // The user has proven ownership of the new email by verifying the code if err := svc.userUpdateUseCase.Execute(ctx, user); err != nil { svc.logger.Error("Failed to update user email", zap.String("user_id", user.ID.String()), zap.Error(err)) return nil, httperror.NewInternalServerError("Failed to complete email change. Please try again.") } // // Send confirmation emails // // Send confirmation to NEW email if err := svc.sendConfirmationEmail(ctx, newEmail, user.FirstName, oldEmail); err != nil { // Log error but don't fail the request - email change succeeded svc.logger.Warn("Failed to send confirmation email to new address", zap.String("email", validation.MaskEmail(newEmail)), zap.Error(err)) } // Send notification to OLD email if err := svc.sendOldEmailNotification(ctx, oldEmail, user.FirstName, newEmail); err != nil { // Log error but don't fail the request - email change succeeded svc.logger.Warn("Failed to send notification to old email", zap.String("email", validation.MaskEmail(oldEmail)), zap.Error(err)) } // // Audit log // svc.auditLogger.LogAuth(ctx, "email_changed", auditlog.OutcomeSuccess, validation.MaskEmail(newEmail), "", map[string]string{ "user_id": user.ID.String(), "old_email": validation.MaskEmail(oldEmail), "new_email": validation.MaskEmail(newEmail), }) svc.logger.Info("Email changed successfully", zap.String("user_id", user.ID.String()), zap.String("old_email", validation.MaskEmail(oldEmail)), zap.String("new_email", validation.MaskEmail(newEmail))) return &ChangeEmailVerifyResponseDTO{ Message: "Email address changed successfully. You can now log in with your new email address.", Success: true, NewEmail: newEmail, }, nil } func (svc *changeEmailVerifyServiceImpl) sendConfirmationEmail(ctx context.Context, newEmail, firstName, oldEmail string) error { subject := "Email Address Changed Successfully" sender := svc.emailer.GetSenderEmail() // Escape user input to prevent HTML injection safeFirstName := html.EscapeString(firstName) maskedOldEmail := validation.MaskEmail(oldEmail) htmlContent := fmt.Sprintf(`

Hi %s,

Your email address has been changed successfully!

Previous email: %s

New email: %s

You can now log in to MapleFile using your new email address.

If you did NOT make this change:

`, safeFirstName, maskedOldEmail, newEmail) return svc.emailer.Send(ctx, sender, subject, newEmail, htmlContent) } func (svc *changeEmailVerifyServiceImpl) sendOldEmailNotification(ctx context.Context, oldEmail, firstName, newEmail string) error { subject := "Your MapleFile Email Address Was Changed" 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 where their account was moved htmlContent := fmt.Sprintf(`

Hi %s,

This is a notification that your MapleFile account email address has been changed.

New email address: %s

This change was completed at %s.

If you made this change, you can ignore this email.

If you did NOT make this change:

This email was sent to your previous email address for security purposes.

`, safeFirstName, safeNewEmail, time.Now().Format("January 2, 2006 at 3:04 PM MST")) return svc.emailer.Send(ctx, sender, subject, oldEmail, htmlContent) }