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
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
newEmail | string | โ | IsEmail() | The desired new email โ server normalizes via .toLowerCase().trim() |
password | string | โ | MinLength(8) | Current account password โ re-confirms via bcrypt |
| Header | Required | Notes |
|---|---|---|
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."
}
}| Field | Type | Notes |
|---|---|---|
message | string | Localizable confirmation message โ render under the form, no further fields |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | (DTO validation) | newEmail not a valid email format; password shorter than 8 chars |
400 | user.change_email.password_required | Account has no passwordHash (OAuth-only โ set a password via the change-password flow first) |
400 | user.change_email.password_incorrect | Bcrypt password check failed |
400 | user.change_email.email_same | New email equals current User.email after normalization |
400 | user.change_email.email_invalid | Email validator rejected (disposable domain or MX failure) |
401 | (guard) | Missing / invalid bearer token |
404 | user.change_email.not_found | Token decoded but user row missing |
409 | user.change_email.email_taken | Active user already holds this email (also caught from Prisma P2002 race) |
409 | user.change_email.email_previously_deleted | Email 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
- Normalize submitted email; lookup
User; reject if nopasswordHash. - Bcrypt-compare
passwordagainstUser.passwordHash. - Reject if
newEmail === user.email(no-op). - Email validator โ checks disposable-domain blocklist + MX record presence.
- Reject if any other user already holds this email.
- GDPR tombstone check โ compute
sha256(newEmail); if a row exists withemail = "deleted_<hash>@deleted.bio.re"ANDstatus = DELETED, reject. (Prevents reusing emails of accounts that were deleted under GDPR right-to-erasure.) - 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), throwemail_taken.
- 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. - Audit log:
[emailChange] Verification sent for user {userId} to <masked email>(last-4 prefix masking viamaskEmail()).
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
Authorization
bearer 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
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/user/user.controller.ts | 125โ135 (changeEmail) |
| DTO (request) | apps/api-core/src/modules/user/dto/index.ts | 26โ32 (ChangeEmailDto) |
| DTO (response) | apps/api-core/src/modules/user/dto/user-client-response.dto.ts | 127โ130 (EmailChangeMessageDto) |
| Service | apps/api-core/src/modules/user/user.service.ts | 271โ345 (requestEmailChange) |
| Notification pipeline | apps/api-core/src/modules/notification/notification.service.ts | send() (enqueues BullMQ G2 job) |
| Email provider | (admin-managed) | external.email.active_provider ConfigService key |
| Prisma models | packages/prisma/prisma/schema.prisma | User.email, User.passwordHash, EmailVerification |
Check Username Availability
Real-time availability probe for the registration / settings form. Public โ no auth required. Returns false on bad format too, so the UI can stop after one round-trip.
Get User Settings
Read the user's preferences row โ locale, theme, three notification toggles, digest mode. Lazily creates the default row on first access.