Skip to content

Commit 7e22543

Browse files
committed
feat: enhance identity management with email template functionality
- Added support for customizable email templates in the account initialization process, allowing users to specify template names and additional variables. - Updated the `InitAccountDto` and `InitManyDto` to include optional fields for template names and variables, improving flexibility in email communications. - Enhanced the UI to restrict email invitation actions to only synchronized identities, ensuring clarity and preventing errors. - Introduced a new modal for sending emails with templates, improving user experience and functionality in identity management.
1 parent 2d2a712 commit 7e22543

File tree

20 files changed

+1013
-20
lines changed

20 files changed

+1013
-20
lines changed

apps/api/configs/mail/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
*
2+
!.gitignore
3+
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
mailTemplates:
2+
# Déclare les variables disponibles côté UI pour construire le payload `variables`.
3+
# Les valeurs peuvent contenir des placeholders Liquid `{{ ... }}` résolus via `resolveConfigVariables`.
4+
variables:
5+
- key: subject
6+
label: Sujet du mail
7+
description: Sujet affiché par le client mail.
8+
example: "Bienvenue sur Sesame"
9+
defaultValue: "Notification"
10+
11+
- key: appName
12+
label: Nom de l'application
13+
description: Nom affiché dans certains templates.
14+
example: "Sesame"
15+
defaultValue: "Sesame"
16+
17+
- key: url
18+
label: URL principale
19+
description: Lien principal utilisé dans le template.
20+
example: "https://example.com"
21+
defaultValue: "https://example.com"
22+
23+
- key: footerDate
24+
label: Date (exemple dynamique)
25+
description: Exemple de valeur dynamique basée sur la date.
26+
example: "{{ date.today }}"
27+
defaultValue: "{{ date.today }}"
28+

