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
4 changes: 4 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Original file line number Diff line number Diff line change
@@ -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');
},
};
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -87,6 +88,7 @@ import { AddressesModule } from './modules/addresses/addresses.module';
AccountModule,
AddressesModule,
GatewayModule,
StalwartEventsModule,
],
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 @@ -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 ?? '',
},
});
21 changes: 12 additions & 9 deletions src/modules/account/account.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,14 +521,20 @@ 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(
'Bridge down',
);
expect(provider.deleteAccount).toHaveBeenCalledWith(params.address);
expect(addresses.deleteProviderLink).toHaveBeenCalledWith('addr-id');
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 () => {
Expand Down Expand Up @@ -574,10 +580,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);

Expand All @@ -590,10 +595,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);

Expand All @@ -603,9 +607,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'));
Expand Down
33 changes: 32 additions & 1 deletion src/modules/account/account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -112,6 +115,20 @@ export class AccountService {
return this.addresses.findUserIdByAddress(address);
}

async findBucketContextByProviderInternalId(
providerInternalId: string,
): Promise<ProviderAccountBucketContext | null> {
return this.addresses.findBucketContextByProviderInternalId(
providerInternalId,
);
}

async findBucketContextByAddress(
address: string,
): Promise<ProviderAccountBucketContext | null> {
return this.addresses.findBucketContextByAddress(address);
}

async getAddressKeys(
userId: string,
address: string,
Expand Down Expand Up @@ -223,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;
}
Expand All @@ -241,6 +259,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}'`);
}
Expand Down
2 changes: 2 additions & 0 deletions src/modules/account/domain/mail-account.domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface MailAccountAttributes {
userId: string;
status: MailAccountState;
suspendedAt: Date | null;
networkBucketId: string | null;
addresses: MailAddressAttributes[];
createdAt: Date;
updatedAt: Date;
Expand All @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/modules/account/models/mail-account.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
12 changes: 12 additions & 0 deletions src/modules/account/repositories/account.repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down Expand Up @@ -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' } },
);
});
});
});
5 changes: 5 additions & 0 deletions src/modules/account/repositories/account.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,17 @@ export class AccountRepository {
await this.accountModel.destroy({ where: { id } });
}

async setNetworkBucketId(id: string, networkBucketId: string): Promise<void> {
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),
Expand Down
77 changes: 77 additions & 0 deletions src/modules/account/repositories/address.repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof MailAddressModel>;
let providerAccountModel: DeepMocked<typeof MailProviderAccountModel>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
Expand All @@ -19,12 +21,16 @@ describe('AddressRepository', () => {
if (token === getModelToken(MailAddressModel)) {
return createMock<typeof MailAddressModel>();
}
if (token === getModelToken(MailProviderAccountModel)) {
return createMock<typeof MailProviderAccountModel>();
}
return createMock<object>();
})
.compile();

repository = module.get(AddressRepository);
addressModel = module.get(getModelToken(MailAddressModel));
providerAccountModel = module.get(getModelToken(MailProviderAccountModel));
});

describe('findUserIdByAddress', () => {
Expand Down Expand Up @@ -124,6 +130,77 @@ 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('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('[email protected]');

expect(addressModel.findOne).toHaveBeenCalledWith({
where: { address: '[email protected]' },
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(
'[email protected]',
);

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');
Expand Down
Loading
Loading