From c46fdc2a80a841c546265dd4a6848ce60f90bad9 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:59:21 -0600 Subject: [PATCH 1/2] feat(email): add mail username validation decorator and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implemented the `IsMailUsername` decorator to validate email usernames based on specified rules, including length, allowed characters, and reserved names. - Created unit tests for the `isValidMailUsername` function to ensure correct validation behavior. - Updated `CreateMailAccountDto` and `CheckAvailabilityQueryDto` to utilize the new `IsMailUsername` decorator for username validation.Ñ: --- .../is-mail-username.decorator.spec.ts | 81 +++++++++++++++++++ .../decorators/is-mail-username.decorator.ts | 77 ++++++++++++++++++ .../account/dto/create-mail-account.dto.ts | 5 +- src/modules/addresses/addresses.dto.ts | 7 +- 4 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 src/common/decorators/is-mail-username.decorator.spec.ts create mode 100644 src/common/decorators/is-mail-username.decorator.ts diff --git a/src/common/decorators/is-mail-username.decorator.spec.ts b/src/common/decorators/is-mail-username.decorator.spec.ts new file mode 100644 index 0000000..abc8900 --- /dev/null +++ b/src/common/decorators/is-mail-username.decorator.spec.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { validate } from 'class-validator'; +import { + IsMailUsername, + isValidMailUsername, +} from './is-mail-username.decorator.js'; + +class TestDto { + @IsMailUsername() + username!: string; +} + +describe('isValidMailUsername', () => { + it('when username is between 3 and 30 characters, then it is valid', () => { + expect(isValidMailUsername('abc')).toBe(true); + expect(isValidMailUsername('a'.repeat(30))).toBe(true); + }); + + it('when username has fewer than 3 characters, then it is invalid', () => { + expect(isValidMailUsername('ab')).toBe(false); + }); + + it('when username has more than 30 characters, then it is invalid', () => { + expect(isValidMailUsername('a'.repeat(31))).toBe(false); + }); + + it('when username only contains lowercase letters, numbers, periods, hyphens and underscores, then it is valid', () => { + expect(isValidMailUsername('jane.doe-99_x')).toBe(true); + }); + + it('when username contains an uppercase letter, then it is invalid', () => { + expect(isValidMailUsername('Jane')).toBe(false); + }); + + it('when username contains an unsupported symbol, then it is invalid', () => { + expect(isValidMailUsername('jane@doe')).toBe(false); + }); + + it('when username has two consecutive special characters, then it is invalid', () => { + expect(isValidMailUsername('jane..doe')).toBe(false); + expect(isValidMailUsername('jane__doe')).toBe(false); + expect(isValidMailUsername('jane.-doe')).toBe(false); + }); + + it('when username starts or ends with a special character, then it is invalid', () => { + expect(isValidMailUsername('.jane')).toBe(false); + expect(isValidMailUsername('jane-')).toBe(false); + expect(isValidMailUsername('_jane')).toBe(false); + }); + + it('when username is a reserved name, then it is invalid', () => { + expect(isValidMailUsername('admin')).toBe(false); + expect(isValidMailUsername('postmaster')).toBe(false); + }); + + it('when username is not a string, then it is invalid', () => { + expect(isValidMailUsername(undefined)).toBe(false); + expect(isValidMailUsername(123)).toBe(false); + }); +}); + +describe('IsMailUsername', () => { + it('when the DTO has a valid username, then validation passes', async () => { + const dto = new TestDto(); + dto.username = 'jane.doe'; + + const errors = await validate(dto); + + expect(errors).toHaveLength(0); + }); + + it('when the DTO has an invalid username, then validation fails with isMailUsername', async () => { + const dto = new TestDto(); + dto.username = 'admin'; + + const errors = await validate(dto); + + expect(errors).toHaveLength(1); + expect(errors[0]?.constraints).toHaveProperty('isMailUsername'); + }); +}); diff --git a/src/common/decorators/is-mail-username.decorator.ts b/src/common/decorators/is-mail-username.decorator.ts new file mode 100644 index 0000000..c2ea337 --- /dev/null +++ b/src/common/decorators/is-mail-username.decorator.ts @@ -0,0 +1,77 @@ +import { + registerDecorator, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +export const MAIL_USERNAME_MIN_LENGTH = 3; +export const MAIL_USERNAME_MAX_LENGTH = 30; + +const ALLOWED_CHARS_REGEX = /^[a-z0-9._-]+$/; +const EDGE_SPECIAL_CHARS_REGEX = /^[._-]|[._-]$/; +const CONSECUTIVE_SPECIAL_CHARS_REGEX = /[._-]{2,}/; + +export const RESERVED_MAIL_USERNAMES = new Set([ + 'admin', + 'administrator', + 'root', + 'support', + 'postmaster', + 'noreply', + 'no-reply', + 'webmaster', + 'hostmaster', + 'abuse', + 'security', + 'info', + 'help', + 'contact', + 'billing', + 'sales', + 'mailer-daemon', + 'daemon', + 'ftp', + 'www', + 'system', + 'test', +]); + +export function isValidMailUsername(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length >= MAIL_USERNAME_MIN_LENGTH && + value.length <= MAIL_USERNAME_MAX_LENGTH && + ALLOWED_CHARS_REGEX.test(value) && + !CONSECUTIVE_SPECIAL_CHARS_REGEX.test(value) && + !EDGE_SPECIAL_CHARS_REGEX.test(value) && + !RESERVED_MAIL_USERNAMES.has(value) + ); +} + +@ValidatorConstraint({ name: 'isMailUsername', async: false }) +class IsMailUsernameConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + return isValidMailUsername(value); + } + + defaultMessage(): string { + return `address must be ${MAIL_USERNAME_MIN_LENGTH}-${MAIL_USERNAME_MAX_LENGTH} characters, contain only lowercase letters, numbers, ".", "-" or "_", not start/end with a special character or repeat one, and not be a reserved name`; + } +} + +/** + * Validates the local part (before the @) of a mail address, mirroring the + * rules enforced client-side in mail-web (identity-setup/emailAddressRules). + */ +export function IsMailUsername(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [], + validator: IsMailUsernameConstraint, + }); + }; +} diff --git a/src/modules/account/dto/create-mail-account.dto.ts b/src/modules/account/dto/create-mail-account.dto.ts index b4b3dfb..afd6765 100644 --- a/src/modules/account/dto/create-mail-account.dto.ts +++ b/src/modules/account/dto/create-mail-account.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { IsNotEmpty, IsString, ValidateNested } from 'class-validator'; +import { IsMailUsername } from '../../../common/decorators/is-mail-username.decorator.js'; export class MailAddressKeyBundleDto { @ApiProperty({ @@ -31,6 +32,8 @@ export class CreateMailAccountDto { @ApiProperty({ example: 'alice' }) @IsString() @IsNotEmpty() + @Transform(({ value }: { value: string }) => value.toLowerCase()) + @IsMailUsername() address!: string; @ApiProperty({ example: 'inxt.eu' }) diff --git a/src/modules/addresses/addresses.dto.ts b/src/modules/addresses/addresses.dto.ts index c874453..6b3e92e 100644 --- a/src/modules/addresses/addresses.dto.ts +++ b/src/modules/addresses/addresses.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsNotEmpty, IsString, Matches } from 'class-validator'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { IsMailUsername } from '../../common/decorators/is-mail-username.decorator.js'; export class CheckAvailabilityQueryDto { @ApiProperty({ @@ -9,10 +10,8 @@ export class CheckAvailabilityQueryDto { }) @IsString() @IsNotEmpty() - @Matches(/^[a-zA-Z0-9._%+-]+$/, { - message: 'username contains invalid characters', - }) @Transform(({ value }: { value: string }) => value.toLowerCase()) + @IsMailUsername() username!: string; @ApiProperty({ description: 'Email domain', example: 'inxt.me' }) From be5db4cfcd7ced9eaa69fc872018ceba663e4fd7 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:44:52 -0600 Subject: [PATCH 2/2] fix(dto): enhance string transformation for username and domain fields - Updated the transformation logic in `CreateMailAccountDto` and `CheckAvailabilityQueryDto` to ensure that only string values are converted to lowercase, improving type safety and preventing potential runtime errors. --- src/modules/account/dto/create-mail-account.dto.ts | 4 +++- src/modules/addresses/addresses.dto.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/modules/account/dto/create-mail-account.dto.ts b/src/modules/account/dto/create-mail-account.dto.ts index afd6765..95c520b 100644 --- a/src/modules/account/dto/create-mail-account.dto.ts +++ b/src/modules/account/dto/create-mail-account.dto.ts @@ -32,7 +32,9 @@ export class CreateMailAccountDto { @ApiProperty({ example: 'alice' }) @IsString() @IsNotEmpty() - @Transform(({ value }: { value: string }) => value.toLowerCase()) + @Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.toLowerCase() : value, + ) @IsMailUsername() address!: string; diff --git a/src/modules/addresses/addresses.dto.ts b/src/modules/addresses/addresses.dto.ts index 6b3e92e..44fd241 100644 --- a/src/modules/addresses/addresses.dto.ts +++ b/src/modules/addresses/addresses.dto.ts @@ -10,14 +10,18 @@ export class CheckAvailabilityQueryDto { }) @IsString() @IsNotEmpty() - @Transform(({ value }: { value: string }) => value.toLowerCase()) + @Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.toLowerCase() : value, + ) @IsMailUsername() username!: string; @ApiProperty({ description: 'Email domain', example: 'inxt.me' }) @IsString() @IsNotEmpty() - @Transform(({ value }: { value: string }) => value.toLowerCase()) + @Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.toLowerCase() : value, + ) domain!: string; }