email templating

This commit is contained in:
Paul Coral
2025-08-06 23:13:10 +02:00
parent fece4dcee1
commit ba90bf221b
11 changed files with 269 additions and 34 deletions

View File

@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= title %></title>
<style>
/* General body styling for all clients */
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
/* Container for the email content */
.container {
width: 100%;
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-collapse: collapse;
}
/* Header section */
.header {
background-color: #007bff;
color: #ffffff;
text-align: center;
padding: 20px;
}
/* Main content section */
.content {
padding: 20px;
}
/* Button styling */
.button {
display: inline-block;
background-color: #007bff;
color: #ffffff;
padding: 10px 20px;
border-radius: 5px;
text-decoration: none;
}
/* List items styling */
.feature-item {
padding: 5px 0;
color: #555555;
}
</style>
</head>
<body style="background-color: #f4f4f4">
<table
class="container"
role="presentation"
cellspacing="0"
cellpadding="0"
border="0"
>
<tr>
<td
class="header"
style="
background-color: #007bff;
color: #ffffff;
text-align: center;
padding: 20px;
"
>
<h1 style="margin: 0"><%= bodyTitle %></h1>
</td>
</tr>
<tr>
<td class="content" style="padding: 20px">
<p>
Thank you for signing up for our service. We're excited to have you
on board!
</p>
<p>Here are some of the key features you can now enjoy:</p>
<p style="text-align: center; margin: 30px 0">
<a
href="https://google.com"
class="button"
style="
display: inline-block;
background-color: #007bff;
color: #ffffff;
padding: 10px 20px;
border-radius: 5px;
text-decoration: none;
"
>
Get Started
</a>
</p>
<p>
If you have any questions, feel free to reply to this email or visit
our
<a href="http://goolge.com" style="color: #007bff">support center</a
>.
</p>
</td>
</tr>
<tr>
<td
style="
text-align: center;
padding: 20px;
font-size: 12px;
color: #888888;
"
>
<p>&copy; <%= currentYear %> Cowsi. All rights reserved.</p>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,4 +1,4 @@
{ {
"mail.create-user.title": "Welcome to Cowsi", "mail.create-user.title": "Welcome to Cowsi!",
"mail.create-user.body-html": "<h1>Welcome to Cowsi<h1>" "mail.create-user.body-title": "Welcome to Cowsi {{name}}!"
} }

View File

@@ -1,4 +1,4 @@
{ {
"mail.create-user.title": "Bienvenue sur Cowsi", "mail.create-user.title": "Bienvenue sur Cowsi!",
"mail.create-user.body-html": "<h1>Bienvenue sur Cowsi<h1>" "mail.create-user.body-title": "Bienvenue sur Cowsi {{name}}!"
} }

View File

@@ -26,7 +26,9 @@
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"dayjs": "^1.11.13",
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
"ejs": "^3.1.10",
"i18next": "^25.3.2", "i18next": "^25.3.2",
"i18next-fs-backend": "^2.6.0", "i18next-fs-backend": "^2.6.0",
"kysely": "^0.28.3", "kysely": "^0.28.3",
@@ -44,6 +46,7 @@
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0", "@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7", "@swc/core": "^1.10.7",
"@types/ejs": "^3.1.5",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",

View File

