Skip to content

Commit 1ca9644

Browse files
committed
feat: enhance mail sending functionality with recipient address source
1 parent f1abf48 commit 1ca9644

13 files changed

Lines changed: 403 additions & 66 deletions

File tree

apps/api/src/management/identities/identities.module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import { IdentitiesPasswordExpirationReminderService } from '~/management/identi
4040
]),
4141
FilestorageModule,
4242
forwardRef(() => BackendsModule),
43-
SettingsModule,
43+
forwardRef(() => SettingsModule),
4444
PasswordHistoryModule,
4545
AgentsModule,
4646
],
Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,28 @@
1-
import { ApiProperty } from '@nestjs/swagger'
2-
import { Types } from 'mongoose'
3-
import { IsArray, IsObject, IsOptional, IsString } from 'class-validator'
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { Types } from 'mongoose';
3+
import { IsArray, IsIn, IsObject, IsOptional, IsString } from 'class-validator';
44

55
export class MailSendManyDto {
66
@ApiProperty({ description: 'Ids des identities destinataires' })
77
@IsArray()
8-
public ids: Types.ObjectId[]
8+
public ids: Types.ObjectId[];
99

1010
@ApiProperty({ description: 'Nom du template mailer (ex: initaccount)' })
1111
@IsString()
12-
public template: string
12+
public template: string;
1313

1414
@ApiProperty({ required: false, description: 'Variables additionnelles injectées dans le template' })
1515
@IsOptional()
1616
@IsObject()
17-
public variables?: Record<string, string>
18-
}
17+
public variables?: Record<string, string>;
1918

19+
@ApiProperty({
20+
required: false,
21+
enum: ['principal', 'personnel'],
22+
description:
23+
'Si renseigné, adresse destinataire lue via le chemin JSON SMTP (e-mail principal ou e-mail personnel). Sinon, politique de mot de passe (emailAttribute).',
24+
})
25+
@IsOptional()
26+
@IsIn(['principal', 'personnel'])
27+
public recipientAddressSource?: 'principal' | 'personnel';
28+
}

apps/api/src/management/mail/mail-send.controller.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { Body, Controller, HttpStatus, Post, Res } from '@nestjs/common'
2-
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'
3-
import { Response } from 'express'
4-
import { UseRoles } from '~/_common/decorators/use-roles.decorator'
5-
import { AC_ACTIONS, AC_DEFAULT_POSSESSION } from '~/_common/types/ac-types'
6-
import { MailSendManyDto } from './_dto/mail-send-many.dto'
7-
import { MailSendService } from './mail-send.service'
1+
import { Body, Controller, HttpStatus, Post, Res } from '@nestjs/common';
2+
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
3+
import { Response } from 'express';
4+
import { UseRoles } from '~/_common/decorators/use-roles.decorator';
5+
import { AC_ACTIONS, AC_DEFAULT_POSSESSION } from '~/_common/types/ac-types';
6+
import { MailSendManyDto } from './_dto/mail-send-many.dto';
7+
import { MailSendService } from './mail-send.service';
88

99
@Controller('mail')
1010
@ApiTags('management/mail')
@@ -24,8 +24,8 @@ export class MailSendController {
2424
ids: (body.ids || []).map((id) => String(id)),
2525
template: body.template,
2626
variables: body.variables,
27-
})
28-
return res.status(HttpStatus.OK).json({ statusCode: HttpStatus.OK, data: result })
27+
recipientAddressSource: body.recipientAddressSource,
28+
});
29+
return res.status(HttpStatus.OK).json({ statusCode: HttpStatus.OK, data: result });
2930
}
3031
}
31-

apps/api/src/management/mail/mail-send.service.ts

Lines changed: 62 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,81 @@
1-
import { BadRequestException, Injectable, Logger } from '@nestjs/common'
2-
import { MailerService } from '@nestjs-modules/mailer'
3-
import { get } from 'radash'
4-
import { IdentitiesCrudService } from '~/management/identities/identities-crud.service'
5-
import { PasswdadmService } from '~/settings/passwdadm.service'
6-
import { IdentityState } from '~/management/identities/_enums/states.enum'
1+
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
2+
import { MailerService } from '@nestjs-modules/mailer';
3+
import { get } from 'radash';
4+
import { IdentitiesCrudService } from '~/management/identities/identities-crud.service';
5+
import { PasswdadmService } from '~/settings/passwdadm.service';
6+
import { MailadmService } from '~/settings/mailadm.service';
7+
import { IdentityState } from '~/management/identities/_enums/states.enum';
78

