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..95c520b 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,10 @@ export class CreateMailAccountDto { @ApiProperty({ example: 'alice' }) @IsString() @IsNotEmpty() + @Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.toLowerCase() : value, + ) + @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..44fd241 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,16 +10,18 @@ export class CheckAvailabilityQueryDto { }) @IsString() @IsNotEmpty() - @Matches(/^[a-zA-Z0-9._%+-]+$/, { - message: 'username contains invalid characters', - }) - @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; }