fix: Add email verification requirement for API v1 and v2 email updates (#24988)

* fix: Add email verification requirement for API v1 and v2 email updates

- API v2 (/v2/me): Implement email verification check when updating primary email
  - Store new email in metadata as emailChangeWaitingForVerification when verification is required
  - Send verification email using sendChangeOfEmailVerification
  - Allow immediate email change if new email is already a verified secondary email
  - Add hasEmailBeenChanged and sendEmailVerification flags to response
  - Add tests to verify email verification behavior
  - Add findVerifiedSecondaryEmail method to UsersRepository
  - Add PrismaFeaturesRepository to MeModule providers

- API v1 (/v1/users/{userId}): Implement same email verification logic
  - Check if email-verification feature flag is enabled
  - Store new email in metadata when verification is required
  - Send verification email for unverified email changes
  - Allow immediate change for verified secondary emails
  - Preserve existing API v1 response format

- Test fixtures: Add createSecondaryEmail method to UserRepositoryFixture

This fixes the vulnerability where users could update their primary email
without verification via API endpoints.

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* update

* update

* refactor: Extract business logic to MeService and add platform-managed bypass

- Create MeService with all business logic extracted from MeController
- Update MeController to delegate to MeService (thin controller pattern)
- Add PrismaWorkerModule to MeModule imports to provide PrismaWriteService
- Add isPlatformManaged bypass: platform-managed users can update email without verification
- Fix test cleanup to use delete by ID instead of email to avoid failures when email changes
- Remove hasEmailBeenChanged and sendEmailVerification response flags per reviewer feedback
- Add comprehensive documentation to @ApiOperation describing email verification behavior
- Update tests to remove flag assertions

Addresses PR comments:
- supalarry: Extract logic to service layer
- supalarry: Allow platform-managed users to update email without verification
- supalarry: Remove response flags and document behavior in @ApiOperation instead
- cubic-dev-ai: Fix module dependency for PrismaFeaturesRepository
- cubic-dev-ai: Fix test cleanup issue

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* docs: Simplify API operation description

Remove internal implementation details about metadata staging and shorten the description to focus on API consumer behavior.

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* fix

* update

* update

* update

* remove comment

* addressed commit

* review addressed

* use repository

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Anik Dhabal Babu
2026-01-08 15:51:11 +00:00
committed by GitHub
co-authored by anik@cal.com <adhabal2002@gmail.com> anik@cal.com <adhabal2002@gmail.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 03bb9481a2
commit ccff0045fc
9 changed files with 336 additions and 18 deletions
@@ -166,8 +166,50 @@ describe("Me Endpoints", () => {
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectWeekStart).expect(400);
});
it("should not update primary email without verification when email-verification is enabled", async () => {
const newEmail = `new-email-${randomString()}@api.com`;
const body: UpdateManagedUserInput = { email: newEmail };
return request(app.getHttpServer())
.patch("/v2/me")
.send(body)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<UserResponse> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.email).toEqual(user.email);
const updatedUser = await userRepositoryFixture.get(user.id);
expect(updatedUser?.email).toEqual(user.email);
expect(updatedUser?.metadata).toHaveProperty("emailChangeWaitingForVerification", newEmail);
});
});
it("should update primary email immediately when changing to a verified secondary email", async () => {
const verifiedSecondaryEmail = `verified-secondary-${randomString()}@api.com`;
await userRepositoryFixture.createSecondaryEmail(user.id, verifiedSecondaryEmail, new Date());
const body: UpdateManagedUserInput = { email: verifiedSecondaryEmail };
return request(app.getHttpServer())
.patch("/v2/me")
.send(body)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<UserResponse> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.email).toEqual(verifiedSecondaryEmail);
const updatedUser = await userRepositoryFixture.get(user.id);
expect(updatedUser?.email).toEqual(verifiedSecondaryEmail);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.delete(user.id);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
+12 -14
View File
@@ -1,6 +1,6 @@
import { GetMeOutput } from "@/ee/me/outputs/get-me.output";
import { UpdateMeOutput } from "@/ee/me/outputs/update-me.output";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { MeService } from "@/ee/me/services/me.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
@@ -9,7 +9,7 @@ import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, UseGuards, Get, Patch, Body } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
@@ -25,9 +25,8 @@ import { userSchemaResponse } from "@calcom/platform-types";
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
export class MeController {
constructor(
private readonly usersRepository: UsersRepository,
private readonly schedulesService: SchedulesService_2024_04_15,
private readonly usersService: UsersService
private readonly usersService: UsersService,
private readonly meService: MeService
) {}
@Get("/")
@@ -55,19 +54,18 @@ export class MeController {
@Patch("/")
@Permissions([PROFILE_WRITE])
@ApiOperation({ summary: "Update my profile" })
@ApiOperation({
summary: "Update my profile",
description:
"Updates the authenticated user's profile. Email changes require verification and the primary email stays unchanged until verification completes, unless the new email is already a verified secondary email or the user is platform-managed.",
})
async updateMe(
@GetUser() user: UserWithProfile,
@Body() bodySchedule: UpdateManagedUserInput
@Body() updateData: UpdateManagedUserInput
): Promise<UpdateMeOutput> {
const updatedUser = await this.usersRepository.update(user.id, bodySchedule);
if (bodySchedule.timeZone && user.defaultScheduleId) {
await this.schedulesService.updateUserSchedule(user, user.defaultScheduleId, {
timeZone: bodySchedule.timeZone,
});
}
const result = await this.meService.updateMe({ user, updateData });
const me = userSchemaResponse.parse(updatedUser);
const me = userSchemaResponse.parse(result.updatedUser);
return {
status: SUCCESS_STATUS,
+5 -1
View File
@@ -1,12 +1,16 @@
import { MeController } from "@/ee/me/me.controller";
import { MeService } from "@/ee/me/services/me.service";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { PrismaWorkerModule } from "@/modules/prisma/prisma-worker.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [UsersModule, SchedulesModule_2024_04_15, TokensModule, OAuthClientModule],
imports: [PrismaWorkerModule, UsersModule, SchedulesModule_2024_04_15, TokensModule, OAuthClientModule],
providers: [PrismaFeaturesRepository, MeService],
controllers: [MeController],
})
export class MeModule {}
@@ -0,0 +1,94 @@
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { sendChangeOfEmailVerification } from "@calcom/platform-libraries/emails";
import type { Prisma } from "@calcom/prisma/client";
export interface UpdateMeResult {
updatedUser: UserWithProfile;
}
@Injectable()
export class MeService {
constructor(
private readonly usersRepository: UsersRepository,
private readonly schedulesService: SchedulesService_2024_04_15,
private readonly featuresRepository: PrismaFeaturesRepository
) {}
async updateMe(params: {
user: UserWithProfile;
updateData: UpdateManagedUserInput;
}): Promise<UpdateMeResult> {
const { user, updateData } = params;
const update = { ...updateData };
if (update.timeZone && user.defaultScheduleId) {
await this.schedulesService.updateUserSchedule(
user,
user.defaultScheduleId,
{
timeZone: update.timeZone,
}
);
}
const isEmailVerificationEnabled = user.isPlatformManaged
? false
: await this.featuresRepository.checkIfFeatureIsEnabledGlobally("email-verification");
const hasEmailBeenChanged = update.email && user.email !== update.email;
const newEmail = update.email;
let sendEmailVerification = false;
if (hasEmailBeenChanged && newEmail && isEmailVerificationEnabled) {
const secondaryEmail = await this.usersRepository.findVerifiedSecondaryEmail(user.id, newEmail);
if (secondaryEmail && secondaryEmail.emailVerified) {
const updatedUser =
await this.usersRepository.swapPrimaryEmailWithSecondaryEmail(
user.id,
secondaryEmail.id,
user.email,
user.emailVerified,
newEmail
);
return {
updatedUser,
};
} else {
update.metadata = {
...(user.metadata as Prisma.JsonObject),
...(update.metadata || {}),
emailChangeWaitingForVerification: newEmail.toLowerCase(),
};
delete update.email;
sendEmailVerification = true;
}
}
const updatedUser =
Object.keys(update).length > 0
? await this.usersRepository.update(user.id, update)
: user;
if (sendEmailVerification && newEmail) {
await sendChangeOfEmailVerification({
user: {
username: updatedUser.username ?? "Nameless User",
emailFrom: user.email,
emailTo: newEmail,
},
});
}
return {
updatedUser,
};
}
}
@@ -434,4 +434,48 @@ export class UsersRepository {
},
});
}
async findVerifiedSecondaryEmail(userId: number, email: string) {
return this.dbRead.prisma.secondaryEmail.findUnique({
where: {
userId_email: {
userId: userId,
email: email,
},
},
select: {
id: true,
emailVerified: true,
},
});
}
async swapPrimaryEmailWithSecondaryEmail(
userId: number,
secondaryEmailId: number,
oldPrimaryEmail: string,
oldPrimaryEmailVerified: Date | null,
newPrimaryEmail: string
) {
const [, updatedUser] = await this.dbWrite.prisma.$transaction([
this.dbWrite.prisma.secondaryEmail.update({
where: {
id: secondaryEmailId,
userId: userId,
},
data: {
email: oldPrimaryEmail,
emailVerified: oldPrimaryEmailVerified,
},
}),
this.dbWrite.prisma.user.update({
where: { id: userId },
data: {
email: newPrimaryEmail,
},
}),
]);
return updatedUser;
}
}