BIO.RE
User

Change Email (Request)

Start an email-change flow by re-confirming the password and sending a verification link to the NEW email. Account email only flips after the new address is verified.

POST /api/v1/users/change-email โ€” ๐Ÿ”‘ Bearer ยท Rate limit: 3 req / hour

Initiates an email-change flow. Re-confirms the current password, validates the new email (format + disposable-domain + MX + GDPR tombstone + uniqueness), creates a fresh verification token, and queues a verification email to the new address through the active email provider (admin-managed). The account's User.email does not change here โ€” it only flips after the user clicks the link in the verification email (separate verify endpoint, in the auth module).

The verification email is sent to the NEW email, not the current one. The current account email is unchanged until the link is followed. This means a stolen access token cannot quietly hijack the account by swapping email โ€” the attacker would also need access to the new mailbox.

The dispatch is delegated to the active email provider (admin-managed via external.email.active_provider; failover handled server-side). Vendor identity stays in admin โ€” this endpoint guarantees delivery via whichever provider is active. The send itself is enqueued via the notification pipeline (BullMQ G2) โ€” failures to deliver SMTP-side are logged but do not fail the request.

Request

Body โ€” ChangeEmailDto

FieldTypeRequiredValidationNotes
newEmailstringโœ“IsEmail()The desired new email โ€” server normalizes via .toLowerCase().trim()
passwordstringโœ“MinLength(8)Current account password โ€” re-confirms via bcrypt
HeaderRequiredNotes
Authorization: Bearer <accessToken>โœ“JWT from POST /auth/login

Response

200 OK โ€” ApiResponseOf<EmailChangeMessageDto>

{
  "success": true,
  "data": {
    "message": "Verification email sent to your new address. Please check your inbox."
  }
}
FieldTypeNotes
messagestringLocalizable confirmation message โ€” render under the form, no further fields

Errors

HTTPcode / i18nKeyReason
400(DTO validation)newEmail not a valid email format; password shorter than 8 chars
400user.change_email.password_requiredAccount has no passwordHash (OAuth-only โ€” set a password via the change-password flow first)
400user.change_email.password_incorrectBcrypt password check failed
400user.change_email.email_sameNew email equals current User.email after normalization
400user.change_email.email_invalidEmail validator rejected (disposable domain or MX failure)
401(guard)Missing / invalid bearer token
404user.change_email.not_foundToken decoded but user row missing
409user.change_email.email_takenActive user already holds this email (also caught from Prisma P2002 race)
409user.change_email.email_previously_deletedEmail matches the deleted-tombstone hash (deleted_<sha256(email)>@deleted.bio.re) โ€” refuse to reuse for compliance
429(throttle)Rate limit exceeded (3 req/hour)

Side effects

  1. Normalize submitted email; lookup User; reject if no passwordHash.
  2. Bcrypt-compare password against User.passwordHash.
  3. Reject if newEmail === user.email (no-op).
  4. Email validator โ€” checks disposable-domain blocklist + MX record presence.
  5. Reject if any other user already holds this email.
  6. GDPR tombstone check โ€” compute sha256(newEmail); if a row exists with email = "deleted_<hash>@deleted.bio.re" AND status = DELETED, reject. (Prevents reusing emails of accounts that were deleted under GDPR right-to-erasure.)
  7. Inside one transaction:
    • UPDATE EmailVerification SET verified=true, usedAt=now() WHERE userId=:userId AND newEmail IS NOT NULL AND verified=false โ€” invalidate any pending change-email tokens.
    • INSERT EmailVerification { userId, token=randomUUID(), newEmail, expiresAt = now() + auth.verification_token_expiry_hours } (default 24h).
    • On Prisma P2002 (concurrent email claim), throw email_taken.
  8. Queue verification email โ€” notificationService.send({ eventKey: 'email_verification', userId, variables: { username, verifyUrl } }). This enqueues a BullMQ job consumed by worker-service, which dispatches via the active email provider. Failure is logged, not surfaced.
  9. Audit log: [emailChange] Verification sent for user {userId} to <masked email> (last-4 prefix masking via maskEmail()).

Code samples

curl -X POST https://api.bio.re/api/v1/users/change-email \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "newEmail": "[email protected]",
    "password": "current-account-password"
  }'
type ChangeEmailInput = { newEmail: string; password: string };
type ChangeEmailResponse = { message: string };

async function changeEmail(accessToken: string, input: ChangeEmailInput): Promise<ChangeEmailResponse> {
  const res = await fetch('https://api.bio.re/api/v1/users/change-email', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(input),
  });
  const json = await res.json();
  if (!res.ok || !json.success) {
    throw Object.assign(new Error(json?.error?.message ?? 'Change email failed'), {
      code: json?.error?.code,
    });
  }
  return json.data;
}
import { useMutation } from '@tanstack/react-query';

export function useChangeEmail() {
  return useMutation({
    mutationFn: async (input: ChangeEmailInput) => {
      const res = await fetch('/api/v1/users/change-email', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(input),
      });
      const json = await res.json();
      if (!res.ok || !json.success) {
        throw Object.assign(new Error(json?.error?.message ?? 'Change email failed'), {
          code: json?.error?.code,
          i18nKey: json?.error?.i18nKey,
        });
      }
      return json.data as ChangeEmailResponse;
    },
  });
}

Try it

POST
/api/v1/users/change-email
AuthorizationBearer <token>

In: header

Request Body

application/json

TypeScript Definitions

Use the request body type in TypeScript.

Response Body

application/json

application/json

application/json

application/json

curl -X POST "https://loading/api/v1/users/change-email" \  -H "Content-Type: application/json" \  -d '{    "newEmail": "[email protected]",    "password": "stringst"  }'
{
  "success": true,
  "data": {
    "message": "Verification email sent to your new address. Please check your inbox."
  }
}
{
  "success": false,
  "error": {
    "code": "AUTH_UNAUTHORIZED",
    "message": "Invalid credentials",
    "i18nKey": "auth.login.invalid_credentials",
    "i18nVars": {
      "field": "email"
    },
    "details": [
      {
        "message": "email must be an email"
      }
    ],
    "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}
{
  "success": false,
  "error": {
    "code": "AUTH_UNAUTHORIZED",
    "message": "Invalid credentials",
    "i18nKey": "auth.login.invalid_credentials",
    "i18nVars": {
      "field": "email"
    },
    "details": [
      {
        "message": "email must be an email"
      }
    ],
    "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}
{
  "success": false,
  "error": {
    "code": "AUTH_UNAUTHORIZED",
    "message": "Invalid credentials",
    "i18nKey": "auth.login.invalid_credentials",
    "i18nVars": {
      "field": "email"
    },
    "details": [
      {
        "message": "email must be an email"
      }
    ],
    "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}

Source

SourcePathLines
Controllerapps/api-core/src/modules/user/user.controller.ts125โ€“135 (changeEmail)
DTO (request)apps/api-core/src/modules/user/dto/index.ts26โ€“32 (ChangeEmailDto)
DTO (response)apps/api-core/src/modules/user/dto/user-client-response.dto.ts127โ€“130 (EmailChangeMessageDto)
Serviceapps/api-core/src/modules/user/user.service.ts271โ€“345 (requestEmailChange)
Notification pipelineapps/api-core/src/modules/notification/notification.service.tssend() (enqueues BullMQ G2 job)
Email provider(admin-managed)external.email.active_provider ConfigService key
Prisma modelspackages/prisma/prisma/schema.prismaUser.email, User.passwordHash, EmailVerification

On this page