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..f0ae05467 --- /dev/null +++ b/src/externals/mail/mail.service.ts @@ -0,0 +1,91 @@ +import { ConflictException, 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 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 { + const baseUrl = this.configService.get('apis.mail.url'); + const headers = this.getAuthHeaders(); + + try { + const res = await this.httpClient.post( + `${baseUrl}/gateway/accounts`, + payload, + { headers }, + ); + + 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/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/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.spec.ts b/src/modules/mail/mail.controller.spec.ts new file mode 100644 index 000000000..5fb7ec131 --- /dev/null +++ b/src/modules/mail/mail.controller.spec.ts @@ -0,0 +1,68 @@ +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 = { 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.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..8536ecbc4 --- /dev/null +++ b/src/modules/mail/mail.module.ts @@ -0,0 +1,20 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { MailServiceModule } from '../../externals/mail/mail.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, + CryptoModule, + AuditLogsModule, + forwardRef(() => FeatureLimitModule), + ], + controllers: [MailController], + providers: [MailUseCases], + exports: [MailUseCases], +}) +export class MailModule {} diff --git a/src/modules/mail/mail.usecase.spec.ts b/src/modules/mail/mail.usecase.spec.ts new file mode 100644 index 000000000..23f5f2f11 --- /dev/null +++ b/src/modules/mail/mail.usecase.spec.ts @@ -0,0 +1,137 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { createMock, type DeepMocked } from '@golevelup/ts-jest'; +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 { 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 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); + 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 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 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', + }); + + const result = await mailUseCases.createMailAccount(user, dto); + + expect(result).toEqual({ address: 'john@inxt.eu' }); + expect(mailService.createAccount).toHaveBeenCalledWith({ + userId: user.uuid, + address: 'john@inxt.eu', + domain: 'inxt.eu', + displayName: 'John Doe', + }); + }); + + 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 new file mode 100644 index 000000000..cf55f7c04 --- /dev/null +++ b/src/modules/mail/mail.usecase.ts @@ -0,0 +1,60 @@ +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { CryptoService } from '../../externals/crypto/crypto.service'; +import { MailService } from '../../externals/mail/mail.service'; +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'; + +@Injectable() +export class MailUseCases { + constructor( + @Inject(CryptoService) + private readonly cryptoService: CryptoService, + @Inject(MailService) + private readonly mailService: MailService, + @Inject(FeatureLimitService) + private readonly featureLimitService: FeatureLimitService, + ) {} + + async createMailAccount( + user: User, + dto: CreateMailAccountDto, + ): Promise<{ address: string }> { + await this.assertMailAccessEnabled(user); + this.verifyPassword(user, dto.password); + + const fullAddress = `${dto.address}@${dto.domain}`; + + await this.mailService.createAccount({ + userId: user.uuid, + address: fullAddress, + domain: dto.domain, + displayName: dto.displayName, + }); + + return { 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', + ); + } + } +}