Skip to content

Commit 64297f0

Browse files
keithwillcodedevin-ai-integration[bot]rodrigoehlersdhairyashiilCarinaWolli
authored
feat: add user-specific email verification setting (calcom#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 calcom#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 calcom#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 (calcom#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>
1 parent 9fe5ff7 commit 64297f0

16 files changed

Lines changed: 1065 additions & 53 deletions

File tree

apps/web/modules/settings/my-account/general-view.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
147147
const [isReceiveMonthlyDigestEmailChecked, setIsReceiveMonthlyDigestEmailChecked] = useState(
148148
!!user.receiveMonthlyDigestEmail
149149
);
150+
const [isRequireBookerEmailVerificationChecked, setIsRequireBookerEmailVerificationChecked] = useState(
151+
!!user.requiresBookerEmailVerification
152+
);
150153

151154
const watchedTzSchedules = formMethods.watch("travelSchedules");
152155

@@ -353,6 +356,19 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
353356
}}
354357
switchContainerClassName="mt-6"
355358
/>
359+
360+
<SettingsToggle
361+
toggleSwitchAtTheEnd={true}
362+
title={t("require_booker_email_verification")}
363+
description={t("require_booker_email_verification_description")}
364+
disabled={mutation.isPending}
365+
checked={isRequireBookerEmailVerificationChecked}
366+
onCheckedChange={(checked) => {
367+
setIsRequireBookerEmailVerificationChecked(checked);
368+
mutation.mutate({ requiresBookerEmailVerification: checked });
369+
}}
370+
switchContainerClassName="mt-6"
371+
/>
356372
<TravelScheduleModal
357373
open={isTZScheduleOpen}
358374
onOpenChange={() => setIsTZScheduleOpen(false)}

apps/web/public/static/locales/en/common.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3626,6 +3626,8 @@
36263626
"no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential",
36273627
"download_expense_log": "Download Expense Log",
36283628
"error_downloading_expense_log": "Error downloading expense log",
3629+
"require_booker_email_verification": "Prevent Impersonation on Bookings",
3630+
"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",
36293631
"offer_to_reschedule_last_booking": "Offer to reschedule last active booking to chosen time slot",
36303632
"booker_limit_exceeded_error": "Booker maximum active booking limit exceeded",
36313633
"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?",

apps/web/test/utils/bookingScenario/bookingScenario.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ import type { z } from "zod";
99

1010
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
1111
import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook";
12+
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
1213
import { weekdayToWeekIndex, type WeekDays } from "@calcom/lib/dayjs";
1314
import type { HttpError } from "@calcom/lib/http-error";
1415
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
1516
import logger from "@calcom/lib/logger";
1617
import { safeStringify } from "@calcom/lib/safeStringify";
17-
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
1818
import type { BookingReference, Attendee, Booking, Membership } from "@calcom/prisma/client";
1919
import type { Prisma } from "@calcom/prisma/client";
2020
import type { WebhookTriggerEvents } from "@calcom/prisma/client";
@@ -240,6 +240,8 @@ type InputUser = Omit<typeof TestData.users.example, "defaultScheduleId"> & {
240240
end: string;
241241
}[];
242242
};
243+
requiresBookerEmailVerification?: boolean;
244+
secondaryEmails?: { email: string; emailVerified: Date | null }[];
243245
};
244246

245247
export type InputEventType = {
@@ -834,6 +836,21 @@ export async function addUsersToDb(users: InputUser[]) {
834836
}
835837
}
836838

839+
for (const user of users) {
840+
if (user.secondaryEmails) {
841+
log.debug("Creating SecondaryEmail entries for user", user.id);
842+
for (const secondaryEmail of user.secondaryEmails) {
843+
await prismock.secondaryEmail.create({
844+
data: {
845+
email: secondaryEmail.email,
846+
emailVerified: secondaryEmail.emailVerified,
847+
userId: user.id,
848+
},
849+
});
850+
}
851+
}
852+
}
853+
837854
const allUsers = await prismock.user.findMany({
838855
include: {
839856
credentials: true,
@@ -845,6 +862,7 @@ export async function addUsersToDb(users: InputUser[]) {
845862
},
846863
},
847864
destinationCalendar: true,
865+
secondaryEmails: true,
848866
},
849867
});
850868

