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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/modules/account/account-provider.port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ export abstract class AccountProvider {
abstract createAccount(params: CreateAccountParams): Promise<void>;
abstract deleteAccount(name: string): Promise<void>;
abstract getAccount(name: string): Promise<AccountInfo | null>;
abstract suspendAccount(name: string): Promise<void>;
abstract reactivateAccount(name: string): Promise<void>;
}
92 changes: 92 additions & 0 deletions src/modules/account/account.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,98 @@ describe('AccountService', () => {
});
});

describe('suspendAccount', () => {
it('when account is active, then suspends every principal and the account', async () => {
const addr1 = newMailAddressAttributes({ isDefault: true });
const addr2 = newMailAddressAttributes({ isDefault: false });
const account = MailAccount.build(
newMailAccountAttributes({
status: MailAccountState.Active,
addresses: [addr1, addr2],
}),
);
accounts.findByUserId.mockResolvedValue(account);

await service.suspendAccount(account.userId);

expect(provider.suspendAccount).toHaveBeenCalledWith(
addr1.providerExternalId,
);
expect(provider.suspendAccount).toHaveBeenCalledWith(
addr2.providerExternalId,
);
expect(accounts.suspend).toHaveBeenCalledWith(account.id);
});

it('when account is already suspended, then is a no-op', async () => {
const account = MailAccount.build(
newMailAccountAttributes({
status: MailAccountState.Suspended,
suspendedAt: new Date(),
}),
);
accounts.findByUserId.mockResolvedValue(account);

await service.suspendAccount(account.userId);

expect(provider.suspendAccount).not.toHaveBeenCalled();
expect(accounts.suspend).not.toHaveBeenCalled();
});

it('when account does not exist, then throws NotFoundException', async () => {
accounts.findByUserId.mockResolvedValue(null);

await expect(service.suspendAccount('unknown')).rejects.toThrow(
NotFoundException,
);
});
});

describe('reactivateAccount', () => {
it('when account is suspended, then reactivates every principal and the account', async () => {
const addr1 = newMailAddressAttributes({ isDefault: true });
const addr2 = newMailAddressAttributes({ isDefault: false });
const account = MailAccount.build(
newMailAccountAttributes({
status: MailAccountState.Suspended,
suspendedAt: new Date(),
addresses: [addr1, addr2],
}),
);
accounts.findByUserId.mockResolvedValue(account);

await service.reactivateAccount(account.userId);

expect(provider.reactivateAccount).toHaveBeenCalledWith(
addr1.providerExternalId,
);
expect(provider.reactivateAccount).toHaveBeenCalledWith(
addr2.providerExternalId,
);
expect(accounts.reactivate).toHaveBeenCalledWith(account.id);
});

it('when account is already active, then is a no-op', async () => {
const account = MailAccount.build(
newMailAccountAttributes({ status: MailAccountState.Active }),
);
accounts.findByUserId.mockResolvedValue(account);

await service.reactivateAccount(account.userId);

expect(provider.reactivateAccount).not.toHaveBeenCalled();
expect(accounts.reactivate).not.toHaveBeenCalled();
});

it('when account does not exist, then throws NotFoundException', async () => {
accounts.findByUserId.mockResolvedValue(null);

await expect(service.reactivateAccount('unknown')).rejects.toThrow(
NotFoundException,
);
});
});

describe('addAddress', () => {
it('when all conditions met, then creates principal and links provider', async () => {
const accountAttrs = newMailAccountAttributes();
Expand Down
35 changes: 35 additions & 0 deletions src/modules/account/account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,4 +377,39 @@
}
return account;
}

async suspendAccount(userId: string): Promise<void> {
const account = await this.getAccountOrFail(userId);
if (account.isSuspended) {
this.logger.log(`Account for user '${userId}' is already suspended`);
return;
}

await Promise.all(
account.addresses.map((a) =>
this.provider.suspendAccount(a.providerExternalId),
),
);

await this.accounts.suspend(account.id);
this.logger.log(`Suspended account for user '${userId}'`);
//TODO: add audit table to keep track of this event

Check warning on line 396 in src/modules/account/account.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=internxt_mail-server&issues=AZ7hyFU-b_fY7CiAb1Nh&open=AZ7hyFU-b_fY7CiAb1Nh&pullRequest=69
}

async reactivateAccount(userId: string): Promise<void> {
const account = await this.getAccountOrFail(userId);
if (!account.isSuspended) {
this.logger.log(`Account for user '${userId}' is already active`);
return;
}

await Promise.all(
account.addresses.map((a) =>
this.provider.reactivateAccount(a.providerExternalId),
),
);

await this.accounts.reactivate(account.id);
this.logger.log(`Reactivated account for user '${userId}'`);
}
}
26 changes: 26 additions & 0 deletions src/modules/account/repositories/account.repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,30 @@ describe('AccountRepository', () => {
});
});
});

