From 429e8c4b8463b604a72d0bd4b3a28eaaa8fe5fd4 Mon Sep 17 00:00:00 2001
From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com>
Date: Fri, 3 Jul 2026 12:48:09 +0530
Subject: [PATCH] fix: align email validation between front and server and roll
back optimistic value on failed save (#22490)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Inline edits of EMAILS fields could leave the UI in a misleading state:
the frontend validated with Zod's default `z.email()` while the server
used the stricter `z.regexes.unicodeEmail` pattern (which caps the local
part at 64 characters). A very long email passed client validation and
was optimistically written to the UI; the server then rejected the
mutation. An error snackbar was shown, but the field kept displaying the
unsaved value until a page reload.
## Changes
- **Single source of truth for email validation**: added a shared
`emailSchema` (`z.email({ pattern: z.regexes.unicodeEmail })`) in
`twenty-shared/utils`, now used by:
- the server-side EMAILS field validator
(`validate-emails-primary-email-subfield-or-throw.util.ts`)
- the `EmailsFieldInput` inline editor
- spreadsheet import validation
- **Rollback on failed save**: `useUpdateOneRecord` now restores the
optimistically updated fields in the record store when the mutation
fails, mirroring the store upsert already done in the success path.
Previously the catch block only rolled back the Apollo cache — which
stopped reverting the UI after table virtualization, since the record
store (the render source of truth) is no longer synced reactively from
the cache. The error is still rethrown, so the existing global
promise-rejection handler keeps showing the error snackbar. This fixes
the stale-value-until-reload behavior for all field types and all
callers, not just EMAILS fields.
- **Regression tests**: added unit tests for the shared schema,
including the >64-character local part case.
Fixes [sonarly issue
#54034](https://sonarly.com/issue/54034?share=eyJ0aWQiOjMzMCwidHlwIjoiYnVnIiwicmlkIjo1NDAzNCwiZXhwIjoxNzgzNTI1OTQzfQ.9e7639034a677301512fceeafab764b1)
---
.../object-record/hooks/useUpdateOneRecord.ts | 24 ++++++++++++++++-
.../input/components/EmailsFieldInput.tsx | 3 +--
.../ui/validation-schemas/emailSchema.ts | 3 ---
...etSpreadSheetFieldValidationDefinitions.ts | 2 +-
...ls-primary-email-subfield-or-throw.util.ts | 7 ++---
packages/twenty-shared/src/utils/index.ts | 1 +
.../validation/__tests__/emailSchema.test.ts | 27 +++++++++++++++++++
.../src/utils/validation/emailSchema.ts | 3 +++
8 files changed, 58 insertions(+), 12 deletions(-)
delete mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/validation-schemas/emailSchema.ts
create mode 100644 packages/twenty-shared/src/utils/validation/__tests__/emailSchema.test.ts
create mode 100644 packages/twenty-shared/src/utils/validation/emailSchema.ts
diff --git a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts
index a362a324dd..d51161409a 100644
--- a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts
+++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts
@@ -20,8 +20,8 @@ import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeO
import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput';
import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
-import { isNull } from '@sniptt/guards';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
+import { isNull } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { buildRecordFromKeysWithSameValue } from '~/utils/array/buildRecordFromKeysWithSameValue';
@@ -239,6 +239,28 @@ export const useUpdateOneRecord = () => {
upsertRecordsInStore,
});
+ const optimisticallyUpdatedFieldsToRestore = Object.keys(
+ optimisticRecordInput,
+ ).reduce>(
+ (restoredFields, fieldName) => ({
+ ...restoredFields,
+ [fieldName]: cachedRecordKeys.has(fieldName)
+ ? cachedRecord?.[fieldName]
+ : null,
+ }),
+ {},
+ );
+
+ upsertRecordsInStore({
+ partialRecords: [
+ {
+ id: idToUpdate,
+ __typename: getObjectTypename(objectMetadataItem.nameSingular),
+ ...optimisticallyUpdatedFieldsToRestore,
+ },
+ ],
+ });
+
throw error;
});
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/EmailsFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/EmailsFieldInput.tsx
index 466a21bbe4..1c9222e91c 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/EmailsFieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/EmailsFieldInput.tsx
@@ -5,12 +5,11 @@ import { MULTI_ITEM_FIELD_INPUT_DROPDOWN_ID_PREFIX } from '@/object-record/recor
import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/record-field/ui/states/recordFieldInputIsFieldInErrorComponentState';
import { type FieldEmailsValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { emailsFieldValueSchema } from '@/object-record/record-field/ui/validation-schemas/emailsFieldValueSchema';
-import { emailSchema } from '@/object-record/record-field/ui/validation-schemas/emailSchema';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { useLingui } from '@lingui/react/macro';
import { useCallback, useContext, useMemo } from 'react';
import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants';
-import { isDefined } from 'twenty-shared/utils';
+import { emailSchema, isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { MultiItemFieldInput } from './MultiItemFieldInput';
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/validation-schemas/emailSchema.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/validation-schemas/emailSchema.ts
deleted file mode 100644
index adcbf8eaf0..0000000000
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/validation-schemas/emailSchema.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { z } from 'zod';
-
-export const emailSchema = z.email();
diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts
index e36d71cb6e..48d3304a16 100644
--- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts
+++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts
@@ -1,5 +1,4 @@
import { isFieldRatingValue } from '@/object-record/record-field/ui/types/guards/isFieldRatingValue';
-import { emailSchema } from '@/object-record/record-field/ui/validation-schemas/emailSchema';
import { type SpreadsheetImportFieldValidationDefinition } from '@/spreadsheet-import/types';
import { t } from '@lingui/core/macro';
import { isDate, isString } from '@sniptt/guards';
@@ -7,6 +6,7 @@ import { parsePhoneNumberWithError } from 'libphonenumber-js';
import { RATING_VALUES } from 'twenty-shared/constants';
import {
absoluteUrlSchema,
+ emailSchema,
getCountryCodesForCallingCode,
isDefined,
isValidCountryCode,
diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-primary-email-subfield-or-throw.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-primary-email-subfield-or-throw.util.ts
index 6fa6495747..92b35cd97a 100644
--- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-primary-email-subfield-or-throw.util.ts
+++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-primary-email-subfield-or-throw.util.ts
@@ -2,7 +2,7 @@ import { inspect } from 'util';
import { msg } from '@lingui/core/macro';
import { isNonEmptyString, isNull } from '@sniptt/guards';
-import { z } from 'zod';
+import { emailSchema } from 'twenty-shared/utils';
import {
CommonQueryRunnerException,
@@ -25,10 +25,7 @@ export const validateEmailsPrimaryEmailSubfieldOrThrow = (
);
}
- if (
- !z.email({ pattern: z.regexes.unicodeEmail }).safeParse(value).success &&
- isNonEmptyString(value)
- ) {
+ if (!emailSchema.safeParse(value).success && isNonEmptyString(value)) {
const inspectedValue = inspect(value);
throw new CommonQueryRunnerException(
diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts
index 7bbd88ecc4..a06f426d46 100644
--- a/packages/twenty-shared/src/utils/index.ts
+++ b/packages/twenty-shared/src/utils/index.ts
@@ -226,6 +226,7 @@ export { normalizeUrlOrigin } from './url/normalizeUrlOrigin';
export { safeDecodeURIComponent } from './url/safeDecodeURIComponent';
export { uuidToBase36 } from './uuidToBase36';
export { assertIsDefinedOrThrow } from './validation/assertIsDefinedOrThrow';
+export { emailSchema } from './validation/emailSchema';
export { isDefined } from './validation/isDefined';
export { isEmptyObject } from './validation/isEmptyObject';
export { isLabelIdentifierFieldMetadataTypes } from './validation/isLabelIdentifierFieldMetadataTypes';
diff --git a/packages/twenty-shared/src/utils/validation/__tests__/emailSchema.test.ts b/packages/twenty-shared/src/utils/validation/__tests__/emailSchema.test.ts
new file mode 100644
index 0000000000..12a30290c8
--- /dev/null
+++ b/packages/twenty-shared/src/utils/validation/__tests__/emailSchema.test.ts
@@ -0,0 +1,27 @@
+import { emailSchema } from '@/utils/validation/emailSchema';
+
+describe('emailSchema', () => {
+ it('should accept valid emails', () => {
+ expect(emailSchema.safeParse('john.doe@example.com').success).toBe(true);
+ expect(emailSchema.safeParse('jöhn@example.com').success).toBe(true);
+ expect(emailSchema.safeParse('john+tag@sub.example.co').success).toBe(true);
+ });
+
+ it('should reject invalid emails', () => {
+ expect(emailSchema.safeParse('not-an-email').success).toBe(false);
+ expect(emailSchema.safeParse('john@').success).toBe(false);
+ expect(emailSchema.safeParse('@example.com').success).toBe(false);
+ expect(emailSchema.safeParse('john doe@example.com').success).toBe(false);
+ });
+
+ it('should reject emails with a local part longer than 64 characters', () => {
+ const longLocalPart = 'a'.repeat(65);
+
+ expect(emailSchema.safeParse(`${longLocalPart}@example.com`).success).toBe(
+ false,
+ );
+ expect(emailSchema.safeParse(`${'a'.repeat(64)}@example.com`).success).toBe(
+ true,
+ );
+ });
+});
diff --git a/packages/twenty-shared/src/utils/validation/emailSchema.ts b/packages/twenty-shared/src/utils/validation/emailSchema.ts
new file mode 100644
index 0000000000..316564bf85
--- /dev/null
+++ b/packages/twenty-shared/src/utils/validation/emailSchema.ts
@@ -0,0 +1,3 @@
+import { z } from 'zod';
+
+export const emailSchema = z.email({ pattern: z.regexes.unicodeEmail });