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
6 changes: 6 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,9 @@ GATEWAY_PUBLIC_SECRET=

# External APIs
PAYMENTS_API_URL=
BRIDGE_API_URL=
BRIDGE_PRIVATE_GATEWAY_SECRET=

# MTA hooks
MTA_HOOKS_USERNAME=
MTA_HOOKS_SECRET=
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use strict';

const TABLE_NAME = 'mail_addresses';

/** @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');
},
};
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { EmailModule } from './modules/email/email.module';
import { AuthModule } from './modules/auth/auth.module';
import { AccountModule } from './modules/account/account.module';
import { GatewayModule } from './modules/gateway/gateway.module';
import { MtaHooksModule } from './modules/mta-hooks/mta-hooks.module';
import { HttpGlobalExceptionFilter } from './common/filters/http-global-exception.filter';
import { AddressesModule } from './modules/addresses/addresses.module';

Expand Down Expand Up @@ -87,6 +88,7 @@ import { AddressesModule } from './modules/addresses/addresses.module';
AccountModule,
AddressesModule,
GatewayModule,
MtaHooksModule,
],
controllers: [],
providers: [
Expand Down
5 changes: 5 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export default () => ({
),
},

mtaHooks: {
username: process.env.MTA_HOOKS_USERNAME ?? 'stalwart',
secret: process.env.MTA_HOOKS_SECRET ?? '',
},

secrets: {
jwt: process.env.JWT_SECRET,
drivePublicGateway: process.env.GATEWAY_PUBLIC_SECRET,
Expand Down
2 changes: 2 additions & 0 deletions src/modules/account/account.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { SequelizeModule } from '@nestjs/sequelize';
import { Reflector } from '@nestjs/core';
import { StalwartModule } from '../infrastructure/stalwart/stalwart.module.js';
import { PaymentsModule } from '../infrastructure/payments/payments.module.js';
import { BridgeModule } from '../infrastructure/bridge/bridge.module.js';
import { AccountService } from './account.service.js';
import { UserController } from './user.controller.js';
import { MailAccountGuard } from '../provisioning/provisioning.guard.js';
Expand All @@ -29,6 +30,7 @@ import { MailAddressKeysRepository } from './repositories/mail-address-keys.repo
]),
StalwartModule,
PaymentsModule,
BridgeModule,
],
controllers: [UserController],
providers: [
Expand Down
189 changes: 188 additions & 1 deletion src/modules/account/account.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { AccountRepository } from './repositories/account.repository.js';
import { AddressRepository } from './repositories/address.repository.js';
import { DomainRepository } from './repositories/domain.repository.js';
import { MailAddressKeysRepository } from './repositories/mail-address-keys.repository.js';
import { BridgeClient } from '../infrastructure/bridge/bridge.service.js';
import {
newMailAccountAttributes,
newMailAddressKeyBundle,
Expand All @@ -33,6 +34,7 @@ describe('AccountService', () => {
let addresses: DeepMocked<AddressRepository>;
let domains: DeepMocked<DomainRepository>;
let keys: DeepMocked<MailAddressKeysRepository>;
let bridge: DeepMocked<BridgeClient>;
let config: DeepMocked<ConfigService>;

beforeEach(async () => {
Expand All @@ -48,6 +50,7 @@ describe('AccountService', () => {
addresses = module.get(AddressRepository);
domains = module.get(DomainRepository);
keys = module.get(MailAddressKeysRepository);
bridge = module.get(BridgeClient);
config = module.get(ConfigService);
});

Expand Down Expand Up @@ -138,6 +141,75 @@ describe('AccountService', () => {
});
});

describe('findRecipientContext', () => {
it('when the address has a bucket, then returns the user and bucket without provisioning', async () => {
addresses.findRecipientContextByAddress.mockResolvedValue({
addressId: 'address-1',
userUuid: 'user-1',
networkBucketId: 'bucket-1',
});

const result = await service.findRecipientContext('alice@internxt.com');

expect(result).toEqual({
userUuid: 'user-1',
networkBucketId: 'bucket-1',
});
expect(bridge.createMailBucket).not.toHaveBeenCalled();
expect(addresses.setNetworkBucketId).not.toHaveBeenCalled();
});

it('when the address has no bucket, then lazily provisions one and persists it', async () => {
addresses.findRecipientContextByAddress.mockResolvedValue({
addressId: 'address-1',
userUuid: 'user-1',
networkBucketId: null,
});
bridge.createMailBucket.mockResolvedValue({
id: 'bucket-new',
name: 'address-1',
});

const result = await service.findRecipientContext('alice@internxt.com');

expect(bridge.createMailBucket).toHaveBeenCalledWith(
'user-1',
'address-1',
);
expect(addresses.setNetworkBucketId).toHaveBeenCalledWith(
'address-1',
'bucket-new',
);
expect(result).toEqual({
userUuid: 'user-1',
networkBucketId: 'bucket-new',
});
});

it('when lazy provisioning fails, then the error propagates', async () => {
addresses.findRecipientContextByAddress.mockResolvedValue({
addressId: 'address-1',
userUuid: 'user-1',
networkBucketId: null,
});
bridge.createMailBucket.mockRejectedValue(new Error('bridge down'));

await expect(
service.findRecipientContext('alice@internxt.com'),
).rejects.toThrow('bridge down');
expect(addresses.setNetworkBucketId).not.toHaveBeenCalled();
});

it('when the address does not exist, then returns null', async () => {
addresses.findRecipientContextByAddress.mockResolvedValue(null);

const result = await service.findRecipientContext('unknown@internxt.com');

expect(result).toBeNull();
expect(bridge.createMailBucket).not.toHaveBeenCalled();
});
});

describe('getAddressKeys', () => {
it('when address belongs to user, then returns the key bundle', async () => {
const addr = newMailAddressAttributes();
Expand Down Expand Up @@ -364,20 +436,30 @@ describe('AccountService', () => {
}),
);

const bucket = { id: 'bucket-1', name: createdAddressId };
domains.findByDomain.mockResolvedValue(domain);
addresses.findByAddress.mockResolvedValue(null);
accounts.findByUserId
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(provisionedAccount);
accounts.create.mockResolvedValue(createdAccount);
addresses.create.mockResolvedValue(createdAddressId);
bridge.createMailBucket.mockResolvedValue(bucket);

const result = await service.provisionAccount(params);

expect(result.userId).toBe(params.userId);
expect(accounts.create).toHaveBeenCalledWith({
userId: params.userId,
});
expect(bridge.createMailBucket).toHaveBeenCalledWith(
params.userId,
createdAddressId,
);
expect(addresses.setNetworkBucketId).toHaveBeenCalledWith(
createdAddressId,
bucket.id,
);
expect(addresses.create).toHaveBeenCalledWith({
mailAccountId: createdAccount.id,
address: params.address,
Expand Down Expand Up @@ -463,6 +545,29 @@ describe('AccountService', () => {
expect(accounts.delete).toHaveBeenCalledWith(createdAccount.id);
});

it('when bucket creation fails, then deletes the principal and account (undo) and rethrows', async () => {
const createdAccount = MailAccount.build(
newMailAccountAttributes({
userId: params.userId,
addresses: [],
}),
);

domains.findByDomain.mockResolvedValue(domain);
addresses.findByAddress.mockResolvedValue(null);
accounts.findByUserId.mockResolvedValue(null);
accounts.create.mockResolvedValue(createdAccount);
addresses.create.mockResolvedValue('addr-id');
bridge.createMailBucket.mockRejectedValue(new Error('Bridge down'));

await expect(service.provisionAccount(params)).rejects.toThrow(
'Bridge down',
);
expect(provider.deleteAccount).toHaveBeenCalledWith(params.address);
expect(accounts.delete).toHaveBeenCalledWith(createdAccount.id);
expect(addresses.setNetworkBucketId).not.toHaveBeenCalled();
});

it('when concurrent provisioning race occurs, then returns the existing account', async () => {
const existingAccount = MailAccount.build(
newMailAccountAttributes({ userId: params.userId }),
Expand Down Expand Up @@ -506,6 +611,47 @@ 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' });
const account = MailAccount.build(
newMailAccountAttributes({ addresses: [addr] }),
);
accounts.findByUserId.mockResolvedValue(account);

await service.deleteAccount(account.userId);

expect(bridge.deleteMailBucket).toHaveBeenCalledWith(
account.userId,
'bucket-1',
);
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 });
const account = MailAccount.build(
newMailAccountAttributes({ addresses: [addr] }),
);
accounts.findByUserId.mockResolvedValue(account);

await service.deleteAccount(account.userId);

expect(bridge.deleteMailBucket).not.toHaveBeenCalled();
});

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] }),
);
accounts.findByUserId.mockResolvedValue(account);
bridge.deleteMailBucket.mockRejectedValue(new Error('Bridge down'));

await service.deleteAccount(account.userId);

expect(accounts.delete).toHaveBeenCalledWith(account.id);
});

it('when account does not exist, then throws NotFoundException', async () => {
accounts.findByUserId.mockResolvedValue(null);

Expand All @@ -528,6 +674,10 @@ describe('AccountService', () => {
domains.findByDomain.mockResolvedValue(domain);
addresses.findByAddress.mockResolvedValue(null);
addresses.create.mockResolvedValue(newAddressId);
bridge.createMailBucket.mockResolvedValue({
id: 'bucket-1',
name: newAddressId,
});

await service.addAddress(
accountAttrs.userId,
Expand All @@ -554,6 +704,36 @@ describe('AccountService', () => {
provider: 'stalwart',
externalId: newAddr,
});
expect(bridge.createMailBucket).toHaveBeenCalledWith(
accountAttrs.userId,
newAddressId,
);
expect(addresses.setNetworkBucketId).toHaveBeenCalledWith(
newAddressId,
'bucket-1',
);
});

it('when bucket creation fails, then rolls back principal, link, and address', async () => {
const account = MailAccount.build(newMailAccountAttributes());
const domain = MailDomain.build(newMailDomainAttributes());
const newAddr = 'new@example.com';
const newAddressId = 'new-address-id';

accounts.findByUserId.mockResolvedValue(account);
domains.findByDomain.mockResolvedValue(domain);
addresses.findByAddress.mockResolvedValue(null);
addresses.create.mockResolvedValue(newAddressId);
bridge.createMailBucket.mockRejectedValue(new Error('Bridge down'));

await expect(
service.addAddress(account.userId, newAddr, domain.domain, 'pass'),
).rejects.toThrow('Bridge down');

expect(provider.deleteAccount).toHaveBeenCalledWith(newAddr);
expect(addresses.deleteProviderLink).toHaveBeenCalledWith(newAddressId);
expect(addresses.delete).toHaveBeenCalledWith(newAddressId);
expect(addresses.setNetworkBucketId).not.toHaveBeenCalled();
});

it('when account not found, then throws NotFoundException', async () => {
Expand Down Expand Up @@ -625,7 +805,10 @@ describe('AccountService', () => {

describe('removeAddress', () => {
it('when address exists and is not default, then deletes principal and address', async () => {
const nonDefaultAddr = newMailAddressAttributes({ isDefault: false });
const nonDefaultAddr = newMailAddressAttributes({
isDefault: false,
networkBucketId: 'bucket-1',
});
const account = MailAccount.build(
newMailAccountAttributes({
addresses: [
Expand All @@ -645,6 +828,10 @@ describe('AccountService', () => {
nonDefaultAddr.id,
);
expect(addresses.delete).toHaveBeenCalledWith(nonDefaultAddr.id);
expect(bridge.deleteMailBucket).toHaveBeenCalledWith(
account.userId,
'bucket-1',
);
});

it('when address is default, then throws UnprocessableEntityException', async () => {
Expand Down
Loading
Loading