Skip to content

Commit ecd534e

Browse files
committed
feat: implement mail account provisioning and recovery email functionality
- Added migration to include recovery_email field in users table. - Introduced Mail module with MailService and MailController for handling mail account creation. - Implemented MailUseCases to manage mail account provisioning and validation. - Updated user model to support recovery email and added necessary DTOs for mail account creation. - Enhanced audit logging to track mail setup actions.
1 parent dfb26bc commit ecd534e

15 files changed

Lines changed: 378 additions & 0 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
'use strict';
2+
3+
/** @type {import('sequelize-cli').Migration} */
4+
module.exports = {
5+
async up(queryInterface, Sequelize) {
6+
await queryInterface.addColumn('users', 'recovery_email', {
7+
type: Sequelize.STRING,
8+
allowNull: true,
9+
defaultValue: null,
10+
});
11+
},
12+
13+
async down(queryInterface) {
14+
await queryInterface.removeColumn('users', 'recovery_email');
15+
},
16+
};
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
'use strict';
2+
3+
const { v4 } = require('uuid');
4+
5+
const LIMIT_LABEL = 'mail-access';
6+
7+
/** @type {import('sequelize-cli').Migration} */
8+
module.exports = {
9+
async up(queryInterface) {
10+
const transaction = await queryInterface.sequelize.transaction();
11+
try {
12+
const disabledLimitId = v4();
13+
const enabledLimitId = v4();
14+
15+
await queryInterface.bulkInsert(
16+
'limits',
17+
[
18+
{
19+
id: disabledLimitId,
20+
label: LIMIT_LABEL,
21+
type: 'boolean',
22+
value: 'false',
23+
created_at: new Date(),
24+
updated_at: new Date(),
25+
},
26+
{
27+
id: enabledLimitId,
28+
label: LIMIT_LABEL,
29+
type: 'boolean',
30+
value: 'true',
31+
created_at: new Date(),
32+
updated_at: new Date(),
33+
},
34+
],
35+
{ transaction },
36+
);
37+
38+
const [tiers] = await queryInterface.sequelize.query(
39+
`SELECT id, label FROM tiers`,
40+
{ transaction },
41+
);
42+
43+
const tierLimitRelations = tiers.map((tier) => ({
44+
id: v4(),
45+
tier_id: tier.id,
46+
limit_id: disabledLimitId,
47+
created_at: new Date(),
48+
updated_at: new Date(),
49+
}));
50+
51+
await queryInterface.bulkInsert('tiers_limits', tierLimitRelations, {
52+
transaction,
53+
});
54+
55+
await transaction.commit();
56+
} catch (error) {
57+
await transaction.rollback();
58+
throw error;
59+
}
60+
},
61+
62+
async down(queryInterface) {
63+
const transaction = await queryInterface.sequelize.transaction();
64+
try {
65+
const [limits] = await queryInterface.sequelize.query(
66+
`SELECT id FROM limits WHERE label = :limitLabel`,
67+
{ replacements: { limitLabel: LIMIT_LABEL }, transaction },
68+
);
69+
70+
const limitIds = limits.map((l) => l.id);
71+
72+
if (limitIds.length > 0) {
73+
await queryInterface.sequelize.query(
74+
`DELETE FROM tiers_limits WHERE limit_id IN (:limitIds)`,
75+
{ replacements: { limitIds }, transaction },
76+
);
77+
}
78+
79+
await queryInterface.sequelize.query(
80+
`DELETE FROM limits WHERE label = :limitLabel`,
81+
{ replacements: { limitLabel: LIMIT_LABEL }, transaction },
82+
);
83+
84+
await transaction.commit();
85+
} catch (error) {
86+
await transaction.rollback();
87+
throw error;
88+
}
89+
},
90+
};

src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { getClientIdFromHeaders } from './common/decorators/client.decorator';
3232
import { AuthGuard } from './modules/auth/auth.guard';
3333
import { CacheManagerModule } from './modules/cache-manager/cache-manager.module';
3434
import { ReferralModule } from './modules/referral/referral.module';
35+
import { MailModule } from './modules/mail/mail.module';
3536