@@ -1554,6 +1572,8 @@ export function getOrganizer({
15541572
username,
15551573
locked,
15561574
emailVerified,
1575+
requiresBookerEmailVerification,
1576+
secondaryEmails,
15571577
}: {
15581578
name: string;
15591579
email: string;
@@ -1572,6 +1592,8 @@ export function getOrganizer({
15721592
username?: string;
15731593
locked?: boolean;
15741594
emailVerified?: Date | null;
1595+
requiresBookerEmailVerification?: boolean;
1596+
secondaryEmails?: { email: string; emailVerified: Date | null }[];
15751597
}) {
15761598
username = username ?? TestData.users.example.username;
15771599
return {
@@ -1594,6 +1616,8 @@ export function getOrganizer({
15941616
completedOnboarding,
15951617
locked,
15961618
emailVerified,
1619+
requiresBookerEmailVerification,
1620+
secondaryEmails,
15971621
};
15981622
}
15991623

apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type CommonPropsMockRequestData = {
2727
rescheduledBy?: string;
2828
cancelledBy?: string;
2929
schedulingType?: SchedulingType;
30+
guests?: string[];
3031
responses: {
3132
email: string;
3233
name: string;

packages/features/bookings/lib/handleNewBooking.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ import { groupHostsByGroupId } from "@calcom/lib/bookings/hostGroupUtils";
5858
import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
5959
import { DEFAULT_GROUP_ID } from "@calcom/lib/constants";
6060
import { ErrorCode } from "@calcom/lib/errorCodes";
61-
import { getErrorFromUnknown } from "@calcom/lib/errors";
61+
import { getErrorFromUnknown, ErrorWithCode } from "@calcom/lib/errors";
6262
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
6363
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
6464
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
@@ -490,6 +490,7 @@ async function handler(
490490
const {
491491
prismaClient: prisma,
492492
bookingRepository,
493+
userRepository,
493494
cacheService,
494495
checkBookingAndDurationLimitsService,
495496
luckyUserService,
@@ -545,7 +546,18 @@ async function handler(
545546
const loggerWithEventDetails = createLoggerWithEventDetails(eventTypeId, reqBody.user, eventTypeSlug);
546547
const emailsAndSmsHandler = new BookingEmailSmsHandler({ logger: loggerWithEventDetails });
547548

548-
await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail });
549+
try {
550+
await checkIfBookerEmailIsBlocked({
551+
loggedInUserId: userId,
552+
bookerEmail,
553+
verificationCode: reqBody.verificationCode,
554+
});
555+
} catch (error) {
556+
if (error instanceof ErrorWithCode) {
557+
throw new HttpError({ statusCode: 403, message: error.message });
558+
}
559+
throw error;
560+
}
549561

550562
const spamCheckService = getSpamCheckService();
551563
const eventOrganizationId = await getEventOrganizationId({
@@ -1196,13 +1208,31 @@ async function handler(
11961208
? process.env.BLACKLISTED_GUEST_EMAILS.split(",")
11971209
: [];
11981210

1211+
const guestEmails = (reqGuests || []).map((email) => extractBaseEmail(email).toLowerCase());
1212+
const guestUsers = await userRepository.findManyByEmailsWithEmailVerificationSettings({
1213+
emails: guestEmails,
1214+
});
1215+
1216+
const emailToRequiresVerification = new Map<string, boolean>();
1217+
for (const user of guestUsers) {
1218+
const matchedBase = extractBaseEmail(user.matchedEmail ?? user.email).toLowerCase();
1219+
emailToRequiresVerification.set(matchedBase, user.requiresBookerEmailVerification === true);
1220+
}
1221+
11991222
const guestsRemoved: string[] = [];
12001223
const guests = (reqGuests || []).reduce((guestArray, guest) => {
12011224
const baseGuestEmail = extractBaseEmail(guest).toLowerCase();
1225+
12021226
if (blacklistedGuestEmails.some((e) => e.toLowerCase() === baseGuestEmail)) {
12031227
guestsRemoved.push(guest);
12041228
return guestArray;
12051229
}
1230+
1231+
if (emailToRequiresVerification.get(baseGuestEmail)) {
1232+
guestsRemoved.push(guest);
1233+
return guestArray;
1234+
}
1235+
12061236
// If it's a team event, remove the team member from guests
12071237
if (isTeamEventType && users.some((user) => user.email === guest)) {
12081238
return guestArray;

packages/features/bookings/lib/handleNewBooking/checkIfBookerEmailIsBlocked.ts

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,28 @@
1+
import { ErrorCode } from "@calcom/lib/errorCodes";
2+
import { ErrorWithCode } from "@calcom/lib/errors";
13
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
2-
import { HttpError } from "@calcom/lib/http-error";
34
import prisma from "@calcom/prisma";
5+
import { verifyCodeUnAuthenticated } from "@calcom/trpc/server/routers/viewer/auth/util";
46

57
export const checkIfBookerEmailIsBlocked = async ({
68
bookerEmail,
79
loggedInUserId,
10+
verificationCode,
811
}: {
912
bookerEmail: string;
1013
loggedInUserId?: number;
14+
verificationCode?: string;
1115
}) => {
1216
const baseEmail = extractBaseEmail(bookerEmail);
17+
1318
const blacklistedGuestEmails = process.env.BLACKLISTED_GUEST_EMAILS
1419
? process.env.BLACKLISTED_GUEST_EMAILS.split(",")
1520
: [];
1621

17-
const blacklistedEmail = blacklistedGuestEmails.find(
22+
const blacklistedByEnv = blacklistedGuestEmails.find(
1823
(guestEmail: string) => guestEmail.toLowerCase() === baseEmail.toLowerCase()
1924
);
2025

21-
if (!blacklistedEmail) {
22-
return false;
23-
}
24-
2526
const user = await prisma.user.findFirst({
2627
where: {
2728
OR: [
@@ -46,17 +47,46 @@ export const checkIfBookerEmailIsBlocked = async ({
4647
select: {
4748
id: true,
4849
email: true,
50+
requiresBookerEmailVerification: true,
4951
},
5052
});
5153

54+
const blockedByUserSetting = user?.requiresBookerEmailVerification ?? false;
55+
const shouldBlock = !!blacklistedByEnv || blockedByUserSetting;
56+
57+
if (!shouldBlock) {
58+
return false;
59+
}
60+
5261
if (!user) {
53-
throw new HttpError({ statusCode: 403, message: "Cannot use this email to create the booking." });
62+
throw new ErrorWithCode(ErrorCode.BookerEmailBlocked, "Cannot use this email to create the booking.");
5463
}
5564

5665
if (user.id !== loggedInUserId) {
57-
throw new HttpError({
58-
statusCode: 403,
59-
message: `Attendee email has been blocked. Make sure to login as ${bookerEmail} to use this email for creating a booking.`,
60-
});
66+
// If a verification code is provided, validate it
67+
if (verificationCode) {
68+
let isValid = false;
69+
70+
try {
71+
isValid = await verifyCodeUnAuthenticated(baseEmail, verificationCode);
72+
} catch {
73+
throw new ErrorWithCode(
74+
ErrorCode.UnableToValidateVerificationCode,
75+
"There was an error validating the verification code"
76+
);
77+
}
78+
79+
if (!isValid) {
80+
throw new ErrorWithCode(ErrorCode.InvalidVerificationCode, "Invalid verification code");
81+
}
82+
83+
return false;
84+
}
85+
86+
throw new ErrorWithCode(
87+
ErrorCode.BookerEmailRequiresLogin,
88+
`Attendee email has been blocked. Make sure to login as ${bookerEmail} to use this email for creating a booking.`,
89+
{ email: bookerEmail }
90+
);
6191
}
6292
};

0 commit comments

Comments
 (0)