From d58f0d26a4ea2c6f61b356197ffbbbcc21105e6b 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/4] 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 f463bda..7f2bd22 100644 --- a/src/modules/account/account-provider.port.ts +++ b/src/modules/account/account-provider.port.ts @@ -10,4 +10,6 @@ export abstract class AccountProvider { ): 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 242db28..933f27c 100644 --- a/src/modules/account/account.service.spec.ts +++ b/src/modules/account/account.service.spec.ts @@ -627,6 +627,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 b877420..5276ece 100644 --- a/src/modules/account/account.service.ts +++ b/src/modules/account/account.service.ts @@ -463,4 +463,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 af4830f..f75411a 100644 --- a/src/modules/account/repositories/account.repository.spec.ts +++ b/src/modules/account/repositories/account.repository.spec.ts @@ -141,4 +141,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 1c759ba..50749af 100644 --- a/src/modules/account/repositories/account.repository.ts +++ b/src/modules/account/repositories/account.repository.ts @@ -50,6 +50,20 @@ export class AccountRepository { await this.accountModel.update({ networkBucketId }, { 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 7056013..e4a52a1 100644 --- a/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts +++ b/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts @@ -94,6 +94,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 1d7803d..58ac622 100644 --- a/src/modules/infrastructure/stalwart/stalwart-account.provider.ts +++ b/src/modules/infrastructure/stalwart/stalwart-account.provider.ts @@ -56,6 +56,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 b61aabce071d312d6a9907088e3b543b69edacca 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/4] 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'], From ca3d4ebf1c5357ba01cc91c0a7ba2344e6f932a4 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:49:23 +0200 Subject: [PATCH 3/4] feat: suspend account correctly by using Merge type --- .env.template | 1 + .../stalwart/stalwart.service.ts | 28 +++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/.env.template b/.env.template index 99c1b5f..a5fe692 100644 --- a/.env.template +++ b/.env.template @@ -22,6 +22,7 @@ STALWART_SMTP_PORT=587 # Auth JWT_SECRET= GATEWAY_PUBLIC_SECRET= +GATEWAY_PRIVATE_SECRET= # External APIs PAYMENTS_API_URL= diff --git a/src/modules/infrastructure/stalwart/stalwart.service.ts b/src/modules/infrastructure/stalwart/stalwart.service.ts index 4f108de..68d7e7b 100644 --- a/src/modules/infrastructure/stalwart/stalwart.service.ts +++ b/src/modules/infrastructure/stalwart/stalwart.service.ts @@ -33,7 +33,7 @@ const TYPE_USER = 'User'; const TYPE_PASSWORD = 'Password'; const CREATE_REF = 'new1'; -const SUSPEND_PERMISSIONS = ['email-receive', 'email-send'] as const; +const SUSPEND_PERMISSIONS = ['emailReceive', 'emailSend'] as const; export interface StalwartAccountCreate { name: string; @@ -205,16 +205,34 @@ export class StalwartService implements OnModuleInit, OnModuleDestroy { 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 disabledPermissions = Object.fromEntries( + SUSPEND_PERMISSIONS.map((p) => [p, true]), ); + const permissions = suspended + ? { + '@type': 'Merge', + enabledPermissions: {}, + disabledPermissions, + } + : { '@type': 'Inherit' }; const response = await this.jmapCall>([ - [JMAP_METHOD.ACCOUNT_SET, { update: { [account.id]: patch } }, 's1'], + [ + JMAP_METHOD.ACCOUNT_SET, + { + update: { + [account.id]: { + permissions, + }, + }, + }, + 's1', + ], ]); const set = firstResponse(response); + this.logger.debug(`[DEBUG] x:Account/set response: ${JSON.stringify(set)}`); + const failed = set.notUpdated?.[account.id]; if (failed) { throw new StalwartApiError( From 132064c5d201d4dec600407014d4ce6b217f98dc Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:59:53 -0600 Subject: [PATCH 4/4] refactor: update permissions structure in StalwartService tests --- .../stalwart/stalwart.service.spec.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/modules/infrastructure/stalwart/stalwart.service.spec.ts b/src/modules/infrastructure/stalwart/stalwart.service.spec.ts index 42d1846..10f6ce8 100644 --- a/src/modules/infrastructure/stalwart/stalwart.service.spec.ts +++ b/src/modules/infrastructure/stalwart/stalwart.service.spec.ts @@ -364,8 +364,14 @@ describe('StalwartService', () => { expect(setCall[1]).toEqual({ update: { acc1: { - 'disabledPermissions/email-receive': true, - 'disabledPermissions/email-send': true, + permissions: { + '@type': 'Merge', + enabledPermissions: {}, + disabledPermissions: { + emailReceive: true, + emailSend: true, + }, + }, }, }, }); @@ -434,8 +440,9 @@ describe('StalwartService', () => { expect(setCall[1]).toEqual({ update: { acc1: { - 'disabledPermissions/email-receive': null, - 'disabledPermissions/email-send': null, + permissions: { + '@type': 'Inherit', + }, }, }, });