diff --git a/src/modules/account/account-provider.port.ts b/src/modules/account/account-provider.port.ts index 1431179..937b265 100644 --- a/src/modules/account/account-provider.port.ts +++ b/src/modules/account/account-provider.port.ts @@ -4,4 +4,6 @@ export abstract class AccountProvider { abstract createAccount(params: CreateAccountParams): Promise; abstract deleteAccount(name: string): Promise; abstract getAccount(name: string): Promise; + abstract suspendAccount(name: string): Promise; + abstract reactivateAccount(name: string): Promise; } diff --git a/src/modules/account/account.service.spec.ts b/src/modules/account/account.service.spec.ts index 0161e1f..57215ec 100644 --- a/src/modules/account/account.service.spec.ts +++ b/src/modules/account/account.service.spec.ts @@ -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(); diff --git a/src/modules/account/account.service.ts b/src/modules/account/account.service.ts index 7e0cee5..4029b82 100644 --- a/src/modules/account/account.service.ts +++ b/src/modules/account/account.service.ts @@ -377,4 +377,39 @@ export class AccountService { } return account; } + + async suspendAccount(userId: string): Promise { + 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 + } + + async reactivateAccount(userId: string): Promise { + 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}'`); + } } diff --git a/src/modules/account/repositories/account.repository.spec.ts b/src/modules/account/repositories/account.repository.spec.ts index 4572034..e1cf895 100644 --- a/src/modules/account/repositories/account.repository.spec.ts +++ b/src/modules/account/repositories/account.repository.spec.ts @@ -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' } }, + ); + }); + }); }); diff --git a/src/modules/account/repositories/account.repository.ts b/src/modules/account/repositories/account.repository.ts index 5b71339..f8a0756 100644 --- a/src/modules/account/repositories/account.repository.ts +++ b/src/modules/account/repositories/account.repository.ts @@ -46,6 +46,20 @@ export class AccountRepository { await this.accountModel.destroy({ where: { id } }); } + async suspend(id: string): Promise { + await this.accountModel.update( + { status: MailAccountState.Suspended, suspendedAt: new Date() }, + { where: { id } }, + ); + } + + async reactivate(id: string): Promise { + await this.accountModel.update( + { status: MailAccountState.Active, suspendedAt: null }, + { where: { id } }, + ); + } + private toDomain(model: MailAccountModel): MailAccount { return MailAccount.build({ id: model.id, diff --git a/src/modules/gateway/gateway.controller.spec.ts b/src/modules/gateway/gateway.controller.spec.ts index a5c366f..de7775d 100644 --- a/src/modules/gateway/gateway.controller.spec.ts +++ b/src/modules/gateway/gateway.controller.spec.ts @@ -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); + }); + }); }); diff --git a/src/modules/gateway/gateway.controller.ts b/src/modules/gateway/gateway.controller.ts index 2f51a2c..0f662d0 100644 --- a/src/modules/gateway/gateway.controller.ts +++ b/src/modules/gateway/gateway.controller.ts @@ -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'; @@ -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); } } diff --git a/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts b/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts index 2831ad4..2f662de 100644 --- a/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts +++ b/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts @@ -76,6 +76,26 @@ describe('StalwartAccountProvider', () => { }); }); + describe('suspendAccount', () => { + it('when called, then delegates to stalwart service', async () => { + await provider.suspendAccount('user@example.com'); + + expect(stalwart.suspendAccountByEmail).toHaveBeenCalledWith( + 'user@example.com', + ); + }); + }); + + describe('reactivateAccount', () => { + it('when called, then delegates to stalwart service', async () => { + await provider.reactivateAccount('user@example.com'); + + expect(stalwart.reactivateAccountByEmail).toHaveBeenCalledWith( + 'user@example.com', + ); + }); + }); + describe('getAccount', () => { it('when account exists, then returns AccountInfo with full email as name', async () => { stalwart.getAccountByEmail.mockResolvedValue({ diff --git a/src/modules/infrastructure/stalwart/stalwart-account.provider.ts b/src/modules/infrastructure/stalwart/stalwart-account.provider.ts index 5e2fe4c..d2327f2 100644 --- a/src/modules/infrastructure/stalwart/stalwart-account.provider.ts +++ b/src/modules/infrastructure/stalwart/stalwart-account.provider.ts @@ -44,6 +44,16 @@ export class StalwartAccountProvider extends AccountProvider { this.logger.log(`Deleted account '${email}'`); } + async suspendAccount(email: string): Promise { + await this.stalwart.suspendAccountByEmail(email); + this.logger.log(`Suspended account '${email}'`); + } + + async reactivateAccount(email: string): Promise { + await this.stalwart.reactivateAccountByEmail(email); + this.logger.log(`Reactivated account '${email}'`); + } + async getAccount(email: string): Promise { const account = await this.stalwart.getAccountByEmail(email); if (!account) return null; diff --git a/src/modules/infrastructure/stalwart/stalwart.service.spec.ts b/src/modules/infrastructure/stalwart/stalwart.service.spec.ts index 149d6f3..42d1846 100644 --- a/src/modules/infrastructure/stalwart/stalwart.service.spec.ts +++ b/src/modules/infrastructure/stalwart/stalwart.service.spec.ts @@ -69,6 +69,8 @@ function setResp( patch: { created?: Record | null; notCreated?: Record | null; + updated?: Record | null; + notUpdated?: Record | null; destroyed?: string[] | null; notDestroyed?: Record | null; }, @@ -82,9 +84,9 @@ function setResp( newState: 's', created: patch.created ?? null, notCreated: patch.notCreated ?? null, - updated: null, + updated: patch.updated ?? null, destroyed: patch.destroyed ?? null, - notUpdated: null, + notUpdated: patch.notUpdated ?? null, notDestroyed: patch.notDestroyed ?? null, }, callId, @@ -330,6 +332,116 @@ describe('StalwartService', () => { }); }); + describe('suspendAccountByEmail', () => { + const accountBatch = () => + jmapResponse([ + queryResp('x:Account/query', ['acc1']), + getResp('x:Account/get', [ + { + id: 'acc1', + '@type': 'User', + name: 'alice', + emailAddress: 'alice@test.com', + domainId: 'dom1', + }, + ]), + ]); + + it('when account exists, then disables receive and send permissions', async () => { + mockRequest + .mockResolvedValueOnce(DOMAIN_BATCH_HIT) + .mockResolvedValueOnce(accountBatch()) + .mockResolvedValueOnce( + jmapResponse([setResp('x:Account/set', { updated: { acc1: null } })]), + ); + + await expect( + service.suspendAccountByEmail('alice@test.com'), + ).resolves.toBeUndefined(); + + const setCall = bodyOf(2).methodCalls[0]!; + expect(setCall[0]).toBe('x:Account/set'); + expect(setCall[1]).toEqual({ + update: { + acc1: { + 'disabledPermissions/email-receive': true, + 'disabledPermissions/email-send': true, + }, + }, + }); + }); + + it('when account not found, then throws StalwartApiError', async () => { + mockRequest + .mockResolvedValueOnce(DOMAIN_BATCH_HIT) + .mockResolvedValueOnce( + jmapResponse([ + queryResp('x:Account/query', []), + getResp('x:Account/get', []), + ]), + ); + + await expect( + service.suspendAccountByEmail('ghost@test.com'), + ).rejects.toThrow(StalwartApiError); + }); + + it('when JMAP reports notUpdated, then throws StalwartApiError', async () => { + mockRequest + .mockResolvedValueOnce(DOMAIN_BATCH_HIT) + .mockResolvedValueOnce(accountBatch()) + .mockResolvedValueOnce( + jmapResponse([ + setResp('x:Account/set', { + notUpdated: { acc1: { type: 'forbidden' } }, + }), + ]), + ); + + await expect( + service.suspendAccountByEmail('alice@test.com'), + ).rejects.toThrow(StalwartApiError); + }); + }); + + describe('reactivateAccountByEmail', () => { + it('when account exists, then removes the disabled receive and send permissions', async () => { + mockRequest + .mockResolvedValueOnce(DOMAIN_BATCH_HIT) + .mockResolvedValueOnce( + jmapResponse([ + queryResp('x:Account/query', ['acc1']), + getResp('x:Account/get', [ + { + id: 'acc1', + '@type': 'User', + name: 'alice', + emailAddress: 'alice@test.com', + domainId: 'dom1', + }, + ]), + ]), + ) + .mockResolvedValueOnce( + jmapResponse([setResp('x:Account/set', { updated: { acc1: null } })]), + ); + + await expect( + service.reactivateAccountByEmail('alice@test.com'), + ).resolves.toBeUndefined(); + + const setCall = bodyOf(2).methodCalls[0]!; + expect(setCall[1]).toEqual({ + update: { + acc1: { + 'disabledPermissions/email-receive': null, + 'disabledPermissions/email-send': null, + }, + }, + }); + }); + }); + describe('resolveDomainId', () => { it('when domain matches, then caches and returns the id in one batched call', async () => { mockRequest.mockResolvedValueOnce(DOMAIN_BATCH_HIT); diff --git a/src/modules/infrastructure/stalwart/stalwart.service.ts b/src/modules/infrastructure/stalwart/stalwart.service.ts index 29ef891..4f108de 100644 --- a/src/modules/infrastructure/stalwart/stalwart.service.ts +++ b/src/modules/infrastructure/stalwart/stalwart.service.ts @@ -33,6 +33,8 @@ const TYPE_USER = 'User'; const TYPE_PASSWORD = 'Password'; const CREATE_REF = 'new1'; +const SUSPEND_PERMISSIONS = ['email-receive', 'email-send'] as const; + export interface StalwartAccountCreate { name: string; domainId: string; @@ -189,6 +191,39 @@ export class StalwartService implements OnModuleInit, OnModuleDestroy { } } + async suspendAccountByEmail(email: string): Promise { + await this.setSuspended(email, true); + } + + async reactivateAccountByEmail(email: string): Promise { + await this.setSuspended(email, false); + } + + private async setSuspended(email: string, suspended: boolean): Promise { + const account = await this.getAccountByEmail(email); + if (!account) { + throw new StalwartApiError(`Account '${email}' not found`, null); + } + + const value = suspended ? true : null; + const patch = Object.fromEntries( + SUSPEND_PERMISSIONS.map((p) => [`disabledPermissions/${p}`, value]), + ); + + const response = await this.jmapCall>([ + [JMAP_METHOD.ACCOUNT_SET, { update: { [account.id]: patch } }, 's1'], + ]); + const set = firstResponse(response); + + const failed = set.notUpdated?.[account.id]; + if (failed) { + throw new StalwartApiError( + `Failed to ${suspended ? 'suspend' : 'reactivate'} account '${email}': ${failed.type} ${failed.description}`, + failed, + ); + } + } + async resolveDomainId(domain: string): Promise { const cached = this.domainIdMap.get(domain); if (cached) return cached;