3637
const isCronjobInstance = process.env.EXECUTE_JOBS === 'true';
3738
const appName = isCronjobInstance ? 'drive-server-cronjob' : 'drive-server';
@@ -150,6 +151,7 @@ const appName = isCronjobInstance ? 'drive-server-cronjob' : 'drive-server';
150151
GatewayModule,
151152
CacheManagerModule,
152153
ReferralModule,
154+
MailModule,
153155
],
154156
controllers: [],
155157
providers: [

src/common/audit-logs/audit-logs.attributes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export enum AuditAction {
3030
AccountReset = 'account-reset',
3131
AccountRecovery = 'account-recovery',
3232
AccountDeactivated = 'account-deactivated',
33+
MailSetup = 'mail-setup',
3334
// Workspace actions
3435
WorkspaceCreated = 'workspace-created',
3536
WorkspaceDeleted = 'workspace-deleted',
@@ -47,6 +48,7 @@ export const AUDIT_ENTITY_ACTIONS: Record<AuditEntityType, AuditAction[]> = {
4748
AuditAction.AccountReset,
4849
AuditAction.AccountRecovery,
4950
AuditAction.AccountDeactivated,
51+
AuditAction.MailSetup,
5052
],
5153
[AuditEntityType.Workspace]: [
5254
AuditAction.WorkspaceCreated,

src/config/configuration.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ export default () => ({
7070
payments: {
7171
url: process.env.PAYMENTS_API_URL,
7272
},
73+
mail: {
74+
url: process.env.MAIL_API_URL,
75+
},
7376
},
7477
apn: {
7578
url: process.env.APN_URL,

src/externals/mail/mail.module.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Module } from '@nestjs/common';
2+
import { ConfigModule } from '@nestjs/config';
3+
import { HttpClientModule } from '../http/http.module';
4+
import { MailService } from './mail.service';
5+
6+
@Module({
7+
imports: [ConfigModule, HttpClientModule],
8+
controllers: [],
9+
providers: [MailService],
10+
exports: [MailService],
11+
})
12+
export class MailServiceModule {}

src/externals/mail/mail.service.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { Inject, Injectable } from '@nestjs/common';
2+
import { sign } from 'jsonwebtoken';
3+
import { ConfigService } from '@nestjs/config';
4+
import { HttpClient } from '../http/http.service';
5+
6+
interface CreateAccountPayload {
7+
userId: string;
8+
address: string;
9+
domain: string;
10+
displayName: string;
11+
}
12+
13+
interface CreateAccountResponse {
14+
address: string;
15+
domain: string;
16+
}
17+
18+
function signToken(duration: string, secret: string, isDevelopment?: boolean) {
19+
return sign({}, Buffer.from(secret, 'base64').toString('utf8'), {
20+
algorithm: 'RS256',
21+
expiresIn: duration,
22+
...(isDevelopment ? { allowInsecureKeySizes: true } : null),
23+
});
24+
}
25+
26+
@Injectable()
27+
export class MailService {
28+
constructor(
29+
@Inject(ConfigService)
30+
private readonly configService: ConfigService,
31+
@Inject(HttpClient)
32+
private readonly httpClient: HttpClient,
33+
) {}
34+
35+
private getAuthHeaders() {
36+
const isDevelopment = this.configService.get('isDevelopment');
37+
const jwt = signToken(
38+
'5m',
39+
this.configService.get('secrets.gateway'),
40+
isDevelopment,
41+
);
42+
43+
return {
44+
'Content-Type': 'application/json',
45+
Authorization: `Bearer ${jwt}`,
46+
};
47+
}
48+
49+
async createAccount(
50+
payload: CreateAccountPayload,
51+
): Promise<CreateAccountResponse> {
52+
const baseUrl = this.configService.get('apis.mail.url');
53+
const headers = this.getAuthHeaders();
54+
55+
const res = await this.httpClient.post(
56+
`${baseUrl}/gateway/accounts`,
57+
payload,
58+
{ headers },
59+
);
60+
61+
return res.data;
62+
}
63+
}

src/modules/feature-limit/limits.enum.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export enum LimitLabels {
1111
RcloneAccess = 'rclone-access',
1212
TrashRetentionDays = 'trash-retention-days',
1313
ReferralAccess = 'referral-access',
14+
MailAccess = 'mail-access',
1415
}
1516

1617
export enum LimitTypes {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsString } from 'class-validator';
3+
4+
export class CreateMailAccountDto {
5+
@ApiProperty({ example: 'john' })
6+
@IsNotEmpty()
7+
@IsString()
8+
address: string;
9+
10+
@ApiProperty({ example: 'inxt.eu' })
11+
@IsNotEmpty()
12+
@IsString()
13+
domain: string;
14+
15+
@ApiProperty({ example: 'John Doe' })
16+
@IsNotEmpty()
17+
@IsString()
18+
displayName: string;
19+
20+
@ApiProperty({ description: 'Encrypted password for re-authentication' })
21+
@IsNotEmpty()
22+
@IsString()
23+
password: string;
24+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
2+
import { ApiBearerAuth, ApiOkResponse, ApiOperation } from '@nestjs/swagger';
3+
import { User as UserDecorator } from '../auth/decorators/user.decorator';
4+
import { User } from '../user/user.domain';
5+
import { MailUseCases } from './mail.usecase';
6+
import { CreateMailAccountDto } from './dto/create-mail-account.dto';
7+
import { AuditLog } from '../../common/audit-logs/decorators/audit-log.decorator';
8+
import { AuditAction } from '../../common/audit-logs/audit-logs.attributes';
9+
10+
@Controller('mail')
11+
export class MailController {
12+
constructor(private readonly mailUseCases: MailUseCases) {}
13+
14+
@Post('accounts')
15+
@HttpCode(HttpStatus.CREATED)
16+
@ApiOperation({ summary: 'Provision a mail account for the user' })
17+
@ApiBearerAuth()
18+
@AuditLog({
19+
action: AuditAction.MailSetup,
20+
metadata: (_req, res) => ({
21+
address: res.address,
22+
}),
23+
})
24+
async createMailAccount(
25+
@UserDecorator() user: User,
26+
@Body() createMailAccountDto: CreateMailAccountDto,
27+
) {
28+
return this.mailUseCases.createMailAccount(user, createMailAccountDto);
29+
}
30+
}

0 commit comments

Comments
 (0)