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>
This commit is contained in:
BOHEUS
2026-02-10 18:24:49 +00:00
committed by GitHub
parent bc72879c70
commit 16d414590b
9 changed files with 368 additions and 9 deletions
@@ -120,10 +120,11 @@ export class DataArgProcessor {
);
}
const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: fieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
const fieldMetadata =
findFlatEntityByIdInFlatEntityMaps<FlatFieldMetadata>({
flatEntityId: fieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!fieldMetadata) {
throw new CommonQueryRunnerException(
@@ -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);
});
});
});
@@ -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;
};
@@ -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(
@@ -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;
};
@@ -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,
});
});
});
@@ -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;
}
@@ -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\\""]"`;
@@ -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]: [
{