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
23 changes: 23 additions & 0 deletions migrations/20260421000000-add-freeze-fields-to-mail-accounts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('mail_accounts', 'enabled_at', {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('NOW()'),
});

await queryInterface.addColumn('mail_accounts', 'disabled_at', {
type: Sequelize.DATE,
allowNull: true,
defaultValue: null,
});
},

async down(queryInterface) {
await queryInterface.removeColumn('mail_accounts', 'enabled_at');
await queryInterface.removeColumn('mail_accounts', 'disabled_at');
},
};
2 changes: 2 additions & 0 deletions src/modules/account/account-provider.port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ export abstract class AccountProvider {
abstract createAccount(params: CreateAccountParams): Promise<void>;
abstract deleteAccount(name: string): Promise<void>;
abstract getAccount(name: string): Promise<AccountInfo | null>;
abstract suspendAccount(name: string): Promise<void>;
abstract reactivateAccount(name: string): Promise<void>;
}
26 changes: 26 additions & 0 deletions src/modules/account/account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,32 @@ export class AccountService {
return this.getAccountOrFail(params.userId);
}

async freezeAccount(userId: string): Promise<void> {
const account = await this.getAccountOrFail(userId);

await Promise.all(
account.addresses.map((a) =>
this.provider.suspendAccount(a.providerExternalId),
),
);

await this.accounts.freeze(account.id);
this.logger.log(`Frozen account for user '${userId}'`);
}

async reactivateAccount(userId: string): Promise<void> {
const account = await this.getAccountOrFail(userId);

await Promise.all(
account.addresses.map((a) =>
this.provider.reactivateAccount(a.providerExternalId),
),
);

await this.accounts.reactivate(account.id);
this.logger.log(`Reactivated account for user '${userId}'`);
}

