From 64297f027c124be2d0a51c397901064c65a74df4 Mon Sep 17 00:00:00 2001 From: Keith Williams Date: Wed, 15 Oct 2025 08:36:03 -0300 Subject: [PATCH] feat: add user-specific email verification setting (#24298) * feat: add user-specific email verification setting Add requiresBookerEmailVerification boolean field to User model that allows users to protect their email from impersonation during bookings. When enabled, anyone attempting to book using the protected user's email address (as booker or guest) must complete email verification and be logged in as that email owner. Key changes: - Add requiresBookerEmailVerification field to User schema - Create settings toggle in /settings/my-account/general - Update checkIfBookerEmailIsBlocked to check booker's account setting - Update guest filtering in handleNewBooking and addGuests handlers - Add i18n translations for new setting - Check both primary and verified secondary emails Additional fixes: - Replace 'any' types with proper Prisma and zod types in user.ts - Fix member role type in sessionMiddleware.ts - Fix avatar URL generation bug in sessionMiddleware.ts These type fixes were necessary to resolve pre-commit lint warnings that were blocking the commit. Co-Authored-By: keith@cal.com * fix: address PR review comments - Remove unrelated Watchlist index drops from migration - Add missing Watchlist indexes to schema.prisma to fix drift - Refactor checkIfBookerEmailIsBlocked to throw ErrorWithCode - Move HttpError handling to handleNewBooking caller layer Addresses review comments on PR #24298 Co-Authored-By: keith@cal.com * refactor: move Prisma queries to UserRepository and remove unrelated Watchlist changes - Add findByEmailWithEmailVerificationSetting method to UserRepository - Add findManyByEmailsWithEmailVerificationSettings method to UserRepository - Refactor checkIfUserEmailVerificationRequired handler to use UserRepository - Refactor addGuests handler to use UserRepository - Remove unrelated Watchlist schema indices (organizationId/isGlobal, source) - Remove unrelated WatchlistAudit unique constraint on id Addresses review comments on PR #24298 Co-Authored-By: keith@cal.com * fix: better error codes + use repo * Updated db query with manully written one using UNION (#24430) * fix: resolve usage of deprecated secondary email in return value * fix: type errors from refactors * fix: address CodeRabbit PR review comments - Add NOT NULL constraint to requiresBookerEmailVerification migration - Dedupe guest input by base email to handle plus-addressing correctly - Compare attendees by base email instead of raw strings - Send emails only to filtered uniqueGuests (not all guests) - Improve error logging with actual error details Co-Authored-By: keith@cal.com * fix: indices added by mistake Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> * chore: update label of setting * fix: return matched email for guests * chore: remove whitespace * test: add comprehensive email verification tests - Add 9 test scenarios covering user email verification setting - Test main booker verification (logged in/out, with/without code) - Test secondary email verification as main booker and guest - Test guest filtering when verification is required - Test plus-addressed email handling - Test multiple guests with mixed verification requirements - Test invalid verification code error handling - Update bookingScenario helper to support requiresBookerEmailVerification and secondaryEmails Co-Authored-By: keith@cal.com * fix: correct guest placement in test mock data Move guests array from top-level booking data into responses object to match expected structure in getBookingData.ts which looks for responses.guests (line 74). Fixes three failing tests: - should filter out guest that requires verification - should filter out secondary email with verification when added as guest - should filter only guests requiring verification from multiple guests Co-Authored-By: keith@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Rodrigo Ehlers Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com> Co-authored-by: Rodrigo Ehlers Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> --- .../settings/my-account/general-view.tsx | 16 + apps/web/public/static/locales/en/common.json | 2 + .../utils/bookingScenario/bookingScenario.ts | 26 +- .../getMockRequestDataForBooking.ts | 1 + .../features/bookings/lib/handleNewBooking.ts | 34 +- .../checkIfBookerEmailIsBlocked.ts | 52 +- .../test/booking-validations.test.ts | 822 +++++++++++++++++- .../users/repositories/UserRepository.ts | 96 +- packages/lib/errorCodes.ts | 4 + .../migration.sql | 2 + packages/prisma/schema.prisma | 4 +- .../server/middlewares/sessionMiddleware.ts | 5 +- ...IfUserEmailVerificationRequired.handler.ts | 11 + .../viewer/bookings/addGuests.handler.ts | 41 +- .../server/routers/viewer/me/get.handler.ts | 1 + .../routers/viewer/me/updateProfile.schema.ts | 1 + 16 files changed, 1065 insertions(+), 53 deletions(-) create mode 100644 packages/prisma/migrations/20251006111422_add_requires_booker_email_verification/migration.sql 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(),