@@ -7,7 +7,8 @@ import { EnvService } from './services/env/env.service';
import { UsersRegistry } from './app/users/users-registry'; import { UsersRegistry } from './app/users/users-registry';
import { UsersController } from './app/users/users.controller'; import { UsersController } from './app/users/users.controller';
import { MailerService } from './services/mailer/mailer.service'; import { MailerService } from './services/mailer/mailer.service';
import { localizationServiceProvider } from './services/localization/localization.service'; import { LOCALIZATION_SERVICE_PROVIDER } from './services/localization/localization.service';
import { TemplateRendererService } from './services/template-renderer/template-renderer.service';
@Module({ @Module({
imports: [], imports: [],
@@ -19,7 +20,8 @@ import { localizationServiceProvider } from './services/localization/localizatio
EnvService, EnvService,
UsersRegistry, UsersRegistry,
MailerService, MailerService,
localizationServiceProvider, LOCALIZATION_SERVICE_PROVIDER,
TemplateRendererService,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -6,6 +6,8 @@ import {
LOCALIZATION_SERVICE, LOCALIZATION_SERVICE,
LocalizationService, LocalizationService,
} from '../../services/localization/localization.service'; } from '../../services/localization/localization.service';
import { TemplateRendererService } from '../../services/template-renderer/template-renderer.service';
import { MailTemplateEnum } from '../../services/template-renderer/mail-template-data';
@Injectable() @Injectable()
export class UsersService { export class UsersService {
@@ -14,18 +16,31 @@ export class UsersService {
private mailerService: MailerService, private mailerService: MailerService,
@Inject(LOCALIZATION_SERVICE) @Inject(LOCALIZATION_SERVICE)
private localizationService: LocalizationService, private localizationService: LocalizationService,
private templateRendererService: TemplateRendererService,
) {} ) {}
async createUser(newUser: CreateUser): Promise<void> { async createUser(newUser: CreateUser): Promise<void> {
// FIXME : return result once auth is done // FIXME : return result once auth is done
await this.userRegistry.addUser(newUser); await this.userRegistry.addUser(newUser);
this.localizationService.translate('mail.create-user.title');
const mailTitle = this.localizationService.translate(
'mail.create-user.title',
);
const mailBodyTitle = this.localizationService.translate(
'mail.create-user.body-title',
{ name: newUser.firstName },
);
const mailHtml = await this.templateRendererService.renderMailTemplate(
MailTemplateEnum.CreateAccount,
{
title: mailTitle,
bodyTitle: mailBodyTitle,
},
);
void this.mailerService.sendEmail({ void this.mailerService.sendEmail({
toAdresses: [newUser.email], toAdresses: [newUser.email],
subject: 'Your account has been created!', subject: mailTitle,
html: `<h1>Welcome to COWSI</h1> html: mailHtml,
<p>Your account has been successfully created</p>
`,
}); });
} }
} }

View File

@@ -6,32 +6,33 @@ import * as i18nextBackend from 'i18next-fs-backend';
export const LOCALIZATION_SERVICE = 'LOCALIZATION_SERVICE'; export const LOCALIZATION_SERVICE = 'LOCALIZATION_SERVICE';
export const localizationServiceProvider: FactoryProvider<LocalizationService> = export const LOCALIZATION_SERVICE_PROVIDER: Readonly<
{ FactoryProvider<LocalizationService>
provide: LOCALIZATION_SERVICE, > = {
scope: Scope.REQUEST, provide: LOCALIZATION_SERVICE,
useFactory: async (req: Request) => { scope: Scope.REQUEST,
const instance = i18next.createInstance().use(i18nextBackend as any); useFactory: async (req: Request) => {
const supportedLang = req const instance = i18next.createInstance().use(i18nextBackend as any);
.acceptsLanguages() const supportedLang = req
.find((l) => isSupportedLang(l)); .acceptsLanguages()
await instance.init<i18nextBackend.FsBackendOptions>({ .find((l) => isSupportedLang(l));
lng: supportedLang, await instance.init<i18nextBackend.FsBackendOptions>({
fallbackLng: 'en', lng: supportedLang,
debug: false, fallbackLng: 'en',
backend: { debug: false,
loadPath: `assets/i18n/{{lng}}.json`, backend: {
}, loadPath: `assets/i18n/{{lng}}.json`,
}); },
return new LocalizationService(instance); });
}, return new LocalizationService(instance);
inject: ['REQUEST'], },
}; inject: ['REQUEST'],
};
export class LocalizationService { export class LocalizationService {
constructor(private readonly i18n: i18next.i18n) {} constructor(private readonly i18n: i18next.i18n) {}
translate(key: string) { translate(key: string, args?: Record<string, unknown>): string {
this.i18n.t(key); return this.i18n.t(key, args);
} }
} }

View File

@@ -0,0 +1,27 @@
const MAIL_BASE = 'assets/ejs-templates/mail';
export interface BaseMailTempalteData {
currentYear: number | string;
}
export enum MailTemplateEnum {
CreateAccount = 'create-account',
}
export interface MailTemplateTypeMap extends Record<MailTemplateEnum, unknown> {
[MailTemplateEnum.CreateAccount]: CreateAccountMailTemplateData;
}
const MAIL_TEMPLATE_PATH_MAP = {
[MailTemplateEnum.CreateAccount]: 'account-created.ejs',
} as const satisfies Record<MailTemplateEnum, string>;
export interface CreateAccountMailTemplateData {
title: string;
bodyTitle: string;
}
export function getMailTemplatePath(template: MailTemplateEnum): string {
const suffix = MAIL_TEMPLATE_PATH_MAP[template];
return `${MAIL_BASE}/${suffix}`;
}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TemplateRendererService } from './template-renderer.service';
describe('TemplateRendererService', () => {
let service: TemplateRendererService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [TemplateRendererService],
}).compile();
service = module.get<TemplateRendererService>(TemplateRendererService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import * as ejs from 'ejs';
import {
BaseMailTempalteData,
getMailTemplatePath,
MailTemplateEnum,
MailTemplateTypeMap,
} from './mail-template-data';
import * as dayjs from 'dayjs';
@Injectable()
export class TemplateRendererService {
renderMailTemplate<T extends MailTemplateEnum>(
template: T,
data: MailTemplateTypeMap[T],
): Promise<string> {
return this.render(getMailTemplatePath(template), data);
}
private render(file: string, data: ejs.Data): Promise<string> {
const base: BaseMailTempalteData = {
currentYear: dayjs().year(),
};
return ejs.renderFile(file, {
...data,
...base,
});
}
}

19
pnpm-lock.yaml generated
View File

@@ -22,9 +22,15 @@ importers:
'@nestjs/platform-express': '@nestjs/platform-express':
specifier: ^11.0.1 specifier: ^11.0.1
version: 11.1.3(@nestjs/common@11.1.3(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3) version: 11.1.3(@nestjs/common@11.1.3(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3)
dayjs:
specifier: ^1.11.13
version: 1.11.13
dotenv: dotenv:
specifier: ^17.2.1 specifier: ^17.2.1
version: 17.2.1 version: 17.2.1
ejs:
specifier: ^3.1.10
version: 3.1.10
i18next: i18next:
specifier: ^25.3.2 specifier: ^25.3.2
version: 25.3.2(typescript@5.7.3) version: 25.3.2(typescript@5.7.3)
@@ -71,6 +77,9 @@ importers:
'@swc/core': '@swc/core':
specifier: ^1.10.7 specifier: ^1.10.7
version: 1.12.11 version: 1.12.11
'@types/ejs':
specifier: ^3.1.5
version: 3.1.5
'@types/express': '@types/express':
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.3 version: 5.0.3
@@ -1002,6 +1011,9 @@ packages:
'@types/cookiejar@2.1.5': '@types/cookiejar@2.1.5':
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
'@types/ejs@3.1.5':
resolution: {integrity: sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==}
'@types/eslint-scope@3.7.7': '@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -1646,6 +1658,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
dayjs@1.11.13:
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
debug@4.4.1: debug@4.4.1:
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -4721,6 +4736,8 @@ snapshots:
'@types/cookiejar@2.1.5': {} '@types/cookiejar@2.1.5': {}
'@types/ejs@3.1.5': {}
'@types/eslint-scope@3.7.7': '@types/eslint-scope@3.7.7':
dependencies: dependencies:
'@types/eslint': 9.6.1 '@types/eslint': 9.6.1
@@ -5530,6 +5547,8 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
dayjs@1.11.13: {}
debug@4.4.1: debug@4.4.1:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3