Skip to content
Draft
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
53 changes: 53 additions & 0 deletions migrations/20260623120000-create-mail-bucket-entries.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
'use strict';

const TABLE_NAME = 'mail_bucket_entries';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable(TABLE_NAME, {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false,
},
mail_address_id: {
type: Sequelize.UUID,
allowNull: false,
references: { model: 'mail_addresses', key: 'id' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
entry_key: {
type: Sequelize.STRING(255),
allowNull: false,
unique: true,
},
bridge_entry_id: {
type: Sequelize.STRING(24),
allowNull: false,
},
size: {
type: Sequelize.BIGINT,
allowNull: false,
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.fn('now'),
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.fn('now'),
},
});

await queryInterface.addIndex(TABLE_NAME, ['mail_address_id']);
},

async down(queryInterface) {
await queryInterface.dropTable(TABLE_NAME);
},
};
4 changes: 4 additions & 0 deletions src/modules/account/repositories/address.repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ describe('AddressRepository', () => {
it('when a provider link resolves to an account, then returns userUuid and networkBucketId', async () => {
const link = {
address: {
id: 'address-1',
networkBucketId: 'bucket-1',
account: { userId: 'user-uuid-1' },
},
Expand All @@ -154,6 +155,7 @@ describe('AddressRepository', () => {
],
});
expect(result).toEqual({
mailAddressId: 'address-1',
userUuid: 'user-uuid-1',
networkBucketId: 'bucket-1',
});
Expand All @@ -172,6 +174,7 @@ describe('AddressRepository', () => {
describe('findBucketContextByAddress', () => {
it('when the address resolves to an account, then returns userUuid and networkBucketId', async () => {
const model = {
id: 'address-1',
networkBucketId: 'bucket-1',
account: { userId: 'user-uuid-1' },
} as unknown as MailAddressModel;
Expand All @@ -185,6 +188,7 @@ describe('AddressRepository', () => {
include: [{ model: MailAccountModel, required: true }],
});
expect(result).toEqual({
mailAddressId: 'address-1',
userUuid: 'user-uuid-1',
networkBucketId: 'bucket-1',
});
Expand Down
3 changes: 3 additions & 0 deletions src/modules/account/repositories/address.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { MailProviderAccountModel } from '../models/mail-provider-account.model.
const MAX_BATCH_LOOKUP = 50;

export interface ProviderAccountBucketContext {
mailAddressId: string;
userUuid: string;
networkBucketId: string | null;
}
Expand Down Expand Up @@ -110,6 +111,7 @@ export class AddressRepository {
if (!model?.account) return null;

return {
mailAddressId: model.id,
userUuid: model.account.userId,
networkBucketId: model.networkBucketId,
};
Expand All @@ -132,6 +134,7 @@ export class AddressRepository {
if (!link?.address?.account) return null;

return {
mailAddressId: link.address.id,
userUuid: link.address.account.userId,
networkBucketId: link.address.networkBucketId,
};
Expand Down
4 changes: 2 additions & 2 deletions src/modules/email/email.module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Module } from '@nestjs/common';
import { BridgeModule } from '../infrastructure/bridge/bridge.module.js';
import { MailUsageModule } from '../usage/mail-usage.module.js';
import { JmapModule } from '../infrastructure/jmap/jmap.module.js';
import { SmtpModule } from '../infrastructure/smtp/smtp.module.js';
import { ProvisioningModule } from '../provisioning/provisioning.module.js';
Expand All @@ -8,7 +8,7 @@ import { EmailService } from './email.service.js';
import { Reflector } from '@nestjs/core';

@Module({
imports: [JmapModule, SmtpModule, ProvisioningModule, BridgeModule],
imports: [JmapModule, SmtpModule, ProvisioningModule, MailUsageModule],
controllers: [EmailController],
providers: [EmailService, Reflector],
})
Expand Down
25 changes: 14 additions & 11 deletions src/modules/email/email.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Readable } from 'node:stream';
import { EmailService } from './email.service.js';
import { MailProvider } from './mail-provider.port.js';
import { AccountService } from '../account/account.service.js';
import { BridgeClient } from '../infrastructure/bridge/bridge.service.js';
import { MailUsageService } from '../usage/mail-usage.service.js';
import { StalwartSmtpService } from '../infrastructure/smtp/stalwart-smtp.service.js';
import {
newMailbox,
Expand Down Expand Up @@ -39,7 +39,7 @@ describe('EmailService', () => {
let accountService: DeepMocked<AccountService>;
let smtp: DeepMocked<StalwartSmtpService>;
let configService: DeepMocked<ConfigService>;
let bridge: DeepMocked<BridgeClient>;
let usage: DeepMocked<MailUsageService>;
const userEmail = '[email protected]';

beforeEach(async () => {
Expand All @@ -55,7 +55,7 @@ describe('EmailService', () => {
accountService = module.get<DeepMocked<AccountService>>(AccountService);
smtp = module.get<DeepMocked<StalwartSmtpService>>(StalwartSmtpService);
configService = module.get<DeepMocked<ConfigService>>(ConfigService);
bridge = module.get<DeepMocked<BridgeClient>>(BridgeClient);
usage = module.get<DeepMocked<MailUsageService>>(MailUsageService);
});

describe('getMailboxes', () => {
Expand Down Expand Up @@ -464,12 +464,13 @@ describe('EmailService', () => {
await service.deleteEmail(userEmail, 'email-id');

expect(provider.deleteEmail).toHaveBeenCalledWith(userEmail, 'email-id');
expect(bridge.deleteBucketEntry).not.toHaveBeenCalled();
expect(usage.releaseStoredMessage).not.toHaveBeenCalled();
});

it('when the message is permanently destroyed, then releases the quota entry on the address bucket', async () => {
provider.deleteEmail.mockResolvedValue({ deletedEntryKey: '42:7' });
accountService.findBucketContextByAddress.mockResolvedValue({
mailAddressId: 'address-1',
userUuid: 'user-1',
networkBucketId: 'bucket-1',
});
Expand All @@ -479,32 +480,34 @@ describe('EmailService', () => {
expect(accountService.findBucketContextByAddress).toHaveBeenCalledWith(
userEmail,
);
expect(bridge.deleteBucketEntry).toHaveBeenCalledWith(
'user-1',
'bucket-1',
'42:7',
);
expect(usage.releaseStoredMessage).toHaveBeenCalledWith({
userUuid: 'user-1',
bucketId: 'bucket-1',
entryKey: '42:7',
});
});

it('when the destroyed address has no network bucket, then no quota entry is released', async () => {
provider.deleteEmail.mockResolvedValue({ deletedEntryKey: '42:7' });
accountService.findBucketContextByAddress.mockResolvedValue({
mailAddressId: 'address-1',
userUuid: 'user-1',
networkBucketId: null,
});

await service.deleteEmail(userEmail, 'email-id');

expect(bridge.deleteBucketEntry).not.toHaveBeenCalled();
expect(usage.releaseStoredMessage).not.toHaveBeenCalled();
});

it('when releasing the quota entry fails, then the deletion still succeeds', async () => {
provider.deleteEmail.mockResolvedValue({ deletedEntryKey: '42:7' });
accountService.findBucketContextByAddress.mockResolvedValue({
mailAddressId: 'address-1',
userUuid: 'user-1',
networkBucketId: 'bucket-1',
});
bridge.deleteBucketEntry.mockRejectedValue(new Error('Bridge down'));
usage.releaseStoredMessage.mockRejectedValue(new Error('Bridge down'));

await expect(
service.deleteEmail(userEmail, 'email-id'),
Expand Down
12 changes: 6 additions & 6 deletions src/modules/email/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AccountService } from '../account/account.service.js';
import { BridgeClient } from '../infrastructure/bridge/bridge.service.js';
import { MailUsageService } from '../usage/mail-usage.service.js';
import { MailProvider } from './mail-provider.port.js';
import type {
DraftEmailDto,
Expand Down Expand Up @@ -60,7 +60,7 @@ export class EmailService {
private readonly accountService: AccountService,
private readonly smtp: StalwartSmtpService,
private readonly configService: ConfigService,
private readonly bridge: BridgeClient,
private readonly usage: MailUsageService,
) {}

getMailboxes(userEmail: string): Promise<Mailbox[]> {
Expand Down Expand Up @@ -276,11 +276,11 @@ export class EmailService {
}

try {
await this.bridge.deleteBucketEntry(
context.userUuid,
context.networkBucketId,
await this.usage.releaseStoredMessage({
userUuid: context.userUuid,
bucketId: context.networkBucketId,
entryKey,
);
});
} catch (error) {
this.logger.warn(
`Failed to release quota entry '${entryKey}' for '${userEmail}': ${(error as Error).message}`,
Expand Down
29 changes: 15 additions & 14 deletions src/modules/infrastructure/bridge/bridge.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ describe('BridgeClient', () => {
});

describe('createBucketEntry', () => {
it('when Bridge returns 200, then signs a token, POSTs key and size, and returns the entry', async () => {
it('when Bridge returns 200, then signs a token, POSTs only the size, and returns the entry', async () => {
const entry = {
id: 'entry-1',
maxSpaceBytes: 1000,
Expand All @@ -155,19 +155,14 @@ describe('BridgeClient', () => {
body: { text: () => Promise.resolve(JSON.stringify(entry)) },
});

const result = await service.createBucketEntry(
'user-1',
'bucket-1',
'42:7',
240,
);
const result = await service.createBucketEntry('user-1', 'bucket-1', 240);

expect(result).toStrictEqual(entry);
expect(httpRequest).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
path: '/v2/gateway/users/user-1/buckets/bucket-1/entries',
body: JSON.stringify({ key: '42:7', size: 240 }),
body: JSON.stringify({ size: 240 }),
headers: expect.objectContaining({
authorization: 'Bearer signed-jwt',
}) as unknown,
Expand All @@ -183,7 +178,7 @@ describe('BridgeClient', () => {
});

const error: unknown = await service
.createBucketEntry('user-1', 'bucket-1', '42:7', 240)
.createBucketEntry('user-1', 'bucket-1', 240)
.catch((e: unknown) => e);

expect(error).toBeInstanceOf(BridgeApiError);
Expand All @@ -196,19 +191,25 @@ describe('BridgeClient', () => {
});

describe('deleteBucketEntry', () => {
it('when Bridge returns 200, then signs a token and DELETEs the url-encoded entry key', async () => {
it('when Bridge returns 200, then signs a token, DELETEs by entry id, and returns the snapshot', async () => {
const snapshot = { maxSpaceBytes: 1000, totalUsedSpaceBytes: 0 };
jwtService.sign.mockReturnValue('signed-jwt');
httpRequest.mockResolvedValue({
statusCode: 200,
body: { text: () => Promise.resolve('') },
body: { text: () => Promise.resolve(JSON.stringify(snapshot)) },
});

await service.deleteBucketEntry('user-1', 'bucket-1', '42:7');
const result = await service.deleteBucketEntry(
'user-1',
'bucket-1',
'entry-1',
);

expect(result).toStrictEqual(snapshot);
expect(httpRequest).toHaveBeenCalledWith(
expect.objectContaining({
method: 'DELETE',
path: '/v2/gateway/users/user-1/buckets/bucket-1/entries/42%3A7',
path: '/v2/gateway/users/user-1/buckets/bucket-1/entries/entry-1',
headers: expect.objectContaining({
authorization: 'Bearer signed-jwt',
}) as unknown,
Expand All @@ -224,7 +225,7 @@ describe('BridgeClient', () => {
});

const error: unknown = await service
.deleteBucketEntry('user-1', 'bucket-1', '42:7')
.deleteBucketEntry('user-1', 'bucket-1', 'entry-1')
.catch((e: unknown) => e);

expect(error).toBeInstanceOf(BridgeApiError);
Expand Down
21 changes: 13 additions & 8 deletions src/modules/infrastructure/bridge/bridge.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import {
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { Client } from 'undici';
import type { BucketEntry, MailBucket } from './bridge.types.js';
import type {
BucketEntry,
MailBucket,
UserSpaceSnapshot,
} from './bridge.types.js';

@Injectable()
export class BridgeClient implements OnModuleInit, OnModuleDestroy {
Expand Down Expand Up @@ -101,7 +105,6 @@ export class BridgeClient implements OnModuleInit, OnModuleDestroy {
async createBucketEntry(
userUuid: string,
bucketId: string,
key: string,
size: number,
): Promise<BucketEntry> {
const token = this.signGatewayToken(userUuid);
Expand All @@ -114,14 +117,14 @@ export class BridgeClient implements OnModuleInit, OnModuleDestroy {
accept: 'application/json',
authorization: `Bearer ${token}`,
},
body: JSON.stringify({ key, size }),
body: JSON.stringify({ size }),
});

const text = await body.text();

if (statusCode !== 200) {
throw new BridgeApiError(
`Failed to create bucket entry '${key}' on bucket '${bucketId}' for user '${userUuid}': HTTP ${statusCode}`,
`Failed to create bucket entry on bucket '${bucketId}' for user '${userUuid}': HTTP ${statusCode}`,
statusCode,
text,
);
Expand All @@ -133,13 +136,13 @@ export class BridgeClient implements OnModuleInit, OnModuleDestroy {
async deleteBucketEntry(
userUuid: string,
bucketId: string,
key: string,
): Promise<void> {
entryId: string,
): Promise<UserSpaceSnapshot> {
const token = this.signGatewayToken(userUuid);

const { statusCode, body } = await this.httpClient.request({
method: 'DELETE',
path: `${this.basePath}/v2/gateway/users/${encodeURIComponent(userUuid)}/buckets/${encodeURIComponent(bucketId)}/entries/${encodeURIComponent(key)}`,
path: `${this.basePath}/v2/gateway/users/${encodeURIComponent(userUuid)}/buckets/${encodeURIComponent(bucketId)}/entries/${encodeURIComponent(entryId)}`,
headers: {
accept: 'application/json',
authorization: `Bearer ${token}`,
Expand All @@ -150,11 +153,13 @@ export class BridgeClient implements OnModuleInit, OnModuleDestroy {

if (statusCode !== 200) {
throw new BridgeApiError(
`Failed to delete bucket entry '${key}' on bucket '${bucketId}' for user '${userUuid}': HTTP ${statusCode}`,
`Failed to delete bucket entry '${entryId}' on bucket '${bucketId}' for user '${userUuid}': HTTP ${statusCode}`,
statusCode,
text,
);
}

return JSON.parse(text) as UserSpaceSnapshot;
}

private signGatewayToken(userUuid: string): string {
Expand Down
Loading
Loading