From e96b83c9aa539b004fdde185d815c97920b77a87 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:17:27 -0600 Subject: [PATCH 1/3] feat: implement mail account provisioning and recovery email functionality - Added migration to include recovery_email field in users table. - Introduced Mail module with MailService and MailController for handling mail account creation. - Implemented MailUseCases to manage mail account provisioning and validation. - Updated user model to support recovery email and added necessary DTOs for mail account creation. - Enhanced audit logging to track mail setup actions. --- ...60330153847-add-recovery-email-to-users.js | 16 +++ .../20260330160219-add-mail-access-limit.js | 90 +++++++++++++++ src/app.module.ts | 2 + .../audit-logs/audit-logs.attributes.ts | 2 + src/config/configuration.ts | 3 + src/externals/mail/mail.module.ts | 12 ++ src/externals/mail/mail.service.ts | 63 +++++++++++ src/modules/feature-limit/limits.enum.ts | 1 + .../mail/dto/create-mail-account.dto.ts | 24 ++++ src/modules/mail/mail.controller.ts | 30 +++++ src/modules/mail/mail.module.ts | 22 ++++ src/modules/mail/mail.usecase.ts | 105 ++++++++++++++++++ src/modules/user/user.attributes.ts | 1 + src/modules/user/user.domain.ts | 3 + src/modules/user/user.model.ts | 4 + 15 files changed, 378 insertions(+) create mode 100644 migrations/20260330153847-add-recovery-email-to-users.js create mode 100644 migrations/20260330160219-add-mail-access-limit.js create mode 100644 src/externals/mail/mail.module.ts create mode 100644 src/externals/mail/mail.service.ts create mode 100644 src/modules/mail/dto/create-mail-account.dto.ts create mode 100644 src/modules/mail/mail.controller.ts create mode 100644 src/modules/mail/mail.module.ts create mode 100644 src/modules/mail/mail.usecase.ts diff --git a/migrations/20260330153847-add-recovery-email-to-users.js b/migrations/20260330153847-add-recovery-email-to-users.js new file mode 100644 index 000000000..27968915e --- /dev/null +++ b/migrations/20260330153847-add-recovery-email-to-users.js @@ -0,0 +1,16 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('users', 'recovery_email', { + type: Sequelize.STRING, + allowNull: true, + defaultValue: null, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('users', 'recovery_email'); + }, +}; diff --git a/migrations/20260330160219-add-mail-access-limit.js b/migrations/20260330160219-add-mail-access-limit.js new file mode 100644 index 000000000..fbff5c9ab --- /dev/null +++ b/migrations/20260330160219-add-mail-access-limit.js @@ -0,0 +1,90 @@ +'use strict'; + +const { v4 } = require('uuid'); + +const LIMIT_LABEL = 'mail-access'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + try { + const disabledLimitId = v4(); + const enabledLimitId = v4(); + + await queryInterface.bulkInsert( + 'limits', + [ + { + id: disabledLimitId, + label: LIMIT_LABEL, + type: 'boolean', + value: 'false', + created_at: new Date(), + updated_at: new Date(), + }, + { + id: enabledLimitId, + label: LIMIT_LABEL, + type: 'boolean', + value: 'true', + created_at: new Date(), + updated_at: new Date(), + }, + ], + { transaction }, + ); + + const [tiers] = await queryInterface.sequelize.query( + `SELECT id, label FROM tiers`, + { transaction }, + ); + + const tierLimitRelations = tiers.map((tier) => ({ + id: v4(), + tier_id: tier.id, + limit_id: disabledLimitId, + created_at: new Date(), + updated_at: new Date(), + })); + + await queryInterface.bulkInsert('tiers_limits', tierLimitRelations, { + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + try { + const [limits] = await queryInterface.sequelize.query( + `SELECT id FROM limits WHERE label = :limitLabel`, + { replacements: { limitLabel: LIMIT_LABEL }, transaction }, + ); + + const limitIds = limits.map((l) => l.id); + + if (limitIds.length > 0) { + await queryInterface.sequelize.query( + `DELETE FROM tiers_limits WHERE limit_id IN (:limitIds)`, + { replacements: { limitIds }, transaction }, + ); + } + + await queryInterface.sequelize.query( + `DELETE FROM limits WHERE label = :limitLabel`, + { replacements: { limitLabel: LIMIT_LABEL }, transaction }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/src/app.module.ts b/src/app.module.ts index 224fe2581..25e3aa770 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -33,6 +33,7 @@ import { AuthGuard } from './modules/auth/auth.guard'; import { CacheManagerModule } from './modules/cache-manager/cache-manager.module'; import { ReferralModule } from './modules/referral/referral.module'; import { HealthModule } from './infrastructure/health/health.module'; +import { MailModule } from './modules/mail/mail.module'; const isCronjobInstance = process.env.EXECUTE_JOBS === 'true'; const appName = isCronjobInstance ? 'drive-server-cronjob' : 'drive-server'; @@ -152,6 +153,7 @@ const appName = isCronjobInstance ? 'drive-server-cronjob' : 'drive-server'; CacheManagerModule, ReferralModule, HealthModule, + MailModule, ], controllers: [], providers: [ diff --git a/src/common/audit-logs/audit-logs.attributes.ts b/src/common/audit-logs/audit-logs.attributes.ts index de1fb8985..a02d50249 100644 --- a/src/common/audit-logs/audit-logs.attributes.ts +++ b/src/common/audit-logs/audit-logs.attributes.ts @@ -30,6 +30,7 @@ export enum AuditAction { AccountReset = 'account-reset', AccountRecovery = 'account-recovery', AccountDeactivated = 'account-deactivated', + MailSetup = 'mail-setup', // Workspace actions WorkspaceCreated = 'workspace-created', WorkspaceDeleted = 'workspace-deleted', @@ -48,6 +49,7 @@ export const AUDIT_ENTITY_ACTIONS: Record = { AuditAction.AccountReset, AuditAction.AccountRecovery, AuditAction.AccountDeactivated, + AuditAction.MailSetup, ], [AuditEntityType.Workspace]: [ AuditAction.WorkspaceCreated, diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 53fc86612..dcadbd512 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -70,6 +70,9 @@ export default () => ({ payments: { url: process.env.PAYMENTS_API_URL, }, + mail: { + url: process.env.MAIL_API_URL, + }, }, apn: { url: process.env.APN_URL, diff --git a/src/externals/mail/mail.module.ts b/src/externals/mail/mail.module.ts new file mode 100644 index 000000000..82a4812d2 --- /dev/null +++ b/src/externals/mail/mail.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { HttpClientModule } from '../http/http.module'; +import { MailService } from './mail.service'; + +@Module({ + imports: [ConfigModule, HttpClientModule], + controllers: [], + providers: [MailService], + exports: [MailService], +}) +export class MailServiceModule {} diff --git a/src/externals/mail/mail.service.ts b/src/externals/mail/mail.service.ts new file mode 100644 index 000000000..5d020f3a6 --- /dev/null +++ b/src/externals/mail/mail.service.ts @@ -0,0 +1,63 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { sign } from 'jsonwebtoken'; +import { ConfigService } from '@nestjs/config'; +import { HttpClient } from '../http/http.service'; + +interface CreateAccountPayload { + userId: string; + address: string; + domain: string; + displayName: string; +} + +interface CreateAccountResponse { + address: string; + domain: string; +} + +function signToken(duration: string, secret: string, isDevelopment?: boolean) { + return sign({}, Buffer.from(secret, 'base64').toString('utf8'), { + algorithm: 'RS256', + expiresIn: duration, + ...(isDevelopment ? { allowInsecureKeySizes: true } : null), + }); +} + +@Injectable() +export class MailService { + constructor( + @Inject(ConfigService) + private readonly configService: ConfigService, + @Inject(HttpClient) + private readonly httpClient: HttpClient, + ) {} + + private getAuthHeaders() { + const isDevelopment = this.configService.get('isDevelopment'); + const jwt = signToken( + '5m', + this.configService.get('secrets.gateway'), + isDevelopment, + ); + + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }; + } + + async createAccount( + payload: CreateAccountPayload, + ): Promise { + const baseUrl = this.configService.get('apis.mail.url'); + const headers = this.getAuthHeaders(); + + const res = await this.httpClient.post( + `${baseUrl}/gateway/accounts`, + payload, + { headers }, + ); + + return res.data; + } +} diff --git a/src/modules/feature-limit/limits.enum.ts b/src/modules/feature-limit/limits.enum.ts index a91e71a49..4a2e5d883 100644 --- a/src/modules/feature-limit/limits.enum.ts +++ b/src/modules/feature-limit/limits.enum.ts @@ -11,6 +11,7 @@ export enum LimitLabels { RcloneAccess = 'rclone-access', TrashRetentionDays = 'trash-retention-days', ReferralAccess = 'referral-access', + MailAccess = 'mail-access', } export enum LimitTypes { diff --git a/src/modules/mail/dto/create-mail-account.dto.ts b/src/modules/mail/dto/create-mail-account.dto.ts new file mode 100644 index 000000000..263c3b10c --- /dev/null +++ b/src/modules/mail/dto/create-mail-account.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateMailAccountDto { + @ApiProperty({ example: 'john' }) + @IsNotEmpty() + @IsString() + address: string; + + @ApiProperty({ example: 'inxt.eu' }) + @IsNotEmpty() + @IsString() + domain: string; + + @ApiProperty({ example: 'John Doe' }) + @IsNotEmpty() + @IsString() + displayName: string; + + @ApiProperty({ description: 'Encrypted password for re-authentication' }) + @IsNotEmpty() + @IsString() + password: string; +} diff --git a/src/modules/mail/mail.controller.ts b/src/modules/mail/mail.controller.ts new file mode 100644 index 000000000..eb57546ec --- /dev/null +++ b/src/modules/mail/mail.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiOperation } from '@nestjs/swagger'; +import { User as UserDecorator } from '../auth/decorators/user.decorator'; +import { User } from '../user/user.domain'; +import { MailUseCases } from './mail.usecase'; +import { CreateMailAccountDto } from './dto/create-mail-account.dto'; +import { AuditLog } from '../../common/audit-logs/decorators/audit-log.decorator'; +import { AuditAction } from '../../common/audit-logs/audit-logs.attributes'; + +@Controller('mail') +export class MailController { + constructor(private readonly mailUseCases: MailUseCases) {} + + @Post('accounts') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Provision a mail account for the user' }) + @ApiBearerAuth() + @AuditLog({ + action: AuditAction.MailSetup, + metadata: (_req, res) => ({ + address: res.address, + }), + }) + async createMailAccount( + @UserDecorator() user: User, + @Body() createMailAccountDto: CreateMailAccountDto, + ) { + return this.mailUseCases.createMailAccount(user, createMailAccountDto); + } +} diff --git a/src/modules/mail/mail.module.ts b/src/modules/mail/mail.module.ts new file mode 100644 index 000000000..35ebd498f --- /dev/null +++ b/src/modules/mail/mail.module.ts @@ -0,0 +1,22 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { MailServiceModule } from '../../externals/mail/mail.module'; +import { UserModule } from '../user/user.module'; +import { CryptoModule } from '../../externals/crypto/crypto.module'; +import { AuditLogsModule } from '../../common/audit-logs/audit-logs.module'; +import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; +import { MailController } from './mail.controller'; +import { MailUseCases } from './mail.usecase'; + +@Module({ + imports: [ + MailServiceModule, + UserModule, + CryptoModule, + AuditLogsModule, + forwardRef(() => FeatureLimitModule), + ], + controllers: [MailController], + providers: [MailUseCases], + exports: [MailUseCases], +}) +export class MailModule {} diff --git a/src/modules/mail/mail.usecase.ts b/src/modules/mail/mail.usecase.ts new file mode 100644 index 000000000..ca3ac206f --- /dev/null +++ b/src/modules/mail/mail.usecase.ts @@ -0,0 +1,105 @@ +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { CryptoService } from '../../externals/crypto/crypto.service'; +import { MailService } from '../../externals/mail/mail.service'; +import { SequelizeUserRepository } from '../user/user.repository'; +import { UserUseCases } from '../user/user.usecase'; +import { FeatureLimitService } from '../feature-limit/feature-limit.service'; +import { LimitLabels } from '../feature-limit/limits.enum'; +import { PaymentRequiredException } from '../feature-limit/exceptions/payment-required.exception'; +import { type User } from '../user/user.domain'; +import { type CreateMailAccountDto } from './dto/create-mail-account.dto'; + +const BLOCKED_RECOVERY_DOMAINS = new Set(['inxt.eu']); + +@Injectable() +export class MailUseCases { + constructor( + @Inject(CryptoService) + private readonly cryptoService: CryptoService, + @Inject(MailService) + private readonly mailService: MailService, + @Inject(SequelizeUserRepository) + private readonly userRepository: SequelizeUserRepository, + @Inject(UserUseCases) + private readonly userUseCases: UserUseCases, + @Inject(FeatureLimitService) + private readonly featureLimitService: FeatureLimitService, + ) {} + + async createMailAccount( + user: User, + dto: CreateMailAccountDto, + ): Promise<{ token: string; newToken: string; address: string }> { + await this.assertMailAccessEnabled(user); + this.verifyPassword(user, dto.password); + this.validateRecoveryEmail(user.email); + + const fullAddress = `${dto.address}@${dto.domain}`; + + await this.mailService.createAccount({ + userId: user.uuid, + address: fullAddress, + domain: dto.domain, + displayName: dto.displayName, + }); + + const previousEmail = user.email; + + try { + await this.userRepository.updateByUuid(user.uuid, { + email: fullAddress, + recoveryEmail: previousEmail, + }); + } catch (error) { + await this.userRepository.updateByUuid(user.uuid, { + email: previousEmail, + recoveryEmail: null, + }); + throw error; + } + + const updatedUser = await this.userRepository.findByUuid(user.uuid); + + const { token, newToken } = + await this.userUseCases.getAuthTokens(updatedUser); + + return { token, newToken, address: fullAddress }; + } + + private verifyPassword(user: User, encryptedPassword: string): void { + const hashedPass = this.cryptoService.decryptText(encryptedPassword); + + if (hashedPass !== user.password.toString()) { + throw new UnauthorizedException('Wrong credentials'); + } + } + + private async assertMailAccessEnabled(user: User): Promise { + const limit = await this.featureLimitService.getUserLimitByLabel( + LimitLabels.MailAccess, + user, + ); + + if (!limit || !limit.isFeatureEnabled()) { + throw new PaymentRequiredException( + 'Mail access is not available for your current plan', + ); + } + } + + private validateRecoveryEmail(currentEmail: string): void { + const domain = currentEmail.split('@')[1]?.toLowerCase(); + + if (BLOCKED_RECOVERY_DOMAINS.has(domain)) { + throw new BadRequestException( + 'This email domain cannot be used as recovery email', + ); + } + } +} diff --git a/src/modules/user/user.attributes.ts b/src/modules/user/user.attributes.ts index 7cf249341..00d9f4a15 100644 --- a/src/modules/user/user.attributes.ts +++ b/src/modules/user/user.attributes.ts @@ -28,6 +28,7 @@ export interface UserAttributes { lastPasswordChangedAt?: Date; tierId?: string; emailVerified: boolean; + recoveryEmail?: string; updatedAt?: Date; createdAt?: Date; } diff --git a/src/modules/user/user.domain.ts b/src/modules/user/user.domain.ts index 8edf686a9..b261730fa 100644 --- a/src/modules/user/user.domain.ts +++ b/src/modules/user/user.domain.ts @@ -29,6 +29,7 @@ export class User implements UserAttributes { lastPasswordChangedAt: Date; tierId: string; emailVerified: boolean; + recoveryEmail: string; updatedAt: Date; createdAt: Date; @@ -61,6 +62,7 @@ export class User implements UserAttributes { lastPasswordChangedAt, tierId, emailVerified, + recoveryEmail, updatedAt, createdAt, }: UserAttributes) { @@ -92,6 +94,7 @@ export class User implements UserAttributes { this.lastPasswordChangedAt = lastPasswordChangedAt; this.tierId = tierId; this.emailVerified = emailVerified; + this.recoveryEmail = recoveryEmail; this.updatedAt = updatedAt; this.createdAt = createdAt; } diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index 20be96fc4..9da190da5 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -140,6 +140,10 @@ export class UserModel extends Model implements UserAttributes { @Column emailVerified: boolean; + @AllowNull + @Column + recoveryEmail: string; + @HasMany(() => UserNotificationTokensModel) notificationTokens: UserNotificationTokensModel[]; } From dd84afb93e1b7a3838e22817e03ad34f76fb03bd Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:59:46 -0600 Subject: [PATCH 2/3] test: add unit tests for MailService, MailController, and MailUseCases --- src/externals/mail/mail.service.ts | 23 ++- src/modules/mail/mail.controller.spec.ts | 72 ++++++++ src/modules/mail/mail.usecase.spec.ts | 221 +++++++++++++++++++++++ src/modules/mail/mail.usecase.ts | 1 - 4 files changed, 309 insertions(+), 8 deletions(-) create mode 100644 src/modules/mail/mail.controller.spec.ts create mode 100644 src/modules/mail/mail.usecase.spec.ts diff --git a/src/externals/mail/mail.service.ts b/src/externals/mail/mail.service.ts index 5d020f3a6..95ff87c3b 100644 --- a/src/externals/mail/mail.service.ts +++ b/src/externals/mail/mail.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { ConflictException, Inject, Injectable } from '@nestjs/common'; import { sign } from 'jsonwebtoken'; import { ConfigService } from '@nestjs/config'; import { HttpClient } from '../http/http.service'; @@ -52,12 +52,21 @@ export class MailService { const baseUrl = this.configService.get('apis.mail.url'); const headers = this.getAuthHeaders(); - const res = await this.httpClient.post( - `${baseUrl}/gateway/accounts`, - payload, - { headers }, - ); + try { + const res = await this.httpClient.post( + `${baseUrl}/gateway/accounts`, + payload, + { headers }, + ); - return res.data; + return res.data; + } catch (error) { + if (error?.response?.status === 409) { + throw new ConflictException( + error.response.data?.message || 'Mail account already exists', + ); + } + throw error; + } } } diff --git a/src/modules/mail/mail.controller.spec.ts b/src/modules/mail/mail.controller.spec.ts new file mode 100644 index 000000000..cdf830cbf --- /dev/null +++ b/src/modules/mail/mail.controller.spec.ts @@ -0,0 +1,72 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { createMock, type DeepMocked } from '@golevelup/ts-jest'; +import { ConflictException } from '@nestjs/common'; +import { MailController } from './mail.controller'; +import { MailUseCases } from './mail.usecase'; +import { newUser } from '../../../test/fixtures'; +import { PaymentRequiredException } from '../feature-limit/exceptions/payment-required.exception'; + +describe('MailController', () => { + let controller: MailController; + let mailUseCases: DeepMocked; + + beforeEach(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + controllers: [MailController], + }) + .useMocker(() => createMock()) + .compile(); + + controller = moduleRef.get(MailController); + mailUseCases = moduleRef.get(MailUseCases); + }); + + describe('createMailAccount', () => { + const dto = { + address: 'john', + domain: 'inxt.eu', + displayName: 'John Doe', + password: 'encrypted-password', + }; + + it('When called with valid input, then it should return the usecase result', async () => { + const user = newUser(); + const expected = { + token: 'token', + newToken: 'newToken', + address: 'john@inxt.eu', + }; + + mailUseCases.createMailAccount.mockResolvedValueOnce(expected); + + const result = await controller.createMailAccount(user, dto); + + expect(result).toEqual(expected); + expect(mailUseCases.createMailAccount).toHaveBeenCalledWith(user, dto); + }); + + it('When user has no mail access, then it should propagate PaymentRequiredException', async () => { + const user = newUser(); + + mailUseCases.createMailAccount.mockRejectedValueOnce( + new PaymentRequiredException('Mail access is not available'), + ); + + await expect(controller.createMailAccount(user, dto)).rejects.toThrow( + PaymentRequiredException, + ); + }); + + it('When user already has a mail account, then it should propagate ConflictException', async () => { + const user = newUser(); + + mailUseCases.createMailAccount.mockRejectedValueOnce( + new ConflictException('User already has a mail account'), + ); + + await expect(controller.createMailAccount(user, dto)).rejects.toThrow( + ConflictException, + ); + }); + }); +}); diff --git a/src/modules/mail/mail.usecase.spec.ts b/src/modules/mail/mail.usecase.spec.ts new file mode 100644 index 000000000..a9b186de4 --- /dev/null +++ b/src/modules/mail/mail.usecase.spec.ts @@ -0,0 +1,221 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { createMock, type DeepMocked } from '@golevelup/ts-jest'; +import { + ConflictException, + UnauthorizedException, + BadRequestException, +} from '@nestjs/common'; +import { MailUseCases } from './mail.usecase'; +import { MailService } from '../../externals/mail/mail.service'; +import { CryptoService } from '../../externals/crypto/crypto.service'; +import { SequelizeUserRepository } from '../user/user.repository'; +import { UserUseCases } from '../user/user.usecase'; +import { FeatureLimitService } from '../feature-limit/feature-limit.service'; +import { PaymentRequiredException } from '../feature-limit/exceptions/payment-required.exception'; +import { newUser, newFeatureLimit } from '../../../test/fixtures'; +import { LimitTypes, LimitLabels } from '../feature-limit/limits.enum'; + +describe('MailUseCases', () => { + let mailUseCases: MailUseCases; + let cryptoService: DeepMocked; + let mailService: DeepMocked; + let userRepository: DeepMocked; + let userUseCases: DeepMocked; + let featureLimitService: DeepMocked; + + const dto = { + address: 'john', + domain: 'inxt.eu', + displayName: 'John Doe', + password: 'encrypted-password', + }; + + beforeEach(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [MailUseCases], + }) + .useMocker(() => createMock()) + .compile(); + + mailUseCases = moduleRef.get(MailUseCases); + cryptoService = moduleRef.get(CryptoService); + mailService = moduleRef.get(MailService); + userRepository = moduleRef.get(SequelizeUserRepository); + userUseCases = moduleRef.get(UserUseCases); + featureLimitService = moduleRef.get(FeatureLimitService); + }); + + describe('createMailAccount', () => { + it('When mail access limit is disabled, then it should throw PaymentRequiredException', async () => { + const user = newUser(); + const disabledLimit = newFeatureLimit({ + type: LimitTypes.Boolean, + label: LimitLabels.MailAccess, + value: 'false', + }); + + featureLimitService.getUserLimitByLabel.mockResolvedValueOnce( + disabledLimit, + ); + + await expect(mailUseCases.createMailAccount(user, dto)).rejects.toThrow( + PaymentRequiredException, + ); + }); + + it('When mail access limit is not found, then it should throw PaymentRequiredException', async () => { + const user = newUser(); + + featureLimitService.getUserLimitByLabel.mockResolvedValueOnce(null); + + await expect(mailUseCases.createMailAccount(user, dto)).rejects.toThrow( + PaymentRequiredException, + ); + }); + + it('When password is incorrect, then it should throw UnauthorizedException', async () => { + const user = newUser({ attributes: { password: 'correct-hash' } }); + const enabledLimit = newFeatureLimit({ + type: LimitTypes.Boolean, + label: LimitLabels.MailAccess, + value: 'true', + }); + + featureLimitService.getUserLimitByLabel.mockResolvedValueOnce( + enabledLimit, + ); + cryptoService.decryptText.mockReturnValueOnce('wrong-hash'); + + await expect(mailUseCases.createMailAccount(user, dto)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('When user email is on a blocked domain, then it should throw BadRequestException', async () => { + const user = newUser({ + attributes: { email: 'user@inxt.eu', password: 'hashed' }, + }); + const enabledLimit = newFeatureLimit({ + type: LimitTypes.Boolean, + label: LimitLabels.MailAccess, + value: 'true', + }); + + featureLimitService.getUserLimitByLabel.mockResolvedValueOnce( + enabledLimit, + ); + cryptoService.decryptText.mockReturnValueOnce('hashed'); + + await expect(mailUseCases.createMailAccount(user, dto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('When all validations pass, then it should create account and return tokens', async () => { + const user = newUser({ + attributes: { email: 'user@gmail.com', password: 'hashed' }, + }); + const updatedUser = newUser({ + attributes: { + uuid: user.uuid, + email: 'john@inxt.eu', + recoveryEmail: 'user@gmail.com', + }, + }); + const enabledLimit = newFeatureLimit({ + type: LimitTypes.Boolean, + label: LimitLabels.MailAccess, + value: 'true', + }); + + featureLimitService.getUserLimitByLabel.mockResolvedValueOnce( + enabledLimit, + ); + cryptoService.decryptText.mockReturnValueOnce('hashed'); + mailService.createAccount.mockResolvedValueOnce({ + address: 'john@inxt.eu', + domain: 'inxt.eu', + }); + userRepository.updateByUuid.mockResolvedValueOnce(undefined); + userRepository.findByUuid.mockResolvedValueOnce(updatedUser); + userUseCases.getAuthTokens.mockResolvedValueOnce({ + token: 'new-token', + newToken: 'new-new-token', + }); + + const result = await mailUseCases.createMailAccount(user, dto); + + expect(result).toEqual({ + token: 'new-token', + newToken: 'new-new-token', + address: 'john@inxt.eu', + }); + expect(mailService.createAccount).toHaveBeenCalledWith({ + userId: user.uuid, + address: 'john@inxt.eu', + domain: 'inxt.eu', + displayName: 'John Doe', + }); + expect(userRepository.updateByUuid).toHaveBeenCalledWith(user.uuid, { + email: 'john@inxt.eu', + recoveryEmail: 'user@gmail.com', + }); + }); + + it('When user update fails, then it should rollback recovery email', async () => { + const user = newUser({ + attributes: { email: 'user@gmail.com', password: 'hashed' }, + }); + const enabledLimit = newFeatureLimit({ + type: LimitTypes.Boolean, + label: LimitLabels.MailAccess, + value: 'true', + }); + + featureLimitService.getUserLimitByLabel.mockResolvedValueOnce( + enabledLimit, + ); + cryptoService.decryptText.mockReturnValueOnce('hashed'); + mailService.createAccount.mockResolvedValueOnce({ + address: 'john@inxt.eu', + domain: 'inxt.eu', + }); + userRepository.updateByUuid + .mockRejectedValueOnce(new Error('DB error')) + .mockResolvedValueOnce(undefined); + + await expect(mailUseCases.createMailAccount(user, dto)).rejects.toThrow( + 'DB error', + ); + + expect(userRepository.updateByUuid).toHaveBeenNthCalledWith( + 2, + user.uuid, + { email: 'user@gmail.com', recoveryEmail: null }, + ); + }); + + it('When mail service returns 409, then it should propagate ConflictException', async () => { + const user = newUser({ + attributes: { email: 'user@gmail.com', password: 'hashed' }, + }); + const enabledLimit = newFeatureLimit({ + type: LimitTypes.Boolean, + label: LimitLabels.MailAccess, + value: 'true', + }); + + featureLimitService.getUserLimitByLabel.mockResolvedValueOnce( + enabledLimit, + ); + cryptoService.decryptText.mockReturnValueOnce('hashed'); + mailService.createAccount.mockRejectedValueOnce( + new ConflictException('Mail account already exists'), + ); + + await expect(mailUseCases.createMailAccount(user, dto)).rejects.toThrow( + ConflictException, + ); + }); + }); +}); diff --git a/src/modules/mail/mail.usecase.ts b/src/modules/mail/mail.usecase.ts index ca3ac206f..5780bfebd 100644 --- a/src/modules/mail/mail.usecase.ts +++ b/src/modules/mail/mail.usecase.ts @@ -1,6 +1,5 @@ import { BadRequestException, - ConflictException, Inject, Injectable, UnauthorizedException, From 3a2e65ec94880e9a4a73e894c9e61381cc503050 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:14:10 +0200 Subject: [PATCH 3/3] refactor: remove recovery email functionality and related migration - Deleted migration for recovery_email field in users table. - Refactored MailService and AuthController to remove recovery email handling. - Updated MailUseCases and user model to eliminate recovery email references. - Introduced managed mail domains logic for email resolution in login process. --- ...60330153847-add-recovery-email-to-users.js | 16 ---- src/externals/mail/mail.service.ts | 19 ++++ src/modules/auth/auth.controller.ts | 21 ++++- src/modules/auth/auth.module.ts | 2 + src/modules/auth/managed-mail-domains.ts | 9 ++ src/modules/mail/mail.controller.spec.ts | 6 +- src/modules/mail/mail.module.ts | 2 - src/modules/mail/mail.usecase.spec.ts | 90 +------------------ src/modules/mail/mail.usecase.ts | 50 +---------- src/modules/user/user.attributes.ts | 1 - src/modules/user/user.domain.ts | 3 - src/modules/user/user.model.ts | 4 - 12 files changed, 57 insertions(+), 166 deletions(-) delete mode 100644 migrations/20260330153847-add-recovery-email-to-users.js create mode 100644 src/modules/auth/managed-mail-domains.ts diff --git a/migrations/20260330153847-add-recovery-email-to-users.js b/migrations/20260330153847-add-recovery-email-to-users.js deleted file mode 100644 index 27968915e..000000000 --- a/migrations/20260330153847-add-recovery-email-to-users.js +++ /dev/null @@ -1,16 +0,0 @@ -'use strict'; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up(queryInterface, Sequelize) { - await queryInterface.addColumn('users', 'recovery_email', { - type: Sequelize.STRING, - allowNull: true, - defaultValue: null, - }); - }, - - async down(queryInterface) { - await queryInterface.removeColumn('users', 'recovery_email'); - }, -}; diff --git a/src/externals/mail/mail.service.ts b/src/externals/mail/mail.service.ts index 95ff87c3b..f0ae05467 100644 --- a/src/externals/mail/mail.service.ts +++ b/src/externals/mail/mail.service.ts @@ -46,6 +46,25 @@ export class MailService { }; } + async findUserIdByAddress(address: string): Promise { + const baseUrl = this.configService.get('apis.mail.url'); + const headers = this.getAuthHeaders(); + + try { + const res = await this.httpClient.get( + `${baseUrl}/gateway/addresses/${encodeURIComponent(address)}`, + { headers }, + ); + + return res.data?.userId ?? null; + } catch (error) { + if (error?.response?.status === 404) { + return null; + } + throw error; + } + } + async createAccount( payload: CreateAccountPayload, ): Promise { diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 69d7083a0..b1c8858ce 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -48,6 +48,8 @@ import { FeatureLimitService } from '../feature-limit/feature-limit.service'; import { PaymentRequiredException } from '../feature-limit/exceptions/payment-required.exception'; import { Client } from '../../common/decorators/client.decorator'; import { type ClientEnum } from '../../common/enums/platform.enum'; +import { MailService } from '../../externals/mail/mail.service'; +import { isManagedMailDomain } from './managed-mail-domains'; @ApiTags('Auth') @Controller('auth') @@ -60,8 +62,19 @@ export class AuthController { private readonly twoFactorAuthService: TwoFactorAuthService, private readonly authUseCases: AuthUsecases, private readonly featureLimitService: FeatureLimitService, + private readonly mailService: MailService, ) {} + private async resolveLoginEmail(email: string): Promise { + if (!isManagedMailDomain(email)) return email; + + const userId = await this.mailService.findUserIdByAddress(email); + if (!userId) return email; + + const user = await this.userUseCases.findByUuid(userId).catch(() => null); + return user?.email ?? email; + } + @Post('/login') @HttpCode(HttpStatus.OK) @ApiOperation({ @@ -70,7 +83,7 @@ export class AuthController { @ApiOkResponse({ description: 'Retrieve details', type: LoginResponseDto }) @Public() async login(@Body() body: LoginDto): Promise { - const email = body.email.toLowerCase(); + const email = await this.resolveLoginEmail(body.email.toLowerCase()); const user = await this.userUseCases.findByEmail(email); @@ -128,8 +141,11 @@ export class AuthController { revocationKey: body.revocateKey, }); + const email = await this.resolveLoginEmail(body.email.toLowerCase()); + const result = await this.userUseCases.loginAccess({ ...body, + email, keys: { kyber, ecc }, }); @@ -299,8 +315,11 @@ export class AuthController { revocationKey: body.revocateKey, }); + const email = await this.resolveLoginEmail(body.email.toLowerCase()); + const result = await this.userUseCases.loginAccess({ ...body, + email, keys: { kyber, ecc }, platform, }); diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 47e479c3a..74c2ca752 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -18,6 +18,7 @@ import { AuthUsecases } from './auth.usecase'; import { CaptchaService } from '../../externals/captcha/captcha.service'; import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; import { AuditLogsModule } from '../../common/audit-logs/audit-logs.module'; +import { MailServiceModule } from '../../externals/mail/mail.module'; @Module({ imports: [ @@ -41,6 +42,7 @@ import { AuditLogsModule } from '../../common/audit-logs/audit-logs.module'; CacheManagerModule, FeatureLimitModule, AuditLogsModule, + MailServiceModule, ], providers: [ CaptchaService, diff --git a/src/modules/auth/managed-mail-domains.ts b/src/modules/auth/managed-mail-domains.ts new file mode 100644 index 000000000..8614aca9c --- /dev/null +++ b/src/modules/auth/managed-mail-domains.ts @@ -0,0 +1,9 @@ +export const MANAGED_MAIL_DOMAINS: ReadonlySet = new Set([ + 'inxt.eu', + 'inxt.me', +]); + +export function isManagedMailDomain(email: string): boolean { + const domain = email.split('@')[1]?.toLowerCase(); + return !!domain && MANAGED_MAIL_DOMAINS.has(domain); +} diff --git a/src/modules/mail/mail.controller.spec.ts b/src/modules/mail/mail.controller.spec.ts index cdf830cbf..5fb7ec131 100644 --- a/src/modules/mail/mail.controller.spec.ts +++ b/src/modules/mail/mail.controller.spec.ts @@ -31,11 +31,7 @@ describe('MailController', () => { it('When called with valid input, then it should return the usecase result', async () => { const user = newUser(); - const expected = { - token: 'token', - newToken: 'newToken', - address: 'john@inxt.eu', - }; + const expected = { address: 'john@inxt.eu' }; mailUseCases.createMailAccount.mockResolvedValueOnce(expected); diff --git a/src/modules/mail/mail.module.ts b/src/modules/mail/mail.module.ts index 35ebd498f..8536ecbc4 100644 --- a/src/modules/mail/mail.module.ts +++ b/src/modules/mail/mail.module.ts @@ -1,6 +1,5 @@ import { forwardRef, Module } from '@nestjs/common'; import { MailServiceModule } from '../../externals/mail/mail.module'; -import { UserModule } from '../user/user.module'; import { CryptoModule } from '../../externals/crypto/crypto.module'; import { AuditLogsModule } from '../../common/audit-logs/audit-logs.module'; import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; @@ -10,7 +9,6 @@ import { MailUseCases } from './mail.usecase'; @Module({ imports: [ MailServiceModule, - UserModule, CryptoModule, AuditLogsModule, forwardRef(() => FeatureLimitModule), diff --git a/src/modules/mail/mail.usecase.spec.ts b/src/modules/mail/mail.usecase.spec.ts index a9b186de4..23f5f2f11 100644 --- a/src/modules/mail/mail.usecase.spec.ts +++ b/src/modules/mail/mail.usecase.spec.ts @@ -1,15 +1,9 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { createMock, type DeepMocked } from '@golevelup/ts-jest'; -import { - ConflictException, - UnauthorizedException, - BadRequestException, -} from '@nestjs/common'; +import { ConflictException, UnauthorizedException } from '@nestjs/common'; import { MailUseCases } from './mail.usecase'; import { MailService } from '../../externals/mail/mail.service'; import { CryptoService } from '../../externals/crypto/crypto.service'; -import { SequelizeUserRepository } from '../user/user.repository'; -import { UserUseCases } from '../user/user.usecase'; import { FeatureLimitService } from '../feature-limit/feature-limit.service'; import { PaymentRequiredException } from '../feature-limit/exceptions/payment-required.exception'; import { newUser, newFeatureLimit } from '../../../test/fixtures'; @@ -19,8 +13,6 @@ describe('MailUseCases', () => { let mailUseCases: MailUseCases; let cryptoService: DeepMocked; let mailService: DeepMocked; - let userRepository: DeepMocked; - let userUseCases: DeepMocked; let featureLimitService: DeepMocked; const dto = { @@ -40,8 +32,6 @@ describe('MailUseCases', () => { mailUseCases = moduleRef.get(MailUseCases); cryptoService = moduleRef.get(CryptoService); mailService = moduleRef.get(MailService); - userRepository = moduleRef.get(SequelizeUserRepository); - userUseCases = moduleRef.get(UserUseCases); featureLimitService = moduleRef.get(FeatureLimitService); }); @@ -91,37 +81,10 @@ describe('MailUseCases', () => { ); }); - it('When user email is on a blocked domain, then it should throw BadRequestException', async () => { - const user = newUser({ - attributes: { email: 'user@inxt.eu', password: 'hashed' }, - }); - const enabledLimit = newFeatureLimit({ - type: LimitTypes.Boolean, - label: LimitLabels.MailAccess, - value: 'true', - }); - - featureLimitService.getUserLimitByLabel.mockResolvedValueOnce( - enabledLimit, - ); - cryptoService.decryptText.mockReturnValueOnce('hashed'); - - await expect(mailUseCases.createMailAccount(user, dto)).rejects.toThrow( - BadRequestException, - ); - }); - - it('When all validations pass, then it should create account and return tokens', async () => { + it('When all validations pass, then it should provision the mail account and return the full address', async () => { const user = newUser({ attributes: { email: 'user@gmail.com', password: 'hashed' }, }); - const updatedUser = newUser({ - attributes: { - uuid: user.uuid, - email: 'john@inxt.eu', - recoveryEmail: 'user@gmail.com', - }, - }); const enabledLimit = newFeatureLimit({ type: LimitTypes.Boolean, label: LimitLabels.MailAccess, @@ -136,63 +99,16 @@ describe('MailUseCases', () => { address: 'john@inxt.eu', domain: 'inxt.eu', }); - userRepository.updateByUuid.mockResolvedValueOnce(undefined); - userRepository.findByUuid.mockResolvedValueOnce(updatedUser); - userUseCases.getAuthTokens.mockResolvedValueOnce({ - token: 'new-token', - newToken: 'new-new-token', - }); const result = await mailUseCases.createMailAccount(user, dto); - expect(result).toEqual({ - token: 'new-token', - newToken: 'new-new-token', - address: 'john@inxt.eu', - }); + expect(result).toEqual({ address: 'john@inxt.eu' }); expect(mailService.createAccount).toHaveBeenCalledWith({ userId: user.uuid, address: 'john@inxt.eu', domain: 'inxt.eu', displayName: 'John Doe', }); - expect(userRepository.updateByUuid).toHaveBeenCalledWith(user.uuid, { - email: 'john@inxt.eu', - recoveryEmail: 'user@gmail.com', - }); - }); - - it('When user update fails, then it should rollback recovery email', async () => { - const user = newUser({ - attributes: { email: 'user@gmail.com', password: 'hashed' }, - }); - const enabledLimit = newFeatureLimit({ - type: LimitTypes.Boolean, - label: LimitLabels.MailAccess, - value: 'true', - }); - - featureLimitService.getUserLimitByLabel.mockResolvedValueOnce( - enabledLimit, - ); - cryptoService.decryptText.mockReturnValueOnce('hashed'); - mailService.createAccount.mockResolvedValueOnce({ - address: 'john@inxt.eu', - domain: 'inxt.eu', - }); - userRepository.updateByUuid - .mockRejectedValueOnce(new Error('DB error')) - .mockResolvedValueOnce(undefined); - - await expect(mailUseCases.createMailAccount(user, dto)).rejects.toThrow( - 'DB error', - ); - - expect(userRepository.updateByUuid).toHaveBeenNthCalledWith( - 2, - user.uuid, - { email: 'user@gmail.com', recoveryEmail: null }, - ); }); it('When mail service returns 409, then it should propagate ConflictException', async () => { diff --git a/src/modules/mail/mail.usecase.ts b/src/modules/mail/mail.usecase.ts index 5780bfebd..cf55f7c04 100644 --- a/src/modules/mail/mail.usecase.ts +++ b/src/modules/mail/mail.usecase.ts @@ -1,21 +1,12 @@ -import { - BadRequestException, - Inject, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { CryptoService } from '../../externals/crypto/crypto.service'; import { MailService } from '../../externals/mail/mail.service'; -import { SequelizeUserRepository } from '../user/user.repository'; -import { UserUseCases } from '../user/user.usecase'; import { FeatureLimitService } from '../feature-limit/feature-limit.service'; import { LimitLabels } from '../feature-limit/limits.enum'; import { PaymentRequiredException } from '../feature-limit/exceptions/payment-required.exception'; import { type User } from '../user/user.domain'; import { type CreateMailAccountDto } from './dto/create-mail-account.dto'; -const BLOCKED_RECOVERY_DOMAINS = new Set(['inxt.eu']); - @Injectable() export class MailUseCases { constructor( @@ -23,10 +14,6 @@ export class MailUseCases { private readonly cryptoService: CryptoService, @Inject(MailService) private readonly mailService: MailService, - @Inject(SequelizeUserRepository) - private readonly userRepository: SequelizeUserRepository, - @Inject(UserUseCases) - private readonly userUseCases: UserUseCases, @Inject(FeatureLimitService) private readonly featureLimitService: FeatureLimitService, ) {} @@ -34,10 +21,9 @@ export class MailUseCases { async createMailAccount( user: User, dto: CreateMailAccountDto, - ): Promise<{ token: string; newToken: string; address: string }> { + ): Promise<{ address: string }> { await this.assertMailAccessEnabled(user); this.verifyPassword(user, dto.password); - this.validateRecoveryEmail(user.email); const fullAddress = `${dto.address}@${dto.domain}`; @@ -48,27 +34,7 @@ export class MailUseCases { displayName: dto.displayName, }); - const previousEmail = user.email; - - try { - await this.userRepository.updateByUuid(user.uuid, { - email: fullAddress, - recoveryEmail: previousEmail, - }); - } catch (error) { - await this.userRepository.updateByUuid(user.uuid, { - email: previousEmail, - recoveryEmail: null, - }); - throw error; - } - - const updatedUser = await this.userRepository.findByUuid(user.uuid); - - const { token, newToken } = - await this.userUseCases.getAuthTokens(updatedUser); - - return { token, newToken, address: fullAddress }; + return { address: fullAddress }; } private verifyPassword(user: User, encryptedPassword: string): void { @@ -91,14 +57,4 @@ export class MailUseCases { ); } } - - private validateRecoveryEmail(currentEmail: string): void { - const domain = currentEmail.split('@')[1]?.toLowerCase(); - - if (BLOCKED_RECOVERY_DOMAINS.has(domain)) { - throw new BadRequestException( - 'This email domain cannot be used as recovery email', - ); - } - } } diff --git a/src/modules/user/user.attributes.ts b/src/modules/user/user.attributes.ts index 00d9f4a15..7cf249341 100644 --- a/src/modules/user/user.attributes.ts +++ b/src/modules/user/user.attributes.ts @@ -28,7 +28,6 @@ export interface UserAttributes { lastPasswordChangedAt?: Date; tierId?: string; emailVerified: boolean; - recoveryEmail?: string; updatedAt?: Date; createdAt?: Date; } diff --git a/src/modules/user/user.domain.ts b/src/modules/user/user.domain.ts index b261730fa..8edf686a9 100644 --- a/src/modules/user/user.domain.ts +++ b/src/modules/user/user.domain.ts @@ -29,7 +29,6 @@ export class User implements UserAttributes { lastPasswordChangedAt: Date; tierId: string; emailVerified: boolean; - recoveryEmail: string; updatedAt: Date; createdAt: Date; @@ -62,7 +61,6 @@ export class User implements UserAttributes { lastPasswordChangedAt, tierId, emailVerified, - recoveryEmail, updatedAt, createdAt, }: UserAttributes) { @@ -94,7 +92,6 @@ export class User implements UserAttributes { this.lastPasswordChangedAt = lastPasswordChangedAt; this.tierId = tierId; this.emailVerified = emailVerified; - this.recoveryEmail = recoveryEmail; this.updatedAt = updatedAt; this.createdAt = createdAt; } diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index 9da190da5..20be96fc4 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -140,10 +140,6 @@ export class UserModel extends Model implements UserAttributes { @Column emailVerified: boolean; - @AllowNull - @Column - recoveryEmail: string; - @HasMany(() => UserNotificationTokensModel) notificationTokens: UserNotificationTokensModel[]; }