diff --git a/apps/web/modules/settings/my-account/general-view.tsx b/apps/web/modules/settings/my-account/general-view.tsx index 5bcbcfd5e5..4ad664fedf 100644 --- a/apps/web/modules/settings/my-account/general-view.tsx +++ b/apps/web/modules/settings/my-account/general-view.tsx @@ -147,6 +147,9 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => { const [isReceiveMonthlyDigestEmailChecked, setIsReceiveMonthlyDigestEmailChecked] = useState( !!user.receiveMonthlyDigestEmail ); + const [isRequireBookerEmailVerificationChecked, setIsRequireBookerEmailVerificationChecked] = useState( + !!user.requiresBookerEmailVerification + ); const watchedTzSchedules = formMethods.watch("travelSchedules"); @@ -353,6 +356,19 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => { }} switchContainerClassName="mt-6" /> + + { + setIsRequireBookerEmailVerificationChecked(checked); + mutation.mutate({ requiresBookerEmailVerification: checked }); + }} + switchContainerClassName="mt-6" + /> setIsTZScheduleOpen(false)} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index b787b49782..a873e6234f 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3626,6 +3626,8 @@ "no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential", "download_expense_log": "Download Expense Log", "error_downloading_expense_log": "Error downloading expense log", + "require_booker_email_verification": "Prevent Impersonation on Bookings", + "require_booker_email_verification_description": "When enabled, anyone trying to book events using your email address must verify they own it via a one time code or be logged in to prevent impersonation", "offer_to_reschedule_last_booking": "Offer to reschedule last active booking to chosen time slot", "booker_limit_exceeded_error": "Booker maximum active booking limit exceeded", "booker_limit_exceeded_error_reschedule": "You already have a booking for this event on {{date}}. Would you like to reschedule to the new selected time?", diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 0b74837371..ea8927f62f 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -9,12 +9,12 @@ import type { z } from "zod"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook"; +import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; import { weekdayToWeekIndex, type WeekDays } from "@calcom/lib/dayjs"; import type { HttpError } from "@calcom/lib/http-error"; import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; -import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; import type { BookingReference, Attendee, Booking, Membership } from "@calcom/prisma/client"; import type { Prisma } from "@calcom/prisma/client"; import type { WebhookTriggerEvents } from "@calcom/prisma/client"; @@ -240,6 +240,8 @@ type InputUser = Omit & { end: string; }[]; }; + requiresBookerEmailVerification?: boolean; + secondaryEmails?: { email: string; emailVerified: Date | null }[]; }; export type InputEventType = { @@ -834,6 +836,21 @@ export async function addUsersToDb(users: InputUser[]) { } } + for (const user of users) { + if (user.secondaryEmails) { + log.debug("Creating SecondaryEmail entries for user", user.id); + for (const secondaryEmail of user.secondaryEmails) { + await prismock.secondaryEmail.create({ + data: { + email: secondaryEmail.email, + emailVerified: secondaryEmail.emailVerified, + userId: user.id, + }, + }); + } + } + } + const allUsers = await prismock.user.findMany({ include: { credentials: true, @@ -845,6 +862,7 @@ export async function addUsersToDb(users: InputUser[]) { }, }, destinationCalendar: true, + secondaryEmails: true, }, }); @@ -1554,6 +1572,8 @@ export function getOrganizer({ username, locked, emailVerified, + requiresBookerEmailVerification, + secondaryEmails, }: { name: string; email: string; @@ -1572,6 +1592,8 @@ export function getOrganizer({ username?: string; locked?: boolean; emailVerified?: Date | null; + requiresBookerEmailVerification?: boolean; + secondaryEmails?: { email: string; emailVerified: Date | null }[]; }) { username = username ?? TestData.users.example.username; return { @@ -1594,6 +1616,8 @@ export function getOrganizer({ completedOnboarding, locked, emailVerified, + requiresBookerEmailVerification, + secondaryEmails, }; } diff --git a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts index a49edc9280..968b4952fc 100644 --- a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts +++ b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts @@ -27,6 +27,7 @@ type CommonPropsMockRequestData = { rescheduledBy?: string; cancelledBy?: string; schedulingType?: SchedulingType; + guests?: string[]; responses: { email: string; name: string; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 2a9a6d4144..1dc8b4a2be 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -58,7 +58,7 @@ import { groupHostsByGroupId } from "@calcom/lib/bookings/hostGroupUtils"; import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils"; import { DEFAULT_GROUP_ID } from "@calcom/lib/constants"; import { ErrorCode } from "@calcom/lib/errorCodes"; -import { getErrorFromUnknown } from "@calcom/lib/errors"; +import { getErrorFromUnknown, ErrorWithCode } from "@calcom/lib/errors"; import { extractBaseEmail } from "@calcom/lib/extract-base-email"; import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; @@ -490,6 +490,7 @@ async function handler( const { prismaClient: prisma, bookingRepository, + userRepository, cacheService, checkBookingAndDurationLimitsService, luckyUserService, @@ -545,7 +546,18 @@ async function handler( const loggerWithEventDetails = createLoggerWithEventDetails(eventTypeId, reqBody.user, eventTypeSlug); const emailsAndSmsHandler = new BookingEmailSmsHandler({ logger: loggerWithEventDetails }); - await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail }); + try { + await checkIfBookerEmailIsBlocked({ + loggedInUserId: userId, + bookerEmail, + verificationCode: reqBody.verificationCode, + }); + } catch (error) { + if (error instanceof ErrorWithCode) { + throw new HttpError({ statusCode: 403, message: error.message }); + } + throw error; + } const spamCheckService = getSpamCheckService(); const eventOrganizationId = await getEventOrganizationId({ @@ -1196,13 +1208,31 @@ async function handler( ? process.env.BLACKLISTED_GUEST_EMAILS.split(",") : []; + const guestEmails = (reqGuests || []).map((email) => extractBaseEmail(email).toLowerCase()); + const guestUsers = await userRepository.findManyByEmailsWithEmailVerificationSettings({ + emails: guestEmails, + }); + + const emailToRequiresVerification = new Map(); + for (const user of guestUsers) { + const matchedBase = extractBaseEmail(user.matchedEmail ?? user.email).toLowerCase(); + emailToRequiresVerification.set(matchedBase, user.requiresBookerEmailVerification === true); + } + const guestsRemoved: string[] = []; const guests = (reqGuests || []).reduce((guestArray, guest) => { const baseGuestEmail = extractBaseEmail(guest).toLowerCase(); + if (blacklistedGuestEmails.some((e) => e.toLowerCase() === baseGuestEmail)) { guestsRemoved.push(guest); return guestArray; } + + if (emailToRequiresVerification.get(baseGuestEmail)) { + guestsRemoved.push(guest); + return guestArray; + } + // If it's a team event, remove the team member from guests if (isTeamEventType && users.some((user) => user.email === guest)) { return guestArray; diff --git a/packages/features/bookings/lib/handleNewBooking/checkIfBookerEmailIsBlocked.ts b/packages/features/bookings/lib/handleNewBooking/checkIfBookerEmailIsBlocked.ts index 4f4aa17649..274b061948 100644 --- a/packages/features/bookings/lib/handleNewBooking/checkIfBookerEmailIsBlocked.ts +++ b/packages/features/bookings/lib/handleNewBooking/checkIfBookerEmailIsBlocked.ts @@ -1,27 +1,28 @@ +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; import { extractBaseEmail } from "@calcom/lib/extract-base-email"; -import { HttpError } from "@calcom/lib/http-error"; import prisma from "@calcom/prisma"; +import { verifyCodeUnAuthenticated } from "@calcom/trpc/server/routers/viewer/auth/util"; export const checkIfBookerEmailIsBlocked = async ({ bookerEmail, loggedInUserId, + verificationCode, }: { bookerEmail: string; loggedInUserId?: number; + verificationCode?: string; }) => { const baseEmail = extractBaseEmail(bookerEmail); + const blacklistedGuestEmails = process.env.BLACKLISTED_GUEST_EMAILS ? process.env.BLACKLISTED_GUEST_EMAILS.split(",") : []; - const blacklistedEmail = blacklistedGuestEmails.find( + const blacklistedByEnv = blacklistedGuestEmails.find( (guestEmail: string) => guestEmail.toLowerCase() === baseEmail.toLowerCase() ); - if (!blacklistedEmail) { - return false; - } - const user = await prisma.user.findFirst({ where: { OR: [ @@ -46,17 +47,46 @@ export const checkIfBookerEmailIsBlocked = async ({ select: { id: true, email: true, + requiresBookerEmailVerification: true, }, }); + const blockedByUserSetting = user?.requiresBookerEmailVerification ?? false; + const shouldBlock = !!blacklistedByEnv || blockedByUserSetting; + + if (!shouldBlock) { + return false; + } + if (!user) { - throw new HttpError({ statusCode: 403, message: "Cannot use this email to create the booking." }); + throw new ErrorWithCode(ErrorCode.BookerEmailBlocked, "Cannot use this email to create the booking."); } if (user.id !== loggedInUserId) { - throw new HttpError({ - statusCode: 403, - message: `Attendee email has been blocked. Make sure to login as ${bookerEmail} to use this email for creating a booking.`, - }); + // If a verification code is provided, validate it + if (verificationCode) { + let isValid = false; + + try { + isValid = await verifyCodeUnAuthenticated(baseEmail, verificationCode); + } catch { + throw new ErrorWithCode( + ErrorCode.UnableToValidateVerificationCode, + "There was an error validating the verification code" + ); + } + + if (!isValid) { + throw new ErrorWithCode(ErrorCode.InvalidVerificationCode, "Invalid verification code"); + } + + return false; + } + + throw new ErrorWithCode( + ErrorCode.BookerEmailRequiresLogin, + `Attendee email has been blocked. Make sure to login as ${bookerEmail} to use this email for creating a booking.`, + { email: bookerEmail } + ); } }; diff --git a/packages/features/bookings/lib/handleNewBooking/test/booking-validations.test.ts b/packages/features/bookings/lib/handleNewBooking/test/booking-validations.test.ts index eed0db94b9..1d4cddcc82 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/booking-validations.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/booking-validations.test.ts @@ -3,6 +3,7 @@ * These specifications verify the business rules and validation behavior for booking creation */ import prismaMock from "../../../../../../tests/libs/__mocks__/prisma"; + import { createBookingScenario, TestData, @@ -15,7 +16,7 @@ import { import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; -import { afterEach, vi } from "vitest"; +import { afterEach, beforeEach, vi } from "vitest"; import { describe, expect } from "vitest"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -23,6 +24,33 @@ import { test } from "@calcom/web/test/fixtures/fixtures"; import { getNewBookingHandler } from "./getNewBookingHandler"; +vi.mock("@calcom/trpc/server/routers/viewer/auth/util", () => ({ + verifyCodeUnAuthenticated: vi.fn(), +})); + +const { mockFindManyByEmailsWithEmailVerificationSettings, mockFindByEmailWithEmailVerificationSetting } = + vi.hoisted(() => ({ + mockFindManyByEmailsWithEmailVerificationSettings: vi.fn(), + mockFindByEmailWithEmailVerificationSetting: vi.fn(), + })); + +vi.mock("@calcom/features/users/repositories/UserRepository", async (importOriginal) => { + const actual = await importOriginal(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const OriginalUserRepository = (actual as any).UserRepository; + + return { + ...actual, + UserRepository: vi.fn().mockImplementation((prisma) => { + const realInstance = new OriginalUserRepository(prisma); + realInstance.findManyByEmailsWithEmailVerificationSettings = + mockFindManyByEmailsWithEmailVerificationSettings; + realInstance.findByEmailWithEmailVerificationSetting = mockFindByEmailWithEmailVerificationSetting; + return realInstance; + }), + }; +}); + function addToBlacklistedEmails(emails: string[]) { process.env.BLACKLISTED_GUEST_EMAILS = emails.join(","); } @@ -31,8 +59,14 @@ function resetBlacklistedEmails() { delete process.env.BLACKLISTED_GUEST_EMAILS; } -afterEach(() => { +beforeEach(() => { + mockFindManyByEmailsWithEmailVerificationSettings.mockResolvedValue([]); + mockFindByEmailWithEmailVerificationSetting.mockResolvedValue(null); +}); + +afterEach(() => { resetBlacklistedEmails(); + vi.clearAllMocks(); }); describe("Booking Validation Specifications", () => { @@ -93,9 +127,11 @@ describe("Booking Validation Specifications", () => { }); // Non logged in user should not be able to book - await expect(handleNewBooking({ - bookingData: mockBookingData, - })).rejects.toThrow( + await expect( + handleNewBooking({ + bookingData: mockBookingData, + }) + ).rejects.toThrow( "Attendee email has been blocked. Make sure to login as organizer@example.com to use this email for creating a booking." ); @@ -169,9 +205,11 @@ describe("Booking Validation Specifications", () => { }); // Should prevent booking when blacklisted email has no verified user in database - await expect(handleNewBooking({ - bookingData: mockBookingData, - })).rejects.toThrow("Cannot use this email to create the booking."); + await expect( + handleNewBooking({ + bookingData: mockBookingData, + }) + ).rejects.toThrow("Cannot use this email to create the booking."); }); }); @@ -225,9 +263,11 @@ describe("Booking Validation Specifications", () => { title: "Existing Booking", status: BookingStatus.ACCEPTED, // Booker already has a booking in future - attendees: [{ - email: booker.email, - }], + attendees: [ + { + email: booker.email, + }, + ], }, ], }) @@ -235,7 +275,6 @@ describe("Booking Validation Specifications", () => { await mockCalendarToHaveNoBusySlots("googlecalendar", {}); - const mockBookingData = getMockRequestDataForBooking({ data: { eventTypeId: 1, @@ -261,9 +300,11 @@ describe("Booking Validation Specifications", () => { ); // Second booking should be rejected - await expect(handleNewBooking({ - bookingData: mockBookingData, - })).rejects.toThrow("booker_limit_exceeded_error"); + await expect( + handleNewBooking({ + bookingData: mockBookingData, + }) + ).rejects.toThrow("booker_limit_exceeded_error"); }); test("enforces booking limits with reschedule option when enabled", async () => { @@ -315,9 +356,11 @@ describe("Booking Validation Specifications", () => { endTime: `${plus1DateString}T10:30:00.000Z`, title: "Existing Booking", status: BookingStatus.ACCEPTED, - attendees: [{ - email: booker.email, - }], + attendees: [ + { + email: booker.email, + }, + ], }, { uid: "existing-booking-2", @@ -327,9 +370,11 @@ describe("Booking Validation Specifications", () => { endTime: `${plus2DateString}T10:30:00.000Z`, title: "Existing Booking", status: BookingStatus.ACCEPTED, - attendees: [{ - email: booker.email, - }], + attendees: [ + { + email: booker.email, + }, + ], }, ], }) @@ -362,4 +407,739 @@ describe("Booking Validation Specifications", () => { } }); }); + + describe("User Email Verification Setting", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("should block booking when main booker requires verification and is not logged in", async () => { + const handleNewBooking = getNewBookingHandler(); + + const booker = getBooker({ + email: "user@example.com", + name: "User", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + emailVerified: new Date(), + }); + + const userWithVerificationRequired = getOrganizer({ + name: "User", + email: "user@example.com", + id: 201, + schedules: [TestData.schedules.IstWorkHours], + emailVerified: new Date(), + requiresBookerEmailVerification: true, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + usersApartFromOrganizer: [userWithVerificationRequired], + apps: [TestData.apps["google-calendar"]], + }) + ); + + await mockCalendarToHaveNoBusySlots("googlecalendar", {}); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + await expect( + handleNewBooking({ + bookingData: mockBookingData, + }) + ).rejects.toThrow( + "Attendee email has been blocked. Make sure to login as user@example.com to use this email for creating a booking." + ); + }); + + test("should allow booking when main booker requires verification but is logged in", async () => { + const handleNewBooking = getNewBookingHandler(); + + const booker = getBooker({ + email: "user@example.com", + name: "User", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + emailVerified: new Date(), + }); + + const userWithVerificationRequired = getOrganizer({ + name: "User", + email: "user@example.com", + id: 201, + schedules: [TestData.schedules.IstWorkHours], + emailVerified: new Date(), + requiresBookerEmailVerification: true, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + usersApartFromOrganizer: [userWithVerificationRequired], + apps: [TestData.apps["google-calendar"]], + }) + ); + + await mockCalendarToHaveNoBusySlots("googlecalendar", {}); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + userId: 201, + }); + + expect(createdBooking).toEqual( + expect.objectContaining({ + id: expect.any(Number), + uid: expect.any(String), + status: BookingStatus.ACCEPTED, + }) + ); + }); + + test("should create booking when main booker provides valid verification code", async () => { + const handleNewBooking = getNewBookingHandler(); + const { verifyCodeUnAuthenticated } = await import("@calcom/trpc/server/routers/viewer/auth/util"); + + vi.mocked(verifyCodeUnAuthenticated).mockResolvedValue(true); + + const booker = getBooker({ + email: "user@example.com", + name: "User", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + emailVerified: new Date(), + }); + + const userWithVerificationRequired = getOrganizer({ + name: "User", + email: "user@example.com", + id: 201, + schedules: [TestData.schedules.IstWorkHours], + emailVerified: new Date(), + requiresBookerEmailVerification: true, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + usersApartFromOrganizer: [userWithVerificationRequired], + apps: [TestData.apps["google-calendar"]], + }) + ); + + await mockCalendarToHaveNoBusySlots("googlecalendar", {}); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + verificationCode: "valid-code-123", + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(createdBooking).toEqual( + expect.objectContaining({ + id: expect.any(Number), + uid: expect.any(String), + status: BookingStatus.ACCEPTED, + }) + ); + + expect(verifyCodeUnAuthenticated).toHaveBeenCalledWith("user@example.com", "valid-code-123"); + }); + + test("should require verification when secondary email of user with verification setting is used as main booker", async () => { + const handleNewBooking = getNewBookingHandler(); + + const booker = getBooker({ + email: "secondary@example.com", + name: "User", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + emailVerified: new Date(), + }); + + const userWithVerificationRequired = getOrganizer({ + name: "User", + email: "primary@example.com", + id: 201, + schedules: [TestData.schedules.IstWorkHours], + emailVerified: new Date(), + requiresBookerEmailVerification: true, + secondaryEmails: [{ email: "secondary@example.com", emailVerified: new Date() }], + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + usersApartFromOrganizer: [userWithVerificationRequired], + apps: [TestData.apps["google-calendar"]], + }) + ); + + await mockCalendarToHaveNoBusySlots("googlecalendar", {}); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + await expect( + handleNewBooking({ + bookingData: mockBookingData, + }) + ).rejects.toThrow( + "Attendee email has been blocked. Make sure to login as secondary@example.com to use this email for creating a booking." + ); + }); + + test("should filter out guest that requires verification", async () => { + const handleNewBooking = getNewBookingHandler(); + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + emailVerified: new Date(), + }); + + const guestWithVerification = getOrganizer({ + name: "Guest", + email: "guest-with-verification@example.com", + id: 202, + schedules: [TestData.schedules.IstWorkHours], + emailVerified: new Date(), + requiresBookerEmailVerification: true, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + usersApartFromOrganizer: [guestWithVerification], + apps: [TestData.apps["google-calendar"]], + }) + ); + + await mockCalendarToHaveNoBusySlots("googlecalendar", {}); + + mockFindManyByEmailsWithEmailVerificationSettings.mockResolvedValue([ + { + email: "guest-with-verification@example.com", + matchedEmail: "guest-with-verification@example.com", + requiresBookerEmailVerification: true, + }, + ]); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + guests: ["guest-with-verification@example.com", "regular-guest@example.com"], + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(createdBooking).toEqual( + expect.objectContaining({ + id: expect.any(Number), + uid: expect.any(String), + status: BookingStatus.ACCEPTED, + }) + ); + + const booking = await prismaMock.booking.findFirst({ + where: { id: createdBooking.id }, + include: { attendees: true }, + }); + + const guestEmails = booking?.attendees.map((a) => a.email).filter((e) => e !== booker.email); + expect(guestEmails).toEqual(["regular-guest@example.com"]); + expect(guestEmails).not.toContain("guest-with-verification@example.com"); + }); + + test("should filter out secondary email with verification when added as guest", async () => { + const handleNewBooking = getNewBookingHandler(); + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + emailVerified: new Date(), + }); + + const userWithSecondaryEmail = getOrganizer({ + name: "User", + email: "primary@example.com", + id: 202, + schedules: [TestData.schedules.IstWorkHours], + emailVerified: new Date(), + requiresBookerEmailVerification: true, + secondaryEmails: [{ email: "secondary@example.com", emailVerified: new Date() }], + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + usersApartFromOrganizer: [userWithSecondaryEmail], + apps: [TestData.apps["google-calendar"]], + }) + ); + + await mockCalendarToHaveNoBusySlots("googlecalendar", {}); + + mockFindManyByEmailsWithEmailVerificationSettings.mockResolvedValue([ + { + email: "primary@example.com", + matchedEmail: "secondary@example.com", + requiresBookerEmailVerification: true, + }, + ]); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + guests: ["secondary@example.com", "regular-guest@example.com"], + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(createdBooking).toEqual( + expect.objectContaining({ + id: expect.any(Number), + uid: expect.any(String), + status: BookingStatus.ACCEPTED, + }) + ); + + const booking = await prismaMock.booking.findFirst({ + where: { id: createdBooking.id }, + include: { attendees: true }, + }); + + const guestEmails = booking?.attendees.map((a) => a.email).filter((e) => e !== booker.email); + expect(guestEmails).toEqual(["regular-guest@example.com"]); + expect(guestEmails).not.toContain("secondary@example.com"); + }); + + test("should match plus-addressed email to base email for verification check", async () => { + const handleNewBooking = getNewBookingHandler(); + + const booker = getBooker({ + email: "user+tag@example.com", + name: "User", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + emailVerified: new Date(), + }); + + const userWithVerificationRequired = getOrganizer({ + name: "User", + email: "user@example.com", + id: 201, + schedules: [TestData.schedules.IstWorkHours], + emailVerified: new Date(), + requiresBookerEmailVerification: true, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + usersApartFromOrganizer: [userWithVerificationRequired], + apps: [TestData.apps["google-calendar"]], + }) + ); + + await mockCalendarToHaveNoBusySlots("googlecalendar", {}); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + await expect( + handleNewBooking({ + bookingData: mockBookingData, + }) + ).rejects.toThrow("Attendee email has been blocked"); + }); + + test("should filter only guests requiring verification from multiple guests", async () => { + const handleNewBooking = getNewBookingHandler(); + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + emailVerified: new Date(), + }); + + const guest1 = getOrganizer({ + name: "Guest1", + email: "guest1-verify@example.com", + id: 202, + schedules: [TestData.schedules.IstWorkHours], + emailVerified: new Date(), + requiresBookerEmailVerification: true, + }); + + const guest3 = getOrganizer({ + name: "Guest3", + email: "guest3-verify@example.com", + id: 203, + schedules: [TestData.schedules.IstWorkHours], + emailVerified: new Date(), + requiresBookerEmailVerification: true, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + usersApartFromOrganizer: [guest1, guest3], + apps: [TestData.apps["google-calendar"]], + }) + ); + + await mockCalendarToHaveNoBusySlots("googlecalendar", {}); + + mockFindManyByEmailsWithEmailVerificationSettings.mockResolvedValue([ + { + email: "guest1-verify@example.com", + matchedEmail: "guest1-verify@example.com", + requiresBookerEmailVerification: true, + }, + { + email: "guest3-verify@example.com", + matchedEmail: "guest3-verify@example.com", + requiresBookerEmailVerification: true, + }, + ]); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + guests: [ + "guest1-verify@example.com", + "guest2-no-verify@example.com", + "guest3-verify@example.com", + "guest4-no-verify@example.com", + ], + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(createdBooking).toEqual( + expect.objectContaining({ + id: expect.any(Number), + uid: expect.any(String), + status: BookingStatus.ACCEPTED, + }) + ); + + const booking = await prismaMock.booking.findFirst({ + where: { id: createdBooking.id }, + include: { attendees: true }, + }); + + const guestEmails = booking?.attendees.map((a) => a.email).filter((e) => e !== booker.email); + expect(guestEmails).toEqual( + expect.arrayContaining(["guest2-no-verify@example.com", "guest4-no-verify@example.com"]) + ); + expect(guestEmails).not.toContain("guest1-verify@example.com"); + expect(guestEmails).not.toContain("guest3-verify@example.com"); + expect(guestEmails?.length).toBe(2); + }); + + test("should throw error when invalid verification code is provided", async () => { + const handleNewBooking = getNewBookingHandler(); + const { verifyCodeUnAuthenticated } = await import("@calcom/trpc/server/routers/viewer/auth/util"); + + vi.mocked(verifyCodeUnAuthenticated).mockResolvedValue(false); + + const booker = getBooker({ + email: "user@example.com", + name: "User", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + emailVerified: new Date(), + }); + + const userWithVerificationRequired = getOrganizer({ + name: "User", + email: "user@example.com", + id: 201, + schedules: [TestData.schedules.IstWorkHours], + emailVerified: new Date(), + requiresBookerEmailVerification: true, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + usersApartFromOrganizer: [userWithVerificationRequired], + apps: [TestData.apps["google-calendar"]], + }) + ); + + await mockCalendarToHaveNoBusySlots("googlecalendar", {}); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + verificationCode: "invalid-code", + }, + }); + + await expect( + handleNewBooking({ + bookingData: mockBookingData, + }) + ).rejects.toThrow("Invalid verification code"); + + expect(verifyCodeUnAuthenticated).toHaveBeenCalledWith("user@example.com", "invalid-code"); + }); + }); }); diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index 5d78e70a43..8333d236ad 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -11,8 +11,8 @@ import { getParsedTeam } from "@calcom/lib/server/repository/teamUtils"; import { withSelectedCalendars } from "@calcom/lib/server/withSelectedCalendars"; import type { PrismaClient } from "@calcom/prisma"; import { availabilityUserSelect } from "@calcom/prisma"; -import type { User as UserType } from "@calcom/prisma/client"; -import type { Prisma } from "@calcom/prisma/client"; +import type { User as UserType, DestinationCalendar, SelectedCalendar } from "@calcom/prisma/client"; +import { Prisma } from "@calcom/prisma/client"; import type { CreationSource } from "@calcom/prisma/enums"; import { MembershipRole, BookingStatus } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; @@ -49,18 +49,19 @@ export type SessionUser = { darkBrandColor: string | null; movedToProfileId: number | null; completedOnboarding: boolean; - destinationCalendar: any; + destinationCalendar: DestinationCalendar | null; locale: string; timeFormat: number | null; trialEndsAt: Date | null; - metadata: any; + metadata: z.infer; role: string; allowDynamicBooking: boolean; allowSEOIndexing: boolean; receiveMonthlyDigestEmail: boolean; - profiles: any[]; - allSelectedCalendars: any[]; - userLevelSelectedCalendars: any[]; + requiresBookerEmailVerification: boolean; + profiles: UserProfile[]; + allSelectedCalendars: SelectedCalendar[]; + userLevelSelectedCalendars: SelectedCalendar[]; }; const log = logger.getSubLogger({ prefix: ["[repository/user]"] }); @@ -108,6 +109,7 @@ const userSelect = { allowDynamicBooking: true, allowSEOIndexing: true, receiveMonthlyDigestEmail: true, + requiresBookerEmailVerification: true, verified: true, disableImpersonation: true, locked: true, @@ -274,6 +276,84 @@ export class UserRepository { }); return user; } + async findByEmailWithEmailVerificationSetting({ email }: { email: string }) { + const user = await this.prismaClient.user.findFirst({ + where: { + OR: [ + { + email: email.toLowerCase(), + emailVerified: { not: null }, + }, + { + secondaryEmails: { + some: { + email: email.toLowerCase(), + emailVerified: { not: null }, + }, + }, + }, + ], + }, + select: { + id: true, + email: true, + requiresBookerEmailVerification: true, + }, + }); + return user; + } + + async findManyByEmailsWithEmailVerificationSettings({ emails }: { emails: string[] }) { + const normalizedEmails = emails.map((e) => e.toLowerCase()); + + if (!normalizedEmails.length) return []; + const users = await this.findVerifiedUsersByEmailsRaw(normalizedEmails); + + if (!users.length) return []; + return users.map((u) => ({ + email: u.email, + matchedEmail: u.matchedEmail, + requiresBookerEmailVerification: u.requiresBookerEmailVerification, + })); + } + + private async findVerifiedUsersByEmailsRaw(emails: string[]) { + const emailListSql = Prisma.join(emails.map((e) => Prisma.sql`${e}`)); + return this.prismaClient.$queryRaw< + Array<{ + id: number; + email: string; + matchedEmail: string; + requiresBookerEmailVerification: boolean; + }> + >(Prisma.sql` + SELECT + u."id", + u."email", + u."email" AS "matchedEmail", + u."requiresBookerEmailVerification" + FROM + "public"."users" AS u + WHERE + u."email" IN (${emailListSql}) + AND u."emailVerified" IS NOT NULL + AND u."locked" = FALSE + UNION + SELECT + u."id", + u."email", + t0."email" AS "matchedEmail", + u."requiresBookerEmailVerification" + FROM + "public"."users" AS u + INNER JOIN "public"."SecondaryEmail" AS t0 + ON t0."userId" = u."id" + WHERE + t0."email" IN (${emailListSql}) + AND t0."emailVerified" IS NOT NULL + AND u."locked" = FALSE + `); + } async findByEmailAndIncludeProfilesAndPassword({ email }: { email: string }) { const user = await this.prismaClient.user.findUnique({ @@ -422,7 +502,6 @@ export class UserRepository { T extends { id: number; username: string | null; - [key: string]: any; } >({ user, @@ -923,6 +1002,7 @@ export class UserRepository { allowDynamicBooking: true, allowSEOIndexing: true, receiveMonthlyDigestEmail: true, + requiresBookerEmailVerification: true, profiles: true, }, }); diff --git a/packages/lib/errorCodes.ts b/packages/lib/errorCodes.ts index b015ae9164..901320d002 100644 --- a/packages/lib/errorCodes.ts +++ b/packages/lib/errorCodes.ts @@ -26,4 +26,8 @@ export enum ErrorCode { EventTypeNoHosts = "event_type_no_hosts", RequestBodyInvalid = "request_body_invalid_error", PrivateLinkExpired = "private_link_expired", + BookerEmailBlocked = "booker_email_blocked", + BookerEmailRequiresLogin = "booker_email_requires_login", + InvalidVerificationCode = "invalid_verification_code", + UnableToValidateVerificationCode = "unable_to_validate_verification_code", } diff --git a/packages/prisma/migrations/20251006111422_add_requires_booker_email_verification/migration.sql b/packages/prisma/migrations/20251006111422_add_requires_booker_email_verification/migration.sql new file mode 100644 index 0000000000..201bbe6252 --- /dev/null +++ b/packages/prisma/migrations/20251006111422_add_requires_booker_email_verification/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "requiresBookerEmailVerification" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 2ae5689f7b..463d2ed3ec 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -402,7 +402,9 @@ model User { allowSEOIndexing Boolean? @default(true) // receive monthly digest email for teams or not - receiveMonthlyDigestEmail Boolean? @default(true) + receiveMonthlyDigestEmail Boolean? @default(true) + // require email verification when someone books using your email + requiresBookerEmailVerification Boolean? @default(false) /// @zod.import(["import { userMetadata } from '../../zod-utils'"]).custom.use(userMetadata) metadata Json? diff --git a/packages/trpc/server/middlewares/sessionMiddleware.ts b/packages/trpc/server/middlewares/sessionMiddleware.ts index d6589366e6..d7b9a19f85 100644 --- a/packages/trpc/server/middlewares/sessionMiddleware.ts +++ b/packages/trpc/server/middlewares/sessionMiddleware.ts @@ -56,7 +56,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe ["OWNER", "ADMIN"].includes(member.role)); + const isOrgAdmin = members.some((member: { role: string }) => ["OWNER", "ADMIN"].includes(member.role)); if (isOrgAdmin) { logger.debug("User is an org admin", safeStringify({ userId: user.id })); @@ -73,7 +73,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe { ? process.env.BLACKLISTED_GUEST_EMAILS.split(",").map((email) => email.toLowerCase()) : []; - const uniqueGuests = guests.filter( - (guest) => - !booking.attendees.some((attendee) => guest === attendee.email) && - !blacklistedGuestEmails.includes(guest) - ); + const seenBaseEmails = new Set(); + const deduplicatedGuests = guests.filter((guest) => { + const baseEmail = extractBaseEmail(guest).toLowerCase(); + if (seenBaseEmails.has(baseEmail)) { + return false; + } + seenBaseEmails.add(baseEmail); + return true; + }); + + const guestEmails = deduplicatedGuests.map((email) => extractBaseEmail(email).toLowerCase()); + const userRepo = new UserRepository(prisma); + const guestUsers = await userRepo.findManyByEmailsWithEmailVerificationSettings({ emails: guestEmails }); + + const emailToRequiresVerification = new Map(); + for (const user of guestUsers) { + const matchedBase = extractBaseEmail(user.matchedEmail ?? user.email).toLowerCase(); + emailToRequiresVerification.set(matchedBase, user.requiresBookerEmailVerification === true); + } + + const uniqueGuests = deduplicatedGuests.filter((guest) => { + const baseGuestEmail = extractBaseEmail(guest).toLowerCase(); + return ( + !booking.attendees.some( + (attendee) => extractBaseEmail(attendee.email).toLowerCase() === baseGuestEmail + ) && + !blacklistedGuestEmails.includes(baseGuestEmail) && + !emailToRequiresVerification.get(baseGuestEmail) + ); + }); if (uniqueGuests.length === 0) throw new TRPCError({ code: "BAD_REQUEST", message: "emails_must_be_unique_valid" }); @@ -182,9 +209,9 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => { await eventManager.updateCalendarAttendees(evt, booking); try { - await sendAddGuestsEmails(evt, guests); + await sendAddGuestsEmails(evt, uniqueGuests); } catch (err) { - console.log("Error sending AddGuestsEmails"); + console.error("Error sending AddGuestsEmails", err); } return { message: "Guests added" }; diff --git a/packages/trpc/server/routers/viewer/me/get.handler.ts b/packages/trpc/server/routers/viewer/me/get.handler.ts index 7fcd011bc5..e860c01a10 100644 --- a/packages/trpc/server/routers/viewer/me/get.handler.ts +++ b/packages/trpc/server/routers/viewer/me/get.handler.ts @@ -140,6 +140,7 @@ export const getHandler = async ({ ctx, input }: MeOptions) => { allowDynamicBooking: user.allowDynamicBooking, allowSEOIndexing: user.allowSEOIndexing, receiveMonthlyDigestEmail: user.receiveMonthlyDigestEmail, + requiresBookerEmailVerification: user.requiresBookerEmailVerification, ...profileData, secondaryEmails, isPremium: userMetadataPrased?.isPremium, diff --git a/packages/trpc/server/routers/viewer/me/updateProfile.schema.ts b/packages/trpc/server/routers/viewer/me/updateProfile.schema.ts index c3c75b1b62..9fd19441e4 100644 --- a/packages/trpc/server/routers/viewer/me/updateProfile.schema.ts +++ b/packages/trpc/server/routers/viewer/me/updateProfile.schema.ts @@ -21,6 +21,7 @@ export const ZUpdateProfileInputSchema = z.object({ allowDynamicBooking: z.boolean().optional(), allowSEOIndexing: z.boolean().optional(), receiveMonthlyDigestEmail: z.boolean().optional(), + requiresBookerEmailVerification: z.boolean().optional(), brandColor: z.string().optional(), darkBrandColor: z.string().optional(), theme: z.string().optional().nullable(),