Skip to content
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
11 changes: 10 additions & 1 deletion src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,23 @@ export default () => ({
),
},

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

secrets: {
jwt: process.env.JWT_SECRET,
gateway: process.env.GATEWAY_PUBLIC_SECRET,
drivePublicGateway: process.env.GATEWAY_PUBLIC_SECRET,
bridgePrivateGateway: process.env.BRIDGE_PRIVATE_GATEWAY_SECRET,
},

apis: {
payments: {
url: process.env.PAYMENTS_API_URL ?? '',
},
bridge: {
url: process.env.BRIDGE_API_URL ?? '',
},
},
});
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('[email protected]');

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('[email protected]');

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('[email protected]'),
).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('[email protected]');

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 = '[email protected]';
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