From 4798222e675896e58b9b6aec182b4f32eaeceed7 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:11:55 -0600 Subject: [PATCH 1/2] feat: add quota management to email and account providers - Introduced `updateQuota` method in `StalwartAccountProvider` for updating user quotas. - Added `getQuota` method in `EmailService` and `JmapMailProvider` to retrieve user quota information. - Defined `MailQuota` interface to standardize quota data structure. - Updated related tests to ensure proper functionality of quota management features. --- src/modules/account/account-provider.port.ts | 1 + src/modules/email/email.service.ts | 5 +++ src/modules/email/email.types.ts | 5 +++ src/modules/email/mail-provider.port.ts | 2 ++ .../jmap/jmap-mail.provider.spec.ts | 31 +++++++++++++++++++ .../infrastructure/jmap/jmap-mail.provider.ts | 22 ++++++++++++- .../infrastructure/jmap/jmap.service.ts | 6 ++++ src/modules/infrastructure/jmap/jmap.types.ts | 14 +++++++++ .../stalwart-account.provider.spec.ts | 18 +++++++++++ .../stalwart/stalwart-account.provider.ts | 7 +++++ test/fixtures.ts | 22 +++++++++++++ 11 files changed, 132 insertions(+), 1 deletion(-) diff --git a/src/modules/account/account-provider.port.ts b/src/modules/account/account-provider.port.ts index 1431179..3c53a7e 100644 --- a/src/modules/account/account-provider.port.ts +++ b/src/modules/account/account-provider.port.ts @@ -4,4 +4,5 @@ export abstract class AccountProvider { abstract createAccount(params: CreateAccountParams): Promise; abstract deleteAccount(name: string): Promise; abstract getAccount(name: string): Promise; + abstract updateQuota(name: string, quotaBytes: number): Promise; } diff --git a/src/modules/email/email.service.ts b/src/modules/email/email.service.ts index e33e639..8e9742f 100644 --- a/src/modules/email/email.service.ts +++ b/src/modules/email/email.service.ts @@ -11,6 +11,7 @@ import type { EmailListResponse, EmailSummary, ListEmails, + MailQuota, Mailbox, MailboxType, SearchEmailDto, @@ -132,4 +133,8 @@ export class EmailService { ): Promise { return this.mail.markAsFlagged(userEmail, id, flagged); } + + getQuota(userEmail: string): Promise { + return this.mail.getQuota(userEmail); + } } diff --git a/src/modules/email/email.types.ts b/src/modules/email/email.types.ts index 0cc7532..07a45ca 100644 --- a/src/modules/email/email.types.ts +++ b/src/modules/email/email.types.ts @@ -113,3 +113,8 @@ export interface SearchEmailFilter { unread?: boolean; hasAttachment?: boolean; } + +export interface MailQuota { + used: number; + limit: number; +} diff --git a/src/modules/email/mail-provider.port.ts b/src/modules/email/mail-provider.port.ts index 9c0c93e..d01b444 100644 --- a/src/modules/email/mail-provider.port.ts +++ b/src/modules/email/mail-provider.port.ts @@ -3,6 +3,7 @@ import type { Email, EmailListResponse, ListEmails, + MailQuota, Mailbox, MailboxType, SearchEmailDto, @@ -42,4 +43,5 @@ export abstract class MailProvider { id: string, flagged: boolean, ): Promise; + abstract getQuota(userEmail: string): Promise; } diff --git a/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts b/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts index 2df62f6..1ae80d0 100644 --- a/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts +++ b/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts @@ -7,6 +7,7 @@ import { newJmapMailbox, newJmapEmail, newJmapIdentity, + newJmapQuota, newSendEmailDto, newDraftEmailDto, } from '../../../../test/fixtures.js'; @@ -536,4 +537,34 @@ describe('JmapMailProvider', () => { expect(update['email-1']).toEqual({ 'keywords/$flagged': true }); }); }); + + describe('getQuota', () => { + it('when quota exists, then returns used and limit from octets quota', async () => { + const quota = newJmapQuota({ used: 500_000, hardLimit: 1_000_000 }); + jmapService.request.mockResolvedValue(jmapResponse({ list: [quota] })); + + const result = await provider.getQuota('user@test.com'); + + expect(result).toEqual({ used: 500_000, limit: 1_000_000 }); + }); + + it('when no octets quota exists, then returns zeros', async () => { + const nonOctetsQuota = newJmapQuota({ resourceType: 'count' }); + jmapService.request.mockResolvedValue( + jmapResponse({ list: [nonOctetsQuota] }), + ); + + const result = await provider.getQuota('user@test.com'); + + expect(result).toEqual({ used: 0, limit: 0 }); + }); + + it('when quota list is empty, then returns zeros', async () => { + jmapService.request.mockResolvedValue(jmapResponse({ list: [] })); + + const result = await provider.getQuota('user@test.com'); + + expect(result).toEqual({ used: 0, limit: 0 }); + }); + }); }); diff --git a/src/modules/infrastructure/jmap/jmap-mail.provider.ts b/src/modules/infrastructure/jmap/jmap-mail.provider.ts index 3264fdc..b7734a0 100644 --- a/src/modules/infrastructure/jmap/jmap-mail.provider.ts +++ b/src/modules/infrastructure/jmap/jmap-mail.provider.ts @@ -5,15 +5,17 @@ import type { Email, EmailListResponse, ListEmails, + MailQuota, Mailbox, MailboxType, SearchEmailFilter, SendEmailDto, } from '../../email/email.types.js'; -import { JmapService } from './jmap.service.js'; +import { JMAP_QUOTA_CAPABILITIES, JmapService } from './jmap.service.js'; import type { Email as JmapEmail, Identity, + JmapQuota, Mailbox as JmapMailbox, JmapGetResponse, JmapQueryResponse, @@ -417,6 +419,24 @@ export class JmapMailProvider extends MailProvider { return this.setKeyword(userEmail, id, '$flagged', flagged); } + async getQuota(userEmail: string): Promise { + const accountId = await this.jmap.getPrimaryAccountId(userEmail); + + const response = await this.jmap.request>( + userEmail, + [['Quota/get', { accountId }, 'r0']], + JMAP_QUOTA_CAPABILITIES, + ); + + const quotas = response.methodResponses[0]![1].list; + const bytesQuota = quotas.find((q) => q.resourceType === 'octets'); + + return { + used: bytesQuota?.used ?? 0, + limit: bytesQuota?.hardLimit ?? 0, + }; + } + private async setKeyword( userEmail: string, id: string, diff --git a/src/modules/infrastructure/jmap/jmap.service.ts b/src/modules/infrastructure/jmap/jmap.service.ts index 7bcce34..cdcd125 100644 --- a/src/modules/infrastructure/jmap/jmap.service.ts +++ b/src/modules/infrastructure/jmap/jmap.service.ts @@ -18,6 +18,7 @@ import type { const JMAP_CAPABILITY_CORE = 'urn:ietf:params:jmap:core'; const JMAP_CAPABILITY_MAIL = 'urn:ietf:params:jmap:mail'; const JMAP_CAPABILITY_SUBMISSION = 'urn:ietf:params:jmap:submission'; +export const JMAP_CAPABILITY_QUOTA = 'urn:ietf:params:jmap:quota'; const JMAP_MAIL_CAPABILITIES = [ JMAP_CAPABILITY_CORE, @@ -25,6 +26,11 @@ const JMAP_MAIL_CAPABILITIES = [ JMAP_CAPABILITY_SUBMISSION, ] as const; +export const JMAP_QUOTA_CAPABILITIES = [ + JMAP_CAPABILITY_CORE, + JMAP_CAPABILITY_QUOTA, +] as const; + const SESSION_TTL_MS = 30_000; interface CachedSession { diff --git a/src/modules/infrastructure/jmap/jmap.types.ts b/src/modules/infrastructure/jmap/jmap.types.ts index f51ddcf..8766c0e 100644 --- a/src/modules/infrastructure/jmap/jmap.types.ts +++ b/src/modules/infrastructure/jmap/jmap.types.ts @@ -212,3 +212,17 @@ export interface DeliveryStatus { delivered: 'queued' | 'yes' | 'no' | 'unknown'; displayed: 'yes' | 'unknown'; } + +// ── Quota (RFC 9245) ─────────────────────────────────────────────── + +export interface JmapQuota { + id: ID; + resourceType: string; + used: number; + hardLimit: number; + scope: string; + name: string; + description?: string; + warnLimit?: number; + softLimit?: number; +} diff --git a/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts b/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts index 2831ad4..721e11d 100644 --- a/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts +++ b/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts @@ -125,4 +125,22 @@ describe('StalwartAccountProvider', () => { }); }); }); + + describe('updateQuota', () => { + it('when called, then patches principal quota field', async () => { + await provider.updateQuota('user@example.com', 5_000_000); + + expect(stalwart.patchPrincipal).toHaveBeenCalledWith('user@example.com', [ + { action: 'set', field: 'quota', value: 5_000_000 }, + ]); + }); + + it('when called with zero, then sets unlimited quota', async () => { + await provider.updateQuota('user@example.com', 0); + + expect(stalwart.patchPrincipal).toHaveBeenCalledWith('user@example.com', [ + { action: 'set', field: 'quota', value: 0 }, + ]); + }); + }); }); diff --git a/src/modules/infrastructure/stalwart/stalwart-account.provider.ts b/src/modules/infrastructure/stalwart/stalwart-account.provider.ts index 5e2fe4c..7078a3c 100644 --- a/src/modules/infrastructure/stalwart/stalwart-account.provider.ts +++ b/src/modules/infrastructure/stalwart/stalwart-account.provider.ts @@ -55,4 +55,11 @@ export class StalwartAccountProvider extends AccountProvider { quota: account.quotas?.maxDiskQuota ?? 0, }; } + + async updateQuota(name: string, quotaBytes: number): Promise { + await this.stalwart.patchPrincipal(name, [ + { action: 'set', field: 'quota', value: quotaBytes }, + ]); + this.logger.log(`Updated quota for '${name}' to ${quotaBytes} bytes`); + } } diff --git a/test/fixtures.ts b/test/fixtures.ts index 05de77c..0173e17 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -33,8 +33,10 @@ import type { EmailAddress as JmapEmailAddress, MailboxRole, Identity, + JmapQuota, } from '../src/modules/infrastructure/jmap/jmap.types.js'; import type { DeepMocked } from '@golevelup/ts-vitest'; +import type { MailQuota } from '../src/modules/email/email.types.js'; export type DeepPartial = T extends Array @@ -378,3 +380,23 @@ export function newSearchEmailDto( ...attrs, }; } + +export function newJmapQuota(attrs?: Partial): JmapQuota { + return { + id: randomId(), + resourceType: 'octets', + used: random.natural({ min: 0, max: 1_000_000_000 }), + hardLimit: random.natural({ min: 1_000_000_000, max: 10_000_000_000 }), + scope: 'account', + name: 'Mail storage', + ...attrs, + }; +} + +export function newMailQuota(attrs?: Partial): MailQuota { + return { + used: random.natural({ min: 0, max: 1_000_000_000 }), + limit: random.natural({ min: 1_000_000_000, max: 10_000_000_000 }), + ...attrs, + }; +} From 0e7aeb59c9ff6c434b9c550159f15b3105e68613 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:37:30 -0600 Subject: [PATCH 2/2] feat: implement quota management in GatewayController and related services - Added `updateQuota` and `getQuota` endpoints in `GatewayController` to manage user quotas. - Implemented `updateQuota` method in `AccountService` to update quotas based on user ID. - Introduced `getQuotaByUuid` method in `EmailService` to retrieve quota information for a user. - Enhanced tests for `GatewayController`, `EmailService`, and `AccountService` to cover new quota management functionality. --- src/modules/account/account.service.spec.ts | 39 ++++++++++++++++ src/modules/account/account.service.ts | 13 ++++++ src/modules/email/email.module.ts | 1 + src/modules/email/email.service.spec.ts | 44 ++++++++++++++++++ src/modules/email/email.service.ts | 10 +++- .../gateway/gateway.controller.spec.ts | 46 +++++++++++++++++++ src/modules/gateway/gateway.controller.ts | 31 ++++++++++++- src/modules/gateway/gateway.module.ts | 3 +- 8 files changed, 183 insertions(+), 4 deletions(-) diff --git a/src/modules/account/account.service.spec.ts b/src/modules/account/account.service.spec.ts index 0161e1f..f2581f0 100644 --- a/src/modules/account/account.service.spec.ts +++ b/src/modules/account/account.service.spec.ts @@ -790,4 +790,43 @@ describe('AccountService', () => { ).rejects.toThrow(NotFoundException); }); }); + + describe('updateQuota', () => { + it('when account has a default address, then calls provider with provider external id', async () => { + const addressAttrs = newMailAddressAttributes({ + isDefault: true, + providerExternalId: 'alice@internxt.me', + }); + const account = MailAccount.build( + newMailAccountAttributes({ addresses: [addressAttrs] }), + ); + accounts.findByUserId.mockResolvedValue(account); + + await service.updateQuota(account.userId, 5368709120); + + expect(provider.updateQuota).toHaveBeenCalledWith( + 'alice@internxt.me', + 5368709120, + ); + }); + + it('when account has no default address, then throws NotFoundException', async () => { + const account = MailAccount.build( + newMailAccountAttributes({ addresses: [] }), + ); + accounts.findByUserId.mockResolvedValue(account); + + await expect(service.updateQuota(account.userId, 0)).rejects.toThrow( + NotFoundException, + ); + }); + + it('when account does not exist, then throws NotFoundException', async () => { + accounts.findByUserId.mockResolvedValue(null); + + await expect(service.updateQuota('unknown', 0)).rejects.toThrow( + NotFoundException, + ); + }); + }); }); diff --git a/src/modules/account/account.service.ts b/src/modules/account/account.service.ts index 7e0cee5..bcf2c62 100644 --- a/src/modules/account/account.service.ts +++ b/src/modules/account/account.service.ts @@ -217,6 +217,19 @@ export class AccountService { return this.getAccountOrFail(params.userId); } + async updateQuota(userId: string, quotaBytes: number): Promise { + const account = await this.getAccountOrFail(userId); + + if (!account.defaultAddress) { + throw new NotFoundException(`No default address for account '${userId}'`); + } + + await this.provider.updateQuota( + account.defaultAddress.providerExternalId, + quotaBytes, + ); + } + async deleteAccount(driveUserUuid: string): Promise { const account = await this.getAccountOrFail(driveUserUuid); diff --git a/src/modules/email/email.module.ts b/src/modules/email/email.module.ts index 2cf5a3a..72ab264 100644 --- a/src/modules/email/email.module.ts +++ b/src/modules/email/email.module.ts @@ -9,5 +9,6 @@ import { Reflector } from '@nestjs/core'; imports: [JmapModule, ProvisioningModule], controllers: [EmailController], providers: [EmailService, Reflector], + exports: [EmailService], }) export class EmailModule {} diff --git a/src/modules/email/email.service.spec.ts b/src/modules/email/email.service.spec.ts index 392cf68..f7f11b7 100644 --- a/src/modules/email/email.service.spec.ts +++ b/src/modules/email/email.service.spec.ts @@ -14,7 +14,11 @@ import { newSearchEmailDto, newEncryptionBlock, newEncryptedWrappedKey, + newMailQuota, + newMailAccountAttributes, + newMailAddressAttributes, } from '../../../test/fixtures.js'; +import { MailAccount } from '../account/domain/mail-account.domain.js'; import { ENCRYPTED_PREFIX, packEnvelope } from './email-encryption.js'; describe('EmailService', () => { @@ -362,4 +366,44 @@ describe('EmailService', () => { ); }); }); + + describe('getQuotaByUuid', () => { + it('when account exists with a default address, then returns quota for that address', async () => { + const addressAttrs = newMailAddressAttributes({ + isDefault: true, + address: 'alice@internxt.me', + }); + const account = MailAccount.build( + newMailAccountAttributes({ addresses: [addressAttrs] }), + ); + const quota = newMailQuota({ used: 512, limit: 1073741824 }); + accountService.findAccount.mockResolvedValue(account); + provider.getQuota.mockResolvedValue(quota); + + const result = await service.getQuotaByUuid(account.userId); + + expect(accountService.findAccount).toHaveBeenCalledWith(account.userId); + expect(provider.getQuota).toHaveBeenCalledWith('alice@internxt.me'); + expect(result).toEqual(quota); + }); + + it('when account does not exist, then throws NotFoundException', async () => { + accountService.findAccount.mockResolvedValue(null); + + await expect(service.getQuotaByUuid('unknown')).rejects.toThrow( + NotFoundException, + ); + }); + + it('when account has no default address, then throws NotFoundException', async () => { + const account = MailAccount.build( + newMailAccountAttributes({ addresses: [] }), + ); + accountService.findAccount.mockResolvedValue(account); + + await expect(service.getQuotaByUuid(account.userId)).rejects.toThrow( + NotFoundException, + ); + }); + }); }); diff --git a/src/modules/email/email.service.ts b/src/modules/email/email.service.ts index 8e9742f..400575a 100644 --- a/src/modules/email/email.service.ts +++ b/src/modules/email/email.service.ts @@ -134,7 +134,13 @@ export class EmailService { return this.mail.markAsFlagged(userEmail, id, flagged); } - getQuota(userEmail: string): Promise { - return this.mail.getQuota(userEmail); + async getQuotaByUuid(userId: string): Promise { + const account = await this.accountService.findAccount(userId); + + if (!account?.defaultAddress) { + throw new NotFoundException(`No mail account for user '${userId}'`); + } + + return this.mail.getQuota(account.defaultAddress.address); } } diff --git a/src/modules/gateway/gateway.controller.spec.ts b/src/modules/gateway/gateway.controller.spec.ts index a5c366f..1fff6a9 100644 --- a/src/modules/gateway/gateway.controller.spec.ts +++ b/src/modules/gateway/gateway.controller.spec.ts @@ -5,10 +5,13 @@ import { NotFoundException } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import { GatewayController } from './gateway.controller.js'; import { AccountService } from '../account/account.service.js'; +import { EmailService } from '../email/email.service.js'; +import { newMailQuota } from '../../../test/fixtures.js'; describe('GatewayController', () => { let controller: GatewayController; let accountService: DeepMocked; + let emailService: DeepMocked; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -19,6 +22,7 @@ describe('GatewayController', () => { controller = module.get(GatewayController); accountService = module.get(AccountService); + emailService = module.get(EmailService); }); describe('getAddress', () => { @@ -42,4 +46,46 @@ describe('GatewayController', () => { ).rejects.toThrow(NotFoundException); }); }); + + describe('getQuota', () => { + it('when account exists, then returns quota usage', async () => { + const uuid = randomUUID(); + const limit = 5368709120; + const quota = newMailQuota({ used: 1024, limit }); + emailService.getQuotaByUuid.mockResolvedValue(quota); + + const result = await controller.getQuota(uuid); + + expect(emailService.getQuotaByUuid).toHaveBeenCalledWith(uuid); + expect(result).toEqual(quota); + }); + + it('when account does not exist, then propagates NotFoundException', async () => { + emailService.getQuotaByUuid.mockRejectedValue(new NotFoundException()); + + await expect(controller.getQuota(randomUUID())).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('updateQuota', () => { + it('when account exists, then delegates to accountService', async () => { + const uuid = randomUUID(); + const quotaBytes = 5368709120; + accountService.updateQuota.mockResolvedValue(undefined); + + await controller.updateQuota(uuid, { quotaBytes }); + + expect(accountService.updateQuota).toHaveBeenCalledWith(uuid, quotaBytes); + }); + + it('when account does not exist, then propagates NotFoundException', async () => { + accountService.updateQuota.mockRejectedValue(new NotFoundException()); + + await expect( + controller.updateQuota(randomUUID(), { quotaBytes: 0 }), + ).rejects.toThrow(NotFoundException); + }); + }); }); diff --git a/src/modules/gateway/gateway.controller.ts b/src/modules/gateway/gateway.controller.ts index 2f51a2c..91b1814 100644 --- a/src/modules/gateway/gateway.controller.ts +++ b/src/modules/gateway/gateway.controller.ts @@ -1,4 +1,5 @@ import { + Body, Controller, Get, HttpCode, @@ -6,20 +7,32 @@ import { NotFoundException, Param, Post, + Put, UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { IsInt, Min } from 'class-validator'; import { Public } from '../auth/decorators/public.decorator.js'; import { AccountService } from '../account/account.service.js'; +import { EmailService } from '../email/email.service.js'; import { GatewayAuthGuard } from './gateway.guard.js'; +class UpdateQuotaDto { + @IsInt() + @Min(0) + quotaBytes!: number; +} + @ApiTags('Gateway') @ApiBearerAuth('gateway') @Public() @UseGuards(GatewayAuthGuard) @Controller('gateway') export class GatewayController { - constructor(private readonly accountService: AccountService) {} + constructor( + private readonly accountService: AccountService, + private readonly emailService: EmailService, + ) {} @Get('addresses/:address') @ApiOperation({ @@ -36,6 +49,22 @@ export class GatewayController { return { address: normalized, userId }; } + @Get('quota/:uuid') + @ApiOperation({ summary: 'Get mail quota usage for a user' }) + getQuota(@Param('uuid') uuid: string) { + return this.emailService.getQuotaByUuid(uuid); + } + + @Put('accounts/:uuid/quota') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Set mail quota for a user' }) + async updateQuota( + @Param('uuid') uuid: string, + @Body() dto: UpdateQuotaDto, + ): Promise { + await this.accountService.updateQuota(uuid, dto.quotaBytes); + } + @Post('accounts/:uuid/suspend') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Suspend a mail account' }) diff --git a/src/modules/gateway/gateway.module.ts b/src/modules/gateway/gateway.module.ts index dd6d73f..d1e7e88 100644 --- a/src/modules/gateway/gateway.module.ts +++ b/src/modules/gateway/gateway.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; import { AccountModule } from '../account/account.module.js'; +import { EmailModule } from '../email/email.module.js'; import { GatewayJwtStrategy } from './gateway-jwt.strategy.js'; import { GatewayAuthGuard } from './gateway.guard.js'; import { GatewayController } from './gateway.controller.js'; @Module({ - imports: [PassportModule, AccountModule], + imports: [PassportModule, AccountModule, EmailModule], controllers: [GatewayController], providers: [GatewayJwtStrategy, GatewayAuthGuard], })