diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 06e33ce6f80593..0d1c06c2218f07 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -1697,6 +1697,41 @@ export function enableEmailFeature() { }); } +// Helper function to enable email validation feature for a team +export async function enableEmailValidationForTeam(teamId: number) { + // Create the feature if it doesn't exist + await prismock.feature.upsert({ + where: { slug: "booking-email-validation" }, + update: { + enabled: true, + type: "OPERATIONAL", + }, + create: { + slug: "booking-email-validation", + enabled: true, + type: "OPERATIONAL", + description: + "Enable email validation during booking process using ZeroBounce API - Prevents bookings with invalid, spam, or abusive email addresses.", + }, + }); + + // Associate the feature with the team + await prismock.teamFeatures.upsert({ + where: { + teamId_featureId: { + teamId, + featureId: "booking-email-validation", + }, + }, + update: {}, + create: { + teamId, + featureId: "booking-email-validation", + assignedBy: "test-setup", + }, + }); +} + export function mockNoTranslations() { log.silly("Mocking i18n.getTranslation to return identity function"); i18nMock.getTranslation.mockImplementation(() => { @@ -1796,6 +1831,7 @@ export async function mockCalendar( creationCrash?: boolean; updationCrash?: boolean; getAvailabilityCrash?: boolean; + getAvailabilitySlowDownTime?: number; } ): Promise { const appStoreLookupKey = metadataLookupKey; @@ -1958,6 +1994,9 @@ export async function mockCalendar( if (calendarData?.getAvailabilityCrash) { throw new Error("MockCalendarService.getAvailability fake error"); } + if (calendarData?.getAvailabilitySlowDownTime) { + await new Promise((resolve) => setTimeout(resolve, calendarData.getAvailabilitySlowDownTime)); + } getAvailabilityCalls.push({ args: { dateFrom, diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index d473c146dd7b1f..8af7602c38b329 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1,4 +1,3 @@ - import short, { uuid } from "short-uuid"; import { v5 as uuidv5 } from "uuid"; @@ -25,6 +24,9 @@ import { handlePayment } from "@calcom/features/bookings/lib/handlePayment"; import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger"; import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled"; import type { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache"; +import { getCheckBookingAndDurationLimitsService } from "@calcom/features/di/containers/BookingLimits"; +import { getCacheService } from "@calcom/features/di/containers/Cache"; +import { getLuckyUserService } from "@calcom/features/di/containers/LuckyUser"; import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder"; import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents"; import { getEventName, updateHostInEventName } from "@calcom/features/eventtypes/lib/eventNaming"; @@ -47,9 +49,6 @@ import { enrichHostsWithDelegationCredentials, getFirstDelegationConferencingCredentialAppLocation, } from "@calcom/lib/delegationCredential/server"; -import { getCheckBookingAndDurationLimitsService } from "@calcom/features/di/containers/BookingLimits"; -import { getCacheService } from "@calcom/features/di/containers/Cache"; -import { getLuckyUserService } from "@calcom/features/di/containers/LuckyUser"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { getErrorFromUnknown } from "@calcom/lib/errors"; import { extractBaseEmail } from "@calcom/lib/extract-base-email"; @@ -89,6 +88,7 @@ import type { import type { CredentialForCalendarService } from "@calcom/types/Credential"; import type { EventResult, PartialReference } from "@calcom/types/EventManager"; +import { validateBookingEmail } from "../../emailValidation/lib/validateBookingEmail"; import type { EventPayloadType, EventTypeInfo } from "../../webhooks/lib/sendPayload"; import { BookingActionMap, BookingEmailSmsHandler } from "./BookingEmailSmsHandler"; import { getAllCredentialsIncludeServiceAccountKey } from "./getAllCredentialsForUsersOnEvent/getAllCredentials"; @@ -497,6 +497,16 @@ async function handler( await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail }); + // Email validation - Fast checks (cache + Cal.com) + const emailValidationResult = await validateBookingEmail({ + email: bookerEmail, + teamId: eventType.teamId ?? eventType.parent?.teamId ?? null, + logger: loggerWithEventDetails, + }); + + // We don't await fullValidation here as we want it to progress along with other slow parallel operations(like availability check) + emailValidationResult?.startProviderValidation(); + if (!rawBookingData.rescheduleUid) { await checkActiveBookingsLimitForBooker({ eventTypeId, @@ -1395,6 +1405,9 @@ async function handler( organizerUser.id ); + // This is a good place to wait as availability loading and other things happen in parallel before it and bookings are created only after it. + await emailValidationResult?.waitForProviderValidation(); + // For seats, if the booking already exists then we want to add the new attendee to the existing booking if (eventType.seatsPerTimeSlot) { const newBooking = await handleSeats({ @@ -1480,7 +1493,8 @@ async function handler( const changedOrganizer = !!originalRescheduledBooking && - (eventType.schedulingType === SchedulingType.ROUND_ROBIN || eventType.schedulingType === SchedulingType.COLLECTIVE) && + (eventType.schedulingType === SchedulingType.ROUND_ROBIN || + eventType.schedulingType === SchedulingType.COLLECTIVE) && originalRescheduledBooking.userId !== evt.organizer.id; const skipDeleteEventsAndMeetings = changedOrganizer; diff --git a/packages/features/bookings/lib/handleNewBooking/test/email-validation.test.ts b/packages/features/bookings/lib/handleNewBooking/test/email-validation.test.ts new file mode 100644 index 00000000000000..64ae9008a82a34 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/email-validation.test.ts @@ -0,0 +1,409 @@ +import { + createBookingScenario, + getGoogleCalendarCredential, + TestData, + getOrganizer, + getBooker, + getScenarioData, + mockCalendarToHaveNoBusySlots, + enableEmailValidationForTeam, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { + expectBookingToBeInDatabase, + expectSuccessfulBookingCreationEmails, +} from "@calcom/web/test/utils/bookingScenario/expects"; +import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; +import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; + +import { createHash } from "crypto"; +import { vi, beforeEach, describe, expect } from "vitest"; + +import { NoopRedisService } from "@calcom/features/redis/NoopRedisService"; +import { RedisService } from "@calcom/features/redis/RedisService"; +import { BookingStatus } from "@calcom/prisma/enums"; +import { test } from "@calcom/web/test/fixtures/fixtures"; + +import { getNewBookingHandler } from "./getNewBookingHandler"; + +// Mock Redis Service +vi.mock("@calcom/features/redis/RedisService"); +vi.mock("@calcom/features/redis/NoopRedisService"); + +const mockRedisService = { + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), + expire: vi.fn(), +}; + +function getExpectedCacheKey(email: string): string { + return `email_validation:${createHash("sha256").update(email.toLowerCase().trim()).digest("hex")}`; +} +// Mock ZeroBounce API responses +const mockZeroBounceResponses: Record = {}; +const zeroBounceAPIKey = "test-api-key"; +// Override global fetch for ZeroBounce API +globalThis.fetch = vi.fn().mockImplementation((url: string | URL) => { + const urlString = url.toString(); + if (urlString.includes("zerobounce.net/v2/validate")) { + if (!urlString.includes("api_key=" + zeroBounceAPIKey)) { + return Promise.resolve({ + ok: false, + status: 401, + json: () => Promise.resolve({}), + } as Response); + } + const urlObj = new URL(urlString); + const email = urlObj.searchParams.get("email") || ""; + + const response = mockZeroBounceResponses[email] || { + address: email, + status: "valid", + sub_status: "", + free_email: false, + processed_at: "2023-12-07 10:00:00.000", + }; + + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(response), + } as Response); + } + + return Promise.resolve({ + ok: false, + status: 404, + } as Response); +}); + +setupAndTeardown(); + +describe("Email Validation", () => { + beforeEach(async () => { + vi.clearAllMocks(); + Object.keys(mockZeroBounceResponses).forEach((key) => delete mockZeroBounceResponses[key]); + process.env.ZEROBOUNCE_API_KEY = zeroBounceAPIKey; + + // Setup Redis mock implementation for both RedisService and NoopRedisService + // This ensures the mock works regardless of which service the DI container chooses + vi.mocked(RedisService).mockImplementation(() => mockRedisService as unknown as RedisService); + vi.mocked(NoopRedisService).mockImplementation(() => mockRedisService as unknown as NoopRedisService); + + // Configure Redis mock to return null by default (cache miss) + mockRedisService.get.mockResolvedValue(null); + mockRedisService.set.mockResolvedValue(undefined); + + // Enable email validation feature for team with id 1 (used in tests) + await enableEmailValidationForTeam(1); + }); + + test("should create booking successfully with valid email", async ({ emails }) => { + const handleNewBooking = getNewBookingHandler(); + const booker = getBooker({ + email: "valid-user@example.com", + name: "Valid User", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@example.com", + }, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + teamId: 1, + slotInterval: 30, + length: 30, + users: [{ id: 101 }], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"]], + }) + ); + + // Mock valid email response + mockZeroBounceResponses["valid-user@example.com"] = { + address: "valid-user@example.com", + status: "valid", + sub_status: "", + }; + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { id: "google_calendar_event_id" }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(createdBooking.responses).toMatchObject({ + name: booker.name, + email: booker.email, + }); + + await expectBookingToBeInDatabase({ + description: "", + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + }); + + expectSuccessfulBookingCreationEmails({ + booking: { uid: createdBooking.uid! }, + booker, + organizer, + emails, + iCalUID: createdBooking.iCalUID, + }); + }); + + test("should reject booking with invalid email from ZeroBounce", async () => { + const handleNewBooking = getNewBookingHandler(); + const booker = getBooker({ + email: "invalid@nonexistent-domain.fake", + name: "Invalid User", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { id: "google_calendar_event_id" }, + // Slow down availability check to 5 seconds to ensure that validation failure would happen before availability check + getAvailabilitySlowDownTime: 5000, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + teamId: 1, + slotInterval: 30, + length: 30, + users: [{ id: 101 }], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"]], + }) + ); + + // Mock invalid email response + mockZeroBounceResponses["invalid@nonexistent-domain.fake"] = { + address: "invalid@nonexistent-domain.fake", + status: "invalid", + sub_status: "mailbox_not_found", + }; + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const newBookingPromise = handleNewBooking({ + bookingData: mockBookingData, + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + expect(fetch).toHaveBeenCalled(); + + // Now with email validation feature enabled, invalid emails should be rejected + await expect(newBookingPromise).rejects.toThrow( + "This email address cannot be used for bookings. Please use a different email." + ); + }, 7000); + + test("should fallback to allow booking when ZeroBounce API fails", async ({ emails }) => { + const handleNewBooking = getNewBookingHandler(); + const booker = getBooker({ + email: "fallback@example.com", + name: "Fallback User", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@example.com", + }, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + teamId: 1, + slotInterval: 30, + length: 30, + users: [{ id: 101 }], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"]], + }) + ); + + // Mock ZeroBounce API to fail with network error for this specific email + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis.fetch as any).mockImplementation((url: string | URL) => { + const urlString = url.toString(); + if (urlString.includes("zerobounce.net/v2/validate")) { + return Promise.reject(new Error("Network error: Failed to connect to ZeroBounce API")); + } + + return Promise.resolve({ + ok: false, + status: 404, + } as Response); + }); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { id: "google_calendar_event_id" }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + // Should succeed because it falls back to allowing the booking when API fails + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(createdBooking.responses).toMatchObject({ + name: booker.name, + email: booker.email, + }); + + await expectBookingToBeInDatabase({ + description: "", + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + }); + + expectSuccessfulBookingCreationEmails({ + booking: { uid: createdBooking.uid! }, + booker, + organizer, + emails, + iCalUID: createdBooking.iCalUID, + }); + }); + + test("should use cached email validation result from Redis", async () => { + const handleNewBooking = getNewBookingHandler(); + const booker = getBooker({ + email: "cached-user@example.com", + name: "Cached User", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@example.com", + }, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + teamId: 1, + slotInterval: 30, + length: 30, + users: [{ id: 101 }], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"]], + }) + ); + + const cachedResult = { + status: "invalid", + }; + + mockRedisService.get.mockResolvedValue(cachedResult); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { id: "google_calendar_event_id" }, + }); + + 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("This email address cannot be used for bookings. Please use a different email."); + + expect(mockRedisService.get).toHaveBeenCalledWith(getExpectedCacheKey(booker.email)); + }); +}); diff --git a/packages/features/di/bookings/tokens.ts b/packages/features/di/bookings/tokens.ts index 84f8827cc393c6..b2209ff0cf6879 100644 --- a/packages/features/di/bookings/tokens.ts +++ b/packages/features/di/bookings/tokens.ts @@ -5,4 +5,8 @@ export const BOOKING_DI_TOKENS = { RECURRING_BOOKING_SERVICE_MODULE: Symbol("RecurringBookingServiceModule"), INSTANT_BOOKING_CREATE_SERVICE: Symbol("InstantBookingCreateService"), INSTANT_BOOKING_CREATE_SERVICE_MODULE: Symbol("InstantBookingCreateServiceModule"), + EMAIL_VALIDATION_SERVICE: Symbol("EmailValidationService"), + EMAIL_VALIDATION_SERVICE_MODULE: Symbol("EmailValidationServiceModule"), + EMAIL_VALIDATION_PROVIDER_SERVICE: Symbol("EmailValidationProviderService"), + EMAIL_VALIDATION_PROVIDER_SERVICE_MODULE: Symbol("EmailValidationProviderServiceModule"), }; diff --git a/packages/features/di/tokens.ts b/packages/features/di/tokens.ts index 9936078b304d5a..78e778c6632be1 100644 --- a/packages/features/di/tokens.ts +++ b/packages/features/di/tokens.ts @@ -5,6 +5,7 @@ export const DI_TOKENS = { READ_ONLY_PRISMA_CLIENT: Symbol("ReadOnlyPrismaClient"), PRISMA_MODULE: Symbol("PrismaModule"), REDIS_CLIENT: Symbol("RedisClient"), + REDIS_CLIENT_MODULE: Symbol("RedisClientModule"), OOO_REPOSITORY: Symbol("OOORepository"), OOO_REPOSITORY_MODULE: Symbol("OOORepositoryModule"), SCHEDULE_REPOSITORY: Symbol("ScheduleRepository"), diff --git a/packages/features/emailValidation/di/EmailValidation.container.ts b/packages/features/emailValidation/di/EmailValidation.container.ts new file mode 100644 index 00000000000000..61c9fc405e9f7a --- /dev/null +++ b/packages/features/emailValidation/di/EmailValidation.container.ts @@ -0,0 +1,12 @@ +import { createContainer } from "@calcom/features/di/di"; +import { DI_TOKENS } from "@calcom/features/di/tokens"; + +import type { IEmailValidationService } from "../lib/service/IEmailValidationService.interface"; +import { moduleLoader as emailValidationServiceModule } from "./EmailValidationService.module"; + +const container = createContainer(); + +export function getEmailValidationService(): IEmailValidationService { + emailValidationServiceModule.loadModule(container); + return container.get(DI_TOKENS.EMAIL_VALIDATION_SERVICE); +} diff --git a/packages/features/emailValidation/di/EmailValidationProviderService.module.ts b/packages/features/emailValidation/di/EmailValidationProviderService.module.ts new file mode 100644 index 00000000000000..7dd61c7fc3b363 --- /dev/null +++ b/packages/features/emailValidation/di/EmailValidationProviderService.module.ts @@ -0,0 +1,22 @@ +import { ModuleLoader, bindModuleToClassOnToken, createModule } from "@calcom/features/di/di"; +import { DI_TOKENS } from "@calcom/features/di/tokens"; + +import { ZeroBounceEmailValidationProviderService } from "../lib/service/ZeroBounceEmailValidationProviderService"; + +export const emailValidationProviderServiceModule = createModule(); + +const token = DI_TOKENS.EMAIL_VALIDATION_PROVIDER_SERVICE; +const moduleToken = DI_TOKENS.EMAIL_VALIDATION_PROVIDER_SERVICE_MODULE; + +export const emailValidationProviderServiceLoader = bindModuleToClassOnToken({ + module: emailValidationProviderServiceModule, + moduleToken, + token, + classs: ZeroBounceEmailValidationProviderService, + depsMap: {}, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule: emailValidationProviderServiceLoader, +}; diff --git a/packages/features/emailValidation/di/EmailValidationService.module.ts b/packages/features/emailValidation/di/EmailValidationService.module.ts new file mode 100644 index 00000000000000..fe1dd5eb1cd596 --- /dev/null +++ b/packages/features/emailValidation/di/EmailValidationService.module.ts @@ -0,0 +1,29 @@ +import { ModuleLoader, bindModuleToClassOnToken, createModule } from "@calcom/features/di/di"; +import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User"; +import { DI_TOKENS } from "@calcom/features/di/tokens"; +import { moduleLoader as redisModuleLoader } from "@calcom/features/redis/di/redisModule"; + +import { EmailValidationService } from "../lib/service/EmailValidationService"; +import { moduleLoader as emailValidationProviderServiceLoader } from "./EmailValidationProviderService.module"; + +export const emailValidationServiceModule = createModule(); + +const token = DI_TOKENS.EMAIL_VALIDATION_SERVICE; +const moduleToken = DI_TOKENS.EMAIL_VALIDATION_SERVICE_MODULE; + +export const emailValidationServiceLoader = bindModuleToClassOnToken({ + module: emailValidationServiceModule, + moduleToken, + token, + classs: EmailValidationService, + depsMap: { + emailValidationProvider: emailValidationProviderServiceLoader, + userRepository: userRepositoryModuleLoader, + redisService: redisModuleLoader, + }, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule: emailValidationServiceLoader, +}; diff --git a/packages/features/emailValidation/lib/dto/types.ts b/packages/features/emailValidation/lib/dto/types.ts new file mode 100644 index 00000000000000..73889b6cc52212 --- /dev/null +++ b/packages/features/emailValidation/lib/dto/types.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +export interface EmailValidationRequest { + email: string; + ipAddress?: string; +} + +/** + * Canonical email validation statuses that all providers must map to. + * + * Status definitions based on ZeroBounce API documentation. + */ +export const validationStatusSchema = z.enum([ + /** + * Email is verified by Cal.com user account + */ + "calcom-verified-email", + /** + * Provider validation unsuccessful/timed out, allowing booking temporarily and avoiding hammering the provider + */ + "calcom-provider-fallback", + /** + * Confirmed to be valid and safe to email, with a very low bounce rate + */ + "valid", + /** + * Email addresses determined to be invalid + */ + "invalid", + /** + * Email servers configured to accept all emails, making it impossible to validate without sending a real email + */ + "catch-all", + /** + * Addresses that couldn't be validated for various reasons (offline mail server, anti-spam systems, etc.) + */ + "unknown", + /** + * Email addresses believed to be spam traps; sending to these can harm your sender reputation + */ + "spamtrap", + /** + * Email addresses associated with individuals known to mark emails as spam or abuse + */ + "abuse", + /** + * Addresses that are valid but should be avoided (disposable, role-based, toxic, etc.) + */ + "do-not-mail", +]); + +export type EmailValidationStatus = z.infer; + +export interface EmailValidationResult { + /** + * The validation status from the canonical set above. + * Providers are responsible for mapping their API responses to these statuses. + */ + status: EmailValidationStatus; + /** + * Optional provider-specific sub-status for additional context. + * This is NOT used for blocking decisions, only for logging/debugging. + */ + subStatus?: string; +} diff --git a/packages/features/emailValidation/lib/service/EmailValidationService.test.ts b/packages/features/emailValidation/lib/service/EmailValidationService.test.ts new file mode 100644 index 00000000000000..4e78522f6a5577 --- /dev/null +++ b/packages/features/emailValidation/lib/service/EmailValidationService.test.ts @@ -0,0 +1,357 @@ +import { createHash } from "crypto"; +import { beforeEach, describe, expect, it, vi, type Mocked } from "vitest"; + +import type { IRedisService } from "@calcom/features/redis/IRedisService"; +import type { UserRepository } from "@calcom/lib/server/repository/user"; + +import type { EmailValidationRequest, EmailValidationResult } from "../dto/types"; +import { EmailValidationService } from "./EmailValidationService"; +import type { IEmailValidationProviderService } from "./IEmailValidationProviderService.interface"; + +vi.mock("@calcom/lib/logger", () => ({ + default: { + getSubLogger: vi.fn(() => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + })), + }, +})); + +/** + * Helper function to generate expected cache key for tests. + * Must match the hashing implementation in EmailValidationService. + */ +const getExpectedCacheKey = (email: string): string => { + const hash = createHash("sha256").update(email.toLowerCase().trim()).digest("hex"); + return `email_validation:${hash}`; +}; + +const mockEmailValidationProvider = { + validateEmail: vi.fn(), +} as unknown as Mocked; + +type MockedUserFindByReturnType = Awaited>; +const mockUserRepository = { + findByEmail: vi.fn(), +} as unknown as Mocked; + +const mockRedisService = { + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), + expire: vi.fn(), + lrange: vi.fn(), + lpush: vi.fn(), +} as unknown as Mocked; + +describe("EmailValidationService", () => { + let service: EmailValidationService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new EmailValidationService({ + emailValidationProvider: mockEmailValidationProvider, + userRepository: mockUserRepository, + redisService: mockRedisService, + }); + }); + + describe("validateWithCalcom", () => { + describe("results from cache", () => { + it("should use cached valid result and return shouldBlock=false and continueWithProvider=false for status calcom-verified-email", async () => { + const email = "cached-valid@example.com"; + + const cachedResult = { + status: "calcom-verified-email", + }; + + mockRedisService.get.mockResolvedValue(cachedResult); + + const response = await service.validateWithCalcom(email); + + expect(response).toEqual({ + shouldBlock: false, + continueWithProvider: false, + }); + expect(mockRedisService.get).toHaveBeenCalledWith(getExpectedCacheKey(email)); + expect(mockUserRepository.findByEmail).not.toHaveBeenCalled(); + }); + + it("should ignore cached result if it has some unknown status and instead check the database", async () => { + const email = "cached-valid@example.com"; + + const cachedResult = { + status: "some-unknown-status", + }; + + mockRedisService.get.mockResolvedValue(cachedResult); + mockUserRepository.findByEmail.mockResolvedValue({ + id: 1, + email: email, + emailVerified: null, + } as MockedUserFindByReturnType); + + const response = await service.validateWithCalcom(email); + + expect(response).toEqual({ + shouldBlock: false, + continueWithProvider: true, + }); + expect(mockRedisService.get).toHaveBeenCalledWith(getExpectedCacheKey(email)); + expect(mockUserRepository.findByEmail).toHaveBeenCalledWith({ email }); + expect(mockRedisService.set).not.toHaveBeenCalled(); + }); + + it("should use cached `invalid` status and return shouldBlock=true and continueWithProvider=false", async () => { + const email = "cached-invalid@example.com"; + + const cachedResult = { + status: "invalid", + }; + + mockRedisService.get.mockResolvedValue(cachedResult); + + const response = await service.validateWithCalcom(email); + + expect(response).toEqual({ + shouldBlock: true, + continueWithProvider: false, + }); + expect(mockUserRepository.findByEmail).not.toHaveBeenCalled(); + }); + + it("should fetch correct cache even when email is in different case", async () => { + const email = "Test@Example.COM"; + + const cachedResult = { + status: "valid", + }; + + mockRedisService.get.mockResolvedValue(cachedResult); + + await service.validateWithCalcom(email); + + // Should be hashed version of lowercase email + expect(mockRedisService.get).toHaveBeenCalledWith(getExpectedCacheKey(email)); + }); + }); + + describe("results from Cal.com DB", () => { + beforeEach(() => { + // Mock cache miss + mockRedisService.get.mockResolvedValue(null); + }); + + it("should return shouldBlock=false and continueWithProvider=true when email not verified in Cal.com. It must not cache the result", async () => { + const email = "unverified@example.com"; + + // Mock user not verified + mockUserRepository.findByEmail.mockResolvedValue({ + id: 1, + email: email, + emailVerified: null, + } as MockedUserFindByReturnType); + + const response = await service.validateWithCalcom(email); + + expect(response).toEqual({ + shouldBlock: false, + continueWithProvider: true, + }); + expect(mockUserRepository.findByEmail).toHaveBeenCalledWith({ email }); + expect(mockRedisService.set).not.toHaveBeenCalled(); + }); + + it("should return shouldBlock=false and continueWithProvider=true when user doesn't exist. It must not cache the result", async () => { + const email = "nonexistent@example.com"; + + mockUserRepository.findByEmail.mockResolvedValue(null); + + const response = await service.validateWithCalcom(email); + + expect(response).toEqual({ + shouldBlock: false, + continueWithProvider: true, + }); + expect(mockRedisService.set).not.toHaveBeenCalled(); + }); + + it("should return shouldBlock=false and continueWithProvider=false when email is verified by Cal.com. Also caches the result", async () => { + const email = "verified@example.com"; + + // Mock Cal.com verified user + mockUserRepository.findByEmail.mockResolvedValue({ + id: 1, + email: email, + emailVerified: new Date("2024-01-01"), + } as MockedUserFindByReturnType); + + const response = await service.validateWithCalcom(email); + + expect(response).toEqual({ + shouldBlock: false, + continueWithProvider: false, + }); + expect(mockUserRepository.findByEmail).toHaveBeenCalledWith({ email }); + expect(mockRedisService.set).toHaveBeenCalledWith( + getExpectedCacheKey(email), + { status: "calcom-verified-email" }, + { ttl: 24 * 3600 * 1000 } + ); + }); + }); + + describe("Error handling", () => { + it("should not continue with provider when cache read fails and user is not verified (avoid hammering)", async () => { + const email = "cache-error-unverified@example.com"; + + mockRedisService.get.mockRejectedValue(new Error("Redis connection error")); + mockUserRepository.findByEmail.mockResolvedValue({ + id: 1, + email: email, + emailVerified: null, + } as MockedUserFindByReturnType); + + const response = await service.validateWithCalcom(email); + + expect(response).toEqual({ + shouldBlock: false, + continueWithProvider: false, // Don't hammer provider when cache is down + }); + expect(mockUserRepository.findByEmail).toHaveBeenCalledWith({ email }); + }); + + it("should not hammer provider when cache miss and DB check fails", async () => { + const email = "cache-and-db-error@example.com"; + + mockUserRepository.findByEmail.mockRejectedValue(new Error("DB connection error")); + + const response = await service.validateWithCalcom(email); + + expect(response).toEqual({ + shouldBlock: false, + continueWithProvider: false, // Don't hammer provider when DB is down + }); + expect(mockUserRepository.findByEmail).toHaveBeenCalledWith({ email }); + }); + + it("should still work if cache set fails in validateWithCalcom", async () => { + const email = "cache-set-error@example.com"; + + mockRedisService.get.mockResolvedValue(null); + mockRedisService.set.mockRejectedValue(new Error("Cache set error")); + mockUserRepository.findByEmail.mockResolvedValue({ + id: 1, + email: email, + emailVerified: new Date(), + } as MockedUserFindByReturnType); + + const response = await service.validateWithCalcom(email); + + expect(response).not.toBeNull(); + expect(response?.shouldBlock).toBe(false); + }); + }); + }); + + describe("validateWithProvider", () => { + it("should return shouldBlock=false when provider returns valid status. Also caches the result", async () => { + const request: EmailValidationRequest = { + email: "new-user@example.com", + }; + + const providerResult: EmailValidationResult = { + status: "valid", + }; + + mockEmailValidationProvider.validateEmail.mockResolvedValue(providerResult); + + const response = await service.validateWithProvider({ request, skipCache: true }); + + expect(response).toEqual({ + shouldBlock: false, + }); + expect(mockRedisService.get).not.toHaveBeenCalled(); + expect(mockRedisService.set).toHaveBeenCalledWith(getExpectedCacheKey(request.email), providerResult, { + ttl: 24 * 3600 * 1000, + }); + }); + + it("should return shouldBlock=true when provider returns `invalid` status. Also caches the result", async () => { + const request: EmailValidationRequest = { + email: "invalid@example.com", + }; + + const providerResult: EmailValidationResult = { + status: "invalid", + }; + + mockEmailValidationProvider.validateEmail.mockResolvedValue(providerResult); + + const response = await service.validateWithProvider({ request, skipCache: true }); + + expect(response).toEqual({ + shouldBlock: true, + }); + expect(mockRedisService.get).not.toHaveBeenCalled(); + expect(mockRedisService.set).toHaveBeenCalledWith(getExpectedCacheKey(request.email), providerResult, { + ttl: 24 * 3600 * 1000, + }); + }); + + describe("Error handling", () => { + it("should return shouldBlock=false when provider times out (fallback). Also caches the result for a few minutes to avoid hammering the provider", async () => { + vi.useFakeTimers(); + + const request: EmailValidationRequest = { + email: "timeout@example.com", + }; + + // Mock provider to never resolve + mockEmailValidationProvider.validateEmail.mockImplementation(() => new Promise(() => {})); + + const validationPromise = service.validateWithProvider({ request, skipCache: true }); + + // Advance time past timeout + await vi.advanceTimersByTimeAsync(3500); + + const response = await validationPromise; + + expect(response).toEqual({ + shouldBlock: false, + }); + expect(mockRedisService.get).not.toHaveBeenCalled(); // No cache check + expect(mockRedisService.set).toHaveBeenCalledWith( + getExpectedCacheKey(request.email), + { status: "calcom-provider-fallback" }, + { ttl: 5 * 60 * 1000 } + ); + vi.useRealTimers(); + }, 10000); + + it("should return shouldBlock=false when provider is down (fallback). Also caches the result for a few minutes to avoid hammering the provider", async () => { + const request: EmailValidationRequest = { + email: "provider-error@example.com", + }; + + mockEmailValidationProvider.validateEmail.mockRejectedValue(new Error("Provider API error")); + + const response = await service.validateWithProvider({ request, skipCache: true }); + + expect(response).toEqual({ + shouldBlock: false, + }); + expect(mockRedisService.get).not.toHaveBeenCalled(); + + // Expect calcom-provider-fallback status to be cached for a few mins to avoid hammering the provider + expect(mockRedisService.set).toHaveBeenCalledWith( + getExpectedCacheKey(request.email), + { status: "calcom-provider-fallback" }, + { ttl: 5 * 60 * 1000 } + ); + }); + }); + }); +}); diff --git a/packages/features/emailValidation/lib/service/EmailValidationService.ts b/packages/features/emailValidation/lib/service/EmailValidationService.ts new file mode 100644 index 00000000000000..22651cb2b77ce1 --- /dev/null +++ b/packages/features/emailValidation/lib/service/EmailValidationService.ts @@ -0,0 +1,284 @@ +import { createHash } from "crypto"; +import { z } from "zod"; + +import type { IRedisService } from "@calcom/features/redis/IRedisService"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import type { UserRepository } from "@calcom/lib/server/repository/user"; + +import { validationStatusSchema } from "../dto/types"; +import type { EmailValidationRequest, EmailValidationResult, EmailValidationStatus } from "../dto/types"; +import type { IEmailValidationProviderService } from "./IEmailValidationProviderService.interface"; +import type { EmailValidationResponse, IEmailValidationService } from "./IEmailValidationService.interface"; + +/** + * Hashes an email address using SHA-256 to protect PII in cache keys. + * This ensures no plaintext email addresses are stored in Redis for privacy/HIPAA compliance. + */ +const hashEmail = (email: string): string => { + return createHash("sha256").update(email.toLowerCase().trim()).digest("hex"); +}; + +const REDIS_EMAIL_VALIDATION_KEY = (email: string) => `email_validation:${hashEmail(email)}`; + +// Consider increasing this to longer period if it make sense to do so +const EMAIL_VALIDATION_CACHE_TTL_MS = 24 * 3600 * 1000; // 1 day + +// Cache the response for 5 mins if the provider is down to avoid hammering the provider +const EMAIL_VALIDATION_PROVIDER_DOWN_CACHE_TTL_MS = 300_000; // 5 minutes + +const redisSchema = z.object({ + status: validationStatusSchema, + subStatus: z.string().optional(), +}); + +/** + * Main email validation service that handles cross-cutting concerns like caching, timing, logging, + * and fallback behavior while delegating actual validation to provider services. + * + * Provides two validation paths: + * 1. validateWithCalcom - checks if email is verified by Cal.com (cached) + * 2. validateWithProvider - validates with external provider like ZeroBounce (cached, with timeout) + * + */ +export class EmailValidationService implements IEmailValidationService { + private readonly logger = logger.getSubLogger({ prefix: ["EmailValidationService"] }); + private readonly providerTimeout: number = 3000; + + /** + * Canonical statuses that should block emails. + * These are based on the universal meanings of the canonical statuses. + */ + private readonly blockedStatuses: Set = new Set([ + "invalid", + "spamtrap", + "abuse", + "do-not-mail", + ]); + + constructor( + private readonly deps: { + emailValidationProvider: IEmailValidationProviderService; + userRepository: UserRepository; + redisService: IRedisService; + } + ) {} + + private async setInCache( + email: string, + result: EmailValidationResult + ): Promise<{ success: boolean; error: string | null }> { + const cacheKey = REDIS_EMAIL_VALIDATION_KEY(email); + try { + await this.deps.redisService.set(cacheKey, result, { + ttl: + result.status === "calcom-provider-fallback" + ? EMAIL_VALIDATION_PROVIDER_DOWN_CACHE_TTL_MS + : EMAIL_VALIDATION_CACHE_TTL_MS, + }); + return { + success: true, + error: null, + }; + } catch (cacheSetError) { + const errorMessage = cacheSetError instanceof Error ? cacheSetError.message : "Unknown error"; + // Log cache set error but don't fail the request + this.logger.error("Failed to cache email validation result", safeStringify(errorMessage)); + return { + success: false, + error: errorMessage, + }; + } + } + + private async getFromCache( + email: string + ): Promise<{ success: boolean; error: string | null; result: EmailValidationResult | null }> { + try { + const cacheKey = REDIS_EMAIL_VALIDATION_KEY(email); + const cachedResult = await this.deps.redisService.get(cacheKey); + const parsedResult = cachedResult ? redisSchema.safeParse(cachedResult) : null; + return { + success: true, + result: parsedResult?.success ? parsedResult?.data : null, + error: null, + }; + } catch (cacheGetError) { + const errorMessage = cacheGetError instanceof Error ? cacheGetError.message : "Unknown error"; + this.logger.error("Failed to get email validation result from cache", safeStringify(errorMessage)); + return { + success: false, + result: null, + error: errorMessage, + }; + } + } + + /** + * Validates if an email is verified by Cal.com. + * Checks cache first (single lookup), then Cal.com DB if cache miss. + * Returns shouldBlock and continueWithProvider flags. + */ + async validateWithCalcom(email: string) { + const { success: cacheReadSuccess, result: cachedResult } = await this.getFromCache(email); + + // If found in cache, return result + if (cachedResult) { + const shouldBlock = this.isEmailBlocked(cachedResult.status); + return { + shouldBlock, + continueWithProvider: false, + }; + } + + // Cache miss or cache error - check Cal.com DB + try { + const user = await this.deps.userRepository.findByEmail({ email }); + const isVerified = !!(user && user.emailVerified); + + if (!isVerified) { + // Not verified by Cal.com - caller should try provider + return { + shouldBlock: false, + // Skip provider when cache is down to prevent: + // 1. Hammering ZeroBounce with duplicate requests (no deduplication) + // 2. Cost explosion (every request = API call) + // 3. Rate limiting cascade (ZeroBounce blocks Cal.com) + // Priority: Fix Redis immediately to restore validation. + continueWithProvider: cacheReadSuccess, + }; + } + + // Verified by Cal.com - try to cache result (but don't fail if cache is down) + await this.setInCache(email, { status: "calcom-verified-email" }); + + return { + shouldBlock: false, + continueWithProvider: false, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + // DB error - log and use fallback + this.logger.warn( + "validateWithCalcom DB error, allowing booking and continuing with provider", + safeStringify(errorMessage) + ); + + return { + shouldBlock: false, + // If we are here due to an unhandled error, we could end up hammering the provider + continueWithProvider: false, + }; + } + } + + /** + * Validates email using external provider (ZeroBounce). + * Assumes cache was already checked by validateWithCalcom. + * Directly calls provider with timeout, then caches the result. + */ + async validateWithProvider({ + request, + skipCache, + }: { + request: EmailValidationRequest; + skipCache: true; + }): Promise { + // For now, we only support skipCache: true + // Cache flow is not implemented as validateWithCalcom already handles cache + if (!skipCache) { + throw new Error( + "validateWithProvider with skipCache=false is not supported. Use validateWithCalcom for cache checks." + ); + } + + const { email } = request; + + try { + // Validate with provider (no cache check - already done in validateWithCalcom) + const result = await this.validateWithProviderInternal(request); + + // Cache the result + await this.setInCache(email, result); + + const shouldBlock = this.isEmailBlocked(result.status); + return { + shouldBlock, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + this.logger.warn("validateWithProvider error, allowing booking", safeStringify(errorMessage)); + return { + shouldBlock: false, + }; + } + } + + /** + * Internal method to validate with provider (with timeout). + */ + private async validateWithProviderInternal( + request: EmailValidationRequest + ): Promise { + const startTime = Date.now(); + + try { + // Create a timeout signal for the provider validation + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.providerTimeout); + + try { + // Race between provider validation and timeout + const result = await Promise.race([ + this.deps.emailValidationProvider.validateEmail({ request, abortSignal: controller.signal }), + new Promise((_, reject) => { + controller.signal.addEventListener("abort", () => { + reject(new Error(`Email validation provider timeout after ${this.providerTimeout}ms`)); + }); + }), + ]); + + clearTimeout(timeoutId); + const duration = Date.now() - startTime; + + this.logger.info( + "Time taken to validate email through provider", + safeStringify({ + duration: `${duration}ms`, + provider: this.deps.emailValidationProvider.constructor.name, + }) + ); + + return result; + } catch (error) { + clearTimeout(timeoutId); + throw error; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + const duration = Date.now() - startTime; + const fallbackResult: EmailValidationResult = { + status: "calcom-provider-fallback", + }; + + this.logger.error( + "Provider seems to be having issues, falling back to assuming the email is valid", + safeStringify(errorMessage), + safeStringify({ + duration: `${duration}ms`, + provider: this.deps.emailValidationProvider.constructor.name, + }) + ); + + return fallbackResult; + } + } + + /** + * Determines if an email should be blocked based on validation status. + * Uses canonical status definitions to make universal blocking decisions. + */ + private isEmailBlocked(status: EmailValidationStatus): boolean { + return this.blockedStatuses.has(status); + } +} diff --git a/packages/features/emailValidation/lib/service/IEmailValidationProviderService.interface.ts b/packages/features/emailValidation/lib/service/IEmailValidationProviderService.interface.ts new file mode 100644 index 00000000000000..aca4fae7299700 --- /dev/null +++ b/packages/features/emailValidation/lib/service/IEmailValidationProviderService.interface.ts @@ -0,0 +1,19 @@ +import type { EmailValidationRequest, EmailValidationResult } from "../dto/types"; + +/** + * Interface for email validation provider services (third-party implementations). + * + * Providers are responsible for: + * - Actual validation logic using third-party APIs + * - Mapping provider-specific responses to canonical EmailValidationStatus + * - Provider-specific error handling and timeouts + * + * Note: Providers should NOT determine blocking logic. The canonical statuses + * have universal meanings, and blocking decisions are centralized in EmailValidationService. + */ +export interface IEmailValidationProviderService { + validateEmail(params: { + request: EmailValidationRequest; + abortSignal?: AbortSignal; + }): Promise; +} diff --git a/packages/features/emailValidation/lib/service/IEmailValidationService.interface.ts b/packages/features/emailValidation/lib/service/IEmailValidationService.interface.ts new file mode 100644 index 00000000000000..1b146a6cc0713e --- /dev/null +++ b/packages/features/emailValidation/lib/service/IEmailValidationService.interface.ts @@ -0,0 +1,36 @@ +import type { EmailValidationRequest } from "../dto/types"; + +export interface EmailValidationResponse { + shouldBlock: boolean; +} + +export type EmailValidationAndCacheResponse = { + shouldBlock: boolean; + continueWithProvider: boolean; +}; + +export interface IEmailValidationService { + /** + * Validates if an email is verified by Cal.com. + * Checks cache first, then Cal.com DB. + * + * @param email - Email address to validate + * @returns Validation result with shouldBlock flag, or null if not verified by Cal.com + */ + validateWithCalcom(email: string): Promise; + + /** + * Validates email with external provider (ZeroBounce). + * Assumes cache was already checked by validateWithCalcom. + * Directly calls provider with timeout, then caches result. + * + * @param params - Object containing: + * - request: Email validation request with email and optional IP + * - skipCache: If true, skip cache; if false, throw error (cache flow not implemented yet). Defaults to false. + * @returns Validation result with shouldBlock flag + */ + validateWithProvider(params: { + request: EmailValidationRequest; + skipCache: true; + }): Promise; +} diff --git a/packages/features/emailValidation/lib/service/ZeroBounceEmailValidationProviderService.test.ts b/packages/features/emailValidation/lib/service/ZeroBounceEmailValidationProviderService.test.ts new file mode 100644 index 00000000000000..e6de80bf22f043 --- /dev/null +++ b/packages/features/emailValidation/lib/service/ZeroBounceEmailValidationProviderService.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import { ZeroBounceEmailValidationProviderService } from "./ZeroBounceEmailValidationProviderService"; + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe("ZeroBounceEmailValidationProviderService", () => { + let service: ZeroBounceEmailValidationProviderService; + + beforeEach(() => { + vi.clearAllMocks(); + // Set environment variable for tests + process.env.ZEROBOUNCE_API_KEY = "test-api-key"; + service = new ZeroBounceEmailValidationProviderService(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + delete process.env.ZEROBOUNCE_API_KEY; + }); + + describe("validateEmail", () => { + it("should return status as provided by ZeroBounce", async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + address: "test@example.com", + status: "valid", + sub_status: "none", + account: "test", + domain: "example.com", + disposable: false, + toxic: false, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await service.validateEmail({ request: { email: "test@example.com" } }); + + expect(result).toEqual({ + status: "valid", + subStatus: "none", + }); + }); + + it("should reject validation request when ZeroBounce API key is not configured", async () => { + // Clear the API key for this test + delete process.env.ZEROBOUNCE_API_KEY; + const serviceWithoutKey = new ZeroBounceEmailValidationProviderService(); + + await expect( + serviceWithoutKey.validateEmail({ request: { email: "test@example.com" } }) + ).rejects.toThrow("ZeroBounce API key not configured"); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("should propagate error when ZeroBounce API is unreachable or returns network error", async () => { + mockFetch.mockRejectedValue(new Error("Network error")); + + await expect(service.validateEmail({ request: { email: "test@example.com" } })).rejects.toThrow( + "Network error" + ); + }); + + it("should reject validation request when ZeroBounce API returns server error", async () => { + const mockResponse = { + ok: false, + status: 500, + statusText: "Internal Server Error", + }; + + mockFetch.mockResolvedValue(mockResponse); + + await expect(service.validateEmail({ request: { email: "test@example.com" } })).rejects.toThrow( + "ZeroBounce API returned 500: Internal Server Error" + ); + }); + + it("should validate email with additional IP address context when IP address is provided", async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + address: "test@example.com", + status: "valid", + sub_status: "none", + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + await service.validateEmail({ + request: { + email: "test@example.com", + ipAddress: "192.168.1.1", + }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("ip_address=192.168.1.1"), + expect.any(Object) + ); + }); + }); +}); diff --git a/packages/features/emailValidation/lib/service/ZeroBounceEmailValidationProviderService.ts b/packages/features/emailValidation/lib/service/ZeroBounceEmailValidationProviderService.ts new file mode 100644 index 00000000000000..ffb463740cf649 --- /dev/null +++ b/packages/features/emailValidation/lib/service/ZeroBounceEmailValidationProviderService.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; + +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; + +import type { EmailValidationRequest, EmailValidationResult, EmailValidationStatus } from "../dto/types"; +import type { IEmailValidationProviderService } from "./IEmailValidationProviderService.interface"; + +const ZeroBounceApiResponseSchema = z.object({ + address: z.string(), + status: z.enum(["valid", "invalid", "catch-all", "unknown", "spamtrap", "abuse", "do_not_mail"]), + sub_status: z.string().nullable(), +}); + +type ZeroBounceApiResponse = z.infer; + +export class ZeroBounceEmailValidationProviderService implements IEmailValidationProviderService { + private readonly apiKey: string; + private readonly apiBaseUrl = "https://api.zerobounce.net/v2"; + private readonly logger = logger.getSubLogger({ prefix: ["ZeroBounceEmailValidationProviderService"] }); + + constructor() { + this.apiKey = process.env.ZEROBOUNCE_API_KEY || ""; + } + + async validateEmail({ + request, + abortSignal, + }: { + request: EmailValidationRequest; + abortSignal?: AbortSignal; + }): Promise { + const { email, ipAddress } = request; + + if (!this.apiKey) { + this.logger.error("ZeroBounce API key not configured"); + throw new Error("ZeroBounce API key not configured"); + } + + // Create the API URL with parameters + const url = new URL(`${this.apiBaseUrl}/validate`); + url.searchParams.append("api_key", this.apiKey); + url.searchParams.append("email", email); + if (ipAddress) { + url.searchParams.append("ip_address", ipAddress); + } + + // Make the API request + const response = await fetch(url.toString(), { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": "Cal.com", + }, + signal: abortSignal, + }); + + if (!response.ok) { + throw new Error(`ZeroBounce API returned ${response.status}: ${response.statusText}`); + } + + const rawData = await response.json(); + + // Validate the API response using Zod schema + const validationResult = ZeroBounceApiResponseSchema.safeParse(rawData); + + if (!validationResult.success) { + this.logger.error( + "ZeroBounce API returned invalid response format", + safeStringify({ + email: email, + rawResponse: rawData, + validationErrors: validationResult.error.issues, + }) + ); + throw new Error(`Invalid response format from ZeroBounce API: ${validationResult.error.message}`); + } + + const data = validationResult.data; + const result = this.mapZeroBounceResponse(data); + + return result; + } + + private mapZeroBounceResponse(response: ZeroBounceApiResponse): EmailValidationResult { + return { + status: this.normalizeStatus(response.status), + subStatus: response.sub_status ?? undefined, + }; + } + + /** + * Maps ZeroBounce API statuses to canonical format. + */ + private normalizeStatus(status: string): EmailValidationStatus { + const normalizedStatus = status.toLowerCase(); + + // Map ZeroBounce statuses to canonical format + switch (normalizedStatus) { + case "valid": + return "valid"; + case "invalid": + return "invalid"; + case "catch-all": + return "catch-all"; + case "unknown": + return "unknown"; + case "spamtrap": + return "spamtrap"; + case "abuse": + return "abuse"; + case "do_not_mail": + return "do-not-mail"; + default: + // For any unknown status, default to valid to avoid blocking legitimate users + this.logger.warn("Unknown ZeroBounce status encountered", safeStringify({ status })); + return "valid"; + } + } +} diff --git a/packages/features/emailValidation/lib/validateBookingEmail.ts b/packages/features/emailValidation/lib/validateBookingEmail.ts new file mode 100644 index 00000000000000..f3dbb9c788f8ae --- /dev/null +++ b/packages/features/emailValidation/lib/validateBookingEmail.ts @@ -0,0 +1,127 @@ +import type { Logger } from "tslog"; + +import { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import { HttpError } from "@calcom/lib/http-error"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { prisma } from "@calcom/prisma"; + +import { getEmailValidationService } from "../di/EmailValidation.container"; +import { EmailValidationResponse } from "./service/IEmailValidationService.interface"; + +interface ValidateBookingEmailParams { + email: string; + teamId: number | null; + logger: Logger; + clientIP?: string; +} + +interface EmailValidationResult { + providerValidationPromise: Promise | null; + providerError: HttpError | null; + startProviderValidation(): void; + waitForProviderValidation(): Promise; +} + +const validationErrorMessage = + "This email address cannot be used for bookings. Please use a different email."; +/** + * Performs fast email validation checks (cache + Cal.com DB). + * Returns immediately with methods to start/wait for provider validation. + * + * If shouldBlock from fast checks, throws HttpError immediately. + * Otherwise, returns methods to start slow provider validation and wait for it later. + */ +export async function validateBookingEmail({ + email, + teamId, + logger, +}: // clientIP, +ValidateBookingEmailParams): Promise { + // Check if email validation feature is enabled for this team/org + const featuresRepository = new FeaturesRepository(prisma); + + // If no team, skip validation (individual users) + if (!teamId) { + logger.debug("Email validation skipped - no team associated with event type", safeStringify({ teamId })); + return null; + } + + const isEmailValidationEnabled = await featuresRepository.checkIfTeamHasFeature( + teamId, + "booking-email-validation" + ); + + if (!isEmailValidationEnabled) { + logger.debug("Email validation feature not enabled for team", safeStringify({ teamId })); + return null; + } + + try { + const emailValidationService = getEmailValidationService(); + + // Fast check: cache + Cal.com DB lookup + const calcomResult = await emailValidationService.validateWithCalcom(email); + + // Found in cache or verified by Cal.com + if (calcomResult.shouldBlock) { + throw new HttpError({ + statusCode: 400, + message: validationErrorMessage, + }); + } + + if (!calcomResult.continueWithProvider) { + return null; // No provider validation needed + } + + const result: EmailValidationResult = { + providerError: null, + providerValidationPromise: null, + startProviderValidation() { + const providerValidationPromise = (result.providerValidationPromise = + emailValidationService.validateWithProvider({ + request: { email }, + skipCache: true, + })); + providerValidationPromise + .then((providerResult) => { + if (providerResult.shouldBlock) { + throw new HttpError({ + statusCode: 400, + message: validationErrorMessage, + }); + } + }) + .catch((error) => { + if (error instanceof HttpError) { + // We catch and store the error because error could happen even before we await waitForProviderValidation + // This prevents unhandled promise rejection + result.providerError = error; + return; + } + logger.error( + "Provider email validation unhandled error - allowing booking", + safeStringify(error) + ); + }); + }, + async waitForProviderValidation() { + if (!result.providerValidationPromise) { + return; + } + await result.providerValidationPromise; + if (result.providerError) { + throw result.providerError; + } + }, + }; + + return result; + } catch (error) { + if (error instanceof HttpError) { + throw error; // Re-throw blocking errors + } + logger.error("Email validation unhandled error - allowing booking", safeStringify(error)); + return null; + } +} diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index 81baf16ca5b98c..115e2ad178efbc 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -29,6 +29,7 @@ export type AppFlags = { "calendar-subscription-cache": boolean; "calendar-subscription-sync": boolean; "booker-botid": boolean; + "booking-email-validation": boolean; }; export type TeamFeatures = Record; diff --git a/packages/features/flags/hooks/index.ts b/packages/features/flags/hooks/index.ts index b8c311a33fc81e..7ffd3810d10d81 100644 --- a/packages/features/flags/hooks/index.ts +++ b/packages/features/flags/hooks/index.ts @@ -28,6 +28,7 @@ const initialData: AppFlags = { "calendar-subscription-cache": false, "calendar-subscription-sync": false, "booker-botid": false, + "booking-email-validation": false, }; if (process.env.NEXT_PUBLIC_IS_E2E) { diff --git a/packages/features/redis/di/redisModule.ts b/packages/features/redis/di/redisModule.ts index 66e357facf8a31..3621b61f89a9cd 100644 --- a/packages/features/redis/di/redisModule.ts +++ b/packages/features/redis/di/redisModule.ts @@ -1,12 +1,13 @@ -import { createModule } from "@calcom/features/di/di"; +import { Container, ModuleLoader, createModule } from "@calcom/features/di/di"; import { DI_TOKENS } from "@calcom/features/di/tokens"; import { NoopRedisService } from "../NoopRedisService"; import { RedisService } from "../RedisService"; const redisModule = createModule(); - -redisModule.bind(DI_TOKENS.REDIS_CLIENT).toFactory(() => { +const token = DI_TOKENS.REDIS_CLIENT; +const moduleToken = DI_TOKENS.REDIS_CLIENT_MODULE; +redisModule.bind(token).toFactory(() => { if (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) { return new RedisService(); } @@ -14,3 +15,11 @@ redisModule.bind(DI_TOKENS.REDIS_CLIENT).toFactory(() => { }, "singleton"); export { redisModule }; + +export const moduleLoader: ModuleLoader = { + token, + loadModule: (container: Container) => { + container.load(moduleToken, redisModule); + return redisModule; + }, +}; diff --git a/packages/prisma/migrations/20251001130927_add_feature_flag_booking_email_validation/migration.sql b/packages/prisma/migrations/20251001130927_add_feature_flag_booking_email_validation/migration.sql new file mode 100644 index 00000000000000..798dfb4230dcc8 --- /dev/null +++ b/packages/prisma/migrations/20251001130927_add_feature_flag_booking_email_validation/migration.sql @@ -0,0 +1,9 @@ +INSERT INTO + "Feature" (slug, enabled, description, "type") +VALUES + ( + 'booking-email-validation', + false, + 'Enable email validation during booking process using ZeroBounce API - Prevents bookings with invalid, spam, or abusive email addresses.', + 'OPERATIONAL' + ) ON CONFLICT (slug) DO NOTHING; diff --git a/turbo.json b/turbo.json index aa404168807326..33764d9030db90 100644 --- a/turbo.json +++ b/turbo.json @@ -286,7 +286,8 @@ "MICROSOFT_WEBHOOK_URL", "_CAL_INTERNAL_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS", "ENTERPRISE_SLUGS", - "PLATFORM_ENTERPRISE_SLUGS" + "PLATFORM_ENTERPRISE_SLUGS", + "ZEROBOUNCE_API_KEY" ], "tasks": { "@calcom/web#copy-app-store-static": {