apps/api/src/app.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { DtoValidationPipe } from './_common/pipes/dto-validation.pipe';
1818
import { SettingsModule } from '~/settings/settings.module';
1919
import { MailerModule } from '@nestjs-modules/mailer';
2020
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
21+
import path from 'node:path';
2122
import { MailadmService } from '~/settings/mailadm.service';
2223
import { FactorydriveModule } from '~/_common/factorydrive';
2324
import { MigrationsModule } from './migrations/migrations.module';
@@ -77,7 +78,7 @@ import { AclRuntimeService } from './core/roles/acl-runtime.service';
7778
from: params.sender,
7879
},
7980
template: {
80-
dir: __dirname + '/../templates',
81+
dir: path.join(process.cwd(), 'templates'),
8182
adapter: new HandlebarsAdapter(undefined, {
8283
inlineCssEnabled: false,
8384
}),
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { ApiProperty } from '@nestjs/swagger'
2+
import { Types } from 'mongoose'
3+
import { IsArray, IsObject, IsOptional, IsString } from 'class-validator'
4+
5+
export class MailSendManyDto {
6+
@ApiProperty({ description: 'Ids des identities destinataires' })
7+
@IsArray()
8+
public ids: Types.ObjectId[]
9+
10+
@ApiProperty({ description: 'Nom du template mailer (ex: initaccount)' })
11+
@IsString()
12+
public template: string
13+
14+
@ApiProperty({ required: false, description: 'Variables additionnelles injectées dans le template' })
15+
@IsOptional()
16+
@IsObject()
17+
public variables?: Record<string, string>
18+
}
19+
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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'
8+
9+
@Controller('mail')
10+
@ApiTags('management/mail')
11+
export class MailSendController {
12+
public constructor(private readonly mailSend: MailSendService) {}
13+
14+
@Post('sendmany')
15+
@UseRoles({
16+
resource: '/management/mail',
17+
action: AC_ACTIONS.CREATE,
18+
possession: AC_DEFAULT_POSSESSION,
19+
})
20+
@ApiOperation({ summary: 'Envoie un template mail à plusieurs identités' })
21+
@ApiResponse({ status: HttpStatus.OK })
22+
public async sendMany(@Body() body: MailSendManyDto, @Res() res: Response): Promise<Response> {
23+
const result = await this.mailSend.sendTemplateToIdentities({
24+
ids: (body.ids || []).map((id) => String(id)),
25+
template: body.template,
26+
variables: body.variables,
27+
})
28+
return res.status(HttpStatus.OK).json({ statusCode: HttpStatus.OK, data: result })
29+
}
30+
}
31+
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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'
7+
8+
@Injectable()
9+
export class MailSendService {
10+
private readonly logger = new Logger(MailSendService.name)
11+
12+
public constructor(
13+
private readonly identities: IdentitiesCrudService,
14+
private readonly passwdadmService: PasswdadmService,
15+
private readonly mailer: MailerService,
16+
) {}
17+
18+
public async sendTemplateToIdentities(args: {
19+
ids: string[]
20+
template: string
21+
variables?: Record<string, string>
22+
}): Promise<{ sent: number; skipped: number }> {
23+
const template = String(args.template || '').trim()
24+
if (!template) {
25+
throw new BadRequestException('Template requis')
26+
}
27+
const variables = (args.variables && typeof args.variables === 'object' ? args.variables : {}) as Record<string, any>
28+
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)")
33+
}
34+
35+
const identities = await this.identities.model.find({ _id: { $in: args.ids }, state: IdentityState.SYNCED }).lean()
36+
if (!identities?.length) {
37+
throw new BadRequestException('Aucune identité synchronisée trouvée')
38+
}
39+
40+
let sent = 0
41+
let skipped = 0
42+
43+
for (const identity of identities) {
44+
const to = get(identity as any, mailAttribute) as string
45+
if (!to) {
46+
skipped++
47+
continue
48+
}
49+
50+
try {
51+
await this.mailer.sendMail({
52+
to,
53+
subject: variables?.subject || 'Notification',
54+
template,
55+
context: {
56+
identity,
57+
...variables,
58+
},
59+
})
60+
sent++
61+
} catch (e) {
62+
this.logger.warn(`Failed to send template <${template}> to identity <${(identity as any)?._id}>: ${e?.message || e}`)
63+
skipped++
64+
}
65+
}
66+
67+
return { sent, skipped }
68+
}
69+
}
70+
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Body, Controller, Get, HttpStatus, Post, Res } from '@nestjs/common'
2+
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'
3+
import { Response } from 'express'
4+
import { MailTemplatesService } from './mail-templates.service'
5+
6+
@Controller('mail/templates')
7+
@ApiTags('management/mail')
8+
export class MailTemplatesController {
9+
public constructor(private readonly mailTemplates: MailTemplatesService) {}
10+
11+
@Get()
12+
@ApiOperation({ summary: 'Liste les templates mails disponibles' })
13+
@ApiResponse({ status: HttpStatus.OK })
14+
public async list(@Res() res: Response): Promise<Response> {
15+
const templates = await this.mailTemplates.listTemplates()
16+
return res.status(HttpStatus.OK).json({ statusCode: HttpStatus.OK, data: templates })
17+
}
18+
19+
@Get('config')
20+
@ApiOperation({ summary: 'Retourne la config mail_templates (variables possibles)' })
21+
@ApiResponse({ status: HttpStatus.OK })
22+
public async config(@Res() res: Response): Promise<Response> {
23+
const cfg = await this.mailTemplates.getMailTemplatesConfig()
24+
return res.status(HttpStatus.OK).json({ statusCode: HttpStatus.OK, data: cfg })
25+
}
26+
27+
@Post('preview')
28+
@ApiOperation({ summary: 'Rend un template en HTML (preview)' })
29+
@ApiResponse({ status: HttpStatus.OK })
30+
public async preview(
31+
@Body()
32+
body: {
33+
template: string
34+
variables?: Record<string, unknown>
35+
},
36+
@Res() res: Response,
37+
): Promise<Response> {
38+
const templateName = String(body?.template || '').trim()
39+
if (!templateName) {
40+
return res.status(HttpStatus.BAD_REQUEST).json({ statusCode: HttpStatus.BAD_REQUEST, message: 'Template requis' })
41+
}
42+
43+
const html = await this.mailTemplates.renderPreviewHtml(templateName, body?.variables)
44+
return res.status(HttpStatus.OK).json({ statusCode: HttpStatus.OK, data: { html } })
45+
}
46+
}
47+
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'
2+
import fs from 'node:fs/promises'
3+
import path from 'node:path'
4+
import Handlebars from 'handlebars'
5+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
6+
import { parse } from 'yaml'
7+
import { resolveConfigVariables } from '~/_common/functions/resolve-config-variables.function'
8+
9+
@Injectable()
10+
export class MailTemplatesService implements OnApplicationBootstrap {
11+
private readonly logger = new Logger(MailTemplatesService.name)
12+
13+
public onApplicationBootstrap(): void {
14+
this.ensureDefaultConfigPresent()
15+
}
16+
17+
private ensureDefaultConfigPresent(): void {
18+
const configDir = path.join(process.cwd(), 'configs', 'mail')
19+
const defaultsDir = path.join(process.cwd(), 'defaults', 'mail')
20+
21+
if (!existsSync(configDir)) {
22+
mkdirSync(configDir, { recursive: true })
23+
}
24+
25+
try {
26+
const files = readdirSync(configDir)
27+
const defaultFiles = readdirSync(defaultsDir)
28+
29+
for (const file of defaultFiles) {
30+
if (!files.includes(file)) {
31+
const defaultFile = readFileSync(path.join(defaultsDir, file), 'utf-8')
32+
writeFileSync(path.join(configDir, file), defaultFile)
33+
this.logger.warn(`Copied default mail config file: ${file}`)
34+
}
35+
}
36+
} catch (e) {
37+
this.logger.error(`Error initializing mail configs: ${e?.message || e}`)
38+
}
39+
}
40+
41+
private getTemplatesDir(): string {
42+
// Même chemin que la config MailerModule (voir app.module.ts)
43+
return path.join(process.cwd(), 'templates')
44+
}
45+
46+
public async getMailTemplatesConfig(): Promise<{
47+
variables: Array<{
48+
key: string
49+
label?: string
50+
description?: string
51+
example?: string
52+
defaultValue?: unknown
53+
}>
54+
}> {
55+
const configPath = path.join(process.cwd(), 'configs', 'mail', 'mail_templates.yml')
56+
const raw = readFileSync(configPath, 'utf8')
57+
const parsed = parse(raw) as any
58+
const cfg = (parsed?.mailTemplates || {}) as any
59+
60+
const resolved = await resolveConfigVariables(cfg)
61+
62+
return {
63+
variables: Array.isArray((resolved as any)?.variables) ? (resolved as any).variables : [],
64+
}
65+
}
66+
67+
public async listTemplates(): Promise<string[]> {
68+
const dir = this.getTemplatesDir()
69+
const entries = await fs.readdir(dir, { withFileTypes: true })
70+
return entries
71+
.filter((e) => e.isFile())
72+
.map((e) => e.name)
73+
.filter((name) => name.endsWith('.hbs'))
74+
.map((name) => name.replace(/\.hbs$/, ''))
75+
.filter((name) => name.startsWith('mail_'))
76+
.sort((a, b) => a.localeCompare(b))
77+
}
78+
79+
public async renderPreviewHtml(template: string, variables?: Record<string, unknown>): Promise<string> {
80+
const templateName = String(template || '').trim()
81+
const filePath = path.join(this.getTemplatesDir(), `${templateName}.hbs`)
82+
const source = await fs.readFile(filePath, 'utf8')
83+
84+
const compiled = Handlebars.compile(source, { strict: true })
85+
return compiled({
86+
// Valeurs minimales pour éviter les crash sur templates existants
87+
displayName: 'Preview',
88+
uid: 'preview',
89+
url: 'https://example.invalid/initaccount/preview',
90+
mail: 'preview@example.invalid',
91+
...(variables || {}),
92+
})
93+
}
94+
}
95+
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Module } from '@nestjs/common'
2+
import { MailTemplatesController } from './mail-templates.controller'
3+
import { MailTemplatesService } from './mail-templates.service'
4+
import { MailSendService } from './mail-send.service'
5+
import { MailSendController } from './mail-send.controller'
6+
import { IdentitiesModule } from '~/management/identities/identities.module'
7+
import { SettingsModule } from '~/settings/settings.module'
8+
9+
@Module({
10+
imports: [IdentitiesModule, SettingsModule],
11+
controllers: [MailTemplatesController, MailSendController],
12+
providers: [MailTemplatesService, MailSendService],
13+
exports: [MailTemplatesService, MailSendService],
14+
})
15+
export class MailModule {}
16+

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import { IdentitiesModule } from './identities/identities.module';
66
import { PasswdModule } from './passwd/passwd.module';
77
import { LifecycleModule } from './lifecycle/lifecycle.module';
88
import { PasswordHistoryModule } from './password-history/password-history.module';
9+
import { MailModule } from './mail/mail.module';
910

1011
@Module({
11-
imports: [IdentitiesModule, PasswdModule, LifecycleModule, PasswordHistoryModule],
12+
imports: [IdentitiesModule, PasswdModule, LifecycleModule, PasswordHistoryModule, MailModule],
1213
providers: [ManagementService],
1314
controllers: [ManagementController],
1415
})

0 commit comments

Comments
 (0)