89
@Injectable()
910
export class MailSendService {
10-
private readonly logger = new Logger(MailSendService.name)
11+
private readonly logger = new Logger(MailSendService.name);
1112

1213
public constructor(
1314
private readonly identities: IdentitiesCrudService,
1415
private readonly passwdadmService: PasswdadmService,
1516
private readonly mailer: MailerService,
17+
private readonly mailadmService: MailadmService,
1618
) {}
1719

1820
public async sendTemplateToIdentities(args: {
19-
ids: string[]
20-
template: string
21-
variables?: Record<string, string>
21+
ids: string[];
22+
template: string;
23+
variables?: Record<string, string>;
24+
recipientAddressSource?: 'principal' | 'personnel';
2225
}): Promise<{ sent: number; skipped: number }> {
23-
const template = String(args.template || '').trim()
26+
const template = String(args.template || '').trim();
2427
if (!template) {
25-
throw new BadRequestException('Template requis')
28+
throw new BadRequestException('Template requis');
2629
}
27-
const variables = (args.variables && typeof args.variables === 'object' ? args.variables : {}) as Record<string, any>
30+
const variables = (args.variables && typeof args.variables === 'object' ? args.variables : {}) as Record<
31+
string,
32+
any
33+
>;
2834

29-
const policies: any = await this.passwdadmService.getPolicies()
30-
const mailAttribute = String(policies?.emailAttribute || '')
31-
if (!mailAttribute) {
32-
throw new BadRequestException("Attribut mail alternatif non configuré (settings.passwordpolicies.emailAttribute)")
35+
const smtp = await this.mailadmService.getParams();
36+
const principalPath = String(smtp?.recipientJsonPathEmailPrincipal || '').trim();
37+
const personnelPath = String(smtp?.recipientJsonPathEmailPersonnel || '').trim();
38+
const policies: any = await this.passwdadmService.getPolicies();
39+
const policyMailAttribute = String(policies?.emailAttribute || '');
40+
41+
const source = args.recipientAddressSource;
42+
let mailPath: string;
43+
if (source === 'principal') {
44+
mailPath = principalPath;
45+
if (!mailPath) {
46+
throw new BadRequestException(
47+
"Chemin JSON « e-mail principal » non configuré (paramètres → Serveur SMTP → Chemin JSON de l'e-mail principal).",
48+
);
49+
}
50+
} else if (source === 'personnel') {
51+
mailPath = personnelPath;
52+
if (!mailPath) {
53+
throw new BadRequestException(
54+
"Chemin JSON « e-mail personnel » non configuré (paramètres → Serveur SMTP → Chemin JSON de l'e-mail personnel).",
55+
);
56+
}
57+
} else {
58+
mailPath = policyMailAttribute;
59+
if (!mailPath) {
60+
throw new BadRequestException(
61+
'Attribut mail alternatif non configuré (settings.passwordpolicies.emailAttribute)',
62+
);
63+
}
3364
}
3465

35-
const identities = await this.identities.model.find({ _id: { $in: args.ids }, state: IdentityState.SYNCED }).lean()
66+
const identities = await this.identities.model.find({ _id: { $in: args.ids }, state: IdentityState.SYNCED }).lean();
3667
if (!identities?.length) {
37-
throw new BadRequestException('Aucune identité synchronisée trouvée')
68+
throw new BadRequestException('Aucune identité synchronisée trouvée');
3869
}
3970

40-
let sent = 0
41-
let skipped = 0
71+
let sent = 0;
72+
let skipped = 0;
4273

4374
for (const identity of identities) {
44-
const to = get(identity as any, mailAttribute) as string
75+
const to = get(identity as any, mailPath) as string;
4576
if (!to) {
46-
skipped++
47-
continue
77+
skipped++;
78+
continue;
4879
}
4980

5081
try {
@@ -56,15 +87,16 @@ export class MailSendService {
5687
identity,
5788
...variables,
5889
},
59-
})
60-
sent++
90+
});
91+
sent++;
6192
} catch (e) {
62-
this.logger.warn(`Failed to send template <${template}> to identity <${(identity as any)?._id}>: ${e?.message || e}`)
63-
skipped++
93+
this.logger.warn(
94+
`Failed to send template <${template}> to identity <${(identity as any)?._id}>: ${e?.message || e}`,
95+
);
96+
skipped++;
6497
}
6598
}
6699

