Skip to content
Open
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
19 changes: 19 additions & 0 deletions migrations/20260818231630-add-user-id-index-to-mail-accounts.js
Original file line number Diff line number Diff line change
@@ -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);
},
};
4 changes: 4 additions & 0 deletions src/modules/account/models/mail-account.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
36 changes: 36 additions & 0 deletions src/modules/email/email.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
22 changes: 15 additions & 7 deletions src/modules/email/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 10 additions & 0 deletions src/modules/email/mail-provider.port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
MailQuota,
Mailbox,
MailboxType,
QuotaEntryKey,
SearchEmailDto,
SendEmailDto,
SendEmailResult,
Expand All @@ -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`);
Expand Down
14 changes: 14 additions & 0 deletions src/modules/gateway/dto/account-usage.response.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
25 changes: 25 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,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<AccountService>;
let mailUsageService: DeepMocked<MailUsageService>;

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

controller = module.get(GatewayController);
accountService = module.get(AccountService);
mailUsageService = module.get(MailUsageService);
});

describe('getAddress', () => {
Expand All @@ -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();
Expand Down
31 changes: 28 additions & 3 deletions src/modules/gateway/gateway.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,23 @@ import {
HttpStatus,
NotFoundException,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} 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')
Expand All @@ -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({
Expand All @@ -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<AccountUsageResponseDto> {
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);
}

Expand All @@ -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);
}
}
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 { 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],
})
Expand Down
59 changes: 58 additions & 1 deletion src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } } },
),
);
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading