From 16d414590b3f20efb2169d87d91c1d1406435215 Mon Sep 17 00:00:00 2001 From: BOHEUS <56270748+BOHEUS@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:24:49 +0000 Subject: [PATCH] Lowercase email (#17775) Fixes https://github.com/twentyhq/core-team-issues/issues/120 #16976 Partially related to #17711 Frontend check surprisingly was one-liner covering both email input and import files Migration script will be done in next commit --------- Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> --- .../data-arg-processor/data-arg.processor.ts | 9 +- ...alidate-emails-field-or-throw.util.spec.ts | 31 ++++ ...dditional-emails-subfield-or-throw.util.ts | 38 ++++ .../validate-emails-field-or-throw.util.ts | 14 +- ...ls-primary-email-subfield-or-throw.util.ts | 42 +++++ .../transform-emails-value.util.spec.ts | 172 ++++++++++++++++++ .../utils/transform-emails-value.util.ts | 3 +- ...-input-validation.integration-spec.ts.snap | 24 +++ ...e-input-by-field-metadata-type.constant.ts | 44 +++++ 9 files changed, 368 insertions(+), 9 deletions(-) create mode 100644 packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-additional-emails-subfield-or-throw.util.ts create mode 100644 packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-primary-email-subfield-or-throw.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-transformer/utils/__tests__/transform-emails-value.util.spec.ts diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg.processor.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg.processor.ts index 1cf6d3916f..0a8e444660 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg.processor.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg.processor.ts @@ -120,10 +120,11 @@ export class DataArgProcessor { ); } - const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({ - flatEntityId: fieldMetadataId, - flatEntityMaps: flatFieldMetadataMaps, - }); + const fieldMetadata = + findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: fieldMetadataId, + flatEntityMaps: flatFieldMetadataMaps, + }); if (!fieldMetadata) { throw new CommonQueryRunnerException( diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/__tests__/validate-emails-field-or-throw.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/__tests__/validate-emails-field-or-throw.util.spec.ts index 0802b8aafd..abc05e1537 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/__tests__/validate-emails-field-or-throw.util.spec.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/__tests__/validate-emails-field-or-throw.util.spec.ts @@ -83,5 +83,36 @@ describe('validateEmailsFieldOrThrow', () => { validateEmailsFieldOrThrow(emailsValue, 'testField'), ).toThrow(CommonQueryRunnerException); }); + + it('should throw when additionalEmails is an invalid string', () => { + const emailsValue = { + additionalEmails: 'ADDITIONALexample.com', + }; + + expect(() => + validateEmailsFieldOrThrow(emailsValue, 'testField'), + ).toThrow(CommonQueryRunnerException); + }); + + it('should throw when primaryEmail is invalid but additionalEmails are valid', () => { + const emailsValue = { + primaryEmail: 'Primaryexample.com', + additionalEmails: ['additional@example.com'], + }; + + expect(() => + validateEmailsFieldOrThrow(emailsValue, 'testField'), + ).toThrow(CommonQueryRunnerException); + }); + + it('should throw when one of additionalEmails is invalid', () => { + const emailsValue = { + additionalEmails: ['Additional1example.com', 'additional2@example.com'], + }; + + expect(() => + validateEmailsFieldOrThrow(emailsValue, 'testField'), + ).toThrow(CommonQueryRunnerException); + }); }); }); diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-additional-emails-subfield-or-throw.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-additional-emails-subfield-or-throw.util.ts new file mode 100644 index 0000000000..02728a5fc7 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-additional-emails-subfield-or-throw.util.ts @@ -0,0 +1,38 @@ +import { inspect } from 'util'; + +import { msg } from '@lingui/core/macro'; +import { isNull } from '@sniptt/guards'; + +import { validateEmailsPrimaryEmailSubfieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-primary-email-subfield-or-throw.util'; +import { + CommonQueryRunnerException, + CommonQueryRunnerExceptionCode, +} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; + +export const validateEmailsAdditionalEmailsSubfieldOrThrow = ( + value: unknown, + fieldName: string, +): string | string[] | null => { + if (isNull(value)) return null; + + if (typeof value === 'string') { + return validateEmailsPrimaryEmailSubfieldOrThrow(value, fieldName); + } + + if ( + !Array.isArray(value) || + value.some((item) => + isNull(validateEmailsPrimaryEmailSubfieldOrThrow(item, fieldName)), + ) + ) { + const inspectedValue = inspect(value); + + throw new CommonQueryRunnerException( + `Invalid value ${inspectedValue} for field "${fieldName} - Array values need to be string"`, + CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA, + { userFriendlyMessage: msg`Invalid value: "${inspectedValue}"` }, + ); + } + + return value; +}; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-field-or-throw.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-field-or-throw.util.ts index f36d70e1cf..d4d033e528 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-field-or-throw.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-field-or-throw.util.ts @@ -1,9 +1,9 @@ import { msg } from '@lingui/core/macro'; import { isNull } from '@sniptt/guards'; -import { validateArrayFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-array-field-or-throw.util'; +import { validateEmailsAdditionalEmailsSubfieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-additional-emails-subfield-or-throw.util'; +import { validateEmailsPrimaryEmailSubfieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-primary-email-subfield-or-throw.util'; import { validateRawJsonFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-raw-json-field-or-throw.util'; -import { validateTextFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-text-field-or-throw.util'; import { CommonQueryRunnerException, CommonQueryRunnerExceptionCode, @@ -23,10 +23,16 @@ export const validateEmailsFieldOrThrow = ( for (const [subField, subFieldValue] of Object.entries(preValidatedValue)) { switch (subField) { case 'primaryEmail': - validateTextFieldOrThrow(subFieldValue, `${fieldName}.${subField}`); + validateEmailsPrimaryEmailSubfieldOrThrow( + subFieldValue, + `${fieldName}.${subField}`, + ); break; case 'additionalEmails': - validateArrayFieldOrThrow(subFieldValue, `${fieldName}.${subField}`); + validateEmailsAdditionalEmailsSubfieldOrThrow( + subFieldValue, + `${fieldName}.${subField}`, + ); break; default: throw new CommonQueryRunnerException( 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 new file mode 100644 index 0000000000..6fa6495747 --- /dev/null +++ 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 @@ -0,0 +1,42 @@ +import { inspect } from 'util'; + +import { msg } from '@lingui/core/macro'; +import { isNonEmptyString, isNull } from '@sniptt/guards'; +import { z } from 'zod'; + +import { + CommonQueryRunnerException, + CommonQueryRunnerExceptionCode, +} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; + +export const validateEmailsPrimaryEmailSubfieldOrThrow = ( + value: unknown, + fieldName: string, +): string | null => { + if (isNull(value)) return null; + + if (typeof value !== 'string') { + const inspectedValue = inspect(value); + + throw new CommonQueryRunnerException( + `Invalid string value ${inspectedValue} for email field "${fieldName}"`, + CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA, + { userFriendlyMessage: msg`Invalid value: "${inspectedValue}"` }, + ); + } + + if ( + !z.email({ pattern: z.regexes.unicodeEmail }).safeParse(value).success && + isNonEmptyString(value) + ) { + const inspectedValue = inspect(value); + + throw new CommonQueryRunnerException( + `Invalid string value ${inspectedValue} for email field "${fieldName}"`, + CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA, + { userFriendlyMessage: msg`Invalid value: "${inspectedValue}"` }, + ); + } + + return value; +}; diff --git a/packages/twenty-server/src/engine/core-modules/record-transformer/utils/__tests__/transform-emails-value.util.spec.ts b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/__tests__/transform-emails-value.util.spec.ts new file mode 100644 index 0000000000..6c39defe80 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/__tests__/transform-emails-value.util.spec.ts @@ -0,0 +1,172 @@ +import { transformEmailsValue } from 'src/engine/core-modules/record-transformer/utils/transform-emails-value.util'; + +describe('transformEmailsValue', () => { + it('should return undefined when value is undefined', () => { + const result = transformEmailsValue(undefined); + + expect(result).toBeUndefined(); + }); + + it('should return null when value is null', () => { + const result = transformEmailsValue(null); + + expect(result).toBeNull(); + }); + + it('should convert primaryEmail to lowercase', () => { + const value = { + primaryEmail: 'TEST@EXAMPLE.COM', + additionalEmails: null, + }; + + const result = transformEmailsValue(value); + + expect(result.primaryEmail).toBe('test@example.com'); + }); + + it('should return null for primaryEmail when it is empty string', () => { + const value = { + primaryEmail: '', + additionalEmails: null, + }; + + const result = transformEmailsValue(value); + + expect(result.primaryEmail).toBeNull(); + }); + + it('should return null for primaryEmail when it is null', () => { + const value = { + primaryEmail: null, + additionalEmails: null, + }; + + const result = transformEmailsValue(value); + + expect(result.primaryEmail).toBeNull(); + }); + + it('should return null for primaryEmail when it is undefined', () => { + const value = { + additionalEmails: null, + }; + + const result = transformEmailsValue(value); + + expect(result.primaryEmail).toBeNull(); + }); + + it('should convert additionalEmails array to lowercase JSON string', () => { + const value = { + primaryEmail: 'test@example.com', + additionalEmails: ['USER1@EXAMPLE.COM', 'USER2@EXAMPLE.COM'], + }; + + const result = transformEmailsValue(value); + + expect(result.additionalEmails).toBe( + '["user1@example.com","user2@example.com"]', + ); + }); + + it('should parse and convert additionalEmails JSON string to lowercase', () => { + const value = { + primaryEmail: 'test@example.com', + additionalEmails: '["USER1@EXAMPLE.COM","USER2@EXAMPLE.COM"]', + }; + + const result = transformEmailsValue(value); + + expect(result.additionalEmails).toBe( + '["user1@example.com","user2@example.com"]', + ); + }); + + it('should return null for additionalEmails when it is an empty array', () => { + const value = { + primaryEmail: 'test@example.com', + additionalEmails: [], + }; + + const result = transformEmailsValue(value); + + expect(result.additionalEmails).toBeNull(); + }); + + it('should return null for additionalEmails when it is null', () => { + const value = { + primaryEmail: 'test@example.com', + additionalEmails: null, + }; + + const result = transformEmailsValue(value); + + expect(result.additionalEmails).toBeNull(); + }); + + it('should handle mixed case emails in additionalEmails array', () => { + const value = { + primaryEmail: 'test@example.com', + additionalEmails: ['Test1@Example.COM', 'TEST2@example.com'], + }; + + const result = transformEmailsValue(value); + + expect(result.additionalEmails).toBe( + '["test1@example.com","test2@example.com"]', + ); + }); + + it('should transform both primaryEmail and additionalEmails correctly', () => { + const value = { + primaryEmail: 'PRIMARY@EXAMPLE.COM', + additionalEmails: ['ADDITIONAL1@EXAMPLE.COM', 'ADDITIONAL2@EXAMPLE.COM'], + }; + + const result = transformEmailsValue(value); + + expect(result).toEqual({ + primaryEmail: 'primary@example.com', + additionalEmails: '["additional1@example.com","additional2@example.com"]', + }); + }); + + it('should handle case where primaryEmail is null and additionalEmails exist', () => { + const value = { + primaryEmail: null, + additionalEmails: ['USER@EXAMPLE.COM'], + }; + + const result = transformEmailsValue(value); + + expect(result).toEqual({ + primaryEmail: null, + additionalEmails: '["user@example.com"]', + }); + }); + + it('should handle case where primaryEmail exists and additionalEmails is null', () => { + const value = { + primaryEmail: 'USER@EXAMPLE.COM', + additionalEmails: null, + }; + + const result = transformEmailsValue(value); + + expect(result).toEqual({ + primaryEmail: 'user@example.com', + additionalEmails: null, + }); + }); + + it('should handle empty value object', () => { + const value = {}; + + const result = transformEmailsValue(value); + + expect(result).toEqual({ + primaryEmail: null, + additionalEmails: undefined, + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-emails-value.util.ts b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-emails-value.util.ts index 1acbe02449..6dfdaf820c 100644 --- a/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-emails-value.util.ts +++ b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-emails-value.util.ts @@ -1,11 +1,12 @@ import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'class-validator'; export const transformEmailsValue = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any, // eslint-disable-next-line @typescript-eslint/no-explicit-any ): any => { - if (!value) { + if (!isDefined(value)) { return value; } diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/__snapshots__/emails-field-create-input-validation.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/__snapshots__/emails-field-create-input-validation.integration-spec.ts.snap index 96b3406d39..5a82f259d7 100644 --- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/__snapshots__/emails-field-create-input-validation.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/__snapshots__/emails-field-create-input-validation.integration-spec.ts.snap @@ -2,4 +2,28 @@ exports[`Create input validation - EMAILS Gql create input - failure EMAILS - should fail with : {"emailsField":"not-an-email"} 1`] = `"Expected type "EmailsCreateInput" to be an object."`; +exports[`Create input validation - EMAILS Gql create input - failure EMAILS - should fail with : {"emailsField":{"additionalEmails":"not-an-email"}} 1`] = `"Invalid string value 'not-an-email' for email field "emailsField.additionalEmails""`; + +exports[`Create input validation - EMAILS Gql create input - failure EMAILS - should fail with : {"emailsField":{"additionalEmails":["not-an-email","additional@email.com"]}} 1`] = `"Invalid string value 'not-an-email' for email field "emailsField.additionalEmails""`; + +exports[`Create input validation - EMAILS Gql create input - failure EMAILS - should fail with : {"emailsField":{"additionalEmails":["not-an-email"]}} 1`] = `"Invalid string value 'not-an-email' for email field "emailsField.additionalEmails""`; + +exports[`Create input validation - EMAILS Gql create input - failure EMAILS - should fail with : {"emailsField":{"primaryEmail":"email@email.com","additionalEmails":["not-an-email"]}} 1`] = `"Invalid string value 'not-an-email' for email field "emailsField.additionalEmails""`; + +exports[`Create input validation - EMAILS Gql create input - failure EMAILS - should fail with : {"emailsField":{"primaryEmail":"not-an-email","additionalEmails":["additional@email.com"]}} 1`] = `"Invalid string value 'not-an-email' for email field "emailsField.primaryEmail""`; + +exports[`Create input validation - EMAILS Gql create input - failure EMAILS - should fail with : {"emailsField":{"primaryEmail":"not-an-email"}} 1`] = `"Invalid string value 'not-an-email' for email field "emailsField.primaryEmail""`; + exports[`Create input validation - EMAILS Rest create input - failure EMAILS - should fail with : {"emailsField":"not-an-email"} 1`] = `"["Invalid object value 'not-an-email' for field \\"emailsField\\""]"`; + +exports[`Create input validation - EMAILS Rest create input - failure EMAILS - should fail with : {"emailsField":{"additionalEmails":"not-an-email"}} 1`] = `"["Invalid string value 'not-an-email' for email field \\"emailsField.additionalEmails\\""]"`; + +exports[`Create input validation - EMAILS Rest create input - failure EMAILS - should fail with : {"emailsField":{"additionalEmails":["not-an-email","additional@email.com"]}} 1`] = `"["Invalid string value 'not-an-email' for email field \\"emailsField.additionalEmails\\""]"`; + +exports[`Create input validation - EMAILS Rest create input - failure EMAILS - should fail with : {"emailsField":{"additionalEmails":["not-an-email"]}} 1`] = `"["Invalid string value 'not-an-email' for email field \\"emailsField.additionalEmails\\""]"`; + +exports[`Create input validation - EMAILS Rest create input - failure EMAILS - should fail with : {"emailsField":{"primaryEmail":"email@email.com","additionalEmails":["not-an-email"]}} 1`] = `"["Invalid string value 'not-an-email' for email field \\"emailsField.additionalEmails\\""]"`; + +exports[`Create input validation - EMAILS Rest create input - failure EMAILS - should fail with : {"emailsField":{"primaryEmail":"not-an-email","additionalEmails":["additional@email.com"]}} 1`] = `"["Invalid string value 'not-an-email' for email field \\"emailsField.primaryEmail\\""]"`; + +exports[`Create input validation - EMAILS Rest create input - failure EMAILS - should fail with : {"emailsField":{"primaryEmail":"not-an-email"}} 1`] = `"["Invalid string value 'not-an-email' for email field \\"emailsField.primaryEmail\\""]"`; diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/failing-create-input-by-field-metadata-type.constant.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/failing-create-input-by-field-metadata-type.constant.ts index 46498d696b..3a9cd972ba 100644 --- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/failing-create-input-by-field-metadata-type.constant.ts +++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/failing-create-input-by-field-metadata-type.constant.ts @@ -345,6 +345,50 @@ export const failingCreateInputByFieldMetadataType: { emailsField: 'not-an-email', }, }, + { + input: { + emailsField: { + primaryEmail: 'not-an-email', + }, + }, + }, + { + input: { + emailsField: { + additionalEmails: 'not-an-email', + }, + }, + }, + { + input: { + emailsField: { + additionalEmails: ['not-an-email'], + }, + }, + }, + { + input: { + emailsField: { + primaryEmail: 'email@email.com', + additionalEmails: ['not-an-email'], + }, + }, + }, + { + input: { + emailsField: { + primaryEmail: 'not-an-email', + additionalEmails: ['additional@email.com'], + }, + }, + }, + { + input: { + emailsField: { + additionalEmails: ['not-an-email', 'additional@email.com'], + }, + }, + }, ], [FieldMetadataType.PHONES]: [ {