67-
return { sent, skipped }
100+
return { sent, skipped };
68101
}
69102
}
70-

apps/api/src/settings/_dto/mail.settings.dto.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { IsEmail, IsString, IsUrl } from 'class-validator';
1+
import { IsEmail, IsOptional, IsString, IsUrl, Matches, ValidateIf } from 'class-validator';
22
import { ApiProperty } from '@nestjs/swagger';
3+
import { MAIL_RECIPIENT_JSON_PATH_REGEX } from '~/settings/_utils/mail-recipient-json-path.util';
34

45
export class MailSettingsDto {
56
@IsUrl({ protocols: ['smtp', 'smtps'], require_protocol: true, require_tld: false })
@@ -17,4 +18,32 @@ export class MailSettingsDto {
1718
@IsString()
1819
@ApiProperty({ example: 'myPassword', description: 'password', type: String })
1920
public password: string;
21+
22+
@ValidateIf((_, v) => v !== undefined && v !== null && String(v).trim() !== '')
23+
@IsString()
24+
@Matches(MAIL_RECIPIENT_JSON_PATH_REGEX, {
25+
message:
26+
'Le chemin doit comporter au moins un point et uniquement des segments alphanumériques, tirets ou underscores (ex. inetOrgPerson.mail)',
27+
})
28+
@IsOptional()
29+
@ApiProperty({
30+
required: false,
31+
example: 'additionalFields.mailPersonnel',
32+
description: "Chemin JSON (point) vers l'e-mail personnel de l'identité (envois template)",
33+
})
34+
public recipientJsonPathEmailPersonnel?: string;
35+
36+
@ValidateIf((_, v) => v !== undefined && v !== null && String(v).trim() !== '')
37+
@IsString()
38+
@Matches(MAIL_RECIPIENT_JSON_PATH_REGEX, {
39+
message:
40+
'Le chemin doit comporter au moins un point et uniquement des segments alphanumériques, tirets ou underscores (ex. inetOrgPerson.mail)',
41+
})
42+
@IsOptional()
43+
@ApiProperty({
44+
required: false,
45+
example: 'inetOrgPerson.mail',
46+
description: "Chemin JSON (point) vers l'e-mail principal de l'identité (envois template)",
47+
})
48+
public recipientJsonPathEmailPrincipal?: string;
2049
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/** Chemin style JSON (segments séparés par des points), ex. `inetOrgPerson.mail` */
2+
export const MAIL_RECIPIENT_JSON_PATH_REGEX = /^[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)+$/;
3+
4+
/**
5+
* Vérifie que chaque segment du chemin existe comme clé sur l'objet (structure présente).
6+
* La valeur finale peut être vide : on valide la présence des clés intermédiaires et finale.
7+
*/
8+
export function identityJsonPathStructureExists(root: unknown, path: string): boolean {
9+
const segments = path
10+
.split('.')
11+
.map((s) => s.trim())
12+
.filter(Boolean);
13+
if (segments.length < 2) {
14+
return false;
15+
}
16+
let cur: unknown = root;
17+
for (const seg of segments) {
18+
if (cur == null || typeof cur !== 'object') {
19+
return false;
20+
}
21+
const obj = cur as Record<string, unknown>;
22+
if (!Object.prototype.hasOwnProperty.call(obj, seg)) {
23+
return false;
24+
}
25+
cur = obj[seg];
26+
}
27+
return true;
28+
}
Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,67 @@
11
import { AbstractSettingsService } from '~/settings/_abstracts/abstract-settings.service';
2-
import { Injectable } from '@nestjs/common';
2+
import { BadRequestException, Injectable } from '@nestjs/common';
3+
import { InjectConnection, InjectModel } from '@nestjs/mongoose';
4+
import { Connection, Model } from 'mongoose';
35
import { MailSettingsDto } from '~/settings/_dto/mail.settings.dto';
6+
import { Settings } from '~/settings/_schemas/settings.schema';
7+
import { IdentityState } from '~/management/identities/_enums/states.enum';
8+
import { identityJsonPathStructureExists } from '~/settings/_utils/mail-recipient-json-path.util';
49

510
@Injectable()
611
export class MailadmService extends AbstractSettingsService {
12+
public constructor(
13+
@InjectModel(Settings.name) model: Model<Settings>,
14+
@InjectConnection() private readonly mongoConnection: Connection,
15+
) {
16+
super(model);
17+
}
18+
719
public async getParams(): Promise<MailSettingsDto | null> {
8-
const data = await this.getParameter<MailSettingsDto>('smtpServer');
9-
return data;
20+
return await this.getParameter<MailSettingsDto>('smtpServer');
1021
}
1122

1223
public async setParams(params: MailSettingsDto): Promise<any> {
24+
await this.assertRecipientJsonPathsOnSampleIdentity(params);
1325
return await this.setParameter('smtpServer', params);
1426
}
1527

1628
protected async defaultValues<T = MailSettingsDto>(): Promise<T> {
1729
return <T>new MailSettingsDto();
1830
}
31+
32+
private async assertRecipientJsonPathsOnSampleIdentity(params: MailSettingsDto): Promise<void> {
33+
const paths: { key: keyof MailSettingsDto; path: string }[] = [];
34+
const personnel = String(params.recipientJsonPathEmailPersonnel || '').trim();
35+
const principal = String(params.recipientJsonPathEmailPrincipal || '').trim();
36+
if (personnel) {
37+
paths.push({ key: 'recipientJsonPathEmailPersonnel', path: personnel });
38+
}
39+
if (principal) {
40+
paths.push({ key: 'recipientJsonPathEmailPrincipal', path: principal });
41+
}
42+
43+
if (paths.length === 0) {
44+
return;
45+
}
46+
47+
const sample = await this.mongoConnection.collection('identities').findOne({ state: IdentityState.SYNCED });
48+
if (!sample) {
49+
return;
50+
}
51+
52+
const validations: Record<string, string> = {};
53+
for (const { key, path } of paths) {
54+
if (!identityJsonPathStructureExists(sample, path)) {
55+
validations[key] =
56+
"Ce chemin n'existe pas sur une identité synchronisée d'exemple (vérifiez la casse et les segments séparés par des points).";
57+
}
58+
}
59+
if (Object.keys(validations).length > 0) {
60+
throw new BadRequestException({
61+
statusCode: 400,
62+
message: 'Erreur de validation : ' + Object.keys(validations).join(', '),
63+
validations,
64+
});
65+
}
66+
}
1967
}

apps/api/src/settings/settings.module.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { PasswdadmService } from '~/settings/passwdadm.service';
1010
import { PasswdadmController } from '~/settings/passwdadm.controller';
1111
import { MailadmService } from '~/settings/mailadm.service';
1212
import { MailadmController } from '~/settings/mailadm.controller';
13+
1314
@Module({
1415
exports: [SmsadmService, PasswdadmService, MailadmService],
1516
imports: [

apps/api/tests/unit/settings/mailadm.service.spec.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,16 @@ import { MailSettingsDto } from '~/settings/_dto/mail.settings.dto'
44
describe('MailadmService', () => {
55
let service: MailadmService
66

7+
const mockCollection = {
8+
findOne: jest.fn().mockResolvedValue(null),
9+
}
10+
11+
const mockConnection = {
12+
collection: jest.fn().mockReturnValue(mockCollection),
13+
}
14+
715
beforeEach(() => {
8-
service = new MailadmService({} as any)
16+
service = new MailadmService({} as any, mockConnection as any)
917
})
1018

1119
it('should read smtp params via getParameter(smtpServer)', async () => {

0 commit comments

Comments
 (0)