From e122ee774412cb4b7cef0daced1905462dee7778 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:21:52 -0600 Subject: [PATCH 1/2] feat: add suspend and reactivate account functionality Implement suspendAccount and reactivateAccount methods in AccountService, along with corresponding methods in AccountProvider and StalwartAccountProvider. Add tests for these functionalities in account.service.spec.ts, account.repository.spec.ts, and stalwart.service.spec.ts. Update GatewayController to handle suspend and reactivate requests. --- src/modules/account/account-provider.port.ts | 2 + src/modules/account/account.service.spec.ts | 92 ++++++++++++++ src/modules/account/account.service.ts | 35 ++++++ .../repositories/account.repository.spec.ts | 26 ++++ .../repositories/account.repository.ts | 14 +++ .../gateway/gateway.controller.spec.ts | 20 +++ src/modules/gateway/gateway.controller.ts | 23 +++- .../stalwart-account.provider.spec.ts | 20 +++ .../stalwart/stalwart-account.provider.ts | 10 ++ .../stalwart/stalwart.service.spec.ts | 116 +++++++++++++++++- .../stalwart/stalwart.service.ts | 35 ++++++ 11 files changed, 386 insertions(+), 7 deletions(-) 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..2f91d7e 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 patch: Record = {}; + for (const permission of SUSPEND_PERMISSIONS) { + patch[`disabledPermissions/${permission}`] = suspended ? true : null; + } + + 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; From 705098ed044e5b15820877d69be11ec6b87c35c4 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:45:55 -0600 Subject: [PATCH 2/2] refactor: simplify patch creation for suspended permissions in StalwartService. --- src/modules/infrastructure/stalwart/stalwart.service.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/modules/infrastructure/stalwart/stalwart.service.ts b/src/modules/infrastructure/stalwart/stalwart.service.ts index 2f91d7e..4f108de 100644 --- a/src/modules/infrastructure/stalwart/stalwart.service.ts +++ b/src/modules/infrastructure/stalwart/stalwart.service.ts @@ -205,10 +205,10 @@ export class StalwartService implements OnModuleInit, OnModuleDestroy { throw new StalwartApiError(`Account '${email}' not found`, null); } - const patch: Record = {}; - for (const permission of SUSPEND_PERMISSIONS) { - patch[`disabledPermissions/${permission}`] = suspended ? true : 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'],