diff --git a/migrations/20260818231630-add-user-id-index-to-mail-accounts.js b/migrations/20260818231630-add-user-id-index-to-mail-accounts.js new file mode 100644 index 0000000..6d9c043 --- /dev/null +++ b/migrations/20260818231630-add-user-id-index-to-mail-accounts.js @@ -0,0 +1,19 @@ +'use strict'; + +const TABLE_NAME = 'mail_accounts'; +const INDEX_NAME = 'mail_accounts_user_id_idx'; + +/** + * @type {import('sequelize-cli').Migration} + */ +module.exports = { + async up(queryInterface) { + await queryInterface.addIndex(TABLE_NAME, ['user_id'], { + name: INDEX_NAME, + }); + }, + + async down(queryInterface) { + await queryInterface.removeIndex(TABLE_NAME, INDEX_NAME); + }, +}; diff --git a/src/modules/account/models/mail-account.model.ts b/src/modules/account/models/mail-account.model.ts index 503536f..2e9145c 100644 --- a/src/modules/account/models/mail-account.model.ts +++ b/src/modules/account/models/mail-account.model.ts @@ -22,6 +22,10 @@ import { MailAddressModel } from './mail-address.model.js'; fields: ['user_id'], where: { deleted_at: null }, }, + { + name: 'mail_accounts_user_id_idx', + fields: ['user_id'], + }, ], }) export class MailAccountModel extends Model { diff --git a/src/modules/email/email.service.spec.ts b/src/modules/email/email.service.spec.ts index d9562a5..7763248 100644 --- a/src/modules/email/email.service.spec.ts +++ b/src/modules/email/email.service.spec.ts @@ -14,6 +14,7 @@ import { EmailService } from './email.service.js'; import { DraftUpdateConflictError, MailProvider, + SendEmailFailedError, } from './mail-provider.port.js'; import { AccountService } from '../account/account.service.js'; import { MailUsageService } from '../usage/mail-usage.service.js'; @@ -997,6 +998,41 @@ describe('EmailService', () => { entryKey: '42:10', }); }); + + it('when sending fails after the draft was destroyed, then the draft quota entry is still released and the error propagates', async () => { + provider.sendEmail.mockRejectedValue(new SendEmailFailedError('42:11')); + + await expect( + service.sendEmail(userEmail, newSendEmailDto({ draftId: 'c' })), + ).rejects.toThrow(SendEmailFailedError); + + expect(usage.releaseStoredMessage).toHaveBeenCalledWith({ + userUuid: 'user-1', + bucketId: 'bucket-1', + entryKey: '42:11', + }); + }); + + it('when sending fails without destroying a draft, then no quota entry is released and the error propagates', async () => { + provider.sendEmail.mockRejectedValue(new SendEmailFailedError(null)); + + await expect( + service.sendEmail(userEmail, newSendEmailDto()), + ).rejects.toThrow(SendEmailFailedError); + + expect(usage.releaseStoredMessage).not.toHaveBeenCalled(); + }); + + it('when sending fails for an unrelated reason, then no quota entry is released and the error propagates unchanged', async () => { + const error = new Error('JMAP request timed out'); + provider.sendEmail.mockRejectedValue(error); + + await expect( + service.sendEmail(userEmail, newSendEmailDto({ draftId: 'c' })), + ).rejects.toThrow(error); + + expect(usage.releaseStoredMessage).not.toHaveBeenCalled(); + }); }); describe('markAsRead', () => { diff --git a/src/modules/email/email.service.ts b/src/modules/email/email.service.ts index e230f0e..3f9fbe4 100644 --- a/src/modules/email/email.service.ts +++ b/src/modules/email/email.service.ts @@ -12,6 +12,7 @@ import { MailUsageService } from '../usage/mail-usage.service.js'; import { DraftUpdateConflictError, MailProvider, + SendEmailFailedError, } from './mail-provider.port.js'; import { deriveReplyRecipients, ensureRePrefix } from './threading.js'; import type { @@ -231,15 +232,22 @@ export class EmailService { }; } - const { id, deletedEntryKey } = await this.mail.sendEmail( - userEmail, - dto, - threading, - ); + try { + const { id, deletedEntryKey } = await this.mail.sendEmail( + userEmail, + dto, + threading, + ); - await this.releaseQuotaEntry(userEmail, deletedEntryKey); + await this.releaseQuotaEntry(userEmail, deletedEntryKey); - return { id }; + return { id }; + } catch (error) { + if (error instanceof SendEmailFailedError) { + await this.releaseQuotaEntry(userEmail, error.deletedEntryKey); + } + throw error; + } } private async dispatchExternal( diff --git a/src/modules/email/mail-provider.port.ts b/src/modules/email/mail-provider.port.ts index 51dc7e3..32bf62d 100644 --- a/src/modules/email/mail-provider.port.ts +++ b/src/modules/email/mail-provider.port.ts @@ -14,6 +14,7 @@ import type { MailQuota, Mailbox, MailboxType, + QuotaEntryKey, SearchEmailDto, SendEmailDto, SendEmailResult, @@ -30,6 +31,15 @@ export class DraftUpdateConflictError extends Error { } } +export class SendEmailFailedError extends Error { + constructor(public readonly deletedEntryKey: QuotaEntryKey | null) { + super('Failed to create email for sending'); + this.name = 'SendEmailFailedError'; + + Object.setPrototypeOf(this, SendEmailFailedError.prototype); + } +} + export class MissingMessageIdError extends UnprocessableEntityException { constructor(parentId: string) { super(`Original email ${parentId} has no Message-ID; cannot thread reply`); diff --git a/src/modules/gateway/dto/account-usage.response.dto.ts b/src/modules/gateway/dto/account-usage.response.dto.ts new file mode 100644 index 0000000..f9fe968 --- /dev/null +++ b/src/modules/gateway/dto/account-usage.response.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AccountUsageResponseDto { + @ApiProperty({ example: 'f3a1b2c4-1234-4abc-9def-0123456789ab' }) + userId!: string; + + @ApiProperty({ + example: 5242880, + description: + 'Bytes of mail storage charged to this user against the shared plan ' + + 'counter. 0 when the user has no mail account.', + }) + usage!: number; +} diff --git a/src/modules/gateway/gateway.controller.spec.ts b/src/modules/gateway/gateway.controller.spec.ts index de7775d..bce80f4 100644 --- a/src/modules/gateway/gateway.controller.spec.ts +++ b/src/modules/gateway/gateway.controller.spec.ts @@ -5,10 +5,12 @@ import { NotFoundException } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import { GatewayController } from './gateway.controller.js'; import { AccountService } from '../account/account.service.js'; +import { MailUsageService } from '../usage/mail-usage.service.js'; describe('GatewayController', () => { let controller: GatewayController; let accountService: DeepMocked; + let mailUsageService: DeepMocked; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -19,6 +21,7 @@ describe('GatewayController', () => { controller = module.get(GatewayController); accountService = module.get(AccountService); + mailUsageService = module.get(MailUsageService); }); describe('getAddress', () => { @@ -43,6 +46,28 @@ describe('GatewayController', () => { }); }); + describe('getAccountUsage', () => { + it('when the user has mail stored, then returns the charged bytes', async () => { + const uuid = randomUUID(); + mailUsageService.getChargedBytes.mockResolvedValue(4096); + + const result = await controller.getAccountUsage(uuid); + + expect(mailUsageService.getChargedBytes).toHaveBeenCalledWith(uuid); + expect(result).toEqual({ userId: uuid, usage: 4096 }); + }); + + it('when the user has no mail account, then returns zero instead of 404', async () => { + const uuid = randomUUID(); + mailUsageService.getChargedBytes.mockResolvedValue(0); + + expect(await controller.getAccountUsage(uuid)).toEqual({ + userId: uuid, + usage: 0, + }); + }); + }); + describe('suspendAccount', () => { it('when called, then delegates to the account service', async () => { const uuid = randomUUID(); diff --git a/src/modules/gateway/gateway.controller.ts b/src/modules/gateway/gateway.controller.ts index 0f662d0..7fc780a 100644 --- a/src/modules/gateway/gateway.controller.ts +++ b/src/modules/gateway/gateway.controller.ts @@ -5,12 +5,14 @@ import { HttpStatus, NotFoundException, Param, + ParseUUIDPipe, Post, UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, ApiNotFoundResponse, + ApiOkResponse, ApiOperation, ApiParam, ApiResponse, @@ -18,6 +20,8 @@ import { } from '@nestjs/swagger'; import { Public } from '../auth/decorators/public.decorator.js'; import { AccountService } from '../account/account.service.js'; +import { MailUsageService } from '../usage/mail-usage.service.js'; +import { AccountUsageResponseDto } from './dto/account-usage.response.dto.js'; import { GatewayAuthGuard } from './gateway.guard.js'; @ApiTags('Gateway') @@ -26,7 +30,10 @@ import { GatewayAuthGuard } from './gateway.guard.js'; @UseGuards(GatewayAuthGuard) @Controller('gateway') export class GatewayController { - constructor(private readonly accountService: AccountService) {} + constructor( + private readonly accountService: AccountService, + private readonly mailUsageService: MailUsageService, + ) {} @Get('addresses/:address') @ApiOperation({ @@ -43,13 +50,31 @@ export class GatewayController { return { address: normalized, userId }; } + @Get('accounts/:uuid/usage') + @ApiParam({ name: 'uuid', description: 'The UUID of the account' }) + @ApiOkResponse({ type: AccountUsageResponseDto }) + @ApiOperation({ + summary: 'Get the mail storage charged to a user, in bytes', + description: + 'Reports the mail share of the shared plan counter, so callers can add ' + + 'it to their own usage and arrive at the same total that gates uploads ' + + 'and inbound delivery. Returns 0 for users without a mail account.', + }) + async getAccountUsage( + @Param('uuid', ParseUUIDPipe) uuid: string, + ): Promise { + const usage = await this.mailUsageService.getChargedBytes(uuid); + + return { userId: uuid, usage }; + } + @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) { + async suspendAccount(@Param('uuid', ParseUUIDPipe) uuid: string) { await this.accountService.suspendAccount(uuid); } @@ -59,7 +84,7 @@ export class GatewayController { @ApiResponse({ status: HttpStatus.NO_CONTENT }) @ApiNotFoundResponse({ description: 'Account not found' }) @ApiOperation({ summary: 'Reactivate a mail account' }) - async reactivateAccount(@Param('uuid') uuid: string) { + async reactivateAccount(@Param('uuid', ParseUUIDPipe) uuid: string) { await this.accountService.reactivateAccount(uuid); } } diff --git a/src/modules/gateway/gateway.module.ts b/src/modules/gateway/gateway.module.ts index dd6d73f..cd1e66b 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 { MailUsageModule } from '../usage/mail-usage.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, MailUsageModule], controllers: [GatewayController], providers: [GatewayJwtStrategy, GatewayAuthGuard], }) diff --git a/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts b/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts index 95e98bb..8ad02b4 100644 --- a/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts +++ b/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts @@ -385,7 +385,7 @@ describe('JmapMailProvider', () => { ); jmapService.request.mockResolvedValueOnce( jmapMultiResponse( - { created: { draft: { id: 'sent-email-id' } } }, + { created: { draft: { id: 'sent-email-id' } }, destroyed: ['c'] }, { created: { submission: { id: 'sub-id' } } }, ), ); @@ -404,6 +404,35 @@ describe('JmapMailProvider', () => { }); }); + test('When the draft is not reported as destroyed even though it is not in notDestroyed, then no quota entry key is returned so its usage is not released', async () => { + const sentMailbox = newJmapMailbox({ role: 'sent' }); + const identity = newJmapIdentity(); + jmapService.getPrimaryAccountId.mockResolvedValue('b'); + + jmapService.request.mockResolvedValueOnce( + jmapResponse({ list: [identity] }), + ); + jmapService.request.mockResolvedValueOnce( + jmapResponse({ list: [sentMailbox] }), + ); + jmapService.request.mockResolvedValueOnce( + jmapMultiResponse( + { created: { draft: { id: 'sent-email-id' } } }, + { created: { submission: { id: 'sub-id' } } }, + ), + ); + + const result = await provider.sendEmail( + 'user@test.com', + newSendEmailDto({ draftId: 'c' }), + ); + + expect(result).toEqual({ + id: 'sent-email-id', + deletedEntryKey: null, + }); + }); + test('When the draft could not be destroyed while sending, then no quota entry key is returned so its usage is not released', async () => { const sentMailbox = newJmapMailbox({ role: 'sent' }); const identity = newJmapIdentity(); @@ -436,6 +465,34 @@ describe('JmapMailProvider', () => { }); }); + test('When email creation fails after the draft was destroyed, then it throws carrying the deleted entry key so its usage can still be released', async () => { + const sentMailbox = newJmapMailbox({ role: 'sent' }); + const identity = newJmapIdentity(); + jmapService.getPrimaryAccountId.mockResolvedValue('b'); + + jmapService.request.mockResolvedValueOnce( + jmapResponse({ list: [identity] }), + ); + jmapService.request.mockResolvedValueOnce( + jmapResponse({ list: [sentMailbox] }), + ); + jmapService.request.mockResolvedValueOnce( + jmapMultiResponse( + { created: null, destroyed: ['c'] }, + { created: null }, + ), + ); + + const dto = newSendEmailDto({ draftId: 'c' }); + + await expect( + provider.sendEmail('user@test.com', dto), + ).rejects.toMatchObject({ + name: 'SendEmailFailedError', + deletedEntryKey: '1:2', + }); + }); + test('When sending without a draftId, then the Email/set call does not include any destroy operation', async () => { const sentMailbox = newJmapMailbox({ role: 'sent' }); const identity = newJmapIdentity(); diff --git a/src/modules/infrastructure/jmap/jmap-mail.provider.ts b/src/modules/infrastructure/jmap/jmap-mail.provider.ts index 77a9d39..d119040 100644 --- a/src/modules/infrastructure/jmap/jmap-mail.provider.ts +++ b/src/modules/infrastructure/jmap/jmap-mail.provider.ts @@ -3,6 +3,7 @@ import { DraftUpdateConflictError, MailProvider, MissingMessageIdError, + SendEmailFailedError, } from '../../email/mail-provider.port.js'; import type { DeleteEmailResult, @@ -478,15 +479,17 @@ export class JmapMailProvider extends MailProvider { .methodResponses[0]![1] as JmapSetResponse; const createdId = emailResult.created?.['draft']?.id; + const deletedEntryKey = dto.draftId + ? this.entryKeyIfDestroyed(accountId, dto.draftId, emailResult) + : null; + if (!createdId) { - throw new Error('Failed to create email for sending'); + throw new SendEmailFailedError(deletedEntryKey); } return { id: createdId, - deletedEntryKey: dto.draftId - ? this.entryKeyIfDestroyed(accountId, dto.draftId, emailResult) - : null, + deletedEntryKey, }; } @@ -504,6 +507,10 @@ export class JmapMailProvider extends MailProvider { return null; } + if (!result.destroyed?.includes(emailId)) { + return null; + } + return this.buildEntryKey(accountId, emailId); } diff --git a/src/modules/usage/mail-usage.service.spec.ts b/src/modules/usage/mail-usage.service.spec.ts index 6225a6a..1807409 100644 --- a/src/modules/usage/mail-usage.service.spec.ts +++ b/src/modules/usage/mail-usage.service.spec.ts @@ -141,6 +141,23 @@ describe('MailUsageService', () => { }); }); + describe('getChargedBytes', () => { + test('when the user has mail stored, then returns the summed pointer sizes', async () => { + entries.sumSizeByUserUuid.mockResolvedValue(4096); + + const result = await service.getChargedBytes('user-1'); + + expect(entries.sumSizeByUserUuid).toHaveBeenCalledWith('user-1'); + expect(result).toBe(4096); + }); + + test('when the user has no mail account, then reports zero rather than failing', async () => { + entries.sumSizeByUserUuid.mockResolvedValue(0); + + expect(await service.getChargedBytes('user-without-mail')).toBe(0); + }); + }); + describe('releaseStoredMessage', () => { test('when the pointer exists, then deletes the bridge entry by id and drops the pointer', async () => { entries.findByEntryKey.mockResolvedValue(entry()); diff --git a/src/modules/usage/mail-usage.service.ts b/src/modules/usage/mail-usage.service.ts index de521a4..7d5af97 100644 --- a/src/modules/usage/mail-usage.service.ts +++ b/src/modules/usage/mail-usage.service.ts @@ -78,6 +78,10 @@ export class MailUsageService { ); } + async getChargedBytes(userUuid: string): Promise { + return this.entries.sumSizeByUserUuid(userUuid); + } + async releaseStoredMessage( params: ReleaseStoredMessageParams, ): Promise { diff --git a/src/modules/usage/repositories/mail-bucket-entry.repository.spec.ts b/src/modules/usage/repositories/mail-bucket-entry.repository.spec.ts index e1353fd..f805d1a 100644 --- a/src/modules/usage/repositories/mail-bucket-entry.repository.spec.ts +++ b/src/modules/usage/repositories/mail-bucket-entry.repository.spec.ts @@ -24,6 +24,56 @@ describe('MailBucketEntryRepository', () => { entryModel = module.get(getModelToken(MailBucketEntryModel)); }); + describe('sumSizeByUserUuid', () => { + test('when the user has entries, then returns the summed size as a number', async () => { + entryModel.findOne.mockResolvedValue({ + total: '4096', + } as unknown as MailBucketEntryModel); + + const result = await repository.sumSizeByUserUuid('user-1'); + + expect(result).toBe(4096); + }); + + test('when the user has no entries, then returns zero instead of null', async () => { + entryModel.findOne.mockResolvedValue({ + total: null, + } as unknown as MailBucketEntryModel); + + expect(await repository.sumSizeByUserUuid('user-1')).toBe(0); + }); + + test('when no row matches at all, then returns zero', async () => { + entryModel.findOne.mockResolvedValue(null); + + expect(await repository.sumSizeByUserUuid('user-1')).toBe(0); + }); + + test('when summing, then includes soft-deleted addresses and accounts', async () => { + entryModel.findOne.mockResolvedValue({ + total: '0', + } as unknown as MailBucketEntryModel); + + await repository.sumSizeByUserUuid('user-1'); + + expect(entryModel.findOne).toHaveBeenCalledWith( + expect.objectContaining({ + include: [ + expect.objectContaining({ + paranoid: false, + include: [ + expect.objectContaining({ + paranoid: false, + where: { userId: 'user-1' }, + }), + ], + }), + ], + }), + ); + }); + }); + describe('create', () => { test('when persisting, then returns the entry with size coerced to a number', async () => { const now = new Date(); diff --git a/src/modules/usage/repositories/mail-bucket-entry.repository.ts b/src/modules/usage/repositories/mail-bucket-entry.repository.ts index 7f63e7d..c72a567 100644 --- a/src/modules/usage/repositories/mail-bucket-entry.repository.ts +++ b/src/modules/usage/repositories/mail-bucket-entry.repository.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/sequelize'; -import { UniqueConstraintError } from 'sequelize'; +import { col, fn, UniqueConstraintError } from 'sequelize'; +import { MailAccountModel } from '../../account/models/mail-account.model.js'; +import { MailAddressModel } from '../../account/models/mail-address.model.js'; import { MailBucketEntry, type MailBucketEntryAttributes, @@ -49,6 +51,32 @@ export class MailBucketEntryRepository { await this.entryModel.destroy({ where: { entryKey } }); } + async sumSizeByUserUuid(userUuid: string): Promise { + const row = (await this.entryModel.findOne({ + attributes: [[fn('SUM', col('size')), 'total']], + include: [ + { + model: MailAddressModel, + attributes: [], + required: true, + paranoid: false, + include: [ + { + model: MailAccountModel, + attributes: [], + required: true, + paranoid: false, + where: { userId: userUuid }, + }, + ], + }, + ], + raw: true, + })) as { total: string | number | null } | null; + + return Number(row?.total ?? 0); + } + private toDomain(model: MailBucketEntryModel): MailBucketEntry { const attrs: MailBucketEntryAttributes = { id: model.id,