describe('suspend', () => {
it('when given an id, then sets status suspended and suspendedAt', async () => {
await repository.suspend('acc-1');

expect(accountModel.update).toHaveBeenCalledWith(
{
status: MailAccountState.Suspended,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
suspendedAt: expect.any(Date),
},
{ where: { id: 'acc-1' } },
);
});
});

describe('reactivate', () => {
it('when given an id, then sets status active and clears suspendedAt', async () => {
await repository.reactivate('acc-1');

expect(accountModel.update).toHaveBeenCalledWith(
{ status: MailAccountState.Active, suspendedAt: null },
{ where: { id: 'acc-1' } },
);
});
});
});
14 changes: 14 additions & 0 deletions src/modules/account/repositories/account.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ export class AccountRepository {
await this.accountModel.destroy({ where: { id } });
}

async suspend(id: string): Promise<void> {
await this.accountModel.update(
{ status: MailAccountState.Suspended, suspendedAt: new Date() },
{ where: { id } },
);
}

async reactivate(id: string): Promise<void> {
await this.accountModel.update(
{ status: MailAccountState.Active, suspendedAt: null },
{ where: { id } },
);
}

private toDomain(model: MailAccountModel): MailAccount {
return MailAccount.build({
id: model.id,
Expand Down
20 changes: 20 additions & 0 deletions src/modules/gateway/gateway.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,24 @@ describe('GatewayController', () => {
).rejects.toThrow(NotFoundException);
});
});

describe('suspendAccount', () => {
it('when called, then delegates to the account service', async () => {
const uuid = randomUUID();

await controller.suspendAccount(uuid);

expect(accountService.suspendAccount).toHaveBeenCalledWith(uuid);
});
});

describe('reactivateAccount', () => {
it('when called, then delegates to the account service', async () => {
const uuid = randomUUID();

await controller.reactivateAccount(uuid);

expect(accountService.reactivateAccount).toHaveBeenCalledWith(uuid);
});
});
});
23 changes: 18 additions & 5 deletions src/modules/gateway/gateway.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiNotFoundResponse,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { Public } from '../auth/decorators/public.decorator.js';
import { AccountService } from '../account/account.service.js';
import { GatewayAuthGuard } from './gateway.guard.js';
Expand Down Expand Up @@ -38,15 +45,21 @@ export class GatewayController {

@Post('accounts/:uuid/suspend')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiParam({ name: 'uuid', description: 'The UUID of the account' })
@ApiResponse({ status: HttpStatus.NO_CONTENT })
@ApiNotFoundResponse({ description: 'Account not found' })
@ApiOperation({ summary: 'Suspend a mail account' })
async suspendAccount(@Param('uuid') _uuid: string) {
// mark as frozen and suspend account in Stalwart
async suspendAccount(@Param('uuid') uuid: string) {
await this.accountService.suspendAccount(uuid);
}

@Post('accounts/:uuid/reactivate')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiParam({ name: 'uuid', description: 'The UUID of the account' })
@ApiResponse({ status: HttpStatus.NO_CONTENT })
@ApiNotFoundResponse({ description: 'Account not found' })
@ApiOperation({ summary: 'Reactivate a mail account' })
async reactivateAccount(@Param('uuid') _uuid: string) {
// unmark as frozen and reactivate account in Stalwart
async reactivateAccount(@Param('uuid') uuid: string) {
await this.accountService.reactivateAccount(uuid);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,26 @@ describe('StalwartAccountProvider', () => {
});
});

describe('suspendAccount', () => {
it('when called, then delegates to stalwart service', async () => {
await provider.suspendAccount('[email protected]');

expect(stalwart.suspendAccountByEmail).toHaveBeenCalledWith(
'[email protected]',
);
});
});

describe('reactivateAccount', () => {
it('when called, then delegates to stalwart service', async () => {
await provider.reactivateAccount('[email protected]');

expect(stalwart.reactivateAccountByEmail).toHaveBeenCalledWith(
'[email protected]',
);
});
});

describe('getAccount', () => {
it('when account exists, then returns AccountInfo with full email as name', async () => {
stalwart.getAccountByEmail.mockResolvedValue({
Expand Down
10 changes: 10 additions & 0 deletions src/modules/infrastructure/stalwart/stalwart-account.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ export class StalwartAccountProvider extends AccountProvider {
this.logger.log(`Deleted account '${email}'`);
}

async suspendAccount(email: string): Promise<void> {
await this.stalwart.suspendAccountByEmail(email);
this.logger.log(`Suspended account '${email}'`);
}

async reactivateAccount(email: string): Promise<void> {
await this.stalwart.reactivateAccountByEmail(email);
this.logger.log(`Reactivated account '${email}'`);
}

async getAccount(email: string): Promise<AccountInfo | null> {
const account = await this.stalwart.getAccountByEmail(email);
if (!account) return null;
Expand Down
Loading
Loading