async deleteAccount(driveUserUuid: string): Promise<void> {
const account = await this.getAccountOrFail(driveUserUuid);

Expand Down
8 changes: 8 additions & 0 deletions src/modules/account/domain/mail-account.domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
export interface MailAccountAttributes {
id: string;
userId: string;
enabledAt: Date;
disabledAt: Date | null;
addresses: MailAddressAttributes[];
createdAt: Date;
updatedAt: Date;
Expand All @@ -14,6 +16,8 @@ export interface MailAccountAttributes {
export class MailAccount {
readonly id!: string;
readonly userId!: string;
readonly enabledAt!: Date;
readonly disabledAt!: Date | null;
readonly addresses!: MailAddress[];
readonly createdAt!: Date;
readonly updatedAt!: Date;
Expand All @@ -27,6 +31,10 @@ export class MailAccount {
return new MailAccount(attributes);
}

get isFrozen(): boolean {
return this.disabledAt !== null;
}

get defaultAddress(): MailAddress | undefined {
return this.addresses.find((a) => a.isDefault);
}
Expand Down
8 changes: 8 additions & 0 deletions src/modules/account/models/mail-account.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ export class MailAccountModel extends Model {
@Column(DataType.UUID)
declare userId: string;

@AllowNull(false)
@Default(DataType.NOW)
@Column(DataType.DATE)
declare enabledAt: Date;

@Column(DataType.DATE)
declare disabledAt: Date | null;

@Column(DataType.DATE)
declare deletedAt: Date | null;

Expand Down
16 changes: 16 additions & 0 deletions src/modules/account/repositories/account.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,26 @@ export class AccountRepository {
await this.accountModel.destroy({ where: { id } });
}

async freeze(id: string): Promise<void> {
await this.accountModel.update(
{ disabledAt: new Date() },
{ where: { id } },
);
}

async reactivate(id: string): Promise<void> {
await this.accountModel.update(
{ disabledAt: null, enabledAt: new Date() },
{ where: { id } },
);
}

private toDomain(model: MailAccountModel): MailAccount {
return MailAccount.build({
id: model.id,
userId: model.userId,
enabledAt: model.enabledAt,
disabledAt: model.disabledAt,
createdAt: model.createdAt as Date,
updatedAt: model.updatedAt as Date,
addresses: (model.addresses ?? []).map(toAddressAttributes),
Expand Down
8 changes: 4 additions & 4 deletions src/modules/gateway/gateway.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,14 @@ export class GatewayController {
@Post('accounts/:uuid/suspend')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Suspend a mail account' })
async suspendAccount(@Param('uuid') _uuid: string) {
// mark as frozen and suspend account in Stalwart
async suspendAccount(@Param('uuid') uuid: string) {
await this.accountService.freezeAccount(uuid);
}

@Post('accounts/:uuid/reactivate')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Reactivate a mail account' })
async reactivateAccount(@Param('uuid') _uuid: string) {
// unmark as frozen and reactivate account in Stalwart
async reactivateAccount(@Param('uuid') uuid: string) {
await this.accountService.reactivateAccount(uuid);
}
}
22 changes: 22 additions & 0 deletions src/modules/infrastructure/stalwart/stalwart-account.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,26 @@ export class StalwartAccountProvider extends AccountProvider {
quota: principal.quota ?? 0,
};
}

async suspendAccount(name: string): Promise<void> {
await this.stalwart.patchPrincipal(name, [
{
action: 'addItem',
field: 'disabledPermissions',
value: 'authenticate',
},
]);
this.logger.log(`Suspended account '${name}'`);
}

async reactivateAccount(name: string): Promise<void> {
await this.stalwart.patchPrincipal(name, [
{
action: 'removeItem',
field: 'disabledPermissions',
value: 'authenticate',
},
]);
this.logger.log(`Reactivated account '${name}'`);
}
}
33 changes: 23 additions & 10 deletions src/modules/provisioning/provisioning.guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,16 @@ import { type ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { MailAccountGuard } from './provisioning.guard.js';
import { AccountService } from '../account/account.service.js';
import { MailAccount } from '../account/domain/mail-account.domain.js';
import { IS_PUBLIC_KEY } from '../auth/decorators/public.decorator.js';
import { SKIP_MAIL_ACCOUNT_CHECK_KEY } from './skip-mail-account-check.decorator.js';
import {
newMailAccountAttributes,
type DeepPartialMocked,
newUserPayload,
} from '../../../test/fixtures.js';

describe('MailAccountGuard', () => {
let guard: MailAccountGuard;
let accountService: DeepMocked<AccountService>;
let accountService: DeepPartialMocked<AccountService>;
let reflector: DeepMocked<Reflector>;

beforeEach(async () => {
Expand Down Expand Up @@ -43,10 +42,10 @@ describe('MailAccountGuard', () => {

it('when user has a provisioned account, then allows the request', async () => {
const user = newUserPayload();
const account = MailAccount.build(
newMailAccountAttributes({ userId: user.uuid }),
);
accountService.findAccount.mockResolvedValue(account);
accountService.findAccount.mockResolvedValue({
userId: user.uuid,
isFrozen: false,
});

const result = await guard.canActivate(mockContext(user));

Expand All @@ -63,9 +62,23 @@ describe('MailAccountGuard', () => {
expect.unreachable('should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(ForbiddenException);
expect((error as ForbiddenException).getResponse()).toEqual(
expect.objectContaining({ code: 'MAIL_NOT_SETUP' }),
);
expect(error).toMatchObject({ response: { code: 'MAIL_NOT_SETUP' } });
}
});

it('when user has mail account frozen, then throws ForbiddenException with MAIL_FROZEN code', async () => {
const user = newUserPayload();
accountService.findAccount.mockResolvedValue({
userId: user.uuid,
isFrozen: true,
});

try {
await guard.canActivate(mockContext(user));
expect.unreachable('should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(ForbiddenException);
expect(error).toMatchObject({ response: { code: 'MAIL_FROZEN' } });
}
});

Expand Down
8 changes: 8 additions & 0 deletions src/modules/provisioning/provisioning.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ export class MailAccountGuard implements CanActivate {
});
}

if (account.isFrozen) {
throw new ForbiddenException({
statusCode: 403,
code: 'MAIL_FROZEN',
message: 'Mail account is frozen due to plan downgrade',
});
}

return true;
}
}
2 changes: 2 additions & 0 deletions test/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,8 @@ export function newMailAccountAttributes(
addresses: [address],
createdAt: new Date(),
updatedAt: new Date(),
enabledAt: new Date(),
disabledAt: null,
...attrs,
};
}
Expand Down
Loading