feat(core): Switch to MJML for email templates (#10518)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2024-08-28 17:15:18 +02:00
committed by GitHub
parent 9e1dac0465
commit dbc10fe9f5
24 changed files with 754 additions and 86 deletions

View File

@@ -78,6 +78,7 @@
"chokidar": "^3.5.2",
"concurrently": "^8.2.0",
"ioredis-mock": "^8.8.1",
"mjml": "^4.15.3",
"ts-essentials": "^7.0.3"
},
"dependencies": {

View File

@@ -3,6 +3,7 @@ import { writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import shell from 'shelljs';
import { rawTimeZones } from '@vvo/tzdb';
import glob from 'fast-glob';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -13,7 +14,7 @@ const SPEC_THEME_FILENAME = 'swagger-theme.css';
const publicApiEnabled = process.env.N8N_PUBLIC_API_DISABLED !== 'true';
copyUserManagementEmailTemplates();
generateUserManagementEmailTemplates();
generateTimezoneData();
if (publicApiEnabled) {
@@ -21,13 +22,22 @@ if (publicApiEnabled) {
bundleOpenApiSpecs();
}
function copyUserManagementEmailTemplates() {
const templates = {
source: path.resolve(ROOT_DIR, 'src', 'user-management', 'email', 'templates'),
destination: path.resolve(ROOT_DIR, 'dist', 'user-management', 'email'),
};
function generateUserManagementEmailTemplates() {
const sourceDir = path.resolve(ROOT_DIR, 'src', 'user-management', 'email', 'templates');
const destinationDir = path.resolve(ROOT_DIR, 'dist', 'user-management', 'email', 'templates');
shell.cp('-r', templates.source, templates.destination);
shell.mkdir('-p', destinationDir);
const templates = glob.sync('*.mjml', { cwd: sourceDir });
templates.forEach((template) => {
if (template.startsWith('_')) return;
const source = path.resolve(sourceDir, template);
const destination = path.resolve(destinationDir, template.replace(/\.mjml$/, '.handlebars'));
const command = `pnpm mjml --output ${destination} ${source}`;
shell.exec(command, { silent: false });
});
shell.cp(path.resolve(sourceDir, 'n8n-logo.png'), destinationDir);
}
function copySwaggerTheme() {

View File

@@ -13,7 +13,6 @@ import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import { MfaService } from '@/mfa/mfa.service';
import { Logger } from '@/logger';
import { ExternalHooks } from '@/external-hooks';
import { UrlService } from '@/services/url.service';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
@@ -31,7 +30,6 @@ export class PasswordResetController {
private readonly authService: AuthService,
private readonly userService: UserService,
private readonly mfaService: MfaService,
private readonly urlService: UrlService,
private readonly license: License,
private readonly passwordUtility: PasswordUtility,
private readonly userRepository: UserRepository,
@@ -108,14 +106,12 @@ export class PasswordResetController {
const url = this.authService.generatePasswordResetUrl(user);
const { id, firstName, lastName } = user;
const { id, firstName } = user;
try {
await this.mailer.passwordReset({
email,
firstName,
lastName,
passwordResetUrl: url,
domain: this.urlService.getInstanceBaseUrl(),
});
} catch (error) {
this.eventService.emit('email-failed', {

View File

@@ -138,7 +138,6 @@ export class UserService {
const result = await this.mailer.invite({
email,
inviteAcceptUrl,
domain,
});
if (result.emailSent) {
invitedUser.user.emailSent = true;
@@ -168,7 +167,6 @@ export class UserService {
this.logger.error('Failed to send email', {
userId: owner.id,
inviteAcceptUrl,
domain,
email,
});
invitedUser.error = e.message;

View File

@@ -1,17 +1,12 @@
export type InviteEmailData = {
email: string;
firstName?: string;
lastName?: string;
inviteAcceptUrl: string;
domain: string;
};
export type PasswordResetData = {
email: string;
firstName?: string;
lastName?: string;
firstName: string;
passwordResetUrl: string;
domain: string;
};
export type SendEmailResult = {

View File

@@ -1,6 +1,7 @@
import type { GlobalConfig } from '@n8n/config';
import { mock } from 'jest-mock-extended';
import type { UrlService } from '@/services/url.service';
import type { InviteEmailData, PasswordResetData } from '@/user-management/email/Interfaces';
import { NodeMailer } from '@/user-management/email/node-mailer';
import { UserManagementMailer } from '@/user-management/email/user-management-mailer';
@@ -31,7 +32,7 @@ describe('UserManagementMailer', () => {
},
},
});
const userManagementMailer = new UserManagementMailer(config, mock(), mock(), mock());
const userManagementMailer = new UserManagementMailer(config, mock(), mock(), mock(), mock());
it('should not setup email transport', async () => {
expect(userManagementMailer.isEmailSetUp).toBe(false);
@@ -56,7 +57,18 @@ describe('UserManagementMailer', () => {
},
},
});
const userManagementMailer = new UserManagementMailer(config, mock(), mock(), mock());
const urlService = mock<UrlService>();
const userManagementMailer = new UserManagementMailer(
config,
mock(),
mock(),
urlService,
mock(),
);
beforeEach(() => {
urlService.getInstanceBaseUrl.mockReturnValue('https://n8n.url');
});
it('should setup email transport', async () => {
expect(userManagementMailer.isEmailSetUp).toBe(true);
@@ -67,9 +79,7 @@ describe('UserManagementMailer', () => {
const result = await userManagementMailer.invite(inviteEmailData);
expect(result.emailSent).toBe(true);
expect(nodeMailer.sendMail).toHaveBeenCalledWith({
body: expect.stringContaining(
`<a href="${inviteEmailData.inviteAcceptUrl}" target="_blank">`,
),
body: expect.stringContaining(`href="${inviteEmailData.inviteAcceptUrl}"`),
emailRecipients: email,
subject: 'You have been invited to n8n',
});
@@ -79,7 +89,7 @@ describe('UserManagementMailer', () => {
const result = await userManagementMailer.passwordReset(passwordResetData);
expect(result.emailSent).toBe(true);
expect(nodeMailer.sendMail).toHaveBeenCalledWith({
body: expect.stringContaining(`<a href="${passwordResetData.passwordResetUrl}">`),
body: expect.stringContaining(`href="${passwordResetData.passwordResetUrl}"`),
emailRecipients: email,
subject: 'n8n password reset',
});

View File

@@ -1,4 +1,5 @@
import { Service } from 'typedi';
import path from 'node:path';
import { pick } from 'lodash';
import type { Transporter } from 'nodemailer';
import { createTransport } from 'nodemailer';
@@ -45,12 +46,20 @@ export class NodeMailer {
async sendMail(mailData: MailData): Promise<SendEmailResult> {
try {
await this.transport?.sendMail({
await this.transport.sendMail({
from: this.sender,
to: mailData.emailRecipients,
subject: mailData.subject,
text: mailData.textOnly,
html: mailData.body,
attachments: [
{
cid: 'n8n-logo',
filename: 'n8n-logo.png',
path: path.resolve(__dirname, 'templates/n8n-logo.png'),
contentDisposition: 'inline',
},
],
});
this.logger.debug(
`Email sent successfully to the following recipients: ${mailData.emailRecipients.toString()}`,

View File

@@ -0,0 +1,25 @@
<mj-head>
<mj-attributes>
<mj-all font-family="Open Sans, sans-serif"></mj-all>
<mj-body background-color="#fbfcfe"></mj-body>
<mj-text
font-weight="400"
font-size="16px"
color="#444444"
line-height="24px"
padding="10px 0 0 0"
align="center"
></mj-text>
<mj-button
background-color="#ff6f5c"
color="#ffffff"
font-size="18px"
font-weight="600"
align="center"
padding-top="20px"
line-height="24px"
border-radius="4px"
></mj-button>
<mj-section padding="20px 0px"></mj-section>
</mj-attributes>
</mj-head>

View File

@@ -0,0 +1,5 @@
<mj-section>
<mj-column>
<mj-image src="cid:n8n-logo" height="40px" width="70px" />
</mj-column>
</mj-section>

View File

@@ -0,0 +1,18 @@
<mjml>
<mj-include path="./_common.mjml" />
<mj-body>
<mj-section>
<mj-column>
<mj-text font-size="24px" color="#ff6f5c">A credential has been shared with you</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#FFFFFF" border="1px solid #ddd">
<mj-column>
<mj-text><b>"{{ credentialsName }}"</b> credential has been shared with you.</mj-text>
<mj-text>To access it, please click the button below.</mj-text>
<mj-button href="{{credentialsListUrl}}">Open credential</mj-button>
</mj-column>
</mj-section>
<mj-include path="./_logo.mjml" />
</mj-body>
</mjml>

View File

@@ -1,4 +0,0 @@
<p>Hi there,</p>
<p><b>"{{ credentialsName }}" credential</b> has been shared with you.</p>
<p>To view all the credentials you have access to within n8n, click the following link:</p>
<p><a href="{{ credentialsListUrl }}" target="_blank">{{ credentialsListUrl }}</a></p>

View File

@@ -1,5 +0,0 @@
<h1>Hi there!</h1>
<p>Welcome to n8n, {{firstName}} {{lastName}}</p>
<p>Your instance is set up!</p>
<p>Use your email to login: {{email}} and the chosen password.</p>
<p>Have fun automating!</p>

View File

@@ -1,4 +0,0 @@
<p>Hi there,</p>
<p>You have been invited to join n8n ({{ domain }}).</p>
<p>To accept, click the following link:</p>
<p><a href="{{ inviteAcceptUrl }}" target="_blank">{{ inviteAcceptUrl }}</a></p>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,31 @@
<mjml>
<mj-include path="./_common.mjml" />
<mj-body>
<mj-section>
<mj-column>
<mj-text font-size="24px" color="#ff6f5c">Reset your n8n password</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#FFFFFF" border="1px solid #ddd">
<mj-column>
<mj-text font-size="20px">Hi {{firstName}},</mj-text>
<mj-text>Somebody asked to reset your password on n8n at <b>{{domain}}</b> .</mj-text>
<mj-text> Click the following link to choose a new password. </mj-text>
<mj-button href="{{passwordResetUrl}}">Set a new password</mj-button>
<mj-text font-size="14px">
The link is only valid for 20 minutes since this email was sent.
</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text font-size="12px" color="#777">
If you did not request this email, you can safely ignore this. <br />
Your password will not be changed.
</mj-text>
</mj-column>
</mj-section>
<mj-include path="./_logo.mjml" />
</mj-body>
</mjml>

View File

@@ -1,5 +0,0 @@
<p>Hi {{firstName}},</p>
<p>Somebody asked to reset your password on n8n ({{ domain }}).</p>
<br />
<p>Click the following link to choose a new password. The link is valid for 20 minutes.</p>
<a href="{{ passwordResetUrl }}">{{ passwordResetUrl }}</a>

View File

@@ -0,0 +1,18 @@
<mjml>
<mj-include path="./_common.mjml" />
<mj-body>
<mj-section>
<mj-column>
<mj-text font-size="24px" color="#ff6f5c">Welcome to n8n! 🎉</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#FFFFFF" border="1px solid #ddd">
<mj-column>
<mj-text>You have been invited to join n8n at <b>{{domain}}</b> .</mj-text>
<mj-text>To accept, please click the button below.</mj-text>
<mj-button href="{{inviteAcceptUrl}}">Set up your n8n account</mj-button>
</mj-column>
</mj-section>
<mj-include path="./_logo.mjml" />
</mj-body>
</mjml>

View File

@@ -0,0 +1,18 @@
<mjml>
<mj-include path="./_common.mjml" />
<mj-body>
<mj-section>
<mj-column>
<mj-text font-size="24px" color="#ff6f5c">A workflow has been shared with you</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#FFFFFF" border="1px solid #ddd">
<mj-column>
<mj-text><b>"{{ workflowName }}"</b> workflow has been shared with you.</mj-text>
<mj-text>To access it, please click the button below.</mj-text>
<mj-button href="{{workflowUrl}}">Open Workflow</mj-button>
</mj-column>
</mj-section>
<mj-include path="./_logo.mjml" />
</mj-body>
</mjml>

View File

@@ -1,4 +0,0 @@
<p>Hi there,</p>
<p><b>"{{ workflowName }}" workflow</b> has been shared with you.</p>
<p>To access the workflow, click the following link:</p>
<p><a href="{{ workflowUrl }}" target="_blank">{{ workflowUrl }}</a></p>

View File

@@ -8,6 +8,7 @@ import { GlobalConfig } from '@n8n/config';
import type { User } from '@/databases/entities/User';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import { UserRepository } from '@/databases/repositories/user.repository';
import { EventService } from '@/events/event.service';
import { Logger } from '@/logger';
import { UrlService } from '@/services/url.service';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
@@ -15,10 +16,14 @@ import { toError } from '@/utils';
import type { InviteEmailData, PasswordResetData, SendEmailResult } from './Interfaces';
import { NodeMailer } from './node-mailer';
import { EventService } from '@/events/event.service';
import { inTest } from '@/constants';
type Template = HandlebarsTemplateDelegate<unknown>;
type TemplateName = 'invite' | 'passwordReset' | 'workflowShared' | 'credentialsShared';
type TemplateName =
| 'user-invited'
| 'password-reset-requested'
| 'workflow-shared'
| 'credentials-shared';
@Service()
export class UserManagementMailer {
@@ -35,6 +40,7 @@ export class UserManagementMailer {
private readonly logger: Logger,
private readonly userRepository: UserRepository,
private readonly urlService: UrlService,
private readonly eventService: EventService,
) {
const emailsConfig = globalConfig.userManagement.emails;
this.isEmailSetUp = emailsConfig.mode === 'smtp' && emailsConfig.smtp.host !== '';
@@ -49,31 +55,23 @@ export class UserManagementMailer {
async invite(inviteEmailData: InviteEmailData): Promise<SendEmailResult> {
if (!this.mailer) return { emailSent: false };
const template = await this.getTemplate('invite');
const result = await this.mailer.sendMail({
const template = await this.getTemplate('user-invited');
return await this.mailer.sendMail({
emailRecipients: inviteEmailData.email,
subject: 'You have been invited to n8n',
body: template(inviteEmailData),
body: template({ ...this.basePayload, ...inviteEmailData }),
});
// If mailer does not exist it means mail has been disabled.
// No error, just say no email was sent.
return result ?? { emailSent: false };
}
async passwordReset(passwordResetData: PasswordResetData): Promise<SendEmailResult> {
if (!this.mailer) return { emailSent: false };
const template = await this.getTemplate('passwordReset', 'passwordReset.html');
const result = await this.mailer.sendMail({
const template = await this.getTemplate('password-reset-requested');
return await this.mailer.sendMail({
emailRecipients: passwordResetData.email,
subject: 'n8n password reset',
body: template(passwordResetData),
body: template({ ...this.basePayload, ...passwordResetData }),
});
// If mailer does not exist it means mail has been disabled.
// No error, just say no email was sent.
return result ?? { emailSent: false };
}
async notifyWorkflowShared({
@@ -93,7 +91,7 @@ export class UserManagementMailer {
const emailRecipients = recipients.map(({ email }) => email);
const populateTemplate = await this.getTemplate('workflowShared', 'workflowShared.html');
const populateTemplate = await this.getTemplate('workflow-shared');
const baseUrl = this.urlService.getInstanceBaseUrl();
@@ -111,7 +109,7 @@ export class UserManagementMailer {
this.logger.info('Sent workflow shared email successfully', { sharerId: sharer.id });
Container.get(EventService).emit('user-transactional-email-sent', {
this.eventService.emit('user-transactional-email-sent', {
userId: sharer.id,
messageType: 'Workflow shared',
publicApi: false,
@@ -119,7 +117,7 @@ export class UserManagementMailer {
return result;
} catch (e) {
Container.get(EventService).emit('email-failed', {
this.eventService.emit('email-failed', {
user: sharer,
messageType: 'Workflow shared',
publicApi: false,
@@ -148,7 +146,7 @@ export class UserManagementMailer {
const emailRecipients = recipients.map(({ email }) => email);
const populateTemplate = await this.getTemplate('credentialsShared', 'credentialsShared.html');
const populateTemplate = await this.getTemplate('credentials-shared');
const baseUrl = this.urlService.getInstanceBaseUrl();
@@ -166,7 +164,7 @@ export class UserManagementMailer {
this.logger.info('Sent credentials shared email successfully', { sharerId: sharer.id });
Container.get(EventService).emit('user-transactional-email-sent', {
this.eventService.emit('user-transactional-email-sent', {
userId: sharer.id,
messageType: 'Credentials shared',
publicApi: false,
@@ -174,7 +172,7 @@ export class UserManagementMailer {
return result;
} catch (e) {
Container.get(EventService).emit('email-failed', {
this.eventService.emit('email-failed', {
user: sharer,
messageType: 'Credentials shared',
publicApi: false,
@@ -186,21 +184,25 @@ export class UserManagementMailer {
}
}
async getTemplate(
templateName: TemplateName,
defaultFilename = `${templateName}.html`,
): Promise<Template> {
async getTemplate(templateName: TemplateName): Promise<Template> {
let template = this.templatesCache[templateName];
if (!template) {
const fileExtension = inTest ? 'mjml' : 'handlebars';
const templateOverride = this.templateOverrides[templateName];
const templatePath =
templateOverride && existsSync(templateOverride)
? templateOverride
: pathJoin(__dirname, `templates/${defaultFilename}`);
: pathJoin(__dirname, `templates/${templateName}.${fileExtension}`);
const markup = await readFile(templatePath, 'utf-8');
template = Handlebars.compile(markup);
this.templatesCache[templateName] = template;
}
return template;
}
private get basePayload() {
const baseUrl = this.urlService.getInstanceBaseUrl();
const domain = new URL(baseUrl).hostname;
return { baseUrl, domain };
}
}