Skip to content

Commit

Permalink
feat(core): Switch to MJML for email templates (#10518)
Browse files Browse the repository at this point in the history
  • Loading branch information
netroy authored Aug 28, 2024
1 parent 9e1dac0 commit dbc10fe
Show file tree
Hide file tree
Showing 24 changed files with 754 additions and 86 deletions.
1 change: 1 addition & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode",
"mjmlio.vscode-mjml",
"Vue.volar"
]
}
8 changes: 4 additions & 4 deletions packages/@n8n/config/src/configs/user-management.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,19 +49,19 @@ class SmtpConfig {
export class TemplateConfig {
/** Overrides default HTML template for inviting new people (use full path) */
@Env('N8N_UM_EMAIL_TEMPLATES_INVITE')
invite: string = '';
'user-invited': string = '';

/** Overrides default HTML template for resetting password (use full path) */
@Env('N8N_UM_EMAIL_TEMPLATES_PWRESET')
passwordReset: string = '';
'password-reset-requested': string = '';

/** Overrides default HTML template for notifying that a workflow was shared (use full path) */
@Env('N8N_UM_EMAIL_TEMPLATES_WORKFLOW_SHARED')
workflowShared: string = '';
'workflow-shared': string = '';

/** Overrides default HTML template for notifying that credentials were shared (use full path) */
@Env('N8N_UM_EMAIL_TEMPLATES_CREDENTIALS_SHARED')
credentialsShared: string = '';
'credentials-shared': string = '';
}

@Config
Expand Down
8 changes: 4 additions & 4 deletions packages/@n8n/config/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,10 @@ describe('GlobalConfig', () => {
},
},
template: {
credentialsShared: '',
invite: '',
passwordReset: '',
workflowShared: '',
'credentials-shared': '',
'user-invited': '',
'password-reset-requested': '',
'workflow-shared': '',
},
},
},
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
24 changes: 17 additions & 7 deletions packages/cli/scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -13,21 +14,30 @@ const SPEC_THEME_FILENAME = 'swagger-theme.css';

const publicApiEnabled = process.env.N8N_PUBLIC_API_DISABLED !== 'true';

copyUserManagementEmailTemplates();
generateUserManagementEmailTemplates();
generateTimezoneData();

if (publicApiEnabled) {
copySwaggerTheme();
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.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('-r', templates.source, templates.destination);
shell.cp(path.resolve(sourceDir, 'n8n-logo.png'), destinationDir);
}

function copySwaggerTheme() {
Expand Down
6 changes: 1 addition & 5 deletions packages/cli/src/controllers/password-reset.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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', {
Expand Down
2 changes: 0 additions & 2 deletions packages/cli/src/services/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ export class UserService {
const result = await this.mailer.invite({
email,
inviteAcceptUrl,
domain,
});
if (result.emailSent) {
invitedUser.user.emailSent = true;
Expand Down Expand Up @@ -168,7 +167,6 @@ export class UserService {
this.logger.error('Failed to send email', {
userId: owner.id,
inviteAcceptUrl,
domain,
email,
});
invitedUser.error = e.message;
Expand Down
7 changes: 1 addition & 6 deletions packages/cli/src/user-management/email/Interfaces.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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',
});
Expand All @@ -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',
});
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/user-management/email/node-mailer.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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()}`,
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/user-management/email/templates/_common.mjml
Original file line number Diff line number Diff line change
@@ -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>
5 changes: 5 additions & 0 deletions packages/cli/src/user-management/email/templates/_logo.mjml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<mj-section>
<mj-column>
<mj-image src="cid:n8n-logo" height="40px" width="70px" />
</mj-column>
</mj-section>
Original file line number Diff line number Diff line change
@@ -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>

This file was deleted.

This file was deleted.

4 changes: 0 additions & 4 deletions packages/cli/src/user-management/email/templates/invite.html

This file was deleted.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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>

This file was deleted.

18 changes: 18 additions & 0 deletions packages/cli/src/user-management/email/templates/user-invited.mjml
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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>
Loading

0 comments on commit dbc10fe

Please sign in to comment.