Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions src/common/decorators/is-mail-username.decorator.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
77 changes: 77 additions & 0 deletions src/common/decorators/is-mail-username.decorator.ts
Original file line number Diff line number Diff line change
@@ -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,
});
};
}
7 changes: 6 additions & 1 deletion src/modules/account/dto/create-mail-account.dto.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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' })
Expand Down
15 changes: 9 additions & 6 deletions src/modules/addresses/addresses.dto.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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;
}

Expand Down
Loading