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.body-html": "<h1>Welcome to Cowsi<h1>"
"mail.create-user.title": "Welcome to Cowsi!",
"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.body-html": "<h1>Bienvenue sur Cowsi<h1>"
"mail.create-user.title": "Bienvenue sur Cowsi!",
"mail.create-user.body-title": "Bienvenue sur Cowsi {{name}}!"
}

View File

@@ -26,7 +26,9 @@
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"dayjs": "^1.11.13",
"dotenv": "^17.2.1",
"ejs": "^3.1.10",
"i18next": "^25.3.2",
"i18next-fs-backend": "^2.6.0",
"kysely": "^0.28.3",
@@ -44,6 +46,7 @@
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7",
"@types/ejs": "^3.1.5",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@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 { UsersController } from './app/users/users.controller';
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({
imports: [],
@@ -19,7 +20,8 @@ import { localizationServiceProvider } from './services/localization/localizatio
EnvService,
UsersRegistry,
MailerService,
localizationServiceProvider,
LOCALIZATION_SERVICE_PROVIDER,
TemplateRendererService,
],
})
export class AppModule {}

View File

@@ -6,6 +6,8 @@ import {
LOCALIZATION_SERVICE,
LocalizationService,
} from '../../services/localization/localization.service';
import { TemplateRendererService } from '../../services/template-renderer/template-renderer.service';
import { MailTemplateEnum } from '../../services/template-renderer/mail-template-data';
@Injectable()
export class UsersService {
@@ -14,18 +16,31 @@ export class UsersService {
private mailerService: MailerService,
@Inject(LOCALIZATION_SERVICE)
private localizationService: LocalizationService,
private templateRendererService: TemplateRendererService,
) {}
async createUser(newUser: CreateUser): Promise<void> {
// FIXME : return result once auth is done
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({
toAdresses: [newUser.email],
subject: 'Your account has been created!',
html: `<h1>Welcome to COWSI</h1>
<p>Your account has been successfully created</p>
`,
subject: mailTitle,
html: mailHtml,
});
}
}

View File

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