Properly use promises in ViewEngine.render(), use ViewEngine for mail and add NunjucksViewEngine

This commit is contained in:
Alice Gaudon 2021-04-28 14:53:46 +02:00
parent a7f421d2f8
commit 05069b15d8
25 changed files with 295 additions and 175 deletions

View File

@ -263,9 +263,21 @@ export default abstract class Application implements Extendable<ApplicationCompo
await MysqlConnectionManager.migrationCommand(mainCommandArgs);
await this.stop();
break;
case 'pre-compile-views':
await this.as(FrontendToolsComponent).preCompileViews(flags.watch);
case 'pre-compile-views': {
// Prepare migrations
for (const migration of this.getMigrations()) {
new migration().registerModels?.();
}
// Prepare routes
for (const controller of this.controllers) {
controller.setupRoutes();
}
const frontendToolsComponent = this.as(FrontendToolsComponent);
frontendToolsComponent.setupGlobals();
await frontendToolsComponent.preCompileViews(flags.watch);
break;
}
default:
logger.fatal('Unimplemented main command', mainCommand);
break;

View File

@ -1,5 +1,5 @@
import config from "config";
import {MailTemplate} from "./mail/Mail";
import MailTemplate from "./mail/MailTemplate";
export const MAGIC_LINK_MAIL = new MailTemplate(
'magic_link',

View File

@ -3,7 +3,6 @@ import Migration, {MigrationType} from "./db/Migration";
import ExpressAppComponent from "./components/ExpressAppComponent";
import RedisComponent from "./components/RedisComponent";
import MysqlComponent from "./components/MysqlComponent";
import NunjucksComponent from "./components/NunjucksComponent";
import LogRequestsComponent from "./components/LogRequestsComponent";
import MailComponent from "./components/MailComponent";
import SessionComponent from "./components/SessionComponent";
@ -34,6 +33,8 @@ import AddNameChangedAtToUsersMigration from "./auth/migrations/AddNameChangedAt
import BackendController from "./helpers/BackendController";
import FrontendToolsComponent from "./components/FrontendToolsComponent";
import SvelteViewEngine from "./frontend/SvelteViewEngine";
import MailViewEngine from "./frontend/MailViewEngine";
import NunjucksViewEngine from "./frontend/NunjucksViewEngine";
import packageJson = require('./package.json');
export const MIGRATIONS = [
@ -76,13 +77,16 @@ export default class TestApp extends Application {
this.use(new ServeStaticDirectoryComponent('public'));
// Dynamic views and routes
this.use(new NunjucksComponent(['test/views', 'views']));
this.use(new FrontendToolsComponent(new SvelteViewEngine('build/views', 'public', 'views')));
this.use(new FrontendToolsComponent(
'public',
new SvelteViewEngine('build/views', 'public', 'views', 'test/views'),
new NunjucksViewEngine('views', 'test/views'),
));
this.use(new PreviousUrlComponent());
// Services
this.use(new MysqlComponent());
this.use(new MailComponent());
this.use(new MailComponent(new MailViewEngine('views', 'test/views')));
// Session
this.use(new RedisComponent());

View File

@ -9,11 +9,11 @@ import User from "./models/User";
import ModelFactory from "../db/ModelFactory";
import UserEmail from "./models/UserEmail";
import MagicLinkController from "./magic_link/MagicLinkController";
import {MailTemplate} from "../mail/Mail";
import {ADD_EMAIL_MAIL_TEMPLATE, REMOVE_PASSWORD_MAIL_TEMPLATE} from "../Mails";
import AuthMagicLinkActionType from "./magic_link/AuthMagicLinkActionType";
import UserNameComponent from "./models/UserNameComponent";
import Time from "../Time";
import MailTemplate from "../mail/MailTemplate";
export default class AccountController extends Controller {

View File

@ -8,10 +8,10 @@ import Mail from "../mail/Mail";
import Controller from "../Controller";
import config from "config";
import Application from "../Application";
import NunjucksComponent from "../components/NunjucksComponent";
import AuthMethod from "./AuthMethod";
import {Session, SessionData} from "express-session";
import UserNameComponent from "./models/UserNameComponent";
import MailComponent from "../components/MailComponent";
export default class AuthGuard {
private readonly authMethods: AuthMethod<AuthProof<User>>[];
@ -145,12 +145,12 @@ export default class AuthGuard {
}
if (User.isApprovalMode()) {
await new Mail(this.app.as(NunjucksComponent).getEnvironment(), PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE, {
await this.app.as(MailComponent).sendMail(new Mail(PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE, {
username: user.asOptional(UserNameComponent)?.getName() ||
(await user.mainEmail.get())?.getOrFail('email') ||
'Could not find an identifier',
link: config.get<string>('public_url') + Controller.route('accounts-approval'),
}).send(config.get<string>('app.contact_email'));
}), config.get<string>('app.contact_email'));
}
}

View File

@ -8,12 +8,12 @@ import Controller from "../../Controller";
import geoip from "geoip-lite";
import MagicLinkController from "./MagicLinkController";
import Application from "../../Application";
import {MailTemplate} from "../../mail/Mail";
import AuthMagicLinkActionType from "./AuthMagicLinkActionType";
import Validator, {EMAIL_REGEX} from "../../db/Validator";
import ModelFactory from "../../db/ModelFactory";
import UserNameComponent from "../models/UserNameComponent";
import {Session} from "express-session";
import MailTemplate from "../../mail/MailTemplate";
export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
public constructor(

View File

@ -3,12 +3,11 @@ import {Request, Response} from "express";
import MagicLinkWebSocketListener from "./MagicLinkWebSocketListener";
import {BadRequestError, NotFoundHttpError} from "../../HttpError";
import Throttler from "../../Throttler";
import Mail, {MailTemplate} from "../../mail/Mail";
import Mail from "../../mail/Mail";
import MagicLink from "../models/MagicLink";
import config from "config";
import Application from "../../Application";
import {ParsedUrlQueryInput} from "querystring";
import NunjucksComponent from "../../components/NunjucksComponent";
import User from "../models/User";
import AuthComponent, {AuthMiddleware} from "../AuthComponent";
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard";
@ -19,6 +18,8 @@ import UserNameComponent from "../models/UserNameComponent";
import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent";
import {logger} from "../../Logger";
import UserPasswordComponent from "../password/UserPasswordComponent";
import MailTemplate from "../../mail/MailTemplate";
import MailComponent from "../../components/MailComponent";
export default class MagicLinkController<A extends Application> extends Controller {
public static async sendMagicLink(
@ -46,12 +47,12 @@ export default class MagicLinkController<A extends Application> extends Controll
await link.save();
// Send email
await new Mail(app.as(NunjucksComponent).getEnvironment(), mailTemplate, Object.assign(data, {
await app.as(MailComponent).sendMail(new Mail(mailTemplate, Object.assign(data, {
link: `${config.get<string>('public_url')}${Controller.route('magic_link', undefined, {
id: link.id,
token: token,
})}`,
})).send(email);
})), email);
}
public static async checkAndAuth(req: Request, res: Response, magicLink: MagicLink): Promise<User | null> {

View File

@ -7,15 +7,21 @@ import "svelte/register";
import ViewEngine from "../frontend/ViewEngine";
import {readdirRecursively} from "../Utils";
import FileCache from "../utils/FileCache";
import Controller, {RouteParams} from "../Controller";
import * as querystring from "querystring";
import {ParsedUrlQueryInput} from "querystring";
import util from "util";
export default class FrontendToolsComponent extends ApplicationComponent {
private readonly publicAssetsCache: FileCache = new FileCache();
private readonly viewEngines: ViewEngine[];
public constructor(
private readonly publicAssetsDir: string,
private readonly viewEngine: ViewEngine,
...viewEngines: ViewEngine[]
) {
super();
this.viewEngines = viewEngines;
}
public async start(app: Express): Promise<void> {
@ -31,11 +37,20 @@ export default class FrontendToolsComponent extends ApplicationComponent {
}
// Setup express view engine
this.viewEngine.setup(app, true);
let main = true;
for (const viewEngine of this.viewEngines) {
viewEngine.setup(app, main);
main = false;
}
// Add util globals
this.setupGlobals();
}
public async stop(): Promise<void> {
await this.viewEngine.stop();
for (const viewEngine of this.viewEngines) {
await viewEngine.stop();
}
}
public async handle(router: Router): Promise<void> {
@ -59,6 +74,27 @@ export default class FrontendToolsComponent extends ApplicationComponent {
}
public async preCompileViews(watch: boolean): Promise<void> {
await this.viewEngine.preCompileAll(watch);
for (const viewEngine of this.viewEngines) {
await viewEngine.preCompileAll(watch);
}
}
public setupGlobals(): void {
ViewEngine.setGlobal('route', (
route: string,
params: RouteParams = [],
query: ParsedUrlQueryInput = {},
absolute: boolean = false,
) => Controller.route(route, params, query, absolute));
ViewEngine.setGlobal('app_version', this.getApp().getVersion());
ViewEngine.setGlobal('core_version', this.getApp().getCoreVersion());
ViewEngine.setGlobal('querystring', querystring);
ViewEngine.setGlobal('app', config.get('app'));
ViewEngine.setGlobal('dump', (val: unknown) => {
return util.inspect(val);
});
ViewEngine.setGlobal('hex', (v: number) => {
return v.toString(16);
});
}
}

View File

@ -1,10 +1,24 @@
import ApplicationComponent from "../ApplicationComponent";
import {Express} from "express";
import Mail from "../mail/Mail";
import config from "config";
import SecurityError from "../SecurityError";
import nodemailer, {SentMessageInfo, Transporter} from "nodemailer";
import MailError from "../mail/MailError";
import util from "util";
import {logger} from "../Logger";
import Controller from "../Controller";
import Mail from "../mail/Mail";
import MailViewEngine from "../frontend/MailViewEngine";
import ViewEngine from "../frontend/ViewEngine";
export default class MailComponent extends ApplicationComponent {
private transporter?: Transporter;
public constructor(
private readonly viewEngine: MailViewEngine,
) {
super();
}
public async checkSecuritySettings(): Promise<void> {
if (!config.get<boolean>('mail.secure')) {
@ -15,12 +29,86 @@ export default class MailComponent extends ApplicationComponent {
}
}
public async start(_app: Express): Promise<void> {
await this.prepare('Mail connection', () => Mail.prepare());
public async start(app: Express): Promise<void> {
await this.prepare('Mail connection', async () => {
const transporter = nodemailer.createTransport({
host: config.get('mail.host'),
port: config.get('mail.port'),
requireTLS: config.get('mail.secure'), // STARTTLS
auth: {
user: config.get('mail.username'),
pass: config.get('mail.password'),
},
tls: {
rejectUnauthorized: !config.get('mail.allow_invalid_tls'),
},
});
try {
await util.promisify(transporter.verify)();
this.transporter = transporter;
} catch (e) {
throw new MailError('Connection to mail service unsuccessful.', e);
}
logger.info(`Mail ready to be distributed via ${config.get('mail.host')}:${config.get('mail.port')}`);
});
await this.viewEngine.setup(app, false);
}
public async stop(): Promise<void> {
Mail.end();
if (this.transporter) {
this.transporter.close();
this.transporter = undefined;
}
await this.viewEngine.stop();
}
public async sendMail(mail: Mail, ...to: string[]): Promise<SentMessageInfo[]> {
if (to.length === 0) throw new Error('Cannot send an email to recipient. (to is empty)');
const results = [];
for (const destEmail of to) {
const template = mail.getTemplate();
const locals = mail.getData();
const options = mail.getOptions();
// Reset options
options.html = options.text = undefined;
// Set options
options.to = destEmail;
options.from = {
name: config.get('mail.from_name'),
address: config.get('mail.from'),
};
// Set locals
locals.mail_subject = options.subject;
locals.mail_to = options.to;
locals.mail_link = config.get<string>('public_url') +
Controller.route('mail', [template.template], locals);
Object.assign(locals, ViewEngine.getGlobals());
// Log
logger.debug(`Send mail from ${options.from.address} to ${options.to}`);
// Render email
options.html = await this.viewEngine.render('mails/' + template.template + '.mnjk', locals, false);
options.text = await this.viewEngine.render('mails/' + template.template + '.mnjk', locals, true);
// Send email
results.push(await this.getTransporter().sendMail(options));
}
return results;
}
private getTransporter(): Transporter {
if (!this.transporter) throw new MailError('Mail system was not prepared.');
return this.transporter;
}
}

View File

@ -1,14 +1,17 @@
import nunjucks, {Environment} from "nunjucks";
import config from "config";
import {Express, NextFunction, Request, Response, Router} from "express";
import {Express} from "express";
import ApplicationComponent from "../ApplicationComponent";
import Controller, {RouteParams} from "../Controller";
import * as querystring from "querystring";
import {ParsedUrlQueryInput} from "querystring";
import * as util from "util";
import * as path from "path";
import Middleware from "../Middleware";
/**
* @deprecated
* TODO: turn into a proper ViewEngine
*/
export default class NunjucksComponent extends ApplicationComponent {
private readonly viewsPath: string[];
private environment?: Environment;

View File

@ -0,0 +1,27 @@
import mjml2html from "mjml";
import MailError from "../mail/MailError";
import NunjucksViewEngine from "./NunjucksViewEngine";
export default class MailViewEngine extends NunjucksViewEngine {
public getExtension(): string {
return 'mnjk';
}
public async render(
file: string,
locals: Record<string, unknown>,
textOnly: boolean = false,
): Promise<string> {
locals.text = textOnly;
const nunjucksResult = await super.render(file, locals);
if (textOnly) return nunjucksResult;
const mjmlResult = mjml2html(nunjucksResult, {});
if (mjmlResult.errors.length > 0) {
throw new MailError(`Error while parsing mail template ${file}: ${JSON.stringify(mjmlResult.errors, null, 4)}`);
}
return mjmlResult.html;
}
}

View File

@ -0,0 +1,34 @@
import ViewEngine from "./ViewEngine";
import config from "config";
import nunjucks, {Environment} from "nunjucks";
export default class NunjucksViewEngine extends ViewEngine {
private readonly environment: Environment;
public constructor(devWatchedViewDir: string, ...additionalViewPaths: string[]) {
super(devWatchedViewDir, ...additionalViewPaths);
const opts = {
autoescape: true,
noCache: !config.get('view.cache'),
throwOnUndefined: true,
};
this.environment = new nunjucks.Environment([
...this.viewPaths.map(path => new nunjucks.FileSystemLoader(path, opts)),
], opts);
}
public getExtension(): string {
return 'njk';
}
public async render(file: string, locals: Record<string, unknown>): Promise<string> {
return await new Promise<string>((resolve, reject) => {
this.environment.render(file, locals, (err, res) => {
if (err) return reject(err);
else if (res === null) reject('Null response from nunjucks environment.render()');
else return resolve(res);
});
});
}
}

View File

@ -63,8 +63,7 @@ export default class SvelteViewEngine extends ViewEngine {
public async render(
file: string,
locals: Record<string, unknown>,
callback: (err: Error | null, output?: string) => void,
): Promise<void> {
): Promise<string> {
const canonicalViewName = this.toCanonicalName(file);
// View
@ -140,7 +139,7 @@ export default class SvelteViewEngine extends ViewEngine {
}
}
callback(null, output);
return output;
}
public async stop(): Promise<void> {

View File

@ -9,7 +9,7 @@ export default abstract class ViewEngine {
private static readonly globals: Record<string, unknown> = {};
public static getGlobals(): Record<string, unknown> {
return this.globals;
return {...this.globals};
}
public static setGlobal(key: string, value: unknown): void {
@ -44,21 +44,26 @@ export default abstract class ViewEngine {
public abstract render(
file: string,
locals: Record<string, unknown>,
callback: (err: Error | null, output?: string) => void,
): Promise<void>;
): Promise<string>;
public setup(app: Express, main: boolean): void {
app.engine(this.getExtension(), (path, options, callback) => {
// Props (locals)
const locals = Object.assign(ViewEngine.getGlobals(), options);
const locals = Object.assign(options, ViewEngine.getGlobals());
this.render(path, locals, callback)
this.render(path, locals)
.then(value => callback(null, value))
.catch(err => callback(err));
});
const existingViewPaths = app.get('views');
app.set('views', existingViewPaths ?
[...existingViewPaths, ...this.getViewPaths()] :
[...new Set([
...typeof existingViewPaths === 'string' ?
[existingViewPaths] :
existingViewPaths,
...this.getViewPaths(),
])] :
this.getViewPaths());
if (main) {

View File

@ -8,9 +8,9 @@ import {ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE} from "../Mails";
import UserEmail from "../auth/models/UserEmail";
import UserApprovedComponent from "../auth/models/UserApprovedComponent";
import {RequireAdminMiddleware, RequireAuthMiddleware} from "../auth/AuthComponent";
import NunjucksComponent from "../components/NunjucksComponent";
import ModelFactory from "../db/ModelFactory";
import UserNameComponent from "../auth/models/UserNameComponent";
import MailComponent from "../components/MailComponent";
export default class BackendController extends Controller {
private static readonly menu: BackendMenuElement[] = [];
@ -78,10 +78,10 @@ export default class BackendController extends Controller {
await account.save();
if (email && email.email) {
await new Mail(this.getApp().as(NunjucksComponent).getEnvironment(), ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE, {
await this.getApp().as(MailComponent).sendMail(new Mail(ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE, {
approved: true,
link: config.get<string>('public_url') + Controller.route('auth'),
}).send(email.email);
}), email.email);
}
req.flash('success', `Account successfully approved.`);
@ -94,9 +94,9 @@ export default class BackendController extends Controller {
await account.delete();
if (email && email.email) {
await new Mail(this.getApp().as(NunjucksComponent).getEnvironment(), ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE, {
await this.getApp().as(MailComponent).sendMail(new Mail(ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE, {
approved: false,
}).send(email.email);
}), email.email);
}
req.flash('success', `Account successfully deleted.`);

View File

@ -1,78 +1,15 @@
import nodemailer, {SentMessageInfo, Transporter} from "nodemailer";
import config from "config";
import {Options} from "nodemailer/lib/mailer";
import {Environment} from 'nunjucks';
import * as util from "util";
import {WrappingError} from "../Utils";
import mjml2html from "mjml";
import {logger} from "../Logger";
import Controller from "../Controller";
import {ParsedUrlQueryInput} from "querystring";
import MailError from "./MailError";
import MailTemplate from "./MailTemplate";
export default class Mail {
private static transporter?: Transporter;
private static getTransporter(): Transporter {
if (!this.transporter) throw new MailError('Mail system was not prepared.');
return this.transporter;
}
public static async prepare(): Promise<void> {
const transporter = nodemailer.createTransport({
host: config.get('mail.host'),
port: config.get('mail.port'),
requireTLS: config.get('mail.secure'), // STARTTLS
auth: {
user: config.get('mail.username'),
pass: config.get('mail.password'),
},
tls: {
rejectUnauthorized: !config.get('mail.allow_invalid_tls'),
},
});
try {
await util.promisify(transporter.verify)();
this.transporter = transporter;
} catch (e) {
throw new MailError('Connection to mail service unsuccessful.', e);
}
logger.info(`Mail ready to be distributed via ${config.get('mail.host')}:${config.get('mail.port')}`);
}
public static end(): void {
if (this.transporter) this.transporter.close();
}
public static parse(
environment: Environment,
template: string,
data: { [p: string]: unknown },
textOnly: boolean,
): string {
data.text = textOnly;
const nunjucksResult = environment.render(template, data);
if (textOnly) return nunjucksResult;
const mjmlResult = mjml2html(nunjucksResult, {});
if (mjmlResult.errors.length > 0) {
throw new MailError(`Error while parsing mail template ${template}: ${JSON.stringify(mjmlResult.errors, null, 4)}`);
}
return mjmlResult.html;
}
private readonly options: Options = {};
public constructor(
private readonly environment: Environment,
private readonly template: MailTemplate,
private readonly data: ParsedUrlQueryInput = {},
) {
this.template = template;
this.data = data;
this.options.subject = this.template.getSubject(data);
this.verifyData();
@ -88,64 +25,15 @@ export default class Mail {
}
}
public async send(...to: string[]): Promise<SentMessageInfo[]> {
const results = [];
public getTemplate(): MailTemplate {
return this.template;
}
for (const destEmail of to) {
// Reset options
this.options.html = this.options.text = undefined;
public getData(): ParsedUrlQueryInput {
return {...this.data};
}
// Set options
this.options.to = destEmail;
this.options.from = {
name: config.get('mail.from_name'),
address: config.get('mail.from'),
};
// Set data
this.data.mail_subject = this.options.subject;
this.data.mail_to = this.options.to;
this.data.mail_link = config.get<string>('public_url') +
Controller.route('mail', [this.template.template], this.data);
this.data.app = config.get('app');
// Log
logger.debug(`Send mail from ${this.options.from.address} to ${this.options.to}`);
// Render email
this.options.html = Mail.parse(this.environment, 'mails/' + this.template.template + '.mjml.njk',
this.data, false);
this.options.text = Mail.parse(this.environment, 'mails/' + this.template.template + '.mjml.njk',
this.data, true);
// Send email
results.push(await Mail.getTransporter().sendMail(this.options));
}
return results;
}
}
export class MailTemplate {
private readonly _template: string;
private readonly subject: (data: { [p: string]: unknown }) => string;
public constructor(template: string, subject: (data: { [p: string]: unknown }) => string) {
this._template = template;
this.subject = subject;
}
public get template(): string {
return this._template;
}
public getSubject(data: { [p: string]: unknown }): string {
return `${config.get('app.name')} - ${this.subject(data)}`;
}
}
class MailError extends WrappingError {
public constructor(message: string = 'An error occurred while sending mail.', cause?: Error) {
super(message, cause);
public getOptions(): Options {
return {...this.options};
}
}

View File

@ -1,7 +1,5 @@
import {Request, Response} from "express";
import Controller from "../Controller";
import Mail from "./Mail";
import NunjucksComponent from "../components/NunjucksComponent";
export default class MailController extends Controller {
public routes(): void {
@ -10,7 +8,6 @@ export default class MailController extends Controller {
protected async getMail(request: Request, response: Response): Promise<void> {
const template = request.params['template'];
response.send(Mail.parse(this.getApp().as(NunjucksComponent).getEnvironment(),
`mails/${template}.mjml.njk`, request.query, false));
response.render(`mails/${template}.mnjk`, request.query);
}
}

7
src/mail/MailError.ts Normal file
View File

@ -0,0 +1,7 @@
import {WrappingError} from "../Utils";
export default class MailError extends WrappingError {
public constructor(message: string = 'An error occurred while sending mail.', cause?: Error) {
super(message, cause);
}
}

19
src/mail/MailTemplate.ts Normal file
View File

@ -0,0 +1,19 @@
import config from "config";
export default class MailTemplate {
private readonly _template: string;
private readonly subject: (data: { [p: string]: unknown }) => string;
public constructor(template: string, subject: (data: { [p: string]: unknown }) => string) {
this._template = template;
this.subject = subject;
}
public get template(): string {
return this._template;
}
public getSubject(data: { [p: string]: unknown }): string {
return `${config.get('app.name')} - ${this.subject(data)}`;
}
}

View File

@ -1,4 +1,4 @@
{% extends 'mails/base_layout.mjml.njk' %}
{% extends 'mails/base_layout.mnjk' %}
{% block body %}
<mj-section>
@ -38,4 +38,4 @@
If you believe that this is an error, please contact us via email.
{% endif %}
{% endblock %}
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends 'mails/base_layout.mjml.njk' %}
{% extends 'mails/base_layout.mnjk' %}
{% block body %}
<mj-section>
@ -24,4 +24,4 @@
Someone wants to add {{ mail_to }} to their account.
To add this email address, please follow this link: {{ link|safe }}
{% endblock %}
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends 'mails/base_layout.mjml.njk' %}
{% extends 'mails/base_layout.mnjk' %}
{% block body %}
<mj-section>
@ -63,4 +63,4 @@
Location: {{ geo }}
To authorize this log in, please follow this link: {{ link|safe }}
{% endif %}
{% endblock %}
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends 'mails/base_layout.mjml.njk' %}
{% extends 'mails/base_layout.mnjk' %}
{% block body %}
<mj-section>
@ -23,4 +23,4 @@
Username: {{ username }}
To review this account, please follow this link: {{ link|safe }}
{% endblock %}
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends 'mails/base_layout.mjml.njk' %}
{% extends 'mails/base_layout.mnjk' %}
{% block body %}
<mj-section>