Skip to content
Closed
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
1 change: 1 addition & 0 deletions src/modules/account/account-provider.port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export abstract class AccountProvider {
abstract createAccount(params: CreateAccountParams): Promise<void>;
abstract deleteAccount(name: string): Promise<void>;
abstract getAccount(name: string): Promise<AccountInfo | null>;
abstract updateQuota(name: string, quotaBytes: number): Promise<void>;
}
39 changes: 39 additions & 0 deletions src/modules/account/account.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
});
});
13 changes: 13 additions & 0 deletions src/modules/account/account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,19 @@ export class AccountService {
return this.getAccountOrFail(params.userId);
}

async updateQuota(userId: string, quotaBytes: number): Promise<void> {
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<void> {
const account = await this.getAccountOrFail(driveUserUuid);

Expand Down
1 change: 1 addition & 0 deletions src/modules/email/email.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ import { Reflector } from '@nestjs/core';
imports: [JmapModule, ProvisioningModule],
controllers: [EmailController],
providers: [EmailService, Reflector],
exports: [EmailService],
})
export class EmailModule {}
44 changes: 44 additions & 0 deletions src/modules/email/email.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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,
);
});
});
});
11 changes: 11 additions & 0 deletions src/modules/email/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
EmailListResponse,
EmailSummary,
ListEmails,
MailQuota,
Mailbox,
MailboxType,
SearchEmailDto,
Expand Down Expand Up @@ -132,4 +133,14 @@ export class EmailService {
): Promise<void> {
return this.mail.markAsFlagged(userEmail, id, flagged);
}

async getQuotaByUuid(userId: string): Promise<MailQuota> {
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);
}
}
5 changes: 5 additions & 0 deletions src/modules/email/email.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,8 @@ export interface SearchEmailFilter {
unread?: boolean;
hasAttachment?: boolean;
}

export interface MailQuota {
used: number;
limit: number;
}
2 changes: 2 additions & 0 deletions src/modules/email/mail-provider.port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
Email,
EmailListResponse,
ListEmails,
MailQuota,
Mailbox,
MailboxType,
SearchEmailDto,
Expand Down Expand Up @@ -42,4 +43,5 @@ export abstract class MailProvider {
id: string,
flagged: boolean,
): Promise<void>;
abstract getQuota(userEmail: string): Promise<MailQuota>;
}
46 changes: 46 additions & 0 deletions src/modules/gateway/gateway.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AccountService>;
let emailService: DeepMocked<EmailService>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
Expand All @@ -19,6 +22,7 @@ describe('GatewayController', () => {

controller = module.get(GatewayController);
accountService = module.get(AccountService);
emailService = module.get(EmailService);
});

describe('getAddress', () => {
Expand All @@ -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);
});
});
});
31 changes: 30 additions & 1 deletion src/modules/gateway/gateway.controller.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,38 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
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({
Expand All @@ -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<void> {
await this.accountService.updateQuota(uuid, dto.quotaBytes);
}

@Post('accounts/:uuid/suspend')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Suspend a mail account' })
Expand Down
3 changes: 2 additions & 1 deletion src/modules/gateway/gateway.module.ts
Original file line number Diff line number Diff line change
@@ -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],
})
Expand Down
31 changes: 31 additions & 0 deletions src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
newJmapMailbox,
newJmapEmail,
newJmapIdentity,
newJmapQuota,
newSendEmailDto,
newDraftEmailDto,
} from '../../../../test/fixtures.js';
Expand Down Expand Up @@ -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 });
});
});
});
Loading
Loading