From 14750ec0a58fdca1fc3d7c4f3583c380d7132ae4 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:48:42 -0600 Subject: [PATCH 1/5] feat: add network bucket creation to mail account provisioning - Introduced a new column `network_bucket_id` in the `mail_accounts` table to associate accounts with network buckets. - Updated `AccountService` to create and delete mail buckets via the BridgeClient. - Enhanced unit tests to cover new functionality related to network bucket management. - Modified relevant models and repositories to handle the new `networkBucketId` attribute. --- ...2-add-network-bucket-id-to-mail-accounts.js | 18 ++++++++++++++++++ src/modules/account/account.service.spec.ts | 15 ++++++--------- src/modules/account/account.service.ts | 13 +++++++++++++ .../account/domain/mail-account.domain.ts | 2 ++ .../account/models/mail-account.model.ts | 4 ++++ .../repositories/account.repository.spec.ts | 12 ++++++++++++ .../account/repositories/account.repository.ts | 5 +++++ test/fixtures.ts | 1 + 8 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 migrations/20260605214402-add-network-bucket-id-to-mail-accounts.js diff --git a/migrations/20260605214402-add-network-bucket-id-to-mail-accounts.js b/migrations/20260605214402-add-network-bucket-id-to-mail-accounts.js new file mode 100644 index 0000000..26056fe --- /dev/null +++ b/migrations/20260605214402-add-network-bucket-id-to-mail-accounts.js @@ -0,0 +1,18 @@ +'use strict'; + +const TABLE_NAME = 'mail_accounts'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn(TABLE_NAME, 'network_bucket_id', { + type: Sequelize.STRING(24), + allowNull: true, + defaultValue: null, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn(TABLE_NAME, 'network_bucket_id'); + }, +}; diff --git a/src/modules/account/account.service.spec.ts b/src/modules/account/account.service.spec.ts index 02cec53..233fc56 100644 --- a/src/modules/account/account.service.spec.ts +++ b/src/modules/account/account.service.spec.ts @@ -528,7 +528,7 @@ describe('AccountService', () => { ); expect(provider.deleteAccount).toHaveBeenCalledWith(params.address); expect(accounts.delete).toHaveBeenCalledWith(createdAccount.id); - expect(addresses.setNetworkBucketId).not.toHaveBeenCalled(); + expect(accounts.setNetworkBucketId).not.toHaveBeenCalled(); }); it('when concurrent provisioning race occurs, then returns the existing account', async () => { @@ -574,10 +574,9 @@ describe('AccountService', () => { expect(accounts.delete).toHaveBeenCalledWith(account.id); }); - it('when an address has a network bucket, then deletes it via the bridge', async () => { - const addr = newMailAddressAttributes({ networkBucketId: 'bucket-1' }); + it('when account has a network bucket, then deletes it via the bridge', async () => { const account = MailAccount.build( - newMailAccountAttributes({ addresses: [addr] }), + newMailAccountAttributes({ networkBucketId: 'bucket-1' }), ); accounts.findByUserId.mockResolvedValue(account); @@ -590,10 +589,9 @@ describe('AccountService', () => { expect(accounts.delete).toHaveBeenCalledWith(account.id); }); - it('when addresses have no network bucket, then does not call the bridge', async () => { - const addr = newMailAddressAttributes({ networkBucketId: null }); + it('when account has no network bucket, then does not call the bridge', async () => { const account = MailAccount.build( - newMailAccountAttributes({ addresses: [addr] }), + newMailAccountAttributes({ networkBucketId: null }), ); accounts.findByUserId.mockResolvedValue(account); @@ -603,9 +601,8 @@ describe('AccountService', () => { }); it('when bridge bucket deletion fails, then logs a warning and still deletes the account', async () => { - const addr = newMailAddressAttributes({ networkBucketId: 'bucket-1' }); const account = MailAccount.build( - newMailAccountAttributes({ addresses: [addr] }), + newMailAccountAttributes({ networkBucketId: 'bucket-1' }), ); accounts.findByUserId.mockResolvedValue(account); bridge.deleteMailBucket.mockRejectedValue(new Error('Bridge down')); diff --git a/src/modules/account/account.service.ts b/src/modules/account/account.service.ts index d8b58e8..40585a0 100644 --- a/src/modules/account/account.service.ts +++ b/src/modules/account/account.service.ts @@ -241,6 +241,19 @@ export class AccountService { }), ); + if (account.networkBucketId) { + try { + await this.bridge.deleteMailBucket( + driveUserUuid, + account.networkBucketId, + ); + } catch (error) { + this.logger.warn( + `Failed to delete network bucket '${account.networkBucketId}' for '${driveUserUuid}': ${(error as Error).message}`, + ); + } + } + await this.accounts.delete(account.id); this.logger.log(`Deleted account for user '${driveUserUuid}'`); } diff --git a/src/modules/account/domain/mail-account.domain.ts b/src/modules/account/domain/mail-account.domain.ts index 7bc3512..38071d1 100644 --- a/src/modules/account/domain/mail-account.domain.ts +++ b/src/modules/account/domain/mail-account.domain.ts @@ -13,6 +13,7 @@ export interface MailAccountAttributes { userId: string; status: MailAccountState; suspendedAt: Date | null; + networkBucketId: string | null; addresses: MailAddressAttributes[]; createdAt: Date; updatedAt: Date; @@ -23,6 +24,7 @@ export class MailAccount { readonly userId!: string; readonly status!: MailAccountState; readonly suspendedAt!: Date | null; + readonly networkBucketId!: string | null; readonly addresses!: MailAddress[]; readonly createdAt!: Date; readonly updatedAt!: Date; diff --git a/src/modules/account/models/mail-account.model.ts b/src/modules/account/models/mail-account.model.ts index a15030f..4f08414 100644 --- a/src/modules/account/models/mail-account.model.ts +++ b/src/modules/account/models/mail-account.model.ts @@ -36,6 +36,10 @@ export class MailAccountModel extends Model { @Column(DataType.DATE) declare suspendedAt: Date | null; + @AllowNull(true) + @Column({ field: 'network_bucket_id', type: DataType.UUID }) + declare networkBucketId: string | null; + @Column(DataType.DATE) declare deletedAt: Date | null; diff --git a/src/modules/account/repositories/account.repository.spec.ts b/src/modules/account/repositories/account.repository.spec.ts index 4572034..af4830f 100644 --- a/src/modules/account/repositories/account.repository.spec.ts +++ b/src/modules/account/repositories/account.repository.spec.ts @@ -34,6 +34,7 @@ describe('AccountRepository', () => { userId: 'user-1', status: 'active', suspendedAt: null, + networkBucketId: null, createdAt: new Date('2026-01-01T00:00:00.000Z'), updatedAt: new Date('2026-01-02T00:00:00.000Z'), addresses: [], @@ -129,4 +130,15 @@ describe('AccountRepository', () => { }); }); }); + + describe('setNetworkBucketId', () => { + it('when given an id and bucket id, then updates the account row', async () => { + await repository.setNetworkBucketId('acc-1', 'bucket-1'); + + expect(accountModel.update).toHaveBeenCalledWith( + { networkBucketId: 'bucket-1' }, + { where: { id: 'acc-1' } }, + ); + }); + }); }); diff --git a/src/modules/account/repositories/account.repository.ts b/src/modules/account/repositories/account.repository.ts index 5b71339..1c759ba 100644 --- a/src/modules/account/repositories/account.repository.ts +++ b/src/modules/account/repositories/account.repository.ts @@ -46,12 +46,17 @@ export class AccountRepository { await this.accountModel.destroy({ where: { id } }); } + async setNetworkBucketId(id: string, networkBucketId: string): Promise { + await this.accountModel.update({ networkBucketId }, { where: { id } }); + } + private toDomain(model: MailAccountModel): MailAccount { return MailAccount.build({ id: model.id, userId: model.userId, status: model.status as MailAccountState, suspendedAt: model.suspendedAt, + networkBucketId: model.networkBucketId, createdAt: model.createdAt as Date, updatedAt: model.updatedAt as Date, addresses: (model.addresses ?? []).map(toAddressAttributes), diff --git a/test/fixtures.ts b/test/fixtures.ts index fccf9ab..2881a1a 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -253,6 +253,7 @@ export function newMailAccountAttributes( userId: randomUuid(), status: MailAccountState.Active, suspendedAt: null, + networkBucketId: null, addresses: [address], createdAt: new Date(), updatedAt: new Date(), From 9a8200d26f14b8d4bf9fe6e2da2791f5fab5d2b5 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:30:44 -0600 Subject: [PATCH 2/5] feat(stalwart): add Stalwart webhook integration with authentication and event handling - Introduced Stalwart webhook configuration in .env.template. - Added StalwartEventsModule, including controller, service, and authentication guard. - Implemented event handling logic for Stalwart webhook events. - Configured authentication using Basic Auth with credentials from environment variables. This integration allows the application to process events from Stalwart's webhook securely. --- .env.template | 4 ++ src/app.module.ts | 2 + src/config/configuration.ts | 5 ++ .../stalwart-events-auth.guard.ts | 49 +++++++++++++++++++ .../stalwart-events.controller.ts | 25 ++++++++++ .../stalwart-events/stalwart-events.module.ts | 10 ++++ .../stalwart-events.service.ts | 43 ++++++++++++++++ .../stalwart-events/stalwart-events.types.ts | 28 +++++++++++ 8 files changed, 166 insertions(+) create mode 100644 src/modules/stalwart-events/stalwart-events-auth.guard.ts create mode 100644 src/modules/stalwart-events/stalwart-events.controller.ts create mode 100644 src/modules/stalwart-events/stalwart-events.module.ts create mode 100644 src/modules/stalwart-events/stalwart-events.service.ts create mode 100644 src/modules/stalwart-events/stalwart-events.types.ts diff --git a/.env.template b/.env.template index 0e60fa8..99c1b5f 100644 --- a/.env.template +++ b/.env.template @@ -26,3 +26,7 @@ GATEWAY_PUBLIC_SECRET= # External APIs PAYMENTS_API_URL= SERVER_PRIVATE_KEY= + +# Stalwart webhook (ingest events) +STALWART_WEBHOOK_USERNAME=stalwart +STALWART_WEBHOOK_SECRET= diff --git a/src/app.module.ts b/src/app.module.ts index e2bd351..d49e129 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -14,6 +14,7 @@ import { AccountModule } from './modules/account/account.module'; import { GatewayModule } from './modules/gateway/gateway.module'; import { HttpGlobalExceptionFilter } from './common/filters/http-global-exception.filter'; import { AddressesModule } from './modules/addresses/addresses.module'; +import { StalwartEventsModule } from './modules/stalwart-events/stalwart-events.module'; @Module({ imports: [ @@ -87,6 +88,7 @@ import { AddressesModule } from './modules/addresses/addresses.module'; AccountModule, AddressesModule, GatewayModule, + StalwartEventsModule, ], controllers: [], providers: [ diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 148af59..ebb486d 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -48,4 +48,9 @@ export default () => ({ url: process.env.BRIDGE_API_URL ?? '', }, }, + + stalwartWebhook: { + username: process.env.STALWART_WEBHOOK_USERNAME ?? 'stalwart', + secret: process.env.STALWART_WEBHOOK_SECRET ?? '', + }, }); diff --git a/src/modules/stalwart-events/stalwart-events-auth.guard.ts b/src/modules/stalwart-events/stalwart-events-auth.guard.ts new file mode 100644 index 0000000..7df6977 --- /dev/null +++ b/src/modules/stalwart-events/stalwart-events-auth.guard.ts @@ -0,0 +1,49 @@ +import { + type CanActivate, + type ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { timingSafeEqual } from 'node:crypto'; +import type { Request } from 'express'; + +@Injectable() +export class StalwartEventsAuthGuard implements CanActivate { + constructor(private readonly configService: ConfigService) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + + if (!authHeader?.startsWith('Basic ')) { + throw new UnauthorizedException(); + } + + const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8'); + const colonIndex = decoded.indexOf(':'); + const username = decoded.slice(0, colonIndex); + const password = decoded.slice(colonIndex + 1); + + const expectedUsername = + this.configService.get('stalwartWebhook.username') ?? ''; + const expectedSecret = + this.configService.get('stalwartWebhook.secret') ?? ''; + + const usernameOk = this.safeCompare(username, expectedUsername); + const passwordOk = this.safeCompare(password, expectedSecret); + + if (!usernameOk || !passwordOk) { + throw new UnauthorizedException(); + } + + return true; + } + + private safeCompare(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); + } +} diff --git a/src/modules/stalwart-events/stalwart-events.controller.ts b/src/modules/stalwart-events/stalwart-events.controller.ts new file mode 100644 index 0000000..7433110 --- /dev/null +++ b/src/modules/stalwart-events/stalwart-events.controller.ts @@ -0,0 +1,25 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from '@nestjs/common'; +import { Public } from '../auth/decorators/public.decorator.js'; +import { StalwartEventsAuthGuard } from './stalwart-events-auth.guard.js'; +import { StalwartEventsService } from './stalwart-events.service.js'; +import type { StalwartWebhookPayload } from './stalwart-events.types.js'; + +@Public() +@UseGuards(StalwartEventsAuthGuard) +@Controller() +export class StalwartEventsController { + constructor(private readonly stalwartEventsService: StalwartEventsService) {} + + @Post('stalwart-events') + @HttpCode(HttpStatus.OK) + async handleEvents(@Body() body: StalwartWebhookPayload): Promise { + await this.stalwartEventsService.handleBatch(body); + } +} diff --git a/src/modules/stalwart-events/stalwart-events.module.ts b/src/modules/stalwart-events/stalwart-events.module.ts new file mode 100644 index 0000000..9618870 --- /dev/null +++ b/src/modules/stalwart-events/stalwart-events.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { StalwartEventsController } from './stalwart-events.controller.js'; +import { StalwartEventsAuthGuard } from './stalwart-events-auth.guard.js'; +import { StalwartEventsService } from './stalwart-events.service.js'; + +@Module({ + controllers: [StalwartEventsController], + providers: [StalwartEventsAuthGuard, StalwartEventsService], +}) +export class StalwartEventsModule {} diff --git a/src/modules/stalwart-events/stalwart-events.service.ts b/src/modules/stalwart-events/stalwart-events.service.ts new file mode 100644 index 0000000..1ccf9b2 --- /dev/null +++ b/src/modules/stalwart-events/stalwart-events.service.ts @@ -0,0 +1,43 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { + StalwartEvent, + StalwartWebhookPayload, +} from './stalwart-events.types.js'; + +@Injectable() +export class StalwartEventsService { + private readonly logger = new Logger(StalwartEventsService.name); + + async handleBatch(payload: StalwartWebhookPayload): Promise { + for (const event of payload.events) { + if (event.type === 'message-ingest.duplicate') { + continue; + } + + if (!event.type.startsWith('message-ingest.')) { + this.logger.debug( + { type: event.type }, + 'skipping unhandled event type', + ); + continue; + } + + await this.handleIngestEvent(event); + } + } + + private async handleIngestEvent(event: StalwartEvent): Promise { + const { accountId, documentId, size } = event.data; + const entryKey = `${accountId}:${documentId}`; + + this.logger.log( + { entryKey, size, type: event.type }, + 'message-ingest event — Bridge entry creation pending Phase 1', + ); + + // TODO: resolve accountId -> userUuid via AccountRepository, + // then call BridgeClient.createEntry(userUuid, bucketId, entryKey, size) + // dummy await + await new Promise((resolve) => setTimeout(resolve, 1000)); + } +} diff --git a/src/modules/stalwart-events/stalwart-events.types.ts b/src/modules/stalwart-events/stalwart-events.types.ts new file mode 100644 index 0000000..180e645 --- /dev/null +++ b/src/modules/stalwart-events/stalwart-events.types.ts @@ -0,0 +1,28 @@ +export type StalwartIngestEventType = + | 'message-ingest.ham' + | 'message-ingest.spam' + | 'message-ingest.jmap-append' + | 'message-ingest.imap-append' + | 'message-ingest.duplicate'; + +export interface StalwartIngestEventData { + accountId: number; + documentId: number; + mailboxId: number[]; + blobId: string; + size: number; + messageId: string; + from: string; + to: string[]; +} + +export interface StalwartEvent { + id: string; + createdAt: string; + type: StalwartIngestEventType; + data: StalwartIngestEventData; +} + +export interface StalwartWebhookPayload { + events: StalwartEvent[]; +} From 17a2fce19133a4b4564a6f69714cef7f11215573 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:17:19 -0600 Subject: [PATCH 3/5] feat(stalwart): enhance event handling and bucket entry creation - Added `findBucketContextByProviderInternalId` method to `AddressRepository` for resolving user and bucket context. - Updated `AccountService` to delete provider links during account provisioning failure. - Implemented `createBucketEntry` method in `BridgeClient` for creating bucket entries in response to Stalwart events. - Enhanced `StalwartEventsService` to handle batch events and create bucket entries based on resolved account context. - Added unit tests for the new functionality in `StalwartEventsService` and `AddressRepository` to ensure proper behavior. --- src/modules/account/account.service.spec.ts | 5 + src/modules/account/account.service.ts | 13 +- .../repositories/address.repository.spec.ts | 45 ++++++ .../repositories/address.repository.ts | 27 ++++ .../bridge/bridge.service.spec.ts | 53 ++++++ .../infrastructure/bridge/bridge.service.ts | 34 +++- .../infrastructure/bridge/bridge.types.ts | 14 +- .../stalwart-events/stalwart-events.module.ts | 3 + .../stalwart-events.service.spec.ts | 153 ++++++++++++++++++ .../stalwart-events.service.ts | 43 ++++- 10 files changed, 376 insertions(+), 14 deletions(-) create mode 100644 src/modules/stalwart-events/stalwart-events.service.spec.ts diff --git a/src/modules/account/account.service.spec.ts b/src/modules/account/account.service.spec.ts index 233fc56..97ff101 100644 --- a/src/modules/account/account.service.spec.ts +++ b/src/modules/account/account.service.spec.ts @@ -521,6 +521,11 @@ describe('AccountService', () => { accounts.findByUserId.mockResolvedValue(null); accounts.create.mockResolvedValue(createdAccount); addresses.create.mockResolvedValue('addr-id'); + provider.createAccount.mockResolvedValue({ + provider: 'stalwart', + externalId: params.address, + internalId: '42', + }); bridge.createMailBucket.mockRejectedValue(new Error('Bridge down')); await expect(service.provisionAccount(params)).rejects.toThrow( diff --git a/src/modules/account/account.service.ts b/src/modules/account/account.service.ts index 40585a0..a06c0da 100644 --- a/src/modules/account/account.service.ts +++ b/src/modules/account/account.service.ts @@ -16,7 +16,10 @@ import { MailAccount, MailAccountState } from './domain/mail-account.domain.js'; import { MailAddress } from './domain/mail-address.domain.js'; import { MailDomain } from './domain/mail-domain.domain.js'; import { AccountRepository } from './repositories/account.repository.js'; -import { AddressRepository } from './repositories/address.repository.js'; +import { + AddressRepository, + type ProviderAccountBucketContext, +} from './repositories/address.repository.js'; import { DomainRepository } from './repositories/domain.repository.js'; import { MailAddressKeysRepository } from './repositories/mail-address-keys.repository.js'; @@ -112,6 +115,14 @@ export class AccountService { return this.addresses.findUserIdByAddress(address); } + async findBucketContextByProviderInternalId( + providerInternalId: string, + ): Promise { + return this.addresses.findBucketContextByProviderInternalId( + providerInternalId, + ); + } + async getAddressKeys( userId: string, address: string, diff --git a/src/modules/account/repositories/address.repository.spec.ts b/src/modules/account/repositories/address.repository.spec.ts index dc8a1cb..888801b 100644 --- a/src/modules/account/repositories/address.repository.spec.ts +++ b/src/modules/account/repositories/address.repository.spec.ts @@ -6,10 +6,12 @@ import { Op } from 'sequelize'; import { AddressRepository } from './address.repository.js'; import { MailAccountModel } from '../models/mail-account.model.js'; import { MailAddressModel } from '../models/mail-address.model.js'; +import { MailProviderAccountModel } from '../models/mail-provider-account.model.js'; describe('AddressRepository', () => { let repository: AddressRepository; let addressModel: DeepMocked; + let providerAccountModel: DeepMocked; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -19,12 +21,16 @@ describe('AddressRepository', () => { if (token === getModelToken(MailAddressModel)) { return createMock(); } + if (token === getModelToken(MailProviderAccountModel)) { + return createMock(); + } return createMock(); }) .compile(); repository = module.get(AddressRepository); addressModel = module.get(getModelToken(MailAddressModel)); + providerAccountModel = module.get(getModelToken(MailProviderAccountModel)); }); describe('findUserIdByAddress', () => { @@ -124,6 +130,45 @@ describe('AddressRepository', () => { }); }); + describe('findBucketContextByProviderInternalId', () => { + it('when a provider link resolves to an account, then returns userUuid and networkBucketId', async () => { + const link = { + address: { + networkBucketId: 'bucket-1', + account: { userId: 'user-uuid-1' }, + }, + } as unknown as MailProviderAccountModel; + providerAccountModel.findOne.mockResolvedValue(link); + + const result = + await repository.findBucketContextByProviderInternalId('42'); + + expect(providerAccountModel.findOne).toHaveBeenCalledWith({ + where: { providerInternalId: '42' }, + include: [ + { + model: MailAddressModel, + required: true, + include: [{ model: MailAccountModel, required: true }], + }, + ], + }); + expect(result).toEqual({ + userUuid: 'user-uuid-1', + networkBucketId: 'bucket-1', + }); + }); + + it('when no provider link matches, then returns null', async () => { + providerAccountModel.findOne.mockResolvedValue(null); + + const result = + await repository.findBucketContextByProviderInternalId('999'); + + expect(result).toBeNull(); + }); + }); + describe('setNetworkBucketId', () => { it('when given an id and bucket id, then updates the address row', async () => { await repository.setNetworkBucketId('addr-1', 'bucket-1'); diff --git a/src/modules/account/repositories/address.repository.ts b/src/modules/account/repositories/address.repository.ts index 0644655..ffc821f 100644 --- a/src/modules/account/repositories/address.repository.ts +++ b/src/modules/account/repositories/address.repository.ts @@ -12,6 +12,11 @@ import { MailProviderAccountModel } from '../models/mail-provider-account.model. const MAX_BATCH_LOOKUP = 50; +export interface ProviderAccountBucketContext { + userUuid: string; + networkBucketId: string | null; +} + export function toAddressAttributes( model: MailAddressModel, ): MailAddressAttributes { @@ -94,6 +99,28 @@ export class AddressRepository { return model?.account?.userId ?? null; } + async findBucketContextByProviderInternalId( + providerInternalId: string, + ): Promise { + const link = await this.providerAccountModel.findOne({ + where: { providerInternalId }, + include: [ + { + model: MailAddressModel, + required: true, + include: [{ model: MailAccountModel, required: true }], + }, + ], + }); + + if (!link?.address?.account) return null; + + return { + userUuid: link.address.account.userId, + networkBucketId: link.address.networkBucketId, + }; + } + async findDefaultForAccount( mailAccountId: string, ): Promise { diff --git a/src/modules/infrastructure/bridge/bridge.service.spec.ts b/src/modules/infrastructure/bridge/bridge.service.spec.ts index 4dc652f..b4f7f8e 100644 --- a/src/modules/infrastructure/bridge/bridge.service.spec.ts +++ b/src/modules/infrastructure/bridge/bridge.service.spec.ts @@ -141,4 +141,57 @@ describe('BridgeClient', () => { expect(error.details).toBe('not found'); }); }); + + describe('createBucketEntry', () => { + it('when Bridge returns 200, then signs a token, POSTs key and size, and returns the entry', async () => { + const entry = { + id: 'entry-1', + maxSpaceBytes: 1000, + totalUsedSpaceBytes: 240, + }; + jwtService.sign.mockReturnValue('signed-jwt'); + httpRequest.mockResolvedValue({ + statusCode: 200, + body: { text: () => Promise.resolve(JSON.stringify(entry)) }, + }); + + const result = await service.createBucketEntry( + 'user-1', + 'bucket-1', + '42:7', + 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 }), + headers: expect.objectContaining({ + authorization: 'Bearer signed-jwt', + }) as unknown, + }), + ); + }); + + it('when Bridge returns a non-200 status, then throws BridgeApiError with statusCode and details', async () => { + jwtService.sign.mockReturnValue('signed-jwt'); + httpRequest.mockResolvedValue({ + statusCode: 404, + body: { text: () => Promise.resolve('bucket not found') }, + }); + + const error: unknown = await service + .createBucketEntry('user-1', 'bucket-1', '42:7', 240) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(BridgeApiError); + if (!(error instanceof BridgeApiError)) { + throw new Error('expected BridgeApiError'); + } + expect(error.statusCode).toBe(404); + expect(error.details).toBe('bucket not found'); + }); + }); }); diff --git a/src/modules/infrastructure/bridge/bridge.service.ts b/src/modules/infrastructure/bridge/bridge.service.ts index 1bc0ae9..59a808a 100644 --- a/src/modules/infrastructure/bridge/bridge.service.ts +++ b/src/modules/infrastructure/bridge/bridge.service.ts @@ -7,7 +7,7 @@ import { import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { Client } from 'undici'; -import type { MailBucket } from './bridge.types.js'; +import type { BucketEntry, MailBucket } from './bridge.types.js'; @Injectable() export class BridgeClient implements OnModuleInit, OnModuleDestroy { @@ -98,6 +98,38 @@ export class BridgeClient implements OnModuleInit, OnModuleDestroy { } } + async createBucketEntry( + userUuid: string, + bucketId: string, + key: string, + size: number, + ): Promise { + const token = this.signGatewayToken(userUuid); + + const { statusCode, body } = await this.httpClient.request({ + method: 'POST', + path: `${this.basePath}/v2/gateway/users/${encodeURIComponent(userUuid)}/buckets/${encodeURIComponent(bucketId)}/entries`, + headers: { + 'content-type': 'application/json', + accept: 'application/json', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ key, 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}`, + statusCode, + text, + ); + } + + return JSON.parse(text) as BucketEntry; + } + private signGatewayToken(userUuid: string): string { return this.jwtService.sign( { payload: { uuid: userUuid } }, diff --git a/src/modules/infrastructure/bridge/bridge.types.ts b/src/modules/infrastructure/bridge/bridge.types.ts index b1f8554..4c69d60 100644 --- a/src/modules/infrastructure/bridge/bridge.types.ts +++ b/src/modules/infrastructure/bridge/bridge.types.ts @@ -1,9 +1,13 @@ -export interface UserStorage { - driveUsed: number; - planQuota: number; -} - export interface MailBucket { id: string; name: string; } + +export interface UserSpaceSnapshot { + maxSpaceBytes: number; + totalUsedSpaceBytes: number; +} + +export interface BucketEntry extends UserSpaceSnapshot { + id: string; +} diff --git a/src/modules/stalwart-events/stalwart-events.module.ts b/src/modules/stalwart-events/stalwart-events.module.ts index 9618870..34d3b1d 100644 --- a/src/modules/stalwart-events/stalwart-events.module.ts +++ b/src/modules/stalwart-events/stalwart-events.module.ts @@ -1,9 +1,12 @@ import { Module } from '@nestjs/common'; +import { AccountModule } from '../account/account.module.js'; +import { BridgeModule } from '../infrastructure/bridge/bridge.module.js'; import { StalwartEventsController } from './stalwart-events.controller.js'; import { StalwartEventsAuthGuard } from './stalwart-events-auth.guard.js'; import { StalwartEventsService } from './stalwart-events.service.js'; @Module({ + imports: [AccountModule, BridgeModule], controllers: [StalwartEventsController], providers: [StalwartEventsAuthGuard, StalwartEventsService], }) diff --git a/src/modules/stalwart-events/stalwart-events.service.spec.ts b/src/modules/stalwart-events/stalwart-events.service.spec.ts new file mode 100644 index 0000000..5db044e --- /dev/null +++ b/src/modules/stalwart-events/stalwart-events.service.spec.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { createMock, type DeepMocked } from '@golevelup/ts-vitest'; +import { AccountService } from '../account/account.service.js'; +import { BridgeClient } from '../infrastructure/bridge/bridge.service.js'; +import { StalwartEventsService } from './stalwart-events.service.js'; +import type { + StalwartEvent, + StalwartIngestEventType, +} from './stalwart-events.types.js'; + +function ingestEvent( + overrides: Partial = {}, + type: StalwartIngestEventType = 'message-ingest.jmap-append', +): StalwartEvent { + return { + id: 'evt-1', + createdAt: '2026-06-15T00:00:00.000Z', + type, + data: { + accountId: 42, + documentId: 7, + mailboxId: [1], + blobId: 'blob-1', + size: 240, + messageId: 'msg-1', + from: 'sender@example.com', + to: ['alice@internxt.com'], + ...overrides, + }, + }; +} + +describe('StalwartEventsService', () => { + let service: StalwartEventsService; + let accounts: DeepMocked; + let bridge: DeepMocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [StalwartEventsService], + }) + .useMocker(() => createMock()) + .compile(); + + service = module.get(StalwartEventsService); + accounts = module.get(AccountService); + bridge = module.get(BridgeClient); + }); + + describe('handleBatch', () => { + it('when an ingest event resolves to a bucket, then creates a bucket entry keyed by accountId:documentId', async () => { + accounts.findBucketContextByProviderInternalId.mockResolvedValue({ + userUuid: 'user-1', + networkBucketId: 'bucket-1', + }); + bridge.createBucketEntry.mockResolvedValue({ + id: 'entry-1', + maxSpaceBytes: 1000, + totalUsedSpaceBytes: 240, + }); + + await service.handleBatch({ events: [ingestEvent()] }); + + expect( + accounts.findBucketContextByProviderInternalId, + ).toHaveBeenCalledWith('42'); + expect(bridge.createBucketEntry).toHaveBeenCalledWith( + 'user-1', + 'bucket-1', + '42:7', + 240, + ); + }); + + it('when the event type is a duplicate, then it is skipped', async () => { + await service.handleBatch({ + events: [ingestEvent({}, 'message-ingest.duplicate')], + }); + + expect( + accounts.findBucketContextByProviderInternalId, + ).not.toHaveBeenCalled(); + expect(bridge.createBucketEntry).not.toHaveBeenCalled(); + }); + + it('when the event type is not a message-ingest, then it is skipped', async () => { + const event = { + ...ingestEvent(), + type: 'delivery.completed' as unknown as StalwartEvent['type'], + }; + + await service.handleBatch({ events: [event] }); + + expect( + accounts.findBucketContextByProviderInternalId, + ).not.toHaveBeenCalled(); + expect(bridge.createBucketEntry).not.toHaveBeenCalled(); + }); + + it('when no account resolves for the event, then no bucket entry is created', async () => { + accounts.findBucketContextByProviderInternalId.mockResolvedValue(null); + + await service.handleBatch({ events: [ingestEvent()] }); + + expect(bridge.createBucketEntry).not.toHaveBeenCalled(); + }); + + it('when the resolved address has no network bucket, then no bucket entry is created', async () => { + accounts.findBucketContextByProviderInternalId.mockResolvedValue({ + userUuid: 'user-1', + networkBucketId: null, + }); + + await service.handleBatch({ events: [ingestEvent()] }); + + expect(bridge.createBucketEntry).not.toHaveBeenCalled(); + }); + + it('when the batch has several events, then each ingest event is processed', async () => { + accounts.findBucketContextByProviderInternalId.mockResolvedValue({ + userUuid: 'user-1', + networkBucketId: 'bucket-1', + }); + bridge.createBucketEntry.mockResolvedValue({ + id: 'entry-1', + maxSpaceBytes: 1000, + totalUsedSpaceBytes: 240, + }); + + await service.handleBatch({ + events: [ + ingestEvent({ documentId: 7 }), + ingestEvent({ documentId: 8 }, 'message-ingest.spam'), + ], + }); + + expect(bridge.createBucketEntry).toHaveBeenCalledTimes(2); + expect(bridge.createBucketEntry).toHaveBeenCalledWith( + 'user-1', + 'bucket-1', + '42:7', + 240, + ); + expect(bridge.createBucketEntry).toHaveBeenCalledWith( + 'user-1', + 'bucket-1', + '42:8', + 240, + ); + }); + }); +}); diff --git a/src/modules/stalwart-events/stalwart-events.service.ts b/src/modules/stalwart-events/stalwart-events.service.ts index 1ccf9b2..615ffa4 100644 --- a/src/modules/stalwart-events/stalwart-events.service.ts +++ b/src/modules/stalwart-events/stalwart-events.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +import { AccountService } from '../account/account.service.js'; +import { BridgeClient } from '../infrastructure/bridge/bridge.service.js'; import type { StalwartEvent, StalwartWebhookPayload, @@ -8,6 +10,11 @@ import type { export class StalwartEventsService { private readonly logger = new Logger(StalwartEventsService.name); + constructor( + private readonly accounts: AccountService, + private readonly bridge: BridgeClient, + ) {} + async handleBatch(payload: StalwartWebhookPayload): Promise { for (const event of payload.events) { if (event.type === 'message-ingest.duplicate') { @@ -30,14 +37,36 @@ export class StalwartEventsService { const { accountId, documentId, size } = event.data; const entryKey = `${accountId}:${documentId}`; - this.logger.log( - { entryKey, size, type: event.type }, - 'message-ingest event — Bridge entry creation pending Phase 1', + const context = await this.accounts.findBucketContextByProviderInternalId( + String(accountId), + ); + + if (!context) { + this.logger.warn( + { accountId, entryKey, type: event.type }, + 'No mail account found for ingest event; skipping bucket entry', + ); + return; + } + + if (!context.networkBucketId) { + this.logger.warn( + { accountId, entryKey, userUuid: context.userUuid }, + 'Mail address has no network bucket; skipping bucket entry', + ); + return; + } + + const { totalUsedSpaceBytes } = await this.bridge.createBucketEntry( + context.userUuid, + context.networkBucketId, + entryKey, + size, ); - // TODO: resolve accountId -> userUuid via AccountRepository, - // then call BridgeClient.createEntry(userUuid, bucketId, entryKey, size) - // dummy await - await new Promise((resolve) => setTimeout(resolve, 1000)); + this.logger.log( + { entryKey, size, totalUsedSpaceBytes, type: event.type }, + 'Created bucket entry for ingested message', + ); } } From 66755071480c7ca8bfc6c5f654a469f61e253c9d Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:14:31 -0600 Subject: [PATCH 4/5] feat(email): enhance email deletion process with quota management - Updated `deleteEmail` method in `EmailService` to handle quota entry release upon email deletion. - Introduced `releaseQuotaEntry` method to manage quota entries based on email deletion results. - Modified `deleteEmail` in `MailProvider` to return a result object containing the deleted entry key. - Enhanced unit tests for `EmailService` and `JmapMailProvider` to cover new deletion behavior and quota management scenarios. - Integrated `BridgeClient` for handling quota entry deletions in the bridge service. --- src/modules/account/account.service.spec.ts | 1 + src/modules/account/account.service.ts | 7 +++ .../repositories/address.repository.spec.ts | 32 ++++++++++++ .../repositories/address.repository.ts | 16 ++++++ src/modules/email/email.module.ts | 3 +- src/modules/email/email.service.spec.ts | 52 ++++++++++++++++++- src/modules/email/email.service.ts | 40 +++++++++++++- src/modules/email/email.types.ts | 4 ++ src/modules/email/mail-provider.port.ts | 6 ++- .../bridge/bridge.service.spec.ts | 43 ++++++++++++++- .../infrastructure/bridge/bridge.service.ts | 27 ++++++++++ .../jmap/jmap-mail.provider.spec.ts | 20 +++++-- .../infrastructure/jmap/jmap-mail.provider.ts | 41 ++++++++++----- 13 files changed, 267 insertions(+), 25 deletions(-) diff --git a/src/modules/account/account.service.spec.ts b/src/modules/account/account.service.spec.ts index 97ff101..242db28 100644 --- a/src/modules/account/account.service.spec.ts +++ b/src/modules/account/account.service.spec.ts @@ -532,6 +532,7 @@ describe('AccountService', () => { 'Bridge down', ); expect(provider.deleteAccount).toHaveBeenCalledWith(params.address); + expect(addresses.deleteProviderLink).toHaveBeenCalledWith('addr-id'); expect(accounts.delete).toHaveBeenCalledWith(createdAccount.id); expect(accounts.setNetworkBucketId).not.toHaveBeenCalled(); }); diff --git a/src/modules/account/account.service.ts b/src/modules/account/account.service.ts index a06c0da..b877420 100644 --- a/src/modules/account/account.service.ts +++ b/src/modules/account/account.service.ts @@ -123,6 +123,12 @@ export class AccountService { ); } + async findBucketContextByAddress( + address: string, + ): Promise { + return this.addresses.findBucketContextByAddress(address); + } + async getAddressKeys( userId: string, address: string, @@ -234,6 +240,7 @@ export class AccountService { await this.createNetworkBucket(params.userId, addressId); } catch (error) { await this.provider.deleteAccount(created.externalId); + await this.addresses.deleteProviderLink(addressId); await this.accounts.delete(account.id); throw error; } diff --git a/src/modules/account/repositories/address.repository.spec.ts b/src/modules/account/repositories/address.repository.spec.ts index 888801b..b85c944 100644 --- a/src/modules/account/repositories/address.repository.spec.ts +++ b/src/modules/account/repositories/address.repository.spec.ts @@ -169,6 +169,38 @@ describe('AddressRepository', () => { }); }); + describe('findBucketContextByAddress', () => { + it('when the address resolves to an account, then returns userUuid and networkBucketId', async () => { + const model = { + networkBucketId: 'bucket-1', + account: { userId: 'user-uuid-1' }, + } as unknown as MailAddressModel; + addressModel.findOne.mockResolvedValue(model); + + const result = + await repository.findBucketContextByAddress('alice@internxt.com'); + + expect(addressModel.findOne).toHaveBeenCalledWith({ + where: { address: 'alice@internxt.com' }, + include: [{ model: MailAccountModel, required: true }], + }); + expect(result).toEqual({ + userUuid: 'user-uuid-1', + networkBucketId: 'bucket-1', + }); + }); + + it('when the address has no linked account, then returns null', async () => { + addressModel.findOne.mockResolvedValue(null); + + const result = await repository.findBucketContextByAddress( + 'missing@internxt.com', + ); + + expect(result).toBeNull(); + }); + }); + describe('setNetworkBucketId', () => { it('when given an id and bucket id, then updates the address row', async () => { await repository.setNetworkBucketId('addr-1', 'bucket-1'); diff --git a/src/modules/account/repositories/address.repository.ts b/src/modules/account/repositories/address.repository.ts index ffc821f..ec11742 100644 --- a/src/modules/account/repositories/address.repository.ts +++ b/src/modules/account/repositories/address.repository.ts @@ -99,6 +99,22 @@ export class AddressRepository { return model?.account?.userId ?? null; } + async findBucketContextByAddress( + address: string, + ): Promise { + const model = await this.addressModel.findOne({ + where: { address }, + include: [{ model: MailAccountModel, required: true }], + }); + + if (!model?.account) return null; + + return { + userUuid: model.account.userId, + networkBucketId: model.networkBucketId, + }; + } + async findBucketContextByProviderInternalId( providerInternalId: string, ): Promise { diff --git a/src/modules/email/email.module.ts b/src/modules/email/email.module.ts index f93dbf5..876af55 100644 --- a/src/modules/email/email.module.ts +++ b/src/modules/email/email.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { BridgeModule } from '../infrastructure/bridge/bridge.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'; @@ -7,7 +8,7 @@ import { EmailService } from './email.service.js'; import { Reflector } from '@nestjs/core'; @Module({ - imports: [JmapModule, SmtpModule, ProvisioningModule], + imports: [JmapModule, SmtpModule, ProvisioningModule, BridgeModule], controllers: [EmailController], providers: [EmailService, Reflector], }) diff --git a/src/modules/email/email.service.spec.ts b/src/modules/email/email.service.spec.ts index 32a57a1..a547dc6 100644 --- a/src/modules/email/email.service.spec.ts +++ b/src/modules/email/email.service.spec.ts @@ -8,6 +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 { StalwartSmtpService } from '../infrastructure/smtp/stalwart-smtp.service.js'; import { newMailbox, @@ -38,6 +39,7 @@ describe('EmailService', () => { let accountService: DeepMocked; let smtp: DeepMocked; let configService: DeepMocked; + let bridge: DeepMocked; const userEmail = 'test@example.com'; beforeEach(async () => { @@ -53,6 +55,7 @@ describe('EmailService', () => { accountService = module.get>(AccountService); smtp = module.get>(StalwartSmtpService); configService = module.get>(ConfigService); + bridge = module.get>(BridgeClient); }); describe('getMailboxes', () => { @@ -455,12 +458,57 @@ describe('EmailService', () => { }); describe('deleteEmail', () => { - it('when called, then delegates to provider', async () => { - provider.deleteEmail.mockResolvedValue(undefined); + it('when the message is moved to trash, then no quota entry is released', async () => { + provider.deleteEmail.mockResolvedValue({ deletedEntryKey: null }); await service.deleteEmail(userEmail, 'email-id'); expect(provider.deleteEmail).toHaveBeenCalledWith(userEmail, 'email-id'); + expect(bridge.deleteBucketEntry).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({ + userUuid: 'user-1', + networkBucketId: 'bucket-1', + }); + + await service.deleteEmail(userEmail, 'email-id'); + + expect(accountService.findBucketContextByAddress).toHaveBeenCalledWith( + userEmail, + ); + expect(bridge.deleteBucketEntry).toHaveBeenCalledWith( + 'user-1', + 'bucket-1', + '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({ + userUuid: 'user-1', + networkBucketId: null, + }); + + await service.deleteEmail(userEmail, 'email-id'); + + expect(bridge.deleteBucketEntry).not.toHaveBeenCalled(); + }); + + it('when releasing the quota entry fails, then the deletion still succeeds', async () => { + provider.deleteEmail.mockResolvedValue({ deletedEntryKey: '42:7' }); + accountService.findBucketContextByAddress.mockResolvedValue({ + userUuid: 'user-1', + networkBucketId: 'bucket-1', + }); + bridge.deleteBucketEntry.mockRejectedValue(new Error('Bridge down')); + + await expect( + service.deleteEmail(userEmail, 'email-id'), + ).resolves.toBeUndefined(); }); }); diff --git a/src/modules/email/email.service.ts b/src/modules/email/email.service.ts index d45a2f4..a9879d3 100644 --- a/src/modules/email/email.service.ts +++ b/src/modules/email/email.service.ts @@ -1,10 +1,12 @@ import { BadRequestException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { AccountService } from '../account/account.service.js'; +import { BridgeClient } from '../infrastructure/bridge/bridge.service.js'; import { MailProvider } from './mail-provider.port.js'; import type { DraftEmailDto, @@ -51,11 +53,14 @@ async function streamToBuffer(stream: Readable): Promise { @Injectable() export class EmailService { + private readonly logger = new Logger(EmailService.name); + constructor( private readonly mail: MailProvider, private readonly accountService: AccountService, private readonly smtp: StalwartSmtpService, private readonly configService: ConfigService, + private readonly bridge: BridgeClient, ) {} getMailboxes(userEmail: string): Promise { @@ -248,8 +253,39 @@ export class EmailService { return this.mail.moveEmail(userEmail, id, target); } - deleteEmail(userEmail: string, id: string): Promise { - return this.mail.deleteEmail(userEmail, id); + async deleteEmail(userEmail: string, id: string): Promise { + const { deletedEntryKey } = await this.mail.deleteEmail(userEmail, id); + if (!deletedEntryKey) return; + + await this.releaseQuotaEntry(userEmail, deletedEntryKey); + } + + private async releaseQuotaEntry( + userEmail: string, + entryKey: string, + ): Promise { + const context = + await this.accountService.findBucketContextByAddress(userEmail); + + if (!context?.networkBucketId) { + this.logger.warn( + { userEmail, entryKey }, + 'Destroyed message has no network bucket; skipping quota release', + ); + return; + } + + try { + await this.bridge.deleteBucketEntry( + context.userUuid, + context.networkBucketId, + entryKey, + ); + } catch (error) { + this.logger.warn( + `Failed to release quota entry '${entryKey}' for '${userEmail}': ${(error as Error).message}`, + ); + } } markAsRead(userEmail: string, id: string, read: boolean): Promise { diff --git a/src/modules/email/email.types.ts b/src/modules/email/email.types.ts index 4ab1b1a..8c6f4a8 100644 --- a/src/modules/email/email.types.ts +++ b/src/modules/email/email.types.ts @@ -22,6 +22,10 @@ export interface Mailbox { export type MailDeliveryMode = 'INTERNXT' | 'EXTERNAL'; +export interface DeleteEmailResult { + deletedEntryKey: string | null; +} + export interface EncryptedSummaryFields { encryptedPreview: string; wrappedKeys: EncryptedWrappedKey[]; diff --git a/src/modules/email/mail-provider.port.ts b/src/modules/email/mail-provider.port.ts index 5f1833a..269bbea 100644 --- a/src/modules/email/mail-provider.port.ts +++ b/src/modules/email/mail-provider.port.ts @@ -5,6 +5,7 @@ import { type UploadAttachmentResponse, } from '../infrastructure/jmap/jmap.types.js'; import type { + DeleteEmailResult, DraftEmailDto, Email, EmailListResponse, @@ -41,7 +42,10 @@ export abstract class MailProvider { id: string, target: MailboxType, ): Promise; - abstract deleteEmail(userEmail: string, id: string): Promise; + abstract deleteEmail( + userEmail: string, + id: string, + ): Promise; abstract markAsRead( userEmail: string, id: string, diff --git a/src/modules/infrastructure/bridge/bridge.service.spec.ts b/src/modules/infrastructure/bridge/bridge.service.spec.ts index b4f7f8e..9910091 100644 --- a/src/modules/infrastructure/bridge/bridge.service.spec.ts +++ b/src/modules/infrastructure/bridge/bridge.service.spec.ts @@ -76,7 +76,7 @@ describe('BridgeClient', () => { jwtService.sign.mockReturnValue('signed-jwt'); httpRequest.mockResolvedValue({ statusCode: 500, - body: { text: () => Promise.resolve('boom') }, + body: { text: () => Promise.resolve('internal error') }, }); const error: unknown = await service @@ -194,4 +194,45 @@ describe('BridgeClient', () => { expect(error.details).toBe('bucket not found'); }); }); + + describe('deleteBucketEntry', () => { + it('when Bridge returns 200, then signs a token and DELETEs the url-encoded entry key', async () => { + jwtService.sign.mockReturnValue('signed-jwt'); + httpRequest.mockResolvedValue({ + statusCode: 200, + body: { text: () => Promise.resolve('') }, + }); + + await service.deleteBucketEntry('user-1', 'bucket-1', '42:7'); + + expect(httpRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'DELETE', + path: '/v2/gateway/users/user-1/buckets/bucket-1/entries/42%3A7', + headers: expect.objectContaining({ + authorization: 'Bearer signed-jwt', + }) as unknown, + }), + ); + }); + + it('when Bridge returns a non-200 status, then throws BridgeApiError with statusCode and details', async () => { + jwtService.sign.mockReturnValue('signed-jwt'); + httpRequest.mockResolvedValue({ + statusCode: 404, + body: { text: () => Promise.resolve('entry not found') }, + }); + + const error: unknown = await service + .deleteBucketEntry('user-1', 'bucket-1', '42:7') + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(BridgeApiError); + if (!(error instanceof BridgeApiError)) { + throw new Error('expected BridgeApiError'); + } + expect(error.statusCode).toBe(404); + expect(error.details).toBe('entry not found'); + }); + }); }); diff --git a/src/modules/infrastructure/bridge/bridge.service.ts b/src/modules/infrastructure/bridge/bridge.service.ts index 59a808a..1068606 100644 --- a/src/modules/infrastructure/bridge/bridge.service.ts +++ b/src/modules/infrastructure/bridge/bridge.service.ts @@ -130,6 +130,33 @@ export class BridgeClient implements OnModuleInit, OnModuleDestroy { return JSON.parse(text) as BucketEntry; } + async deleteBucketEntry( + userUuid: string, + bucketId: string, + key: string, + ): Promise { + 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)}`, + headers: { + accept: 'application/json', + authorization: `Bearer ${token}`, + }, + }); + + const text = await body.text(); + + if (statusCode !== 200) { + throw new BridgeApiError( + `Failed to delete bucket entry '${key}' on bucket '${bucketId}' for user '${userUuid}': HTTP ${statusCode}`, + statusCode, + text, + ); + } + } + private signGatewayToken(userUuid: string): string { return this.jwtService.sign( { payload: { uuid: userUuid } }, diff --git a/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts b/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts index 74488e2..a3fe329 100644 --- a/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts +++ b/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts @@ -320,11 +320,13 @@ describe('JmapMailProvider', () => { }); describe('deleteEmail', () => { - it('When email is in trash, then it permanently destroys it', async () => { + it('When email is in trash, then it permanently destroys it and returns the quota entry key', async () => { const trashMailbox = newJmapMailbox({ role: 'trash' }); const emailInTrash = newJmapEmail({ + id: 'c', mailboxIds: { [trashMailbox.id]: true }, }); + jmapService.getPrimaryAccountId.mockResolvedValue('b'); jmapService.request.mockResolvedValueOnce( jmapResponse({ list: [emailInTrash] }), @@ -334,11 +336,15 @@ describe('JmapMailProvider', () => { ); jmapService.request.mockResolvedValueOnce(jmapResponse({})); - await provider.deleteEmail('user@test.com', emailInTrash.id); + const result = await provider.deleteEmail( + 'user@test.com', + emailInTrash.id, + ); const lastCall = jmapService.request.mock.calls.at(-1)!; const methodArgs = lastCall[1][0]![1]; expect(methodArgs['destroy']).toEqual([emailInTrash.id]); + expect(result).toEqual({ deletedEntryKey: '1:2' }); }); it('When email is not in trash, then it moves it to trash', async () => { @@ -355,7 +361,10 @@ describe('JmapMailProvider', () => { ); jmapService.request.mockResolvedValueOnce(jmapResponse({})); - await provider.deleteEmail('user@test.com', emailNotInTrash.id); + const result = await provider.deleteEmail( + 'user@test.com', + emailNotInTrash.id, + ); const lastCall = jmapService.request.mock.calls.at(-1)!; const methodArgs = lastCall[1][0]![1]; @@ -363,14 +372,15 @@ describe('JmapMailProvider', () => { expect(update[emailNotInTrash.id]).toEqual({ mailboxIds: { [trashMailbox.id]: true }, }); + expect(result).toEqual({ deletedEntryKey: null }); }); - it('When email does not exist, then it returns without error', async () => { + it('When email does not exist, then it returns without a quota entry key', async () => { jmapService.request.mockResolvedValue(jmapResponse({ list: [] })); await expect( provider.deleteEmail('user@test.com', 'nonexistent'), - ).resolves.toBeUndefined(); + ).resolves.toEqual({ deletedEntryKey: null }); }); }); diff --git a/src/modules/infrastructure/jmap/jmap-mail.provider.ts b/src/modules/infrastructure/jmap/jmap-mail.provider.ts index bb50b85..d40931b 100644 --- a/src/modules/infrastructure/jmap/jmap-mail.provider.ts +++ b/src/modules/infrastructure/jmap/jmap-mail.provider.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { MailProvider } from '../../email/mail-provider.port.js'; import type { + DeleteEmailResult, DraftEmailDto, Email, EmailListResponse, @@ -10,6 +11,10 @@ import type { SearchEmailFilter, SendEmailDto, } from '../../email/email.types.js'; +import { + decodeStalwartId, + decodeStalwartIdBig, +} from '../stalwart/stalwart-id.codec.js'; import { JmapService } from './jmap.service.js'; import type { DownloadAttachmentPayload, @@ -396,7 +401,7 @@ export class JmapMailProvider extends MailProvider { ]); } - async deleteEmail(userEmail: string, id: string): Promise { + async deleteEmail(userEmail: string, id: string): Promise { const accountId = await this.jmap.getPrimaryAccountId(userEmail); const response = await this.jmap.request>( @@ -411,7 +416,7 @@ export class JmapMailProvider extends MailProvider { ); const email = response.methodResponses[0]![1].list[0]; - if (!email) return; + if (!email) return { deletedEntryKey: null }; const trashMailboxId = await this.resolveMailboxId(userEmail, 'trash'); const isInTrash = !!email.mailboxIds[trashMailboxId]; @@ -420,18 +425,28 @@ export class JmapMailProvider extends MailProvider { await this.jmap.request>(userEmail, [ ['Email/set', { accountId, destroy: [id] }, 'r0'], ]); - } else { - await this.jmap.request>(userEmail, [ - [ - 'Email/set', - { - accountId, - update: { [id]: { mailboxIds: { [trashMailboxId]: true } } }, - }, - 'r0', - ], - ]); + + return { deletedEntryKey: this.buildEntryKey(accountId, id) }; } + + await this.jmap.request>(userEmail, [ + [ + 'Email/set', + { + accountId, + update: { [id]: { mailboxIds: { [trashMailboxId]: true } } }, + }, + 'r0', + ], + ]); + + return { deletedEntryKey: null }; + } + + private buildEntryKey(accountId: string, emailId: string): string { + const numericAccountId = decodeStalwartId(accountId); + const documentId = decodeStalwartId(emailId) % 2 ** 32; + return `${numericAccountId}:${documentId}`; } async markAsRead( From 96cf7aaffe47dfb2519a88a0b737be49d994362d Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:19:30 -0600 Subject: [PATCH 5/5] feat: enhance Stalwart ID decoding and authentication guard - Introduced `decodeStalwartIdBig` function to handle Stalwart IDs exceeding JavaScript's safe integer range, allowing for proper decoding of email IDs. - Updated `decodeStalwartId` to utilize the new decoding function and throw an error for IDs exceeding safe integer limits. - Enhanced `StalwartEventsAuthGuard` to throw an `UnauthorizedException` for malformed authorization headers lacking a colon separator. - Added unit tests for the new decoding function and authentication guard to ensure robust error handling and functionality. --- .../infrastructure/jmap/jmap-mail.provider.ts | 2 +- .../stalwart/stalwart-id.codec.spec.ts | 40 ++++++++- .../stalwart/stalwart-id.codec.ts | 8 +- .../stalwart-events-auth.guard.spec.ts | 82 +++++++++++++++++++ .../stalwart-events-auth.guard.ts | 3 + 5 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 src/modules/stalwart-events/stalwart-events-auth.guard.spec.ts diff --git a/src/modules/infrastructure/jmap/jmap-mail.provider.ts b/src/modules/infrastructure/jmap/jmap-mail.provider.ts index d40931b..0b07278 100644 --- a/src/modules/infrastructure/jmap/jmap-mail.provider.ts +++ b/src/modules/infrastructure/jmap/jmap-mail.provider.ts @@ -445,7 +445,7 @@ export class JmapMailProvider extends MailProvider { private buildEntryKey(accountId: string, emailId: string): string { const numericAccountId = decodeStalwartId(accountId); - const documentId = decodeStalwartId(emailId) % 2 ** 32; + const documentId = decodeStalwartIdBig(emailId) & 0xffffffffn; return `${numericAccountId}:${documentId}`; } diff --git a/src/modules/infrastructure/stalwart/stalwart-id.codec.spec.ts b/src/modules/infrastructure/stalwart/stalwart-id.codec.spec.ts index 1d66eea..665d583 100644 --- a/src/modules/infrastructure/stalwart/stalwart-id.codec.spec.ts +++ b/src/modules/infrastructure/stalwart/stalwart-id.codec.spec.ts @@ -1,5 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { decodeStalwartId } from './stalwart-id.codec.js'; +import { decodeStalwartId, decodeStalwartIdBig } from './stalwart-id.codec.js'; + +const ALPHABET = 'abcdefghijklmnopqrstuvwxyz792013'; + +function encodeStalwartId(value: bigint): string { + if (value === 0n) return ALPHABET.charAt(0); + let v = value; + let out = ''; + while (v > 0n) { + out = ALPHABET.charAt(Number(v % 32n)) + out; + v /= 32n; + } + return out; +} describe('decodeStalwartId', () => { it('when given single-character ids, then decodes alphabet positions', () => { @@ -37,3 +50,28 @@ describe('decodeStalwartId', () => { ); }); }); + +describe('decodeStalwartIdBig', () => { + it('when given small ids, then returns the value as a bigint', () => { + expect(decodeStalwartIdBig('a')).toBe(0n); + expect(decodeStalwartIdBig('ba')).toBe(32n); + }); + + it('when the value exceeds the safe integer range, then decodes without throwing', () => { + const threadId = 5_000_000n; + const documentId = 13n; + const emailId = (threadId << 32n) | documentId; + const encoded = encodeStalwartId(emailId); + + expect(emailId > BigInt(Number.MAX_SAFE_INTEGER)).toBe(true); + expect(decodeStalwartIdBig(encoded)).toBe(emailId); + expect(decodeStalwartIdBig(encoded) & 0xffffffffn).toBe(documentId); + expect(() => decodeStalwartId(encoded)).toThrow( + 'exceeds safe integer range', + ); + }); + + it('when given a character outside the alphabet, then throws', () => { + expect(() => decodeStalwartIdBig('b4')).toThrow("Invalid character '4'"); + }); +}); diff --git a/src/modules/infrastructure/stalwart/stalwart-id.codec.ts b/src/modules/infrastructure/stalwart/stalwart-id.codec.ts index 20875c3..7df484f 100644 --- a/src/modules/infrastructure/stalwart/stalwart-id.codec.ts +++ b/src/modules/infrastructure/stalwart/stalwart-id.codec.ts @@ -7,7 +7,7 @@ const CHAR_VALUES = new Map( [...STALWART_BASE32_ALPHABET].map((char, index) => [char, BigInt(index)]), ); -export function decodeStalwartId(id: string): number { +export function decodeStalwartIdBig(id: string): bigint { if (id.length === 0) { throw new Error('Cannot decode empty Stalwart id'); } @@ -21,6 +21,12 @@ export function decodeStalwartId(id: string): number { value = value * 32n + digit; } + return value; +} + +export function decodeStalwartId(id: string): number { + const value = decodeStalwartIdBig(id); + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { throw new Error(`Stalwart id '${id}' exceeds safe integer range`); } diff --git a/src/modules/stalwart-events/stalwart-events-auth.guard.spec.ts b/src/modules/stalwart-events/stalwart-events-auth.guard.spec.ts new file mode 100644 index 0000000..f91f0e6 --- /dev/null +++ b/src/modules/stalwart-events/stalwart-events-auth.guard.spec.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { UnauthorizedException } from '@nestjs/common'; +import type { ExecutionContext } from '@nestjs/common'; +import { StalwartEventsAuthGuard } from './stalwart-events-auth.guard.js'; + +function basicAuth(username: string, password: string): string { + return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; +} + +function createContext(authHeader?: string): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => ({ + headers: { authorization: authHeader }, + }), + }), + } as ExecutionContext; +} + +describe('StalwartEventsAuthGuard', () => { + let guard: StalwartEventsAuthGuard; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StalwartEventsAuthGuard, + { + provide: ConfigService, + useValue: { + get: (key: string) => { + if (key === 'stalwartWebhook.username') return 'username'; + if (key === 'stalwartWebhook.secret') return 'secret'; + return undefined; + }, + }, + }, + ], + }).compile(); + + guard = module.get(StalwartEventsAuthGuard); + }); + + it('when credentials are valid, then allows access', () => { + expect( + guard.canActivate(createContext(basicAuth('username', 'secret'))), + ).toBe(true); + }); + + it('when the authorization header is missing, then rejects', () => { + expect(() => guard.canActivate(createContext())).toThrow( + UnauthorizedException, + ); + }); + + it('when the authorization scheme is not Basic, then rejects', () => { + expect(() => guard.canActivate(createContext('Bearer token'))).toThrow( + UnauthorizedException, + ); + }); + + it('when decoded credentials lack a colon separator, then rejects', () => { + const malformed = `Basic ${Buffer.from('foob').toString('base64')}`; + + expect(() => guard.canActivate(createContext(malformed))).toThrow( + UnauthorizedException, + ); + }); + + it('when the username is wrong, then rejects', () => { + expect(() => + guard.canActivate(createContext(basicAuth('wrong', 'secret'))), + ).toThrow(UnauthorizedException); + }); + + it('when the password is wrong, then rejects', () => { + expect(() => + guard.canActivate(createContext(basicAuth('username', 'wrong'))), + ).toThrow(UnauthorizedException); + }); +}); diff --git a/src/modules/stalwart-events/stalwart-events-auth.guard.ts b/src/modules/stalwart-events/stalwart-events-auth.guard.ts index 7df6977..21c46d7 100644 --- a/src/modules/stalwart-events/stalwart-events-auth.guard.ts +++ b/src/modules/stalwart-events/stalwart-events-auth.guard.ts @@ -22,6 +22,9 @@ export class StalwartEventsAuthGuard implements CanActivate { const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8'); const colonIndex = decoded.indexOf(':'); + if (colonIndex === -1) { + throw new UnauthorizedException(); + } const username = decoded.slice(0, colonIndex); const password = decoded.slice(colonIndex + 1);