fix: optional email validation (#27329)

* fix: validate optional email fields when input is provided #27299

* fix: validate optional email fields when input is provided

* fix: validate optional email fields when input is provided

* refactor: move empty value handling out of zod-utils

* fix: remove PII from error logs

* test: verify optional email allows empty submission

* test: e2e validation for optional email using url state

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Romit <85230081+romitg2@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Deepesh Bind
2026-02-06 18:39:47 +00:00
committed by GitHub
co-authored by Eunjae Lee Romit Peer Richelsen
parent 68022f369c
commit b45d7c15db
4 changed files with 297 additions and 59 deletions
+53
View File
@@ -920,3 +920,56 @@ test.describe("Past booking cancellation", () => {
await expect(page.locator('[data-testid="cancel"]')).toBeHidden();
});
});
test.describe("Optional Email Field Validation", () => {
test("should validate format if input is provided, but allow empty submission", async ({ page, users }) => {
const user = await users.create({
eventTypes: [
{
title: "Test Event",
slug: "test-event",
length: 30,
bookingFields: [
{ name: "name", type: "name", required: true },
{ name: "email", type: "email", required: true },
{
name: "altEmail",
type: "email",
required: false,
label: "Alternate Email"
},
],
},
],
});
await page.goto(`/${user.username}/test-event`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator('input[name="name"]').fill('Test Booker');
await page.locator('input[name="email"]').fill('booker@example.com');
const optionalField = page.getByLabel(/Alternate Email/i);
// Visibility
await expect(optionalField).toBeVisible();
// Trigger validation error
await optionalField.fill('not-an-email');
await page.click('[data-testid="confirm-book-button"]');
// Verify form didn't submit
await expect(page).not.toHaveURL(/.*isSuccessBookingPage=true.*/);
// Verify empty submission is allowed
await optionalField.fill('');
// Click the button to submit
await page.click('[data-testid="confirm-book-button"]');
// Verify it reached the end
await expect(page).toHaveURL(/.*isSuccessBookingPage=true.*/, { timeout: 15000 });
await expect(page.getByTestId('success-page')).toBeVisible();
});
});
@@ -1854,4 +1854,73 @@ describe("require email/domain validation", () => {
})
);
});
test(`optional email field should fail if invalid input is provided`, async () => {
const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
},
{
name: "optionalEmail",
type: "email",
required: false,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});
const parsedResponses = await schema.safeParseAsync({
name: "John Doe",
email: "john@example.com",
optionalEmail: "not-a-valid-email",
});
expect(parsedResponses.success).toBe(false);
if (!parsedResponses.success) {
expect(parsedResponses.error.issues[0]).toEqual(
expect.objectContaining({
message: `{optionalEmail}${CUSTOM_EMAIL_VALIDATION_ERROR_MSG}`,
})
);
}
});
test(`optional email field should pass if left empty`, async () => {
const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
},
{
name: "optionalEmail",
type: "email",
required: false,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});
const parsedResponses = await schema.safeParseAsync({
name: "John Doe",
email: "john@example.com",
optionalEmail: "",
});
expect(parsedResponses.success).toBe(true);
});
});
@@ -6,12 +6,24 @@ import { fieldTypesSchemaMap } from "@calcom/features/form-builder/schema";
import { dbReadResponseSchema } from "@calcom/lib/dbReadResponseSchema";
import logger from "@calcom/lib/logger";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { bookingResponses, emailSchemaRefinement } from "@calcom/prisma/zod-utils";
import {
bookingResponses,
emailSchemaRefinement,
} from "@calcom/prisma/zod-utils";
type View = ALL_VIEWS | (string & {});
type BookingFields = (z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">) | null;
type TranslationFunction = (key: string, options?: Record<string, unknown>) => string;
type CommonParams = { bookingFields: BookingFields; view: View; translateFn?: TranslationFunction };
type BookingFields =
| (z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">)
| null;
type TranslationFunction = (
key: string,
options?: Record<string, unknown>
) => string;
type CommonParams = {
bookingFields: BookingFields;
view: View;
translateFn?: TranslationFunction;
};
export const bookingResponse = dbReadResponseSchema;
export const bookingResponsesDbSchema = z.record(dbReadResponseSchema);
@@ -46,18 +58,38 @@ const doesEmailMatchEntry = (bookerEmail: string, entry: string): boolean => {
return bookerEmailLower.endsWith("@" + entry.toLowerCase());
};
export const getBookingResponsesPartialSchema = ({ bookingFields, view, translateFn }: CommonParams) => {
export const getBookingResponsesPartialSchema = ({
bookingFields,
view,
translateFn,
}: CommonParams) => {
const schema = bookingResponses.unwrap().partial().and(catchAllSchema);
return preprocess({ schema, bookingFields, isPartialSchema: true, view, translateFn });
return preprocess({
schema,
bookingFields,
isPartialSchema: true,
view,
translateFn,
});
};
// Should be used when we know that not all fields responses are present
// - Can happen when we are parsing the prefill query string
// - Can happen when we are parsing a booking's responses (which was created before we added a new required field)
export default function getBookingResponsesSchema({ bookingFields, view, translateFn }: CommonParams) {
export default function getBookingResponsesSchema({
bookingFields,
view,
translateFn,
}: CommonParams) {
const schema = bookingResponses.and(z.record(z.any()));
return preprocess({ schema, bookingFields, isPartialSchema: false, view, translateFn });
return preprocess({
schema,
bookingFields,
isPartialSchema: false,
view,
translateFn,
});
}
// Should be used when we want to check if the optional fields are entered and valid as well
@@ -97,7 +129,8 @@ function preprocess<T extends z.ZodType>({
const log = logger.getSubLogger({ prefix: ["getBookingResponsesSchema"] });
const preprocessed = z.preprocess(
(responses) => {
const parsedResponses = z.record(z.any()).nullable().parse(responses) || {};
const parsedResponses =
z.record(z.any()).nullable().parse(responses) || {};
const newResponses = {} as typeof parsedResponses;
// if eventType has been deleted, we won't have bookingFields and thus we can't preprocess or validate them.
if (!bookingFields) return parsedResponses;
@@ -109,12 +142,17 @@ function preprocess<T extends z.ZodType>({
}
const views = field.views;
const isFieldApplicableToCurrentView =
currentView === "ALL_VIEWS" ? true : views ? views.find((view) => view.id === currentView) : true;
currentView === "ALL_VIEWS"
? true
: views
? views.find((view) => view.id === currentView)
: true;
if (!isFieldApplicableToCurrentView) {
// If the field is not applicable in the current view, then we don't need to do any processing
return;
}
const fieldTypeSchema = fieldTypesSchemaMap[field.type as keyof typeof fieldTypesSchemaMap];
const fieldTypeSchema =
fieldTypesSchemaMap[field.type as keyof typeof fieldTypesSchemaMap];
// TODO: Move all the schemas along with their respective types to fieldTypeSchema, that would make schemas shared across Routing Forms builder and Booking Question Formm builder
if (fieldTypeSchema) {
newResponses[field.name] = fieldTypeSchema.preprocess({
@@ -129,7 +167,11 @@ function preprocess<T extends z.ZodType>({
newResponses[field.name] = value === "true" || value === true;
}
// Make sure that the value is an array
else if (field.type === "multiemail" || field.type === "checkbox" || field.type === "multiselect") {
else if (
field.type === "multiemail" ||
field.type === "checkbox" ||
field.type === "multiselect"
) {
newResponses[field.name] = value instanceof Array ? value : [value];
}
// Parse JSON
@@ -141,12 +183,17 @@ function preprocess<T extends z.ZodType>({
try {
parsedValue = JSON.parse(value);
} catch (e) {
log.error(`Failed to parse JSON for field ${field.name}: ${value}`, e);
log.error(
`Failed to parse JSON for field ${field.name}`,
e
);
}
const optionsInputs = field.optionsInputs;
const optionInputField = optionsInputs?.[parsedValue.value];
if (optionInputField && optionInputField.type === "phone") {
parsedValue.optionValue = ensureValidPhoneNumber(parsedValue.optionValue);
parsedValue.optionValue = ensureValidPhoneNumber(
parsedValue.optionValue
);
}
newResponses[field.name] = parsedValue;
} else if (field.type === "phone") {
@@ -167,7 +214,9 @@ function preprocess<T extends z.ZodType>({
return;
}
const attendeePhoneNumberField = bookingFields.find((field) => field.name === "attendeePhoneNumber");
const attendeePhoneNumberField = bookingFields.find(
(field) => field.name === "attendeePhoneNumber"
);
const isAttendeePhoneNumberFieldHidden = attendeePhoneNumberField?.hidden;
const emailField = bookingFields.find((field) => field.name === "email");
@@ -181,7 +230,9 @@ function preprocess<T extends z.ZodType>({
for (const bookingField of bookingFields) {
const value = responses[bookingField.name];
const stringSchema = z.string();
const emailSchema = isPartialSchema ? z.string() : z.string().refine(emailSchemaRefinement);
const emailSchema = isPartialSchema
? z.string()
: z.string().refine(emailSchemaRefinement);
const phoneSchema = isPartialSchema
? z.string()
: z.string().refine(async (val) => {
@@ -189,12 +240,18 @@ function preprocess<T extends z.ZodType>({
});
// Tag the message with the input name so that the message can be shown at appropriate place
const m = (message: string, options?: Record<string, unknown>) => {
const translatedMessage = translateFn ? translateFn(message, options) : message;
const translatedMessage = translateFn
? translateFn(message, options)
: message;
return `{${bookingField.name}}${translatedMessage}`;
};
const views = bookingField.views;
const isFieldApplicableToCurrentView =
currentView === "ALL_VIEWS" ? true : views ? views.find((view) => view.id === currentView) : true;
currentView === "ALL_VIEWS"
? true
: views
? views.find((view) => view.id === currentView)
: true;
let hidden = bookingField.hidden;
const numOptions = bookingField.options?.length ?? 0;
if (bookingField.hideWhenJustOneOption) {
@@ -211,12 +268,18 @@ function preprocess<T extends z.ZodType>({
}
if (isRequired && !isPartialSchema && !value) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m(`error_required_field`) });
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m(`error_required_field`),
});
return;
}
if (bookingField.type === "email") {
if (!bookingField.hidden && (checkOptional || bookingField.required)) {
if (
!bookingField.hidden &&
(isRequired || (value && value.trim() !== ""))
) {
// Email RegExp to validate if the input is a valid email
if (!emailSchema.safeParse(value).success) {
ctx.addIssue({
@@ -226,37 +289,46 @@ function preprocess<T extends z.ZodType>({
}
// validate the excluded emails
const bookerEmail = value;
const excludedEmails =
bookingField.excludeEmails?.split(",").map((domain) => domain.trim()) || [];
if (value) {
const bookerEmail = value;
const excludedEmails =
bookingField.excludeEmails
?.split(",")
.map((domain) => domain.trim()) || [];
const match = excludedEmails.find((excludedEntry) => doesEmailMatchEntry(bookerEmail, excludedEntry));
if (match) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("exclude_emails_match_found_error_message"),
});
}
const requiredEmails =
bookingField.requireEmails
?.split(",")
.map((domain) => domain.trim())
.filter(Boolean) || [];
const requiredEmailsMatch = requiredEmails.find((requiredEntry) =>
doesEmailMatchEntry(bookerEmail, requiredEntry)
);
if (requiredEmails.length > 0 && !requiredEmailsMatch) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("require_emails_no_match_found_error_message"),
});
const match = excludedEmails.find((excludedEntry) =>
doesEmailMatchEntry(bookerEmail, excludedEntry)
);
if (match) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("exclude_emails_match_found_error_message"),
});
}
const requiredEmails =
bookingField.requireEmails
?.split(",")
.map((domain) => domain.trim())
.filter(Boolean) || [];
const requiredEmailsMatch = requiredEmails.find((requiredEntry) =>
doesEmailMatchEntry(bookerEmail, requiredEntry)
);
if (requiredEmails.length > 0 && !requiredEmailsMatch) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("require_emails_no_match_found_error_message"),
});
}
}
}
continue;
}
const fieldTypeSchema = fieldTypesSchemaMap[bookingField.type as keyof typeof fieldTypesSchemaMap];
const fieldTypeSchema =
fieldTypesSchemaMap[
bookingField.type as keyof typeof fieldTypesSchemaMap
];
if (fieldTypeSchema) {
fieldTypeSchema.superRefine({
@@ -273,13 +345,19 @@ function preprocess<T extends z.ZodType>({
const emailsParsed = emailSchema.array().safeParse(value);
if (isRequired && (!value || value.length === 0)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m(`error_required_field`) });
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m(`error_required_field`),
});
continue;
}
if (!emailsParsed.success) {
// If additional guests are shown but all inputs are empty then don't show any errors
if (bookingField.name === "guests" && value.every((email: string) => email === "")) {
if (
bookingField.name === "guests" &&
value.every((email: string) => email === "")
) {
// reset guests to empty array, otherwise it adds "" for every input
responses[bookingField.name] = [];
continue;
@@ -294,7 +372,10 @@ function preprocess<T extends z.ZodType>({
const emails = emailsParsed.data;
emails.sort().some((item, i) => {
if (item === emails[i + 1]) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("duplicate_email") });
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("duplicate_email"),
});
return true;
}
});
@@ -303,18 +384,27 @@ function preprocess<T extends z.ZodType>({
if (bookingField.type === "multiselect") {
if (isRequired && (!value || value.length === 0)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m(`error_required_field`) });
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m(`error_required_field`),
});
continue;
}
if (!stringSchema.array().safeParse(value).success) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid array of strings") });
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("Invalid array of strings"),
});
}
continue;
}
if (bookingField.type === "checkbox") {
if (!stringSchema.array().safeParse(value).success) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid array of strings") });
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("Invalid array of strings"),
});
}
continue;
}
@@ -326,7 +416,10 @@ function preprocess<T extends z.ZodType>({
// Validate phone number if the field is not hidden and requires validation
if (!bookingField.hidden && needsValidation) {
if (!(await phoneSchema.safeParseAsync(value)).success) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("invalid_number") });
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("invalid_number"),
});
}
}
continue;
@@ -335,7 +428,10 @@ function preprocess<T extends z.ZodType>({
if (bookingField.type === "boolean") {
const schema = z.boolean();
if (!schema.safeParse(value).success) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid Boolean") });
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("Invalid Boolean"),
});
}
continue;
}
@@ -347,9 +443,14 @@ function preprocess<T extends z.ZodType>({
const typeOfOptionInput = optionField?.type;
if (
// Either the field is required or there is a radio selected, we need to check if the optionInput is required or not.
(isRequired || value?.value) && checkOptional ? true : optionField?.required && !optionValue
(isRequired || value?.value) && checkOptional
? true
: optionField?.required && !optionValue
) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("error_required_field") });
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("error_required_field"),
});
return;
}
@@ -357,7 +458,10 @@ function preprocess<T extends z.ZodType>({
// `typeOfOptionInput` can be any of the main types. So, we the same validations should run for `optionValue`
if (typeOfOptionInput === "phone") {
if (!(await phoneSchema.safeParseAsync(optionValue)).success) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("invalid_number") });
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("invalid_number"),
});
}
}
}
@@ -367,10 +471,17 @@ function preprocess<T extends z.ZodType>({
// Use fieldTypeConfig.propsType to validate for propsType=="text" or propsType=="select" as in those cases, the response would be a string.
// If say we want to do special validation for 'address' that can be added to `fieldTypesSchemaMap`
if (["address", "text", "select", "number", "radio", "textarea"].includes(bookingField.type)) {
if (
["address", "text", "select", "number", "radio", "textarea"].includes(
bookingField.type
)
) {
const schema = stringSchema;
if (!schema.safeParse(value).success) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid string") });
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("Invalid string"),
});
}
continue;
}
@@ -385,7 +496,10 @@ function preprocess<T extends z.ZodType>({
if (isPartialSchema) {
// Query Params can be completely invalid, try to preprocess as much of it in correct format but in worst case simply don't prefill instead of crashing
return preprocessed.catch(function (res?: { error?: unknown[] }) {
console.error("Failed to preprocess query params, prefilling will be skipped", res?.error);
console.error(
"Failed to preprocess query params, prefilling will be skipped",
res?.error
);
return {};
});
}
+3 -1
View File
@@ -901,7 +901,9 @@ export const emailSchema = emailRegexSchema;
// The PR at https://github.com/colinhacks/zod/pull/2157 addresses this issue and improves email validation
// I introduced this refinement(to be used with z.email()) as a short term solution until we upgrade to a zod
// version that will include updates in the above PR.
export const emailSchemaRefinement = (value: string) => {
export const emailSchemaRefinement = (value: string | null | undefined) => {
// If there's no value, it's NOT a valid email format, so return false.
if (!value) return false;
return emailSchema.safeParse(value).success;
};