From 9d43a3c89ca9db9d5a8a3473b7d662e73de51176 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Mon, 11 May 2026 09:13:24 -0600 Subject: [PATCH 1/4] feat: add state management to mail accounts with migration and model updates - Introduced 'state' and 'suspended_at' columns in the mail_accounts table. - Updated MailAccount domain and model to include state management. - Enhanced AccountRepository to handle new state attributes. - Modified test fixtures to initialize mail accounts with state. --- ...260507151019-add-state-to-mail-accounts.js | 33 +++++++++++++++++++ .../account/domain/mail-account.domain.ts | 13 ++++++++ .../account/models/mail-account.model.ts | 8 +++++ .../repositories/account.repository.ts | 7 +++- test/fixtures.ts | 7 +++- 5 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 migrations/20260507151019-add-state-to-mail-accounts.js diff --git a/migrations/20260507151019-add-state-to-mail-accounts.js b/migrations/20260507151019-add-state-to-mail-accounts.js new file mode 100644 index 0000000..844a83f --- /dev/null +++ b/migrations/20260507151019-add-state-to-mail-accounts.js @@ -0,0 +1,33 @@ +'use strict'; + +const TABLE_NAME = 'mail_accounts'; +const PURGE_INDEX = 'mail_accounts_suspended_at_purge_idx'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn(TABLE_NAME, 'status', { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'active', + }); + + await queryInterface.addColumn(TABLE_NAME, 'suspended_at', { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }); + + await queryInterface.sequelize.query( + `CREATE INDEX ${PURGE_INDEX} + ON ${TABLE_NAME} (suspended_at) + WHERE status = 'suspended'`, + ); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(`DROP INDEX IF EXISTS ${PURGE_INDEX}`); + await queryInterface.removeColumn(TABLE_NAME, 'suspended_at'); + await queryInterface.removeColumn(TABLE_NAME, 'status'); + }, +}; diff --git a/src/modules/account/domain/mail-account.domain.ts b/src/modules/account/domain/mail-account.domain.ts index 3aefaad..f1f793f 100644 --- a/src/modules/account/domain/mail-account.domain.ts +++ b/src/modules/account/domain/mail-account.domain.ts @@ -3,9 +3,16 @@ import { type MailAddressAttributes, } from './mail-address.domain.js'; +export enum MailAccountState { + Active = 'active', + Suspended = 'suspended', +} + export interface MailAccountAttributes { id: string; userId: string; + state: MailAccountState; + suspendedAt: Date | null; addresses: MailAddressAttributes[]; createdAt: Date; updatedAt: Date; @@ -14,6 +21,8 @@ export interface MailAccountAttributes { export class MailAccount { readonly id!: string; readonly userId!: string; + readonly state!: MailAccountState; + readonly suspendedAt!: Date | null; readonly addresses!: MailAddress[]; readonly createdAt!: Date; readonly updatedAt!: Date; @@ -30,4 +39,8 @@ export class MailAccount { get defaultAddress(): MailAddress | undefined { return this.addresses.find((a) => a.isDefault); } + + get isSuspended(): boolean { + return this.state === MailAccountState.Suspended; + } } diff --git a/src/modules/account/models/mail-account.model.ts b/src/modules/account/models/mail-account.model.ts index 8928f84..07f5f5d 100644 --- a/src/modules/account/models/mail-account.model.ts +++ b/src/modules/account/models/mail-account.model.ts @@ -28,6 +28,14 @@ export class MailAccountModel extends Model { @Column(DataType.UUID) declare userId: string; + @AllowNull(false) + @Default('active') + @Column(DataType.STRING(20)) + declare state: string; + + @Column(DataType.DATE) + declare suspendedAt: Date | null; + @Column(DataType.DATE) declare deletedAt: Date | null; diff --git a/src/modules/account/repositories/account.repository.ts b/src/modules/account/repositories/account.repository.ts index 1e05ea9..ee8cfda 100644 --- a/src/modules/account/repositories/account.repository.ts +++ b/src/modules/account/repositories/account.repository.ts @@ -1,6 +1,9 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/sequelize'; -import { MailAccount } from '../domain/mail-account.domain.js'; +import { + MailAccount, + MailAccountState, +} from '../domain/mail-account.domain.js'; import { MailAccountModel } from '../models/mail-account.model.js'; import { MailAddressModel } from '../models/mail-address.model.js'; import { MailProviderAccountModel } from '../models/mail-provider-account.model.js'; @@ -47,6 +50,8 @@ export class AccountRepository { return MailAccount.build({ id: model.id, userId: model.userId, + state: model.state as MailAccountState, + suspendedAt: model.suspendedAt, createdAt: model.createdAt as Date, updatedAt: model.updatedAt as Date, addresses: (model.addresses ?? []).map(toAddressAttributes), diff --git a/test/fixtures.ts b/test/fixtures.ts index 059a2d3..bee620d 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -10,7 +10,10 @@ import type { SearchEmailDto, } from '../src/modules/email/email.types.js'; import type { UserPayload } from '../src/modules/auth/jwt-payload.dto.js'; -import type { MailAccountAttributes } from '../src/modules/account/domain/mail-account.domain.js'; +import { + type MailAccountAttributes, + MailAccountState, +} from '../src/modules/account/domain/mail-account.domain.js'; import type { MailAddressKeysAttributes } from '../src/modules/account/domain/mail-address-keys.domain.js'; import type { MailAddressAttributes } from '../src/modules/account/domain/mail-address.domain.js'; import type { MailAddressKeyBundle } from '../src/modules/account/account.service.js'; @@ -222,6 +225,8 @@ export function newMailAccountAttributes( return { id: accountId, userId: randomUuid(), + state: MailAccountState.Active, + suspendedAt: null, addresses: [address], createdAt: new Date(), updatedAt: new Date(), From 7aab58c7ec580b8f9534fefd2f9c126971541ff9 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Mon, 11 May 2026 15:16:09 -0600 Subject: [PATCH 2/4] feat: implement account status retrieval with retention configuration - Added 'suspendedRetentionDays' configuration for account suspension management. - Enhanced AccountService to compute account deletion date based on suspension status and retention days. - Introduced MailAccountStatus interface and updated related services to return account status. - Updated UserController to expose account status retrieval endpoint. - Added corresponding tests to ensure correct behavior of account status logic. --- src/config/configuration.ts | 7 ++ src/modules/account/account.service.spec.ts | 49 ++++++++++++- src/modules/account/account.service.ts | 30 +++++++- .../account/domain/mail-account.domain.ts | 6 +- .../account/dto/mail-account.response.dto.ts | 72 +++++++++++++++++++ .../account/models/mail-account.model.ts | 2 +- .../repositories/account.repository.ts | 2 +- src/modules/account/user.controller.spec.ts | 23 +++++- src/modules/account/user.controller.ts | 47 +++++++++++- test/fixtures.ts | 2 +- 10 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 src/modules/account/dto/mail-account.response.dto.ts diff --git a/src/config/configuration.ts b/src/config/configuration.ts index c7fab8e..dcfeee1 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -21,6 +21,13 @@ export default () => ({ masterPassword: process.env.STALWART_MASTER_PASSWORD ?? '', }, + accounts: { + suspendedRetentionDays: Number.parseInt( + process.env.SUSPENDED_ACCOUNT_RETENTION_DAYS ?? '30', + 10, + ), + }, + secrets: { jwt: process.env.JWT_SECRET, gateway: process.env.GATEWAY_PUBLIC_SECRET, diff --git a/src/modules/account/account.service.spec.ts b/src/modules/account/account.service.spec.ts index 0f9085f..fcf8a05 100644 --- a/src/modules/account/account.service.spec.ts +++ b/src/modules/account/account.service.spec.ts @@ -6,9 +6,10 @@ import { NotFoundException, UnprocessableEntityException, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { AccountService } from './account.service.js'; import { AccountProvider } from './account-provider.port.js'; -import { MailAccount } from './domain/mail-account.domain.js'; +import { MailAccount, MailAccountState } from './domain/mail-account.domain.js'; import { MailDomain } from './domain/mail-domain.domain.js'; import { MailAddress } from './domain/mail-address.domain.js'; import { AccountRepository } from './repositories/account.repository.js'; @@ -32,6 +33,7 @@ describe('AccountService', () => { let addresses: DeepMocked; let domains: DeepMocked; let keys: DeepMocked; + let config: DeepMocked; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -46,6 +48,7 @@ describe('AccountService', () => { addresses = module.get(AddressRepository); domains = module.get(DomainRepository); keys = module.get(MailAddressKeysRepository); + config = module.get(ConfigService); }); describe('getAccount', () => { @@ -69,6 +72,50 @@ describe('AccountService', () => { }); }); + describe('getAccountStatus', () => { + it('when account is active, then returns status with null suspendedAt and deletionAt', async () => { + const attrs = newMailAccountAttributes(); + accounts.findByUserId.mockResolvedValue(MailAccount.build(attrs)); + + const result = await service.getAccountStatus(attrs.userId); + + expect(result).toEqual({ + id: attrs.id, + defaultAddress: attrs.addresses[0]?.address ?? null, + status: MailAccountState.Active, + suspendedAt: null, + deletionAt: null, + }); + }); + + it('when account is suspended, then computes deletionAt from retention config', async () => { + const suspendedAt = new Date('2026-01-01T00:00:00.000Z'); + const attrs = newMailAccountAttributes({ + status: MailAccountState.Suspended, + suspendedAt, + }); + accounts.findByUserId.mockResolvedValue(MailAccount.build(attrs)); + config.get.mockReturnValue(30); + + const result = await service.getAccountStatus(attrs.userId); + + expect(config.get).toHaveBeenCalledWith( + 'accounts.suspendedRetentionDays', + ); + expect(result.status).toBe(MailAccountState.Suspended); + expect(result.suspendedAt).toEqual(suspendedAt); + expect(result.deletionAt).toEqual(new Date('2026-01-31T00:00:00.000Z')); + }); + + it('when account does not exist, then throws NotFoundException', async () => { + accounts.findByUserId.mockResolvedValue(null); + + await expect(service.getAccountStatus('unknown-uuid')).rejects.toThrow( + NotFoundException, + ); + }); + }); + describe('findUserIdByAddress', () => { it('when address exists, then returns the userId', async () => { const userId = 'user-uuid-1'; diff --git a/src/modules/account/account.service.ts b/src/modules/account/account.service.ts index b28a99b..6c6e4ed 100644 --- a/src/modules/account/account.service.ts +++ b/src/modules/account/account.service.ts @@ -6,9 +6,10 @@ import { NotFoundException, UnprocessableEntityException, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { MailNotSetupException } from '../provisioning/mail-not-setup.exception.js'; import { AccountProvider } from './account-provider.port.js'; -import { MailAccount } from './domain/mail-account.domain.js'; +import { MailAccount, MailAccountState } from './domain/mail-account.domain.js'; import { MailDomain } from './domain/mail-domain.domain.js'; import { AccountRepository } from './repositories/account.repository.js'; import { AddressRepository } from './repositories/address.repository.js'; @@ -21,6 +22,14 @@ export interface MailAddressKeyBundle { recoveryPrivateKey: string; } +export interface MailAccountStatus { + id: string; + defaultAddress: string | null; + status: MailAccountState; + suspendedAt: Date | null; + deletionAt: Date | null; +} + @Injectable() export class AccountService { private readonly logger = new Logger(AccountService.name); @@ -31,12 +40,31 @@ export class AccountService { private readonly addresses: AddressRepository, private readonly domains: DomainRepository, private readonly keys: MailAddressKeysRepository, + private readonly config: ConfigService, ) {} async getAccount(userId: string): Promise { return this.getAccountOrFail(userId); } + async getAccountStatus(userId: string): Promise { + const account = await this.getAccountOrFail(userId); + + return { + id: account.id, + defaultAddress: account.defaultAddress?.address ?? null, + status: account.status, + suspendedAt: account.suspendedAt, + deletionAt: this.computeDeletionAt(account.suspendedAt), + }; + } + + private computeDeletionAt(suspendedAt: Date | null): Date | null { + if (!suspendedAt) return null; + const days = this.config.get('accounts.suspendedRetentionDays')!; + return new Date(suspendedAt.getTime() + days * 24 * 60 * 60 * 1000); + } + async listActiveDomains(): Promise { return this.domains.findAllActive(); } diff --git a/src/modules/account/domain/mail-account.domain.ts b/src/modules/account/domain/mail-account.domain.ts index f1f793f..7bc3512 100644 --- a/src/modules/account/domain/mail-account.domain.ts +++ b/src/modules/account/domain/mail-account.domain.ts @@ -11,7 +11,7 @@ export enum MailAccountState { export interface MailAccountAttributes { id: string; userId: string; - state: MailAccountState; + status: MailAccountState; suspendedAt: Date | null; addresses: MailAddressAttributes[]; createdAt: Date; @@ -21,7 +21,7 @@ export interface MailAccountAttributes { export class MailAccount { readonly id!: string; readonly userId!: string; - readonly state!: MailAccountState; + readonly status!: MailAccountState; readonly suspendedAt!: Date | null; readonly addresses!: MailAddress[]; readonly createdAt!: Date; @@ -41,6 +41,6 @@ export class MailAccount { } get isSuspended(): boolean { - return this.state === MailAccountState.Suspended; + return this.status === MailAccountState.Suspended; } } diff --git a/src/modules/account/dto/mail-account.response.dto.ts b/src/modules/account/dto/mail-account.response.dto.ts new file mode 100644 index 0000000..2dad367 --- /dev/null +++ b/src/modules/account/dto/mail-account.response.dto.ts @@ -0,0 +1,72 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { MailAccountState } from '../domain/mail-account.domain.js'; + +export class MailAccountStatusResponseDto { + @ApiProperty({ example: 'f3a1b2c4-1234-4abc-9def-0123456789ab' }) + id!: string; + + @ApiPropertyOptional({ + type: String, + nullable: true, + example: 'alice@inxt.eu', + description: 'Default address of the account, null if none is set', + }) + defaultAddress!: string | null; + + @ApiProperty({ + enum: MailAccountState, + enumName: 'MailAccountState', + example: MailAccountState.Active, + }) + status!: MailAccountState; + + @ApiPropertyOptional({ + type: String, + nullable: true, + example: '2026-05-01T10:29:55.000Z', + description: 'When the account was suspended; null when active', + }) + suspendedAt!: Date | null; + + @ApiPropertyOptional({ + type: String, + nullable: true, + example: '2026-05-31T10:29:55.000Z', + description: + 'Scheduled deletion date for suspended accounts; null when active', + }) + deletionAt!: Date | null; +} + +export class CreateMailAccountResponseDto { + @ApiProperty({ example: 'f3a1b2c4-1234-4abc-9def-0123456789ab' }) + id!: string; + + @ApiProperty({ example: 'alice@inxt.eu' }) + address!: string; + + @ApiProperty({ example: 'inxt.eu' }) + domain!: string; +} + +export class MailAccountKeysResponseDto { + @ApiProperty({ example: 'alice@inxt.eu' }) + address!: string; + + @ApiProperty({ + description: 'Hybrid (X25519 + ML-KEM-768) public key, base64-encoded', + }) + publicKey!: string; + + @ApiProperty({ + description: + 'Private key encrypted with the encryption keystore key (base64)', + }) + encryptionPrivateKey!: string; + + @ApiProperty({ + description: + 'Private key encrypted with the recovery keystore key (base64)', + }) + recoveryPrivateKey!: string; +} diff --git a/src/modules/account/models/mail-account.model.ts b/src/modules/account/models/mail-account.model.ts index 07f5f5d..a15030f 100644 --- a/src/modules/account/models/mail-account.model.ts +++ b/src/modules/account/models/mail-account.model.ts @@ -31,7 +31,7 @@ export class MailAccountModel extends Model { @AllowNull(false) @Default('active') @Column(DataType.STRING(20)) - declare state: string; + declare status: string; @Column(DataType.DATE) declare suspendedAt: Date | null; diff --git a/src/modules/account/repositories/account.repository.ts b/src/modules/account/repositories/account.repository.ts index ee8cfda..5b71339 100644 --- a/src/modules/account/repositories/account.repository.ts +++ b/src/modules/account/repositories/account.repository.ts @@ -50,7 +50,7 @@ export class AccountRepository { return MailAccount.build({ id: model.id, userId: model.userId, - state: model.state as MailAccountState, + status: model.status as MailAccountState, suspendedAt: model.suspendedAt, createdAt: model.createdAt as Date, updatedAt: model.updatedAt as Date, diff --git a/src/modules/account/user.controller.spec.ts b/src/modules/account/user.controller.spec.ts index eae95b2..c1a8cfa 100644 --- a/src/modules/account/user.controller.spec.ts +++ b/src/modules/account/user.controller.spec.ts @@ -3,9 +3,9 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { createMock, type DeepMocked } from '@golevelup/ts-vitest'; import { ForbiddenException } from '@nestjs/common'; import { UserController } from './user.controller.js'; -import { AccountService } from './account.service.js'; +import { AccountService, type MailAccountStatus } from './account.service.js'; +import { MailAccountState, MailAccount } from './domain/mail-account.domain.js'; import { PaymentsService } from '../infrastructure/payments/payments.service.js'; -import { MailAccount } from './domain/mail-account.domain.js'; import { newMailAccountAttributes, newMailAddressKeyBundle, @@ -48,6 +48,25 @@ describe('UserController', () => { payments = module.get(PaymentsService); }); + describe('getMailAccount', () => { + it('when called, then delegates to accountService.getAccountStatus', async () => { + const user = newUserPayload(); + const status: MailAccountStatus = { + id: 'acc-1', + defaultAddress: 'alice@inxt.eu', + status: MailAccountState.Suspended, + suspendedAt: new Date('2026-01-01T00:00:00.000Z'), + deletionAt: new Date('2026-01-31T00:00:00.000Z'), + }; + accountService.getAccountStatus.mockResolvedValue(status); + + const result = await controller.getMailAccount(user); + + expect(accountService.getAccountStatus).toHaveBeenCalledWith(user.uuid); + expect(result).toBe(status); + }); + }); + describe('getMailAccountKeys', () => { it('when address query is omitted, then uses the caller`s default address', async () => { const user = newUserPayload(); diff --git a/src/modules/account/user.controller.ts b/src/modules/account/user.controller.ts index 79d490b..d6b3f31 100644 --- a/src/modules/account/user.controller.ts +++ b/src/modules/account/user.controller.ts @@ -10,7 +10,16 @@ import { Query, UseGuards, } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiConflictResponse, + ApiCreatedResponse, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; import { GetMailAccountKeysDto } from './dto/get-mail-account-keys.dto.js'; import { User } from '../auth/decorators/user.decorator.js'; import type { UserPayload } from '../auth/jwt-payload.dto.js'; @@ -19,6 +28,11 @@ import { AccountService } from './account.service.js'; import { CreateMailAccountDto } from './dto/create-mail-account.dto.js'; import { MailAccountGuard } from '../provisioning/provisioning.guard.js'; import { MailAddress } from './decorators/mail-address.decorator.js'; +import { + CreateMailAccountResponseDto, + MailAccountKeysResponseDto, + MailAccountStatusResponseDto, +} from './dto/mail-account.response.dto.js'; @ApiTags('User') @ApiBearerAuth() @@ -31,13 +45,36 @@ export class UserController { private readonly payments: PaymentsService, ) {} + @Get('me/mail-account') + @UseGuards(MailAccountGuard) + @ApiOperation({ + summary: 'Get the caller`s mail account status', + description: + 'Returns the account status. When suspended, includes `suspendedAt` and the scheduled `deletionAt`.', + }) + @ApiOkResponse({ type: MailAccountStatusResponseDto }) + @ApiNotFoundResponse({ description: 'No mail account exists for the caller' }) + async getMailAccount( + @User() user: UserPayload, + ): Promise { + return this.accountService.getAccountStatus(user.uuid); + } + @Post('me/mail-account') @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Provision the caller`s mail account' }) + @ApiCreatedResponse({ type: CreateMailAccountResponseDto }) + @ApiForbiddenResponse({ + description: 'Caller`s tier does not include mail access', + }) + @ApiNotFoundResponse({ description: 'Requested domain does not exist' }) + @ApiConflictResponse({ + description: 'Caller already has an account, or the address is taken', + }) async createMailAccount( @User() user: UserPayload, @Body() dto: CreateMailAccountDto, - ) { + ): Promise { const tier = await this.payments.getUserTier(user.uuid); if (!tier.featuresPerService.mail?.enabled) { throw new ForbiddenException( @@ -77,11 +114,15 @@ export class UserController { description: 'If `address` is omitted, returns keys for the caller`s primary address.', }) + @ApiOkResponse({ type: MailAccountKeysResponseDto }) + @ApiNotFoundResponse({ + description: 'Address not found on this account, or keys not set', + }) async getMailAccountKeys( @User() user: UserPayload, @MailAddress('address') defaultAddress: string, @Query() query: GetMailAccountKeysDto, - ) { + ): Promise { const address = query.address ?? defaultAddress; return this.accountService.getAddressKeys(user.uuid, address); } diff --git a/test/fixtures.ts b/test/fixtures.ts index bee620d..58ec4e0 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -225,7 +225,7 @@ export function newMailAccountAttributes( return { id: accountId, userId: randomUuid(), - state: MailAccountState.Active, + status: MailAccountState.Active, suspendedAt: null, addresses: [address], createdAt: new Date(), From 7ea20c12846300e10b10e304869edf118d3f7e46 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Mon, 11 May 2026 17:51:57 -0600 Subject: [PATCH 3/4] test: enhance UserController and add AccountRepository tests for account status handling - Updated UserController tests to verify behavior for active and suspended accounts in getMailAccount method. - Introduced new AccountRepository tests to validate account retrieval, creation, and deletion logic, including handling of addresses and account states. --- .../repositories/account.repository.spec.ts | 132 ++++++++++++++++++ src/modules/account/user.controller.spec.ts | 19 ++- 2 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 src/modules/account/repositories/account.repository.spec.ts diff --git a/src/modules/account/repositories/account.repository.spec.ts b/src/modules/account/repositories/account.repository.spec.ts new file mode 100644 index 0000000..4572034 --- /dev/null +++ b/src/modules/account/repositories/account.repository.spec.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { getModelToken } from '@nestjs/sequelize'; +import { createMock, type DeepMocked } from '@golevelup/ts-vitest'; +import { AccountRepository } from './account.repository.js'; +import { MailAccountModel } from '../models/mail-account.model.js'; +import { MailAddressModel } from '../models/mail-address.model.js'; +import { MailProviderAccountModel } from '../models/mail-provider-account.model.js'; +import { MailAccountState } from '../domain/mail-account.domain.js'; + +describe('AccountRepository', () => { + let repository: AccountRepository; + let accountModel: DeepMocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AccountRepository], + }) + .useMocker((token) => { + if (token === getModelToken(MailAccountModel)) { + return createMock(); + } + return createMock(); + }) + .compile(); + + repository = module.get(AccountRepository); + accountModel = module.get(getModelToken(MailAccountModel)); + }); + + const buildModel = (overrides: Partial = {}) => + ({ + id: 'acc-1', + userId: 'user-1', + status: 'active', + suspendedAt: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + addresses: [], + ...overrides, + }) as unknown as MailAccountModel; + + describe('findByUserId', () => { + it('when account exists, then includes addresses and provider links in the query', async () => { + accountModel.findOne.mockResolvedValue(buildModel()); + + await repository.findByUserId('user-1'); + + expect(accountModel.findOne).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + include: [ + { + model: MailAddressModel, + include: [MailProviderAccountModel], + }, + ], + }); + }); + + it('when account is active, then maps status and null suspendedAt to domain', async () => { + accountModel.findOne.mockResolvedValue( + buildModel({ status: 'active', suspendedAt: null }), + ); + + const result = await repository.findByUserId('user-1'); + + expect(result?.status).toBe(MailAccountState.Active); + expect(result?.suspendedAt).toBeNull(); + expect(result?.isSuspended).toBe(false); + }); + + it('when account is suspended, then maps status and suspendedAt to domain', async () => { + const suspendedAt = new Date('2026-03-15T10:00:00.000Z'); + accountModel.findOne.mockResolvedValue( + buildModel({ status: 'suspended', suspendedAt }), + ); + + const result = await repository.findByUserId('user-1'); + + expect(result?.status).toBe(MailAccountState.Suspended); + expect(result?.suspendedAt).toEqual(suspendedAt); + expect(result?.isSuspended).toBe(true); + }); + + it('when account does not exist, then returns null', async () => { + accountModel.findOne.mockResolvedValue(null); + + const result = await repository.findByUserId('unknown'); + + expect(result).toBeNull(); + }); + + it('when addresses are missing on the model, then returns empty addresses', async () => { + accountModel.findOne.mockResolvedValue( + buildModel({ addresses: undefined as unknown as MailAddressModel[] }), + ); + + const result = await repository.findByUserId('user-1'); + + expect(result?.addresses).toEqual([]); + }); + }); + + describe('create', () => { + it('when given a userId, then creates the account and maps it to domain', async () => { + accountModel.create.mockResolvedValue(buildModel()); + + const result = await repository.create({ userId: 'user-1' }); + + expect(accountModel.create).toHaveBeenCalledWith( + { userId: 'user-1' }, + { + include: [ + { model: MailAddressModel, include: [MailProviderAccountModel] }, + ], + }, + ); + expect(result.userId).toBe('user-1'); + expect(result.status).toBe(MailAccountState.Active); + }); + }); + + describe('delete', () => { + it('when given an id, then destroys by id', async () => { + await repository.delete('acc-1'); + + expect(accountModel.destroy).toHaveBeenCalledWith({ + where: { id: 'acc-1' }, + }); + }); + }); +}); diff --git a/src/modules/account/user.controller.spec.ts b/src/modules/account/user.controller.spec.ts index c1a8cfa..16c557e 100644 --- a/src/modules/account/user.controller.spec.ts +++ b/src/modules/account/user.controller.spec.ts @@ -49,7 +49,7 @@ describe('UserController', () => { }); describe('getMailAccount', () => { - it('when called, then delegates to accountService.getAccountStatus', async () => { + it('when account is suspended, then returns suspendedAt and deletionAt from the service', async () => { const user = newUserPayload(); const status: MailAccountStatus = { id: 'acc-1', @@ -65,6 +65,23 @@ describe('UserController', () => { expect(accountService.getAccountStatus).toHaveBeenCalledWith(user.uuid); expect(result).toBe(status); }); + + it('when account is active, then returns null suspendedAt and deletionAt', async () => { + const user = newUserPayload(); + const status: MailAccountStatus = { + id: 'acc-1', + defaultAddress: 'alice@inxt.eu', + status: MailAccountState.Active, + suspendedAt: null, + deletionAt: null, + }; + accountService.getAccountStatus.mockResolvedValue(status); + + const result = await controller.getMailAccount(user); + + expect(accountService.getAccountStatus).toHaveBeenCalledWith(user.uuid); + expect(result).toBe(status); + }); }); describe('getMailAccountKeys', () => { From de1dfd74b8dc7df7573db37da94022ca8d7cf91b Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Tue, 12 May 2026 07:15:43 -0600 Subject: [PATCH 4/4] refactor: update deletion date computation in AccountService to use dayjs --- package-lock.json | 60 ++++++++++++++++++++++---- package.json | 1 + src/modules/account/account.service.ts | 3 +- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 271bd9f..8cfd3fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "cross-env": "^10.1.0", + "dayjs": "^1.11.20", "helmet": "^8.1.0", "ioredis": "^5.10.1", "nanoid": "^5.1.5", @@ -1572,6 +1573,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.17.tgz", "integrity": "sha512-hLODw5Abp8OQgA+mUO4tHou4krKgDtUcM9j5Ihxncst9XeyxYBTt2bwZm4e4EQr5E352S4Fyy6V3iFx9ggxKAg==", "license": "MIT", + "peer": true, "dependencies": { "file-type": "21.3.2", "iterare": "1.2.1", @@ -1619,6 +1621,7 @@ "integrity": "sha512-lD5mAYekTTurF3vDaa8C2OKPnjiz4tsfxIc5XlcSUzOhkwWf6Ay3HKvt6FmvuWQam6uIIHX52Clg+e6tAvf/cg==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -1715,6 +1718,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.17.tgz", "integrity": "sha512-mAf4eOsSBsTOn/VbrUO1gsjW6dVh91qqXPMXun4dN8SnNjf7PTQagM9o8d6ab8ZBpNe6UdZftdrZoDetU+n4Qg==", "license": "MIT", + "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -1881,6 +1885,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", @@ -2355,6 +2360,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" @@ -2394,6 +2400,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2410,6 +2417,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2426,6 +2434,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2442,6 +2451,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2458,6 +2468,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2474,6 +2485,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2490,6 +2502,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2506,6 +2519,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2522,6 +2536,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2538,6 +2553,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2727,6 +2743,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2802,7 +2819,8 @@ "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.1", @@ -2849,6 +2867,7 @@ "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", @@ -3420,6 +3439,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3454,6 +3474,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3735,6 +3756,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3934,6 +3956,7 @@ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "license": "MIT", + "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -3957,13 +3980,15 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/class-validator": { "version": "0.14.4", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", "license": "MIT", + "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -4369,6 +4394,12 @@ "node": "*" } }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5074,6 +5105,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5417,8 +5449,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.3", @@ -5800,7 +5831,6 @@ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "license": "ISC", - "peer": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -5823,6 +5853,7 @@ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", "license": "MIT", + "peer": true, "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", @@ -7313,6 +7344,7 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", + "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -7359,7 +7391,6 @@ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7441,6 +7472,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -7548,6 +7580,7 @@ "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", "license": "MIT", + "peer": true, "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", @@ -7579,6 +7612,7 @@ "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-11.0.0.tgz", "integrity": "sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==", "license": "MIT", + "peer": true, "dependencies": { "get-caller-file": "^2.0.5", "pino": "^10.0.0", @@ -7740,6 +7774,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -7923,7 +7958,8 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/require-directory": { "version": "2.1.1", @@ -8080,6 +8116,7 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -8140,6 +8177,7 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -8202,6 +8240,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -8293,6 +8332,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@types/debug": "^4.1.8", "@types/validator": "^13.7.17", @@ -8395,7 +8435,6 @@ "resolved": "https://registry.npmjs.org/sequelize-typescript/-/sequelize-typescript-2.1.6.tgz", "integrity": "sha512-Vc2N++3en346RsbGjL3h7tgAl2Y7V+2liYTAOZ8XL0KTw3ahFHsyAUzOwct51n+g70I1TOUDgs06Oh6+XGcFkQ==", "license": "MIT", - "peer": true, "dependencies": { "glob": "7.2.0" }, @@ -8415,7 +8454,6 @@ "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -9450,6 +9488,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9682,6 +9721,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -9793,6 +9833,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -9894,6 +9935,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/package.json b/package.json index 5b50b1d..5320f57 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "cross-env": "^10.1.0", + "dayjs": "^1.11.20", "helmet": "^8.1.0", "ioredis": "^5.10.1", "nanoid": "^5.1.5", diff --git a/src/modules/account/account.service.ts b/src/modules/account/account.service.ts index 6c6e4ed..08a9961 100644 --- a/src/modules/account/account.service.ts +++ b/src/modules/account/account.service.ts @@ -7,6 +7,7 @@ import { UnprocessableEntityException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import dayjs from 'dayjs'; import { MailNotSetupException } from '../provisioning/mail-not-setup.exception.js'; import { AccountProvider } from './account-provider.port.js'; import { MailAccount, MailAccountState } from './domain/mail-account.domain.js'; @@ -62,7 +63,7 @@ export class AccountService { private computeDeletionAt(suspendedAt: Date | null): Date | null { if (!suspendedAt) return null; const days = this.config.get('accounts.suspendedRetentionDays')!; - return new Date(suspendedAt.getTime() + days * 24 * 60 * 60 * 1000); + return dayjs(suspendedAt).add(days, 'day').toDate(); } async listActiveDomains(): Promise {