fix: fix email exclusion validation for full emails and and @-prefixed domains (#26727)

* fix(bookings): fix email exclusion validation for full emails and @-prefixed domains

* NIT

* make case insensitive

* fix: make domain matching case-insensitive in doesEmailMatchEntry

Co-Authored-By: unknown <>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Anik Dhabal Babu
2026-01-13 16:56:28 -03:00
committed by GitHub
co-authored by unknown Devin AI
parent 5fa76f943b
commit ce997447a4
2 changed files with 146 additions and 2 deletions
@@ -1536,6 +1536,126 @@ describe("excluded email/domain validation", () => {
email: "blocked.com@allowed.com",
});
});
test("should block full email match when exact email is excluded", async () => {
const excludedEmails = "anik@cal.com";
const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
excludeEmails: excludedEmails,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});
// anik@cal.com should be blocked
const parsedResponses = await schema.safeParseAsync({
name: "test",
email: "anik@cal.com",
});
expect(parsedResponses.success).toBe(false);
if (parsedResponses.success) {
throw new Error("Should not reach here");
}
expect(parsedResponses.error.issues[0]).toEqual(
expect.objectContaining({
code: "custom",
message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`,
})
);
});
test("should block emails with domain when domain starts with @", async () => {
const excludedEmails = "@gmail.com";
const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
excludeEmails: excludedEmails,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});
// anik@gmail.com should be blocked when @gmail.com is excluded
const parsedResponses = await schema.safeParseAsync({
name: "test",
email: "anik@gmail.com",
});
expect(parsedResponses.success).toBe(false);
if (parsedResponses.success) {
throw new Error("Should not reach here");
}
expect(parsedResponses.error.issues[0]).toEqual(
expect.objectContaining({
code: "custom",
message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`,
})
);
});
test("should block emails with domain when excluded email is just the domain without @", async () => {
const excludedEmails = "gmail.com";
const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
excludeEmails: excludedEmails,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});
// test@gmail.com should be blocked when gmail.com is excluded
const parsedResponses = await schema.safeParseAsync({
name: "test",
email: "test@gmail.com",
});
expect(parsedResponses.success).toBe(false);
if (parsedResponses.success) {
throw new Error("Should not reach here");
}
expect(parsedResponses.error.issues[0]).toEqual(
expect.objectContaining({
code: "custom",
message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`,
})
);
});
});
describe("require email/domain validation", () => {
@@ -24,6 +24,28 @@ const ensureValidPhoneNumber = (value: string) => {
// Replace the space(s) in the beginning with + as it is supposed to be provided in the beginning only
return value.replace(/^ +/, "+");
};
/**
* Checks if a booker email matches an email/domain entry.
* Supports three formats:
* - Full email: "user@example.com" - matches exactly
* - Domain with @ prefix: "@example.com" - matches any email ending with "@example.com"
* - Domain without @ prefix: "example.com" - matches any email ending with "@example.com"
*/
const doesEmailMatchEntry = (bookerEmail: string, entry: string): boolean => {
const bookerEmailLower = bookerEmail.toLowerCase();
if (entry.startsWith("@")) {
const domain = entry.slice(1).toLowerCase();
return bookerEmailLower.endsWith("@" + domain);
}
if (entry.includes("@")) {
return bookerEmailLower === entry.toLowerCase();
}
return bookerEmailLower.endsWith("@" + entry.toLowerCase());
};
export const getBookingResponsesPartialSchema = ({ bookingFields, view, translateFn }: CommonParams) => {
const schema = bookingResponses.unwrap().partial().and(catchAllSchema);
@@ -208,7 +230,7 @@ function preprocess<T extends z.ZodType>({
const excludedEmails =
bookingField.excludeEmails?.split(",").map((domain) => domain.trim()) || [];
const match = excludedEmails.find((email) => bookerEmail.endsWith("@" + email));
const match = excludedEmails.find((excludedEntry) => doesEmailMatchEntry(bookerEmail, excludedEntry));
if (match) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -220,7 +242,9 @@ function preprocess<T extends z.ZodType>({
?.split(",")
.map((domain) => domain.trim())
.filter(Boolean) || [];
const requiredEmailsMatch = requiredEmails.find((email) => bookerEmail.endsWith("@" + email));
const requiredEmailsMatch = requiredEmails.find((requiredEntry) =>
doesEmailMatchEntry(bookerEmail, requiredEntry)
);
if (requiredEmails.length > 0 && !requiredEmailsMatch) {
ctx.addIssue({
code: z.ZodIssueCode.custom,