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 <keithwillcode@gmail.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 <keithwillcode@gmail.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 <keithwillcode@gmail.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 <keithwillcode@gmail.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 <keithwillcode@gmail.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 <keithwillcode@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Rodrigo Ehlers <rodrigoehlers@outlook.com>
Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
Co-authored-by: Rodrigo Ehlers <rodrigo@chatbyte.ai>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
This commit is contained in:
Keith Williams
2025-10-15 11:36:03 +00:00
committed by GitHub
co-authored by keith@cal.com <keithwillcode@gmail.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Rodrigo Ehlers Dhairyashil Shinde Rodrigo Ehlers Carina Wollendorfer
parent 9fe5ff7721
commit 64297f027c
16 changed files with 1065 additions and 53 deletions
@@ -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"
/>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("require_booker_email_verification")}
description={t("require_booker_email_verification_description")}
disabled={mutation.isPending}
checked={isRequireBookerEmailVerificationChecked}
onCheckedChange={(checked) => {
setIsRequireBookerEmailVerificationChecked(checked);
mutation.mutate({ requiresBookerEmailVerification: checked });
}}
switchContainerClassName="mt-6"
/>
<TravelScheduleModal
open={isTZScheduleOpen}
onOpenChange={() => setIsTZScheduleOpen(false)}
@@ -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?",
@@ -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<typeof TestData.users.example, "defaultScheduleId"> & {
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,
};
}
@@ -27,6 +27,7 @@ type CommonPropsMockRequestData = {
rescheduledBy?: string;
cancelledBy?: string;
schedulingType?: SchedulingType;
guests?: string[];
responses: {
email: string;
name: string;
@@ -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<string, boolean>();
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;
@@ -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 }
);
}
};
@@ -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");
});
});
});
@@ -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<typeof userMetadata>;
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,
},
});
+4
View File
@@ -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",
}
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "requiresBookerEmailVerification" BOOLEAN NOT NULL DEFAULT false;
+3 -1
View File
@@ -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?
@@ -56,7 +56,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
const locale = user?.locale ?? ctx.locale;
const { members = [], ..._organization } = user.profile?.organization || {};
const isOrgAdmin = members.some((member: any) => ["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<S
return {
...user,
avatar: `${WEBAPP_URL}/${user.username}/avatar.png?${organization.id}` && `orgId=${organization.id}`,
avatar: `${WEBAPP_URL}/${user.username}/avatar.png${organization.id ? `?orgId=${organization.id}` : ""}`,
// TODO: OrgNewSchema - later - We could consolidate the props in user.profile?.organization as organization is a profile thing now.
organization,
organizationId: organization.id,
@@ -82,6 +82,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
username,
locale,
defaultBookerLayouts: userMetaData?.defaultBookerLayouts || null,
requiresBookerEmailVerification: user.requiresBookerEmailVerification,
};
}
@@ -1,5 +1,7 @@
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import type { TUserEmailVerificationRequiredSchema } from "./checkIfUserEmailVerificationRequired.schema";
@@ -30,6 +32,15 @@ export const checkEmailVerificationRequired = async ({
log.warn(`blacklistedEmail: ${blacklistedEmail}`);
return true;
}
const userRepo = new UserRepository(prisma);
const user = await userRepo.findByEmailWithEmailVerificationSetting({ email: baseEmail });
if (user?.requiresBookerEmailVerification && baseEmail.toLowerCase() !== userSessionEmail?.toLowerCase()) {
log.warn(`user email requiring verification: ${baseEmail}`);
return true;
}
return false;
};
@@ -3,6 +3,8 @@ import dayjs from "@calcom/dayjs";
import { sendAddGuestsEmails } from "@calcom/emails";
import EventManager from "@calcom/features/bookings/lib/EventManager";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
@@ -79,11 +81,36 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
? 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<string>();
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<string, boolean>();
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" };
@@ -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,
@@ -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(),